@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,388 @@
1
+ /**
2
+ * useContestEditor — the single form model behind the contest editor shell,
3
+ * shared by BOTH the create and edit route shells (the ContestEditor orchestrator
4
+ * passes `mode`). Extracted from the two divergent monolith pages so create ≡ edit:
5
+ * one source of truth for the editable shape, slugging, date validation, dirty
6
+ * tracking, and the POST/PUT save.
7
+ *
8
+ * Owns refs + functions + two reactive watchers (dirty + create-mode slug derive),
9
+ * no component lifecycle hooks, so it unit-tests by calling it directly with a
10
+ * stubbed `$fetch` and the context callbacks as spies (mirrors useDocsSiteSettings).
11
+ *
12
+ * Dates are stored as ISO instants (the canonical API shape); CpubDateTimeField
13
+ * does the local-wall-clock conversion for the native picker, so there's no UTC
14
+ * round-trip bug and no per-field re-conversion at save (the Phase 1 datetime fix).
15
+ */
16
+ import { ref, computed, watch, nextTick, type Ref, type ComputedRef } from 'vue';
17
+ import type { ContestStage } from '@commonpub/schema';
18
+
19
+ export type ContestFormat = 'markdown' | 'html';
20
+ export type ContestVisibility = 'public' | 'unlisted' | 'private';
21
+ export type ContestJudgingVisibility = 'public' | 'judges-only' | 'private';
22
+
23
+ /** A prize row in the editor (every field optional; filtered on save). */
24
+ export interface ContestPrizeRow {
25
+ place: number | null;
26
+ category: string;
27
+ title: string;
28
+ description: string;
29
+ value: string;
30
+ }
31
+
32
+ /** A judging-criterion row (matches ContestCriteriaEditor's v-model shape). */
33
+ export interface ContestCriterionRow {
34
+ label: string;
35
+ weight?: number;
36
+ description?: string;
37
+ }
38
+
39
+ /** The subset of a fetched contest the editor hydrates from (edit mode). */
40
+ export interface ContestEditorSource {
41
+ title?: string | null;
42
+ slug?: string | null;
43
+ subheading?: string | null;
44
+ description?: string | null;
45
+ descriptionBlocks?: unknown[] | null;
46
+ rulesBlocks?: unknown[] | null;
47
+ prizesBlocks?: unknown[] | null;
48
+ rules?: string | null;
49
+ descriptionFormat?: string | null;
50
+ rulesFormat?: string | null;
51
+ prizesDescriptionFormat?: string | null;
52
+ bannerUrl?: string | null;
53
+ coverImageUrl?: string | null;
54
+ startDate?: string | null;
55
+ endDate?: string | null;
56
+ judgingEndDate?: string | null;
57
+ communityVotingEnabled?: boolean | null;
58
+ judgingVisibility?: string | null;
59
+ eligibleContentTypes?: string[] | null;
60
+ maxEntriesPerUser?: number | null;
61
+ visibility?: string | null;
62
+ visibleToRoles?: string[] | null;
63
+ showPrizes?: boolean | null;
64
+ stages?: ContestStage[] | null;
65
+ currentStageId?: string | null;
66
+ prizesDescription?: string | null;
67
+ prizes?: { place?: number; category?: string; title?: string; description?: string; value?: string }[] | null;
68
+ judgingCriteria?: { label: string; weight?: number; description?: string }[] | null;
69
+ }
70
+
71
+ export interface UseContestEditorOptions {
72
+ mode: 'create' | 'edit';
73
+ /** Current contest slug (edit mode; the `[slug]` route param). '' in create. */
74
+ slug: () => string;
75
+ /** Toast helper (kind maps to useToast().success / .error). */
76
+ toast: (message: string, kind: 'success' | 'error') => void;
77
+ /** Extract a human message from an API error (useApiError().extract). */
78
+ extractError: (err: unknown) => string;
79
+ /** Navigate to a path (navigateTo); the return is awaited but otherwise unused. */
80
+ navigate: (path: string) => unknown;
81
+ /** Re-fetch the contest after an edit save (edit mode only). */
82
+ refresh?: () => Promise<void> | void;
83
+ /**
84
+ * Called (instead of `navigate`) when a SILENT save renames the slug, so the
85
+ * orchestrator can swap the edit URL in place without leaving the editor (the
86
+ * draft-autosave path). The next save targets `newSlug` via `opts.slug()`.
87
+ */
88
+ onRenamed?: (newSlug: string) => void;
89
+ }
90
+
91
+ export interface UseContestEditor {
92
+ // form refs
93
+ title: Ref<string>;
94
+ slugInput: Ref<string>;
95
+ slugTouched: Ref<boolean>;
96
+ subheading: Ref<string>;
97
+ description: Ref<string>;
98
+ descriptionBlocks: Ref<unknown[] | null>;
99
+ rulesBlocks: Ref<unknown[] | null>;
100
+ prizesBlocks: Ref<unknown[] | null>;
101
+ rules: Ref<string>;
102
+ descriptionFormat: Ref<ContestFormat>;
103
+ rulesFormat: Ref<ContestFormat>;
104
+ prizesDescriptionFormat: Ref<ContestFormat>;
105
+ bannerUrl: Ref<string>;
106
+ coverImageUrl: Ref<string>;
107
+ startDate: Ref<string>;
108
+ endDate: Ref<string>;
109
+ judgingEndDate: Ref<string>;
110
+ communityVotingEnabled: Ref<boolean>;
111
+ judgingVisibility: Ref<ContestJudgingVisibility>;
112
+ eligibleContentTypes: Ref<string[]>;
113
+ maxEntriesPerUser: Ref<number | null>;
114
+ visibility: Ref<ContestVisibility>;
115
+ visibleToRoles: Ref<string[]>;
116
+ showPrizes: Ref<boolean>;
117
+ prizesDescription: Ref<string>;
118
+ prizes: Ref<ContestPrizeRow[]>;
119
+ criteria: Ref<ContestCriterionRow[]>;
120
+ stages: Ref<ContestStage[]>;
121
+ currentStageId: Ref<string | null>;
122
+ // status
123
+ saving: Ref<boolean>;
124
+ formDirty: Ref<boolean>;
125
+ dateError: ComputedRef<string>;
126
+ canSubmit: ComputedRef<boolean>;
127
+ // helpers
128
+ slugify: (s: string) => string;
129
+ toggleType: (type: string) => void;
130
+ toggleRole: (role: string) => void;
131
+ addPrize: () => void;
132
+ removePrize: (index: number) => void;
133
+ prizeLabel: (prize: ContestPrizeRow) => string;
134
+ // lifecycle
135
+ hydrate: (c: ContestEditorSource) => void;
136
+ buildPayload: () => Record<string, unknown>;
137
+ /** Persist the form. `silent` (autosave) skips the success toast + navigation +
138
+ * refresh, renames in place via `onRenamed`, and rethrows on failure so the
139
+ * caller's status machine can react. */
140
+ save: (opts?: { silent?: boolean }) => Promise<void>;
141
+ }
142
+
143
+ export function slugifyContest(s: string): string {
144
+ return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
145
+ }
146
+
147
+ function asFormat(v: string | null | undefined): ContestFormat {
148
+ return v === 'html' ? 'html' : 'markdown';
149
+ }
150
+
151
+ export function useContestEditor(opts: UseContestEditorOptions): UseContestEditor {
152
+ const title = ref('');
153
+ const slugInput = ref('');
154
+ // In create mode the slug auto-derives from the title until the operator edits
155
+ // it; in edit mode it's seeded from the contest and never auto-derives.
156
+ const slugTouched = ref(opts.mode === 'edit');
157
+ const subheading = ref('');
158
+ // Legacy plain-text body fields — no longer edited (the body is BlockTuple[]),
159
+ // but kept on the model so an edit save round-trips them unchanged (back-compat).
160
+ const description = ref('');
161
+ const rules = ref('');
162
+ const prizesDescription = ref('');
163
+ const descriptionBlocks = ref<unknown[] | null>(null);
164
+ const rulesBlocks = ref<unknown[] | null>(null);
165
+ const prizesBlocks = ref<unknown[] | null>(null);
166
+ const descriptionFormat = ref<ContestFormat>('markdown');
167
+ const rulesFormat = ref<ContestFormat>('markdown');
168
+ const prizesDescriptionFormat = ref<ContestFormat>('markdown');
169
+ const bannerUrl = ref('');
170
+ const coverImageUrl = ref('');
171
+ const startDate = ref('');
172
+ const endDate = ref('');
173
+ const judgingEndDate = ref('');
174
+ const communityVotingEnabled = ref(false);
175
+ const judgingVisibility = ref<ContestJudgingVisibility>('judges-only');
176
+ const eligibleContentTypes = ref<string[]>([]);
177
+ const maxEntriesPerUser = ref<number | null>(null);
178
+ const visibility = ref<ContestVisibility>('public');
179
+ const visibleToRoles = ref<string[]>([]);
180
+ const showPrizes = ref(true);
181
+ const prizes = ref<ContestPrizeRow[]>([]);
182
+ const criteria = ref<ContestCriterionRow[]>([]);
183
+ const stages = ref<ContestStage[]>([]);
184
+ const currentStageId = ref<string | null>(null);
185
+
186
+ const saving = ref(false);
187
+ const formDirty = ref(false);
188
+ // Suppress the dirty watcher while hydrate() bulk-populates the refs.
189
+ let hydrating = false;
190
+
191
+ const dateError = computed<string>(() => {
192
+ if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
193
+ return 'End date must be after the start date.';
194
+ }
195
+ if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
196
+ return 'Judging end date must be on or after the end date.';
197
+ }
198
+ return '';
199
+ });
200
+
201
+ // Create requires title + both dates up front; edit only enables Save once dirty.
202
+ const canSubmit = computed<boolean>(() => {
203
+ if (dateError.value) return false;
204
+ if (opts.mode === 'create') return !!title.value.trim() && !!startDate.value && !!endDate.value;
205
+ return !!title.value.trim() && formDirty.value;
206
+ });
207
+
208
+ function toggleType(type: string): void {
209
+ const i = eligibleContentTypes.value.indexOf(type);
210
+ if (i >= 0) eligibleContentTypes.value.splice(i, 1);
211
+ else eligibleContentTypes.value.push(type);
212
+ }
213
+ function toggleRole(role: string): void {
214
+ const i = visibleToRoles.value.indexOf(role);
215
+ if (i >= 0) visibleToRoles.value.splice(i, 1);
216
+ else visibleToRoles.value.push(role);
217
+ }
218
+ function addPrize(): void {
219
+ prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
220
+ }
221
+ function removePrize(index: number): void {
222
+ prizes.value.splice(index, 1);
223
+ }
224
+ function prizeLabel(prize: ContestPrizeRow): string {
225
+ if (prize.category.trim()) return prize.category;
226
+ if (prize.place && prize.place > 0) {
227
+ const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
228
+ return `${labels[prize.place - 1] || `${prize.place}th`} Place`;
229
+ }
230
+ // No place + no category: a flexible/description-only prize — don't invent a
231
+ // placement (the old code labelled these "Nth Place" by row index).
232
+ return 'Prize';
233
+ }
234
+
235
+ function hydrate(c: ContestEditorSource): void {
236
+ hydrating = true;
237
+ title.value = c.title ?? '';
238
+ slugInput.value = c.slug ?? '';
239
+ slugTouched.value = true;
240
+ subheading.value = c.subheading ?? '';
241
+ description.value = c.description ?? '';
242
+ descriptionBlocks.value = c.descriptionBlocks ?? null;
243
+ rulesBlocks.value = c.rulesBlocks ?? null;
244
+ prizesBlocks.value = c.prizesBlocks ?? null;
245
+ rules.value = c.rules ?? '';
246
+ descriptionFormat.value = asFormat(c.descriptionFormat);
247
+ rulesFormat.value = asFormat(c.rulesFormat);
248
+ prizesDescriptionFormat.value = asFormat(c.prizesDescriptionFormat);
249
+ bannerUrl.value = c.bannerUrl ?? '';
250
+ coverImageUrl.value = c.coverImageUrl ?? '';
251
+ // ISO instants stored verbatim; CpubDateTimeField renders them in local time.
252
+ startDate.value = c.startDate ?? '';
253
+ endDate.value = c.endDate ?? '';
254
+ judgingEndDate.value = c.judgingEndDate ?? '';
255
+ communityVotingEnabled.value = !!c.communityVotingEnabled;
256
+ judgingVisibility.value = (c.judgingVisibility as ContestJudgingVisibility) ?? 'judges-only';
257
+ eligibleContentTypes.value = [...(c.eligibleContentTypes ?? [])];
258
+ maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
259
+ visibility.value = (c.visibility as ContestVisibility) ?? 'public';
260
+ visibleToRoles.value = [...(c.visibleToRoles ?? [])];
261
+ showPrizes.value = c.showPrizes !== false;
262
+ stages.value = Array.isArray(c.stages) ? [...c.stages] : [];
263
+ currentStageId.value = c.currentStageId ?? null;
264
+ prizesDescription.value = c.prizesDescription ?? '';
265
+ prizes.value = (c.prizes ?? []).map((p) => ({
266
+ place: p.place ?? null,
267
+ category: p.category ?? '',
268
+ title: p.title ?? '',
269
+ description: p.description ?? '',
270
+ value: p.value ?? '',
271
+ }));
272
+ criteria.value = (c.judgingCriteria ?? []).map((cr) => ({
273
+ label: cr.label,
274
+ weight: cr.weight ?? undefined,
275
+ description: cr.description ?? undefined,
276
+ }));
277
+ formDirty.value = false;
278
+ // Re-arm dirty tracking once this hydration's reactive effects have settled.
279
+ void nextTick(() => { hydrating = false; });
280
+ }
281
+
282
+ function buildPayload(): Record<string, unknown> {
283
+ const prizeData = prizes.value
284
+ .filter((p) => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
285
+ .map((p) => ({
286
+ place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
287
+ category: p.category.trim() || undefined,
288
+ title: p.title.trim() || undefined,
289
+ description: p.description.trim() || undefined,
290
+ value: p.value.trim() || undefined,
291
+ }));
292
+ const criteriaData = criteria.value
293
+ .filter((c) => c.label.trim())
294
+ .map((c) => ({
295
+ label: c.label.trim(),
296
+ weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
297
+ description: (c.description ?? '').trim() || undefined,
298
+ }));
299
+ return {
300
+ title: title.value,
301
+ slug: slugifyContest(slugInput.value) || undefined,
302
+ subheading: subheading.value || undefined,
303
+ description: description.value || undefined,
304
+ descriptionBlocks: descriptionBlocks.value ?? undefined,
305
+ rulesBlocks: rulesBlocks.value ?? undefined,
306
+ prizesBlocks: prizesBlocks.value ?? undefined,
307
+ rules: rules.value || undefined,
308
+ descriptionFormat: descriptionFormat.value,
309
+ rulesFormat: rulesFormat.value,
310
+ prizesDescriptionFormat: prizesDescriptionFormat.value,
311
+ bannerUrl: bannerUrl.value || undefined,
312
+ coverImageUrl: coverImageUrl.value || undefined,
313
+ startDate: startDate.value || undefined,
314
+ endDate: endDate.value || undefined,
315
+ judgingEndDate: judgingEndDate.value || undefined,
316
+ communityVotingEnabled: communityVotingEnabled.value,
317
+ judgingVisibility: judgingVisibility.value,
318
+ eligibleContentTypes: eligibleContentTypes.value,
319
+ maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
320
+ visibility: visibility.value,
321
+ visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
322
+ showPrizes: showPrizes.value,
323
+ stages: stages.value,
324
+ currentStageId: currentStageId.value ?? undefined,
325
+ prizesDescription: prizesDescription.value || undefined,
326
+ prizes: prizeData,
327
+ judgingCriteria: criteriaData,
328
+ };
329
+ }
330
+
331
+ async function save(saveOpts?: { silent?: boolean }): Promise<void> {
332
+ const silent = saveOpts?.silent ?? false;
333
+ if (dateError.value) { if (!silent) opts.toast(dateError.value, 'error'); return; }
334
+ if (opts.mode === 'create' && (!title.value.trim() || !startDate.value || !endDate.value)) return;
335
+ saving.value = true;
336
+ try {
337
+ if (opts.mode === 'create') {
338
+ const result = await $fetch<{ slug: string }>('/api/contests', { method: 'POST', body: buildPayload() });
339
+ if (!silent) opts.toast('Contest created', 'success');
340
+ await opts.navigate(`/contests/${result.slug}`);
341
+ return;
342
+ }
343
+ const fromSlug = opts.slug();
344
+ const updated = await $fetch<{ slug: string }>(`/api/contests/${fromSlug}`, { method: 'PUT', body: buildPayload() });
345
+ if (!silent) opts.toast('Contest updated', 'success');
346
+ formDirty.value = false;
347
+ // Slug changed -> the old URL no longer resolves. A manual save navigates to
348
+ // the renamed contest's page; an autosave (silent) swaps the URL in place via
349
+ // onRenamed so the organizer keeps editing without a jump.
350
+ if (updated?.slug && updated.slug !== fromSlug) {
351
+ if (silent) opts.onRenamed?.(updated.slug);
352
+ else await opts.navigate(`/contests/${updated.slug}`);
353
+ return;
354
+ }
355
+ if (!silent) await opts.refresh?.();
356
+ } catch (err: unknown) {
357
+ if (silent) throw err; // let the autosave status machine surface it
358
+ opts.toast(opts.extractError(err), 'error');
359
+ } finally {
360
+ saving.value = false;
361
+ }
362
+ }
363
+
364
+ // Any post-hydration edit flips the dirty flag (drives the topbar "unsaved" cue).
365
+ watch(
366
+ [title, slugInput, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks, rules,
367
+ descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate, endDate,
368
+ judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
369
+ visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId],
370
+ () => { if (!hydrating) formDirty.value = true; },
371
+ { deep: true },
372
+ );
373
+
374
+ // Create mode: derive the slug from the title until the operator overrides it.
375
+ if (opts.mode === 'create') {
376
+ watch(title, (t) => { if (!slugTouched.value) slugInput.value = slugifyContest(t); });
377
+ }
378
+
379
+ return {
380
+ title, slugInput, slugTouched, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks,
381
+ rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate,
382
+ endDate, judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
383
+ visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId,
384
+ saving, formDirty, dateError, canSubmit,
385
+ slugify: slugifyContest, toggleType, toggleRole, addPrize, removePrize, prizeLabel,
386
+ hydrate, buildPayload, save,
387
+ };
388
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * useDocsPageTree — page-tree CRUD orchestration for the docs editor.
3
+ *
4
+ * Extracted from docs/[siteSlug]/edit.vue (whose autosave engine was already
5
+ * pulled into useEditorAutosave). The six tree actions — create, rename,
6
+ * duplicate, delete, reorder, reparent — were ~100 lines of inline
7
+ * `$fetch` + `refreshPages()` + toast scattered through the 1.4k-line page.
8
+ *
9
+ * The subtle part now lives here and is unit-tested: a drag that reparents a
10
+ * page and then immediately reorders its new siblings must NOT refresh twice
11
+ * (the first refresh would race the reorder). So `reparent` defers its refresh
12
+ * behind a short timer and `reorder` cancels that pending refresh — a tiny
13
+ * state machine that was easy to break inline.
14
+ *
15
+ * The page supplies its context (slug, version, page count, refresh, selection)
16
+ * via getters/callbacks, so this composable stays free of route globals and
17
+ * unit-tests with a stubbed `$fetch`.
18
+ */
19
+ import { ref } from 'vue';
20
+
21
+ export interface UseDocsPageTreeOptions {
22
+ /** Current site slug (the `[siteSlug]` route param). */
23
+ siteSlug: () => string;
24
+ /** Selected version id, sent as the new page's `versionId` (undefined = default version). */
25
+ versionId: () => string | undefined;
26
+ /** Selected version string, sent as the reorder `version` (falsy = omitted). */
27
+ version: () => string;
28
+ /** Number of existing pages, used to compute the new page's `sortOrder`. */
29
+ pageCount: () => number;
30
+ /** Re-fetch the page list. */
31
+ refreshPages: () => Promise<void> | void;
32
+ /** Select a page by id (after create / duplicate). */
33
+ selectPage: (id: string) => void;
34
+ /** Called when a page is deleted so the page can clear selection/editor if it was active. */
35
+ onDeleted: (pageId: string) => void;
36
+ /** Toast helper (useToast().show). */
37
+ toast: (message: string, kind: 'success' | 'error') => void;
38
+ }
39
+
40
+ export interface UseDocsPageTree {
41
+ createPage: (parentId: string | null, title: string) => Promise<void>;
42
+ renamePage: (pageId: string, newTitle: string) => Promise<void>;
43
+ duplicatePage: (pageId: string) => Promise<void>;
44
+ deletePage: (pageId: string) => Promise<void>;
45
+ reorder: (pageIds: string[]) => Promise<void>;
46
+ reparent: (pageId: string, newParentId: string | null) => Promise<void>;
47
+ }
48
+
49
+ /** A drag-reparent defers its refresh this long so a following reorder can cancel it. */
50
+ const REPARENT_REFRESH_DELAY_MS = 100;
51
+
52
+ export function useDocsPageTree(opts: UseDocsPageTreeOptions): UseDocsPageTree {
53
+ // True while a reparent is waiting to refresh; a reorder clears it to suppress
54
+ // the deferred refresh (the reorder's own refresh covers both moves).
55
+ const pendingReparent = ref(false);
56
+
57
+ function hasId(value: unknown): value is { id: string } {
58
+ return !!value && typeof value === 'object' && 'id' in value;
59
+ }
60
+
61
+ async function createPage(parentId: string | null, title: string): Promise<void> {
62
+ try {
63
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
64
+ const result = await $fetch(`/api/docs/${opts.siteSlug()}/pages`, {
65
+ method: 'POST',
66
+ body: {
67
+ title,
68
+ slug,
69
+ content: [['paragraph', { html: '' }]],
70
+ parentId: parentId ?? undefined,
71
+ sortOrder: opts.pageCount() + 1,
72
+ versionId: opts.versionId(),
73
+ },
74
+ });
75
+ await opts.refreshPages();
76
+ if (hasId(result)) opts.selectPage(result.id);
77
+ opts.toast('Page created', 'success');
78
+ } catch (err: unknown) {
79
+ opts.toast(err instanceof Error ? err.message : 'Failed to create page', 'error');
80
+ }
81
+ }
82
+
83
+ async function renamePage(pageId: string, newTitle: string): Promise<void> {
84
+ try {
85
+ await $fetch(`/api/docs/${opts.siteSlug()}/pages/${pageId}`, {
86
+ method: 'PUT',
87
+ body: { title: newTitle },
88
+ });
89
+ await opts.refreshPages();
90
+ opts.toast('Page renamed', 'success');
91
+ } catch (err: unknown) {
92
+ opts.toast(err instanceof Error ? err.message : 'Failed to rename', 'error');
93
+ }
94
+ }
95
+
96
+ async function duplicatePage(pageId: string): Promise<void> {
97
+ try {
98
+ const result = await $fetch(`/api/docs/${opts.siteSlug()}/pages/${pageId}/duplicate`, {
99
+ method: 'POST',
100
+ });
101
+ await opts.refreshPages();
102
+ if (hasId(result)) opts.selectPage(result.id);
103
+ opts.toast('Page duplicated', 'success');
104
+ } catch (err: unknown) {
105
+ opts.toast(err instanceof Error ? err.message : 'Failed to duplicate page', 'error');
106
+ }
107
+ }
108
+
109
+ async function deletePage(pageId: string): Promise<void> {
110
+ try {
111
+ await $fetch(`/api/docs/${opts.siteSlug()}/pages/${pageId}`, { method: 'DELETE' });
112
+ opts.onDeleted(pageId);
113
+ await opts.refreshPages();
114
+ opts.toast('Page deleted', 'success');
115
+ } catch (err: unknown) {
116
+ opts.toast(err instanceof Error ? err.message : 'Failed to delete', 'error');
117
+ }
118
+ }
119
+
120
+ async function reorder(pageIds: string[]): Promise<void> {
121
+ pendingReparent.value = false; // Cancel a preceding reparent's deferred refresh.
122
+ try {
123
+ await $fetch(`/api/docs/${opts.siteSlug()}/pages/reorder`, {
124
+ method: 'POST',
125
+ body: { pageIds, version: opts.version() || undefined },
126
+ });
127
+ await opts.refreshPages();
128
+ } catch {
129
+ opts.toast('Failed to reorder', 'error');
130
+ }
131
+ }
132
+
133
+ async function reparent(pageId: string, newParentId: string | null): Promise<void> {
134
+ try {
135
+ await $fetch(`/api/docs/${opts.siteSlug()}/pages/${pageId}`, {
136
+ method: 'PUT',
137
+ body: { parentId: newParentId ?? null },
138
+ });
139
+ // Defer the refresh: if a reorder follows immediately (drag across siblings),
140
+ // it cancels this and refreshes once. A standalone reparent refreshes itself.
141
+ pendingReparent.value = true;
142
+ setTimeout(async () => {
143
+ if (pendingReparent.value) {
144
+ pendingReparent.value = false;
145
+ await opts.refreshPages();
146
+ }
147
+ }, REPARENT_REFRESH_DELAY_MS);
148
+ } catch {
149
+ opts.toast('Failed to move page', 'error');
150
+ }
151
+ }
152
+
153
+ return { createPage, renamePage, duplicatePage, deletePage, reorder, reparent };
154
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * useDocsSiteSettings — site-level settings/versions actions for the docs editor.
3
+ *
4
+ * Continues the monolith split of docs/[siteSlug]/edit.vue (after
5
+ * useEditorAutosave and useDocsPageTree). The three site actions —
6
+ * save settings, delete site, create version — plus their dialog/form state
7
+ * were inlined in the 1.3k-line page.
8
+ *
9
+ * Like useDocsPageTree, this owns no lifecycle hooks (just refs + functions),
10
+ * so it unit-tests with a stubbed `$fetch` and `confirm`. The page supplies its
11
+ * context (slug, refresh, post-delete navigation, toast) via getters/callbacks,
12
+ * and seeds settingsName/settingsDesc from its own `watch(site)`.
13
+ */
14
+ import { ref, type Ref } from 'vue';
15
+
16
+ export interface UseDocsSiteSettingsOptions {
17
+ /** Current site slug (the `[siteSlug]` route param). */
18
+ siteSlug: () => string;
19
+ /** Re-fetch the site after a settings/version change. */
20
+ refreshSite: () => Promise<void> | void;
21
+ /** Called after the site is deleted (the page navigates away). */
22
+ onSiteDeleted: () => Promise<void> | void;
23
+ /** Toast helper (useToast().show). */
24
+ toast: (message: string, kind: 'success' | 'error') => void;
25
+ }
26
+
27
+ export interface UseDocsSiteSettings {
28
+ showSettings: Ref<boolean>;
29
+ settingsName: Ref<string>;
30
+ settingsDesc: Ref<string>;
31
+ savingSettings: Ref<boolean>;
32
+ newVersion: Ref<string>;
33
+ newVersionDefault: Ref<boolean>;
34
+ savingVersion: Ref<boolean>;
35
+ saveSiteSettings: () => Promise<void>;
36
+ deleteSite: () => Promise<void>;
37
+ createVersion: () => Promise<void>;
38
+ }
39
+
40
+ export function useDocsSiteSettings(opts: UseDocsSiteSettingsOptions): UseDocsSiteSettings {
41
+ const showSettings = ref(false);
42
+ const settingsName = ref('');
43
+ const settingsDesc = ref('');
44
+ const savingSettings = ref(false);
45
+ const newVersion = ref('');
46
+ const newVersionDefault = ref(false);
47
+ const savingVersion = ref(false);
48
+
49
+ async function saveSiteSettings(): Promise<void> {
50
+ savingSettings.value = true;
51
+ try {
52
+ await $fetch(`/api/docs/${opts.siteSlug()}`, {
53
+ method: 'PUT',
54
+ body: { name: settingsName.value, description: settingsDesc.value },
55
+ });
56
+ opts.toast('Site settings updated', 'success');
57
+ await opts.refreshSite();
58
+ } catch (err: unknown) {
59
+ opts.toast(err instanceof Error ? err.message : 'Failed to update settings', 'error');
60
+ } finally {
61
+ savingSettings.value = false;
62
+ }
63
+ }
64
+
65
+ async function deleteSite(): Promise<void> {
66
+ if (!confirm('Delete this entire docs site? All pages and versions will be permanently deleted.')) return;
67
+ try {
68
+ await $fetch(`/api/docs/${opts.siteSlug()}`, { method: 'DELETE' });
69
+ opts.toast('Docs site deleted', 'success');
70
+ await opts.onSiteDeleted();
71
+ } catch {
72
+ opts.toast('Failed to delete docs site', 'error');
73
+ }
74
+ }
75
+
76
+ async function createVersion(): Promise<void> {
77
+ if (!newVersion.value.trim()) return;
78
+ savingVersion.value = true;
79
+ try {
80
+ await $fetch(`/api/docs/${opts.siteSlug()}/versions`, {
81
+ method: 'POST',
82
+ body: { version: newVersion.value, isDefault: newVersionDefault.value },
83
+ });
84
+ opts.toast('Version created', 'success');
85
+ newVersion.value = '';
86
+ newVersionDefault.value = false;
87
+ await opts.refreshSite();
88
+ } catch (err: unknown) {
89
+ opts.toast(err instanceof Error ? err.message : 'Failed to create version', 'error');
90
+ } finally {
91
+ savingVersion.value = false;
92
+ }
93
+ }
94
+
95
+ return {
96
+ showSettings,
97
+ settingsName,
98
+ settingsDesc,
99
+ savingSettings,
100
+ newVersion,
101
+ newVersionDefault,
102
+ savingVersion,
103
+ saveSiteSettings,
104
+ deleteSite,
105
+ createVersion,
106
+ };
107
+ }