@commonpub/layer 0.82.0 → 0.83.1

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,948 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ContestEditor — the one editor shell behind BOTH the create and edit routes
4
+ * (`mode` prop). create.vue / [slug]/edit.vue are thin `layout: false` shells that
5
+ * mount this, so creating a contest is identical to editing one.
6
+ *
7
+ * Layout matches the house project/blog/explainer editor: a full-screen
8
+ * `cpub-ce-layout` with a topbar (back · title · status · autosave · View · Save)
9
+ * and a 3-panel `cpub-ce-shell` — LEFT block palette · CENTER body tabs
10
+ * (Overview/Rules/Prizes) · RIGHT settings rail (Details/Schedule/Stages/Entries/
11
+ * Prizes/Judging/Access/People/Danger). The editable form model lives in
12
+ * useContestEditor (tested in isolation). Edit-only rails (People, lifecycle
13
+ * transitions, advancement, danger zone) are gated on `mode === 'edit'`.
14
+ */
15
+ import type { Ref } from 'vue';
16
+ import { EditorBlocks, EditorSection, useBlockEditor, BLOCK_COMPONENTS_KEY, UPLOAD_HANDLER_KEY, type BlockTypeGroup } from '@commonpub/editor/vue';
17
+ import type { Serialized, ContestEntryItem } from '@commonpub/server';
18
+ import type { ContestEditorSource } from '../../composables/useContestEditor';
19
+ import JudgesShowcaseBlock from './blocks/JudgesShowcaseBlock.vue';
20
+ import HtmlBlock from './blocks/HtmlBlock.vue';
21
+ import CriteriaBarBlock from './blocks/CriteriaBarBlock.vue';
22
+ import TableBlock from './blocks/TableBlock.vue';
23
+ import TabsBlock from './blocks/TabsBlock.vue';
24
+ import SponsorsBlock from './blocks/SponsorsBlock.vue';
25
+ import CompareColumnsBlock from './blocks/CompareColumnsBlock.vue';
26
+ import RoadmapBlock from './blocks/RoadmapBlock.vue';
27
+ import { CONTEST_SCHEDULE_KEY, roadmapFromSchedule } from '../../utils/contestBlocks';
28
+
29
+ const props = defineProps<{ mode: 'create' | 'edit' }>();
30
+
31
+ const route = useRoute();
32
+ const toast = useToast();
33
+ const { extract: extractError } = useApiError();
34
+ const { user, isAdmin } = useAuth();
35
+
36
+ // Edit mode reads the contest slug from the route; create has none yet.
37
+ const slug = computed(() => (props.mode === 'edit' ? String(route.params.slug ?? '') : ''));
38
+
39
+ // Edit-mode fetch — lazy and `immediate:false` in create so it never fires there.
40
+ const { data: contest, refresh, status: contestStatus } = useLazyFetch(
41
+ () => `/api/contests/${slug.value}`,
42
+ { immediate: props.mode === 'edit' },
43
+ );
44
+ // useLazyFetch doesn't block navigation, so on a client-side nav `contest` is null
45
+ // until the fetch resolves. Treat idle/pending as "loading", not "not found".
46
+ const contestLoading = computed(() => props.mode === 'edit' && (contestStatus.value === 'idle' || contestStatus.value === 'pending'));
47
+ const isOwner = computed(() => isAdmin.value || !!(user.value?.id && contest.value?.createdById === user.value.id));
48
+ // Owner, a per-contest `editor`, or a `contest.manage` holder may edit. Owner-only
49
+ // surfaces (delete, collaborators) stay gated on `isOwner`. Create is always allowed
50
+ // (the route middleware + server policy gate who can reach it).
51
+ const canManage = computed(() => props.mode === 'create' || isOwner.value || !!contest.value?.viewerCanManage);
52
+
53
+ const editor = useContestEditor({
54
+ mode: props.mode,
55
+ slug: () => slug.value,
56
+ toast: (m, k) => (k === 'success' ? toast.success(m) : toast.error(m)),
57
+ extractError,
58
+ navigate: (p) => navigateTo(p),
59
+ refresh: () => refresh(),
60
+ // Autosave renames swap the edit URL in place (same page component, no remount);
61
+ // the hydrate guard below keeps the refetch from clobbering in-progress edits.
62
+ onRenamed: (s) => navigateTo(`/contests/${s}/edit`, { replace: true }),
63
+ });
64
+ const {
65
+ title, slugInput, slugTouched, subheading, descriptionBlocks, rulesBlocks, prizesBlocks,
66
+ description, descriptionFormat, rules, rulesFormat, prizesDescription, prizesDescriptionFormat,
67
+ bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate, communityVotingEnabled,
68
+ judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
69
+ showPrizes, prizes, criteria, stages, currentStageId,
70
+ saving, formDirty, dateError, canSubmit, slugify, toggleType, toggleRole, addPrize, removePrize, prizeLabel, save,
71
+ } = editor;
72
+
73
+ // --- Hoisted body block editors (the one refactor: a single left palette inserts
74
+ // into the CURRENTLY-active body, so the three useBlockEditor instances live here
75
+ // where the palette lives, not inside per-body components). ---
76
+ const blockDefaults = { blockDefaults: { judgesShowcase: () => ({ judges: [] }), html: () => ({ html: '' }), criteriaBar: () => ({ items: [], showLegend: true }), table: () => ({ header: ['Column 1', 'Column 2'], rows: [['', ''], ['', '']] }), tabs: () => ({ tabs: [{ label: 'Tab 1', blocks: [] }, { label: 'Tab 2', blocks: [] }] }), sponsors: () => ({ logos: [] }), compareColumns: () => ({ columns: [{ tone: 'positive', title: 'Encouraged', items: [''] }, { tone: 'negative', title: 'Out of scope', items: [''] }] }), roadmap: () => ({ items: [] }) } };
77
+ const overviewEditor = useBlockEditor(seedBodyBlocks(descriptionBlocks.value, description.value, descriptionFormat.value), blockDefaults);
78
+ const rulesEditor = useBlockEditor(seedBodyBlocks(rulesBlocks.value, rules.value, rulesFormat.value), blockDefaults);
79
+ const prizesEditor = useBlockEditor(seedBodyBlocks(prizesBlocks.value, prizesDescription.value, prizesDescriptionFormat.value), blockDefaults);
80
+
81
+ type BodyTab = 'overview' | 'rules' | 'prizes';
82
+ const activeTab = ref<BodyTab>('overview');
83
+ const bodyMode = ref<'write' | 'preview' | 'code'>('write');
84
+ const activeBodyEditor = computed(() => ({ overview: overviewEditor, rules: rulesEditor, prizes: prizesEditor })[activeTab.value] ?? overviewEditor);
85
+
86
+ // Contest-specific edit block + image upload, provided once for all three bodies.
87
+ provide(BLOCK_COMPONENTS_KEY, { judgesShowcase: JudgesShowcaseBlock, html: HtmlBlock, criteriaBar: CriteriaBarBlock, table: TableBlock, tabs: TabsBlock, sponsors: SponsorsBlock, compareColumns: CompareColumnsBlock, roadmap: RoadmapBlock });
88
+ // Feed the criteria-bar block this contest's live rubric (for its auto-fill).
89
+ provide(CONTEST_RUBRIC_KEY, criteria);
90
+ // Feed the roadmap block a timeline derived from the contest's effective schedule
91
+ // (custom stages, else the core flow) for its "Pull from schedule" seed.
92
+ const scheduleRoadmap = computed(() => roadmapFromSchedule(stages.value, { startDate: startDate.value, endDate: endDate.value, judgingEndDate: judgingEndDate.value }));
93
+ provide(CONTEST_SCHEDULE_KEY, scheduleRoadmap);
94
+ const { uploadFile } = useFileUpload();
95
+ provide(UPLOAD_HANDLER_KEY, (file: File) => uploadFile<{ url: string; width?: number | null; height?: number | null }>(file, 'content'));
96
+
97
+ // Editor -> model write-back: each body's blocks flow into the composable's
98
+ // descriptionBlocks/rulesBlocks/prizesBlocks refs (read by buildPayload).
99
+ // `syncingBodies` suppresses the write-back while reseeding from a load so hydration
100
+ // doesn't mark the form dirty (legacy contests keep their markdown until the
101
+ // organizer actually edits). We mark dirty explicitly here rather than leaning on
102
+ // the composable's deep watch, which doesn't reliably observe a whole-array
103
+ // reassignment from a structural block insert (an empty/content-less block — image,
104
+ // divider, judges-showcase — would otherwise leave Save disabled).
105
+ let syncingBodies = false;
106
+ function reseedBodies(): void {
107
+ syncingBodies = true;
108
+ overviewEditor.fromBlockTuples(seedBodyBlocks(descriptionBlocks.value, description.value, descriptionFormat.value));
109
+ rulesEditor.fromBlockTuples(seedBodyBlocks(rulesBlocks.value, rules.value, rulesFormat.value));
110
+ prizesEditor.fromBlockTuples(seedBodyBlocks(prizesBlocks.value, prizesDescription.value, prizesDescriptionFormat.value));
111
+ void nextTick(() => { syncingBodies = false; });
112
+ }
113
+ function syncBody(target: Ref<unknown[] | null>, ed: typeof overviewEditor): void {
114
+ if (syncingBodies) return;
115
+ target.value = ed.toBlockTuples();
116
+ formDirty.value = true;
117
+ }
118
+ // Watch a GETTER of `.value` (not the readonly ref directly) — the proven pattern
119
+ // (pages/.../edit.vue): the getter form fires on structural inserts/removals, the
120
+ // bare-readonly-ref form only caught nested content edits.
121
+ watch(() => overviewEditor.blocks.value, () => syncBody(descriptionBlocks, overviewEditor), { deep: true });
122
+ watch(() => rulesEditor.blocks.value, () => syncBody(rulesBlocks, rulesEditor), { deep: true });
123
+ watch(() => prizesEditor.blocks.value, () => syncBody(prizesBlocks, prizesEditor), { deep: true });
124
+
125
+ const contestBlockGroups: BlockTypeGroup[] = [
126
+ {
127
+ name: 'Basic',
128
+ blocks: [
129
+ { type: 'paragraph', label: 'Text', icon: 'fa-align-left', description: 'Body text' },
130
+ { type: 'heading', label: 'Heading', icon: 'fa-heading', description: 'Section header' },
131
+ { type: 'image', label: 'Image', icon: 'fa-image', description: 'Upload or embed' },
132
+ { type: 'code_block', label: 'Code', icon: 'fa-code', description: 'Syntax-highlighted code' },
133
+ ],
134
+ },
135
+ {
136
+ name: 'Contest',
137
+ blocks: [
138
+ { type: 'judgesShowcase', label: 'Judges Showcase', icon: 'fa-user-group', description: 'Avatar + bio cards for the overview' },
139
+ { type: 'criteriaBar', label: 'Criteria Bar', icon: 'fa-chart-simple', description: 'Weighted judging criteria as one stacked bar' },
140
+ { type: 'sponsors', label: 'Sponsors', icon: 'fa-handshake-angle', description: 'Logo wall with optional tiers + links' },
141
+ { type: 'compareColumns', label: 'In / Out of Scope', icon: 'fa-table-columns', description: 'Side-by-side columns, e.g. Encouraged vs Out of scope' },
142
+ { type: 'roadmap', label: 'Roadmap', icon: 'fa-timeline', description: 'Schedule timeline, seedable from the contest stages' },
143
+ ],
144
+ },
145
+ {
146
+ name: 'Media',
147
+ blocks: [
148
+ { type: 'video', label: 'Video', icon: 'fa-film', description: 'YouTube, Vimeo embed' },
149
+ { type: 'embed', label: 'Embed', icon: 'fa-globe', description: 'External embed (translates YouTube/Vimeo URLs)' },
150
+ ],
151
+ },
152
+ {
153
+ name: 'Rich',
154
+ blocks: [
155
+ { type: 'callout', label: 'Tip', icon: 'fa-lightbulb', description: 'Tip callout', attrs: { variant: 'tip' } },
156
+ { type: 'callout', label: 'Warning', icon: 'fa-triangle-exclamation', description: 'Warning callout', attrs: { variant: 'warning' } },
157
+ { type: 'blockquote', label: 'Quote', icon: 'fa-quote-left', description: 'Blockquote' },
158
+ { type: 'horizontal_rule', label: 'Divider', icon: 'fa-minus', description: 'Visual separator' },
159
+ { type: 'markdown', label: 'Markdown', icon: 'fa-brands fa-markdown', description: 'Raw markdown block' },
160
+ { type: 'html', label: 'HTML', icon: 'fa-code', description: 'Raw HTML snippet (sanitized on render)' },
161
+ { type: 'table', label: 'Table', icon: 'fa-table', description: 'Responsive data table' },
162
+ ],
163
+ },
164
+ {
165
+ name: 'Layout',
166
+ blocks: [
167
+ { type: 'tabs', label: 'Tabs', icon: 'fa-folder-tree', description: 'Tabbed panels, e.g. multiple rule sets (Track A / Track B)' },
168
+ ],
169
+ },
170
+ ];
171
+
172
+ // Draft contests autosave (background PUT once edits settle); published contests
173
+ // save on an explicit action. Create has no slug yet, so it never autosaves.
174
+ const isDraftAutosave = computed(() => props.mode === 'edit' && contest.value?.status === 'draft');
175
+ // Autosave retries on every keystroke after a failure, so toast each DISTINCT
176
+ // message once (else a persistent error, e.g. a slug conflict, spams every 3s
177
+ // while the organizer keeps typing). A successful save re-arms the toast.
178
+ const lastAutosaveError = ref('');
179
+ const autosave = useEditorAutosave({
180
+ persist: () => editor.save({ silent: true }),
181
+ canSave: () => isDraftAutosave.value && !dateError.value && !!title.value.trim(),
182
+ debounceMs: 3000,
183
+ onError: (err) => {
184
+ const msg = extractError(err);
185
+ if (msg !== lastAutosaveError.value) toast.error(msg);
186
+ lastAutosaveError.value = msg;
187
+ },
188
+ });
189
+ watch(autosave.status, (s) => { if (s === 'saved') lastAutosaveError.value = ''; });
190
+ // Any post-hydration edit re-arms the trailing debounce (only while autosaving).
191
+ watch(formDirty, (d) => { if (d && isDraftAutosave.value) autosave.markDirty(); });
192
+ const busy = computed(() => saving.value || autosave.saving.value);
193
+ const autosaveError = computed(() => autosave.status.value === 'error');
194
+ const autosaveLabel = computed(() => {
195
+ if (busy.value) return 'Saving changes';
196
+ if (autosaveError.value) return "Couldn't autosave";
197
+ if (formDirty.value) return 'Unsaved changes';
198
+ return 'All changes saved';
199
+ });
200
+ const autosaveIcon = computed(() => {
201
+ if (busy.value) return 'fa-circle-notch fa-spin';
202
+ if (autosaveError.value) return 'fa-triangle-exclamation';
203
+ if (formDirty.value) return 'fa-circle';
204
+ return 'fa-circle-check';
205
+ });
206
+ function onSave(): void {
207
+ if (isDraftAutosave.value) void autosave.saveNow();
208
+ else void save();
209
+ }
210
+
211
+ useSeoMeta({
212
+ title: () => (props.mode === 'create'
213
+ ? `Create Contest, ${useSiteName()}`
214
+ : `Edit: ${contest.value?.title ?? 'Contest'}, ${useSiteName()}`),
215
+ });
216
+
217
+ const { enabledTypeMeta } = useContentTypes();
218
+ const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
219
+
220
+ // --- Inline media (banner + cover) shown at the top of the Overview body ---
221
+ function uploadMedia(event: Event, target: Ref<string>, purpose: string): void {
222
+ const input = event.target as HTMLInputElement;
223
+ const file = input.files?.[0];
224
+ if (!file) return;
225
+ uploadFile<{ url: string }>(file, purpose)
226
+ .then((res) => { target.value = res.url; })
227
+ .catch((err: unknown) => { toast.error(extractError(err) || 'Image upload failed'); });
228
+ input.value = '';
229
+ }
230
+ function onBannerUpload(e: Event): void { uploadMedia(e, bannerUrl, 'banner'); }
231
+ function onCoverUpload(e: Event): void { uploadMedia(e, coverImageUrl, 'cover'); }
232
+ function onBannerUrl(): void { const url = window.prompt('Enter banner image URL:'); if (url) bannerUrl.value = url; }
233
+
234
+ // --- Right-rail collapsible sections ---
235
+ const openSections = ref<Record<string, boolean>>({
236
+ details: true, schedule: true, stages: false, entries: true,
237
+ prizes: false, judging: false, access: false, people: false, danger: false,
238
+ });
239
+ function toggleSection(key: string): void {
240
+ openSections.value[key] = !openSections.value[key];
241
+ }
242
+
243
+ // Edit-only advancement state (operates on real entries, not the editable model).
244
+ const advancing = ref<string | null>(null);
245
+ const advanceN = ref<Record<string, number>>({});
246
+ const advanceMode = ref<Record<string, 'topN' | 'manual'>>({});
247
+ const manualPick = ref<Record<string, string[]>>({});
248
+ const deleting = ref(false);
249
+
250
+ // Hydrate the form model when the contest loads (edit), and pre-fill each review
251
+ // stage's advancement cut from its persisted advanceCount.
252
+ watch(contest, (c) => {
253
+ if (!c) return;
254
+ // Never clobber unsaved edits with a refetch (e.g. an autosave rename swaps the
255
+ // URL and re-fetches the renamed contest while the organizer keeps typing).
256
+ if (formDirty.value) return;
257
+ editor.hydrate(c as ContestEditorSource);
258
+ reseedBodies();
259
+ advanceN.value = {};
260
+ for (const s of stages.value) {
261
+ if (s.kind === 'review' && typeof s.advanceCount === 'number') advanceN.value[s.id] = s.advanceCount;
262
+ }
263
+ }, { immediate: true });
264
+
265
+ async function handleDelete(): Promise<void> {
266
+ if (!confirm('Permanently delete this contest? All entries, judges, and reviewers are removed. This cannot be undone.')) return;
267
+ deleting.value = true;
268
+ try {
269
+ await $fetch(`/api/contests/${slug.value}`, { method: 'DELETE' });
270
+ toast.success('Contest deleted');
271
+ await navigateTo('/contests');
272
+ } catch (err: unknown) {
273
+ toast.error(extractError(err));
274
+ deleting.value = false;
275
+ }
276
+ }
277
+
278
+ // Bidirectional lifecycle controls — the valid-transition map + button metadata
279
+ // live in utils/contestTransitions.ts (shared with ContestHero).
280
+ const availableTransitions = computed<string[]>(() => contestTransitionsFrom(contest.value?.status));
281
+ const statusAction = contestStatusAction;
282
+
283
+ async function transitionStatus(newStatus: string): Promise<void> {
284
+ // Only the consequential transitions confirm; reversible nudges (pause/resume,
285
+ // go-back) just apply.
286
+ if (newStatus === 'cancelled' && !confirm('Cancel this contest? This cannot be undone.')) return;
287
+ if (newStatus === 'completed' && !confirm('Complete this contest and publish results? Final rankings will be calculated.')) return;
288
+ try {
289
+ await $fetch(`/api/contests/${slug.value}/transition`, { method: 'POST', body: { status: newStatus } });
290
+ toast.success(`Status changed to ${newStatus}`);
291
+ await refresh();
292
+ } catch (err: unknown) {
293
+ toast.error(extractError(err));
294
+ }
295
+ }
296
+
297
+ // --- Topbar Status menu (the contest analogue of Publish; lifecycle lives here,
298
+ // not the rail). Edit-only. Closes on select, Escape, or an outside pointer. ---
299
+ const statusMenuOpen = ref(false);
300
+ const statusMenuRef = ref<HTMLElement | null>(null);
301
+ const statusToggleRef = ref<HTMLButtonElement | null>(null);
302
+ function statusItems(): HTMLElement[] {
303
+ return Array.from(statusMenuRef.value?.querySelectorAll<HTMLElement>('.cpub-ce-status-item') ?? []);
304
+ }
305
+ function closeStatusMenu(focusToggle = false): void {
306
+ statusMenuOpen.value = false;
307
+ if (focusToggle) void nextTick(() => statusToggleRef.value?.focus());
308
+ }
309
+ // Menu-button keyboard pattern: opening focuses the first action; arrows rove;
310
+ // Escape closes and returns focus to the toggle.
311
+ function toggleStatusMenu(): void {
312
+ statusMenuOpen.value = !statusMenuOpen.value;
313
+ if (statusMenuOpen.value) void nextTick(() => statusItems()[0]?.focus());
314
+ }
315
+ function onStatusItemKey(e: KeyboardEvent): void {
316
+ const items = statusItems();
317
+ if (!items.length) return;
318
+ const cur = items.indexOf(document.activeElement as HTMLElement);
319
+ if (e.key === 'ArrowDown') { e.preventDefault(); items[(cur + 1) % items.length]?.focus(); }
320
+ else if (e.key === 'ArrowUp') { e.preventDefault(); items[(cur - 1 + items.length) % items.length]?.focus(); }
321
+ else if (e.key === 'Home') { e.preventDefault(); items[0]?.focus(); }
322
+ else if (e.key === 'End') { e.preventDefault(); items[items.length - 1]?.focus(); }
323
+ }
324
+ async function selectTransition(t: string): Promise<void> { closeStatusMenu(); await transitionStatus(t); }
325
+ function onStatusDocPointer(e: PointerEvent): void {
326
+ if (statusMenuOpen.value && statusMenuRef.value && !statusMenuRef.value.contains(e.target as Node)) closeStatusMenu();
327
+ }
328
+ function onStatusDocKey(e: KeyboardEvent): void { if (e.key === 'Escape' && statusMenuOpen.value) closeStatusMenu(true); }
329
+ onMounted(() => {
330
+ document.addEventListener('pointerdown', onStatusDocPointer);
331
+ document.addEventListener('keydown', onStatusDocKey);
332
+ });
333
+ onUnmounted(() => {
334
+ document.removeEventListener('pointerdown', onStatusDocPointer);
335
+ document.removeEventListener('keydown', onStatusDocKey);
336
+ });
337
+
338
+ // Advancement cuts operate on the PERSISTED stages (contest.value), not the
339
+ // editable `stages` ref, since they act on real entries.
340
+ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
341
+ const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[] }>(
342
+ () => `/api/contests/${slug.value}/entries`,
343
+ { immediate: props.mode === 'edit' },
344
+ );
345
+ const eligibleEntries = computed(() => (entriesData.value?.items ?? []).filter((e) => !e.eliminated));
346
+
347
+ function toggleManual(stageId: string, entryId: string): void {
348
+ const cur = manualPick.value[stageId] ?? [];
349
+ manualPick.value[stageId] = cur.includes(entryId) ? cur.filter((x) => x !== entryId) : [...cur, entryId];
350
+ }
351
+ async function advanceStageManual(stageId: string): Promise<void> {
352
+ const ids = manualPick.value[stageId] ?? [];
353
+ if (!ids.length) { toast.error('Select at least one entry to advance.'); return; }
354
+ if (!confirm(`Advance the ${ids.length} selected ${ids.length === 1 ? 'entry' : 'entries'}? The rest of the cohort is marked "not advanced" and drops out of later judging + final results.`)) return;
355
+ advancing.value = stageId;
356
+ try {
357
+ const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug.value}/advance`, {
358
+ method: 'POST',
359
+ body: { reviewStageId: stageId, mode: 'manual', advancedEntryIds: ids },
360
+ });
361
+ toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
362
+ await Promise.all([refresh(), refreshEntries()]);
363
+ } catch (err: unknown) {
364
+ toast.error(extractError(err));
365
+ } finally {
366
+ advancing.value = null;
367
+ }
368
+ }
369
+ async function advanceStage(stageId: string): Promise<void> {
370
+ const topN = advanceN.value[stageId];
371
+ if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
372
+ if (!confirm(`Advance the top ${topN} entries from this stage? Entries below the cut are marked "not advanced" and drop out of later judging + final results. You can re-run this.`)) return;
373
+ advancing.value = stageId;
374
+ try {
375
+ const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug.value}/advance`, {
376
+ method: 'POST',
377
+ body: { reviewStageId: stageId, mode: 'topN', topN },
378
+ });
379
+ toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
380
+ await Promise.all([refresh(), refreshEntries()]);
381
+ } catch (err: unknown) {
382
+ toast.error(extractError(err));
383
+ } finally {
384
+ advancing.value = null;
385
+ }
386
+ }
387
+ </script>
388
+
389
+ <template>
390
+ <!-- The editor is authed and entirely client-data-driven (its model hydrates from
391
+ a client-side fetch), so SSR would render an unhydrated form and mismatch on
392
+ hydration. Render it client-only with a loading fallback. -->
393
+ <ClientOnly>
394
+ <div v-if="mode === 'edit' && contest && !canManage" class="cpub-not-found">
395
+ <p>You don't have permission to edit this contest.</p>
396
+ <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
397
+ </div>
398
+ <!-- Not a <form>: the 3-panel shell embeds many third-party buttons (palette,
399
+ section headers, canvas controls) that default to type="submit"; a form would
400
+ let any of them trigger a save. We drive Save explicitly via @click. -->
401
+ <div v-else-if="mode === 'create' || contest" class="cpub-ce-layout">
402
+ <!-- Topbar: back · title · status · autosave · View · Save -->
403
+ <header class="cpub-ce-topbar">
404
+ <NuxtLink :to="mode === 'edit' ? `/contests/${slug}` : '/contests'" class="cpub-ce-back" aria-label="Back">
405
+ <i class="fa-solid fa-arrow-left"></i>
406
+ </NuxtLink>
407
+ <div class="cpub-ce-topbar-divider" aria-hidden="true" />
408
+ <div class="cpub-ce-title-wrap">
409
+ <input
410
+ v-model="title"
411
+ type="text"
412
+ class="cpub-ce-title-input"
413
+ :placeholder="mode === 'create' ? 'Contest title...' : 'Contest title'"
414
+ aria-label="Contest title"
415
+ />
416
+ <span v-if="mode === 'edit' && contest" class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
417
+ <span v-if="mode === 'create'" class="cpub-ce-required">Required: title, start &amp; end dates</span>
418
+ <span
419
+ v-else-if="isDraftAutosave"
420
+ class="cpub-ce-autosave"
421
+ :class="{ 'cpub-ce-autosave-err': autosaveError }"
422
+ role="status"
423
+ aria-live="polite"
424
+ ><i class="fa-solid" :class="autosaveIcon"></i> {{ autosaveLabel }}</span>
425
+ <span v-else-if="formDirty" class="cpub-ce-dirty"><i class="fa-solid fa-circle"></i> Unsaved</span>
426
+ </div>
427
+ <div class="cpub-ce-topbar-spacer" />
428
+ <div class="cpub-ce-topbar-actions">
429
+ <NuxtLink v-if="mode === 'edit'" :to="`/contests/${slug}`" class="cpub-ce-topbar-btn">
430
+ <i class="fa-solid fa-arrow-up-right-from-square"></i> View
431
+ </NuxtLink>
432
+ <button type="button" class="cpub-ce-topbar-btn cpub-ce-topbar-btn-primary" :disabled="busy || !canSubmit" @click="onSave">
433
+ <i class="fa-solid" :class="mode === 'create' ? 'fa-trophy' : 'fa-floppy-disk'"></i>
434
+ {{ busy ? (mode === 'create' ? 'Creating…' : 'Saving…') : (mode === 'create' ? 'Create Contest' : (formDirty ? 'Save Changes' : 'Saved')) }}
435
+ </button>
436
+
437
+ <!-- Lifecycle transitions (edit-only) live in this Status menu, not the rail. -->
438
+ <div v-if="mode === 'edit' && contest" ref="statusMenuRef" class="cpub-ce-status-menu">
439
+ <button
440
+ ref="statusToggleRef"
441
+ type="button"
442
+ class="cpub-ce-topbar-btn"
443
+ id="cpub-ce-status-toggle"
444
+ aria-haspopup="menu"
445
+ aria-controls="cpub-ce-status-dropdown"
446
+ :aria-expanded="statusMenuOpen"
447
+ @click="toggleStatusMenu"
448
+ >
449
+ <i class="fa-solid fa-flag"></i> Status <i class="fa-solid fa-chevron-down cpub-ce-status-caret" :class="{ open: statusMenuOpen }"></i>
450
+ </button>
451
+ <div v-if="statusMenuOpen" id="cpub-ce-status-dropdown" class="cpub-ce-status-dropdown" role="menu" aria-label="Change contest status" @keydown="onStatusItemKey">
452
+ <p class="cpub-ce-status-current">
453
+ Current: <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
454
+ </p>
455
+ <button
456
+ v-for="t in availableTransitions"
457
+ :key="t"
458
+ type="button"
459
+ role="menuitem"
460
+ tabindex="-1"
461
+ class="cpub-ce-status-item"
462
+ :class="{
463
+ 'cpub-ce-status-go': statusAction(t).tone === 'go',
464
+ 'cpub-ce-status-warn': statusAction(t).tone === 'warn',
465
+ 'cpub-ce-status-danger': statusAction(t).tone === 'danger',
466
+ }"
467
+ @click="selectTransition(t)"
468
+ >
469
+ <i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}
470
+ </button>
471
+ <p v-if="!availableTransitions.length" class="cpub-ce-status-empty">
472
+ <i class="fa-solid fa-circle-check"></i> No status changes available from <strong>{{ contest.status }}</strong>.
473
+ </p>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ </header>
478
+
479
+ <div class="cpub-ce-shell">
480
+ <!-- LEFT: block palette — inserts into the currently-active body. -->
481
+ <aside class="cpub-ce-library" aria-label="Block palette">
482
+ <EditorBlocks :groups="contestBlockGroups" :block-editor="activeBodyEditor" />
483
+ </aside>
484
+
485
+ <!-- CENTER: contest body (Overview / Rules / Prizes). -->
486
+ <div class="cpub-ce-center">
487
+ <ContestBodyCanvas
488
+ :editor="activeBodyEditor"
489
+ :groups="contestBlockGroups"
490
+ :active-tab="activeTab"
491
+ :mode="bodyMode"
492
+ @update:active-tab="activeTab = $event"
493
+ @update:mode="bodyMode = $event"
494
+ >
495
+ <!-- Banner + cover render inline at the top of the Overview body only. -->
496
+ <template #overview-lead>
497
+ <div class="cpub-ce-media">
498
+ <div class="cpub-ce-banner" :class="{ 'has-image': !!bannerUrl }">
499
+ <img v-if="bannerUrl" :src="bannerUrl" alt="Contest banner" class="cpub-ce-banner-img" />
500
+ <div v-else class="cpub-ce-media-placeholder">
501
+ <i class="fa-regular fa-image"></i>
502
+ <span>Banner image</span>
503
+ </div>
504
+ <div class="cpub-ce-media-overlay">
505
+ <label class="cpub-ce-media-btn primary">
506
+ <i class="fa-solid fa-arrow-up-from-bracket"></i> {{ bannerUrl ? 'Replace' : 'Upload' }}
507
+ <input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload banner image" @change="onBannerUpload">
508
+ </label>
509
+ <button type="button" class="cpub-ce-media-btn" @click="onBannerUrl"><i class="fa-solid fa-link"></i> URL</button>
510
+ <button v-if="bannerUrl" type="button" class="cpub-ce-media-btn" @click="bannerUrl = ''"><i class="fa-solid fa-trash"></i> Remove</button>
511
+ </div>
512
+
513
+ <!-- Cover thumbnail, inset over the banner's lower-left (mirrors the public hero). -->
514
+ <div class="cpub-ce-cover" :class="{ 'has-image': !!coverImageUrl }">
515
+ <img v-if="coverImageUrl" :src="coverImageUrl" alt="Contest cover" class="cpub-ce-cover-img" />
516
+ <div v-else class="cpub-ce-media-placeholder cpub-ce-media-placeholder-sm">
517
+ <i class="fa-regular fa-image"></i>
518
+ <span>Cover</span>
519
+ </div>
520
+ <div class="cpub-ce-media-overlay">
521
+ <label class="cpub-ce-media-btn primary cpub-ce-media-btn-icon" title="Upload cover image">
522
+ <i class="fa-solid fa-arrow-up-from-bracket"></i>
523
+ <input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload cover image" @change="onCoverUpload">
524
+ </label>
525
+ <button v-if="coverImageUrl" type="button" class="cpub-ce-media-btn cpub-ce-media-btn-icon" title="Remove cover" @click="coverImageUrl = ''"><i class="fa-solid fa-trash"></i></button>
526
+ </div>
527
+ </div>
528
+ </div>
529
+ <p class="cpub-form-hint cpub-ce-media-hint">Banner is the wide hero (~4:1). Cover is the card thumbnail in listings (~4:3); it falls back to the banner if unset.</p>
530
+ </div>
531
+ </template>
532
+ </ContestBodyCanvas>
533
+ <p class="cpub-form-hint cpub-ce-body-hint">
534
+ The <strong>Overview</strong>, <strong>Rules</strong>, and <strong>Prizes</strong> bodies are blocks
535
+ (headings, lists, images, callouts, and the <strong>Judges Showcase</strong>), like the project and blog
536
+ editors. Add blocks from the palette on the left. Stages, judging, and the rest live in the settings rail.
537
+ Legacy text converts to blocks on first edit.
538
+ </p>
539
+ </div>
540
+
541
+ <!-- RIGHT: settings rail. -->
542
+ <aside class="cpub-ce-settings" aria-label="Contest settings">
543
+ <div class="cpub-ce-settings-body">
544
+ <EditorSection title="Details" icon="fa-circle-info" :open="openSections.details" @toggle="toggleSection('details')">
545
+ <div class="cpub-form-field">
546
+ <label for="contest-slug" class="cpub-form-label">URL Slug</label>
547
+ <input id="contest-slug" v-model="slugInput" type="text" class="cpub-form-input" :placeholder="mode === 'create' ? 'auto-generated from title' : ''" @input="slugTouched = true" @blur="slugInput = slugify(slugInput)" />
548
+ <p class="cpub-form-hint"><code>/contests/{{ slugify(slugInput) || 'your-contest' }}</code>. {{ mode === 'create' ? 'Auto-fills from the title.' : 'Changing it breaks old links, they won\'t redirect.' }}</p>
549
+ </div>
550
+ <div class="cpub-form-field">
551
+ <label for="contest-subheading" class="cpub-form-label">Subheading</label>
552
+ <input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
553
+ <p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero.</p>
554
+ </div>
555
+ </EditorSection>
556
+
557
+ <EditorSection title="Schedule" icon="fa-calendar" :open="openSections.schedule" @toggle="toggleSection('schedule')">
558
+ <div class="cpub-form-field">
559
+ <CpubDateTimeField label="Start Date" :model-value="startDate" :required="mode === 'create'" @update:model-value="startDate = $event ?? ''" />
560
+ </div>
561
+ <div class="cpub-form-field">
562
+ <CpubDateTimeField label="End Date" :model-value="endDate" :min="startDate || undefined" :required="mode === 'create'" @update:model-value="endDate = $event ?? ''" />
563
+ </div>
564
+ <div class="cpub-form-field">
565
+ <CpubDateTimeField label="Judging End Date" :model-value="judgingEndDate" :min="endDate || undefined" @update:model-value="judgingEndDate = $event ?? ''" />
566
+ </div>
567
+ <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
568
+ </EditorSection>
569
+
570
+ <EditorSection title="Stages" icon="fa-diagram-project" :open="openSections.stages" @toggle="toggleSection('stages')">
571
+ <p class="cpub-form-hint">Optional. The standard flow (Submissions → Judging → Results) is derived from the schedule. Add custom stages for multi-round contests, proposal rounds, a Top-N selection, a build sprint, or a showcase event.</p>
572
+ <p class="cpub-form-hint">How the pieces fit: <strong>Stages</strong> are the public timeline. The <strong>Status</strong> control is what's actually open now. <strong>Advancement</strong> (below) runs each review round's Top-N cut. Mark a stage <strong>Current</strong> to point judges + the countdown at it.</p>
573
+ <ContestStagesEditor
574
+ v-model="stages"
575
+ v-model:current-stage-id="currentStageId"
576
+ :start-date="startDate"
577
+ :end-date="endDate"
578
+ :judging-end-date="judgingEndDate"
579
+ />
580
+ <div v-if="mode === 'edit' && reviewStages.length" class="cpub-advance-section">
581
+ <h3 class="cpub-form-subtitle">Advancement</h3>
582
+ <p class="cpub-form-hint">After judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced". Re-running re-computes the cut. (Save any stage changes above first.)</p>
583
+ <div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-block">
584
+ <div class="cpub-advance-row">
585
+ <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
586
+ <div class="cpub-advance-mode">
587
+ <label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="(advanceMode[rs.id] ?? 'topN') === 'topN'" @change="advanceMode[rs.id] = 'topN'" /> <span>Top N</span></label>
588
+ <label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="advanceMode[rs.id] === 'manual'" @change="advanceMode[rs.id] = 'manual'" /> <span>Pick manually</span></label>
589
+ </div>
590
+ </div>
591
+ <div v-if="(advanceMode[rs.id] ?? 'topN') === 'topN'" class="cpub-advance-ctl">
592
+ <label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
593
+ <input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
594
+ <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
595
+ <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
596
+ </button>
597
+ </div>
598
+ <div v-else class="cpub-advance-manual">
599
+ <p v-if="!eligibleEntries.length" class="cpub-form-hint" style="margin: 0;">No entries in the current cohort to pick from yet.</p>
600
+ <template v-else>
601
+ <label v-for="e in eligibleEntries" :key="e.id" class="cpub-advance-pick">
602
+ <input type="checkbox" :checked="(manualPick[rs.id] ?? []).includes(e.id)" @change="toggleManual(rs.id, e.id)" />
603
+ <span class="cpub-advance-pick-title">{{ e.contentTitle }}</span>
604
+ <span v-if="e.score != null" class="cpub-advance-pick-score">{{ e.score }}</span>
605
+ </label>
606
+ <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id || !(manualPick[rs.id] ?? []).length" @click="advanceStageManual(rs.id)">
607
+ <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : `Advance ${(manualPick[rs.id] ?? []).length} selected` }}
608
+ </button>
609
+ </template>
610
+ </div>
611
+ </div>
612
+ </div>
613
+ </EditorSection>
614
+
615
+ <EditorSection title="Entries" icon="fa-inbox" :open="openSections.entries" @toggle="toggleSection('entries')">
616
+ <div class="cpub-form-field">
617
+ <span class="cpub-form-label">Eligible content types</span>
618
+ <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
619
+ <div class="cpub-type-options" role="group" aria-label="Eligible content types">
620
+ <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
621
+ <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
622
+ <span>{{ t.label }}</span>
623
+ </label>
624
+ </div>
625
+ </div>
626
+ <div class="cpub-form-field">
627
+ <label for="contest-max-entries" class="cpub-form-label">Max entries per person</label>
628
+ <input id="contest-max-entries" v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
629
+ </div>
630
+ </EditorSection>
631
+
632
+ <EditorSection title="Prizes" icon="fa-trophy" :open="openSections.prizes" @toggle="toggleSection('prizes')">
633
+ <label class="cpub-form-check" style="margin-bottom: 10px;">
634
+ <input v-model="showPrizes" type="checkbox" />
635
+ <span>Show the Prizes tab on the contest page</span>
636
+ </label>
637
+ <p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden, any prizes below are saved but not shown to visitors.</p>
638
+ <p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong>. The prizes <em>overview</em> copy is edited in the body's Prizes tab.</p>
639
+ <div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
640
+ <div class="cpub-prize-header">
641
+ <span class="cpub-prize-label">{{ prizeLabel(prize) }}</span>
642
+ <button type="button" class="cpub-prize-remove" aria-label="Remove prize" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
643
+ </div>
644
+ <div class="cpub-form-field">
645
+ <label :for="`prize-place-${i}`" class="cpub-form-label">Place</label>
646
+ <input :id="`prize-place-${i}`" v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
647
+ </div>
648
+ <div class="cpub-form-field">
649
+ <label :for="`prize-category-${i}`" class="cpub-form-label">Category (optional)</label>
650
+ <input :id="`prize-category-${i}`" v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
651
+ </div>
652
+ <div class="cpub-form-field">
653
+ <label :for="`prize-title-${i}`" class="cpub-form-label">Title</label>
654
+ <input :id="`prize-title-${i}`" v-model="prize.title" type="text" class="cpub-form-input" placeholder="e.g. Gold Prize" />
655
+ </div>
656
+ <div class="cpub-form-field">
657
+ <label :for="`prize-value-${i}`" class="cpub-form-label">Value</label>
658
+ <input :id="`prize-value-${i}`" v-model="prize.value" type="text" class="cpub-form-input" placeholder="e.g. $500" />
659
+ </div>
660
+ <div class="cpub-form-field">
661
+ <label :for="`prize-desc-${i}`" class="cpub-form-label">Description</label>
662
+ <input :id="`prize-desc-${i}`" v-model="prize.description" type="text" class="cpub-form-input" placeholder="Optional description" />
663
+ </div>
664
+ </div>
665
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addPrize"><i class="fa-solid fa-plus"></i> Add Prize</button>
666
+ </EditorSection>
667
+
668
+ <EditorSection title="Judging" icon="fa-scale-balanced" :open="openSections.judging" @toggle="toggleSection('judging')">
669
+ <div class="cpub-form-field">
670
+ <label for="contest-judging-visibility" class="cpub-form-label">Score visibility</label>
671
+ <select id="contest-judging-visibility" v-model="judgingVisibility" class="cpub-form-input">
672
+ <option value="judges-only">Judges only, scores hidden until results</option>
673
+ <option value="public">Public, show scores during judging</option>
674
+ <option value="private">Private, scores stay with organizers</option>
675
+ </select>
676
+ </div>
677
+ <label class="cpub-form-check"><input v-model="communityVotingEnabled" type="checkbox" /> <span>Enable community voting (advisory audience favourite, doesn't affect ranks)</span></label>
678
+ <p class="cpub-form-hint" style="margin-top: 12px;">The rubric below is the contest's default criteria. A review stage can override it with per-round criteria. Leave it empty and judges score an overall 1 to 100.</p>
679
+ <ContestCriteriaEditor v-model="criteria" label="Judging criteria" :show-total="true" />
680
+ </EditorSection>
681
+
682
+ <EditorSection title="Access" icon="fa-eye" :open="openSections.access" @toggle="toggleSection('access')">
683
+ <div class="cpub-form-field">
684
+ <label for="contest-visibility" class="cpub-form-label">Who can see this contest</label>
685
+ <select id="contest-visibility" v-model="visibility" class="cpub-form-input">
686
+ <option value="public">Public, listed and visible to everyone</option>
687
+ <option value="unlisted">Unlisted, visible by direct link, hidden from listings</option>
688
+ <option value="private">Private, restricted</option>
689
+ </select>
690
+ </div>
691
+ <div v-if="visibility === 'private'" class="cpub-form-field">
692
+ <span class="cpub-form-label">Also visible to roles</span>
693
+ <p v-if="mode === 'create'" class="cpub-form-hint">Owner, admins, judges, and reviewers (added after creation) can always see it. Optionally grant whole roles too.</p>
694
+ <div class="cpub-type-options" role="group" aria-label="Roles that can view">
695
+ <label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
696
+ <input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
697
+ <span>{{ r }}</span>
698
+ </label>
699
+ </div>
700
+ </div>
701
+ <p v-if="mode === 'create' && visibility === 'private'" class="cpub-form-hint">Add named reviewers (stakeholders) from the contest's Edit page after creating it.</p>
702
+ </EditorSection>
703
+
704
+ <EditorSection v-if="mode === 'edit'" title="People" icon="fa-user-group" :open="openSections.people" @toggle="toggleSection('people')">
705
+ <ContestJudgeManager :contest-slug="slug" :is-owner="isOwner" />
706
+ <div v-if="isOwner" class="cpub-people-collab">
707
+ <h3 class="cpub-form-subtitle">Collaborators</h3>
708
+ <p class="cpub-form-hint">Per-contest access only (no system-wide). Reviewers can view, even while private or draft; editors can edit.</p>
709
+ <ContestStakeholderManager :contest-slug="slug" />
710
+ </div>
711
+ </EditorSection>
712
+ <EditorSection v-else title="People" icon="fa-user-group" :open="openSections.people" @toggle="toggleSection('people')">
713
+ <p class="cpub-form-hint" style="margin: 0;">Add judges, reviewers, and collaborators from the contest's Edit page once it's created.</p>
714
+ </EditorSection>
715
+
716
+ <EditorSection v-if="mode === 'edit' && isOwner" title="Danger Zone" icon="fa-triangle-exclamation" :open="openSections.danger" @toggle="toggleSection('danger')">
717
+ <p class="cpub-danger-label">Delete this contest</p>
718
+ <p class="cpub-form-hint">Permanently removes the contest and all of its entries, judges, and reviewers. This cannot be undone.</p>
719
+ <button type="button" class="cpub-btn cpub-btn-danger cpub-danger-btn" :disabled="deleting" @click="handleDelete">
720
+ <i class="fa-solid fa-trash"></i> {{ deleting ? 'Deleting...' : 'Delete Contest' }}
721
+ </button>
722
+ </EditorSection>
723
+ </div>
724
+ </aside>
725
+ </div>
726
+ </div>
727
+ <div v-else-if="contestLoading" class="cpub-not-found"><p>Loading contest…</p></div>
728
+ <div v-else class="cpub-not-found"><p>Contest not found</p></div>
729
+ <template #fallback>
730
+ <div class="cpub-not-found"><p>Loading editor…</p></div>
731
+ </template>
732
+ </ClientOnly>
733
+ </template>
734
+
735
+ <style scoped>
736
+ /* --- Full-screen editor layout (matches the house project/blog editor) --- */
737
+ .cpub-ce-layout {
738
+ display: flex;
739
+ flex-direction: column;
740
+ height: 100vh;
741
+ overflow: hidden;
742
+ background: var(--bg);
743
+ color: var(--text);
744
+ font-family: var(--font-sans);
745
+ }
746
+
747
+ /* Topbar */
748
+ .cpub-ce-topbar {
749
+ height: 48px;
750
+ background: var(--surface);
751
+ border-bottom: var(--border-width-default) solid var(--border);
752
+ display: flex;
753
+ align-items: center;
754
+ padding: 0 16px;
755
+ flex-shrink: 0;
756
+ z-index: 100;
757
+ }
758
+ .cpub-ce-back {
759
+ width: 30px; height: 30px;
760
+ background: none;
761
+ border: var(--border-width-default) solid transparent;
762
+ color: var(--text-dim);
763
+ cursor: pointer;
764
+ display: flex; align-items: center; justify-content: center;
765
+ font-size: 12px;
766
+ flex-shrink: 0;
767
+ text-decoration: none;
768
+ }
769
+ .cpub-ce-back:hover { background: var(--surface2); border-color: var(--border2); color: var(--text); }
770
+ .cpub-ce-topbar-divider { width: 2px; height: 22px; background: var(--border); margin: 0 12px; flex-shrink: 0; }
771
+ .cpub-ce-title-wrap { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
772
+ .cpub-ce-title-input {
773
+ font-size: 13px; font-weight: 500; color: var(--text);
774
+ background: none; border: var(--border-width-default) solid transparent;
775
+ padding: 4px 8px; cursor: text;
776
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
777
+ max-width: 380px; outline: none; font-family: var(--font-sans, system-ui);
778
+ }
779
+ .cpub-ce-title-input:hover { border-color: var(--border2); background: var(--surface2); }
780
+ .cpub-ce-title-input:focus { border-color: var(--accent); background: var(--surface2); }
781
+ .cpub-ce-required { font-size: 11px; color: var(--text-faint); white-space: nowrap; }
782
+ .cpub-ce-dirty { color: var(--accent); display: inline-flex; align-items: center; gap: 5px; font-size: 11px; }
783
+ .cpub-ce-dirty i { font-size: 6px; }
784
+ .cpub-ce-autosave { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-faint); white-space: nowrap; }
785
+ .cpub-ce-autosave i { font-size: 9px; }
786
+ .cpub-ce-autosave-err { color: var(--red); }
787
+ .cpub-ce-topbar-spacer { flex: 1; }
788
+ .cpub-ce-topbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
789
+ .cpub-ce-topbar-btn {
790
+ font-family: var(--font-sans, system-ui); font-size: 12px;
791
+ padding: 6px 14px; border: var(--border-width-default) solid var(--border);
792
+ background: var(--surface); color: var(--text); cursor: pointer;
793
+ display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
794
+ }
795
+ .cpub-ce-topbar-btn:hover { background: var(--surface2); }
796
+ .cpub-ce-topbar-btn:disabled { opacity: 0.5; cursor: not-allowed; }
797
+ .cpub-ce-topbar-btn-primary { background: var(--accent); color: var(--color-text-inverse); font-weight: 600; border-color: var(--accent); box-shadow: var(--shadow-md); }
798
+ .cpub-ce-topbar-btn-primary:hover:not(:disabled) { box-shadow: var(--shadow-sm); background: var(--accent); }
799
+
800
+ /* Topbar Status ▾ dropdown */
801
+ .cpub-ce-status-menu { position: relative; }
802
+ .cpub-ce-status-caret { font-size: 9px; transition: transform 0.15s; }
803
+ .cpub-ce-status-caret.open { transform: rotate(180deg); }
804
+ .cpub-ce-status-dropdown {
805
+ position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; min-width: 220px;
806
+ background: var(--surface); border: var(--border-width-default) solid var(--border);
807
+ box-shadow: var(--shadow-md); padding: 6px; display: flex; flex-direction: column; gap: 2px;
808
+ }
809
+ .cpub-ce-status-current { font-size: 11px; color: var(--text-faint); margin: 0; padding: 4px 8px 6px; display: flex; align-items: center; gap: 6px; border-bottom: var(--border-width-default) solid var(--border); }
810
+ .cpub-ce-status-item {
811
+ display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
812
+ padding: 8px 10px; background: none; border: var(--border-width-default) solid transparent;
813
+ color: var(--text-dim); cursor: pointer; font-size: 12px; font-family: var(--font-sans);
814
+ }
815
+ .cpub-ce-status-item:hover { background: var(--surface2); border-color: var(--border2); color: var(--text); }
816
+ .cpub-ce-status-item i { width: 14px; text-align: center; font-size: 11px; }
817
+ .cpub-ce-status-go { color: var(--green); }
818
+ .cpub-ce-status-warn { color: var(--yellow); }
819
+ .cpub-ce-status-danger { color: var(--red); }
820
+ .cpub-ce-status-empty { font-size: 11px; color: var(--text-dim); margin: 0; padding: 8px 10px; display: flex; align-items: center; gap: 6px; }
821
+ .cpub-ce-status-empty i { color: var(--green); }
822
+
823
+ /* 3-panel shell */
824
+ .cpub-ce-shell { display: flex; flex: 1; overflow: hidden; }
825
+ .cpub-ce-library { width: 220px; flex-shrink: 0; background: var(--surface); border-right: var(--border-width-default) solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
826
+
827
+ .cpub-ce-center { flex: 1; overflow-y: auto; background: var(--bg); padding: 24px; display: flex; flex-direction: column; gap: 16px; min-width: 0; }
828
+ .cpub-ce-body-hint { margin: 0; }
829
+
830
+ .cpub-ce-settings { width: 340px; flex-shrink: 0; background: var(--surface); border-left: var(--border-width-default) solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
831
+ .cpub-ce-settings-body { flex: 1; overflow-y: auto; }
832
+
833
+ /* --- Status badge (also used in the topbar) --- */
834
+ .cpub-status-badge { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; flex-shrink: 0; }
835
+ .cpub-status-draft { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); border-style: dashed; }
836
+ .cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
837
+ .cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
838
+ .cpub-status-paused { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
839
+ .cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
840
+ .cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
841
+ .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
842
+
843
+ /* --- Form fields inside the rail (carried over verbatim) --- */
844
+ .cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
845
+ .cpub-form-field:last-child { margin-bottom: 0; }
846
+ .cpub-form-label { font-size: 11px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); }
847
+ .cpub-form-input {
848
+ width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border);
849
+ background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans);
850
+ }
851
+ .cpub-form-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
852
+ .cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
853
+ .cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
854
+ .cpub-form-check input { width: 14px; height: 14px; flex-shrink: 0; }
855
+ .cpub-type-options { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 6px; }
856
+ .cpub-form-subtitle { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; margin: 0 0 8px; }
857
+ .cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 0 0 12px; line-height: 1.5; }
858
+ .cpub-people-collab { margin-top: 16px; padding-top: 12px; border-top: var(--border-width-default) solid var(--border2); }
859
+
860
+ .cpub-prize-row { border: var(--border-width-default) solid var(--border); padding: 12px; margin-bottom: 10px; background: var(--surface2); }
861
+ .cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
862
+ .cpub-prize-label { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent); }
863
+ .cpub-prize-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; }
864
+ .cpub-prize-remove:hover { color: var(--red); }
865
+
866
+ /* Advancement (edit-only, inside the Stages section) */
867
+ .cpub-advance-section { margin-top: 16px; padding-top: 12px; border-top: var(--border-width-default) solid var(--border2); }
868
+ .cpub-advance-block { padding: 12px 0; border-top: var(--border-width-default) solid var(--border); }
869
+ .cpub-advance-block:first-of-type { border-top: 0; }
870
+ .cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
871
+ .cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
872
+ .cpub-advance-name i { color: var(--accent); font-size: 11px; }
873
+ .cpub-advance-mode { display: inline-flex; gap: 12px; }
874
+ .cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; }
875
+ .cpub-advance-ctl .cpub-form-label { margin: 0; }
876
+ .cpub-advance-n { width: 80px; }
877
+ .cpub-advance-manual { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
878
+ .cpub-advance-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); padding: 4px 8px; border: var(--border-width-default) solid var(--border); background: var(--surface2); cursor: pointer; }
879
+ .cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
880
+ .cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
881
+ .cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
882
+
883
+ /* Danger zone */
884
+ .cpub-danger-label { font-size: 13px; font-weight: 600; margin: 0 0 2px; color: var(--red); }
885
+ .cpub-danger-btn { color: var(--red); border-color: var(--red-border); margin-top: 6px; }
886
+ .cpub-danger-btn:hover:not(:disabled) { background: var(--red-bg); }
887
+
888
+ .cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
889
+
890
+ /* --- Inline media (banner + cover) in the Overview body --- */
891
+ .cpub-ce-media { display: flex; flex-direction: column; gap: 6px; }
892
+ .cpub-ce-banner {
893
+ position: relative; width: 100%; aspect-ratio: 4 / 1; background: var(--surface2);
894
+ border: var(--border-width-default) solid var(--border); overflow: hidden;
895
+ display: flex; align-items: center; justify-content: center;
896
+ }
897
+ .cpub-ce-banner-img { width: 100%; height: 100%; object-fit: cover; display: block; }
898
+ .cpub-ce-cover {
899
+ position: absolute; left: 16px; bottom: 12px; width: 120px; aspect-ratio: 4 / 3;
900
+ background: var(--surface); border: var(--border-width-default) solid var(--border);
901
+ box-shadow: var(--shadow-md); overflow: hidden;
902
+ display: flex; align-items: center; justify-content: center;
903
+ }
904
+ .cpub-ce-cover-img { width: 100%; height: 100%; object-fit: cover; display: block; }
905
+ .cpub-ce-media-placeholder { display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--text-faint); }
906
+ .cpub-ce-media-placeholder > i { font-size: 24px; }
907
+ .cpub-ce-media-placeholder > span { font-size: 11px; font-family: var(--font-mono); }
908
+ .cpub-ce-media-placeholder-sm > i { font-size: 16px; }
909
+ .cpub-ce-media-placeholder-sm > span { font-size: 9px; }
910
+ .cpub-ce-media-overlay {
911
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap;
912
+ background: var(--color-surface-scrim); opacity: 0; transition: opacity 0.15s;
913
+ }
914
+ .cpub-ce-banner:hover > .cpub-ce-media-overlay,
915
+ .cpub-ce-banner:focus-within > .cpub-ce-media-overlay,
916
+ .cpub-ce-cover:hover .cpub-ce-media-overlay,
917
+ .cpub-ce-cover:focus-within .cpub-ce-media-overlay { opacity: 1; }
918
+ @media (hover: none) {
919
+ .cpub-ce-banner > .cpub-ce-media-overlay, .cpub-ce-cover .cpub-ce-media-overlay { opacity: 1; }
920
+ }
921
+ .cpub-ce-media-btn {
922
+ font-size: 10px; padding: 5px 10px; background: var(--surface); border: var(--border-width-default) solid var(--border);
923
+ color: var(--text-dim); cursor: pointer; display: inline-flex; align-items: center; gap: 4px;
924
+ font-family: var(--font-mono); box-shadow: var(--shadow-sm);
925
+ }
926
+ .cpub-ce-media-btn.primary { background: var(--accent); color: var(--color-text-inverse); border-color: var(--accent); }
927
+ .cpub-ce-media-btn:hover { background: var(--surface2); }
928
+ .cpub-ce-media-btn.primary:hover { opacity: 0.9; background: var(--accent); }
929
+ .cpub-ce-media-btn-icon { padding: 5px 7px; }
930
+ .cpub-ce-media-hint { margin: 0; }
931
+ .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
932
+
933
+ /* --- Responsive: stack the rail under the body on narrow viewports --- */
934
+ @media (max-width: 1024px) {
935
+ .cpub-ce-layout { height: auto; min-height: 100vh; overflow: visible; }
936
+ .cpub-ce-shell { flex-direction: column; overflow: visible; }
937
+ .cpub-ce-library { width: auto; border-right: none; border-bottom: var(--border-width-default) solid var(--border); }
938
+ .cpub-ce-center { overflow: visible; }
939
+ .cpub-ce-settings { width: auto; border-left: none; border-top: var(--border-width-default) solid var(--border); }
940
+ .cpub-ce-settings-body { overflow: visible; }
941
+ }
942
+ @media (max-width: 768px) {
943
+ .cpub-ce-topbar { padding: 0 10px; }
944
+ .cpub-ce-topbar-divider { display: none; }
945
+ .cpub-ce-title-input { max-width: none; }
946
+ .cpub-ce-center { padding: 12px; }
947
+ }
948
+ </style>