@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,5 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import type { ContentViewData } from '../../composables/useEngagement';
3
+ import {
4
+ extractParts,
5
+ extractBuildSteps,
6
+ extractCodeBlocks,
7
+ extractDownloadFiles,
8
+ extractTocEntries,
9
+ } from '../../utils/projectBlocks';
10
+ // Explicit import (not Nuxt auto-import): ProjectView.test.ts mounts the SFC with
11
+ // no Nuxt transform, so an auto-imported composable would be undefined there.
12
+ import { useScrollSpy } from '../../composables/useScrollSpy';
3
13
 
4
14
  const { hubs: hubsEnabled } = useFeatures();
5
15
  const { user: authUser } = useAuth();
@@ -36,6 +46,23 @@ const tabs = computed(() => {
36
46
  return result;
37
47
  });
38
48
 
49
+ // Roving-tabindex keyboard nav for the tablist (WCAG 4.1.2 / APG tabs pattern):
50
+ // Arrow keys move + activate the adjacent tab, Home/End jump to the ends.
51
+ function onTabKeydown(e: KeyboardEvent, idx: number): void {
52
+ const count = tabs.value.length;
53
+ let next = -1;
54
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % count;
55
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + count) % count;
56
+ else if (e.key === 'Home') next = 0;
57
+ else if (e.key === 'End') next = count - 1;
58
+ else return;
59
+ e.preventDefault();
60
+ const tab = tabs.value[next];
61
+ if (!tab) return;
62
+ activeTab.value = tab.value;
63
+ nextTick(() => document.getElementById(`cpub-tab-${tab.value}`)?.focus());
64
+ }
65
+
39
66
  const contentId = computed(() => props.content?.id);
40
67
  const contentType = computed(() => props.content?.type ?? 'project');
41
68
  const fedId = computed(() => props.federatedId);
