From 59d6e97787ce0b1a9834a418b30769a9a2254f03 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 09:25:09 +0100 Subject: [PATCH] Member/Ecology revamp. --- .gitignore | 1 + app/components/CirclePicker.vue | 2 +- app/components/ColumnsLayout.vue | 6 +- app/components/TopStrip.vue | 4 +- app/composables/useMemberStatus.js | 158 ++-- app/config/circles.js | 4 +- app/middleware/members-auth.js | 12 + app/pages/about.vue | 49 +- app/pages/accept-invite.vue | 12 +- app/pages/admin/wiki.vue | 800 +++++++++++++++--- app/pages/auth/logout-success.vue | 2 +- app/pages/coming-soon.vue | 2 +- app/pages/events/[slug].vue | 6 + app/pages/events/index.vue | 57 +- app/pages/index.vue | 90 +- app/pages/join.vue | 546 ++++++------ app/pages/member/account.vue | 19 +- app/pages/member/activity.vue | 2 +- app/pages/member/dashboard.vue | 324 +++---- app/pages/members/[id].vue | 2 + app/pages/series/index.vue | 10 +- server/api/admin/wiki/[id].patch.js | 22 +- .../api/admin/wiki/batch-visibility.post.js | 34 + server/api/admin/wiki/index.get.js | 2 +- server/api/admin/wiki/sync.post.js | 72 +- server/api/wiki/recent.get.js | 20 + server/models/wikiArticle.js | 1 + server/plugins/wiki-sync.js | 26 + server/utils/outline.js | 51 +- server/utils/syncWikiArticles.js | 72 ++ .../composables/useMemberStatus.test.js | 365 ++++---- 31 files changed, 1763 insertions(+), 1010 deletions(-) create mode 100644 app/middleware/members-auth.js create mode 100644 server/api/admin/wiki/batch-visibility.post.js create mode 100644 server/api/wiki/recent.get.js create mode 100644 server/plugins/wiki-sync.js create mode 100644 server/utils/syncWikiArticles.js diff --git a/.gitignore b/.gitignore index 8916e2a..0944c34 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ e2e/.auth/ # Worktrees .worktrees/ +.claude/worktrees/ diff --git a/app/components/CirclePicker.vue b/app/components/CirclePicker.vue index 19f4900..227bb3c 100644 --- a/app/components/CirclePicker.vue +++ b/app/components/CirclePicker.vue @@ -26,7 +26,7 @@ defineProps({ default: () => [ { value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' }, { value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' }, - { value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative business' }, + { value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative practice' }, ], }, }) diff --git a/app/components/ColumnsLayout.vue b/app/components/ColumnsLayout.vue index b0fa072..3ea07c4 100644 --- a/app/components/ColumnsLayout.vue +++ b/app/components/ColumnsLayout.vue @@ -53,6 +53,7 @@ if (props.cols === 'events-sidebar') { /* cols="events-sidebar" */ .columns-events-sidebar { grid-template-columns: 1fr 200px; + flex: 1; } /* Ensure grid children don't overflow */ @@ -60,11 +61,14 @@ if (props.cols === 'events-sidebar') { min-width: 0; } -/* Dashed divider: right border on the first column child */ +/* Dashed divider: right border on the first column child (except events-sidebar, which owns its own border-left) */ .divider-dashed .col:first-child, .divider-dashed .col-main { border-right: 1px dashed var(--border); } +.divider-dashed.columns-events-sidebar .col-main { + border-right: none; +} /* Responsive collapse at 1024px (default) */ .collapse-1024 { diff --git a/app/components/TopStrip.vue b/app/components/TopStrip.vue index decb4cb..e34682f 100644 --- a/app/components/TopStrip.vue +++ b/app/components/TopStrip.vue @@ -57,8 +57,8 @@ {{ memberData.name }} - - + + diff --git a/app/composables/useMemberStatus.js b/app/composables/useMemberStatus.js index 44f37e9..6c4acb9 100644 --- a/app/composables/useMemberStatus.js +++ b/app/composables/useMemberStatus.js @@ -4,137 +4,149 @@ */ export const MEMBER_STATUSES = { - PENDING_PAYMENT: 'pending_payment', - ACTIVE: 'active', - SUSPENDED: 'suspended', - CANCELLED: 'cancelled', -} + PENDING_PAYMENT: "pending_payment", + ACTIVE: "active", + SUSPENDED: "suspended", + CANCELLED: "cancelled", +}; export const MEMBER_STATUS_CONFIG = { pending_payment: { - label: 'Payment Pending', - color: 'orange', - bgColor: 'bg-orange-500/10', - borderColor: 'border-orange-500/30', - textColor: 'text-orange-300', - icon: 'heroicons:exclamation-triangle', - severity: 'warning', + label: "Payment Pending", + color: "orange", + bgColor: "bg-orange-500/10", + borderColor: "border-orange-500/30", + textColor: "text-orange-300", + icon: "heroicons:exclamation-triangle", + severity: "warning", canRSVP: false, canAccessMembers: true, canPeerSupport: false, }, active: { - label: 'Active Member', - color: 'green', - bgColor: 'bg-green-500/10', - borderColor: 'border-green-500/30', - textColor: 'text-green-300', - icon: 'heroicons:check-circle', - severity: 'success', + label: "Active Member", + color: "green", + bgColor: "bg-green-500/10", + borderColor: "border-green-500/30", + textColor: "text-green-300", + icon: "heroicons:check-circle", + severity: "success", canRSVP: true, canAccessMembers: true, canPeerSupport: true, }, suspended: { - label: 'Membership Suspended', - color: 'red', - bgColor: 'bg-red-500/10', - borderColor: 'border-red-500/30', - textColor: 'text-red-300', - icon: 'heroicons:no-symbol', - severity: 'error', + label: "Membership Suspended", + color: "red", + bgColor: "bg-red-500/10", + borderColor: "border-red-500/30", + textColor: "text-red-300", + icon: "heroicons:no-symbol", + severity: "error", canRSVP: false, canAccessMembers: false, canPeerSupport: false, }, cancelled: { - label: 'Membership Cancelled', - color: 'gray', - bgColor: 'bg-gray-500/10', - borderColor: 'border-gray-500/30', - textColor: 'text-gray-300', - icon: 'heroicons:x-circle', - severity: 'error', + label: "Membership Cancelled", + color: "gray", + bgColor: "bg-gray-500/10", + borderColor: "border-gray-500/30", + textColor: "text-gray-300", + icon: "heroicons:x-circle", + severity: "error", canRSVP: false, canAccessMembers: false, canPeerSupport: false, }, -} +}; export const useMemberStatus = () => { - const { memberData } = useAuth() + const { memberData } = useAuth(); // Get current member status - const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT) + const status = computed( + () => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT, + ); // Get status configuration - const statusConfig = computed(() => MEMBER_STATUS_CONFIG[status.value] || MEMBER_STATUS_CONFIG.pending_payment) + const statusConfig = computed( + () => + MEMBER_STATUS_CONFIG[status.value] || + MEMBER_STATUS_CONFIG.pending_payment, + ); // Helper methods - const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE) - const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT) - const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED) - const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED) - const isInactive = computed(() => !isActive.value) + const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE); + const isPendingPayment = computed( + () => status.value === MEMBER_STATUSES.PENDING_PAYMENT, + ); + const isSuspended = computed( + () => status.value === MEMBER_STATUSES.SUSPENDED, + ); + const isCancelled = computed( + () => status.value === MEMBER_STATUSES.CANCELLED, + ); + const isInactive = computed(() => !isActive.value); // Check if member can perform action - const canRSVP = computed(() => statusConfig.value.canRSVP) - const canAccessMembers = computed(() => statusConfig.value.canAccessMembers) - const canPeerSupport = computed(() => statusConfig.value.canPeerSupport) + const canRSVP = computed(() => statusConfig.value.canRSVP); + const canAccessMembers = computed(() => statusConfig.value.canAccessMembers); + const canPeerSupport = computed(() => statusConfig.value.canPeerSupport); // Get action button text and link based on status const getNextAction = () => { if (isPendingPayment.value) { return { - label: 'Complete Payment', - link: '/member/profile#account', - icon: 'heroicons:credit-card', - color: 'orange', - } + label: "Complete Payment", + link: "/member/account", + icon: "heroicons:credit-card", + color: "orange", + }; } if (isCancelled.value) { return { - label: 'Reactivate Membership', - link: '/member/profile#account', - icon: 'heroicons:arrow-path', - color: 'blue', - } + label: "Reactivate Membership", + link: "/member/account", + icon: "heroicons:arrow-path", + color: "blue", + }; } if (isSuspended.value) { return { - label: 'Contact Support', - link: 'mailto:support@ghostguild.org', - icon: 'heroicons:envelope', - color: 'gray', - } + label: "Contact Support", + link: "mailto:support@ghostguild.org", + icon: "heroicons:envelope", + color: "gray", + }; } - return null - } + return null; + }; // Get banner message based on status const getBannerMessage = () => { if (isPendingPayment.value) { - return 'Your membership is pending payment. Please complete your payment to unlock full features.' + return "Your membership is pending payment. Please complete your payment to unlock full features."; } if (isSuspended.value) { - return 'Your membership has been suspended. Please contact support to reactivate your account.' + return "Your membership has been suspended. Please contact support to reactivate your account."; } if (isCancelled.value) { - return 'Your membership has been cancelled. Would you like to reactivate?' + return "Your membership has been cancelled. Would you like to reactivate?"; } - return null - } + return null; + }; // Get RSVP restriction message const getRSVPMessage = () => { if (isPendingPayment.value) { - return 'Complete your payment to register for events' + return "Complete your payment to register for events"; } if (isSuspended.value || isCancelled.value) { - return 'Your membership status prevents RSVP. Please reactivate your account.' + return "Your membership status prevents RSVP. Please reactivate your account."; } - return null - } + return null; + }; return { status, @@ -151,5 +163,5 @@ export const useMemberStatus = () => { getBannerMessage, getRSVPMessage, MEMBER_STATUSES, - } -} + }; +}; diff --git a/app/config/circles.js b/app/config/circles.js index 0e2faa2..ccd5875 100644 --- a/app/config/circles.js +++ b/app/config/circles.js @@ -21,7 +21,7 @@ export const CIRCLES = { shortDescription: "Building your studio", description: "For those actively establishing or growing their coop", features: [ - "Teams working toward applying for the Peer Accelerator", + "Teams working toward applying for Cooperative Foundations", "Early-stage coop studios", "Studios transitioning to coop model", ], @@ -33,7 +33,7 @@ export const CIRCLES = { value: "practitioner", label: "Practitioners", shortDescription: "Leading and mentoring", - description: "For Peer Accelerator alumni and experienced studio founders", + description: "For alumni and experienced studio founders", features: [ "Those implementing cooperative models", "Industry mentors and advisors", diff --git a/app/middleware/members-auth.js b/app/middleware/members-auth.js new file mode 100644 index 0000000..bba27db --- /dev/null +++ b/app/middleware/members-auth.js @@ -0,0 +1,12 @@ +export default defineNuxtRouteMiddleware(async (to, from) => { + if (process.server) return; + + const { memberData, checkMemberStatus } = useAuth(); + + if (!memberData.value) { + const isAuthenticated = await checkMemberStatus(); + if (!isAuthenticated) { + return navigateTo("/join"); + } + } +}); diff --git a/app/pages/about.vue b/app/pages/about.vue index e3a8b6f..a423811 100644 --- a/app/pages/about.vue +++ b/app/pages/about.vue @@ -6,26 +6,27 @@

