@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
@@ -14,6 +14,8 @@ 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
+
17
19
  const blockTypes: BlockTypeGroup[] = [
18
20
  {
19
21
  name: 'Text',
@@ -130,10 +132,7 @@ function onAssetUpload(event: Event): void {
130
132
  }
131
133
 
132
134
  uploading.value = true;
133
- const formData = new FormData();
134
- formData.append('file', file);
135
- formData.append('purpose', 'content');
136
- $fetch<{ url: string; originalName: string; sizeBytes: number; mimeType: string }>('/api/files/upload', { method: 'POST', body: formData })
135
+ uploadFile<{ url: string; originalName: string; sizeBytes: number; mimeType: string }>(file, 'content')
137
136
  .then((res) => {
138
137
  uploadedFiles.value.unshift({
139
138
  name: res.originalName || file.name,
@@ -181,12 +180,9 @@ function onCoverUpload(event: Event): void {
181
180
  if (!input.files?.length) return;
182
181
  const file = input.files[0];
183
182
  if (!file) return;
184
- const formData = new FormData();
185
- formData.append('file', file);
186
- formData.append('purpose', 'cover');
187
- $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
188
- .then((res) => { updateMeta('coverImageUrl', res.url); })
189
- .catch(() => { /* silent fallback */ });
183
+ uploadFile(file, 'cover')
184
+ .then((res) => { updateMeta('coverImageUrl', res.url); uploadError.value = ''; })
185
+ .catch((err) => { uploadError.value = err?.data?.statusMessage || 'Cover image upload failed'; });
190
186
  }
191
187
 
192
188
  function onCoverUrl(): void {
@@ -206,12 +202,9 @@ function onBannerUpload(event: Event): void {
206
202
  if (!input.files?.length) return;
207
203
  const file = input.files[0];
208
204
  if (!file) return;
209
- const formData = new FormData();
210
- formData.append('file', file);
211
- formData.append('purpose', 'cover');
212
- $fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
213
- .then((res) => { updateMeta('bannerUrl', res.url); })
214
- .catch(() => {});
205
+ uploadFile(file, 'cover')
206
+ .then((res) => { updateMeta('bannerUrl', res.url); uploadError.value = ''; })
207
+ .catch((err) => { uploadError.value = err?.data?.statusMessage || 'Banner image upload failed'; });
215
208
  }
216
209
 
217
210
  function removeBanner(): void {
@@ -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 activeLeftTab = ref<'modules' | 'structure' | 'assets'>('modules');
18
21
 
19
22
  // Interactive blocks FIRST — they're the core of an explainer
@@ -118,10 +121,7 @@ function onAssetUpload(event: Event): void {
118
121
  if (!input.files?.length) return;
119
122
  const file = input.files[0];
120
123
  if (!file) return;
121
- const formData = new FormData();
122
- formData.append('file', file);
123
- formData.append('purpose', 'content');
124
- $fetch<{ url: string; originalName: string; size: number }>('/api/files/upload', { method: 'POST', body: formData })
124
+ uploadFile<{ url: string; originalName: string; size: number }>(file, 'content')
125
125
  .then((res) => {
126
126
  uploadedFiles.value.unshift({
127
127
  name: res.originalName || file.name,
@@ -129,7 +129,10 @@ function onAssetUpload(event: Event): void {
129
129
  type: file.type.startsWith('image/') ? 'image' : 'file',
130
130
  });
131
131
  })
132
- .catch(() => { /* silent */ });
132
+ .catch((err: unknown) => {
133
+ const msg = (err as { data?: { statusMessage?: string } })?.data?.statusMessage;
134
+ toast.error(msg || 'Upload failed');
135
+ });
133
136
  }
134
137
 
135
138
  const openSections = ref<Record<string, boolean>>({
@@ -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>