@commonpub/layer 0.81.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 (212) 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 +11 -3
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +54 -20
  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/useAuth.ts +13 -0
  64. package/composables/useCan.ts +23 -0
  65. package/composables/useContestEditor.ts +388 -0
  66. package/composables/useDocsPageTree.ts +154 -0
  67. package/composables/useDocsSiteSettings.ts +107 -0
  68. package/composables/useEditorAutosave.ts +131 -0
  69. package/composables/useEngagement.ts +13 -6
  70. package/composables/useFeatures.ts +9 -1
  71. package/composables/useFileUpload.ts +60 -0
  72. package/composables/useProfileContent.ts +84 -0
  73. package/composables/useSanitize.ts +38 -4
  74. package/composables/useScrollSpy.ts +87 -0
  75. package/layouts/admin.vue +43 -18
  76. package/layouts/default.vue +18 -9
  77. package/nuxt.config.ts +13 -0
  78. package/package.json +8 -8
  79. package/pages/[type]/index.vue +6 -1
  80. package/pages/admin/api-keys.vue +13 -3
  81. package/pages/admin/features.vue +2 -0
  82. package/pages/admin/federation.vue +1 -1
  83. package/pages/admin/layouts/[id].vue +30 -2
  84. package/pages/admin/roles.vue +286 -0
  85. package/pages/admin/settings.vue +2 -1
  86. package/pages/admin/users.vue +81 -1
  87. package/pages/admin/video-categories.vue +203 -0
  88. package/pages/cert/[code].vue +6 -2
  89. package/pages/contests/[slug]/edit.vue +4 -764
  90. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  91. package/pages/contests/[slug]/index.vue +97 -8
  92. package/pages/contests/[slug]/judge.vue +49 -26
  93. package/pages/contests/create.vue +5 -466
  94. package/pages/contests/index.vue +7 -2
  95. package/pages/cookies.vue +1 -1
  96. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  97. package/pages/docs/[siteSlug]/edit.vue +93 -231
  98. package/pages/events/[slug]/edit.vue +20 -20
  99. package/pages/events/create.vue +18 -18
  100. package/pages/events/index.vue +7 -2
  101. package/pages/hubs/[slug]/index.vue +34 -9
  102. package/pages/hubs/[slug]/invites.vue +312 -0
  103. package/pages/hubs/[slug]/members.vue +128 -0
  104. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  105. package/pages/hubs/index.vue +6 -1
  106. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  107. package/pages/learn/index.vue +8 -1
  108. package/pages/messages/index.vue +1 -1
  109. package/pages/mirror/[id].vue +1 -1
  110. package/pages/products/[slug].vue +55 -2
  111. package/pages/products/index.vue +6 -1
  112. package/pages/settings/account.vue +8 -8
  113. package/pages/settings/profile.vue +23 -14
  114. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  115. package/pages/u/[username]/followers.vue +11 -3
  116. package/pages/u/[username]/following.vue +10 -8
  117. package/pages/u/[username]/index.vue +73 -7
  118. package/pages/videos/index.vue +13 -10
  119. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  120. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  121. package/server/api/admin/api-keys/index.get.ts +1 -0
  122. package/server/api/admin/api-keys/index.post.ts +1 -0
  123. package/server/api/admin/federation/refederate.post.ts +18 -1
  124. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  125. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  126. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  127. package/server/api/admin/layouts/[id].delete.ts +1 -4
  128. package/server/api/admin/layouts/[id].get.ts +1 -4
  129. package/server/api/admin/layouts/[id].put.ts +1 -4
  130. package/server/api/admin/permissions.get.ts +14 -0
  131. package/server/api/admin/roles/[id]/index.delete.ts +25 -0
  132. package/server/api/admin/roles/[id]/index.put.ts +24 -0
  133. package/server/api/admin/roles/index.get.ts +10 -0
  134. package/server/api/admin/roles/index.post.ts +27 -0
  135. package/server/api/admin/users/[id]/role.put.ts +20 -1
  136. package/server/api/admin/users/[id]/roles.get.ts +10 -0
  137. package/server/api/admin/users/[id]/roles.put.ts +17 -0
  138. package/server/api/auth/federated/login.post.ts +12 -5
  139. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  140. package/server/api/content/[id]/build.get.ts +11 -0
  141. package/server/api/content/[id]/report.post.ts +2 -0
  142. package/server/api/content/[id]/versions.get.ts +15 -0
  143. package/server/api/contests/[slug]/advance.post.ts +10 -5
  144. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  145. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  146. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  147. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  148. package/server/api/contests/[slug]/export.get.ts +43 -0
  149. package/server/api/contests/[slug]/index.get.ts +10 -2
  150. package/server/api/contests/[slug]/index.put.ts +11 -2
  151. package/server/api/contests/[slug]/judge.post.ts +8 -2
  152. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  153. package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
  154. package/server/api/contests/[slug]/transition.post.ts +8 -3
  155. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  156. package/server/api/contests/index.post.ts +1 -1
  157. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  158. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  159. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  160. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  161. package/server/api/events/[slug]/attendees.get.ts +10 -0
  162. package/server/api/events/[slug].get.ts +9 -0
  163. package/server/api/events/index.get.ts +8 -1
  164. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  165. package/server/api/federation/content/[id]/build.get.ts +10 -0
  166. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  167. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  168. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  169. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  170. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  171. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  172. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  173. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  174. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  175. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  176. package/server/api/me.get.ts +7 -0
  177. package/server/api/products/[id].delete.ts +22 -2
  178. package/server/api/registry/ping.post.ts +17 -3
  179. package/server/api/search/index.get.ts +5 -3
  180. package/server/api/social/bookmark.get.ts +1 -0
  181. package/server/api/social/bookmark.post.ts +1 -0
  182. package/server/api/social/bookmarks.get.ts +1 -0
  183. package/server/api/social/comments/[id].delete.ts +1 -0
  184. package/server/api/social/comments.get.ts +1 -0
  185. package/server/api/social/comments.post.ts +1 -0
  186. package/server/api/social/like.get.ts +1 -0
  187. package/server/api/social/like.post.ts +1 -0
  188. package/server/api/users/[username]/content.get.ts +15 -3
  189. package/server/api/users/[username]/follow.delete.ts +1 -0
  190. package/server/api/users/[username]/follow.post.ts +1 -0
  191. package/server/api/users/[username]/followers.get.ts +2 -1
  192. package/server/api/users/[username]/following.get.ts +2 -1
  193. package/server/middleware/content-ap.ts +8 -3
  194. package/server/middleware/csrf.ts +93 -0
  195. package/server/plugins/federation-hub-sync.ts +48 -17
  196. package/server/plugins/notification-email.ts +22 -3
  197. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  198. package/server/routes/inbox.ts +14 -1
  199. package/server/routes/users/[username]/inbox.ts +13 -1
  200. package/server/utils/inbox.ts +7 -2
  201. package/server/utils/validate.ts +22 -0
  202. package/theme/base.css +5 -0
  203. package/theme/prose.css +20 -0
  204. package/theme/stoa-dark.css +4 -0
  205. package/types/contestBlocks.ts +122 -0
  206. package/utils/contestBlocks.ts +107 -0
  207. package/utils/contestBody.ts +25 -0
  208. package/utils/contestStages.ts +62 -0
  209. package/utils/contestSubmission.ts +97 -0
  210. package/utils/datetime.ts +45 -0
  211. package/utils/projectBlocks.ts +162 -0
  212. package/components/editors/BlogEditor.vue +0 -648
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import type { RoleWithPermissions } from '@commonpub/server';
3
+
2
4
  definePageMeta({ layout: 'admin', middleware: 'auth' });