@@ -43,6 +70,7 @@ const { liked, bookmarked, likeCount, isFederated, toggleLike, toggleBookmark, s
43
70
 
44
71
  onMounted(() => {
45
72
  fetchInitialState(props.content?.likeCount ?? 0);
73
+ hydrateBuildState();
46
74
  });
47
75
 
48
76
  const config = useRuntimeConfig();
@@ -93,203 +121,27 @@ const formattedDate = computed(() => {
93
121
  return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
94
122
  });
95
123
 
96
- // Extract parts list blocks from content for BOM tab
97
- interface PartItem {
98
- name: string;
99
- quantity: number;
100
- productId?: string;
101
- notes?: string;
102
- }
103
-
104
- const partsFromBlocks = computed<PartItem[]>(() => {
105
- const blocks = props.content?.content;
106
- if (!Array.isArray(blocks)) return [];
107
- const items: PartItem[] = [];
108
- for (const block of blocks) {
109
- const [type, data] = block as [string, Record<string, unknown>];
110
- if (type === 'partsList' && Array.isArray(data.parts)) {
111
- for (const part of data.parts as Array<Record<string, unknown>>) {
112
- items.push({
113
- name: (part.name as string) || 'Unknown',
114
- quantity: (part.qty as number) ?? (part.quantity as number) ?? 1,
115
- productId: part.productId as string | undefined,
116
- notes: (part.notes as string) || '',
117
- });
118
- }
119
- }
120
- }
121
- return items;
122
- });
123
-
124
- // Extract build steps from content
125
- interface BuildStep {
126
- number: number;
127
- title: string;
128
- children: Array<[string, Record<string, unknown>]>;
129
- time?: string;
130
- }
131
-
132
- const buildStepsFromBlocks = computed<BuildStep[]>(() => {
133
- const blocks = props.content?.content;
134
- if (!Array.isArray(blocks)) return [];
135
- const steps: BuildStep[] = [];
136
- let stepNum = 0;
137
- for (const block of blocks) {
138
- const [type, data] = block as [string, Record<string, unknown>];
139
- if (type === 'buildStep') {
140
- stepNum++;
141
- // Migrate old format (instructions + image) to children
142
- let children: Array<[string, Record<string, unknown>]> = [];
143
- if (data.children && Array.isArray(data.children) && data.children.length > 0) {
144
- children = data.children as Array<[string, Record<string, unknown>]>;
145
- } else {
146
- const instructions = data.instructions as string | undefined;
147
- if (instructions && instructions.trim()) {
148
- const html = instructions.startsWith('<') ? instructions : `<p>${instructions}</p>`;
149
- children.push(['paragraph', { html }]);
150
- }
151
- const image = data.image as string | undefined;
152
- if (image && image.trim()) {
153
- children.push(['image', { src: image, alt: `Step ${stepNum}`, caption: '' }]);
154
- }
155
- }
156
- steps.push({
157
- number: (data.stepNumber as number) || stepNum,
158
- title: (data.title as string) || `Step ${stepNum}`,
159
- children,
160
- time: data.time as string | undefined,
161
- });
162
- }
163
- }
164
- return steps;
165
- });
166
-
167
- // Extract code blocks for code tab
168
- interface CodeSnippet {
169
- language: string;
170
- filename: string;
171
- code: string;
172
- }
173
-
174
- const codeBlocks = computed<CodeSnippet[]>(() => {
175
- const blocks = props.content?.content;
176
- if (!Array.isArray(blocks)) return [];
177
- const snippets: CodeSnippet[] = [];
178
- for (const block of blocks) {
179
- const [type, data] = block as [string, Record<string, unknown>];
180
- if (type === 'code_block' || type === 'codeBlock') {
181
- snippets.push({
182
- language: (data.language as string) || '',
183
- filename: (data.filename as string) || '',
184
- code: (data.code as string) || '',
185
- });
186
- }
187
- }
188
- return snippets;
189
- });
190
-
191
- // Extract download blocks for files tab
192
- interface FileItem {
193
- name: string;
194
- url: string;
195
- size?: string;
196
- }
197
-
198
- const downloadFiles = computed<FileItem[]>(() => {
199
- const blocks = props.content?.content;
200
- if (!Array.isArray(blocks)) return [];
201
- const files: FileItem[] = [];
202
- for (const block of blocks) {
203
- const [type, data] = block as [string, Record<string, unknown>];
204
- if (type === 'downloads' && Array.isArray(data.files)) {
205
- for (const file of data.files as Array<Record<string, unknown>>) {
206
- files.push({
207
- name: (file.name as string) || 'Unknown',
208
- url: (file.url as string) || '',
209
- size: (file.size as string) || '',
210
- });
211
- }
212
- }
213
- }
214
- return files;
215
- });
216
-
217
- // Extract headings from content for table of contents
218
- interface TocEntry { id: string; text: string; level: number }
219
- const tocEntries = computed<TocEntry[]>(() => {
220
- const blocks = props.content?.content;
221
- if (!Array.isArray(blocks)) return [];
222
- const entries: TocEntry[] = [];
223
- for (const block of blocks) {
224
- const [type, data] = block as [string, Record<string, unknown>];
225
- if (type === 'heading' && data.text) {
226
- const text = String(data.text).replace(/<[^>]+>/g, '');
227
- if (text.trim()) {
228
- entries.push({
229
- id: text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
230
- text: text.trim(),
231
- level: (data.level as number) ?? 2,
232
- });
233
- }
234
- }
235
- }
236
- return entries;
237
- });
238
-
239
- const tocActiveId = ref('');
240
-
241
- function scrollToHeading(id: string): void {
242
- const el = document.getElementById(id);
243
- if (el) {
244
- // CSS scroll-behavior is reduced-motion-gated in base.css, but the JS
245
- // smooth option ignores that — honour the preference explicitly.
246
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
247
- el.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
248
- tocActiveId.value = id;
249
- }
250
- }
251
-
252
- // Scroll-spy: highlight active TOC entry based on which heading is in view
253
- let observer: IntersectionObserver | null = null;
254
-
255
- onMounted(() => {
256
- nextTick(() => {
257
- setupScrollSpy();
258
- });
259
- });
260
-
261
- onUnmounted(() => {
262
- observer?.disconnect();
124
+ // Structured views of the project's block content. The parsing lives in the
125
+ // pure, unit-tested helpers in utils/projectBlocks.ts (imported above); the BOM,
126
+ // build-steps, code, files, and TOC tabs all read from these computeds.
127
+ const partsFromBlocks = computed(() => extractParts(props.content?.content));
128
+ const buildStepsFromBlocks = computed(() => extractBuildSteps(props.content?.content));
129
+ const codeBlocks = computed(() => extractCodeBlocks(props.content?.content));
130
+ const downloadFiles = computed(() => extractDownloadFiles(props.content?.content));
131
+ const tocEntries = computed(() => extractTocEntries(props.content?.content));
132
+
133
+ // TOC scroll-spy + smooth scroll, shared with the docs viewer via useScrollSpy.
134
+ // Re-observes when the heading set changes (the inline version never did, so the
135
+ // highlight went stale on content change) and disconnects on unmount.
136
+ const { activeId: tocActiveId, scrollTo: scrollToHeading } = useScrollSpy({
137
+ source: () => tocEntries.value,
138
+ getHeadingElements: () =>
139
+ tocEntries.value
140
+ .map((e) => document.getElementById(e.id))
141
+ .filter((el): el is HTMLElement => !!el),
142
+ rootMargin: '-80px 0px -70% 0px',
263
143
  });
264
144
 
265
- function setupScrollSpy(): void {
266
- if (!tocEntries.value.length) return;
267
- observer?.disconnect();
268
-
269
- const headingEls = tocEntries.value
270
- .map((e) => document.getElementById(e.id))
271
- .filter((el): el is HTMLElement => !!el);
272
-
273
- if (!headingEls.length) return;
274
-
275
- observer = new IntersectionObserver(
276
- (entries) => {
277
- // Find the topmost visible heading
278
- for (const entry of entries) {
279
- if (entry.isIntersecting) {
280
- tocActiveId.value = entry.target.id;
281
- break;
282
- }
283
- }
284
- },
285
- { rootMargin: '-80px 0px -70% 0px', threshold: 0 },
286
- );
287
-
288
- for (const el of headingEls) {
289
- observer.observe(el);
290
- }
291
- }
292
-
293
145
  // Fork
294
146
  const forking = ref(false);
295
147
  async function handleFork(): Promise<void> {
@@ -310,6 +162,21 @@ async function handleFork(): Promise<void> {
310
162
  // I Built This
311
163
  const buildMarked = ref(false);
312
164
  const localBuildCount = ref(props.content?.buildCount ?? 0);
165
+
166
+ // Hydrate the "I Built This" state on load — without this the button always
167
+ // renders inactive after a reload and a re-click un-marks + decrements.
168
+ async function hydrateBuildState(): Promise<void> {
169
+ if (!props.content?.id && !props.federatedId) return;
170
+ const url = isFederated.value
171
+ ? `/api/federation/content/${props.federatedId}/build`
172
+ : `/api/content/${props.content.id}/build`;
173
+ try {
174
+ const res = await $fetch<{ marked: boolean }>(url);
175
+ buildMarked.value = res.marked;
176
+ } catch {
177
+ // logged-out (401) or not-found → leave unmarked
178
+ }
179
+ }
313
180
  const buildToggling = ref(false);
314
181
  async function handleBuild(): Promise<void> {
315
182
  buildToggling.value = true;
@@ -371,13 +238,12 @@ async function handleBuild(): Promise<void> {
371
238
  <!-- Author Row -->
372
239
  <div class="cpub-author-row">
373
240
  <NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-av-link">
374
- <img
375
- v-if="content.author?.avatarUrl"
376
- :src="content.author.avatarUrl"
377
- :alt="content.author?.displayName || content.author?.username"
378
- class="cpub-av cpub-av-lg cpub-av-img"
241
+ <ContentAvatar
242
+ :src="content.author?.avatarUrl"
243
+ :name="content.author?.displayName || content.author?.username || ''"
244
+ :size="36"
245
+ :font-size="12"
379
246
  />
380
- <div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
381
247
  </NuxtLink>
382
248
  <div>
383
249
  <NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-author-name cpub-author-link">
@@ -416,13 +282,19 @@ async function handleBuild(): Promise<void> {
416
282
 
417
283
  <!-- STICKY TABS -->
418
284
  <div class="cpub-tabs-sticky">
419
- <div class="cpub-tabs-inner">
285
+ <div class="cpub-tabs-inner" role="tablist" aria-label="Project sections">
420
286
  <button
421
- v-for="tab in tabs"
287
+ v-for="(tab, idx) in tabs"
422
288
  :key="tab.value"
289
+ :id="`cpub-tab-${tab.value}`"
423
290
  class="cpub-tab"
424
291
  :class="{ active: activeTab === tab.value }"
292
+ role="tab"
293
+ :aria-selected="activeTab === tab.value"
294
+ aria-controls="cpub-project-tabpanel"
295
+ :tabindex="activeTab === tab.value ? 0 : -1"
425
296
  @click="activeTab = tab.value"
297
+ @keydown="onTabKeydown($event, idx)"
426
298
  >
427
299
  {{ tab.label }}
428
300
  <span v-if="tab.count" class="cpub-tab-badge">{{ tab.count }}</span>
@@ -452,7 +324,13 @@ async function handleBuild(): Promise<void> {
452
324
  </nav>
453
325
 
454
326
  <!-- CENTER: CONTENT -->
455
- <div class="cpub-content-col">
327
+ <div
328
+ class="cpub-content-col"
329
+ role="tabpanel"
330
+ id="cpub-project-tabpanel"
331
+ :aria-labelledby="`cpub-tab-${activeTab}`"
332
+ tabindex="0"
333
+ >
456
334
  <!-- OVERVIEW TAB -->
457
335
  <template v-if="activeTab === 'overview'">
458
336
  <!-- Cover photo (in-body featured image) -->
@@ -763,41 +641,7 @@ async function handleBuild(): Promise<void> {
763
641
  flex-wrap: wrap;
764
642
  }
765
643
 
766
- /* See ArticleView.vue's .cpub-av comment for why display:flex is scoped
767
- * to the div-variant only — stops img-variant from squishing portrait
768
- * avatars (object-fit:cover gets dropped on flex-set replaced elements). */
769
- .cpub-av {
770
- --cpub-av-size: 28px;
771
- width: var(--cpub-av-size);
772
- height: var(--cpub-av-size);
773
- /* Hard-lock to a square (min/max on both axes) so a portrait photo can't
774
- render as an oval if a global reset or dropped dimension lets the <img>
775
- take its intrinsic aspect ratio. See ArticleView.vue. */
776
- min-width: var(--cpub-av-size);
777
- max-width: var(--cpub-av-size);
778
- min-height: var(--cpub-av-size);
779
- max-height: var(--cpub-av-size);
780
- border-radius: 50%;
781
- background: var(--surface3);
782
- border: var(--border-width-default) solid var(--border);
783
- flex-shrink: 0;
784
- }
785
-
786
- div.cpub-av {
787
- display: flex;
788
- align-items: center;
789
- justify-content: center;
790
- font-size: 10px;
791
- font-weight: 700;
792
- color: var(--text-dim);
793
- font-family: var(--font-mono);
794
- }
795
-
796
- img.cpub-av {
797
- object-fit: cover;
798
- }
799
-
800
- .cpub-av-lg { --cpub-av-size: 36px; font-size: 12px; }
644
+ /* Author avatar lives in <ContentAvatar> (its .cpub-av CSS travels with it). */
801
645
 
802
646
  .cpub-author-name {
803
647
  font-size: 13px;
@@ -814,12 +658,6 @@ img.cpub-av {
814
658
  .cpub-av-link {
815
659
  text-decoration: none;
816
660
  }
817
- .cpub-av-img {
818
- width: 36px;
819
- height: 36px;
820
- object-fit: cover;
821
- border: var(--border-width-default) solid var(--border);
822
- }
823
661
 
824
662
  .cpub-author-meta-row {
825
663
  display: flex;
@@ -23,6 +23,9 @@ export interface ClientAuthSession {
23
23
  interface AuthResponse {
24
24
  user: ClientAuthUser | null;
25
25
  session: ClientAuthSession | null;
26
+ /** Effective RBAC permission grants (advisory — server enforces). */
27
+ permissions?: string[];
28
+ roleKeys?: string[];
26
29
  }
27
30
 
28
31
  // `$fetch<T>(url, options)` instantiates Nuxt's NitroFetchRequest generic
@@ -49,6 +52,10 @@ async function authGet(url: string): Promise<AuthResponse | null> {
49
52
  export function useAuth() {
50
53
  const user = useState<ClientAuthUser | null>('auth-user', () => null);
51
54
  const session = useState<ClientAuthSession | null>('auth-session', () => null);
55
+ // Effective RBAC permission grants for the current user (advisory, UX-only —
56
+ // the server is always the enforcement boundary). Populated by refreshSession.
57
+ const permissions = useState<string[]>('auth-permissions', () => []);
58
+ const roleKeys = useState<string[]>('auth-role-keys', () => []);
52
59
 
53
60
  const isAuthenticated = computed(() => !!user.value);
54
61
  const isAdmin = computed(() => user.value?.role === 'admin');
@@ -69,6 +76,8 @@ export function useAuth() {
69
76
  await authPost('/api/auth/sign-out', {});
70
77
  user.value = null;
71
78
  session.value = null;
79
+ permissions.value = [];
80
+ roleKeys.value = [];
72
81
  await navigateTo('/');
73
82
  }
74
83
 
@@ -84,6 +93,8 @@ export function useAuth() {
84
93
  // (a logged-out user gets `{ user: null }`), so mirror it exactly.
85
94
  user.value = data?.user ?? null;
86
95
  session.value = data?.session ?? null;
96
+ permissions.value = data?.permissions ?? [];
97
+ roleKeys.value = data?.roleKeys ?? [];
87
98
  } catch {
88
99
  // A *thrown* error means we couldn't reach /api/me (network blip, 5xx, a
89
100
  // slow/overloaded server timing out). That is NOT evidence the session is
@@ -96,6 +107,8 @@ export function useAuth() {
96
107
  return {
97
108
  user: readonly(user),
98
109
  session: readonly(session),
110
+ permissions: readonly(permissions),
111
+ roleKeys: readonly(roleKeys),
99
112
  isAuthenticated,
100
113
  isAdmin,
101
114
  signIn,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Client-side permission check (UX only — the server enforces via
3
+ * `requirePermission`). Mirrors `hasPermissionPure` (@commonpub/auth): admin
4
+ * floor → `*` → exact match → `<prefix>.*` segment wildcard.
5
+ *
6
+ * const canModerate = useCan('content.moderate')
7
+ * <button v-if="canModerate">…</button>
8
+ *
9
+ * Never gate real access on this alone — always have a server guard behind it.
10
+ */
11
+ export function useCan(key: string): ComputedRef<boolean> {
12
+ const { isAdmin, permissions } = useAuth();
13
+ return computed(() => {
14
+ // Admin floor — admins pass everything (their resolved set is empty server-side).
15
+ if (isAdmin.value) return true;
16
+ const granted = permissions.value;
17
+ if (!granted.length) return false;
18
+ if (granted.includes('*')) return true;
19
+ if (granted.includes(key)) return true;
20
+ const prefix = key.includes('.') ? key.slice(0, key.indexOf('.')) : null;
21
+ return prefix ? granted.includes(`${prefix}.*`) : false;
22
+ });
23
+ }