About Ghost Guild

A membership community for game developers exploring cooperative - business models. + models.

Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been - supporting indie game developers since 2018. We noticed a gap: game - developers interested in cooperative models had nowhere to learn, - practice, and connect with others doing the same work. + advancing cooperative and worker-centric models in the game industry + since 2023.

- Ghost Guild is the response — a membership program where - developers at every stage of cooperative practice can find resources, - events, mentorship, and community. + Developers interested in co-op practice had few places to learn, + connect, and figure things out alongside others doing the same work. + Ghost Guild is that place: a membership community for developers at + every stage of cooperative practice, with resources, events, and peers + to learn from.

- We don't prescribe a single model. We're a place to explore the - options, learn from people who've tried them, and build something that - works for your team. + We don't prescribe a single model. We're here to explore the options, + learn from people who've tried them, and build something that works + for your team.

@@ -38,27 +39,16 @@

Community

-
"The open hall"
-

- For anyone exploring cooperative models. Wiki access, public - events, Slack community, monthly meetings. -

+ +

For anyone exploring cooperative models.

Founder

-
"The workshop"
-

- For people actively building cooperatives. Peer accelerator, - mentorship, governance templates. -

+

For people actively building cooperatives.

Practitioner

-
"The alcove"
-

- For experienced practitioners. Mentoring, teaching, shaping the - program direction. -

