@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,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { Serialized, ContestDetail, ContestEntryItem } from '@commonpub/server';
2
+ import type { Serialized, ContestDetail, ContestEntryItem, EntryPrivateData } from '@commonpub/server';
3
3
  import type { ContestStage } from '@commonpub/schema';
4
4
 
5
5
  // Entry detail: the content summary plus the entry's per-stage artifacts in a
@@ -60,6 +60,30 @@ const contentLink = computed(() =>
60
60
  function fmtDate(iso: string): string {
61
61
  return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
62
62
  }
63
+
64
+ // --- Personal data (PII + agreement acceptances) ---
65
+ // Fetched CLIENT-SIDE only, so partitioned PII never lands in the SSR payload.
66
+ // The /private endpoint gates access (entrant or `contest.pii`); a 403/empty just
67
+ // leaves the section hidden, so judges + the public never see it.
68
+ const { contestPii } = useFeatures();
69
+ const privateData = ref<Serialized<EntryPrivateData> | null>(null);
70
+ const allTemplateFields = computed(() =>
71
+ stages.value.flatMap((s) => s.submissionTemplate ?? []).map((f) => ({ key: f.key, label: f.label, type: f.type })),
72
+ );
73
+ let piiFetched = false;
74
+ async function loadPrivate(): Promise<void> {
75
+ if (piiFetched || typeof window === 'undefined') return;
76
+ piiFetched = true;
77
+ try {
78
+ const d = await $fetch<Serialized<EntryPrivateData>>(`/api/contests/${slug}/entries/${entryId}/private`);
79
+ if (d && (Object.keys(d.fields ?? {}).length > 0 || (d.agreements ?? []).length > 0)) privateData.value = d;
80
+ } catch {
81
+ // 403 (not the entrant / no contest.pii) or no data → section stays hidden.
82
+ }
83
+ }
84
+ // contestPii hydrates from /api/features on the client (DB overrides), so watch it
85
+ // rather than reading once at mount.
86
+ watch(contestPii, (on) => { if (on) void loadPrivate(); }, { immediate: true });
63
87
  </script>
64
88
 
65
89
  <template>
@@ -122,6 +146,15 @@ function fmtDate(iso: string): string {
122
146
  </li>
123
147
  </ol>
124
148
  </section>
149
+
150
+ <!-- Personal data viewer (entrant / contest.pii holders only; client-fetched). -->
151
+ <ContestEntryPrivateData
152
+ v-if="privateData"
153
+ :fields="privateData.fields"
154
+ :agreements="privateData.agreements"
155
+ :template="allTemplateFields"
156
+ :updated-at="privateData.updatedAt"
157
+ />
125
158
  </template>
126
159
  </div>
127
160
  </template>
@@ -2,6 +2,7 @@
2
2
  import type { Serialized, ContestEntryItem, ContestJudgeItem } from '@commonpub/server';
3
3
 
4
4
  const route = useRoute();
5
+ const router = useRouter();
5
6
  const slug = route.params.slug as string;
6
7
  const toast = useToast();
7
8
  const { extract: extractError } = useApiError();
@@ -23,6 +24,9 @@ const c = computed(() => contest.value);
23
24
  const entries = computed(() => apiEntriesData.value?.items ?? []);
24
25
  const judges = computed<ContestJudgeItem[]>(() => judgesData.value ?? []);
25
26
  const isOwner = computed(() => isAdmin.value || !!(user.value?.id && c.value?.createdById === user.value.id));
27
+ // Can edit this contest (owner / per-contest editor / contest.manage holder).
28
+ // Drives the Edit affordance; judge/collaborator management stays owner-only.
29
+ const canManage = computed(() => isOwner.value || !!c.value?.viewerCanManage);
26
30
 
27
31
  // Judge state derives entirely from the contest_judges table (single source of
28
32
  // truth) — not the legacy `judges` jsonb column.
@@ -53,17 +57,41 @@ const visibilityNote = computed(() => {
53
57
  interface Tab { key: string; label: string; icon: string; count?: number }
54
58
  const tabs = computed<Tab[]>(() => {
55
59
  const t: Tab[] = [{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' }];
56
- if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
57
- if (c.value?.showPrizes !== false && (c.value?.prizes?.length || c.value?.prizesDescription)) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
60
+ if (c.value?.rules || c.value?.rulesBlocks?.length) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
61
+ if (c.value?.showPrizes !== false && (c.value?.prizes?.length || c.value?.prizesDescription || c.value?.prizesBlocks?.length)) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
58
62
  t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
59
63
  if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
60
64
  if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
61
65
  return t;
62
66
  });
63
- const activeTab = ref('overview');
67
+ // Active tab is synced to ?tab= so every section is directly linkable + shareable
68
+ // and survives reload (the WAI-ARIA tablist + keyboard nav below are unchanged).
69
+ // Validate against the known tab keys; unknown/garbage falls back to overview.
70
+ const KNOWN_TABS = ['overview', 'rules', 'prizes', 'entries', 'participants', 'judges'];
71
+ function tabFromQuery(): string {
72
+ const t = route.query.tab;
73
+ return typeof t === 'string' && KNOWN_TABS.includes(t) ? t : 'overview';
74
+ }
75
+ const activeTab = ref(tabFromQuery());
64
76
  watch(tabs, (list) => {
65
77
  if (!list.some((t) => t.key === activeTab.value)) activeTab.value = 'overview';
66
78
  });
79
+ // Reflect tab changes in the URL (replace, not push, so tab clicks don't flood
80
+ // history); overview is the default and omits the param for a clean URL.
81
+ watch(activeTab, (key) => {
82
+ const q = { ...route.query };
83
+ if (key === 'overview') delete q.tab;
84
+ else q.tab = key;
85
+ if (q.tab !== route.query.tab) router.replace({ query: q });
86
+ });
87
+ // Honor browser back/forward that lands on a different ?tab=.
88
+ watch(
89
+ () => route.query.tab,
90
+ () => {
91
+ const key = tabFromQuery();
92
+ if (key !== activeTab.value && tabs.value.some((t) => t.key === key)) activeTab.value = key;
93
+ },
94
+ );
67
95
 
68
96
  // WAI-ARIA tabs keyboard pattern (arrow keys + Home/End, roving focus).
69
97
  function focusTab(key: string): void {
@@ -150,6 +178,32 @@ const currentSubmissionStage = computed(() => {
150
178
  });
151
179
  const myEntries = computed(() => entries.value.filter((e) => e.userId === user.value?.id));
152
180
 
181
+ // Proposal mode (Phase 4): when the CURRENT submission stage is proposal-mode
182
+ // and proposals are enabled, entrants submit a form (no pre-existing project)
183
+ // and the server creates a draft placeholder. Replaces the attach-an-entry CTA.
184
+ const currentProposalStage = computed(() => {
185
+ if (!c.value || features.value.contestProposals !== true || c.value.status !== 'active') return null;
186
+ const source = {
187
+ status: c.value.status,
188
+ startDate: c.value.startDate,
189
+ endDate: c.value.endDate,
190
+ judgingEndDate: c.value.judgingEndDate ?? null,
191
+ stages: c.value.stages,
192
+ currentStageId: c.value.currentStageId,
193
+ };
194
+ const stage = normalizeStages(source).find((s) => s.id === currentStageId(source));
195
+ return stage && stage.kind === 'submission' && stage.submissionMode === 'proposal' && stage.submissionTemplate?.length ? stage : null;
196
+ });
197
+
198
+ function onProposalSubmitted(projectSlug: string, contentType: string): void {
199
+ refreshNuxtData();
200
+ // Route the entrant into their new draft project to develop it for later rounds.
201
+ // Use the server's ACTUAL created type (not a client guess) so the URL resolves.
202
+ if (user.value?.username) {
203
+ navigateTo(`/u/${user.value.username}/${contentType}/${projectSlug}/edit`);
204
+ }
205
+ }
206
+
153
207
  // Restrict the submit picker to the contest's eligible content types (if set).
154
208
  const eligibleTypes = computed<string[]>(() => (c.value?.eligibleContentTypes as string[] | undefined) ?? []);
155
209
  const submittableContent = computed(() => {
@@ -311,8 +365,14 @@ async function withdrawEntry(entryId: string): Promise<void> {
311
365
  <div v-show="activeTab === 'overview'" id="cpub-panel-overview" role="tabpanel" aria-labelledby="cpub-tab-overview" tabindex="0">
312
366
  <div class="cpub-about-section">
313
367
  <div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
368
+ <img v-if="c?.coverImageUrl" :src="c.coverImageUrl" :alt="`${c?.title || 'Contest'} cover`" class="cpub-about-cover" />
314
369
  <div class="cpub-about-card">
315
- <CpubMarkdown v-if="c?.description" :source="c.description" :format="c?.descriptionFormat" />
370
+ <BlocksBlockContentRenderer
371
+ v-if="c?.descriptionBlocks?.length"
372
+ :blocks="(c.descriptionBlocks as [string, Record<string, unknown>][])"
373
+ class="cpub-prose cpub-md"
374
+ />
375
+ <CpubMarkdown v-else-if="c?.description" :source="c.description" :format="c?.descriptionFormat" />
316
376
  <p v-else>No description available for this contest.</p>
317
377
  </div>
318
378
  </div>
@@ -321,12 +381,12 @@ async function withdrawEntry(entryId: string): Promise<void> {
321
381
 
322
382
  <!-- RULES -->
323
383
  <div v-show="activeTab === 'rules'" id="cpub-panel-rules" role="tabpanel" aria-labelledby="cpub-tab-rules" tabindex="0">
324
- <ContestRules v-if="c?.rules" :rules="c.rules" :format="c?.rulesFormat" />
384
+ <ContestRules v-if="c?.rules || c?.rulesBlocks?.length" :rules="c?.rules ?? ''" :blocks="c?.rulesBlocks" :format="c?.rulesFormat" />
325
385
  </div>
326
386
 
327
387
  <!-- PRIZES -->
328
388
  <div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
329
- <ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" :format="c?.prizesDescriptionFormat" />
389
+ <ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription || c?.prizesBlocks?.length)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" :blocks="c?.prizesBlocks" :format="c?.prizesDescriptionFormat" />
330
390
  </div>
331
391
 
332
392
  <!-- ENTRIES -->
@@ -338,7 +398,25 @@ async function withdrawEntry(entryId: string): Promise<void> {
338
398
  :entries="myEntries"
339
399
  @saved="refreshEntries"
340
400
  />
341
- <div v-if="c?.status === 'active'" class="cpub-entries-cta">
401
+ <!-- Proposal mode: a first-time entrant submits the form (no project yet). -->
402
+ <ContestProposalForm
403
+ v-if="currentProposalStage && isAuthenticated && !myEntries.length"
404
+ :contest-slug="slug"
405
+ :stage="currentProposalStage"
406
+ @submitted="onProposalSubmitted"
407
+ />
408
+ <!-- Proposal mode + anonymous: prompt to log in. -->
409
+ <div v-else-if="currentProposalStage && !isAuthenticated" class="cpub-entries-cta">
410
+ <div class="cpub-entries-cta-text">
411
+ <p class="cpub-entries-cta-title"><i class="fa-solid fa-clipboard-list"></i> Submit a proposal</p>
412
+ <p class="cpub-entries-cta-sub">Log in to submit a proposal for this contest.</p>
413
+ </div>
414
+ <NuxtLink :to="`/auth/login?redirect=/contests/${slug}`" class="cpub-btn cpub-btn-primary cpub-btn-lg">
415
+ <i class="fa-solid fa-right-to-bracket"></i> Log in to enter
416
+ </NuxtLink>
417
+ </div>
418
+ <!-- Attach mode (no proposal stage): the classic enter-with-a-project CTA. -->
419
+ <div v-if="c?.status === 'active' && !currentProposalStage" class="cpub-entries-cta">
342
420
  <div class="cpub-entries-cta-text">
343
421
  <p class="cpub-entries-cta-title"><i class="fa-solid fa-trophy"></i> Enter this contest</p>
344
422
  <p class="cpub-entries-cta-sub">Submit one of your published projects, or start a new one.</p>
@@ -350,6 +428,15 @@ async function withdrawEntry(entryId: string): Promise<void> {
350
428
  <i class="fa-solid fa-right-to-bracket"></i> Log in to enter
351
429
  </NuxtLink>
352
430
  </div>
431
+ <div v-if="canManage && entries.length" class="cpub-entries-tools">
432
+ <a
433
+ :href="`/api/contests/${slug}/export`"
434
+ class="cpub-btn cpub-btn-sm"
435
+ download
436
+ >
437
+ <i class="fa-solid fa-file-csv"></i> Export entries (CSV)
438
+ </a>
439
+ </div>
353
440
  <ContestEntries
354
441
  :entries="entries"
355
442
  :contest-status="c?.status"
@@ -384,7 +471,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
384
471
  </div>
385
472
  </div>
386
473
 
387
- <ContestSidebar :contest="c" :is-owner="isOwner" :can-judge="canJudge" @copy-link="copyLink" />
474
+ <ContestSidebar :contest="c" :is-owner="isOwner" :can-manage="canManage" :can-judge="canJudge" @copy-link="copyLink" />
388
475
  </div>
389
476
  </div>
390
477
  </div>
@@ -421,6 +508,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
421
508
  /* LAYOUT */
422
509
  .cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 32px; }
423
510
 
511
+ .cpub-entries-tools { display: flex; justify-content: flex-end; margin-bottom: 12px; }
424
512
  .cpub-entries-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; margin-bottom: 18px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
425
513
  .cpub-entries-cta-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
426
514
  .cpub-entries-cta-title i { color: var(--accent); }
@@ -467,6 +555,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
467
555
 
468
556
  /* ABOUT */
469
557
  .cpub-about-section { margin-bottom: 20px; }
558
+ .cpub-about-cover { width: 100%; max-height: 380px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-bottom: 16px; }
470
559
  .cpub-about-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-md); font-size: 12px; color: var(--text-dim); line-height: 1.7; }
471
560
  .cpub-about-card p { margin: 0; white-space: pre-line; }
472
561
 
@@ -135,8 +135,9 @@ const scoring = ref<Record<string, number>>({});
135
135
  const critScoring = ref<Record<string, number[]>>({}); // per entry → [criterionScore...]
136
136
  const feedback = ref<Record<string, string>>({});
137
137
  const submitting = ref<string | null>(null);
138
- const error = ref('');
139
- const success = ref('');
138
+ // Per-card save status (announced via aria-live) so a judge sees the result next
139
+ // to the entry they just scored, not in one banner far up the page (G8).
140
+ const saveStatus = ref<Record<string, { ok: boolean; msg: string }>>({});
140
141
 
141
142
  // Pre-fill from existing scores
142
143
  watch(entryList, (list) => {
@@ -165,9 +166,13 @@ function critTotal(entryId: string): number {
165
166
  return Math.round((sum / totalMax) * 100);
166
167
  }
167
168
 
169
+ function setStatus(entryId: string, ok: boolean, msg: string): void {
170
+ saveStatus.value[entryId] = { ok, msg };
171
+ }
172
+
168
173
  async function submitScore(entryId: string): Promise<void> {
169
174
  if (!inJudgingPhase.value) {
170
- error.value = 'Scoring is only open during the judging phase.';
175
+ setStatus(entryId, false, 'Scoring is only open during the judging phase.');
171
176
  return;
172
177
  }
173
178
 
@@ -180,31 +185,26 @@ async function submitScore(entryId: string): Promise<void> {
180
185
  max: critMax(i),
181
186
  }));
182
187
  if (criteriaScores.some((c) => c.score < 0 || c.score > c.max)) {
183
- error.value = 'Each criterion score must be between 0 and its maximum.';
188
+ setStatus(entryId, false, 'Each criterion score must be between 0 and its maximum.');
184
189
  return;
185
190
  }
186
191
  body = { entryId, criteriaScores, feedback: feedback.value[entryId] || undefined };
187
192
  } else {
188
193
  const score = scoring.value[entryId];
189
- if (score === undefined || score < 1 || score > 100) {
190
- error.value = 'Score must be between 1 and 100.';
194
+ if (score === undefined || score < 0 || score > 100) {
195
+ setStatus(entryId, false, 'Score must be between 0 and 100.');
191
196
  return;
192
197
  }
193
198
  body = { entryId, score, feedback: feedback.value[entryId] || undefined };
194
199
  }
195
200
 
196
- error.value = '';
197
- success.value = '';
198
201
  submitting.value = entryId;
199
-
200
202
  try {
201
203
  await $fetch(`/api/contests/${slug}/judge`, { method: 'POST', body });
202
- success.value = 'Score submitted for entry.';
203
- await refreshEntries().catch(() => {
204
- success.value = 'Score saved, refresh to see the updated totals.';
205
- });
204
+ setStatus(entryId, true, 'Score saved.');
205
+ await refreshEntries().catch(() => setStatus(entryId, true, 'Score saved, refresh to see the updated totals.'));
206
206
  } catch (err: unknown) {
207
- error.value = (err as { data?: { message?: string } })?.data?.message || 'Failed to submit score.';
207
+ setStatus(entryId, false, (err as { data?: { message?: string } })?.data?.message || 'Failed to submit score.');
208
208
  } finally {
209
209
  submitting.value = null;
210
210
  }
@@ -212,6 +212,9 @@ async function submitScore(entryId: string): Promise<void> {
212
212
  </script>
213
213
 
214
214
  <template>
215
+ <!-- Auth-gated tool page (no SEO); ClientOnly avoids the lazy-fetch SSR/CSR
216
+ hydration race on the scoring controls (same rationale as the editor). -->
217
+ <ClientOnly>
215
218
  <div class="cpub-judge-page">
216
219
  <header class="cpub-judge-header">
217
220
  <NuxtLink :to="`/contests/${slug}`" class="cpub-judge-back">
@@ -223,7 +226,7 @@ async function submitScore(entryId: string): Promise<void> {
223
226
  <span v-if="currentReviewStage" class="cpub-judge-round">{{ currentReviewStage.name }}</span>
224
227
  </h1>
225
228
  <p class="cpub-judge-desc">
226
- Score each entry from 1 to 100. Add optional feedback. Scores are saved immediately.
229
+ Score each entry from 0 to 100. Add optional feedback. Scores are saved immediately.
227
230
  <template v-if="currentReviewStage"> You're judging the <strong>{{ entryList.length }}</strong> {{ entryList.length === 1 ? 'entry' : 'entries' }} still in this round.</template>
228
231
  </p>
229
232
  </header>
@@ -278,9 +281,6 @@ async function submitScore(entryId: string): Promise<void> {
278
281
  </div>
279
282
  </div>
280
283
 
281
- <div v-if="error" class="cpub-judge-alert cpub-judge-alert--error" role="alert">{{ error }}</div>
282
- <div v-if="success" class="cpub-judge-alert cpub-judge-alert--success">{{ success }}</div>
283
-
284
284
  <div v-if="entryList.length === 0" class="cpub-judge-empty">
285
285
  <i class="fa-solid fa-inbox"></i>
286
286
  <p>No entries to judge yet.</p>
@@ -320,11 +320,13 @@ async function submitScore(entryId: string): Promise<void> {
320
320
  </div>
321
321
  <div class="cpub-judge-score-controls">
322
322
  <!-- Per-criterion scoring (when the contest defines a rubric) -->
323
- <div v-if="hasCriteria && critScoring[entry.id]" class="cpub-judge-criteria-inputs">
323
+ <fieldset v-if="hasCriteria && critScoring[entry.id]" class="cpub-judge-criteria-inputs">
324
+ <legend class="cpub-sr-only">Scores by criterion for {{ entry.contentTitle }}</legend>
324
325
  <div v-for="(crit, i) in criteria" :key="i" class="cpub-judge-crit-row">
325
- <label class="cpub-judge-crit-label">{{ crit.label }}</label>
326
+ <label :for="`crit-${entry.id}-${i}`" class="cpub-judge-crit-label">{{ crit.label }}</label>
326
327
  <div class="cpub-judge-crit-input-wrap">
327
328
  <input
329
+ :id="`crit-${entry.id}-${i}`"
328
330
  v-model.number="critScoring[entry.id][i]"
329
331
  type="number"
330
332
  class="cpub-judge-crit-input"
@@ -336,7 +338,7 @@ async function submitScore(entryId: string): Promise<void> {
336
338
  </div>
337
339
  </div>
338
340
  <div class="cpub-judge-crit-total">Overall <strong>{{ critTotal(entry.id) }}</strong> / 100</div>
339
- </div>
341
+ </fieldset>
340
342
 
341
343
  <div class="cpub-judge-score-input-wrap">
342
344
  <input
@@ -344,10 +346,10 @@ async function submitScore(entryId: string): Promise<void> {
344
346
  v-model.number="scoring[entry.id]"
345
347
  type="number"
346
348
  class="cpub-judge-score-input"
347
- min="1"
349
+ min="0"
348
350
  max="100"
349
- placeholder="1-100"
350
- aria-label="Overall score, 1 to 100"
351
+ placeholder="0-100"
352
+ :aria-label="`Overall score for ${entry.contentTitle}, 0 to 100`"
351
353
  />
352
354
  <button
353
355
  class="cpub-judge-score-btn"
@@ -357,19 +359,35 @@ async function submitScore(entryId: string): Promise<void> {
357
359
  {{ submitting === entry.id ? '...' : entry.myScore !== null ? 'Update' : 'Score' }}
358
360
  </button>
359
361
  </div>
362
+ <label :for="`fb-${entry.id}`" class="cpub-sr-only">Feedback for {{ entry.contentTitle }}</label>
360
363
  <textarea
364
+ :id="`fb-${entry.id}`"
361
365
  v-model="feedback[entry.id]"
362
366
  class="cpub-judge-feedback"
363
367
  placeholder="Optional feedback (max 2000 chars)"
364
368
  maxlength="2000"
365
369
  rows="2"
366
370
  ></textarea>
371
+ <p
372
+ v-if="saveStatus[entry.id]"
373
+ class="cpub-judge-save-status"
374
+ :class="saveStatus[entry.id]!.ok ? 'is-ok' : 'is-err'"
375
+ role="status"
376
+ aria-live="polite"
377
+ >
378
+ <i :class="saveStatus[entry.id]!.ok ? 'fa-solid fa-circle-check' : 'fa-solid fa-circle-exclamation'"></i>
379
+ {{ saveStatus[entry.id]!.msg }}
380
+ </p>
381
+ <p v-else-if="!inJudgingPhase" class="cpub-judge-save-status is-muted">
382
+ Scoring opens in the judging phase.
383
+ </p>
367
384
  </div>
368
385
  </div>
369
386
  </div>
370
387
  </div>
371
388
  </template>
372
389
  </div>
390
+ </ClientOnly>
373
391
  </template>
374
392
 
375
393
  <style scoped>
@@ -426,13 +444,13 @@ async function submitScore(entryId: string): Promise<void> {
426
444
  .cpub-judge-score-label { display: block; font-family: var(--font-mono); font-size: 9px; color: var(--text-faint); text-transform: uppercase; }
427
445
  .cpub-judge-score-value { font-size: 20px; font-weight: 700; color: var(--accent); font-family: var(--font-mono); }
428
446
  .cpub-judge-score-controls { display: flex; flex-direction: column; gap: 6px; }
429
- .cpub-judge-criteria-inputs { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: var(--border-width-default) dashed var(--border); background: var(--surface2); margin-bottom: 2px; }
447
+ .cpub-judge-criteria-inputs { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: var(--border-width-default) dashed var(--border); background: var(--surface2); margin: 0 0 2px; min-inline-size: 0; }
430
448
  .cpub-judge-crit-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
431
449
  .cpub-judge-crit-label { font-size: 11px; color: var(--text-dim); flex: 1; min-width: 0; }
432
450
  .cpub-judge-crit-input-wrap { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
433
451
  .cpub-judge-crit-input { width: 52px; padding: 4px 6px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 12px; font-family: var(--font-mono); text-align: center; outline: none; }
434
452
  .cpub-judge-crit-input:focus { border-color: var(--accent); }
435
- .cpub-judge-crit-max { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
453
+ .cpub-judge-crit-max { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); }
436
454
  .cpub-judge-crit-total { font-size: 11px; font-family: var(--font-mono); color: var(--text-dim); text-align: right; padding-top: 4px; border-top: var(--border-width-default) solid var(--border); }
437
455
  .cpub-judge-crit-total strong { color: var(--accent); font-size: 13px; }
438
456
  .cpub-judge-score-input-wrap { display: flex; gap: 0; }
@@ -452,6 +470,11 @@ async function submitScore(entryId: string): Promise<void> {
452
470
  color: var(--text); font-size: 11px; font-family: inherit; resize: vertical; outline: none;
453
471
  }
454
472
  .cpub-judge-feedback:focus { border-color: var(--accent); }
473
+ .cpub-judge-save-status { display: flex; align-items: center; gap: 5px; margin: 2px 0 0; font-size: 11px; font-family: var(--font-mono); }
474
+ .cpub-judge-save-status.is-ok { color: var(--green); }
475
+ .cpub-judge-save-status.is-err { color: var(--red); }
476
+ .cpub-judge-save-status.is-muted { color: var(--text-faint); }
477
+ .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
455
478
 
456
479
  @media (max-width: 768px) {
457
480
  .cpub-judge-entry { flex-direction: column; }