3
5
  useSeoMeta({ title: `Users, Admin, ${useSiteName()}` });
4
6
 
@@ -9,6 +11,11 @@ const { data: users, refresh } = await useFetch('/api/admin/users', {
9
11
  query: computed(() => ({ search: search.value || undefined })),
10
12
  });
11
13
 
14
+ // Custom (non-system) roles — for per-user assignment. Requires `roles.manage`;
15
+ // useFetch won't crash the page if the viewer lacks it (data stays null).
16
+ const { data: allRoles } = await useFetch<RoleWithPermissions[]>('/api/admin/roles');
17
+ const customRoles = computed<RoleWithPermissions[]>(() => (allRoles.value ?? []).filter((r) => !r.isSystem));
18
+
12
19
  interface AdminUser {
13
20
  id: string;
14
21
  username: string;
@@ -39,6 +46,49 @@ async function changeRole(userId: string, role: string): Promise<void> {
39
46
  }
40
47
  }
41
48
 
49
+ // --- Custom-role assignment (expand a row to edit a user's custom roles) ---
50
+ const expandedUserId = ref<string | null>(null);
51
+ const editingRoleIds = ref<Set<string>>(new Set());
52
+ const savingRoles = ref(false);
53
+
54
+ async function toggleRolesEditor(userId: string): Promise<void> {
55
+ if (expandedUserId.value === userId) {
56
+ expandedUserId.value = null;
57
+ return;
58
+ }
59
+ expandedUserId.value = userId;
60
+ editingRoleIds.value = new Set();
61
+ try {
62
+ const { roleIds } = await $fetch<{ roleIds: string[] }>(`/api/admin/users/${userId}/roles`);
63
+ editingRoleIds.value = new Set(roleIds);
64
+ } catch {
65
+ toast.error('Could not load this user’s roles');
66
+ }
67
+ }
68
+
69
+ function toggleRoleId(roleId: string): void {
70
+ const next = new Set(editingRoleIds.value);
71
+ if (next.has(roleId)) next.delete(roleId);
72
+ else next.add(roleId);
73
+ editingRoleIds.value = next;
74
+ }
75
+
76
+ async function saveRoles(userId: string): Promise<void> {
77
+ savingRoles.value = true;
78
+ try {
79
+ // Only custom role ids are sent; the system/primary role is the dropdown above.
80
+ const customIds = new Set(customRoles.value.map((r) => r.id));
81
+ const roleIds = [...editingRoleIds.value].filter((id) => customIds.has(id));
82
+ await $fetch(`/api/admin/users/${userId}/roles`, { method: 'PUT', body: { roleIds } });
83
+ toast.success('Custom roles updated');
84
+ expandedUserId.value = null;
85
+ } catch {
86
+ toast.error('Failed to update custom roles');
87
+ } finally {
88
+ savingRoles.value = false;
89
+ }
90
+ }
91
+
42
92
  async function toggleStatus(userId: string, currentStatus: string): Promise<void> {
43
93
  const newStatus = currentStatus === 'active' ? 'suspended' : 'active';
44
94
  try {
@@ -78,13 +128,15 @@ async function deleteUser(userId: string, username: string): Promise<void> {
78
128
  <th>Username</th>
79
129
  <th>Email</th>
80
130
  <th>Role</th>
131
+ <th v-if="customRoles.length">Custom roles</th>
81
132
  <th>Status</th>
82
133
  <th>Joined</th>
83
134
  <th>Actions</th>
84
135
  </tr>
85
136
  </thead>
86
137
  <tbody>
87
- <tr v-for="u in userList" :key="u.id">
138
+ <template v-for="u in userList" :key="u.id">
139
+ <tr>
88
140
  <td>
89
141
  <NuxtLink :to="`/u/${u.username}`" class="admin-link">@{{ u.username }}</NuxtLink>
90
142
  </td>
@@ -98,6 +150,11 @@ async function deleteUser(userId: string, username: string): Promise<void> {
98
150
  <option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
99
151
  </select>
100
152
  </td>
153
+ <td v-if="customRoles.length">
154
+ <button class="admin-roles-btn" :aria-expanded="expandedUserId === u.id" @click="toggleRolesEditor(u.id)">
155
+ <i class="fa-solid fa-user-shield"></i> {{ expandedUserId === u.id ? 'Close' : 'Assign' }}
156
+ </button>
157
+ </td>
101
158
  <td>
102
159
  <button
103
160
  class="admin-status-btn"
@@ -114,6 +171,21 @@ async function deleteUser(userId: string, username: string): Promise<void> {
114
171
  </button>
115
172
  </td>
116
173
  </tr>
174
+ <tr v-if="expandedUserId === u.id && customRoles.length" class="admin-roles-row">
175
+ <td :colspan="7">
176
+ <div class="admin-roles-editor">
177
+ <span class="admin-roles-label">Custom roles for @{{ u.username }}:</span>
178
+ <label v-for="r in customRoles" :key="r.id" class="admin-roles-check">
179
+ <input type="checkbox" :checked="editingRoleIds.has(r.id)" @change="toggleRoleId(r.id)" />
180
+ <span>{{ r.name }}</span>
181
+ </label>
182
+ <button class="admin-roles-save" :disabled="savingRoles" @click="saveRoles(u.id)">
183
+ {{ savingRoles ? 'Saving...' : 'Save' }}
184
+ </button>
185
+ </div>
186
+ </td>
187
+ </tr>
188
+ </template>
117
189
  </tbody>
118
190
  </table>
119
191
  </div>
@@ -141,5 +213,13 @@ async function deleteUser(userId: string, username: string): Promise<void> {
141
213
  .admin-status-btn:hover { opacity: 0.8; }
142
214
  .admin-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
143
215
  .admin-delete-btn:hover { color: var(--red); }
216
+ .admin-roles-btn { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border: var(--border-width-default) solid var(--border2); background: var(--surface); color: var(--text-dim); cursor: pointer; }
217
+ .admin-roles-btn:hover { border-color: var(--accent); color: var(--text); }
218
+ .admin-roles-row td { background: var(--surface2); }
219
+ .admin-roles-editor { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 4px 0; }
220
+ .admin-roles-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
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(--color-on-accent); cursor: pointer; margin-left: auto; }
223
+ .admin-roles-save:disabled { opacity: 0.6; cursor: default; }
144
224
  .admin-empty { color: var(--text-faint); text-align: center; padding: 32px 0; }
145
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">