@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
@@ -42,6 +42,7 @@ async function handleShare(): Promise<void> {
42
42
  </script>
43
43
 
44
44
  <template>
45
+ <Teleport to="body">
45
46
  <div class="cpub-modal-backdrop" @click.self="emit('close')">
46
47
  <div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-share-hub-title">
47
48
  <div class="cpub-modal-header">
@@ -83,6 +84,7 @@ async function handleShare(): Promise<void> {
83
84
  </template>
84
85
  </div>
85
86
  </div>
87
+ </Teleport>
86
88
  </template>
87
89
 
88
90
  <style scoped>
@@ -90,7 +92,7 @@ async function handleShare(): Promise<void> {
90
92
  position: fixed;
91
93
  inset: 0;
92
94
  background: var(--color-surface-scrim, rgba(0,0,0,0.5));
93
- z-index: 1000;
95
+ z-index: var(--z-modal-backdrop);
94
96
  display: flex;
95
97
  align-items: center;
96
98
  justify-content: center;
@@ -13,9 +13,12 @@
13
13
  */
14
14
  import { computed } from 'vue';
15
15
  import { useSectionRegistry } from '../../../sections/registry';
16
- import type { SectionCategory } from '@commonpub/ui';
16
+ import type { SectionCategory, SectionDefinition } from '@commonpub/ui';
17
17
  import AdminLayoutsPaletteTile from './AdminLayoutsPaletteTile.vue';
18
18
 
19
+ // Forward a tile's keyboard-insert request up to the editor page.
20
+ const emit = defineEmits<{ insert: [SectionDefinition] }>();
21
+
19
22
  const CATEGORY_LABELS: Record<SectionCategory, string> = {
20
23
  layout: 'Layout',
21
24
  content: 'Content',
@@ -65,6 +68,7 @@ const grouped = computed(() => {
65
68
  v-for="section in group.sections"
66
69
  :key="section.type"
67
70
  :section="section"
71
+ @insert="emit('insert', $event)"
68
72
  />
69
73
  </ul>
70
74
  </div>
@@ -28,6 +28,10 @@ const props = defineProps<{
28
28
  section: SectionDefinition;
29
29
  }>();
30
30
 
31
+ // Keyboard alternative to drag (WCAG 2.1.1): Enter/Space inserts the section.
32
+ // Mouse/touch users drag; keyboard users press to insert into the layout.
33
+ const emit = defineEmits<{ insert: [SectionDefinition] }>();
34
+
31
35
  const tileRef = ref<HTMLElement | null>(null);
32
36
 
33
37
  // dnd-kit's payload factory takes [index, items]. A palette tile is
@@ -52,8 +56,10 @@ makeDraggable(
52
56
  class="cpub-admin-layouts-palette-tile"
53
57
  :data-section-type="section.type"
54
58
  :data-section-status="section.status ?? 'stable'"
55
- :aria-label="`Drag to insert ${section.name} (${section.type}) section`"
59
+ :aria-label="`Insert ${section.name} (${section.type}) section. Drag onto a row, or press Enter.`"
56
60
  tabindex="0"
61
+ @keydown.enter.prevent="emit('insert', section)"
62
+ @keydown.space.prevent="emit('insert', section)"
57
63
  >
58
64
  <i :class="['cpub-admin-layouts-palette-tile-icon', section.icon]"></i>
59
65
  <div class="cpub-admin-layouts-palette-tile-body">
@@ -182,7 +182,7 @@ const VIEWPORTS: Array<{ value: 'mobile' | 'tablet' | 'desktop'; icon: string; l
182
182
  -->
183
183
  <div
184
184
  class="cpub-admin-layouts-toolbar-viewport"
185
- role="radiogroup"
185
+ role="group"
186
186
  aria-label="Preview viewport"
187
187
  >
188
188
  <button
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-side renderer for the `compareColumns` block — side-by-side guidance
4
+ * columns (the "Encouraged / Out of scope" do-vs-don't pattern). Each column is
5
+ * tinted by its tone (positive=green, negative=red, neutral=accent) with a matching
6
+ * header + per-item icon, plus an optional eyebrow, heading, and footer note.
7
+ * Registered in BlockContentRenderer's map. All colors via var(--*).
8
+ */
9
+ import type { CompareColumn, CompareTone } from '../../types/contestBlocks';
10
+
11
+ const props = defineProps<{ content: Record<string, unknown> }>();
12
+
13
+ const eyebrow = computed(() => (typeof props.content.eyebrow === 'string' ? props.content.eyebrow.trim() : ''));
14
+ const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading.trim() : ''));
15
+ const note = computed(() => (typeof props.content.note === 'string' ? props.content.note.trim() : ''));
16
+ const columns = computed<CompareColumn[]>(() =>
17
+ Array.isArray(props.content.columns)
18
+ ? (props.content.columns as CompareColumn[])
19
+ .filter((c) => c && (c.title?.trim() || (c.items ?? []).some((i) => (i ?? '').trim())))
20
+ .map((c) => ({ tone: c.tone ?? 'neutral', title: (c.title ?? '').trim(), items: (c.items ?? []).filter((i) => (i ?? '').trim()) }))
21
+ : [],
22
+ );
23
+
24
+ const toneIcon = (tone: CompareTone): string =>
25
+ tone === 'positive' ? 'fa-circle-check' : tone === 'negative' ? 'fa-circle-xmark' : 'fa-circle-info';
26
+ </script>
27
+
28
+ <template>
29
+ <section v-if="columns.length" class="cpub-cmp">
30
+ <p v-if="eyebrow" class="cpub-cmp-eyebrow"><i class="fa-solid fa-circle-dot" aria-hidden="true"></i> {{ eyebrow }}</p>
31
+ <h3 v-if="heading" class="cpub-cmp-heading">{{ heading }}</h3>
32
+
33
+ <div class="cpub-cmp-grid" :style="{ '--cpub-cmp-cols': Math.min(columns.length, 3) }">
34
+ <div v-for="(c, ci) in columns" :key="ci" class="cpub-cmp-col" :class="`cpub-cmp-${c.tone}`">
35
+ <p class="cpub-cmp-col-head"><i class="fa-solid" :class="toneIcon(c.tone)" aria-hidden="true"></i> {{ c.title }}</p>
36
+ <ul class="cpub-cmp-list">
37
+ <li v-for="(item, ii) in c.items" :key="ii" class="cpub-cmp-item">
38
+ <i class="fa-solid cpub-cmp-item-icon" :class="toneIcon(c.tone)" aria-hidden="true"></i>
39
+ <span>{{ item }}</span>
40
+ </li>
41
+ </ul>
42
+ </div>
43
+ </div>
44
+
45
+ <p v-if="note" class="cpub-cmp-note"><i class="fa-regular fa-circle-question" aria-hidden="true"></i> {{ note }}</p>
46
+ </section>
47
+ </template>
48
+
49
+ <style scoped>
50
+ .cpub-cmp { margin: var(--space-4) 0; }
51
+ .cpub-cmp-eyebrow {
52
+ font-family: var(--font-mono); font-size: 11px; font-weight: 700; text-transform: uppercase;
53
+ letter-spacing: 0.1em; color: var(--accent); margin: 0 0 8px; display: flex; align-items: center; gap: 7px;
54
+ }
55
+ .cpub-cmp-eyebrow i { font-size: 8px; }
56
+ .cpub-cmp-heading { font-size: var(--text-lg); font-weight: 700; color: var(--text); margin: 0 0 16px; line-height: 1.3; }
57
+
58
+ .cpub-cmp-grid { display: grid; grid-template-columns: repeat(var(--cpub-cmp-cols, 2), 1fr); gap: 16px; }
59
+
60
+ .cpub-cmp-col {
61
+ border: var(--border-width-default) solid var(--border); border-radius: var(--radius);
62
+ padding: 16px 18px; background: var(--surface);
63
+ }
64
+ /* Tone tints — left accent edge + tinted surface, mirroring the callout family. */
65
+ .cpub-cmp-positive { background: var(--green-bg); border-color: var(--green-border); }
66
+ .cpub-cmp-negative { background: var(--red-bg); border-color: var(--red-border); }
67
+ .cpub-cmp-neutral { background: var(--accent-bg); border-color: var(--accent-border); }
68
+
69
+ .cpub-cmp-col-head {
70
+ display: flex; align-items: center; gap: 8px; margin: 0 0 12px;
71
+ font-size: var(--text-sm); font-weight: 700;
72
+ }
73
+ .cpub-cmp-positive .cpub-cmp-col-head { color: var(--green); }
74
+ .cpub-cmp-negative .cpub-cmp-col-head { color: var(--red); }
75
+ .cpub-cmp-neutral .cpub-cmp-col-head { color: var(--accent); }
76
+
77
+ .cpub-cmp-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
78
+ .cpub-cmp-item { display: flex; gap: 9px; font-size: var(--text-sm); line-height: 1.55; color: var(--text-dim); }
79
+ .cpub-cmp-item-icon { flex-shrink: 0; margin-top: 3px; font-size: 12px; }
80
+ .cpub-cmp-positive .cpub-cmp-item-icon { color: var(--green); }
81
+ .cpub-cmp-negative .cpub-cmp-item-icon { color: var(--red); }
82
+ .cpub-cmp-neutral .cpub-cmp-item-icon { color: var(--accent); }
83
+
84
+ .cpub-cmp-note {
85
+ margin: 16px 0 0; padding: 12px 14px; font-size: var(--text-sm); color: var(--text-dim); line-height: 1.55;
86
+ background: var(--surface2); border: var(--border-width-default) solid var(--border);
87
+ border-radius: var(--radius); display: flex; gap: 9px; align-items: baseline;
88
+ }
89
+ .cpub-cmp-note i { color: var(--text-faint); flex-shrink: 0; }
90
+
91
+ @media (max-width: 640px) { .cpub-cmp-grid { grid-template-columns: 1fr; } }
92
+ </style>
@@ -26,6 +26,14 @@ import BlockMathView from './BlockMathView.vue';
26
26
  import BlockGalleryView from './BlockGalleryView.vue';
27
27
  import BlockSectionHeaderView from './BlockSectionHeaderView.vue';
28
28
  import BlockMarkdownView from './BlockMarkdownView.vue';
29
+ import BlockHtmlView from './BlockHtmlView.vue';
30
+ import BlockCriteriaBarView from './BlockCriteriaBarView.vue';
31
+ import BlockTableView from './BlockTableView.vue';
32
+ import BlockTabsView from './BlockTabsView.vue';
33
+ import BlockJudgesShowcaseView from './BlockJudgesShowcaseView.vue';
34
+ import BlockSponsorsView from './BlockSponsorsView.vue';
35
+ import BlockCompareColumnsView from './BlockCompareColumnsView.vue';
36
+ import BlockRoadmapView from './BlockRoadmapView.vue';
29
37
  import type { BlockTuple } from '@commonpub/editor';
30
38
 
31
39
  const props = defineProps<{
@@ -52,6 +60,7 @@ const componentMap: Record<string, unknown> = {
52
60
  callout: BlockCalloutView,
53
61
  divider: BlockDividerView,
54
62
  horizontalRule: BlockDividerView,
63
+ horizontal_rule: BlockDividerView,
55
64
  video: BlockVideoView,
56
65
  embed: BlockEmbedView,
57
66
  partsList: BlockPartsListView,
@@ -67,6 +76,14 @@ const componentMap: Record<string, unknown> = {
67
76
  gallery: BlockGalleryView,
68
77
  sectionHeader: BlockSectionHeaderView,
69
78
  markdown: BlockMarkdownView,
79
+ html: BlockHtmlView,
80
+ criteriaBar: BlockCriteriaBarView,
81
+ table: BlockTableView,
82
+ tabs: BlockTabsView,
83
+ judgesShowcase: BlockJudgesShowcaseView,
84
+ sponsors: BlockSponsorsView,
85
+ compareColumns: BlockCompareColumnsView,
86
+ roadmap: BlockRoadmapView,
70
87
  };
71
88
 
72
89
  const visibleBlocks = computed(() => {
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-only view for the `criteriaBar` block — judging criteria as one stacked
4
+ * weighted bar + legend. Thin wrapper over the shared CpubCriteriaBar so the block,
5
+ * the editor preview, and the public Judging Criteria section all render identically.
6
+ */
7
+ import type { CriteriaBarItem } from '../../utils/contestBlocks';
8
+
9
+ defineProps<{
10
+ content: { heading?: string; items?: CriteriaBarItem[]; showLegend?: boolean };
11
+ }>();
12
+ </script>
13
+
14
+ <template>
15
+ <CpubCriteriaBar
16
+ :items="content.items"
17
+ :heading="content.heading"
18
+ :show-legend="content.showLegend !== false"
19
+ class="cpub-block-cbar"
20
+ />
21
+ </template>
22
+
23
+ <style scoped>
24
+ .cpub-block-cbar { margin: 0 0 18px; }
25
+ </style>
@@ -46,6 +46,11 @@ const images = computed<GalleryImage[]>(() => {
46
46
  object-fit: cover;
47
47
  display: block;
48
48
  border: var(--border-width-default) solid var(--border);
49
+ /* Defend against the global `.cpub-prose img` rule (prose.css) leaking its
50
+ box-shadow + margin into this scoped block when rendered inside prose
51
+ (the margin would otherwise disrupt the gallery grid). */
52
+ box-shadow: none;
53
+ margin: 0;
49
54
  }
50
55
 
51
56
  .cpub-gallery-caption {
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-only view for the `html` block — author-supplied raw HTML rendered through
4
+ * the rich-HTML sanitizer (allowlist; scripts, event handlers, and unsafe URLs are
5
+ * stripped) with color neutralization so a pasted light-mode snippet stays readable
6
+ * in dark mode. Same security + theming path as the contest "Full HTML" fields and
7
+ * CustomHtmlSection; replaces the renderer's anonymous `content.html` fallback for
8
+ * this block type.
9
+ */
10
+ import { sanitizeRichHtml } from '../../composables/useSanitize';
11
+
12
+ const props = defineProps<{ content: { html?: string } }>();
13
+
14
+ const safeHtml = computed(() =>
15
+ typeof props.content.html === 'string' ? sanitizeRichHtml(props.content.html, { neutralizeColors: true }) : '',
16
+ );
17
+ </script>
18
+
19
+ <template>
20
+ <!-- eslint-disable-next-line vue/no-v-html — sanitizeRichHtml is the XSS barrier -->
21
+ <div v-if="safeHtml" class="cpub-md-html cpub-block-html" v-html="safeHtml" />
22
+ </template>
23
+
24
+ <style scoped>
25
+ .cpub-block-html { margin: 0 0 14px; font-size: 15px; line-height: 1.7; }
26
+ </style>
@@ -53,6 +53,10 @@ const size = computed<ImageSize>(() => {
53
53
  height: auto;
54
54
  display: block;
55
55
  border: var(--border-width-default) solid var(--border);
56
+ /* Defend against the global `.cpub-prose img` rule (prose.css) leaking its
57
+ box-shadow + margin into this scoped block when rendered inside prose. */
58
+ box-shadow: none;
59
+ margin: 0;
56
60
  }
57
61
 
58
62
  .cpub-image-caption {
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-side renderer for the `judgesShowcase` contest block — avatar + name +
4
+ * title + bio cards for the contest overview. Registered in BlockContentRenderer's
5
+ * map; receives the block content object as `content`. All colors via var(--*).
6
+ */
7
+ import type { JudgesShowcaseContent, JudgeShowcaseEntry } from '../../types/contestBlocks';
8
+
9
+ const props = defineProps<{ content: Record<string, unknown> }>();
10
+
11
+ const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading.trim() : ''));
12
+ const judges = computed<JudgeShowcaseEntry[]>(() =>
13
+ Array.isArray(props.content.judges) ? (props.content.judges as JudgeShowcaseEntry[]).filter((j) => j && j.name) : [],
14
+ );
15
+
16
+ const initial = (name: string): string => (name || '?').charAt(0).toUpperCase();
17
+ const safeLink = (link?: string): string | undefined => (link && /^https?:\/\//i.test(link) ? link : undefined);
18
+ </script>
19
+
20
+ <template>
21
+ <div v-if="judges.length" class="cpub-jshow">
22
+ <h3 v-if="heading" class="cpub-jshow-heading">{{ heading }}</h3>
23
+ <div class="cpub-jshow-grid">
24
+ <div v-for="(j, i) in judges" :key="i" class="cpub-jshow-card">
25
+ <div class="cpub-jshow-av">
26
+ <img v-if="j.avatarUrl" :src="j.avatarUrl" :alt="j.name" class="cpub-jshow-av-img" />
27
+ <span v-else>{{ initial(j.name) }}</span>
28
+ </div>
29
+ <a v-if="safeLink(j.link)" :href="safeLink(j.link)" target="_blank" rel="noopener noreferrer" class="cpub-jshow-name">{{ j.name }}</a>
30
+ <span v-else class="cpub-jshow-name">{{ j.name }}</span>
31
+ <div v-if="j.title" class="cpub-jshow-title">{{ j.title }}</div>
32
+ <p v-if="j.bio" class="cpub-jshow-bio">{{ j.bio }}</p>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <style scoped>
39
+ .cpub-jshow { margin: var(--space-4) 0; }
40
+ .cpub-jshow-heading { font-size: var(--text-md); font-weight: var(--font-weight-bold); margin: 0 0 var(--space-3); color: var(--text); }
41
+ .cpub-jshow-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); }
42
+ .cpub-jshow-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center; box-shadow: var(--shadow-md); }
43
+ .cpub-jshow-av { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto var(--space-2); display: flex; align-items: center; justify-content: center; font-size: var(--text-lg); font-weight: 700; font-family: var(--font-mono); border: var(--border-width-default) solid var(--border); background: var(--surface3); color: var(--text-dim); overflow: hidden; }
44
+ .cpub-jshow-av-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
45
+ .cpub-jshow-name { font-size: var(--text-sm); font-weight: 600; color: var(--text); text-decoration: none; display: block; }
46
+ a.cpub-jshow-name:hover { color: var(--accent); }
47
+ .cpub-jshow-title { font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); margin-top: 3px; }
48
+ .cpub-jshow-bio { font-size: var(--text-sm); color: var(--text-dim); line-height: 1.6; margin: var(--space-2) 0 0; }
49
+
50
+ @media (max-width: 768px) { .cpub-jshow-grid { grid-template-columns: 1fr 1fr; } }
51
+ @media (max-width: 480px) { .cpub-jshow-grid { grid-template-columns: 1fr; } }
52
+ </style>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-side renderer for the `roadmap` block — a vertical schedule timeline with a
4
+ * connector line + nodes. Each milestone shows a date, an optional badge, a title,
5
+ * and a blurb; node + date color follow the item's tone (default=hollow accent
6
+ * ring, accent=filled accent, highlight=filled warm "finale"). Registered in
7
+ * BlockContentRenderer's map. All colors via var(--*).
8
+ */
9
+ import type { RoadmapItem, RoadmapTone } from '../../types/contestBlocks';
10
+
11
+ const props = defineProps<{ content: Record<string, unknown> }>();
12
+
13
+ const eyebrow = computed(() => (typeof props.content.eyebrow === 'string' ? props.content.eyebrow.trim() : ''));
14
+ const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading.trim() : ''));
15
+ const items = computed<RoadmapItem[]>(() =>
16
+ Array.isArray(props.content.items)
17
+ ? (props.content.items as RoadmapItem[]).filter((i) => i && ((i.title ?? '').trim() || (i.date ?? '').trim()))
18
+ : [],
19
+ );
20
+
21
+ const nodeColor = (tone?: RoadmapTone): string => (tone === 'highlight' ? 'var(--yellow)' : 'var(--accent)');
22
+ const filled = (tone?: RoadmapTone): boolean => tone === 'accent' || tone === 'highlight';
23
+ </script>
24
+
25
+ <template>
26
+ <section v-if="items.length" class="cpub-rmap">
27
+ <p v-if="eyebrow" class="cpub-rmap-eyebrow"><i class="fa-regular fa-calendar" aria-hidden="true"></i> {{ eyebrow }}</p>
28
+ <h3 v-if="heading" class="cpub-rmap-heading">{{ heading }}</h3>
29
+
30
+ <ol class="cpub-rmap-list">
31
+ <li v-for="(item, i) in items" :key="i" class="cpub-rmap-item" :class="`cpub-rmap-${item.tone ?? 'default'}`">
32
+ <div class="cpub-rmap-rail" aria-hidden="true">
33
+ <span class="cpub-rmap-dot" :style="{ borderColor: nodeColor(item.tone), background: filled(item.tone) ? nodeColor(item.tone) : 'var(--bg)' }" />
34
+ </div>
35
+ <div class="cpub-rmap-body">
36
+ <div v-if="(item.date ?? '').trim() || (item.badge ?? '').trim()" class="cpub-rmap-meta">
37
+ <span v-if="(item.date ?? '').trim()" class="cpub-rmap-date" :style="{ color: nodeColor(item.tone) }">{{ item.date }}</span>
38
+ <span v-if="(item.badge ?? '').trim()" class="cpub-rmap-badge">{{ item.badge }}</span>
39
+ </div>
40
+ <p v-if="(item.title ?? '').trim()" class="cpub-rmap-title">{{ item.title }}</p>
41
+ <p v-if="(item.description ?? '').trim()" class="cpub-rmap-desc">{{ item.description }}</p>
42
+ </div>
43
+ </li>
44
+ </ol>
45
+ </section>
46
+ </template>
47
+
48
+ <style scoped>
49
+ .cpub-rmap { margin: var(--space-4) 0; }
50
+ .cpub-rmap-eyebrow {
51
+ font-family: var(--font-mono); font-size: 11px; font-weight: 700; text-transform: uppercase;
52
+ letter-spacing: 0.1em; color: var(--accent); margin: 0 0 8px; display: flex; align-items: center; gap: 7px;
53
+ }
54
+ .cpub-rmap-eyebrow i { font-size: 11px; }
55
+ .cpub-rmap-heading { font-size: var(--text-lg); font-weight: 700; color: var(--text); margin: 0 0 18px; line-height: 1.3; }
56
+
57
+ .cpub-rmap-list { list-style: none; margin: 0; padding: 0; }
58
+ .cpub-rmap-item { display: grid; grid-template-columns: 18px 1fr; gap: 14px; }
59
+ /* The connector line lives on the rail column; it runs from each node down into
60
+ the gap to meet the next, and is trimmed on the last item. */
61
+ .cpub-rmap-rail { position: relative; display: flex; justify-content: center; }
62
+ .cpub-rmap-rail::before {
63
+ content: ''; position: absolute; top: 4px; bottom: -8px; left: 50%; width: 2px;
64
+ transform: translateX(-50%); background: var(--border);
65
+ }
66
+ .cpub-rmap-item:last-child .cpub-rmap-rail::before { display: none; }
67
+ .cpub-rmap-dot {
68
+ position: relative; z-index: 1; width: 13px; height: 13px; margin-top: 3px; border-radius: 50%;
69
+ border: 2px solid var(--accent); background: var(--bg); box-sizing: border-box; flex-shrink: 0;
70
+ }
71
+ .cpub-rmap-body { padding-bottom: 22px; min-width: 0; }
72
+ .cpub-rmap-item:last-child .cpub-rmap-body { padding-bottom: 0; }
73
+ .cpub-rmap-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
74
+ .cpub-rmap-date { font-family: var(--font-mono); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
75
+ .cpub-rmap-badge {
76
+ font-family: var(--font-mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
77
+ color: var(--text-dim); background: var(--surface2); border: var(--border-width-default) solid var(--border2);
78
+ padding: 1px 6px;
79
+ }
80
+ .cpub-rmap-title { font-size: var(--text-md); font-weight: 700; color: var(--text); margin: 0; line-height: 1.35; }
81
+ .cpub-rmap-desc { font-size: var(--text-sm); color: var(--text-dim); line-height: 1.55; margin: 4px 0 0; }
82
+
83
+ .cpub-rmap-highlight .cpub-rmap-title { color: var(--text); }
84
+ </style>
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-side renderer for the `sponsors` block — a logo wall in a card with an
4
+ * optional eyebrow heading. Logos that share a `tier` group together (tier labels
5
+ * show only when at least one logo is tiered). Registered in BlockContentRenderer's
6
+ * map. All colors via var(--*).
7
+ */
8
+ import type { SponsorLogo } from '../../types/contestBlocks';
9
+
10
+ const props = defineProps<{ content: Record<string, unknown> }>();
11
+
12
+ const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading.trim() : ''));
13
+ const logos = computed<SponsorLogo[]>(() =>
14
+ Array.isArray(props.content.logos) ? (props.content.logos as SponsorLogo[]).filter((l) => l && l.src) : [],
15
+ );
16
+ const safeLink = (url?: string): string | undefined => (url && /^https?:\/\//i.test(url) ? url : undefined);
17
+
18
+ // Group by tier, preserving first-seen order; untiered logos collect under ''.
19
+ const tiers = computed(() => {
20
+ const order: string[] = [];
21
+ const map = new Map<string, SponsorLogo[]>();
22
+ for (const l of logos.value) {
23
+ const key = (l.tier ?? '').trim();
24
+ if (!map.has(key)) { map.set(key, []); order.push(key); }
25
+ map.get(key)!.push(l);
26
+ }
27
+ return order.map((label) => ({ label, logos: map.get(label)! }));
28
+ });
29
+ const hasTiers = computed(() => tiers.value.some((t) => t.label));
30
+ </script>
31
+
32
+ <template>
33
+ <section v-if="logos.length" class="cpub-spon">
34
+ <p v-if="heading" class="cpub-spon-eyebrow">{{ heading }}</p>
35
+ <div class="cpub-spon-card">
36
+ <div v-for="(t, ti) in tiers" :key="ti" class="cpub-spon-tier">
37
+ <p v-if="hasTiers && t.label" class="cpub-spon-tier-label">{{ t.label }}</p>
38
+ <ul class="cpub-spon-row">
39
+ <li v-for="(l, i) in t.logos" :key="i" class="cpub-spon-item">
40
+ <a
41
+ v-if="safeLink(l.url)"
42
+ :href="safeLink(l.url)"
43
+ target="_blank"
44
+ rel="noopener noreferrer"
45
+ class="cpub-spon-link"
46
+ :aria-label="l.alt || 'Sponsor'"
47
+ >
48
+ <img :src="l.src" :alt="l.alt || 'Sponsor'" class="cpub-spon-img" loading="lazy" />
49
+ </a>
50
+ <img v-else :src="l.src" :alt="l.alt || 'Sponsor'" class="cpub-spon-img" loading="lazy" />
51
+ </li>
52
+ </ul>
53
+ </div>
54
+ </div>
55
+ </section>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .cpub-spon { margin: var(--space-4) 0; }
60
+ .cpub-spon-eyebrow {
61
+ text-align: center; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
62
+ text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-faint); margin: 0 0 12px;
63
+ }
64
+ .cpub-spon-card {
65
+ background: var(--surface); border: var(--border-width-default) solid var(--border);
66
+ border-radius: var(--radius); box-shadow: var(--shadow-md);
67
+ padding: 24px 28px; display: flex; flex-direction: column; gap: 20px;
68
+ }
69
+ .cpub-spon-tier { display: flex; flex-direction: column; gap: 10px; }
70
+ .cpub-spon-tier-label {
71
+ text-align: center; font-family: var(--font-mono); font-size: 10px; font-weight: 700;
72
+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-dim); margin: 0;
73
+ }
74
+ .cpub-spon-row {
75
+ list-style: none; margin: 0; padding: 0;
76
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 24px 44px;
77
+ }
78
+ .cpub-spon-item { display: flex; align-items: center; }
79
+ /* Neutralize the .cpub-prose a/img leak (border-bottom underline, border box, shadow, margin). */
80
+ .cpub-spon-link { display: flex; align-items: center; transition: opacity 0.15s; border-bottom: 0; text-decoration: none; }
81
+ .cpub-spon-link:hover { opacity: 0.7; border-bottom: 0; }
82
+ .cpub-spon-img { max-height: 48px; max-width: 180px; width: auto; height: auto; object-fit: contain; display: block; border: 0; box-shadow: none; margin: 0; }
83
+
84
+ @media (max-width: 768px) {
85
+ .cpub-spon-card { padding: 18px; }
86
+ .cpub-spon-row { gap: 18px 28px; }
87
+ .cpub-spon-img { max-height: 38px; max-width: 130px; }
88
+ }
89
+ </style>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-only view for the `table` block — a responsive data table (header row +
4
+ * body rows). Cells are plain text (rendered via `{{ }}`, so HTML is escaped — no
5
+ * injection surface). Horizontally scrolls on narrow viewports.
6
+ */
7
+ const props = defineProps<{
8
+ content: { caption?: string; header?: string[]; rows?: string[][] };
9
+ }>();
10
+
11
+ const header = computed<string[]>(() => (Array.isArray(props.content.header) ? props.content.header : []));
12
+ const rows = computed<string[][]>(() => (Array.isArray(props.content.rows) ? props.content.rows : []));
13
+ const cols = computed(() => Math.max(header.value.length, ...rows.value.map((r) => r.length), 0));
14
+ const hasContent = computed(() => header.value.some((h) => (h ?? '').trim()) || rows.value.length > 0);
15
+ </script>
16
+
17
+ <template>
18
+ <figure v-if="hasContent" class="cpub-tbl">
19
+ <figcaption v-if="content.caption" class="cpub-tbl-caption">{{ content.caption }}</figcaption>
20
+ <div class="cpub-tbl-scroll">
21
+ <table>
22
+ <thead v-if="header.length">
23
+ <tr>
24
+ <th v-for="(h, i) in header" :key="i" scope="col">{{ h }}</th>
25
+ </tr>
26
+ </thead>
27
+ <tbody>
28
+ <tr v-for="(row, r) in rows" :key="r">
29
+ <td v-for="c in cols" :key="c">{{ row[c - 1] ?? '' }}</td>
30
+ </tr>
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+ </figure>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .cpub-tbl { margin: 0 0 18px; }
39
+ .cpub-tbl-caption { font-size: 12px; color: var(--text-faint); margin-bottom: 8px; font-style: italic; }
40
+ .cpub-tbl-scroll { overflow-x: auto; border: var(--border-width-default) solid var(--border); }
41
+ .cpub-tbl table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 420px; }
42
+ .cpub-tbl thead th {
43
+ text-align: left; background: var(--surface2); color: var(--text); font-weight: 700;
44
+ font-size: 11px; letter-spacing: 0.03em; text-transform: uppercase;
45
+ padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border); vertical-align: top;
46
+ }
47
+ .cpub-tbl tbody td { padding: 10px 14px; border-top: var(--border-width-default) solid var(--border); color: var(--text-dim); vertical-align: top; }
48
+ .cpub-tbl tbody tr:first-child td { border-top: none; }
49
+ </style>