@commonpub/layer 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/components/AppToast.vue +1 -1
  2. package/components/ContentAvatar.vue +98 -0
  3. package/components/CpubCriteriaBar.vue +88 -0
  4. package/components/CpubDateTimeField.vue +73 -0
  5. package/components/CpubMarkdown.vue +3 -1
  6. package/components/FormatToggle.vue +2 -2
  7. package/components/ImageUpload.vue +5 -8
  8. package/components/MirrorDetailModal.vue +3 -1
  9. package/components/MirrorRequestApproveModal.vue +3 -1
  10. package/components/ProductEditModal.vue +184 -0
  11. package/components/RemoteFollowDialog.vue +2 -2
  12. package/components/SearchSidebar.vue +14 -21
  13. package/components/ShareToHubModal.vue +3 -1
  14. package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
  15. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
  16. package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
  17. package/components/blocks/BlockCompareColumnsView.vue +92 -0
  18. package/components/blocks/BlockContentRenderer.vue +17 -0
  19. package/components/blocks/BlockCriteriaBarView.vue +25 -0
  20. package/components/blocks/BlockGalleryView.vue +5 -0
  21. package/components/blocks/BlockHtmlView.vue +26 -0
  22. package/components/blocks/BlockImageView.vue +4 -0
  23. package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
  24. package/components/blocks/BlockRoadmapView.vue +84 -0
  25. package/components/blocks/BlockSponsorsView.vue +89 -0
  26. package/components/blocks/BlockTableView.vue +49 -0
  27. package/components/blocks/BlockTabsView.vue +121 -0
  28. package/components/contest/ContestBodyCanvas.vue +155 -0
  29. package/components/contest/ContestCriteriaEditor.vue +79 -0
  30. package/components/contest/ContestEditor.vue +948 -0
  31. package/components/contest/ContestEntries.vue +1 -1
  32. package/components/contest/ContestEntryPrivateData.vue +126 -0
  33. package/components/contest/ContestHero.vue +114 -186
  34. package/components/contest/ContestJudgeManager.vue +6 -4
  35. package/components/contest/ContestJudgingCriteria.vue +5 -21
  36. package/components/contest/ContestPrizes.vue +8 -1
  37. package/components/contest/ContestProposalForm.vue +88 -0
  38. package/components/contest/ContestRules.vue +8 -1
  39. package/components/contest/ContestSidebar.vue +8 -2
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +3 -2
  43. package/components/contest/ContestSubmissionField.vue +141 -0
  44. package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
  45. package/components/contest/blocks/ContestTabPanel.vue +27 -0
  46. package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
  47. package/components/contest/blocks/HtmlBlock.vue +61 -0
  48. package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
  49. package/components/contest/blocks/RoadmapBlock.vue +127 -0
  50. package/components/contest/blocks/SponsorsBlock.vue +127 -0
  51. package/components/contest/blocks/TableBlock.vue +101 -0
  52. package/components/contest/blocks/TabsBlock.vue +168 -0
  53. package/components/editors/ArticleEditor.vue +9 -16
  54. package/components/editors/ExplainerEditor.vue +8 -5
  55. package/components/editors/ProjectEditor.vue +13 -10
  56. package/components/homepage/CustomHtmlSection.vue +11 -2
  57. package/components/hub/HubProducts.vue +4 -2
  58. package/components/nav/NavDropdown.vue +1 -5
  59. package/components/nav/NavLink.vue +2 -0
  60. package/components/views/ArticleView.vue +3 -56
  61. package/components/views/ExplainerView.vue +4 -0
  62. package/components/views/ProjectView.vue +83 -245
  63. package/composables/useContestEditor.ts +388 -0
  64. package/composables/useDocsPageTree.ts +154 -0
  65. package/composables/useDocsSiteSettings.ts +107 -0
  66. package/composables/useEditorAutosave.ts +131 -0
  67. package/composables/useEngagement.ts +13 -6
  68. package/composables/useFeatures.ts +9 -1
  69. package/composables/useFileUpload.ts +60 -0
  70. package/composables/useProfileContent.ts +84 -0
  71. package/composables/useSanitize.ts +38 -4
  72. package/composables/useScrollSpy.ts +87 -0
  73. package/layouts/admin.vue +41 -19
  74. package/layouts/default.vue +18 -9
  75. package/nuxt.config.ts +13 -0
  76. package/package.json +9 -9
  77. package/pages/[type]/index.vue +6 -1
  78. package/pages/admin/api-keys.vue +13 -3
  79. package/pages/admin/features.vue +2 -0
  80. package/pages/admin/federation.vue +1 -1
  81. package/pages/admin/layouts/[id].vue +30 -2
  82. package/pages/admin/settings.vue +2 -1
  83. package/pages/admin/users.vue +1 -1
  84. package/pages/admin/video-categories.vue +203 -0
  85. package/pages/cert/[code].vue +6 -2
  86. package/pages/contests/[slug]/edit.vue +4 -769
  87. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  88. package/pages/contests/[slug]/index.vue +93 -7
  89. package/pages/contests/[slug]/judge.vue +49 -26
  90. package/pages/contests/create.vue +5 -466
  91. package/pages/contests/index.vue +7 -2
  92. package/pages/cookies.vue +1 -1
  93. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  94. package/pages/docs/[siteSlug]/edit.vue +93 -231
  95. package/pages/events/[slug]/edit.vue +20 -20
  96. package/pages/events/create.vue +18 -18
  97. package/pages/events/index.vue +7 -2
  98. package/pages/hubs/[slug]/index.vue +34 -9
  99. package/pages/hubs/[slug]/invites.vue +312 -0
  100. package/pages/hubs/[slug]/members.vue +128 -0
  101. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  102. package/pages/hubs/index.vue +6 -1
  103. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  104. package/pages/learn/index.vue +8 -1
  105. package/pages/messages/index.vue +1 -1
  106. package/pages/mirror/[id].vue +1 -1
  107. package/pages/products/[slug].vue +55 -2
  108. package/pages/products/index.vue +6 -1
  109. package/pages/settings/account.vue +8 -8
  110. package/pages/settings/profile.vue +23 -14
  111. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  112. package/pages/u/[username]/followers.vue +11 -3
  113. package/pages/u/[username]/following.vue +10 -8
  114. package/pages/u/[username]/index.vue +73 -7
  115. package/pages/videos/index.vue +13 -10
  116. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  117. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  118. package/server/api/admin/api-keys/index.get.ts +1 -0
  119. package/server/api/admin/api-keys/index.post.ts +1 -0
  120. package/server/api/admin/federation/refederate.post.ts +18 -1
  121. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  122. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  123. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  124. package/server/api/admin/layouts/[id].delete.ts +1 -4
  125. package/server/api/admin/layouts/[id].get.ts +1 -4
  126. package/server/api/admin/layouts/[id].put.ts +1 -4
  127. package/server/api/auth/federated/login.post.ts +12 -5
  128. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  129. package/server/api/content/[id]/build.get.ts +11 -0
  130. package/server/api/content/[id]/report.post.ts +2 -0
  131. package/server/api/content/[id]/versions.get.ts +15 -0
  132. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  133. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  134. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  135. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  136. package/server/api/contests/[slug]/export.get.ts +43 -0
  137. package/server/api/contests/[slug]/judge.post.ts +8 -2
  138. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  139. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  140. package/server/api/contests/index.post.ts +1 -1
  141. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  142. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  143. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  144. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  145. package/server/api/events/[slug]/attendees.get.ts +10 -0
  146. package/server/api/events/[slug].get.ts +9 -0
  147. package/server/api/events/index.get.ts +8 -1
  148. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  149. package/server/api/federation/content/[id]/build.get.ts +10 -0
  150. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  151. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  152. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  153. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  154. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  155. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  156. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  157. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  158. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  159. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  160. package/server/api/products/[id].delete.ts +22 -2
  161. package/server/api/registry/ping.post.ts +17 -3
  162. package/server/api/search/index.get.ts +5 -3
  163. package/server/api/social/bookmark.get.ts +1 -0
  164. package/server/api/social/bookmark.post.ts +1 -0
  165. package/server/api/social/bookmarks.get.ts +1 -0
  166. package/server/api/social/comments/[id].delete.ts +1 -0
  167. package/server/api/social/comments.get.ts +1 -0
  168. package/server/api/social/comments.post.ts +1 -0
  169. package/server/api/social/like.get.ts +1 -0
  170. package/server/api/social/like.post.ts +1 -0
  171. package/server/api/users/[username]/content.get.ts +15 -3
  172. package/server/api/users/[username]/follow.delete.ts +1 -0
  173. package/server/api/users/[username]/follow.post.ts +1 -0
  174. package/server/api/users/[username]/followers.get.ts +2 -1
  175. package/server/api/users/[username]/following.get.ts +2 -1
  176. package/server/middleware/content-ap.ts +8 -3
  177. package/server/middleware/csrf.ts +93 -0
  178. package/server/plugins/federation-hub-sync.ts +48 -17
  179. package/server/plugins/notification-email.ts +22 -3
  180. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  181. package/server/routes/inbox.ts +14 -1
  182. package/server/routes/users/[username]/inbox.ts +13 -1
  183. package/server/utils/inbox.ts +7 -2
  184. package/server/utils/validate.ts +22 -0
  185. package/theme/base.css +5 -0
  186. package/theme/prose.css +20 -0
  187. package/theme/stoa-dark.css +4 -0
  188. package/types/contestBlocks.ts +122 -0
  189. package/utils/contestBlocks.ts +107 -0
  190. package/utils/contestBody.ts +25 -0
  191. package/utils/contestStages.ts +62 -0
  192. package/utils/contestSubmission.ts +97 -0
  193. package/utils/datetime.ts +45 -0
  194. package/utils/projectBlocks.ts +162 -0
  195. package/components/editors/BlogEditor.vue +0 -648