+

For experienced practitioners sharing what they know.

@@ -101,13 +91,12 @@

- Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit - advancing cooperative models in game development. No tracking. No ads. - No venture capital. + Ghost Guild is part of Baby Ghosts, a Canadian nonprofit advancing + cooperative models in game development.

- babyghosts.fund →babyghosts.org →

diff --git a/app/pages/accept-invite.vue b/app/pages/accept-invite.vue index 1fea8a8..260d7dc 100644 --- a/app/pages/accept-invite.vue +++ b/app/pages/accept-invite.vue @@ -130,13 +130,13 @@ v-model="form.contributionTier" class="form-select" > - - - - - + + + + + -

Every dollar above $0 goes to the Solidarity Fund. Your contribution is never a gate -- it is a gift.

+

Pay what you can. If you can pay more, you're making room for someone who can't.

diff --git a/app/pages/admin/wiki.vue b/app/pages/admin/wiki.vue index a8c3358..9650bb3 100644 --- a/app/pages/admin/wiki.vue +++ b/app/pages/admin/wiki.vue @@ -45,6 +45,20 @@
+
+ +
+
+ +
@@ -74,6 +88,22 @@ {{ batchApplying ? 'Applying...' : 'Apply' }} +
+ + +
@@ -89,109 +119,390 @@
- - - - - - - - - - - - - - - - -
- - - Collection - {{ sortDir === 'asc' ? '↑' : '↓' }} - - Title - {{ sortDir === 'asc' ? '↑' : '↓' }} - TagsActions
- - {{ article.collection || '—' }} - - {{ article.title }} - - -
- {{ tagLabel(tag) }} - no tags -
-
- -
- - {{ tagLabel(tag) }} - - -
-
- - -
-
-
- + + + + + + + + + + + + + + + + + + + + + + +
+ + + Title {{ sortDir === 'asc' ? '▲' : '▼' }} + Tags + Vis {{ sortDir === 'asc' ? '▲' : '▼' }} + + Updated {{ sortDir === 'asc' ? '▲' : '▼' }} + Actions
+ + + + {{ article.title }} + + +
+ {{ tagLabel(tag) }} + no tags +
+
+ +
+ + {{ tagLabel(tag) }} + + +
+
+ + +
+
+
+ + + {{ formatDate(article.outlineUpdatedAt) }} + + +
+ + @@ -229,6 +540,8 @@ const tagLabel = (slug) => tagLabelMap.value[slug] || slug // ---- Filters & Sort ---- const searchQuery = ref('') const collectionFilter = ref('') +const visibilityFilter = ref('') +const groupBy = ref('collection') const sortKey = ref('') const sortDir = ref('asc') @@ -257,7 +570,10 @@ const filtered = computed(() => { const q = searchQuery.value.toLowerCase() const matchesSearch = !q || a.title.toLowerCase().includes(q) const matchesCollection = !collectionFilter.value || a.collection === collectionFilter.value - return matchesSearch && matchesCollection + const matchesVisibility = !visibilityFilter.value + || (visibilityFilter.value === 'hidden' && a.hidden) + || (visibilityFilter.value === 'visible' && !a.hidden) + return matchesSearch && matchesCollection && matchesVisibility }) if (sortKey.value) { @@ -273,6 +589,58 @@ const filtered = computed(() => { return result }) +const groupedFiltered = computed(() => { + if (groupBy.value === 'none') return [] + + const groups = new Map() + + if (groupBy.value === 'tag') { + for (const article of filtered.value) { + if (!article.tags?.length) { + const key = 'Untagged' + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(article) + } else { + for (const tag of article.tags) { + const key = tagLabel(tag) + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(article) + } + } + } + } else { + for (const article of filtered.value) { + const key = article.collection || 'Uncategorized' + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(article) + } + } + + return [...groups.entries()] + .map(([name, articles]) => ({ name, articles })) + .sort((a, b) => a.name.localeCompare(b.name)) +}) + +const visibleGroups = computed(() => { + if (visibilityFilter.value === 'hidden') return groupedFiltered.value + return groupedFiltered.value.filter((group) => + group.articles.some((a) => !a.hidden), + ) +}) + +const hiddenGroups = computed(() => { + if (visibilityFilter.value) return [] + return groupedFiltered.value.filter((group) => + group.articles.every((a) => a.hidden), + ) +}) + +const formatDate = (dateStr) => { + if (!dateStr) return '—' + const d = new Date(dateStr) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + // ---- Selection ---- const selectedIds = ref([]) @@ -313,6 +681,28 @@ const toggleSelect = (id) => { } } +const allInGroupSelected = (groupArticles) => { + if (!groupArticles.length) return false + return groupArticles.every((a) => selectedIds.value.includes(a._id)) +} + +const someInGroupSelected = (groupArticles) => { + return groupArticles.some((a) => selectedIds.value.includes(a._id)) +} + +const toggleSelectGroup = (groupArticles) => { + if (allInGroupSelected(groupArticles)) { + const groupIds = new Set(groupArticles.map((a) => a._id)) + selectedIds.value = selectedIds.value.filter((id) => !groupIds.has(id)) + } else { + const currentSet = new Set(selectedIds.value) + for (const a of groupArticles) { + currentSet.add(a._id) + } + selectedIds.value = [...currentSet] + } +} + const selectAllInCollection = () => { if (!collectionFilter.value || !articles.value) return const currentSet = new Set(selectedIds.value) @@ -442,6 +832,73 @@ const applyBatchTag = async () => { batchApplying.value = false } } + +// ---- Visibility Toggle ---- +const toggleArticleVisibility = async (article) => { + try { + await $fetch(`/api/admin/wiki/${article._id}`, { + method: 'PATCH', + body: { hidden: !article.hidden }, + }) + await refresh() + } catch (err) { + toast.add({ + title: 'Failed to update visibility', + description: err.data?.statusMessage || err.message, + color: 'red', + }) + } +} + +// ---- Show Entire Collection ---- +const showCollection = async (group) => { + const ids = group.articles.map((a) => a._id) + try { + await $fetch('/api/admin/wiki/batch-visibility', { + method: 'POST', + body: { articleIds: ids, hidden: false }, + }) + await refresh() + toast.add({ + title: `${group.name} is now visible`, + color: 'green', + }) + } catch (err) { + toast.add({ + title: 'Failed to show collection', + description: err.data?.statusMessage || err.message, + color: 'red', + }) + } +} + +// ---- Batch Visibility ---- +const batchVisibilityApplying = ref(false) + +const applyBatchVisibility = async (hidden) => { + if (!selectedIds.value.length) return + batchVisibilityApplying.value = true + try { + const result = await $fetch('/api/admin/wiki/batch-visibility', { + method: 'POST', + body: { articleIds: selectedIds.value, hidden }, + }) + await refresh() + toast.add({ + title: `${result.modified} articles ${hidden ? 'hidden' : 'shown'}`, + color: 'green', + }) + selectedIds.value = [] + } catch (err) { + toast.add({ + title: 'Batch visibility failed', + description: err.data?.statusMessage || err.message, + color: 'red', + }) + } finally { + batchVisibilityApplying.value = false + } +}