@commonpub/layer 0.82.0 → 0.83.1

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
@@ -146,7 +146,7 @@ useSeoMeta({
146
146
  display: flex; align-items: center; gap: 4px; font-size: 11px;
147
147
  }
148
148
  .cpub-fed-banner-follow {
149
- margin-left: auto; background: var(--accent); color: var(--accent-text, #fff); border: none;
149
+ margin-left: auto; background: var(--accent); color: var(--color-on-accent); border: none;
150
150
  font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer;
151
151
  display: flex; align-items: center; gap: 4px; white-space: nowrap;
152
152
  }
@@ -1,10 +1,45 @@
1
1
  <script setup lang="ts">
2
2
  const route = useRoute();
3
3
  const slug = route.params.slug as string;
4
+ const toast = useToast();
4
5
 
5
- const { data: product, pending } = useLazyFetch(`/api/products/${slug}`) as { data: Ref<Record<string, any> | null>; pending: Ref<boolean> };
6
+ const { data: product, pending, refresh } = useLazyFetch(`/api/products/${slug}`) as { data: Ref<Record<string, any> | null>; pending: Ref<boolean>; refresh: () => Promise<void> };
6
7
  const { data: projectsUsing } = useLazyFetch(`/api/products/${slug}/content`) as { data: Ref<any[] | null> };
7
8
 
9
+ const { user } = useAuth();
10
+ const canModerate = useCan('content.moderate');
11
+ // PUT is owner-only on the server (updateProduct), so only the owner may edit.
12
+ const canEdit = computed(() => !!user.value && product.value?.createdBy?.id === user.value.id);
13
+ // DELETE allows owner OR content.moderate on the server.
14
+ const canDelete = computed(() => canEdit.value || canModerate.value);
15
+
16
+ const showEdit = ref(false);
17
+ const deleting = ref(false);
18
+
19
+ async function handleUpdated(updated: { slug: string }): Promise<void> {
20
+ // The slug regenerates when the name changes; route to the new slug if so.
21
+ if (updated.slug && updated.slug !== slug) {
22
+ await navigateTo(`/products/${updated.slug}`);
23
+ } else {
24
+ await refresh();
25
+ }
26
+ }
27
+
28
+ async function handleDelete(): Promise<void> {
29
+ if (!product.value) return;
30
+ if (!confirm(`Delete "${product.value.name}"? This cannot be undone.`)) return;
31
+ deleting.value = true;
32
+ try {
33
+ await $fetch(`/api/products/${product.value.id}`, { method: 'DELETE' });
34
+ toast.success('Product deleted');
35
+ await navigateTo('/products');
36
+ } catch {
37
+ toast.error('Failed to delete product');
38
+ } finally {
39
+ deleting.value = false;
40
+ }
41
+ }
42
+
8
43
  useSeoMeta({
9
44
  title: () => product.value ? `${product.value.name}, ${useSiteName()}` : `Product, ${useSiteName()}`,
10
45
  description: () => product.value?.description ?? '',
@@ -23,10 +58,18 @@ useSeoMeta({
23
58
  <div class="product-main">
24
59
  <div class="product-header">
25
60
  <div class="product-icon"><i class="fa-solid fa-microchip"></i></div>
26
- <div>
61
+ <div class="product-header-main">
27
62
  <h1 class="product-name">{{ product.name }}</h1>
28
63
  <span v-if="product.category" class="product-category">{{ product.category }}</span>
29
64
  </div>
65
+ <div v-if="canEdit || canDelete" class="product-header-actions">
66
+ <button v-if="canEdit" class="cpub-btn cpub-btn-sm" aria-label="Edit product" @click="showEdit = true">
67
+ <i class="fa-solid fa-pen"></i> Edit
68
+ </button>
69
+ <button v-if="canDelete" class="cpub-btn cpub-btn-sm cpub-product-delete" aria-label="Delete product" :disabled="deleting" @click="handleDelete">
70
+ <i class="fa-solid fa-trash"></i> {{ deleting ? 'Deleting...' : 'Delete' }}
71
+ </button>
72
+ </div>
30
73
  </div>
31
74
 
32
75
  <p v-if="product.description" class="product-desc">{{ product.description }}</p>
@@ -83,6 +126,13 @@ useSeoMeta({
83
126
  </div>
84
127
  </aside>
85
128
  </div>
129
+
130
+ <ProductEditModal
131
+ v-if="showEdit"
132
+ :product="{ id: product.id, name: product.name, description: product.description, category: product.category, purchaseUrl: product.purchaseUrl, datasheetUrl: product.datasheetUrl, status: product.status }"
133
+ @close="showEdit = false"
134
+ @updated="handleUpdated"
135
+ />
86
136
  </div>
87
137
  <div v-else class="product-not-found">
88
138
  <h1>Product not found</h1>
@@ -97,6 +147,9 @@ useSeoMeta({
97
147
  .product-layout { display: grid; grid-template-columns: 1fr 280px; gap: 32px; }
98
148
 
99
149
  .product-header { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
150
+ .product-header-main { flex: 1; min-width: 0; }
151
+ .product-header-actions { display: flex; gap: 8px; flex-shrink: 0; }
152
+ .cpub-product-delete:hover { color: var(--red); border-color: var(--red); }
100
153
  .product-icon { width: 56px; height: 56px; border: var(--border-width-default) solid var(--border); background: var(--accent-bg); display: flex; align-items: center; justify-content: center; font-size: 24px; color: var(--accent); }
101
154
  .product-name { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; }
102
155
  .product-category { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); text-transform: capitalize; }
@@ -4,7 +4,7 @@ useSeoMeta({ title: `Products, ${useSiteName()}`, description: 'Browse products,
4
4
  const search = ref('');
5
5
  const category = ref('');
6
6
 
7
- const { data: productsData, pending } = useFetch('/api/products', {
7
+ const { data: productsData, pending, error, refresh } = useFetch('/api/products', {
8
8
  query: computed(() => ({
9
9
  q: search.value || undefined,
10
10
  category: category.value || undefined,
@@ -57,6 +57,11 @@ const categories = [
57
57
 
58
58
  <!-- Grid -->
59
59
  <div v-if="pending" class="products-loading">Loading...</div>
60
+ <div v-else-if="error" class="cpub-fetch-error">
61
+ <div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
62
+ <div class="cpub-fetch-error-msg">Failed to load products.</div>
63
+ <button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
64
+ </div>
60
65
  <div v-else-if="products.length" class="products-grid">
61
66
  <NuxtLink
62
67
  v-for="product in products"
@@ -72,22 +72,22 @@ async function handleDeleteAccount(): Promise<void> {
72
72
  <h2 class="cpub-section-title-lg">Account Settings</h2>
73
73
 
74
74
  <div class="cpub-form-group">
75
- <label class="cpub-form-label">Email</label>
76
- <input type="email" class="cpub-input" :value="user?.email" disabled />
75
+ <label for="acct-email" class="cpub-form-label">Email</label>
76
+ <input id="acct-email" type="email" class="cpub-input" :value="user?.email" disabled />
77
77
  <span class="cpub-form-hint">Contact support to change your email.</span>
78
78
  </div>
79
79
 
80
80
  <form class="cpub-form-group" @submit.prevent="handlePasswordChange">
81
- <label class="cpub-form-label">Change Password</label>
82
- <input v-model="currentPassword" type="password" class="cpub-input" placeholder="Current password" required />
83
- <input v-model="newPassword" type="password" class="cpub-input cpub-mt-2" placeholder="New password (min 8 characters)" required minlength="8" />
81
+ <label for="acct-current-password" class="cpub-form-label">Change Password</label>
82
+ <input id="acct-current-password" v-model="currentPassword" type="password" class="cpub-input" placeholder="Current password" aria-label="Current password" required />
83
+ <input v-model="newPassword" type="password" class="cpub-input cpub-mt-2" placeholder="New password (min 8 characters)" aria-label="New password (min 8 characters)" required minlength="8" />
84
84
  <button type="submit" class="cpub-btn cpub-btn-sm cpub-mt-2" :disabled="passwordLoading">
85
85
  {{ passwordLoading ? 'Updating...' : 'Update Password' }}
86
86
  </button>
87
87
  </form>
88
88
 
89
89
  <div class="cpub-form-group">
90
- <label class="cpub-form-label">Your Data</label>
90
+ <span class="cpub-form-label">Your Data</span>
91
91
  <p class="cpub-form-hint cpub-mb-2">Download a copy of all your data in JSON format.</p>
92
92
  <a href="/api/auth/export-data" download class="cpub-btn cpub-btn-sm">
93
93
  <i class="fa-solid fa-download"></i> Download My Data
@@ -104,8 +104,8 @@ async function handleDeleteAccount(): Promise<void> {
104
104
 
105
105
  <template v-if="deleteConfirm">
106
106
  <div class="cpub-form-group">
107
- <label class="cpub-form-label">Type your username <strong>{{ user?.username ?? '' }}</strong> to confirm</label>
108
- <input v-model="deleteConfirmText" type="text" class="cpub-input" :placeholder="user?.username" autocomplete="off" />
107
+ <label for="acct-delete-confirm" class="cpub-form-label">Type your username <strong>{{ user?.username ?? '' }}</strong> to confirm</label>
108
+ <input id="acct-delete-confirm" v-model="deleteConfirmText" type="text" class="cpub-input" :placeholder="user?.username" autocomplete="off" />
109
109
  </div>
110
110
  <div class="cpub-danger-actions">
111
111
  <button
@@ -5,6 +5,7 @@ useSeoMeta({ title: `Edit Profile, ${useSiteName()}` });
5
5
  const { user } = useAuth();
6
6
  const toast = useToast();
7
7
  const { extract: extractError } = useApiError();
8
+ const { uploadFile } = useFileUpload();
8
9
  const saving = ref(false);
9
10
  const isDirty = ref(false);
10
11
 
@@ -30,7 +31,14 @@ const form = ref({
30
31
  bannerUrl: '',
31
32
  });
32
33
 
34
+ // Stable row ids so v-for keys survive splice-removal (keying by array index
35
+ // rebinds v-model to the wrong row after deleting an entry).
36
+ let rowIdCounter = 0;
37
+ function nextRowId(): string { return `row-${rowIdCounter++}`; }
38
+
33
39
  const skills = ref<string[]>([]);
40
+ // Parallel id list kept in lockstep with `skills` for stable v-for keys.
41
+ const skillIds = ref<string[]>([]);
34
42
  const socialLinks = ref({
35
43
  github: '',
36
44
  twitter: '',
@@ -41,7 +49,7 @@ const socialLinks = ref({
41
49
  discord: '',
42
50
  });
43
51
  const pronouns = ref('');
44
- const experience = ref<Array<{ title: string; company: string; startDate: string; endDate: string; description: string }>>([]);
52
+ const experience = ref<Array<{ _id: string; title: string; company: string; startDate: string; endDate: string; description: string }>>([]);
45
53
 
46
54
  const emailNotifications = ref<{
47
55
  digest: 'daily' | 'weekly' | 'none';
@@ -80,6 +88,7 @@ if (profile.value) {
80
88
 
81
89
  if (Array.isArray(p.skills)) {
82
90
  skills.value = (p.skills as unknown[]).filter((s): s is string => typeof s === 'string');
91
+ skillIds.value = skills.value.map(() => nextRowId());
83
92
  }
84
93
  pronouns.value = p.pronouns || '';
85
94
  if (p.socialLinks) {
@@ -95,6 +104,7 @@ if (profile.value) {
95
104
  const profileRecord = p as Record<string, unknown>;
96
105
  if (Array.isArray(profileRecord.experience)) {
97
106
  experience.value = (profileRecord.experience as Array<Record<string, unknown>>).map((e) => ({
107
+ _id: nextRowId(),
98
108
  title: String(e.title || ''),
99
109
  company: String(e.company || ''),
100
110
  startDate: String(e.startDate || ''),
@@ -123,14 +133,17 @@ onMounted(() => {
123
133
 
124
134
  function addSkill(): void {
125
135
  skills.value.push('');
136
+ skillIds.value.push(nextRowId());
126
137
  }
127
138
 
128
139
  function removeSkill(index: number): void {
129
140
  skills.value.splice(index, 1);
141
+ skillIds.value.splice(index, 1);
130
142
  }
131
143
 
132
144
  function addExperience(): void {
133
145
  experience.value.push({
146
+ _id: nextRowId(),
134
147
  title: '',
135
148
  company: '',
136
149
  startDate: '',
@@ -146,11 +159,8 @@ function removeExperience(index: number): void {
146
159
  async function handleAvatarUpload(event: Event): Promise<void> {
147
160
  const file = (event.target as HTMLInputElement).files?.[0];
148
161
  if (!file) return;
149
- const formData = new FormData();
150
- formData.append('file', file);
151
- formData.append('purpose', 'avatar');
152
162
  try {
153
- const result = await $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData });
163
+ const result = await uploadFile(file, 'avatar');
154
164
  form.value.avatarUrl = result.url;
155
165
  } catch (err: unknown) {
156
166
  toast.error(extractError(err));
@@ -160,11 +170,8 @@ async function handleAvatarUpload(event: Event): Promise<void> {
160
170
  async function handleBannerUpload(event: Event): Promise<void> {
161
171
  const file = (event.target as HTMLInputElement).files?.[0];
162
172
  if (!file) return;
163
- const formData = new FormData();
164
- formData.append('file', file);
165
- formData.append('purpose', 'banner');
166
173
  try {
167
- const result = await $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData });
174
+ const result = await uploadFile(file, 'banner');
168
175
  form.value.bannerUrl = result.url;
169
176
  } catch (err: unknown) {
170
177
  toast.error(extractError(err));
@@ -179,7 +186,9 @@ async function handleSave(): Promise<void> {
179
186
  body: {
180
187
  ...form.value,
181
188
  skills: skills.value.filter((s) => s.trim()),
182
- experience: experience.value.filter((e) => e.title.trim()),
189
+ experience: experience.value
190
+ .filter((e) => e.title.trim())
191
+ .map(({ _id, ...rest }) => rest),
183
192
  pronouns: pronouns.value || undefined,
184
193
  socialLinks: socialLinks.value,
185
194
  ...(emailNotificationsEnabled.value ? { emailNotifications: emailNotifications.value } : {}),
@@ -206,7 +215,7 @@ async function handleSave(): Promise<void> {
206
215
 
207
216
  <!-- Banner upload -->
208
217
  <div class="cpub-form-group">
209
- <label class="cpub-form-label">Banner Image</label>
218
+ <span class="cpub-form-label">Banner Image</span>
210
219
  <button
211
220
  type="button"
212
221
  class="cpub-banner-upload"
@@ -236,7 +245,7 @@ async function handleSave(): Promise<void> {
236
245
 
237
246
  <!-- Avatar upload -->
238
247
  <div class="cpub-form-group">
239
- <label class="cpub-form-label">Avatar</label>
248
+ <span class="cpub-form-label">Avatar</span>
240
249
  <button
241
250
  type="button"
242
251
  class="cpub-avatar-upload"
@@ -361,7 +370,7 @@ async function handleSave(): Promise<void> {
361
370
 
362
371
  <div
363
372
  v-for="(_skill, index) in skills"
364
- :key="index"
373
+ :key="skillIds[index]"
365
374
  class="cpub-skill-row"
366
375
  >
367
376
  <input
@@ -456,7 +465,7 @@ async function handleSave(): Promise<void> {
456
465
 
457
466
  <div
458
467
  v-for="(entry, index) in experience"
459
- :key="index"
468
+ :key="entry._id"
460
469
  class="cpub-experience-card"
461
470
  >
462
471
  <div class="cpub-experience-header">
@@ -121,11 +121,9 @@ const { errors: publishErrors, showErrors: showPublishErrors, validate, dismiss:
121
121
  });
122
122
 
123
123
  // --- Provide upload + search handlers to block components via inject ---
124
+ const { uploadFile } = useFileUpload();
124
125
  provide(UPLOAD_HANDLER_KEY, async (file: File) => {
125
- const formData = new FormData();
126
- formData.append('file', file);
127
- formData.append('purpose', 'content');
128
- const res = await $fetch<{ url: string; width?: number | null; height?: number | null }>('/api/files/upload', { method: 'POST', body: formData });
126
+ const res = await uploadFile<{ url: string; width?: number | null; height?: number | null }>(file, 'content');
129
127
  return { url: res.url, width: res.width ?? null, height: res.height ?? null };
130
128
  });
131
129
 
@@ -306,7 +304,7 @@ async function handlePublish(): Promise<void> {
306
304
  }
307
305
 
308
306
  // --- Schedule (deferred publish) ---
309
- // metadata.scheduledAt is set by the editor's schedule control (e.g. BlogEditor).
307
+ // metadata.scheduledAt is set by the editor's schedule control (e.g. ArticleEditor).
310
308
  const canSchedule = computed(() => !!(metadata.value.scheduledAt as string | undefined));
311
309
  async function handleSchedule(): Promise<void> {
312
310
  await doSchedule(validate);
@@ -345,6 +343,15 @@ if (import.meta.client) {
345
343
  onUnmounted(() => { window.removeEventListener('beforeunload', onBeforeUnload); });
346
344
  }
347
345
 
346
+ // --- Warn before in-app navigation (beforeunload only covers full-page unload) ---
347
+ onBeforeRouteLeave((_to, _from, next) => {
348
+ if (isDirty.value && !window.confirm('You have unsaved changes. Leave anyway?')) {
349
+ next(false);
350
+ } else {
351
+ next();
352
+ }
353
+ });
354
+
348
355
  // --- Markdown import ---
349
356
  const showImportDialog = ref(false);
350
357
  const { importing, importMarkdown } = useMarkdownImport(blockEditor);
@@ -4,12 +4,20 @@ const username = route.params.username as string;
4
4
 
5
5
  useSeoMeta({ title: `Followers, @${username}, ${useSiteName()}` });
6
6
 
7
- const { data: followers } = useLazyFetch<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>(`/api/users/${username}/followers`);
7
+ type FollowRow = { id: string; username: string; displayName: string | null; avatarUrl: string | null; isFollowing?: boolean };
8
+ const { data: followers } = useLazyFetch<{ items: FollowRow[]; total: number }>(`/api/users/${username}/followers`);
8
9
  const { isAuthenticated, user } = useAuth();
9
10
  const toast = useToast();
10
11
 
11
12
  const followingState = ref<Record<string, boolean>>({});
12
13
 
14
+ // Seed each row's button from the VIEWER's real relationship (server-provided).
15
+ watch(followers, (d) => {
16
+ for (const f of d?.items ?? []) {
17
+ followingState.value[f.username] = !!f.isFollowing;
18
+ }
19
+ }, { immediate: true });
20
+
13
21
  async function toggleFollow(targetUsername: string, isFollowing: boolean): Promise<void> {
14
22
  try {
15
23
  if (isFollowing) {
@@ -32,8 +40,8 @@ async function toggleFollow(targetUsername: string, isFollowing: boolean): Promi
32
40
  <h1 class="follow-title">Followers</h1>
33
41
  </div>
34
42
 
35
- <div v-if="followers?.length" class="follow-list">
36
- <div v-for="f in (followers as Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>)" :key="f.id" class="follow-item">
43
+ <div v-if="followers?.items?.length" class="follow-list">
44
+ <div v-for="f in followers.items" :key="f.id" class="follow-item">
37
45
  <NuxtLink :to="`/u/${f.username}`" class="follow-user">
38
46
  <div class="follow-avatar">
39
47
  <img v-if="f.avatarUrl" :src="f.avatarUrl" :alt="f.displayName || f.username" class="follow-avatar-img" />
@@ -4,17 +4,19 @@ const username = route.params.username as string;
4
4
 
5
5
  useSeoMeta({ title: `Following, @${username}, ${useSiteName()}` });
6
6
 
7
- const { data: following } = useLazyFetch<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>(`/api/users/${username}/following`);
7
+ type FollowRow = { id: string; username: string; displayName: string | null; avatarUrl: string | null; isFollowing?: boolean };
8
+ const { data: following } = useLazyFetch<{ items: FollowRow[]; total: number }>(`/api/users/${username}/following`);
8
9
  const { isAuthenticated, user } = useAuth();
9
10
  const toast = useToast();
10
11
 
11
12
  const followingState = ref<Record<string, boolean>>({});
12
13
 
13
- // Initialize all as "following" since they appear in this user's following list
14
- watch(following, (f) => {
15
- if (!f) return;
16
- for (const u of (f as Array<{ username: string }>)) {
17
- followingState.value[u.username] = true;
14
+ // Seed each row from the VIEWER's real relationship. (This is the profile owner's
15
+ // following list whether the VIEWER also follows each account is server-provided
16
+ // per row, NOT implied by membership in this list.)
17
+ watch(following, (d) => {
18
+ for (const u of d?.items ?? []) {
19
+ followingState.value[u.username] = !!u.isFollowing;
18
20
  }
19
21
  }, { immediate: true });
20
22
 
@@ -40,8 +42,8 @@ async function toggleFollow(targetUsername: string, isFollowing: boolean): Promi
40
42
  <h1 class="follow-title">Following</h1>
41
43
  </div>
42
44
 
43
- <div v-if="following?.length" class="follow-list">
44
- <div v-for="f in (following as Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>)" :key="f.id" class="follow-item">
45
+ <div v-if="following?.items?.length" class="follow-list">
46
+ <div v-for="f in following.items" :key="f.id" class="follow-item">
45
47
  <NuxtLink :to="`/u/${f.username}`" class="follow-user">
46
48
  <div class="follow-avatar">
47
49
  <img v-if="f.avatarUrl" :src="f.avatarUrl" :alt="f.displayName || f.username" class="follow-avatar-img" />
@@ -41,6 +41,11 @@ const tabDefs = computed(() => {
41
41
  if (learningEnabled.value) {
42
42
  tabs.push({ value: 'learning', label: 'Learning', icon: 'fa-solid fa-graduation-cap' });
43
43
  }
44
+ // Owner-only: their unpublished work. Draft visibility is also enforced
45
+ // server-side from the authenticated viewer, so this tab is the surface only.
46
+ if (isOwnProfile.value) {
47
+ tabs.push({ value: 'drafts', label: 'Drafts', icon: 'fa-solid fa-file-pen' });
48
+ }
44
49
  tabs.push({ value: 'about', label: 'About', icon: 'fa-solid fa-id-card' });
45
50
  return tabs;
46
51
  });
@@ -97,6 +102,34 @@ const isOwnProfile = computed(() => user.value?.username === username);
97
102
  const following = ref(false);
98
103
  const followLoading = ref(false);
99
104
 
105
+ // Per-tab paginated content (projects/articles/explainers/drafts). The single
106
+ // `content` fetch above still feeds the heatmap + About-tab teaser; these tabs
107
+ // get their own keyset-paginated list with a Load more button.
108
+ const FEED_TAB_TYPE: Record<string, string | undefined> = {
109
+ projects: 'project',
110
+ articles: 'blog', // the 'blog' filter also matches legacy 'article' rows server-side
111
+ explainers: 'explainer',
112
+ drafts: undefined, // the owner's drafts of any type
113
+ };
114
+ const FEED_TABS = ['projects', 'articles', 'explainers', 'drafts'];
115
+ const isFeedTab = computed(() => FEED_TABS.includes(activeTab.value));
116
+ const feedQuery = computed(() => {
117
+ const isDrafts = activeTab.value === 'drafts';
118
+ const type = FEED_TAB_TYPE[activeTab.value];
119
+ return {
120
+ ...(type ? { type } : {}),
121
+ ...(isDrafts ? { drafts: 'true' } : {}),
122
+ limit: 12,
123
+ };
124
+ });
125
+ const {
126
+ items: tabItems,
127
+ pending: tabPending,
128
+ loadMore: loadMoreTab,
129
+ canLoadMore: canLoadMoreTab,
130
+ loadingMore: loadingMoreTab,
131
+ } = useProfileContent(username, feedQuery);
132
+
100
133
  // Initialize follow state from API response
101
134
  watch(() => profile.value, (profileData) => {
102
135
  if (profileData && typeof (profileData as Record<string, unknown>).isFollowing === 'boolean') {
@@ -277,8 +310,8 @@ async function handleReport(): Promise<void> {
277
310
 
278
311
  <!-- Content -->
279
312
  <div class="cpub-profile-main">
280
- <!-- Projects / Articles / Explainers / Videos tabs -->
281
- <template v-if="['projects', 'articles', 'explainers'].includes(activeTab)">
313
+ <!-- Projects / Articles / Explainers / Drafts tabs -->
314
+ <template v-if="isFeedTab">
282
315
  <!-- Section header -->
283
316
  <div class="cpub-sec-head">
284
317
  <h2>
@@ -287,12 +320,23 @@ async function handleReport(): Promise<void> {
287
320
  </h2>
288
321
  </div>
289
322
 
290
- <div v-if="filteredContent.length" class="cpub-grid-3">
291
- <ContentCard v-for="item in filteredContent" :key="item.id" :item="item" />
292
- </div>
293
- <div v-else class="cpub-empty-state">
294
- <p class="cpub-empty-state-title">No {{ activeTab }} yet</p>
323
+ <div v-if="tabPending && !tabItems.length" class="cpub-empty-state">
324
+ <p class="cpub-empty-state-title">Loading...</p>
295
325
  </div>
326
+ <template v-else>
327
+ <div v-if="tabItems.length" class="cpub-grid-3">
328
+ <ContentCard v-for="item in tabItems" :key="item.id" :item="item" />
329
+ </div>
330
+ <div v-else class="cpub-empty-state">
331
+ <p class="cpub-empty-state-title">{{ activeTab === 'drafts' ? 'No drafts yet' : `No ${activeTab} yet` }}</p>
332
+ </div>
333
+ <div v-if="canLoadMoreTab" class="cpub-load-more-row">
334
+ <button class="cpub-btn-load-more" :disabled="loadingMoreTab" @click="loadMoreTab">
335
+ <i :class="loadingMoreTab ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-rotate'"></i>
336
+ {{ loadingMoreTab ? 'Loading...' : 'Load more' }}
337
+ </button>
338
+ </div>
339
+ </template>
296
340
  </template>
297
341
 
298
342
  <!-- Learning tab — Certificates + In-progress paths -->
@@ -894,4 +938,26 @@ async function handleReport(): Promise<void> {
894
938
  .cpub-profile-stat { min-width: 100%; padding: 10px 16px; }
895
939
  .cpub-profile-name { font-size: 18px; }
896
940
  }
941
+
942
+ .cpub-load-more-row { display: flex; justify-content: center; padding: 20px 0 4px; }
943
+ .cpub-btn-load-more {
944
+ padding: 8px 28px;
945
+ background: var(--surface);
946
+ border: var(--border-width-default) solid var(--border);
947
+ color: var(--text-dim);
948
+ font-size: 12px;
949
+ font-family: var(--font-mono);
950
+ display: flex;
951
+ align-items: center;
952
+ gap: 8px;
953
+ transition: all 0.15s;
954
+ box-shadow: var(--shadow-sm);
955
+ cursor: pointer;
956
+ }
957
+ .cpub-btn-load-more:hover {
958
+ background: var(--surface2);
959
+ color: var(--text);
960
+ box-shadow: var(--shadow-md);
961
+ transform: translate(-1px, -1px);
962
+ }
897
963
  </style>
@@ -22,6 +22,9 @@ const { data: videosData, pending: loadingVideos } = useFetch<{ items: any[]; to
22
22
  });
23
23
 
24
24
  const videos = computed(() => videosData.value?.items ?? []);
25
+ // The first video is shown in the Featured hero, so the grid below skips it
26
+ // (otherwise the most-recent video renders twice).
27
+ const gridVideos = computed(() => videos.value.slice(1));
25
28
  const totalVideos = computed(() => videosData.value?.total ?? 0);
26
29
 
27
30
  const filterOptions = computed(() => {
@@ -75,8 +78,7 @@ function formatDate(dateStr: string): string {
75
78
  <select v-model="sortOption" class="cpub-sort-select">
76
79
  <option value="recent">Sort: Most Recent</option>
77
80
  <option value="viewed">Sort: Most Viewed</option>
78
- <option value="rated">Sort: Top Rated</option>
79
- <option value="shortest">Sort: Shortest First</option>
81
+ <option value="liked">Sort: Most Liked</option>
80
82
  </select>
81
83
  </div>
82
84
  </div>
@@ -113,10 +115,10 @@ function formatDate(dateStr: string): string {
113
115
  </div>
114
116
  </div>
115
117
 
116
- <hr v-if="videos.length" class="cpub-divider" style="margin:20px 0;" />
118
+ <hr v-if="gridVideos.length" class="cpub-divider" style="margin:20px 0;" />
117
119
 
118
- <!-- VIDEO GRID -->
119
- <div class="cpub-sec-head">
120
+ <!-- VIDEO GRID — shown when there are non-featured videos, or while loading -->
121
+ <div v-if="loadingVideos || gridVideos.length" class="cpub-sec-head">
120
122
  <h2>Recent Videos</h2>
121
123
  <span class="cpub-sec-sub">{{ totalVideos }} videos</span>
122
124
  </div>
@@ -134,15 +136,16 @@ function formatDate(dateStr: string): string {
134
136
  </div>
135
137
  </div>
136
138
 
137
- <!-- Real data -->
138
- <div v-else-if="videos.length" class="cpub-video-grid">
139
- <NuxtLink v-for="v in videos" :key="v.id" :to="`/videos/${v.id}`" style="text-decoration: none;">
139
+ <!-- Real data (skip videos[0] — it's the Featured hero above) -->
140
+ <div v-else-if="gridVideos.length" class="cpub-video-grid">
141
+ <NuxtLink v-for="v in gridVideos" :key="v.id" :to="`/videos/${v.id}`" style="text-decoration: none;">
140
142
  <VideoCard :video="v" />
141
143
  </NuxtLink>
142
144
  </div>
143
145
 
144
- <!-- Empty state -->
145
- <div v-else class="cpub-empty-state">
146
+ <!-- Empty state — only when there are genuinely no videos (not just an
147
+ empty grid because the sole video is in the Featured hero above). -->
148
+ <div v-else-if="!videos.length" class="cpub-empty-state">
146
149
  <div class="cpub-empty-icon"><i class="fa-solid fa-film"></i></div>
147
150
  <p class="cpub-empty-title">No videos yet</p>
148
151
  <p class="cpub-empty-sub">Be the first to upload a video to the community.</p>
@@ -6,9 +6,9 @@ const querySchema = z.object({
6
6
  });
7
7
 
8
8
  export default defineEventHandler(async (event) => {
9
+ requireFeature('publicApi');
9
10
  requirePermission(event, 'apikeys.manage');
10
- const id = getRouterParam(event, 'id');
11
- if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
11
+ const { id } = parseParams(event, { id: 'uuid' });
12
12
  const parsed = querySchema.safeParse(getQuery(event));
13
13
  if (!parsed.success) {
14
14
  throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters' });
@@ -1,9 +1,9 @@
1
1
  import { revokeApiKey, createAuditEntry } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event) => {
4
+ requireFeature('publicApi');
4
5
  const user = requirePermission(event, 'apikeys.manage');
5
- const id = getRouterParam(event, 'id');
6
- if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
6
+ const { id } = parseParams(event, { id: 'uuid' });
7
7
  const db = useDB();
8
8
  const result = await revokeApiKey(db, id, user.id);
9
9
  if (!result) throw createError({ statusCode: 404, statusMessage: 'Key not found or already revoked' });
@@ -1,6 +1,7 @@
1
1
  import { listApiKeys } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event) => {
4
+ requireFeature('publicApi');
4
5
  requirePermission(event, 'apikeys.manage');
5
6
  const query = getQuery(event);
6
7
  const includeRevoked = query.includeRevoked === 'true' || query.includeRevoked === '1';
@@ -14,6 +14,7 @@ import { createApiKeySchema } from '@commonpub/schema';
14
14
  * land in the metadata column.
15
15
  */
16
16
  export default defineEventHandler(async (event) => {
17
+ requireFeature('publicApi');
17
18
  const user = requirePermission(event, 'apikeys.manage');
18
19
  const body = await readBody(event);
19
20
  const parsed = createApiKeySchema.safeParse(body);