@@ -4,6 +4,12 @@ import type { HubViewModel, HubPostViewModel, HubMemberViewModel, HubTabDef } fr
4
4
 
5
5
  const route = useRoute();
6
6
  const slug = computed(() => route.params.slug as string);
7
+ // Invite-link redemption: `/hubs/<slug>?invite=<token>` lets a recipient join an
8
+ // invite-only / approval hub. The token rides along to the join endpoint below.
9
+ const inviteToken = computed(() => {
10
+ const q = route.query.invite;
11
+ return typeof q === 'string' ? q : '';
12
+ });
7
13
 
8
14
  function remoteDomain(uri: string | undefined): string | null {
9
15
  if (!uri) return null;
@@ -228,14 +234,14 @@ function openImagePicker(): void {
228
234
  imageInput.value?.click();
229
235
  }
230
236
 
237
+ const { uploadFile } = useFileUpload();
238
+
231
239
  async function handleImageUpload(event: Event): Promise<void> {
232
240
  const input = event.target as HTMLInputElement;
233
241
  const file = input.files?.[0];
234
242
  if (!file) return;
235
- const formData = new FormData();
236
- formData.append('file', file);
237
243
  try {
238
- const result = await $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData });
244
+ const result = await uploadFile(file);
239
245
  newPostContent.value += (newPostContent.value ? ' ' : '') + result.url;
240
246
  toast.success('Image uploaded');
241
247
  } catch {
@@ -253,13 +259,27 @@ function handleLinkInsert(): void {
253
259
 
254
260
  async function handleJoin(): Promise<void> {
255
261
  if (!isAuthenticated.value) {
256
- await navigateTo(`/auth/login?redirect=/hubs/${slug.value}`);
262
+ // Preserve the invite token across login so the redemption survives the round-trip.
263
+ const target = `/hubs/${slug.value}${inviteToken.value ? `?invite=${inviteToken.value}` : ''}`;
264
+ await navigateTo(`/auth/login?redirect=${encodeURIComponent(target)}`);
257
265
  return;
258
266
  }
259
267
  try {
260
- await $fetch(`/api/hubs/${slug.value}/join`, { method: 'POST' });
261
- toast.success('Joined hub!');
262
- await refreshHub();
268
+ const result = await $fetch<{ joined: boolean; pending?: boolean; error?: string }>(`/api/hubs/${slug.value}/join`, {
269
+ method: 'POST',
270
+ body: inviteToken.value ? { inviteToken: inviteToken.value } : undefined,
271
+ });
272
+ if (result.joined) {
273
+ toast.success('Joined hub!');
274
+ await refreshHub();
275
+ } else if (result.pending) {
276
+ // Approval-gated hub: a pending request was created, awaiting admin review.
277
+ toast.success('Join request submitted');
278
+ await refreshHub();
279
+ } else {
280
+ // joinHub resolves 200 with { joined: false } for policy failures (e.g. invite required).
281
+ toast.error(result.error || 'Could not join this hub');
282
+ }
263
283
  } catch {
264
284
  toast.error('Failed to join hub');
265
285
  }
@@ -292,13 +312,18 @@ async function onRefreshGallery(): Promise<void> {
292
312
  <template #hero>
293
313
  <HubHero :hub="hubVM" :gallery-total="gallery?.total">
294
314
  <template #actions>
295
- <button v-if="isAuthenticated && !hub?.currentUserRole" class="cpub-btn cpub-btn-primary" @click="handleJoin">
296
- <i class="fa-solid fa-plus"></i> Join Hub
315
+ <button v-if="(isAuthenticated || inviteToken) && !hub?.currentUserRole && !hub?.joinRequestPending" class="cpub-btn cpub-btn-primary" @click="handleJoin">
316
+ <i class="fa-solid fa-plus"></i> {{ inviteToken ? 'Accept invite' : (hub?.joinPolicy === 'approval' ? 'Request to join' : 'Join Hub') }}
297
317
  </button>
318
+ <span v-else-if="hub?.joinRequestPending" class="cpub-member-badge">
319
+ <i class="fa-solid fa-hourglass-half"></i> Request pending
320
+ </span>
298
321
  <span v-else-if="hub?.currentUserRole" class="cpub-member-badge">
299
322
  <i class="fa-solid fa-check"></i> Joined
300
323
  </span>
301
324
  <button class="cpub-btn cpub-btn-sm" aria-label="Share hub" @click="handleShare"><i class="fa-solid fa-share-nodes"></i></button>
325
+ <NuxtLink v-if="['owner', 'admin'].includes(hub?.currentUserRole ?? '')" :to="`/hubs/${slug}/members`" class="cpub-btn cpub-btn-sm" aria-label="Manage members"><i class="fa-solid fa-users-gear"></i> Members</NuxtLink>
326
+ <NuxtLink v-if="['owner', 'admin'].includes(hub?.currentUserRole ?? '')" :to="`/hubs/${slug}/invites`" class="cpub-btn cpub-btn-sm" aria-label="Manage invites"><i class="fa-solid fa-user-plus"></i> Invites</NuxtLink>
302
327
  <NuxtLink v-if="hub?.currentUserRole === 'owner'" :to="`/hubs/${slug}/settings`" class="cpub-btn cpub-btn-sm" aria-label="Hub settings"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
303
328
  </template>
304
329
  <template #badges>
@@ -0,0 +1,312 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ middleware: 'auth' });
3
+
4
+ import type { Serialized, HubDetail, HubInviteItem } from '@commonpub/server';
5
+
6
+ const route = useRoute();
7
+ const slug = computed(() => route.params.slug as string);
8
+ const toast = useToast();
9
+ const { extract: extractError } = useApiError();
10
+
11
+ const { data: hub } = useLazyFetch<Serialized<HubDetail>>(() => `/api/hubs/${slug.value}`);
12
+ const { data: invitesData, refresh, error: invitesError } = useLazyFetch<Serialized<HubInviteItem>[]>(
13
+ () => `/api/hubs/${slug.value}/invites`,
14
+ { default: () => [] },
15
+ );
16
+ const invites = computed(() => invitesData.value ?? []);
17
+
18
+ const currentUserRole = computed(() => hub.value?.currentUserRole ?? null);
19
+ // admin+ only — mirrors the server's manageMembers permission (createInvite/revokeInvite).
20
+ const canManage = computed(() =>
21
+ ['owner', 'admin'].includes(currentUserRole.value ?? ''),
22
+ );
23
+
24
+ useSeoMeta({ title: () => `Invites, ${hub.value?.name ?? 'Hub'}, ${useSiteName()}` });
25
+
26
+ // --- Create form ---
27
+ const expiryOptions = [
28
+ { label: 'Never expires', days: 0 },
29
+ { label: 'Expires in 1 day', days: 1 },
30
+ { label: 'Expires in 7 days', days: 7 },
31
+ { label: 'Expires in 30 days', days: 30 },
32
+ ] as const;
33
+
34
+ const form = reactive({ maxUses: '', expiryDays: 0 });
35
+ const creating = ref(false);
36
+
37
+ async function createInvite(): Promise<void> {
38
+ creating.value = true;
39
+ try {
40
+ const body: { maxUses?: number; expiresAt?: string } = {};
41
+ const max = Number(form.maxUses);
42
+ if (form.maxUses && Number.isFinite(max) && max > 0) body.maxUses = Math.trunc(max);
43
+ if (form.expiryDays > 0) {
44
+ body.expiresAt = new Date(Date.now() + form.expiryDays * 86_400_000).toISOString();
45
+ }
46
+ await $fetch(`/api/hubs/${slug.value}/invites`, { method: 'POST', body });
47
+ toast.success('Invite created');
48
+ form.maxUses = '';
49
+ form.expiryDays = 0;
50
+ await refresh();
51
+ } catch (err: unknown) {
52
+ toast.error(extractError(err));
53
+ } finally {
54
+ creating.value = false;
55
+ }
56
+ }
57
+
58
+ function inviteLink(token: string): string {
59
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
60
+ return `${origin}/hubs/${slug.value}?invite=${token}`;
61
+ }
62
+
63
+ async function copyLink(token: string): Promise<void> {
64
+ try {
65
+ await navigator.clipboard.writeText(inviteLink(token));
66
+ toast.success('Invite link copied');
67
+ } catch {
68
+ toast.error('Could not copy link');
69
+ }
70
+ }
71
+
72
+ async function revoke(id: string): Promise<void> {
73
+ if (!confirm('Revoke this invite? The link will stop working.')) return;
74
+ try {
75
+ await $fetch(`/api/hubs/${slug.value}/invites/${id}`, { method: 'DELETE' });
76
+ toast.success('Invite revoked');
77
+ await refresh();
78
+ } catch (err: unknown) {
79
+ toast.error(extractError(err));
80
+ }
81
+ }
82
+
83
+ function usesLabel(invite: Serialized<HubInviteItem>): string {
84
+ return invite.maxUses == null
85
+ ? `${invite.useCount} uses`
86
+ : `${invite.useCount} / ${invite.maxUses} uses`;
87
+ }
88
+
89
+ function isExhausted(invite: Serialized<HubInviteItem>): boolean {
90
+ const expired = invite.expiresAt != null && new Date(invite.expiresAt).getTime() <= Date.now();
91
+ const maxedOut = invite.maxUses != null && invite.useCount >= invite.maxUses;
92
+ return expired || maxedOut;
93
+ }
94
+
95
+ function fmtDate(d: string | null): string {
96
+ if (!d) return '';
97
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <div class="cpub-invites-page">
103
+ <div class="cpub-invites-header">
104
+ <NuxtLink :to="`/hubs/${slug}`" class="cpub-back-link">
105
+ <i class="fa-solid fa-arrow-left"></i> {{ hub?.name ?? 'Hub' }}
106
+ </NuxtLink>
107
+ <h1 class="cpub-invites-title">Invites</h1>
108
+ <p class="cpub-invites-subtitle">Share an invite link so people can join this hub.</p>
109
+ </div>
110
+
111
+ <div v-if="!hub" class="cpub-loading">Loading...</div>
112
+
113
+ <div v-else-if="invitesError || !canManage" class="cpub-empty-state" style="padding: 48px 24px">
114
+ <p class="cpub-empty-state-title">You do not have permission to manage invites.</p>
115
+ </div>
116
+
117
+ <template v-else>
118
+ <form class="cpub-invites-create" @submit.prevent="createInvite">
119
+ <div class="cpub-invites-create-fields">
120
+ <div class="cpub-field">
121
+ <label for="invite-max-uses" class="cpub-field-label">Max uses (optional)</label>
122
+ <input
123
+ id="invite-max-uses"
124
+ v-model="form.maxUses"
125
+ type="number"
126
+ min="1"
127
+ inputmode="numeric"
128
+ class="cpub-field-input"
129
+ placeholder="Unlimited"
130
+ />
131
+ </div>
132
+ <div class="cpub-field">
133
+ <label for="invite-expiry" class="cpub-field-label">Expiry</label>
134
+ <select id="invite-expiry" v-model.number="form.expiryDays" class="cpub-field-input">
135
+ <option v-for="opt in expiryOptions" :key="opt.days" :value="opt.days">{{ opt.label }}</option>
136
+ </select>
137
+ </div>
138
+ </div>
139
+ <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="creating">
140
+ <i class="fa-solid fa-plus"></i> {{ creating ? 'Creating...' : 'Create invite' }}
141
+ </button>
142
+ </form>
143
+
144
+ <ul v-if="invites.length" class="cpub-invites-list">
145
+ <li v-for="invite in invites" :key="invite.id" class="cpub-invite-card">
146
+ <div class="cpub-invite-info">
147
+ <code class="cpub-invite-token">{{ invite.token.slice(0, 12) }}...</code>
148
+ <div class="cpub-invite-meta">
149
+ <span :class="{ 'cpub-invite-exhausted': isExhausted(invite) }">{{ usesLabel(invite) }}</span>
150
+ <span v-if="invite.expiresAt">Expires {{ fmtDate(invite.expiresAt) }}</span>
151
+ <span v-else>No expiry</span>
152
+ <span>Created {{ fmtDate(invite.createdAt) }}</span>
153
+ </div>
154
+ </div>
155
+ <div class="cpub-invite-actions">
156
+ <button class="cpub-btn cpub-btn-sm" type="button" @click="copyLink(invite.token)">
157
+ <i class="fa-solid fa-link"></i> Copy link
158
+ </button>
159
+ <button class="cpub-btn cpub-btn-sm cpub-invite-revoke" type="button" aria-label="Revoke invite" @click="revoke(invite.id)">
160
+ <i class="fa-solid fa-trash"></i>
161
+ </button>
162
+ </div>
163
+ </li>
164
+ </ul>
165
+
166
+ <div v-else class="cpub-empty-state" style="padding: 48px 24px">
167
+ <p class="cpub-empty-state-title">No invites yet.</p>
168
+ <p class="cpub-empty-state-text">Create one above to share a join link.</p>
169
+ </div>
170
+ </template>
171
+ </div>
172
+ </template>
173
+
174
+ <style scoped>
175
+ .cpub-invites-page {
176
+ max-width: 640px;
177
+ margin: 0 auto;
178
+ padding: 32px;
179
+ }
180
+
181
+ .cpub-invites-header {
182
+ margin-bottom: 24px;
183
+ }
184
+
185
+ /* cpub-back-link → global components.css */
186
+
187
+ .cpub-invites-title {
188
+ font-size: 22px;
189
+ font-weight: 700;
190
+ margin-bottom: 4px;
191
+ }
192
+
193
+ .cpub-invites-subtitle {
194
+ font-size: 13px;
195
+ color: var(--text-dim);
196
+ }
197
+
198
+ .cpub-invites-create {
199
+ display: flex;
200
+ align-items: flex-end;
201
+ gap: 12px;
202
+ flex-wrap: wrap;
203
+ padding: 16px;
204
+ border: var(--border-width-default) solid var(--border);
205
+ background: var(--surface);
206
+ margin-bottom: 24px;
207
+ }
208
+
209
+ .cpub-invites-create-fields {
210
+ display: grid;
211
+ grid-template-columns: 1fr 1fr;
212
+ gap: 12px;
213
+ flex: 1;
214
+ min-width: 240px;
215
+ }
216
+
217
+ .cpub-field {
218
+ display: flex;
219
+ flex-direction: column;
220
+ gap: 6px;
221
+ }
222
+
223
+ .cpub-field-label {
224
+ font-size: 11px;
225
+ font-weight: 600;
226
+ font-family: var(--font-mono);
227
+ text-transform: uppercase;
228
+ letter-spacing: 0.06em;
229
+ color: var(--text-dim);
230
+ }
231
+
232
+ .cpub-field-input {
233
+ padding: 8px 12px;
234
+ border: var(--border-width-default) solid var(--border);
235
+ background: var(--surface);
236
+ color: var(--text);
237
+ font-size: 13px;
238
+ font-family: var(--font-sans);
239
+ outline: none;
240
+ transition: border-color 0.15s;
241
+ }
242
+
243
+ .cpub-field-input:focus {
244
+ border-color: var(--accent);
245
+ }
246
+
247
+ select.cpub-field-input {
248
+ cursor: pointer;
249
+ }
250
+
251
+ .cpub-invites-list {
252
+ list-style: none;
253
+ margin: 0;
254
+ padding: 0;
255
+ border: var(--border-width-default) solid var(--border);
256
+ background: var(--surface);
257
+ }
258
+
259
+ .cpub-invite-card {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: 12px;
263
+ padding: 12px 16px;
264
+ border-bottom: var(--border-width-default) solid var(--border2);
265
+ }
266
+
267
+ .cpub-invite-card:last-child {
268
+ border-bottom: none;
269
+ }
270
+
271
+ .cpub-invite-info {
272
+ flex: 1;
273
+ min-width: 0;
274
+ }
275
+
276
+ .cpub-invite-token {
277
+ font-family: var(--font-mono);
278
+ font-size: 13px;
279
+ color: var(--text);
280
+ }
281
+
282
+ .cpub-invite-meta {
283
+ display: flex;
284
+ flex-wrap: wrap;
285
+ gap: 12px;
286
+ margin-top: 4px;
287
+ font-size: 11px;
288
+ font-family: var(--font-mono);
289
+ color: var(--text-faint);
290
+ }
291
+
292
+ .cpub-invite-exhausted {
293
+ color: var(--red);
294
+ }
295
+
296
+ .cpub-invite-actions {
297
+ display: flex;
298
+ gap: 6px;
299
+ flex-shrink: 0;
300
+ }
301
+
302
+ .cpub-invite-revoke:hover {
303
+ color: var(--red);
304
+ border-color: var(--red);
305
+ }
306
+
307
+ @media (max-width: 640px) {
308
+ .cpub-invites-page { padding: 16px; }
309
+ .cpub-invites-create-fields { grid-template-columns: 1fr; }
310
+ .cpub-invite-card { flex-wrap: wrap; }
311
+ }
312
+ </style>
@@ -11,6 +11,46 @@ const { user } = useAuth();
11
11
  const currentUserRole = computed(() => hub.value?.currentUserRole ?? null);
12
12
  const canManage = computed(() => currentUserRole.value === 'owner' || currentUserRole.value === 'admin');
13
13
 
14
+ interface BanItem {
15
+ id: string;
16
+ reason: string | null;
17
+ expiresAt: string | null;
18
+ createdAt: string;
19
+ user: { id: string; username: string; displayName: string | null; avatarUrl: string | null };
20
+ bannedBy: { username: string; displayName: string | null };
21
+ }
22
+
23
+ // The bans endpoint is manager-only (403 otherwise), so fetch it lazily once
24
+ // the viewer is confirmed to manage this hub.
25
+ const { data: bansData, refresh: refreshBans } = useLazyFetch<BanItem[]>(() => `/api/hubs/${slug.value}/bans`, { immediate: false });
26
+ const bans = computed(() => bansData.value ?? []);
27
+ watch(canManage, (v) => { if (v) refreshBans(); }, { immediate: true });
28
+
29
+ // Pending join requests (approval-gated hubs). Manager-only, fetched lazily.
30
+ const { data: requestsData, refresh: refreshRequests } = useLazyFetch<{ items: any[]; total: number }>(() => `/api/hubs/${slug.value}/requests`, { immediate: false });
31
+ const requests = computed(() => requestsData.value?.items ?? []);
32
+ watch(canManage, (v) => { if (v) refreshRequests(); }, { immediate: true });
33
+
34
+ async function approveRequest(userId: string): Promise<void> {
35
+ try {
36
+ await $fetch(`/api/hubs/${slug.value}/requests/${userId}/approve`, { method: 'POST' });
37
+ toast.success('Request approved');
38
+ await Promise.all([refreshRequests(), refresh()]);
39
+ } catch {
40
+ toast.error('Failed to approve request');
41
+ }
42
+ }
43
+
44
+ async function denyRequest(userId: string): Promise<void> {
45
+ try {
46
+ await $fetch(`/api/hubs/${slug.value}/requests/${userId}/deny`, { method: 'POST' });
47
+ toast.success('Request declined');
48
+ await refreshRequests();
49
+ } catch {
50
+ toast.error('Failed to decline request');
51
+ }
52
+ }
53
+
14
54
  useSeoMeta({ title: () => `Members, ${hub.value?.name ?? 'Hub'}, ${useSiteName()}` });
15
55
 
16
56
  const roles = ['member', 'moderator', 'admin'] as const;
@@ -45,6 +85,34 @@ async function kickMember(userId: string, username: string): Promise<void> {
45
85
  toast.error('Failed to remove member');
46
86
  }
47
87
  }
88
+
89
+ async function banMember(userId: string, username: string): Promise<void> {
90
+ // One native dialog doubles as the confirm: OK (even empty) bans with that
91
+ // optional reason; Cancel aborts.
92
+ const reason = prompt(`Ban @${username} from this hub? They will be removed and blocked from rejoining. Enter an optional reason, or Cancel to abort.`);
93
+ if (reason === null) return;
94
+ try {
95
+ await $fetch(`/api/hubs/${slug.value}/bans`, {
96
+ method: 'POST',
97
+ body: { userId, reason: reason.trim() || undefined },
98
+ });
99
+ toast.success('Member banned');
100
+ await Promise.all([refresh(), refreshBans()]);
101
+ } catch {
102
+ toast.error('Failed to ban member');
103
+ }
104
+ }
105
+
106
+ async function unbanMember(userId: string, username: string): Promise<void> {
107
+ if (!confirm(`Lift the ban on @${username}? They will be able to rejoin.`)) return;
108
+ try {
109
+ await $fetch(`/api/hubs/${slug.value}/bans/${userId}`, { method: 'DELETE' });
110
+ toast.success('Ban lifted');
111
+ await refreshBans();
112
+ } catch {
113
+ toast.error('Failed to lift ban');
114
+ }
115
+ }
48
116
  </script>
49
117
 
50
118
  <template>
@@ -55,6 +123,28 @@ async function kickMember(userId: string, username: string): Promise<void> {
55
123
  <p class="members-count" v-if="membersData?.total">{{ membersData.total }} members</p>
56
124
  </div>
57
125
 
126
+ <!-- Pending join requests (managers only) -->
127
+ <section v-if="canManage && requests.length" class="requests-section">
128
+ <h2 class="requests-title">Join requests</h2>
129
+ <div class="members-list">
130
+ <div class="member-card" v-for="r in requests" :key="r.userId">
131
+ <NuxtLink :to="`/u/${r.user.username}`" class="member-avatar">
132
+ <img v-if="r.user.avatarUrl" :src="r.user.avatarUrl" :alt="r.user.displayName || r.user.username" class="member-avatar-img" />
133
+ <span v-else>{{ (r.user.displayName || r.user.username).charAt(0).toUpperCase() }}</span>
134
+ </NuxtLink>
135
+ <div class="member-info">
136
+ <NuxtLink :to="`/u/${r.user.username}`" class="member-name">{{ r.user.displayName || r.user.username }}</NuxtLink>
137
+ <span class="member-handle">@{{ r.user.username }}</span>
138
+ </div>
139
+ <time class="member-joined">{{ new Date(r.joinedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}</time>
140
+ <div class="member-actions">
141
+ <button class="cpub-btn cpub-btn-sm cpub-btn-primary" @click="approveRequest(r.userId)">Approve</button>
142
+ <button class="cpub-btn cpub-btn-sm member-deny-btn" @click="denyRequest(r.userId)">Deny</button>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </section>
147
+
58
148
  <div class="members-list" v-if="members?.length">
59
149
  <div class="member-card" v-for="m in members" :key="m.userId">
60
150
  <NuxtLink :to="`/u/${m.user.username}`" class="member-avatar">
@@ -84,12 +174,37 @@ async function kickMember(userId: string, username: string): Promise<void> {
84
174
  <button class="member-kick-btn" title="Remove member" @click="kickMember(m.userId, m.user.username)">
85
175
  <i class="fa-solid fa-user-xmark"></i>
86
176
  </button>
177
+ <button class="member-ban-btn" title="Ban member" aria-label="Ban member" @click="banMember(m.userId, m.user.username)">
178
+ <i class="fa-solid fa-ban"></i>
179
+ </button>
87
180
  </div>
88
181
  </div>
89
182
  </div>
90
183
  <div v-else class="members-empty">
91
184
  <p>No members yet.</p>
92
185
  </div>
186
+
187
+ <!-- Banned users (managers only) -->
188
+ <section v-if="canManage && bans.length" class="bans-section">
189
+ <h2 class="bans-title">Banned</h2>
190
+ <div class="members-list">
191
+ <div class="member-card" v-for="b in bans" :key="b.id">
192
+ <NuxtLink :to="`/u/${b.user.username}`" class="member-avatar">
193
+ <img v-if="b.user.avatarUrl" :src="b.user.avatarUrl" :alt="b.user.displayName || b.user.username" class="member-avatar-img" />
194
+ <span v-else>{{ (b.user.displayName || b.user.username).charAt(0).toUpperCase() }}</span>
195
+ </NuxtLink>
196
+ <div class="member-info">
197
+ <NuxtLink :to="`/u/${b.user.username}`" class="member-name">{{ b.user.displayName || b.user.username }}</NuxtLink>
198
+ <span class="member-handle">@{{ b.user.username }}</span>
199
+ <span v-if="b.reason" class="ban-reason">{{ b.reason }}</span>
200
+ </div>
201
+ <span class="ban-meta">{{ b.expiresAt ? 'Temporary' : 'Permanent' }}</span>
202
+ <button class="member-unban-btn" title="Lift ban" @click="unbanMember(b.user.id, b.user.username)">
203
+ <i class="fa-solid fa-rotate-left"></i> Unban
204
+ </button>
205
+ </div>
206
+ </div>
207
+ </section>
93
208
  </div>
94
209
  </template>
95
210
 
@@ -122,9 +237,22 @@ async function kickMember(userId: string, username: string): Promise<void> {
122
237
  .member-role-select:focus { border-color: var(--accent); outline: none; }
123
238
  .member-kick-btn { background: none; border: var(--border-width-default) solid var(--border2); color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 3px 6px; }
124
239
  .member-kick-btn:hover { color: var(--red); border-color: var(--red); }
240
+ .member-ban-btn { background: none; border: var(--border-width-default) solid var(--border2); color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 3px 6px; }
241
+ .member-ban-btn:hover { color: var(--red); border-color: var(--red); }
125
242
 
126
243
  .members-empty { text-align: center; padding: 48px 0; color: var(--text-faint); }
127
244
 
245
+ .requests-section { margin-bottom: 28px; }
246
+ .requests-title { font-size: 13px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 10px; }
247
+ .member-deny-btn:hover { color: var(--red); border-color: var(--red); }
248
+
249
+ .bans-section { margin-top: 28px; }
250
+ .bans-title { font-size: 13px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 10px; }
251
+ .ban-reason { display: block; font-size: 11px; color: var(--text-faint); margin-top: 2px; }
252
+ .ban-meta { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); flex-shrink: 0; }
253
+ .member-unban-btn { background: none; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; font-size: 10px; font-family: var(--font-mono); padding: 3px 8px; display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; }
254
+ .member-unban-btn:hover { color: var(--accent); border-color: var(--accent); }
255
+
128
256
  @media (max-width: 768px) {
129
257
  .members-page { padding: 16px; }
130
258
  .member-card { flex-wrap: wrap; gap: 8px; padding: 10px 12px; }
@@ -379,11 +379,11 @@ useSeoMeta({
379
379
  display: flex; gap: 6px;
380
380
  }
381
381
 
382
- .cpub-btn-primary { background: var(--accent); color: var(--accent-text, #fff); border-color: var(--accent); }
382
+ .cpub-btn-primary { background: var(--accent); color: var(--color-on-accent); border-color: var(--accent); }
383
383
  .cpub-btn-primary:hover:not(:disabled) { opacity: 0.9; }
384
384
  .cpub-btn-primary:disabled { opacity: 0.5; cursor: default; }
385
385
  .cpub-btn-danger { color: var(--red); border-color: var(--red); }
386
- .cpub-btn-danger:hover { background: var(--red); color: var(--accent-text, #fff); }
386
+ .cpub-btn-danger:hover { background: var(--red); color: var(--color-on-accent); }
387
387
 
388
388
  /* Reply form */
389
389
  .cpub-reply-form { margin-bottom: 16px; }
@@ -4,7 +4,7 @@ useSeoMeta({
4
4
  description: 'Browse and join maker communities.',
5
5
  });
6
6
 
7
- const { data, pending } = await useFetch('/api/hubs');
7
+ const { data, pending, error, refresh } = await useFetch('/api/hubs');
8
8
  const { isAuthenticated } = useAuth();
9
9
 
10
10
  const hubs = computed(() => data.value?.items ?? []);
@@ -32,6 +32,11 @@ function hubLink(hub: Record<string, unknown>): string {
32
32
  </div>
33
33
 
34
34
  <div v-if="pending" class="cpub-empty-state"><p><i class="fa-solid fa-circle-notch fa-spin"></i> Loading hubs...</p></div>
35
+ <div v-else-if="error" class="cpub-fetch-error">
36
+ <div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
37
+ <div class="cpub-fetch-error-msg">Failed to load hubs.</div>
38
+ <button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
39
+ </div>
35
40
  <div v-else-if="hubs.length" class="cpub-hubs-grid">
36
41
  <NuxtLink
37
42
  v-for="hub in hubs"
@@ -34,13 +34,22 @@ useSeoMeta({
34
34
  const { isAuthenticated, user } = useAuth();
35
35
  const toast = useToast();
36
36
  const completing = ref(false);
37
- const completed = ref(false);
37
+
38
+ // Completion is seeded from the server (the path fetch carries per-lesson
39
+ // isCompleted for the viewer) and flipped locally once the viewer marks it,
40
+ // so a reload no longer shows "Mark as Complete" on an already-done lesson.
41
+ const serverCompleted = computed(() => {
42
+ const lessons = path.value?.modules?.flatMap((m) => m.lessons ?? []) ?? [];
43
+ return lessons.find((l) => l.slug === lessonSlug.value)?.isCompleted ?? false;
44
+ });
45
+ const justCompleted = ref(false);
46
+ const completed = computed(() => justCompleted.value || serverCompleted.value);
38
47
 
39
48
  async function markComplete(): Promise<void> {
40
49
  completing.value = true;
41
50
  try {
42
51
  await $fetch(`/api/learn/${slug.value}/${lessonSlug.value}/complete`, { method: 'POST' });
43
- completed.value = true;
52
+ justCompleted.value = true;
44
53
  toast.success('Lesson completed!');
45
54
  } catch {
46
55
  toast.error('Failed to mark as complete');
@@ -164,7 +173,7 @@ async function submitQuiz(): Promise<void> {
164
173
  if (res.quiz) {
165
174
  quizGrade.value = res.quiz;
166
175
  if (res.quiz.passed) {
167
- completed.value = true;
176
+ justCompleted.value = true;
168
177
  toast.success(`Passed, ${res.quiz.score}%`);
169
178
  } else {
170
179
  toast.error(`Scored ${res.quiz.score}%, below passing. Try again.`);
@@ -3,7 +3,7 @@ useSeoMeta({ title: `Learn, ${useSiteName()}` });
3
3
 
4
4
  const { isAuthenticated, user } = useAuth();
5
5
 
6
- const { data: pathsData, pending: loadingPaths } = useFetch('/api/learn');
6
+ const { data: pathsData, pending: loadingPaths, error: pathsError, refresh: refreshPaths } = useFetch('/api/learn');
7
7
 
8
8
  // Fetch author's own paths (including drafts) when authenticated
9
9
  const myPathsQuery = computed(() => user.value?.id ? { authorId: user.value.id } : {});
@@ -141,6 +141,13 @@ const activeDifficultyFilter = ref('');
141
141
  <i class="fa-solid fa-circle-notch fa-spin"></i> Loading paths...
142
142
  </div>
143
143
 
144
+ <!-- Fetch error -->
145
+ <div v-else-if="pathsError" class="cpub-fetch-error">
146
+ <div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
147
+ <div class="cpub-fetch-error-msg">Failed to load learning paths.</div>
148
+ <button class="cpub-btn cpub-btn-sm" @click="refreshPaths()">Retry</button>
149
+ </div>
150
+
144
151
  <!-- Real data -->
145
152
  <template v-else-if="paths.length">
146
153
  <NuxtLink v-for="p in paths" :key="p.id" :to="`/learn/${p.slug}`" class="cpub-path-card" style="text-decoration: none; color: inherit;">
@@ -128,7 +128,7 @@ async function startConversation(): Promise<void> {
128
128
  <div class="cpub-new-msg-field">
129
129
  <label class="cpub-new-msg-label">Recipients</label>
130
130
  <div v-if="newRecipients.length" class="cpub-new-msg-chips">
131
- <span v-for="(r, idx) in newRecipients" :key="idx" class="cpub-new-msg-chip">
131
+ <span v-for="(r, idx) in newRecipients" :key="r" class="cpub-new-msg-chip">
132
132
  {{ r }}
133
133
  <button class="cpub-new-msg-chip-remove" @click="removeRecipient(idx)" :aria-label="`Remove ${r}`">&times;</button>
134
134
  </span>