@commonpub/layer 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/components/AppToast.vue +1 -1
  2. package/components/ContentAvatar.vue +98 -0
  3. package/components/CpubCriteriaBar.vue +88 -0
  4. package/components/CpubDateTimeField.vue +73 -0
  5. package/components/CpubMarkdown.vue +3 -1
  6. package/components/FormatToggle.vue +2 -2
  7. package/components/ImageUpload.vue +5 -8
  8. package/components/MirrorDetailModal.vue +3 -1
  9. package/components/MirrorRequestApproveModal.vue +3 -1
  10. package/components/ProductEditModal.vue +184 -0
  11. package/components/RemoteFollowDialog.vue +2 -2
  12. package/components/SearchSidebar.vue +14 -21
  13. package/components/ShareToHubModal.vue +3 -1
  14. package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
  15. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
  16. package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
  17. package/components/blocks/BlockCompareColumnsView.vue +92 -0
  18. package/components/blocks/BlockContentRenderer.vue +17 -0
  19. package/components/blocks/BlockCriteriaBarView.vue +25 -0
  20. package/components/blocks/BlockGalleryView.vue +5 -0
  21. package/components/blocks/BlockHtmlView.vue +26 -0
  22. package/components/blocks/BlockImageView.vue +4 -0
  23. package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
  24. package/components/blocks/BlockRoadmapView.vue +84 -0
  25. package/components/blocks/BlockSponsorsView.vue +89 -0
  26. package/components/blocks/BlockTableView.vue +49 -0
  27. package/components/blocks/BlockTabsView.vue +121 -0
  28. package/components/contest/ContestBodyCanvas.vue +155 -0
  29. package/components/contest/ContestCriteriaEditor.vue +79 -0
  30. package/components/contest/ContestEditor.vue +948 -0
  31. package/components/contest/ContestEntries.vue +1 -1
  32. package/components/contest/ContestEntryPrivateData.vue +126 -0
  33. package/components/contest/ContestHero.vue +114 -186
  34. package/components/contest/ContestJudgeManager.vue +6 -4
  35. package/components/contest/ContestJudgingCriteria.vue +5 -21
  36. package/components/contest/ContestPrizes.vue +8 -1
  37. package/components/contest/ContestProposalForm.vue +88 -0
  38. package/components/contest/ContestRules.vue +8 -1
  39. package/components/contest/ContestSidebar.vue +11 -3
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +54 -20
  43. package/components/contest/ContestSubmissionField.vue +141 -0
  44. package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
  45. package/components/contest/blocks/ContestTabPanel.vue +27 -0
  46. package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
  47. package/components/contest/blocks/HtmlBlock.vue +61 -0
  48. package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
  49. package/components/contest/blocks/RoadmapBlock.vue +127 -0
  50. package/components/contest/blocks/SponsorsBlock.vue +127 -0
  51. package/components/contest/blocks/TableBlock.vue +101 -0
  52. package/components/contest/blocks/TabsBlock.vue +168 -0
  53. package/components/editors/ArticleEditor.vue +9 -16
  54. package/components/editors/ExplainerEditor.vue +8 -5
  55. package/components/editors/ProjectEditor.vue +13 -10
  56. package/components/homepage/CustomHtmlSection.vue +11 -2
  57. package/components/hub/HubProducts.vue +4 -2
  58. package/components/nav/NavDropdown.vue +1 -5
  59. package/components/nav/NavLink.vue +2 -0
  60. package/components/views/ArticleView.vue +3 -56
  61. package/components/views/ExplainerView.vue +4 -0
  62. package/components/views/ProjectView.vue +83 -245
  63. package/composables/useAuth.ts +13 -0
  64. package/composables/useCan.ts +23 -0
  65. package/composables/useContestEditor.ts +388 -0
  66. package/composables/useDocsPageTree.ts +154 -0
  67. package/composables/useDocsSiteSettings.ts +107 -0
  68. package/composables/useEditorAutosave.ts +131 -0
  69. package/composables/useEngagement.ts +13 -6
  70. package/composables/useFeatures.ts +9 -1
  71. package/composables/useFileUpload.ts +60 -0
  72. package/composables/useProfileContent.ts +84 -0
  73. package/composables/useSanitize.ts +38 -4
  74. package/composables/useScrollSpy.ts +87 -0
  75. package/layouts/admin.vue +43 -18
  76. package/layouts/default.vue +18 -9
  77. package/nuxt.config.ts +13 -0
  78. package/package.json +8 -8
  79. package/pages/[type]/index.vue +6 -1
  80. package/pages/admin/api-keys.vue +13 -3
  81. package/pages/admin/features.vue +2 -0
  82. package/pages/admin/federation.vue +1 -1
  83. package/pages/admin/layouts/[id].vue +30 -2
  84. package/pages/admin/roles.vue +286 -0
  85. package/pages/admin/settings.vue +2 -1
  86. package/pages/admin/users.vue +81 -1
  87. package/pages/admin/video-categories.vue +203 -0
  88. package/pages/cert/[code].vue +6 -2
  89. package/pages/contests/[slug]/edit.vue +4 -764
  90. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  91. package/pages/contests/[slug]/index.vue +97 -8
  92. package/pages/contests/[slug]/judge.vue +49 -26
  93. package/pages/contests/create.vue +5 -466
  94. package/pages/contests/index.vue +7 -2
  95. package/pages/cookies.vue +1 -1
  96. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  97. package/pages/docs/[siteSlug]/edit.vue +93 -231
  98. package/pages/events/[slug]/edit.vue +20 -20
  99. package/pages/events/create.vue +18 -18
  100. package/pages/events/index.vue +7 -2
  101. package/pages/hubs/[slug]/index.vue +34 -9
  102. package/pages/hubs/[slug]/invites.vue +312 -0
  103. package/pages/hubs/[slug]/members.vue +128 -0
  104. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  105. package/pages/hubs/index.vue +6 -1
  106. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  107. package/pages/learn/index.vue +8 -1
  108. package/pages/messages/index.vue +1 -1
  109. package/pages/mirror/[id].vue +1 -1
  110. package/pages/products/[slug].vue +55 -2
  111. package/pages/products/index.vue +6 -1
  112. package/pages/settings/account.vue +8 -8
  113. package/pages/settings/profile.vue +23 -14
  114. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  115. package/pages/u/[username]/followers.vue +11 -3
  116. package/pages/u/[username]/following.vue +10 -8
  117. package/pages/u/[username]/index.vue +73 -7
  118. package/pages/videos/index.vue +13 -10
  119. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  120. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  121. package/server/api/admin/api-keys/index.get.ts +1 -0
  122. package/server/api/admin/api-keys/index.post.ts +1 -0
  123. package/server/api/admin/federation/refederate.post.ts +18 -1
  124. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  125. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  126. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  127. package/server/api/admin/layouts/[id].delete.ts +1 -4
  128. package/server/api/admin/layouts/[id].get.ts +1 -4
  129. package/server/api/admin/layouts/[id].put.ts +1 -4
  130. package/server/api/admin/permissions.get.ts +14 -0
  131. package/server/api/admin/roles/[id]/index.delete.ts +25 -0
  132. package/server/api/admin/roles/[id]/index.put.ts +24 -0
  133. package/server/api/admin/roles/index.get.ts +10 -0
  134. package/server/api/admin/roles/index.post.ts +27 -0
  135. package/server/api/admin/users/[id]/role.put.ts +20 -1
  136. package/server/api/admin/users/[id]/roles.get.ts +10 -0
  137. package/server/api/admin/users/[id]/roles.put.ts +17 -0
  138. package/server/api/auth/federated/login.post.ts +12 -5
  139. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  140. package/server/api/content/[id]/build.get.ts +11 -0
  141. package/server/api/content/[id]/report.post.ts +2 -0
  142. package/server/api/content/[id]/versions.get.ts +15 -0
  143. package/server/api/contests/[slug]/advance.post.ts +10 -5
  144. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  145. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  146. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  147. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  148. package/server/api/contests/[slug]/export.get.ts +43 -0
  149. package/server/api/contests/[slug]/index.get.ts +10 -2
  150. package/server/api/contests/[slug]/index.put.ts +11 -2
  151. package/server/api/contests/[slug]/judge.post.ts +8 -2
  152. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  153. package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
  154. package/server/api/contests/[slug]/transition.post.ts +8 -3
  155. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  156. package/server/api/contests/index.post.ts +1 -1
  157. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  158. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  159. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  160. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  161. package/server/api/events/[slug]/attendees.get.ts +10 -0
  162. package/server/api/events/[slug].get.ts +9 -0
  163. package/server/api/events/index.get.ts +8 -1
  164. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  165. package/server/api/federation/content/[id]/build.get.ts +10 -0
  166. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  167. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  168. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  169. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  170. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  171. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  172. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  173. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  174. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  175. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  176. package/server/api/me.get.ts +7 -0
  177. package/server/api/products/[id].delete.ts +22 -2
  178. package/server/api/registry/ping.post.ts +17 -3
  179. package/server/api/search/index.get.ts +5 -3
  180. package/server/api/social/bookmark.get.ts +1 -0
  181. package/server/api/social/bookmark.post.ts +1 -0
  182. package/server/api/social/bookmarks.get.ts +1 -0
  183. package/server/api/social/comments/[id].delete.ts +1 -0
  184. package/server/api/social/comments.get.ts +1 -0
  185. package/server/api/social/comments.post.ts +1 -0
  186. package/server/api/social/like.get.ts +1 -0
  187. package/server/api/social/like.post.ts +1 -0
  188. package/server/api/users/[username]/content.get.ts +15 -3
  189. package/server/api/users/[username]/follow.delete.ts +1 -0
  190. package/server/api/users/[username]/follow.post.ts +1 -0
  191. package/server/api/users/[username]/followers.get.ts +2 -1
  192. package/server/api/users/[username]/following.get.ts +2 -1
  193. package/server/middleware/content-ap.ts +8 -3
  194. package/server/middleware/csrf.ts +93 -0
  195. package/server/plugins/federation-hub-sync.ts +48 -17
  196. package/server/plugins/notification-email.ts +22 -3
  197. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  198. package/server/routes/inbox.ts +14 -1
  199. package/server/routes/users/[username]/inbox.ts +13 -1
  200. package/server/utils/inbox.ts +7 -2
  201. package/server/utils/validate.ts +22 -0
  202. package/theme/base.css +5 -0
  203. package/theme/prose.css +20 -0
  204. package/theme/stoa-dark.css +4 -0
  205. package/types/contestBlocks.ts +122 -0
  206. package/utils/contestBlocks.ts +107 -0
  207. package/utils/contestBody.ts +25 -0
  208. package/utils/contestStages.ts +62 -0
  209. package/utils/contestSubmission.ts +97 -0
  210. package/utils/datetime.ts +45 -0
  211. package/utils/projectBlocks.ts +162 -0
  212. package/components/editors/BlogEditor.vue +0 -648
