@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
@@ -71,6 +71,15 @@ function handleGlobalKeydown(e: KeyboardEvent): void {
71
71
  }
72
72
  }
73
73
 
74
+ const avatarBtnRef = ref<HTMLButtonElement | null>(null);
75
+
76
+ // Disclosure menu: Esc closes and returns focus to the trigger.
77
+ function closeUserMenu(): void {
78
+ if (!userMenuOpen.value) return;
79
+ userMenuOpen.value = false;
80
+ avatarBtnRef.value?.focus();
81
+ }
82
+
74
83
  // Close menus on click outside
75
84
  function handleClickOutside(e: MouseEvent): void {
76
85
  const target = e.target as HTMLElement;
@@ -162,32 +171,32 @@ const userUsername = computed(() => user.value?.username ?? '');
162
171
  <i class="fa-solid fa-plus"></i> <span class="cpub-new-text">New</span>
163
172
  </NuxtLink>
164
173
  <div class="cpub-user-menu-wrapper">
165
- <button class="cpub-avatar-btn" aria-label="User menu" :aria-expanded="userMenuOpen" @click.stop="userMenuOpen = !userMenuOpen">
174
+ <button ref="avatarBtnRef" class="cpub-avatar-btn" aria-label="User menu" :aria-expanded="userMenuOpen" @click.stop="userMenuOpen = !userMenuOpen" @keydown.esc="closeUserMenu">
166
175
  <span class="cpub-user-avatar">
167
176
  <img v-if="userImage" :src="userImage" :alt="user?.name || user?.username" class="cpub-user-avatar-img" />
168
177
  <span v-else>{{ userInitial }}</span>
169
178
  </span>
170
179
  </button>
171
- <div v-if="userMenuOpen" class="cpub-user-dropdown" role="menu">
180
+ <div v-if="userMenuOpen" class="cpub-user-dropdown" @keydown.esc="closeUserMenu">
172
181
  <!-- Mobile-only: messages/notifications relocated here from
173
182
  the top bar (hidden on desktop, which keeps the icons). -->
174
- <NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
183
+ <NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile" @click="userMenuOpen = false">
175
184
  <i class="fa-solid fa-envelope"></i> Messages
176
185
  <span v-if="unreadMessages > 0" class="cpub-dropdown-count">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
177
186
  </NuxtLink>
178
- <NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
187
+ <NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile" @click="userMenuOpen = false">
179
188
  <i class="fa-solid fa-bell"></i> Notifications
180
189
  <span v-if="unreadCount > 0" class="cpub-dropdown-count">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
181
190
  </NuxtLink>
182
191
  <div class="cpub-dropdown-divider cpub-dropdown-item--mobile" />
183
- <NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-user"></i> Profile</NuxtLink>
184
- <NuxtLink to="/dashboard" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
185
- <NuxtLink to="/settings" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
186
- <button class="cpub-dropdown-item" role="menuitem" @click="setDarkMode(!isDark)">
192
+ <NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-user"></i> Profile</NuxtLink>
193
+ <NuxtLink to="/dashboard" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
194
+ <NuxtLink to="/settings" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
195
+ <button class="cpub-dropdown-item" @click="setDarkMode(!isDark)">
187
196
  <i :class="isDark ? 'fa-solid fa-sun' : 'fa-solid fa-moon'"></i> {{ isDark ? 'Light mode' : 'Dark mode' }}
188
197
  </button>
189
198
  <div class="cpub-dropdown-divider" />
190
- <button class="cpub-dropdown-item" role="menuitem" @click="handleSignOut"><i class="fa-solid fa-right-from-bracket"></i> Sign out</button>
199
+ <button class="cpub-dropdown-item" @click="handleSignOut"><i class="fa-solid fa-right-from-bracket"></i> Sign out</button>
191
200
  </div>
192
201
  </div>
193
202
  </template>
package/nuxt.config.ts CHANGED
@@ -100,6 +100,8 @@ export default defineNuxtConfig({
100
100
  video: true,
101
101
  contests: false,
102
102
  contestStageSubmissions: true,
103
+ contestProposals: false,
104
+ contestPii: false,
103
105
  events: false,
104
106
  learning: true,
105
107
  explainers: true,
@@ -117,6 +119,17 @@ export default defineNuxtConfig({
117
119
  actAsRegistry: false,
118
120
  announceToRegistry: true,
119
121
  publicApiMetricsFederation: false,
122
+ // Nested identity sub-flags must be declared here too, or
123
+ // NUXT_PUBLIC_FEATURES_IDENTITY_* env overrides silently drop (same
124
+ // rule as the booleans above). Mirrors @commonpub/config's
125
+ // IdentityFeatures; all default false.
126
+ identity: {
127
+ linkRemoteAccounts: false,
128
+ signInWithRemote: false,
129
+ actingAs: false,
130
+ remoteInteract: false,
131
+ remotePublish: false,
132
+ },
120
133
  },
121
134
  contentTypes: 'project,blog,explainer',
122
135
  contestCreation: 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.82.0",
3
+ "version": "0.83.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -55,16 +55,16 @@
55
55
  "vue-router": "^4.3.0",
56
56
  "zod": "^4.3.6",
57
57
  "@commonpub/auth": "0.8.0",
58
- "@commonpub/config": "0.22.1",
59
- "@commonpub/editor": "0.7.12",
58
+ "@commonpub/config": "0.23.0",
59
+ "@commonpub/editor": "0.8.0",
60
+ "@commonpub/explainer": "0.8.0",
61
+ "@commonpub/docs": "0.6.3",
62
+ "@commonpub/protocol": "0.14.0",
60
63
  "@commonpub/learning": "0.5.2",
61
- "@commonpub/protocol": "0.13.0",
62
- "@commonpub/server": "2.89.0",
64
+ "@commonpub/schema": "0.46.0",
65
+ "@commonpub/server": "2.90.0",
63
66
  "@commonpub/theme-studio": "0.6.1",
64
- "@commonpub/docs": "0.6.3",
65
- "@commonpub/ui": "0.13.1",
66
- "@commonpub/explainer": "0.7.15",
67
- "@commonpub/schema": "0.45.0"
67
+ "@commonpub/ui": "0.13.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@testing-library/jest-dom": "^6.9.1",
@@ -23,7 +23,7 @@ useSeoMeta({
23
23
  const sortBy = ref('recent');
24
24
  const sortOptions = ['recent', 'popular'] as const;
25
25
 
26
- const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
26
+ const { data, pending, error, refresh } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
27
27
  query: computed(() => ({
28
28
  status: 'published',
29
29
  type: contentType.value,
@@ -48,6 +48,11 @@ const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentLis
48
48
  </div>
49
49
 
50
50
  <p v-if="pending" class="cpub-listing-empty"><i class="fa-solid fa-circle-notch fa-spin"></i> Loading...</p>
51
+ <div v-else-if="error" class="cpub-fetch-error">
52
+ <div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
53
+ <div class="cpub-fetch-error-msg">Failed to load {{ contentType }}s.</div>
54
+ <button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
55
+ </div>
51
56
  <div v-else-if="data?.items?.length" class="cpub-listing-grid">
52
57
  <ContentCard v-for="item in data.items" :key="item.id" :item="item" />
53
58
  </div>
@@ -6,6 +6,11 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
6
6
 
7
7
  useSeoMeta({ title: `API Keys, Admin, ${useSiteName()}` });
8
8
 
9
+ // Keys only authenticate when the Public API feature is on (public-api-auth 404s
10
+ // every /api/public/* route otherwise). Surface that so admins don't mint dead keys.
11
+ const { publicApi } = useFeatures();
12
+ const toast = useToast();
13
+
9
14
  interface KeyListResponse {
10
15
  items: AdminApiKeyView[];
11
16
  total: number;
@@ -122,8 +127,9 @@ async function revoke(id: string, name: string): Promise<void> {
122
127
  try {
123
128
  await $fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE' });
124
129
  await refresh();
130
+ toast.success(`Revoked "${name}"`);
125
131
  } catch {
126
- // toast would go here
132
+ toast.error('Failed to revoke key');
127
133
  }
128
134
  }
129
135
 
@@ -185,12 +191,16 @@ function fmtErrorRate(rate: number): string {
185
191
  <input type="checkbox" v-model="includeRevoked" @change="refresh()" />
186
192
  Show revoked
187
193
  </label>
188
- <button class="cpub-btn cpub-btn-primary" @click="showCreate = true">
194
+ <button v-if="publicApi" class="cpub-btn cpub-btn-primary" @click="showCreate = true">
189
195
  <i class="fa-solid fa-plus"></i> New key
190
196
  </button>
191
197
  </div>
192
198
  </header>
193
199
 
200
+ <p v-if="!publicApi" class="cpub-admin-sub" role="status">
201
+ The Public API feature is disabled, so keys created here will not authenticate. Turn it on in Features to use API keys.
202
+ </p>
203
+
194
204
  <!-- One-time token reveal -->
195
205
  <div v-if="createdKey" class="cpub-key-reveal" role="alert">
196
206
  <div class="cpub-key-reveal-head">
@@ -298,7 +308,7 @@ function fmtErrorRate(rate: number): string {
298
308
 
299
309
  <!-- List -->
300
310
  <div v-if="pending" class="cpub-loading">Loading keys...</div>
301
- <p v-else-if="listError" class="cpub-form-error">Failed to load keys.</p>
311
+ <p v-else-if="publicApi && listError" class="cpub-form-error">Failed to load keys.</p>
302
312
  <p v-else-if="!data?.items?.length" class="cpub-empty">
303
313
  No API keys yet. Create one to start consuming <code>/api/public/v1/*</code>.
304
314
  </p>
@@ -25,6 +25,8 @@ const flagMeta: Record<string, { label: string; description: string; icon: strin
25
25
  video: { label: 'Video', description: 'Video content and categories', icon: 'fa-solid fa-video' },
26
26
  contests: { label: 'Contests', description: 'Contest system with judging', icon: 'fa-solid fa-trophy' },
27
27
  contestStageSubmissions: { label: 'Contest Stage Submissions', description: 'Per-stage submission forms for multi-round contests', icon: 'fa-solid fa-file-pen' },
28
+ contestProposals: { label: 'Contest Proposals', description: 'Form-first proposal entries with a draft placeholder project', icon: 'fa-solid fa-clipboard-list' },
29
+ contestPii: { label: 'Contest PII Fields', description: 'Offer personal-data fields (email, address) in submission forms', icon: 'fa-solid fa-user-shield' },
28
30
  learning: { label: 'Learning', description: 'Learning paths and courses', icon: 'fa-solid fa-graduation-cap' },
29
31
  explainers: { label: 'Explainers', description: 'Interactive explainer modules', icon: 'fa-solid fa-lightbulb' },
30
32
  editorial: { label: 'Editorial', description: 'Staff picks and content categories', icon: 'fa-solid fa-pen-fancy' },
@@ -57,7 +57,7 @@ async function removeTrusted(domain: string): Promise<void> {
57
57
  });
58
58
  await refreshTrusted();
59
59
  } catch {
60
- alert('Failed to remove trusted instance');
60
+ toast.error('Failed to remove trusted instance');
61
61
  }
62
62
  }
63
63
 
@@ -17,11 +17,13 @@
17
17
  import type { LayoutRecord } from '@commonpub/server';
18
18
  import { PublishStepError } from '../../../composables/useLayoutEditor';
19
19
  import { useLayoutAnnouncer, narrateUndo, narrateRedo, narrateUndoEmpty, narrateRedoEmpty, narrateRowAdded, narrateRowRemoved } from '../../../composables/useLayoutAnnouncer';
20
- import { useLayoutHistory, addRowCommand, removeRowCommand } from '../../../composables/useLayoutHistory';
20
+ import { useLayoutHistory, addRowCommand, removeRowCommand, insertSectionCommand } from '../../../composables/useLayoutHistory';
21
21
  import { useLayoutHotkeys } from '../../../composables/useLayoutHotkeys';
22
22
  import { DnDProvider } from '@vue-dnd-kit/core';
23
23
  import { useSectionRegistry } from '../../../sections/registry';
24
24
  import { useLayoutResize } from '../../../composables/useLayoutResize';
25
+ import { createSectionFromSpec, paletteDragPayload } from '../../../composables/useLayoutDrag';
26
+ import type { SectionDefinition } from '@commonpub/ui';
25
27
 
26
28
  definePageMeta({
27
29
  layout: 'admin',
@@ -163,6 +165,32 @@ function onAddRow(zoneSlug: string): void {
163
165
  }));
164
166
  }
165
167
 
168
+ /**
169
+ * Keyboard-insert from the palette — WCAG 2.1.1 alternative to drag. Mirrors the
170
+ * drop path (createSectionFromSpec + push); appends to the last row of the first
171
+ * populated zone, adding a row if the layout has none. Direct mutation fires the
172
+ * dirty watcher + autosave, exactly like a drop.
173
+ */
174
+ function onPaletteInsert(section: SectionDefinition): void {
175
+ const draft = editor.draft.value;
176
+ if (!draft || draft.zones.length === 0) return;
177
+ const zone = draft.zones.find((z) => z.rows.length > 0) ?? draft.zones[0]!;
178
+ let row = zone.rows[zone.rows.length - 1];
179
+ // Record each mutation to history so Cmd+Z reverses it and the undo stack
180
+ // stays in sync with the draft (mirrors onAddRow + the LayoutRow drop path).
181
+ if (!row) {
182
+ const position = zone.rows.length;
183
+ row = { id: crypto.randomUUID(), order: position, config: null, sections: [] };
184
+ zone.rows.push(row);
185
+ history.record(addRowCommand({ zoneSlug: zone.zone, position, row, label: `add row to ${zone.zone}` }));
186
+ }
187
+ const newSection = createSectionFromSpec(paletteDragPayload(section));
188
+ const at = row.sections.length;
189
+ row.sections.push(newSection);
190
+ history.record(insertSectionCommand({ rowId: row.id, at, section: newSection, label: `insert ${section.type}` }));
191
+ useLayoutAnnouncer().announce(`Inserted ${section.name} section into ${zone.zone}`);
192
+ }
193
+
166
194
  /**
167
195
  * Session 164 polish — Remove row. Confirm before removing rows with
168
196
  * sections (destructive intent: section data goes away, only restorable
@@ -702,7 +730,7 @@ async function onConflictForceSave(): Promise<void> {
702
730
  :on-add-row="onAddRow"
703
731
  :on-remove-row="onRemoveRow"
704
732
  />
705
- <AdminLayoutsPalette v-show="!chrome.paletteHidden.value" />
733
+ <AdminLayoutsPalette v-show="!chrome.paletteHidden.value" @insert="onPaletteInsert" />
706
734
  <AdminLayoutsInspector
707
735
  v-show="!chrome.inspectorHidden.value"
708
736
  :draft="editor.draft.value"
@@ -4,6 +4,7 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
4
4
  useSeoMeta({ title: `Settings, Admin, ${useSiteName()}` });
5
5
 
6
6
  const { data: settings, pending, refresh } = await useFetch<Record<string, string>>('/api/admin/settings');
7
+ const toast = useToast();
7
8
 
8
9
  const saving = ref(false);
9
10
  const editKey = ref('');
@@ -40,7 +41,7 @@ async function saveSetting(key: string, value: string): Promise<void> {
40
41
  editValue.value = '';
41
42
  await refresh();
42
43
  } catch {
43
- // Error handling via toast if available
44
+ toast.error('Failed to save setting');
44
45
  } finally {
45
46
  saving.value = false;
46
47
  }
@@ -219,7 +219,7 @@ async function deleteUser(userId: string, username: string): Promise<void> {
219
219
  .admin-roles-editor { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 4px 0; }
220
220
  .admin-roles-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
221
221
  .admin-roles-check { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; }
222
- .admin-roles-save { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); background: var(--accent); color: var(--accent-contrast, #fff); cursor: pointer; margin-left: auto; }
222
+ .admin-roles-save { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); background: var(--accent); color: var(--color-on-accent); cursor: pointer; margin-left: auto; }
223
223
  .admin-roles-save:disabled { opacity: 0.6; cursor: default; }
224
224
  .admin-empty { color: var(--text-faint); text-align: center; padding: 32px 0; }
225
225
  </style>
@@ -0,0 +1,203 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
3
+ useSeoMeta({ title: `Video Categories, Admin, ${useSiteName()}` });
4
+
5
+ const toast = useToast();
6
+
7
+ interface VideoCategory {
8
+ id: string;
9
+ name: string;
10
+ slug: string;
11
+ description: string | null;
12
+ sortOrder: number;
13
+ }
14
+
15
+ const { data: categories, refresh } = await useFetch<VideoCategory[]>('/api/videos/categories');
16
+
17
+ const showForm = ref(false);
18
+ const editingId = ref<string | null>(null);
19
+ const form = ref({
20
+ name: '',
21
+ description: '',
22
+ sortOrder: 0,
23
+ });
24
+
25
+ function openNew(): void {
26
+ editingId.value = null;
27
+ form.value = { name: '', description: '', sortOrder: categories.value?.length ?? 0 };
28
+ showForm.value = true;
29
+ }
30
+
31
+ function openEdit(cat: VideoCategory): void {
32
+ editingId.value = cat.id;
33
+ form.value = {
34
+ name: cat.name,
35
+ description: cat.description ?? '',
36
+ sortOrder: cat.sortOrder,
37
+ };
38
+ showForm.value = true;
39
+ }
40
+
41
+ function cancelForm(): void {
42
+ showForm.value = false;
43
+ editingId.value = null;
44
+ }
45
+
46
+ async function saveCategory(): Promise<void> {
47
+ const payload = {
48
+ name: form.value.name,
49
+ description: form.value.description || undefined,
50
+ sortOrder: form.value.sortOrder,
51
+ };
52
+
53
+ try {
54
+ if (editingId.value) {
55
+ await $fetch(`/api/videos/categories/${editingId.value}`, { method: 'PUT', body: payload });
56
+ toast.success('Category updated');
57
+ } else {
58
+ await $fetch('/api/videos/categories', { method: 'POST', body: payload });
59
+ toast.success('Category created');
60
+ }
61
+ showForm.value = false;
62
+ editingId.value = null;
63
+ await refresh();
64
+ } catch {
65
+ toast.error('Failed to save category');
66
+ }
67
+ }
68
+
69
+ async function deleteCategory(cat: VideoCategory): Promise<void> {
70
+ if (!confirm(`Delete "${cat.name}"? Videos using this category will become uncategorized.`)) return;
71
+ try {
72
+ await $fetch(`/api/videos/categories/${cat.id}`, { method: 'DELETE' });
73
+ toast.success('Category deleted');
74
+ await refresh();
75
+ } catch {
76
+ toast.error('Failed to delete category');
77
+ }
78
+ }
79
+ </script>
80
+
81
+ <template>
82
+ <div class="cpub-admin-video-categories">
83
+ <div class="cpub-admin-header">
84
+ <h1 class="cpub-admin-title">Video Categories</h1>
85
+ <button class="cpub-btn cpub-btn-primary cpub-btn-sm" @click="openNew">
86
+ <i class="fa-solid fa-plus"></i> New Category
87
+ </button>
88
+ </div>
89
+
90
+ <!-- Category Form -->
91
+ <div v-if="showForm" class="cpub-cat-form">
92
+ <h2 class="cpub-cat-form-title">{{ editingId ? 'Edit Category' : 'New Category' }}</h2>
93
+ <div class="cpub-cat-form-grid">
94
+ <div class="cpub-cat-field">
95
+ <label for="cpub-vcat-name" class="cpub-cat-label">Name</label>
96
+ <input id="cpub-vcat-name" v-model="form.name" class="cpub-cat-input" placeholder="e.g. Tutorials" />
97
+ </div>
98
+ <div class="cpub-cat-field">
99
+ <label for="cpub-vcat-order" class="cpub-cat-label">Sort Order</label>
100
+ <input id="cpub-vcat-order" v-model.number="form.sortOrder" type="number" class="cpub-cat-input" min="0" />
101
+ </div>
102
+ <div class="cpub-cat-field cpub-cat-field--wide">
103
+ <label for="cpub-vcat-desc" class="cpub-cat-label">Description</label>
104
+ <input id="cpub-vcat-desc" v-model="form.description" class="cpub-cat-input" placeholder="Optional description" />
105
+ </div>
106
+ </div>
107
+ <div class="cpub-cat-form-actions">
108
+ <button class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="!form.name.trim()" @click="saveCategory">
109
+ {{ editingId ? 'Update' : 'Create' }}
110
+ </button>
111
+ <button class="cpub-btn cpub-btn-sm" @click="cancelForm">Cancel</button>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- Categories Table -->
116
+ <div v-if="categories?.length" class="cpub-admin-table-wrap">
117
+ <table class="cpub-admin-table">
118
+ <thead>
119
+ <tr>
120
+ <th>Order</th>
121
+ <th>Name</th>
122
+ <th>Slug</th>
123
+ <th>Description</th>
124
+ <th>Actions</th>
125
+ </tr>
126
+ </thead>
127
+ <tbody>
128
+ <tr v-for="cat in categories" :key="cat.id">
129
+ <td class="cpub-admin-num">{{ cat.sortOrder }}</td>
130
+ <td><span class="cpub-cat-name">{{ cat.name }}</span></td>
131
+ <td class="cpub-admin-slug">{{ cat.slug }}</td>
132
+ <td class="cpub-admin-desc">{{ cat.description }}</td>
133
+ <td class="cpub-admin-actions">
134
+ <button class="cpub-admin-action" title="Edit" @click="openEdit(cat)">
135
+ <i class="fa-solid fa-pencil"></i>
136
+ </button>
137
+ <button class="cpub-admin-action cpub-admin-action--danger" title="Delete" @click="deleteCategory(cat)">
138
+ <i class="fa-solid fa-trash"></i>
139
+ </button>
140
+ </td>
141
+ </tr>
142
+ </tbody>
143
+ </table>
144
+ </div>
145
+ <p v-else class="cpub-empty">No video categories found. Create one to get started.</p>
146
+ </div>
147
+ </template>
148
+
149
+ <style scoped>
150
+ .cpub-admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-6); }
151
+ .cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
152
+
153
+ .cpub-cat-form {
154
+ background: var(--surface);
155
+ border: var(--border-width-default) solid var(--border);
156
+ padding: var(--space-5);
157
+ margin-bottom: var(--space-6);
158
+ }
159
+
160
+ .cpub-cat-form-title { font-size: var(--text-base); font-weight: 600; margin-bottom: var(--space-4); }
161
+
162
+ .cpub-cat-form-grid {
163
+ display: grid;
164
+ grid-template-columns: 1fr 1fr;
165
+ gap: var(--space-3);
166
+ }
167
+
168
+ .cpub-cat-field { display: flex; flex-direction: column; gap: 4px; }
169
+ .cpub-cat-field--wide { grid-column: 1 / -1; }
170
+ .cpub-cat-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
171
+ .cpub-cat-input {
172
+ font-size: 13px;
173
+ padding: 6px 10px;
174
+ border: var(--border-width-default) solid var(--border);
175
+ background: var(--bg);
176
+ color: var(--text);
177
+ outline: none;
178
+ }
179
+ .cpub-cat-input:focus { border-color: var(--accent); }
180
+
181
+ .cpub-cat-form-actions { display: flex; gap: var(--space-2); margin-top: var(--space-4); }
182
+
183
+ .cpub-admin-table-wrap { overflow-x: auto; }
184
+ .cpub-admin-table { width: 100%; border-collapse: collapse; }
185
+ .cpub-admin-table th { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); text-align: left; padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border); }
186
+ .cpub-admin-table td { padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border2); font-size: 13px; }
187
+ .cpub-admin-num { font-family: var(--font-mono); font-size: 11px; color: var(--text-faint); }
188
+ .cpub-admin-slug { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); }
189
+ .cpub-admin-desc { color: var(--text-dim); }
190
+ .cpub-admin-actions { display: flex; gap: 6px; }
191
+ .cpub-admin-action { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
192
+ .cpub-admin-action:hover { color: var(--accent); }
193
+ .cpub-admin-action--danger:hover { color: var(--red); }
194
+
195
+ .cpub-cat-name { display: flex; align-items: center; gap: 6px; font-weight: 500; }
196
+
197
+ .cpub-empty { color: var(--text-faint); text-align: center; padding: var(--space-10) 0; }
198
+
199
+ @media (max-width: 768px) {
200
+ .cpub-cat-form-grid { grid-template-columns: 1fr; }
201
+ .cpub-admin-header { flex-direction: column; gap: var(--space-3); align-items: flex-start; }
202
+ }
203
+ </style>
@@ -2,7 +2,7 @@
2
2
  const route = useRoute();
3
3
  const code = route.params.code as string;
4
4
 
5
- const { data: certData } = useLazyFetch(`/api/cert/${code}`);
5
+ const { data: certData, pending } = useLazyFetch(`/api/cert/${code}`);
6
6
 
7
7
  useSeoMeta({
8
8
  title: () => certData.value ? `Certificate, ${certData.value.path.title}, ${useSiteName()}` : `Certificate, ${useSiteName()}`,
@@ -12,7 +12,11 @@ useSeoMeta({
12
12
 
13
13
  <template>
14
14
  <div class="cert-page">
15
- <div v-if="certData" class="cert-card">
15
+ <div v-if="pending" class="cert-not-found">
16
+ <div class="cert-not-found-icon"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
17
+ <p>Loading certificate...</p>
18
+ </div>
19
+ <div v-else-if="certData" class="cert-card">
16
20
  <!-- Certificate Badge -->
17
21
  <div class="cert-badge-wrap">
18
22
  <div class="cert-badge">