@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
@@ -14,6 +14,9 @@ function updateMeta(key: string, value: unknown): void {
14
14
  emit('update:metadata', { ...props.metadata, [key]: value });
15
15
  }
16
16
 
17
+ const { uploadFile } = useFileUpload();
18
+ const toast = useToast();
19
+
17
20
  const blockTypes: BlockTypeGroup[] = [
18
21
  {
19
22
  name: 'Basic',
@@ -75,12 +78,12 @@ function onCoverUpload(event: Event): void {
75
78
  if (!input.files?.length) return;
76
79
  const file = input.files[0];
77
80
  if (!file) return;
78
- const formData = new FormData();
79
- formData.append('file', file);
80
- formData.append('purpose', 'cover');
81
- $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
81
+ uploadFile(file, 'cover')
82
82
  .then((res) => { updateMeta('coverImageUrl', res.url); })
83
- .catch(() => { /* silent fallback */ });
83
+ .catch((err: unknown) => {
84
+ const msg = (err as { data?: { statusMessage?: string } })?.data?.statusMessage;
85
+ toast.error(msg || 'Cover image upload failed');
86
+ });
84
87
  }
85
88
 
86
89
  function onCoverUrl(): void {
@@ -100,12 +103,12 @@ function onBannerUpload(event: Event): void {
100
103
  if (!input.files?.length) return;
101
104
  const file = input.files[0];
102
105
  if (!file) return;
103
- const formData = new FormData();
104
- formData.append('file', file);
105
- formData.append('purpose', 'cover');
106
- $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
106
+ uploadFile(file, 'cover')
107
107
  .then((res) => { updateMeta('bannerUrl', res.url); })
108
- .catch(() => {});
108
+ .catch((err: unknown) => {
109
+ const msg = (err as { data?: { statusMessage?: string } })?.data?.statusMessage;
110
+ toast.error(msg || 'Banner image upload failed');
111
+ });
109
112
  }
110
113
 
111
114
  function removeBanner(): void {
@@ -1,16 +1,25 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue';
2
3
  import type { HomepageSectionConfig } from '@commonpub/server';
4
+ import { sanitizeRichHtml } from '../../composables/useSanitize';
3
5
 
4
6
  const props = defineProps<{
5
7
  config: HomepageSectionConfig;
6
8
  title?: string;
7
9
  }>();
10
+
11
+ // Admin-authored raw HTML renders on the PUBLIC homepage with v-html; strip
12
+ // scripts/event-handlers/javascript: before injecting (CSP allows unsafe-inline,
13
+ // so this is the only XSS barrier). (audit session 204 — P1)
14
+ const safeHtml = computed(() =>
15
+ typeof props.config.html === 'string' ? sanitizeRichHtml(props.config.html) : '',
16
+ );
8
17
  </script>
9
18
 
10
19
  <template>
11
- <section v-if="config.html" class="cpub-custom-section">
20
+ <section v-if="safeHtml" class="cpub-custom-section">
12
21
  <h2 v-if="title" class="cpub-custom-title">{{ title }}</h2>
13
- <div class="cpub-custom-content" v-html="config.html" />
22
+ <div class="cpub-custom-content" v-html="safeHtml" />
14
23
  </section>
15
24
  </template>
16
25
 
@@ -6,6 +6,7 @@ const props = defineProps<{
6
6
  }>();
7
7
 
8
8
  const emit = defineEmits<{ 'product-created': [] }>();
9
+ const toast = useToast();
9
10
 
10
11
  const canManage = computed(() => ['owner', 'admin', 'moderator'].includes(props.currentUserRole ?? ''));
11
12
  const showForm = ref(false);
@@ -29,8 +30,9 @@ async function handleCreate(): Promise<void> {
29
30
  formPurchaseUrl.value = '';
30
31
  showForm.value = false;
31
32
  emit('product-created');
32
- } catch { /* toast error */ }
33
- finally { creating.value = false; }
33
+ } catch {
34
+ toast.error('Failed to create product');
35
+ } finally { creating.value = false; }
34
36
  }
35
37
  </script>
36
38
 
@@ -47,7 +47,6 @@ function handleKeydown(e: KeyboardEvent): void {
47
47
  class="cpub-nav-link cpub-nav-trigger"
48
48
  :class="{ 'cpub-nav-trigger--open': open }"
49
49
  :aria-label="`${item.label} menu`"
50
- aria-haspopup="true"
51
50
  :aria-expanded="open"
52
51
  @click.stop="emit('toggle')"
53
52
  @keydown.enter.stop="emit('toggle')"
@@ -56,12 +55,11 @@ function handleKeydown(e: KeyboardEvent): void {
56
55
  <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
57
56
  <i class="fa-solid fa-chevron-down cpub-nav-caret" />
58
57
  </button>
59
- <div v-if="open" class="cpub-nav-panel" role="menu">
58
+ <div v-if="open" class="cpub-nav-panel">
60
59
  <template v-for="child in visibleChildren" :key="child.id">
61
60
  <span
62
61
  v-if="child.disabled"
63
62
  class="cpub-nav-panel-item cpub-nav-panel-item--disabled"
64
- role="menuitem"
65
63
  aria-disabled="true"
66
64
  >
67
65
  <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
@@ -72,7 +70,6 @@ function handleKeydown(e: KeyboardEvent): void {
72
70
  target="_blank"
73
71
  rel="noopener"
74
72
  class="cpub-nav-panel-item"
75
- role="menuitem"
76
73
  @click="emit('close')"
77
74
  >
78
75
  <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
@@ -82,7 +79,6 @@ function handleKeydown(e: KeyboardEvent): void {
82
79
  v-else-if="child.route"
83
80
  :to="child.route"
84
81
  class="cpub-nav-panel-item"
85
- role="menuitem"
86
82
  @click="emit('close')"
87
83
  >
88
84
  <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
@@ -26,6 +26,8 @@ const isExternal = computed(() => props.item.type === 'external' && props.item.h
26
26
  v-else-if="item.route"
27
27
  :to="item.route"
28
28
  class="cpub-nav-link"
29
+ :active-class="item.route === '/' ? '' : undefined"
30
+ :exact-active-class="item.route === '/' ? 'router-link-active' : undefined"
29
31
  >
30
32
  <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
31
33
  </NuxtLink>
@@ -117,8 +117,7 @@ useJsonLd({
117
117
  <!-- AUTHOR ROW -->
118
118
  <div class="cpub-author-row">
119
119
  <NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" style="text-decoration:none;">
120
- <img v-if="content.author?.avatarUrl" :src="content.author.avatarUrl" :alt="content.author?.displayName ?? content.author?.username ?? ''" class="cpub-av cpub-av-lg" style="object-fit:cover;border:2px solid var(--border);" />
121
- <div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
120
+ <ContentAvatar :src="content.author?.avatarUrl" :name="content.author?.displayName ?? content.author?.username ?? ''" :size="44" :font-size="14" />
122
121
  </NuxtLink>
123
122
  <div class="cpub-author-info">
124
123
  <NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-author-name">
@@ -239,8 +238,7 @@ useJsonLd({
239
238
 
240
239
  <!-- AUTHOR CARD -->
241
240
  <div v-if="content.author" class="cpub-author-card">
242
- <img v-if="content.author.avatarUrl" :src="content.author.avatarUrl" :alt="content.author.displayName ?? content.author.username ?? ''" class="cpub-av cpub-av-xl" style="object-fit:cover;border:2px solid var(--border);" />
243
- <div v-else class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
241
+ <ContentAvatar :src="content.author.avatarUrl" :name="content.author.displayName ?? content.author.username ?? ''" :size="64" :font-size="18" />
244
242
  <div class="cpub-author-card-info">
245
243
  <div class="cpub-author-card-label">Written by</div>
246
244
  <div class="cpub-author-card-name">
@@ -419,58 +417,7 @@ useJsonLd({
419
417
  font-weight: 400;
420
418
  }
421
419
 
422
- /* ── AVATARS ──
423
- * Two render modes share the .cpub-av class:
424
- * <img class="cpub-av cpub-av-lg" ...> ← avatar photo
425
- * <div class="cpub-av cpub-av-lg">JD</div> ← initials fallback when no avatar
426
- *
427
- * Sizing + border-radius is shared. But `display: flex` MUST NOT apply to
428
- * the <img> — when a replaced element gets `display: flex` set, browsers
429
- * (notably Chromium) treat the img content render-box inconsistently and
430
- * the inline `object-fit: cover` is silently dropped, producing a squished
431
- * (stretched-to-box) image instead of a center-cropped one. Visible on
432
- * deveco.io blog pages where author avatars are vertical photos (e.g.
433
- * 816×1456) rendered into a 44×44 square.
434
- *
435
- * Fix: scope display:flex centering to the div variant only.
436
- */
437
- .cpub-av {
438
- --cpub-av-size: 28px;
439
- width: var(--cpub-av-size);
440
- height: var(--cpub-av-size);
441
- /* Hard-lock to a square. Without min/max clamps, a global img reset or a
442
- dropped dimension lets the <img> fall back to its intrinsic aspect ratio,
443
- so a portrait photo renders as a tall oval (the deveco blog-avatar bug -
444
- visible even on wide viewports, so it's not flex compression). min/max on
445
- BOTH axes clamp the used size regardless of what sets width/height. */
446
- min-width: var(--cpub-av-size);
447
- max-width: var(--cpub-av-size);
448
- min-height: var(--cpub-av-size);
449
- max-height: var(--cpub-av-size);
450
- border-radius: 50%;
451
- background: var(--surface3);
452
- border: var(--border-width-default) solid var(--border);
453
- flex-shrink: 0;
454
- }
455
-
456
- div.cpub-av {
457
- display: flex;
458
- align-items: center;
459
- justify-content: center;
460
- font-size: 10px;
461
- font-weight: 600;
462
- color: var(--text-dim);
463
- font-family: var(--font-mono);
464
- }
465
-
466
- /* Defensive: even when consumers forget the inline `object-fit:cover`,
467
- img.cpub-av crops instead of stretching. */
468
- img.cpub-av {
469
- object-fit: cover;
470
- }
471
-
472
- .cpub-av-lg { --cpub-av-size: 44px; font-size: 14px; }
473
- .cpub-av-xl { --cpub-av-size: 64px; font-size: 18px; }
420
+ /* Author avatar lives in <ContentAvatar> (its .cpub-av CSS travels with it). */
474
421
 
475
422
  /* ── AUTHOR ROW ── */
476
423
  .cpub-author-row {
@@ -345,6 +345,10 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
345
345
  </div>
346
346
  </div>
347
347
  </div>
348
+
349
+ <!-- Discussion — explainers are a federating content type like projects/blogs,
350
+ so readers can comment (parity with ProjectView/ArticleView). -->
351
+ <CommentSection :target-type="content.type" :target-id="content.id" :federated-content-id="federatedId" />
348
352
  </main>
349
353
  </div>
350
354
  </div>
@@ -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;