@@ -1,769 +1,9 @@
1
1
  <script setup lang="ts">
2
- import type { ContestStage } from '@commonpub/schema';
3
- import type { Serialized, ContestEntryItem } from '@commonpub/server';
4
-
5
- definePageMeta({ middleware: 'auth' });
6
-
7
- const route = useRoute();
8
- const slug = route.params.slug as string;
9
- const toast = useToast();
10
- const { extract: extractError } = useApiError();
11
- const { user, isAdmin } = useAuth();
12
-
13
- const { data: contest, refresh, status: contestStatus } = useLazyFetch(`/api/contests/${slug}`);
14
- // `useLazyFetch` doesn't block navigation, so on a client-side nav (clicking
15
- // "Edit Contest") `contest` is null until the fetch resolves. Without this we'd
16
- // render the "Contest not found" branch during that window — which reads as a
17
- // broken link. Treat idle/pending as "loading", not "not found".
18
- const contestLoading = computed(() => contestStatus.value === 'idle' || contestStatus.value === 'pending');
19
- const isOwner = computed(() => isAdmin.value || !!(user.value?.id && contest.value?.createdById === user.value.id));
20
- useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'}, ${useSiteName()}` });
21
-
22
- const saving = ref(false);
23
- const title = ref('');
24
- // Editable slug — initialised from the loaded contest, manual override allowed.
25
- const slugInput = ref('');
26
- function slugify(s: string): string {
27
- return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
28
- }
29
- const subheading = ref('');
30
- const description = ref('');
31
- const rules = ref('');
32
- // Per-field render mode: Markdown (default) or raw HTML, independent per field.
33
- const descriptionFormat = ref<'markdown' | 'html'>('markdown');
34
- const rulesFormat = ref<'markdown' | 'html'>('markdown');
35
- const prizesDescriptionFormat = ref<'markdown' | 'html'>('markdown');
36
- const bannerUrl = ref('');
37
- const coverImageUrl = ref('');
38
- const startDate = ref('');
39
- const endDate = ref('');
40
- const judgingEndDate = ref('');
41
- const communityVotingEnabled = ref(false);
42
- const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
43
-
44
- const { enabledTypeMeta } = useContentTypes();
45
- const eligibleContentTypes = ref<string[]>([]);
46
- const maxEntriesPerUser = ref<number | null>(null);
47
- function toggleType(type: string): void {
48
- const i = eligibleContentTypes.value.indexOf(type);
49
- if (i >= 0) eligibleContentTypes.value.splice(i, 1);
50
- else eligibleContentTypes.value.push(type);
51
- }
52
-
53
- const visibility = ref<'public' | 'unlisted' | 'private'>('public');
54
- const visibleToRoles = ref<string[]>([]);
55
- const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
56
- function toggleRole(r: string): void {
57
- const i = visibleToRoles.value.indexOf(r);
58
- if (i >= 0) visibleToRoles.value.splice(i, 1);
59
- else visibleToRoles.value.push(r);
60
- }
61
-
62
- const showPrizes = ref(true);
63
- const prizesDescription = ref('');
64
- interface Prize { place: number | null; category: string; title: string; description: string; value: string }
65
- const prizes = ref<Prize[]>([]);
66
-
67
- interface Criterion { label: string; weight: number | null; description: string }
68
- const criteria = ref<Criterion[]>([]);
69
-
70
- // Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
71
- const stages = ref<ContestStage[]>([]);
72
- const currentStageIdRef = ref<string | null>(null);
73
- // Declared before the contest loader (below) since the loader pre-fills advanceN.
74
- const advancing = ref<string | null>(null);
75
- const advanceN = ref<Record<string, number>>({});
76
-
77
- // Dirty tracking: any edit after the contest loads flips this so the save bar
78
- // shows "unsaved changes" — feedback that a change (e.g. checking an eligible
79
- // type) registered. `hydratingForm` suppresses the watcher while the loader
80
- // populates the fields from the fetched contest.
81
- const formDirty = ref(false);
82
- let hydratingForm = false;
83
-
84
- // Load current data
85
- watch(contest, (c) => {
86
- if (!c) return;
87
- hydratingForm = true;
88
- title.value = c.title ?? '';
89
- slugInput.value = c.slug ?? '';
90
- subheading.value = c.subheading ?? '';
91
- description.value = c.description ?? '';
92
- rules.value = c.rules ?? '';
93
- descriptionFormat.value = (c.descriptionFormat as 'markdown' | 'html') ?? 'markdown';
94
- rulesFormat.value = (c.rulesFormat as 'markdown' | 'html') ?? 'markdown';
95
- prizesDescriptionFormat.value = (c.prizesDescriptionFormat as 'markdown' | 'html') ?? 'markdown';
96
- bannerUrl.value = c.bannerUrl ?? '';
97
- coverImageUrl.value = c.coverImageUrl ?? '';
98
- startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
99
- endDate.value = c.endDate ? new Date(c.endDate).toISOString().slice(0, 16) : '';
100
- judgingEndDate.value = c.judgingEndDate ? new Date(c.judgingEndDate).toISOString().slice(0, 16) : '';
101
- communityVotingEnabled.value = !!c.communityVotingEnabled;
102
- judgingVisibility.value = (c.judgingVisibility as typeof judgingVisibility.value) ?? 'judges-only';
103
- eligibleContentTypes.value = [...(c.eligibleContentTypes ?? [])];
104
- maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
105
- visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
106
- visibleToRoles.value = [...(c.visibleToRoles ?? [])];
107
- showPrizes.value = c.showPrizes !== false;
108
- stages.value = Array.isArray(c.stages) ? [...c.stages] : [];
109
- currentStageIdRef.value = c.currentStageId ?? null;
110
- // Pre-fill the Advancement control from each review stage's defined cut.
111
- for (const s of stages.value) {
112
- if (s.kind === 'review' && typeof s.advanceCount === 'number') advanceN.value[s.id] = s.advanceCount;
113
- }
114
- prizesDescription.value = c.prizesDescription ?? '';
115
- prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
116
- place: p.place ?? null,
117
- category: p.category ?? '',
118
- title: p.title ?? '',
119
- description: p.description ?? '',
120
- value: p.value ?? '',
121
- }));
122
- criteria.value = (c.judgingCriteria ?? []).map((cr: { label: string; weight?: number; description?: string }) => ({
123
- label: cr.label,
124
- weight: cr.weight ?? null,
125
- description: cr.description ?? '',
126
- }));
127
- // Let the field watchers settle from this hydration, then re-arm dirty tracking.
128
- void nextTick(() => { hydratingForm = false; });
129
- }, { immediate: true });
130
-
131
- // Mark the form dirty on any post-hydration edit (gives the save bar its
132
- // "unsaved changes" cue). Worst case (timing) is a harmless early "dirty".
133
- watch(
134
- [title, slugInput, subheading, description, rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
135
- communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
136
- showPrizes, stages, currentStageIdRef, prizesDescription, prizes, criteria],
137
- () => { if (!hydratingForm) formDirty.value = true; },
138
- { deep: true },
139
- );
140
-
141
- function addPrize(): void {
142
- prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
143
- }
144
- function removePrize(index: number): void {
145
- prizes.value.splice(index, 1);
146
- }
147
- function prizeLabel(prize: Prize): string {
148
- if (prize.category.trim()) return prize.category;
149
- if (prize.place && prize.place > 0) {
150
- const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
151
- return `${labels[prize.place - 1] || `${prize.place}th`} Place`;
152
- }
153
- // No place + no category: a flexible/description-only prize — don't invent
154
- // a placement (the old code labelled these "Nth Place" by row index).
155
- return 'Prize';
156
- }
157
-
158
- function addCriterion(): void {
159
- criteria.value.push({ label: '', weight: null, description: '' });
160
- }
161
- function removeCriterion(index: number): void {
162
- criteria.value.splice(index, 1);
163
- }
164
- const criteriaTotal = computed(() => criteria.value.reduce((s, c) => s + (c.weight ?? 0), 0));
165
-
166
- const dateError = computed(() => {
167
- if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
168
- return 'End date must be after the start date.';
169
- }
170
- if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
171
- return 'Judging end date must be on or after the end date.';
172
- }
173
- return '';
174
- });
175
-
176
- async function handleSave(): Promise<void> {
177
- if (dateError.value) { toast.error(dateError.value); return; }
178
- saving.value = true;
179
- try {
180
- const prizeData = prizes.value
181
- .filter((p) => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
182
- .map((p) => ({
183
- place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
184
- category: p.category.trim() || undefined,
185
- title: p.title.trim() || undefined,
186
- description: p.description.trim() || undefined,
187
- value: p.value.trim() || undefined,
188
- }));
189
- const criteriaData = criteria.value
190
- .filter((c) => c.label.trim())
191
- .map((c) => ({
192
- label: c.label.trim(),
193
- weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
194
- description: c.description.trim() || undefined,
195
- }));
196
-
197
- const updated = await $fetch<{ slug: string }>(`/api/contests/${slug}`, {
198
- method: 'PUT',
199
- body: {
200
- title: title.value,
201
- slug: slugify(slugInput.value) || undefined,
202
- subheading: subheading.value || undefined,
203
- description: description.value || undefined,
204
- rules: rules.value || undefined,
205
- descriptionFormat: descriptionFormat.value,
206
- rulesFormat: rulesFormat.value,
207
- prizesDescriptionFormat: prizesDescriptionFormat.value,
208
- bannerUrl: bannerUrl.value || undefined,
209
- coverImageUrl: coverImageUrl.value || undefined,
210
- startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
211
- endDate: endDate.value ? new Date(endDate.value).toISOString() : undefined,
212
- judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
213
- communityVotingEnabled: communityVotingEnabled.value,
214
- judgingVisibility: judgingVisibility.value,
215
- eligibleContentTypes: eligibleContentTypes.value,
216
- maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
217
- visibility: visibility.value,
218
- visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
219
- showPrizes: showPrizes.value,
220
- stages: stages.value,
221
- currentStageId: currentStageIdRef.value ?? undefined,
222
- prizesDescription: prizesDescription.value || undefined,
223
- prizes: prizeData,
224
- judgingCriteria: criteriaData,
225
- },
226
- });
227
- toast.success('Contest updated');
228
- formDirty.value = false;
229
- // Slug changed → the old URL no longer resolves. Navigate to the renamed
230
- // contest's page — a different route component, so it loads fresh. (Navigating
231
- // to the new /edit URL would reuse THIS component with its stale fetch key.)
232
- if (updated?.slug && updated.slug !== slug) {
233
- await navigateTo(`/contests/${updated.slug}`);
234
- return;
235
- }
236
- await refresh();
237
- } catch (err: unknown) {
238
- toast.error(extractError(err));
239
- } finally {
240
- saving.value = false;
241
- }
242
- }
243
-
244
- const deleting = ref(false);
245
- async function handleDelete(): Promise<void> {
246
- if (!confirm('Permanently delete this contest? All entries, judges, and reviewers are removed. This cannot be undone.')) return;
247
- deleting.value = true;
248
- try {
249
- await $fetch(`/api/contests/${slug}`, { method: 'DELETE' });
250
- toast.success('Contest deleted');
251
- await navigateTo('/contests');
252
- } catch (err: unknown) {
253
- toast.error(extractError(err));
254
- deleting.value = false;
255
- }
256
- }
257
-
258
- // Bidirectional lifecycle controls — the valid-transition map + button metadata
259
- // live in utils/contestTransitions.ts (shared with ContestHero).
260
- const availableTransitions = computed<string[]>(() => contestTransitionsFrom(contest.value?.status));
261
- const statusAction = contestStatusAction;
262
-
263
- // Phase B2 — advancement cuts. Operates on the PERSISTED stages (contest.value),
264
- // not the editable `stages` ref, since it acts on real entries.
265
- const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
266
-
267
- // Entries (the cohort) — for the manual advancement picker. The eligible set is
268
- // everyone not already eliminated by a prior round's cut.
269
- const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[] }>(`/api/contests/${slug}/entries`);
270
- const eligibleEntries = computed(() => (entriesData.value?.items ?? []).filter((e) => !e.eliminated));
271
- const advanceMode = ref<Record<string, 'topN' | 'manual'>>({});
272
- const manualPick = ref<Record<string, string[]>>({});
273
- function toggleManual(stageId: string, entryId: string): void {
274
- const cur = manualPick.value[stageId] ?? [];
275
- manualPick.value[stageId] = cur.includes(entryId) ? cur.filter((x) => x !== entryId) : [...cur, entryId];
276
- }
277
- async function advanceStageManual(stageId: string): Promise<void> {
278
- const ids = manualPick.value[stageId] ?? [];
279
- if (!ids.length) { toast.error('Select at least one entry to advance.'); return; }
280
- 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;
281
- advancing.value = stageId;
282
- try {
283
- const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug}/advance`, {
284
- method: 'POST',
285
- body: { reviewStageId: stageId, mode: 'manual', advancedEntryIds: ids },
286
- });
287
- toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
288
- await Promise.all([refresh(), refreshEntries()]);
289
- } catch (err: unknown) {
290
- toast.error(extractError(err));
291
- } finally {
292
- advancing.value = null;
293
- }
294
- }
295
- async function advanceStage(stageId: string): Promise<void> {
296
- const topN = advanceN.value[stageId];
297
- if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
298
- 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;
299
- advancing.value = stageId;
300
- try {
301
- const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug}/advance`, {
302
- method: 'POST',
303
- body: { reviewStageId: stageId, mode: 'topN', topN },
304
- });
305
- toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
306
- await Promise.all([refresh(), refreshEntries()]);
307
- } catch (err: unknown) {
308
- toast.error(extractError(err));
309
- } finally {
310
- advancing.value = null;
311
- }
312
- }
313
-
314
- async function transitionStatus(newStatus: string): Promise<void> {
315
- // Only the consequential transitions confirm; reversible nudges (pause/resume,
316
- // go-back) just apply.
317
- if (newStatus === 'cancelled' && !confirm('Cancel this contest? This cannot be undone.')) return;
318
- if (newStatus === 'completed' && !confirm('Complete this contest and publish results? Final rankings will be calculated.')) return;
319
- try {
320
- await $fetch(`/api/contests/${slug}/transition`, { method: 'POST', body: { status: newStatus } });
321
- toast.success(`Status changed to ${newStatus}`);
322
- await refresh();
323
- } catch (err: unknown) {
324
- toast.error(extractError(err));
325
- }
326
- }
2
+ // Thin route shell the editor lives in the shared ContestEditor component
3
+ // (mode-aware), so create and edit stay identical. SEO + auth are handled inside.
4
+ definePageMeta({ middleware: 'auth', layout: false });
327
5
  </script>
