@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
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Edit component for the `roadmap` block — a schedule timeline. House block-edit
4
+ * contract: `content` in, `update` out, immutable list ops. The contest editor
5
+ * provides a roadmap derived from the live stages/schedule under CONTEST_SCHEDULE_KEY,
6
+ * so this offers a one-click "Pull from schedule" seed; from there each milestone
7
+ * (date, title, blurb, badge, tone) is freely edited and reordered. Provided via
8
+ * BLOCK_COMPONENTS_KEY.
9
+ */
10
+ import { inject } from 'vue';
11
+ import { CONTEST_SCHEDULE_KEY } from '../../../utils/contestBlocks';
12
+ import type { RoadmapItem, RoadmapContent, RoadmapTone } from '../../../types/contestBlocks';
13
+
14
+ const props = defineProps<{ content: Record<string, unknown> }>();
15
+ const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
16
+
17
+ const TONES: { value: RoadmapTone; label: string }[] = [
18
+ { value: 'default', label: 'Default' },
19
+ { value: 'accent', label: 'Accent' },
20
+ { value: 'highlight', label: 'Highlight' },
21
+ ];
22
+
23
+ // The contest editor's live schedule (custom stages, else the core flow).
24
+ const schedule = inject(CONTEST_SCHEDULE_KEY, null);
25
+ const canPull = computed(() => !!schedule?.value?.length);
26
+
27
+ const eyebrow = computed(() => (typeof props.content.eyebrow === 'string' ? props.content.eyebrow : ''));
28
+ const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
29
+ const items = computed<RoadmapItem[]>(() => (Array.isArray(props.content.items) ? (props.content.items as RoadmapItem[]) : []));
30
+
31
+ function commit(next: Partial<RoadmapContent>): void {
32
+ emit('update', { eyebrow: eyebrow.value || undefined, heading: heading.value || undefined, items: items.value, ...next });
33
+ }
34
+ function addItem(): void {
35
+ commit({ items: [...items.value, { title: '', tone: 'default' }] });
36
+ }
37
+ function setItem(i: number, field: keyof RoadmapItem, value: string): void {
38
+ commit({ items: items.value.map((it, idx) => (idx === i ? { ...it, [field]: value || undefined } : it)) });
39
+ }
40
+ function removeItem(i: number): void {
41
+ commit({ items: items.value.filter((_, idx) => idx !== i) });
42
+ }
43
+ function move(i: number, dir: -1 | 1): void {
44
+ const j = i + dir;
45
+ if (j < 0 || j >= items.value.length) return;
46
+ const next = [...items.value];
47
+ [next[i], next[j]] = [next[j]!, next[i]!];
48
+ commit({ items: next });
49
+ }
50
+ function pullFromSchedule(): void {
51
+ commit({ items: (schedule?.value ?? []).map((it) => ({ ...it })) });
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div class="cpub-rmedit">
57
+ <div class="cpub-rmedit-header">
58
+ <div class="cpub-rmedit-icon"><i class="fa-solid fa-timeline"></i></div>
59
+ <span class="cpub-rmedit-title">Roadmap</span>
60
+ <span class="cpub-rmedit-count">{{ items.length }} {{ items.length === 1 ? 'milestone' : 'milestones' }}</span>
61
+ <button v-if="canPull" type="button" class="cpub-rmedit-add" title="Seed from this contest's stages / schedule" @click="pullFromSchedule"><i class="fa-solid fa-wand-magic-sparkles"></i> Pull from schedule</button>
62
+ <button type="button" class="cpub-rmedit-add" @click="addItem"><i class="fa-solid fa-plus"></i> Add milestone</button>
63
+ </div>
64
+
65
+ <div class="cpub-rmedit-body">
66
+ <input class="cpub-rmedit-input" type="text" :value="eyebrow" placeholder="Eyebrow (optional), e.g. Key dates, 2026" aria-label="Roadmap eyebrow" @input="commit({ eyebrow: ($event.target as HTMLInputElement).value || undefined })" />
67
+ <input class="cpub-rmedit-input cpub-rmedit-heading" type="text" :value="heading" placeholder="Heading (optional), e.g. The 18-week roadmap" aria-label="Roadmap heading" @input="commit({ heading: ($event.target as HTMLInputElement).value || undefined })" />
68
+
69
+ <div v-for="(it, i) in items" :key="i" class="cpub-rmedit-row" :class="`cpub-rmedit-${it.tone ?? 'default'}`">
70
+ <div class="cpub-rmedit-rowtop">
71
+ <input class="cpub-rmedit-input cpub-rmedit-date" type="text" :value="it.date ?? ''" placeholder="Date, e.g. Jun 30" :aria-label="`Milestone ${i + 1} date`" @input="setItem(i, 'date', ($event.target as HTMLInputElement).value)" />
72
+ <input class="cpub-rmedit-input cpub-rmedit-badge" type="text" :value="it.badge ?? ''" placeholder="Badge (optional)" :aria-label="`Milestone ${i + 1} badge`" @input="setItem(i, 'badge', ($event.target as HTMLInputElement).value)" />
73
+ <select class="cpub-rmedit-input cpub-rmedit-tone" :value="it.tone ?? 'default'" :aria-label="`Milestone ${i + 1} style`" @change="setItem(i, 'tone', ($event.target as HTMLSelectElement).value)">
74
+ <option v-for="t in TONES" :key="t.value" :value="t.value">{{ t.label }}</option>
75
+ </select>
76
+ <div class="cpub-rmedit-moves">
77
+ <button type="button" class="cpub-rmedit-move" :disabled="i === 0" :aria-label="`Move milestone ${i + 1} up`" @click="move(i, -1)"><i class="fa-solid fa-chevron-up"></i></button>
78
+ <button type="button" class="cpub-rmedit-move" :disabled="i === items.length - 1" :aria-label="`Move milestone ${i + 1} down`" @click="move(i, 1)"><i class="fa-solid fa-chevron-down"></i></button>
79
+ </div>
80
+ <button type="button" class="cpub-rmedit-remove" :aria-label="`Remove milestone ${i + 1}`" @click="removeItem(i)"><i class="fa-solid fa-xmark"></i></button>
81
+ </div>
82
+ <input class="cpub-rmedit-input" type="text" :value="it.title" placeholder="Title" :aria-label="`Milestone ${i + 1} title`" @input="setItem(i, 'title', ($event.target as HTMLInputElement).value)" />
83
+ <textarea class="cpub-rmedit-input cpub-rmedit-desc" rows="2" :value="it.description ?? ''" placeholder="Description (optional)" :aria-label="`Milestone ${i + 1} description`" @input="setItem(i, 'description', ($event.target as HTMLTextAreaElement).value)" />
84
+ </div>
85
+
86
+ <div v-if="!items.length" class="cpub-rmedit-empty" @click="canPull ? pullFromSchedule() : addItem()">
87
+ <i class="fa-solid fa-plus"></i> {{ canPull ? 'Pull milestones from the schedule' : 'Add the first milestone' }}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </template>
92
+
93
+ <style scoped>
94
+ .cpub-rmedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
95
+ .cpub-rmedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); flex-wrap: wrap; }
96
+ .cpub-rmedit-icon { font-size: 12px; color: var(--accent); }
97
+ .cpub-rmedit-title { font-size: 12px; font-weight: 600; }
98
+ .cpub-rmedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
99
+ .cpub-rmedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; }
100
+ .cpub-rmedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
101
+
102
+ .cpub-rmedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
103
+ .cpub-rmedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
104
+ .cpub-rmedit-input:focus { border-color: var(--accent); }
105
+ .cpub-rmedit-input::placeholder { color: var(--text-faint); }
106
+ .cpub-rmedit-heading { font-weight: 600; }
107
+ .cpub-rmedit-desc { resize: vertical; font-family: inherit; }
108
+
109
+ .cpub-rmedit-row { border: var(--border-width-default) solid var(--border2); border-left-width: 3px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
110
+ .cpub-rmedit-default { border-left-color: var(--border2); }
111
+ .cpub-rmedit-accent { border-left-color: var(--accent); }
112
+ .cpub-rmedit-highlight { border-left-color: var(--yellow); }
113
+ .cpub-rmedit-rowtop { display: flex; gap: 6px; align-items: center; }
114
+ .cpub-rmedit-date { width: 110px; flex-shrink: 0; }
115
+ .cpub-rmedit-badge { flex: 1; min-width: 0; }
116
+ .cpub-rmedit-tone { width: 100px; flex-shrink: 0; }
117
+ .cpub-rmedit-moves { display: inline-flex; flex-shrink: 0; }
118
+ .cpub-rmedit-move { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 9px; padding: 0 6px; }
119
+ .cpub-rmedit-move:first-child { border-right: 0; }
120
+ .cpub-rmedit-move:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
121
+ .cpub-rmedit-move:disabled { opacity: 0.4; cursor: not-allowed; }
122
+ .cpub-rmedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
123
+ .cpub-rmedit-remove:hover { border-color: var(--red-border); color: var(--red); }
124
+
125
+ .cpub-rmedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
126
+ .cpub-rmedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
127
+ </style>
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Edit component for the `sponsors` block — a logo wall. House block-edit contract:
4
+ * `content` in, `update` out, immutable list ops. Each logo can be uploaded (via
5
+ * the contest editor's UPLOAD_HANDLER_KEY) or pasted as a URL, with an alt name,
6
+ * an optional outbound link, and an optional tier label. Provided via
7
+ * BLOCK_COMPONENTS_KEY.
8
+ */
9
+ import { inject, ref } from 'vue';
10
+ import { UPLOAD_HANDLER_KEY } from '@commonpub/editor/vue';
11
+ import type { SponsorLogo, SponsorsContent } from '../../../types/contestBlocks';
12
+
13
+ const props = defineProps<{ content: Record<string, unknown> }>();
14
+ const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
15
+
16
+ const uploadHandler = inject(UPLOAD_HANDLER_KEY, undefined);
17
+ const uploadingIndex = ref<number | null>(null);
18
+
19
+ const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
20
+ const logos = computed<SponsorLogo[]>(() => (Array.isArray(props.content.logos) ? (props.content.logos as SponsorLogo[]) : []));
21
+
22
+ function commit(next: Partial<SponsorsContent>): void {
23
+ emit('update', { heading: heading.value || undefined, logos: logos.value, ...next });
24
+ }
25
+ function addLogo(): void {
26
+ commit({ logos: [...logos.value, { src: '', alt: '' }] });
27
+ }
28
+ function setLogo(i: number, field: keyof SponsorLogo, value: string): void {
29
+ commit({ logos: logos.value.map((l, idx) => (idx === i ? { ...l, [field]: value } : l)) });
30
+ }
31
+ function removeLogo(i: number): void {
32
+ commit({ logos: logos.value.filter((_, idx) => idx !== i) });
33
+ }
34
+ async function onFile(i: number, event: Event): Promise<void> {
35
+ const input = event.target as HTMLInputElement;
36
+ const file = input.files?.[0];
37
+ input.value = '';
38
+ if (!file || !uploadHandler) return;
39
+ uploadingIndex.value = i;
40
+ try {
41
+ const res = await uploadHandler(file);
42
+ setLogo(i, 'src', res.url);
43
+ } finally {
44
+ uploadingIndex.value = null;
45
+ }
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <div class="cpub-spedit">
51
+ <div class="cpub-spedit-header">
52
+ <div class="cpub-spedit-icon"><i class="fa-solid fa-handshake-angle"></i></div>
53
+ <span class="cpub-spedit-title">Sponsors</span>
54
+ <span class="cpub-spedit-count">{{ logos.length }} {{ logos.length === 1 ? 'logo' : 'logos' }}</span>
55
+ <button type="button" class="cpub-spedit-add" @click="addLogo"><i class="fa-solid fa-plus"></i> Add logo</button>
56
+ </div>
57
+
58
+ <div class="cpub-spedit-body">
59
+ <input
60
+ class="cpub-spedit-input cpub-spedit-heading"
61
+ type="text"
62
+ :value="heading"
63
+ placeholder="Eyebrow heading (optional), e.g. Sponsors"
64
+ aria-label="Sponsors heading"
65
+ @input="commit({ heading: ($event.target as HTMLInputElement).value || undefined })"
66
+ />
67
+
68
+ <div v-for="(l, i) in logos" :key="i" class="cpub-spedit-row">
69
+ <div class="cpub-spedit-thumb" :class="{ 'is-empty': !l.src }">
70
+ <img v-if="l.src" :src="l.src" :alt="l.alt || 'Sponsor logo'" />
71
+ <i v-else class="fa-regular fa-image"></i>
72
+ </div>
73
+ <div class="cpub-spedit-fields">
74
+ <div class="cpub-spedit-srcrow">
75
+ <input class="cpub-spedit-input" type="url" :value="l.src" placeholder="Logo image URL (https://…)" :aria-label="`Logo ${i + 1} image URL`" @input="setLogo(i, 'src', ($event.target as HTMLInputElement).value)" />
76
+ <label v-if="uploadHandler" class="cpub-spedit-upload" :title="`Upload logo ${i + 1}`">
77
+ <i class="fa-solid" :class="uploadingIndex === i ? 'fa-circle-notch fa-spin' : 'fa-arrow-up-from-bracket'"></i>
78
+ <input type="file" accept="image/*" class="cpub-sr-only" :aria-label="`Upload logo ${i + 1}`" @change="onFile(i, $event)" />
79
+ </label>
80
+ </div>
81
+ <div class="cpub-spedit-metarow">
82
+ <input class="cpub-spedit-input" type="text" :value="l.alt" placeholder="Name (alt text)" :aria-label="`Logo ${i + 1} name`" @input="setLogo(i, 'alt', ($event.target as HTMLInputElement).value)" />
83
+ <input class="cpub-spedit-input" type="text" :value="l.tier ?? ''" placeholder="Tier (optional)" :aria-label="`Logo ${i + 1} tier`" @input="setLogo(i, 'tier', ($event.target as HTMLInputElement).value)" />
84
+ </div>
85
+ <input class="cpub-spedit-input" type="url" :value="l.url ?? ''" placeholder="Link (https://…, optional)" :aria-label="`Logo ${i + 1} link`" @input="setLogo(i, 'url', ($event.target as HTMLInputElement).value)" />
86
+ </div>
87
+ <button type="button" class="cpub-spedit-remove" :aria-label="`Remove logo ${i + 1}`" @click="removeLogo(i)"><i class="fa-solid fa-xmark"></i></button>
88
+ </div>
89
+
90
+ <div v-if="!logos.length" class="cpub-spedit-empty" @click="addLogo"><i class="fa-solid fa-plus"></i> Add the first sponsor logo</div>
91
+ </div>
92
+ </div>
93
+ </template>
94
+
95
+ <style scoped>
96
+ .cpub-spedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
97
+ .cpub-spedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
98
+ .cpub-spedit-icon { font-size: 12px; color: var(--accent); }
99
+ .cpub-spedit-title { font-size: 12px; font-weight: 600; }
100
+ .cpub-spedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
101
+ .cpub-spedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
102
+ .cpub-spedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
103
+
104
+ .cpub-spedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
105
+ .cpub-spedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
106
+ .cpub-spedit-input:focus { border-color: var(--accent); }
107
+ .cpub-spedit-input::placeholder { color: var(--text-faint); }
108
+ .cpub-spedit-heading { font-weight: 600; }
109
+
110
+ .cpub-spedit-row { display: flex; gap: 8px; align-items: flex-start; border: var(--border-width-default) dashed var(--border2); padding: 8px; }
111
+ .cpub-spedit-thumb { width: 56px; height: 56px; flex-shrink: 0; border: var(--border-width-default) solid var(--border); background: var(--surface2); display: flex; align-items: center; justify-content: center; overflow: hidden; }
112
+ .cpub-spedit-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
113
+ .cpub-spedit-thumb.is-empty { color: var(--text-faint); font-size: 16px; }
114
+ .cpub-spedit-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
115
+ .cpub-spedit-srcrow { display: flex; gap: 6px; }
116
+ .cpub-spedit-srcrow .cpub-spedit-input { flex: 1; }
117
+ .cpub-spedit-metarow { display: flex; gap: 6px; }
118
+ .cpub-spedit-metarow .cpub-spedit-input { flex: 1; }
119
+ .cpub-spedit-upload { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 30px; border: var(--border-width-default) solid var(--border); background: var(--surface2); color: var(--text-dim); cursor: pointer; }
120
+ .cpub-spedit-upload:hover { border-color: var(--accent); color: var(--accent); }
121
+ .cpub-spedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; align-self: stretch; }
122
+ .cpub-spedit-remove:hover { border-color: var(--red-border); color: var(--red); }
123
+
124
+ .cpub-spedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
125
+ .cpub-spedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
126
+ .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
127
+ </style>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Edit component for the `table` block — an editable grid (header row + body
4
+ * rows) with add/remove row + column. Cells are plain strings (rendered as text
5
+ * in the view, so no HTML injection). House block-edit contract: `content` in,
6
+ * `update` out. Provided via BLOCK_COMPONENTS_KEY.
7
+ */
8
+ const props = defineProps<{ content: Record<string, unknown> }>();
9
+ const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
10
+
11
+ const caption = computed(() => (typeof props.content.caption === 'string' ? props.content.caption : ''));
12
+ const header = computed<string[]>(() => (Array.isArray(props.content.header) ? (props.content.header as string[]) : ['Column 1', 'Column 2']));
13
+ const rows = computed<string[][]>(() => (Array.isArray(props.content.rows) ? (props.content.rows as string[][]) : [['', '']]));
14
+
15
+ function commit(next: Partial<{ caption: string; header: string[]; rows: string[][] }>): void {
16
+ emit('update', { caption: caption.value || undefined, header: header.value, rows: rows.value, ...next });
17
+ }
18
+ function setHeader(c: number, v: string): void {
19
+ commit({ header: header.value.map((h, i) => (i === c ? v : h)) });
20
+ }
21
+ function setCell(r: number, c: number, v: string): void {
22
+ commit({ rows: rows.value.map((row, ri) => (ri === r ? row.map((cell, ci) => (ci === c ? v : cell)) : row)) });
23
+ }
24
+ function addColumn(): void {
25
+ commit({ header: [...header.value, `Column ${header.value.length + 1}`], rows: rows.value.map((row) => [...row, '']) });
26
+ }
27
+ function removeColumn(c: number): void {
28
+ if (header.value.length <= 1) return;
29
+ commit({ header: header.value.filter((_, i) => i !== c), rows: rows.value.map((row) => row.filter((_, i) => i !== c)) });
30
+ }
31
+ function addRow(): void {
32
+ commit({ rows: [...rows.value, header.value.map(() => '')] });
33
+ }
34
+ function removeRow(r: number): void {
35
+ commit({ rows: rows.value.filter((_, i) => i !== r) });
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <div class="cpub-tedit">
41
+ <div class="cpub-tedit-header">
42
+ <div class="cpub-tedit-icon"><i class="fa-solid fa-table"></i></div>
43
+ <span class="cpub-tedit-title">Table</span>
44
+ <button type="button" class="cpub-tedit-act" @click="addColumn"><i class="fa-solid fa-plus"></i> Column</button>
45
+ <button type="button" class="cpub-tedit-act" @click="addRow"><i class="fa-solid fa-plus"></i> Row</button>
46
+ </div>
47
+
48
+ <div class="cpub-tedit-body">
49
+ <input class="cpub-tedit-input cpub-tedit-caption" type="text" :value="caption" placeholder="Caption (optional)" aria-label="Table caption" @input="commit({ caption: ($event.target as HTMLInputElement).value || undefined })" />
50
+
51
+ <div class="cpub-tedit-scroll">
52
+ <table class="cpub-tedit-grid">
53
+ <thead>
54
+ <tr>
55
+ <th v-for="(h, c) in header" :key="c" class="cpub-tedit-th">
56
+ <input class="cpub-tedit-input cpub-tedit-hcell" type="text" :value="h" :placeholder="`Column ${c + 1}`" :aria-label="`Header ${c + 1}`" @input="setHeader(c, ($event.target as HTMLInputElement).value)" />
57
+ <button type="button" class="cpub-tedit-del" :disabled="header.length <= 1" :aria-label="`Remove column ${c + 1}`" title="Remove column" @click="removeColumn(c)"><i class="fa-solid fa-xmark"></i></button>
58
+ </th>
59
+ <th class="cpub-tedit-rowspacer" aria-hidden="true"></th>
60
+ </tr>
61
+ </thead>
62
+ <tbody>
63
+ <tr v-for="(row, r) in rows" :key="r">
64
+ <td v-for="(_, c) in header" :key="c">
65
+ <input class="cpub-tedit-input" type="text" :value="row[c] ?? ''" :aria-label="`Row ${r + 1} column ${c + 1}`" @input="setCell(r, c, ($event.target as HTMLInputElement).value)" />
66
+ </td>
67
+ <td class="cpub-tedit-rowdel">
68
+ <button type="button" class="cpub-tedit-del" :aria-label="`Remove row ${r + 1}`" title="Remove row" @click="removeRow(r)"><i class="fa-solid fa-xmark"></i></button>
69
+ </td>
70
+ </tr>
71
+ </tbody>
72
+ </table>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped>
79
+ .cpub-tedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
80
+ .cpub-tedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
81
+ .cpub-tedit-icon { font-size: 12px; color: var(--accent); }
82
+ .cpub-tedit-title { font-size: 12px; font-weight: 600; margin-right: auto; }
83
+ .cpub-tedit-act { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
84
+ .cpub-tedit-act:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
85
+ .cpub-tedit-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
86
+ .cpub-tedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
87
+ .cpub-tedit-input:focus { border-color: var(--accent); }
88
+ .cpub-tedit-input::placeholder { color: var(--text-faint); }
89
+ .cpub-tedit-caption { font-style: italic; }
90
+ .cpub-tedit-scroll { overflow-x: auto; }
91
+ .cpub-tedit-grid { border-collapse: collapse; }
92
+ .cpub-tedit-th { padding: 0 4px 6px 0; vertical-align: bottom; }
93
+ .cpub-tedit-hcell { font-weight: 700; min-width: 120px; }
94
+ .cpub-tedit-grid td { padding: 0 4px 4px 0; }
95
+ .cpub-tedit-grid td .cpub-tedit-input { min-width: 120px; }
96
+ .cpub-tedit-del { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 4px 6px; margin-top: 3px; }
97
+ .cpub-tedit-del:hover:not(:disabled) { border-color: var(--red-border); color: var(--red); }
98
+ .cpub-tedit-del:disabled { opacity: 0.35; cursor: not-allowed; }
99
+ .cpub-tedit-rowspacer { width: 28px; }
100
+ .cpub-tedit-rowdel { width: 28px; text-align: center; }
101
+ </style>
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Edit component for the `tabs` block — a tabbed container whose panels each hold
4
+ * nested blocks (the buildStep pattern, generalized). Solves tabbed / multiple
5
+ * rule sets (e.g. Track A vs Track B), each panel a full rich body. House
6
+ * block-edit contract: `content` in, `update` out. Provided via
7
+ * BLOCK_COMPONENTS_KEY. The nested palette (`PANEL_GROUPS`) deliberately omits
8
+ * container blocks so you can't nest tabs-in-tabs.
9
+ */
10
+ import type { BlockTuple } from '@commonpub/editor';
11
+ import type { BlockTypeGroup } from '@commonpub/editor/vue';
12
+ import ContestTabPanel from './ContestTabPanel.vue';
13
+
14
+ interface TabDef { label: string; blocks: BlockTuple[] }
15
+
16
+ const props = defineProps<{ content: Record<string, unknown> }>();
17
+ const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
18
+
19
+ // Blocks offered inside a panel — rich content, but NO container blocks (tabs,
20
+ // buildStep) so nesting can't recurse.
21
+ const PANEL_GROUPS: BlockTypeGroup[] = [
22
+ { name: 'Basic', blocks: [
23
+ { type: 'paragraph', label: 'Text', icon: 'fa-align-left', description: 'Body text' },
24
+ { type: 'heading', label: 'Heading', icon: 'fa-heading', description: 'Section header' },
25
+ { type: 'image', label: 'Image', icon: 'fa-image', description: 'Upload or embed' },
26
+ { type: 'code_block', label: 'Code', icon: 'fa-code', description: 'Code block' },
27
+ ] },
28
+ { name: 'Rich', blocks: [
29
+ { type: 'callout', label: 'Tip', icon: 'fa-lightbulb', description: 'Tip callout', attrs: { variant: 'tip' } },
30
+ { type: 'callout', label: 'Warning', icon: 'fa-triangle-exclamation', description: 'Warning callout', attrs: { variant: 'warning' } },
31
+ { type: 'blockquote', label: 'Quote', icon: 'fa-quote-left', description: 'Blockquote' },
32
+ { type: 'horizontal_rule', label: 'Divider', icon: 'fa-minus', description: 'Visual separator' },
33
+ { type: 'table', label: 'Table', icon: 'fa-table', description: 'Responsive data table' },
34
+ { type: 'criteriaBar', label: 'Criteria Bar', icon: 'fa-chart-simple', description: 'Weighted criteria bar' },
35
+ { type: 'markdown', label: 'Markdown', icon: 'fa-brands fa-markdown', description: 'Raw markdown block' },
36
+ { type: 'html', label: 'HTML', icon: 'fa-code', description: 'Raw HTML (sanitized on render)' },
37
+ ] },
38
+ ];
39
+
40
+ const tabs = computed<TabDef[]>(() =>
41
+ Array.isArray(props.content.tabs)
42
+ ? (props.content.tabs as TabDef[]).map((t) => ({ label: t?.label ?? '', blocks: Array.isArray(t?.blocks) ? t.blocks : [] }))
43
+ : [],
44
+ );
45
+ const active = ref(0);
46
+ watchEffect(() => { if (active.value >= tabs.value.length) active.value = Math.max(0, tabs.value.length - 1); });
47
+
48
+ const urlKey = computed(() => (typeof props.content.urlKey === 'string' ? props.content.urlKey : ''));
49
+ function commit(next: TabDef[]): void {
50
+ emit('update', { tabs: next, urlKey: urlKey.value || undefined });
51
+ }
52
+ function setUrlKey(v: string): void {
53
+ emit('update', { tabs: tabs.value, urlKey: v.trim() || undefined });
54
+ }
55
+ function setLabel(i: number, label: string): void {
56
+ commit(tabs.value.map((t, idx) => (idx === i ? { ...t, label } : t)));
57
+ }
58
+ function setBlocks(i: number, blocks: BlockTuple[]): void {
59
+ commit(tabs.value.map((t, idx) => (idx === i ? { ...t, blocks } : t)));
60
+ }
61
+ function addTab(): void {
62
+ commit([...tabs.value, { label: `Tab ${tabs.value.length + 1}`, blocks: [] }]);
63
+ void nextTick(() => { active.value = tabs.value.length - 1; });
64
+ }
65
+ function removeTab(i: number): void {
66
+ commit(tabs.value.filter((_, idx) => idx !== i));
67
+ }
68
+ function moveTab(i: number, dir: -1 | 1): void {
69
+ const j = i + dir;
70
+ if (j < 0 || j >= tabs.value.length) return;
71
+ const next = [...tabs.value];
72
+ [next[i], next[j]] = [next[j]!, next[i]!];
73
+ commit(next);
74
+ active.value = j;
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <div class="cpub-tabsedit">
80
+ <div class="cpub-tabsedit-header">
81
+ <div class="cpub-tabsedit-icon"><i class="fa-solid fa-folder-tree"></i></div>
82
+ <span class="cpub-tabsedit-title">Tabs</span>
83
+ <span class="cpub-tabsedit-count">{{ tabs.length }} {{ tabs.length === 1 ? 'tab' : 'tabs' }}</span>
84
+ <button type="button" class="cpub-tabsedit-add" @click="addTab"><i class="fa-solid fa-plus"></i> Add tab</button>
85
+ </div>
86
+
87
+ <div v-if="tabs.length" class="cpub-tabsedit-urlkey">
88
+ <input
89
+ class="cpub-tabsedit-keyinput"
90
+ type="text"
91
+ :value="urlKey"
92
+ placeholder="Deep-link key (optional), e.g. track"
93
+ aria-label="Deep-link URL key"
94
+ @input="setUrlKey(($event.target as HTMLInputElement).value)"
95
+ />
96
+ <span class="cpub-tabsedit-keyhint">Shareable: <code>?{{ urlKey || 'key' }}=&lt;tab&gt;</code> opens that tab.</span>
97
+ </div>
98
+
99
+ <div v-if="tabs.length" class="cpub-tabsedit-bar" role="group" aria-label="Select a tab to edit">
100
+ <div v-for="(t, i) in tabs" :key="i" class="cpub-tabsedit-tab" :class="{ 'cpub-tabsedit-tab-on': active === i }">
101
+ <button type="button" class="cpub-tabsedit-select" :aria-pressed="active === i" @click="active = i">
102
+ {{ t.label || `Tab ${i + 1}` }}
103
+ </button>
104
+ </div>
105
+ </div>
106
+
107
+ <div v-if="tabs.length" class="cpub-tabsedit-panel">
108
+ <div class="cpub-tabsedit-meta">
109
+ <input
110
+ class="cpub-tabsedit-label"
111
+ type="text"
112
+ :value="tabs[active]?.label"
113
+ :placeholder="`Tab ${active + 1} label`"
114
+ aria-label="Active tab label"
115
+ @input="setLabel(active, ($event.target as HTMLInputElement).value)"
116
+ />
117
+ <div class="cpub-tabsedit-meta-actions">
118
+ <button type="button" class="cpub-tabsedit-mbtn" :disabled="active === 0" aria-label="Move tab left" title="Move left" @click="moveTab(active, -1)"><i class="fa-solid fa-arrow-left"></i></button>
119
+ <button type="button" class="cpub-tabsedit-mbtn" :disabled="active === tabs.length - 1" aria-label="Move tab right" title="Move right" @click="moveTab(active, 1)"><i class="fa-solid fa-arrow-right"></i></button>
120
+ <button type="button" class="cpub-tabsedit-mbtn cpub-tabsedit-mbtn--danger" aria-label="Remove this tab" title="Remove tab" @click="removeTab(active)"><i class="fa-solid fa-trash"></i></button>
121
+ </div>
122
+ </div>
123
+ <!-- One panel editor at a time, keyed by tab so each gets a clean editor;
124
+ parent content persists across switches. -->
125
+ <ContestTabPanel
126
+ :key="active"
127
+ :blocks="tabs[active]?.blocks ?? []"
128
+ :groups="PANEL_GROUPS"
129
+ @update:blocks="setBlocks(active, $event)"
130
+ />
131
+ </div>
132
+
133
+ <div v-else class="cpub-tabsedit-empty" @click="addTab"><i class="fa-solid fa-plus"></i> Add the first tab (e.g. Track A, Track B)</div>
134
+ </div>
135
+ </template>
136
+
137
+ <style scoped>
138
+ .cpub-tabsedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
139
+ .cpub-tabsedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
140
+ .cpub-tabsedit-icon { font-size: 12px; color: var(--accent); }
141
+ .cpub-tabsedit-title { font-size: 12px; font-weight: 600; }
142
+ .cpub-tabsedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
143
+ .cpub-tabsedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
144
+ .cpub-tabsedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
145
+
146
+ .cpub-tabsedit-urlkey { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px 0; }
147
+ .cpub-tabsedit-keyinput { padding: 5px 8px; font-size: 11px; font-family: var(--font-mono); background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; min-width: 220px; }
148
+ .cpub-tabsedit-keyinput:focus { border-color: var(--accent); }
149
+ .cpub-tabsedit-keyhint { font-size: 10px; color: var(--text-faint); }
150
+ .cpub-tabsedit-keyhint code { font-family: var(--font-mono); color: var(--text-dim); }
151
+
152
+ .cpub-tabsedit-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 10px 14px 0; }
153
+ .cpub-tabsedit-tab { display: inline-flex; }
154
+ .cpub-tabsedit-select { font-size: 12px; font-weight: 600; padding: 6px 12px; background: transparent; border: var(--border-width-default) solid var(--border2); border-bottom: none; color: var(--text-dim); cursor: pointer; }
155
+ .cpub-tabsedit-tab-on .cpub-tabsedit-select { color: var(--accent); background: var(--accent-bg); border-color: var(--accent-border); }
156
+
157
+ .cpub-tabsedit-panel { padding: 12px 14px; border-top: var(--border-width-default) solid var(--border2); }
158
+ .cpub-tabsedit-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
159
+ .cpub-tabsedit-label { flex: 1; padding: 6px 8px; font-size: 12px; font-weight: 600; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
160
+ .cpub-tabsedit-label:focus { border-color: var(--accent); }
161
+ .cpub-tabsedit-meta-actions { display: inline-flex; gap: 3px; }
162
+ .cpub-tabsedit-mbtn { width: 26px; height: 26px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-faint); cursor: pointer; font-size: 10px; display: inline-flex; align-items: center; justify-content: center; }
163
+ .cpub-tabsedit-mbtn:hover:not(:disabled) { color: var(--text); }
164
+ .cpub-tabsedit-mbtn:disabled { opacity: 0.35; cursor: not-allowed; }
165
+ .cpub-tabsedit-mbtn--danger:hover:not(:disabled) { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
166
+ .cpub-tabsedit-empty { padding: 18px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; margin: 12px 14px; border: var(--border-width-default) dashed var(--border2); }
167
+ .cpub-tabsedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
168
+ </style>
@@ -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>>({