328
6
 
329
7
  <template>
330
- <div v-if="contest && !isOwner" class="cpub-not-found">
331
- <p>You don't have permission to edit this contest.</p>
332
- <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
333
- </div>
334
- <div v-else-if="contest" class="cpub-contest-edit">
335
- <NuxtLink :to="`/contests/${slug}`" class="cpub-back-link"><i class="fa-solid fa-arrow-left"></i> Back to contest</NuxtLink>
336
- <h1 class="cpub-edit-title">Edit Contest</h1>
337
- <p class="cpub-edit-subtitle">
338
- Status: <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
339
- </p>
340
-
341
- <form class="cpub-edit-form" @submit.prevent="handleSave">
342
- <div class="cpub-edit-layout">
343
- <div class="cpub-edit-main">
344
- <section class="cpub-form-section">
345
- <h2 class="cpub-form-section-title">Details</h2>
346
- <div class="cpub-form-field">
347
- <label class="cpub-form-label">Title</label>
348
- <input v-model="title" type="text" class="cpub-form-input" />
349
- </div>
350
- <div class="cpub-form-field">
351
- <label class="cpub-form-label">URL Slug</label>
352
- <input v-model="slugInput" type="text" class="cpub-form-input" @blur="slugInput = slugify(slugInput)" />
353
- <p class="cpub-form-hint">The contest URL: <code>/contests/{{ slugify(slugInput) || 'your-contest' }}</code>. Changing it breaks old links, they won't redirect.</p>
354
- </div>
355
- <div class="cpub-form-field">
356
- <label class="cpub-form-label">Subheading</label>
357
- <input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
358
- <p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
359
- </div>
360
- <div class="cpub-form-field">
361
- <div class="cpub-field-head">
362
- <label class="cpub-form-label">Description</label>
363
- <FormatToggle v-model="descriptionFormat" />
364
- </div>
365
- <textarea v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" />
366
- <p class="cpub-form-hint">{{ descriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
367
- </div>
368
- <div class="cpub-form-field">
369
- <div class="cpub-field-head">
370
- <label class="cpub-form-label">Rules</label>
371
- <FormatToggle v-model="rulesFormat" />
372
- </div>
373
- <textarea v-model="rules" class="cpub-form-textarea" rows="6" maxlength="50000" placeholder="One rule per line, or full Markdown" />
374
- <p class="cpub-form-hint">{{ rulesFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown. Plain one-rule-per-line text renders as a list.' }}</p>
375
- </div>
376
- <div class="cpub-form-field">
377
- <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
378
- </div>
379
- <div class="cpub-form-field">
380
- <ImageUpload v-model="coverImageUrl" purpose="cover" label="Cover Image (optional)" hint="Card/thumbnail image shown in listings (~4:3). Falls back to the banner if unset." />
381
- </div>
382
- </section>
383
-
384
- <section class="cpub-form-section">
385
- <h2 class="cpub-form-section-title">Schedule</h2>
386
- <div class="cpub-form-row">
387
- <div class="cpub-form-field">
388
- <label class="cpub-form-label">Start Date</label>
389
- <input v-model="startDate" type="datetime-local" class="cpub-form-input" />
390
- </div>
391
- <div class="cpub-form-field">
392
- <label class="cpub-form-label">End Date</label>
393
- <input v-model="endDate" type="datetime-local" class="cpub-form-input" />
394
- </div>
395
- </div>
396
- <div class="cpub-form-field">
397
- <label class="cpub-form-label">Judging End Date</label>
398
- <input v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
399
- </div>
400
- <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
401
- </section>
402
-
403
- <section class="cpub-form-section">
404
- <h2 class="cpub-form-section-title">Stages</h2>
405
- <p class="cpub-form-hint">Optional. The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests, proposal rounds, a Top-N selection, a build sprint, multiple judging rounds, or a showcase event.</p>
406
- <p class="cpub-form-hint">How the pieces fit: <strong>Stages</strong> are the public timeline entrants see. The <strong>Status</strong> control (right) is what's actually open right now (accepting entries / judging / completed). <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>
407
- <ContestStagesEditor
408
- v-model="stages"
409
- v-model:current-stage-id="currentStageIdRef"
410
- :start-date="startDate"
411
- :end-date="endDate"
412
- :judging-end-date="judgingEndDate"
413
- />
414
- </section>
415
-
416
- <section v-if="reviewStages.length" class="cpub-form-section">
417
- <h2 class="cpub-form-section-title">Advancement</h2>
418
- <p class="cpub-form-hint">Multi-round contests: after judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced" and excluded from later judging + final results. Re-running re-computes the cut. (Save any stage changes above first.)</p>
419
- <div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-block">
420
- <div class="cpub-advance-row">
421
- <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
422
- <div class="cpub-advance-mode">
423
- <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>
424
- <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>
425
- </div>
426
- </div>
427
-
428
- <div v-if="(advanceMode[rs.id] ?? 'topN') === 'topN'" class="cpub-advance-ctl">
429
- <label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
430
- <input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
431
- <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
432
- <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
433
- </button>
434
- </div>
435
-
436
- <div v-else class="cpub-advance-manual">
437
- <p v-if="!eligibleEntries.length" class="cpub-form-hint" style="margin: 0;">No entries in the current cohort to pick from yet.</p>
438
- <template v-else>
439
- <label v-for="e in eligibleEntries" :key="e.id" class="cpub-advance-pick">
440
- <input type="checkbox" :checked="(manualPick[rs.id] ?? []).includes(e.id)" @change="toggleManual(rs.id, e.id)" />
441
- <span class="cpub-advance-pick-title">{{ e.contentTitle }}</span>
442
- <span v-if="e.score != null" class="cpub-advance-pick-score">{{ e.score }}</span>
443
- </label>
444
- <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id || !(manualPick[rs.id] ?? []).length" @click="advanceStageManual(rs.id)">
445
- <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : `Advance ${(manualPick[rs.id] ?? []).length} selected` }}
446
- </button>
447
- </template>
448
- </div>
449
- </div>
450
- </section>
451
-
452
- <section class="cpub-form-section">
453
- <h2 class="cpub-form-section-title">Prizes</h2>
454
- <label class="cpub-form-check" style="margin-bottom: 10px;">
455
- <input v-model="showPrizes" type="checkbox" />
456
- <span>Show the Prizes tab on the contest page</span>
457
- </label>
458
- <p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden, any prizes below are saved but not shown to visitors.</p>
459
- <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>, whatever fits. Cash value is optional.</p>
460
- <div class="cpub-form-field">
461
- <div class="cpub-field-head">
462
- <label class="cpub-form-label">Prizes overview (optional)</label>
463
- <FormatToggle v-model="prizesDescriptionFormat" />
464
- </div>
465
- <textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" maxlength="50000" placeholder="Intro shown above the prize cards." />
466
- <p class="cpub-form-hint">{{ prizesDescriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Markdown intro' }} displayed on the Prizes tab, above the individual prizes.</p>
467
- </div>
468
- <div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
469
- <div class="cpub-prize-header">
470
- <span class="cpub-prize-label">{{ prizeLabel(prize) }}</span>
471
- <button type="button" class="cpub-prize-remove" aria-label="Remove prize" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
472
- </div>
473
- <div class="cpub-form-row">
474
- <div class="cpub-form-field">
475
- <label class="cpub-form-label">Place</label>
476
- <input v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
477
- </div>
478
- <div class="cpub-form-field">
479
- <label class="cpub-form-label">Category (optional)</label>
480
- <input v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
481
- </div>
482
- </div>
483
- <div class="cpub-form-row">
484
- <div class="cpub-form-field">
485
- <label class="cpub-form-label">Title</label>
486
- <input v-model="prize.title" type="text" class="cpub-form-input" placeholder="e.g. Gold Prize" />
487
- </div>
488
- <div class="cpub-form-field">
489
- <label class="cpub-form-label">Value</label>
490
- <input v-model="prize.value" type="text" class="cpub-form-input" placeholder="e.g. $500" />
491
- </div>
492
- </div>
493
- <div class="cpub-form-field">
494
- <label class="cpub-form-label">Description</label>
495
- <input v-model="prize.description" type="text" class="cpub-form-input" placeholder="Optional description" />
496
- </div>
497
- </div>
498
- <button type="button" class="cpub-btn cpub-btn-sm" @click="addPrize"><i class="fa-solid fa-plus"></i> Add Prize</button>
499
- </section>
500
-
501
- <section class="cpub-form-section">
502
- <h2 class="cpub-form-section-title">Judging</h2>
503
- <div class="cpub-form-field">
504
- <label class="cpub-form-label">Score Visibility</label>
505
- <select v-model="judgingVisibility" class="cpub-form-input">
506
- <option value="judges-only">Judges only, scores hidden until results</option>
507
- <option value="public">Public, show scores during judging</option>
508
- <option value="private">Private, scores stay with organizers</option>
509
- </select>
510
- </div>
511
- <label class="cpub-form-check">
512
- <input v-model="communityVotingEnabled" type="checkbox" />
513
- <span>Enable community voting</span>
514
- </label>
515
-
516
- <div class="cpub-subhead">
517
- <h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
518
- <button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
519
- </div>
520
- <div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
521
- <div class="cpub-form-row">
522
- <div class="cpub-form-field" style="flex: 3">
523
- <label class="cpub-form-label">Criterion</label>
524
- <input v-model="crit.label" type="text" class="cpub-form-input" placeholder="e.g. Documentation" />
525
- </div>
526
- <div class="cpub-form-field" style="flex: 1">
527
- <label class="cpub-form-label">Points</label>
528
- <input v-model.number="crit.weight" type="number" min="0" max="100" class="cpub-form-input" placeholder="20" />
529
- </div>
530
- <button type="button" class="cpub-prize-remove cpub-criterion-del" aria-label="Remove criterion" @click="removeCriterion(ci)"><i class="fa-solid fa-times"></i></button>
531
- </div>
532
- <div class="cpub-form-field">
533
- <input v-model="crit.description" type="text" class="cpub-form-input" placeholder="What judges look for (optional)" />
534
- </div>
535
- </div>
536
- </section>
537
-
538
- <!-- Visibility & Access -->
539
- <section class="cpub-form-section">
540
- <h2 class="cpub-form-section-title">Visibility &amp; Access</h2>
541
- <div class="cpub-form-field">
542
- <label class="cpub-form-label">Who can see this contest</label>
543
- <select v-model="visibility" class="cpub-form-input">
544
- <option value="public">Public, listed and visible to everyone</option>
545
- <option value="unlisted">Unlisted, visible by direct link, hidden from listings</option>
546
- <option value="private">Private, restricted</option>
547
- </select>
548
- </div>
549
- <div v-if="visibility === 'private'" class="cpub-form-field">
550
- <span class="cpub-form-label">Also visible to roles</span>
551
- <div class="cpub-type-options" role="group" aria-label="Roles that can view">
552
- <label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
553
- <input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
554
- <span>{{ r }}</span>
555
- </label>
556
- </div>
557
- </div>
558
- <div class="cpub-subhead">
559
- <h3 class="cpub-form-subtitle">Reviewers</h3>
560
- </div>
561
- <p class="cpub-form-hint">Reviewers can view this contest (even while it's private or in draft) without being a judge or an admin, view access scoped to this contest only. They can't edit or score entries.</p>
562
- <ContestStakeholderManager :contest-slug="slug" />
563
- </section>
564
-
565
- <!-- Judge panel (single source of truth: contest_judges table) -->
566
- <section class="cpub-form-section">
567
- <h2 class="cpub-form-section-title">Judges</h2>
568
- <p class="cpub-form-hint">Invited judges receive a notification and must accept before they can score.</p>
569
- <ContestJudgeManager :contest-slug="slug" :is-owner="true" />
570
- </section>
571
- </div><!-- /cpub-edit-main -->
572
-
573
- <aside class="cpub-edit-side">
574
- <section class="cpub-form-section">
575
- <h2 class="cpub-form-section-title">Entries</h2>
576
- <div class="cpub-form-field">
577
- <span class="cpub-form-label">Eligible content types</span>
578
- <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
579
- <div class="cpub-type-options" role="group" aria-label="Eligible content types">
580
- <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
581
- <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
582
- <span>{{ t.label }}</span>
583
- </label>
584
- </div>
585
- </div>
586
- <div class="cpub-form-field">
587
- <label class="cpub-form-label">Max entries per person</label>
588
- <input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
589
- </div>
590
- </section>
591
-
592
- <section class="cpub-form-section">
593
- <h2 class="cpub-form-section-title">Stage &amp; Status</h2>
594
- <p class="cpub-form-hint">
595
- A contest runs through <strong>Draft</strong> → <strong>Upcoming</strong> →
596
- <strong>Active</strong> (accepting entries) → <strong>Judging</strong> →
597
- <strong>Completed</strong>. You can move <em>backwards</em>, <strong>Pause</strong> to
598
- temporarily stop submissions without cancelling, resume later, or cancel. Current status:
599
- <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
600
- </p>
601
- <div class="cpub-status-actions">
602
- <button
603
- v-for="t in availableTransitions"
604
- :key="t"
605
- type="button"
606
- class="cpub-btn cpub-transition-btn"
607
- :class="{
608
- 'cpub-transition-activate': statusAction(t).tone === 'go',
609
- 'cpub-transition-judging': statusAction(t).tone === 'warn',
610
- 'cpub-transition-cancel': statusAction(t).tone === 'danger',
611
- }"
612
- @click="transitionStatus(t)"
613
- >
614
- <i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}
615
- </button>
616
- <p v-if="!availableTransitions.length" class="cpub-status-terminal">
617
- <i class="fa-solid fa-circle-check"></i>
618
- No status changes available from <strong>{{ contest.status }}</strong>.
619
- </p>
620
- </div>
621
- </section>
622
-
623
- <section class="cpub-form-section cpub-danger-zone">
624
- <h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
625
- <div class="cpub-danger-row">
626
- <div>
627
- <p class="cpub-danger-label">Delete this contest</p>
628
- <p class="cpub-form-hint">Permanently removes the contest and all of its entries, judges, and reviewers. This cannot be undone.</p>
629
- </div>
630
- <button type="button" class="cpub-btn cpub-btn-danger cpub-danger-btn" :disabled="deleting" @click="handleDelete">
631
- <i class="fa-solid fa-trash"></i> {{ deleting ? 'Deleting...' : 'Delete Contest' }}
632
- </button>
633
- </div>
634
- </section>
635
- </aside><!-- /cpub-edit-side -->
636
- </div><!-- /cpub-edit-layout -->
637
-
638
- <!-- Sticky save bar — always reachable without scrolling to the bottom. -->
639
- <div class="cpub-edit-actionbar">
640
- <span class="cpub-edit-actionbar-status">
641
- Status <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
642
- <span v-if="formDirty" class="cpub-edit-dirty"><i class="fa-solid fa-circle"></i> Unsaved changes</span>
643
- </span>
644
- <div class="cpub-edit-actionbar-btns">
645
- <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-edit-cancel">Cancel</NuxtLink>
646
- <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError || !formDirty">
647
- <i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : formDirty ? 'Save Changes' : 'Saved' }}
648
- </button>
649
- </div>
650
- </div>
651
- </form>
652
- </div>
653
- <div v-else-if="contestLoading" class="cpub-not-found"><p>Loading contest…</p></div>
654
- <div v-else class="cpub-not-found"><p>Contest not found</p></div>
8
+ <ContestEditor mode="edit" />
655
9
  </template>
656
-
657
- <style scoped>
658
- .cpub-contest-edit { max-width: 1080px; margin: 0 auto; padding: 32px; }
659
- .cpub-back-link { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 16px; }
660
- .cpub-back-link:hover { color: var(--accent); }
661
- .cpub-edit-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
662
- .cpub-edit-subtitle { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; display: flex; align-items: center; gap: 8px; }
663
-
664
- .cpub-status-badge { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; }
665
- .cpub-status-draft { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); border-style: dashed; }
666
- .cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
667
- .cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
668
- .cpub-status-paused { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
669
- .cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
670
- .cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
671
- .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
672
-
673
- .cpub-edit-form { display: flex; flex-direction: column; gap: 16px; }
674
- /* Two-column editor: wide content column + a sticky meta rail (Stage & Status,
675
- Entry rules, Danger Zone) so lifecycle controls stay reachable while editing. */
676
- .cpub-edit-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; }
677
- .cpub-edit-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
678
- .cpub-edit-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 76px; }
679
- .cpub-form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
680
- .cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 14px; }
681
- .cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
682
- .cpub-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
683
- .cpub-form-field:last-child { margin-bottom: 0; }
684
- .cpub-form-input, .cpub-form-textarea { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
685
- .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
686
- .cpub-form-textarea { resize: vertical; }
687
- .cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-3); }
688
-
689
- .cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
690
- .cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
691
- .cpub-form-check input { width: 14px; height: 14px; }
692
- .cpub-type-options { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
693
- .cpub-subhead { display: flex; align-items: center; justify-content: space-between; margin: 18px 0 10px; }
694
- .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; }
695
- .cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
696
- .cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 0 0 12px; line-height: 1.5; }
697
-
698
- .cpub-prize-row, .cpub-criterion-row { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
699
- .cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
700
- .cpub-prize-label { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent); }
701
- .cpub-prize-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; }
702
- .cpub-prize-remove:hover { color: var(--red); }
703
- .cpub-criterion-row .cpub-form-row { align-items: flex-end; }
704
- .cpub-criterion-del { align-self: flex-end; margin-bottom: 12px; }
705
-
706
- .cpub-status-actions { display: flex; gap: 8px; flex-wrap: wrap; }
707
- .cpub-transition-btn { display: inline-flex; align-items: center; gap: 6px; }
708
- .cpub-transition-activate { color: var(--green); border-color: var(--green-border); }
709
- .cpub-transition-judging { color: var(--yellow); border-color: var(--yellow-border); }
710
- .cpub-transition-complete { color: var(--accent); border-color: var(--accent-border); }
711
- .cpub-transition-cancel { color: var(--red); border-color: var(--red-border); }
712
-
713
- .cpub-status-terminal { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 8px; margin: 0; }
714
- .cpub-status-terminal i { color: var(--green); }
715
-
716
- .cpub-danger-zone { border-color: var(--red-border); }
717
- .cpub-danger-title { color: var(--red); }
718
- .cpub-danger-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
719
- .cpub-danger-label { font-size: 13px; font-weight: 600; margin: 0 0 2px; }
720
- .cpub-danger-btn { color: var(--red); border-color: var(--red-border); flex-shrink: 0; }
721
- .cpub-danger-btn:hover:not(:disabled) { background: var(--red-bg); }
722
-
723
- .cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
724
-
725
- /* Sticky save bar — pinned to the viewport bottom while editing the long form. */
726
- .cpub-edit-actionbar {
727
- position: sticky;
728
- bottom: 0;
729
- z-index: 20;
730
- display: flex;
731
- align-items: center;
732
- justify-content: space-between;
733
- gap: 12px;
734
- margin: 4px -32px -32px;
735
- padding: 14px 32px;
736
- background: var(--surface);
737
- border-top: 2px solid var(--border);
738
- box-shadow: var(--shadow-lg);
739
- }
740
- .cpub-advance-block { padding: 12px 0; border-top: var(--border-width-default) solid var(--border); }
741
- .cpub-advance-block:first-of-type { border-top: 0; }
742
- .cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
743
- .cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
744
- .cpub-advance-name i { color: var(--accent); font-size: 11px; }
745
- .cpub-advance-mode { display: inline-flex; gap: 12px; }
746
- .cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; }
747
- .cpub-advance-ctl .cpub-form-label { margin: 0; }
748
- .cpub-advance-n { width: 80px; }
749
- .cpub-advance-manual { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
750
- .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; }
751
- .cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
752
- .cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
753
- .cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
754
- .cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
755
- .cpub-edit-dirty { color: var(--accent); display: inline-flex; align-items: center; gap: 5px; }
756
- .cpub-edit-dirty i { font-size: 6px; }
757
- .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
758
-
759
- /* Collapse the meta rail under the main column on narrower viewports. */
760
- @media (max-width: 900px) {
761
- .cpub-edit-layout { grid-template-columns: 1fr; }
762
- .cpub-edit-side { position: static; }
763
- }
764
- @media (max-width: 768px) {
765
- .cpub-contest-edit { padding: 16px; }
766
- .cpub-form-row { grid-template-columns: 1fr; }
767
- .cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
768
- }
769
- </style>