@commonpub/layer 0.82.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/components/AppToast.vue +1 -1
  2. package/components/ContentAvatar.vue +98 -0
  3. package/components/CpubCriteriaBar.vue +88 -0
  4. package/components/CpubDateTimeField.vue +73 -0
  5. package/components/CpubMarkdown.vue +3 -1
  6. package/components/FormatToggle.vue +2 -2
  7. package/components/ImageUpload.vue +5 -8
  8. package/components/MirrorDetailModal.vue +3 -1
  9. package/components/MirrorRequestApproveModal.vue +3 -1
  10. package/components/ProductEditModal.vue +184 -0
  11. package/components/RemoteFollowDialog.vue +2 -2
  12. package/components/SearchSidebar.vue +14 -21
  13. package/components/ShareToHubModal.vue +3 -1
  14. package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
  15. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
  16. package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
  17. package/components/blocks/BlockCompareColumnsView.vue +92 -0
  18. package/components/blocks/BlockContentRenderer.vue +17 -0
  19. package/components/blocks/BlockCriteriaBarView.vue +25 -0
  20. package/components/blocks/BlockGalleryView.vue +5 -0
  21. package/components/blocks/BlockHtmlView.vue +26 -0
  22. package/components/blocks/BlockImageView.vue +4 -0
  23. package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
  24. package/components/blocks/BlockRoadmapView.vue +84 -0
  25. package/components/blocks/BlockSponsorsView.vue +89 -0
  26. package/components/blocks/BlockTableView.vue +49 -0
  27. package/components/blocks/BlockTabsView.vue +121 -0
  28. package/components/contest/ContestBodyCanvas.vue +155 -0
  29. package/components/contest/ContestCriteriaEditor.vue +79 -0
  30. package/components/contest/ContestEditor.vue +948 -0
  31. package/components/contest/ContestEntries.vue +1 -1
  32. package/components/contest/ContestEntryPrivateData.vue +126 -0
  33. package/components/contest/ContestHero.vue +114 -186
  34. package/components/contest/ContestJudgeManager.vue +6 -4
  35. package/components/contest/ContestJudgingCriteria.vue +5 -21
  36. package/components/contest/ContestPrizes.vue +8 -1
  37. package/components/contest/ContestProposalForm.vue +88 -0
  38. package/components/contest/ContestRules.vue +8 -1
  39. package/components/contest/ContestSidebar.vue +8 -2
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +3 -2
  43. package/components/contest/ContestSubmissionField.vue +141 -0
  44. package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
  45. package/components/contest/blocks/ContestTabPanel.vue +27 -0
  46. package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
  47. package/components/contest/blocks/HtmlBlock.vue +61 -0
  48. package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
  49. package/components/contest/blocks/RoadmapBlock.vue +127 -0
  50. package/components/contest/blocks/SponsorsBlock.vue +127 -0
  51. package/components/contest/blocks/TableBlock.vue +101 -0
  52. package/components/contest/blocks/TabsBlock.vue +168 -0
  53. package/components/editors/ArticleEditor.vue +9 -16
  54. package/components/editors/ExplainerEditor.vue +8 -5
  55. package/components/editors/ProjectEditor.vue +13 -10
  56. package/components/homepage/CustomHtmlSection.vue +11 -2
  57. package/components/hub/HubProducts.vue +4 -2
  58. package/components/nav/NavDropdown.vue +1 -5
  59. package/components/nav/NavLink.vue +2 -0
  60. package/components/views/ArticleView.vue +3 -56
  61. package/components/views/ExplainerView.vue +4 -0
  62. package/components/views/ProjectView.vue +83 -245
  63. package/composables/useContestEditor.ts +388 -0
  64. package/composables/useDocsPageTree.ts +154 -0
  65. package/composables/useDocsSiteSettings.ts +107 -0
  66. package/composables/useEditorAutosave.ts +131 -0
  67. package/composables/useEngagement.ts +13 -6
  68. package/composables/useFeatures.ts +9 -1
  69. package/composables/useFileUpload.ts +60 -0
  70. package/composables/useProfileContent.ts +84 -0
  71. package/composables/useSanitize.ts +38 -4
  72. package/composables/useScrollSpy.ts +87 -0
  73. package/layouts/admin.vue +41 -19
  74. package/layouts/default.vue +18 -9
  75. package/nuxt.config.ts +13 -0
  76. package/package.json +9 -9
  77. package/pages/[type]/index.vue +6 -1
  78. package/pages/admin/api-keys.vue +13 -3
  79. package/pages/admin/features.vue +2 -0
  80. package/pages/admin/federation.vue +1 -1
  81. package/pages/admin/layouts/[id].vue +30 -2
  82. package/pages/admin/settings.vue +2 -1
  83. package/pages/admin/users.vue +1 -1
  84. package/pages/admin/video-categories.vue +203 -0
  85. package/pages/cert/[code].vue +6 -2
  86. package/pages/contests/[slug]/edit.vue +4 -769
  87. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  88. package/pages/contests/[slug]/index.vue +93 -7
  89. package/pages/contests/[slug]/judge.vue +49 -26
  90. package/pages/contests/create.vue +5 -466
  91. package/pages/contests/index.vue +7 -2
  92. package/pages/cookies.vue +1 -1
  93. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  94. package/pages/docs/[siteSlug]/edit.vue +93 -231
  95. package/pages/events/[slug]/edit.vue +20 -20
  96. package/pages/events/create.vue +18 -18
  97. package/pages/events/index.vue +7 -2
  98. package/pages/hubs/[slug]/index.vue +34 -9
  99. package/pages/hubs/[slug]/invites.vue +312 -0
  100. package/pages/hubs/[slug]/members.vue +128 -0
  101. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  102. package/pages/hubs/index.vue +6 -1
  103. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  104. package/pages/learn/index.vue +8 -1
  105. package/pages/messages/index.vue +1 -1
  106. package/pages/mirror/[id].vue +1 -1
  107. package/pages/products/[slug].vue +55 -2
  108. package/pages/products/index.vue +6 -1
  109. package/pages/settings/account.vue +8 -8
  110. package/pages/settings/profile.vue +23 -14
  111. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  112. package/pages/u/[username]/followers.vue +11 -3
  113. package/pages/u/[username]/following.vue +10 -8
  114. package/pages/u/[username]/index.vue +73 -7
  115. package/pages/videos/index.vue +13 -10
  116. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  117. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  118. package/server/api/admin/api-keys/index.get.ts +1 -0
  119. package/server/api/admin/api-keys/index.post.ts +1 -0
  120. package/server/api/admin/federation/refederate.post.ts +18 -1
  121. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  122. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  123. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  124. package/server/api/admin/layouts/[id].delete.ts +1 -4
  125. package/server/api/admin/layouts/[id].get.ts +1 -4
  126. package/server/api/admin/layouts/[id].put.ts +1 -4
  127. package/server/api/auth/federated/login.post.ts +12 -5
  128. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  129. package/server/api/content/[id]/build.get.ts +11 -0
  130. package/server/api/content/[id]/report.post.ts +2 -0
  131. package/server/api/content/[id]/versions.get.ts +15 -0
  132. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  133. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  134. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  135. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  136. package/server/api/contests/[slug]/export.get.ts +43 -0
  137. package/server/api/contests/[slug]/judge.post.ts +8 -2
  138. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  139. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  140. package/server/api/contests/index.post.ts +1 -1
  141. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  142. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  143. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  144. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  145. package/server/api/events/[slug]/attendees.get.ts +10 -0
  146. package/server/api/events/[slug].get.ts +9 -0
  147. package/server/api/events/index.get.ts +8 -1
  148. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  149. package/server/api/federation/content/[id]/build.get.ts +10 -0
  150. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  151. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  152. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  153. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  154. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  155. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  156. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  157. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  158. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  159. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  160. package/server/api/products/[id].delete.ts +22 -2
  161. package/server/api/registry/ping.post.ts +17 -3
  162. package/server/api/search/index.get.ts +5 -3
  163. package/server/api/social/bookmark.get.ts +1 -0
  164. package/server/api/social/bookmark.post.ts +1 -0
  165. package/server/api/social/bookmarks.get.ts +1 -0
  166. package/server/api/social/comments/[id].delete.ts +1 -0
  167. package/server/api/social/comments.get.ts +1 -0
  168. package/server/api/social/comments.post.ts +1 -0
  169. package/server/api/social/like.get.ts +1 -0
  170. package/server/api/social/like.post.ts +1 -0
  171. package/server/api/users/[username]/content.get.ts +15 -3
  172. package/server/api/users/[username]/follow.delete.ts +1 -0
  173. package/server/api/users/[username]/follow.post.ts +1 -0
  174. package/server/api/users/[username]/followers.get.ts +2 -1
  175. package/server/api/users/[username]/following.get.ts +2 -1
  176. package/server/middleware/content-ap.ts +8 -3
  177. package/server/middleware/csrf.ts +93 -0
  178. package/server/plugins/federation-hub-sync.ts +48 -17
  179. package/server/plugins/notification-email.ts +22 -3
  180. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  181. package/server/routes/inbox.ts +14 -1
  182. package/server/routes/users/[username]/inbox.ts +13 -1
  183. package/server/utils/inbox.ts +7 -2
  184. package/server/utils/validate.ts +22 -0
  185. package/theme/base.css +5 -0
  186. package/theme/prose.css +20 -0
  187. package/theme/stoa-dark.css +4 -0
  188. package/types/contestBlocks.ts +122 -0
  189. package/utils/contestBlocks.ts +107 -0
  190. package/utils/contestBody.ts +25 -0
  191. package/utils/contestStages.ts +62 -0
  192. package/utils/contestSubmission.ts +97 -0
  193. package/utils/datetime.ts +45 -0
  194. package/utils/projectBlocks.ts +162 -0
  195. package/components/editors/BlogEditor.vue +0 -648
@@ -119,7 +119,7 @@ function entryLink(entry: Serialized<ContestEntryItem>): string {
119
119
  <span v-else>{{ (entry.authorName || entry.authorUsername || '?').charAt(0).toUpperCase() }}</span>
120
120
  </div>
121
121
  <NuxtLink v-if="entry.authorUsername" :to="`/u/${entry.authorUsername}`" class="cpub-entry-author-link">{{ entry.authorName }}</NuxtLink>
122
- <span class="cpub-entry-meta">{{ new Date(entry.submittedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}</span>
122
+ <span class="cpub-entry-meta">{{ formatLocalDate(entry.submittedAt, { year: false }) }}</span>
123
123
  </div>
124
124
  <div class="cpub-entry-footer">
125
125
  <span v-if="entry.score != null" class="cpub-entry-score">Score: {{ entry.score }}</span>
@@ -0,0 +1,126 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ContestEntryPrivateData — the in-app viewer for an entry's partitioned personal
4
+ * data: PII fields (addresses, etc. stored in contest_entry_private_fields) and
5
+ * agreement acceptances (contest_agreement_acceptances). Presentational only — the
6
+ * parent fetches `/api/contests/:slug/entries/:entryId/private` CLIENT-SIDE (so PII
7
+ * never lands in the SSR payload) and passes the result here. The endpoint already
8
+ * gates access to the entrant + `contest.pii` holders; this component just renders.
9
+ *
10
+ * `template` is the flat list of every stage's submission-template fields, used to
11
+ * label PII keys + detect `address` fields (rendered as a formatted block rather
12
+ * than raw JSON). Unknown keys fall back to the raw key + value.
13
+ */
14
+ import { ADDRESS_SUBFIELDS, parseAddress } from '../../utils/contestSubmission';
15
+
16
+ interface TemplateField { key: string; label: string; type: string }
17
+ interface Agreement { fieldKey: string; stageId: string; termsHash: string; termsSnapshot: string; acceptedAt: string | Date; ip?: string | null }
18
+
19
+ const props = defineProps<{
20
+ fields: Record<string, string>;
21
+ agreements: Agreement[];
22
+ template: TemplateField[];
23
+ updatedAt?: string | Date | null;
24
+ }>();
25
+
26
+ const byKey = computed(() => new Map(props.template.map((f) => [f.key, f])));
27
+
28
+ interface FieldRow { key: string; label: string; isAddress: boolean; value: string; addressLines: string[] }
29
+ const fieldRows = computed<FieldRow[]>(() =>
30
+ Object.entries(props.fields).map(([key, value]) => {
31
+ const f = byKey.value.get(key);
32
+ const isAddress = f?.type === 'address';
33
+ return { key, label: f?.label ?? key, isAddress, value, addressLines: isAddress ? formatAddress(value) : [] };
34
+ }),
35
+ );
36
+
37
+ function formatAddress(json: string): string[] {
38
+ const a = parseAddress(json);
39
+ const cityLine = [a.city, a.region].filter(Boolean).join(', ') + (a.postal ? ` ${a.postal}` : '');
40
+ return [a.line1, a.line2, cityLine.trim(), a.country].map((l) => (l ?? '').trim()).filter(Boolean);
41
+ }
42
+
43
+ function agreementLabel(fieldKey: string): string {
44
+ return byKey.value.get(fieldKey)?.label ?? fieldKey;
45
+ }
46
+ function fmtDate(d: string | Date): string {
47
+ const dt = typeof d === 'string' ? new Date(d) : d;
48
+ if (Number.isNaN(dt.getTime())) return '';
49
+ return dt.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
50
+ }
51
+
52
+ const hasData = computed(() => fieldRows.value.length > 0 || props.agreements.length > 0);
53
+ </script>
54
+
55
+ <template>
56
+ <section v-if="hasData" class="cpub-epd" aria-labelledby="cpub-epd-head">
57
+ <div class="cpub-epd-bar">
58
+ <h2 id="cpub-epd-head" class="cpub-epd-head"><i class="fa-solid fa-user-shield"></i> Personal information</h2>
59
+ <span class="cpub-epd-note"><i class="fa-solid fa-lock"></i> Visible only to you and authorized contest organizers — never shown publicly or to judges.</span>
60
+ </div>
61
+
62
+ <dl v-if="fieldRows.length" class="cpub-epd-fields">
63
+ <template v-for="row in fieldRows" :key="row.key">
64
+ <dt>{{ row.label }}</dt>
65
+ <dd>
66
+ <address v-if="row.isAddress && row.addressLines.length" class="cpub-epd-address">
67
+ <span v-for="(line, i) in row.addressLines" :key="i">{{ line }}</span>
68
+ </address>
69
+ <span v-else>{{ row.value }}</span>
70
+ </dd>
71
+ </template>
72
+ </dl>
73
+
74
+ <div v-if="agreements.length" class="cpub-epd-agreements">
75
+ <h3 class="cpub-epd-subhead">Agreement acceptances</h3>
76
+ <ul class="cpub-epd-agree-list">
77
+ <li v-for="(a, i) in agreements" :key="`${a.fieldKey}-${a.stageId}-${i}`" class="cpub-epd-agree">
78
+ <div class="cpub-epd-agree-top">
79
+ <span class="cpub-epd-agree-label"><i class="fa-solid fa-circle-check"></i> {{ agreementLabel(a.fieldKey) }}</span>
80
+ <span class="cpub-epd-agree-when">Accepted {{ fmtDate(a.acceptedAt) }}<template v-if="a.ip"> · from {{ a.ip }}</template></span>
81
+ </div>
82
+ <details v-if="a.termsSnapshot" class="cpub-epd-terms">
83
+ <summary>View the terms accepted</summary>
84
+ <p class="cpub-epd-terms-text">{{ a.termsSnapshot }}</p>
85
+ </details>
86
+ <code v-if="a.termsHash" class="cpub-epd-hash" :title="`SHA-256 of the accepted terms (${a.stageId})`">sha256:{{ a.termsHash.slice(0, 16) }}…</code>
87
+ </li>
88
+ </ul>
89
+ </div>
90
+
91
+ <p v-if="updatedAt" class="cpub-epd-updated">Last updated {{ fmtDate(updatedAt) }}</p>
92
+ </section>
93
+ </template>
94
+
95
+ <style scoped>
96
+ .cpub-epd { border: var(--border-width-default) dashed var(--accent-border); background: var(--accent-bg); box-shadow: var(--shadow-md); margin-bottom: 22px; }
97
+ .cpub-epd-bar { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border-bottom: var(--border-width-default) solid var(--accent-border); }
98
+ .cpub-epd-head { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
99
+ .cpub-epd-head i { color: var(--accent); }
100
+ .cpub-epd-note { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); display: inline-flex; align-items: center; gap: 5px; }
101
+ .cpub-epd-note i { color: var(--text-faint); }
102
+
103
+ .cpub-epd-fields { margin: 0; padding: 14px 16px; display: grid; grid-template-columns: minmax(120px, 200px) 1fr; gap: 8px 16px; }
104
+ .cpub-epd-fields dt { font-size: 11px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); }
105
+ .cpub-epd-fields dd { margin: 0; font-size: 13px; color: var(--text); line-height: 1.6; overflow-wrap: anywhere; }
106
+ .cpub-epd-address { font-style: normal; display: flex; flex-direction: column; }
107
+
108
+ .cpub-epd-agreements { padding: 0 16px 14px; }
109
+ .cpub-epd-subhead { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); margin: 8px 0 8px; }
110
+ .cpub-epd-agree-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
111
+ .cpub-epd-agree { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 10px 12px; }
112
+ .cpub-epd-agree-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
113
+ .cpub-epd-agree-label { font-size: 12px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }
114
+ .cpub-epd-agree-label i { color: var(--green); font-size: 11px; }
115
+ .cpub-epd-agree-when { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
116
+ .cpub-epd-terms { margin: 8px 0 4px; }
117
+ .cpub-epd-terms summary { font-size: 11px; color: var(--accent); cursor: pointer; }
118
+ .cpub-epd-terms-text { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 8px 0 0; white-space: pre-line; }
119
+ .cpub-epd-hash { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
120
+ .cpub-epd-updated { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); margin: 0; padding: 0 16px 12px; }
121
+
122
+ @media (max-width: 600px) {
123
+ .cpub-epd-fields { grid-template-columns: 1fr; gap: 2px 0; }
124
+ .cpub-epd-fields dd { margin-bottom: 8px; }
125
+ }
126
+ </style>
@@ -16,6 +16,13 @@ const emit = defineEmits<{
16
16
 
17
17
  const c = computed(() => props.contest);
18
18
 
19
+ // Local wall-clock formatting (dates) and the live countdown are timezone- and
20
+ // clock-dependent, so they would mismatch between the server's TZ and the viewer's
21
+ // on hydration (and Vue won't rectify it in prod). Gate them on a client `mounted`
22
+ // flag: SSR + the first hydration tick render nothing, then the viewer-local value
23
+ // fills in on mount.
24
+ const mounted = ref(false);
25
+
19
26
  // Countdown timer
20
27
  const countdown = ref({ days: '00', hours: '00', mins: '00', secs: '00' });
21
28
  const targetPassed = ref(false);
@@ -48,6 +55,7 @@ function updateCountdown(): void {
48
55
  }
49
56
 
50
57
  onMounted(() => {
58
+ mounted.value = true;
51
59
  updateCountdown();
52
60
  countdownInterval = setInterval(updateCountdown, 1000);
53
61
  });
@@ -56,6 +64,18 @@ onUnmounted(() => {
56
64
  if (countdownInterval) clearInterval(countdownInterval);
57
65
  });
58
66
 
67
+ // Compact "5d 12h" style remaining-time string for the inline countdown chip.
68
+ const compactCountdown = computed<string>(() => {
69
+ const d = Number(countdown.value.days);
70
+ const h = Number(countdown.value.hours);
71
+ const m = Number(countdown.value.mins);
72
+ const s = Number(countdown.value.secs);
73
+ if (d > 0) return `${d}d ${h}h`;
74
+ if (h > 0) return `${h}h ${m}m`;
75
+ if (m > 0) return `${m}m ${s}s`;
76
+ return `${s}s`;
77
+ });
78
+
59
79
  const countdownLabel = computed(() => {
60
80
  const s = c.value?.status;
61
81
  if (s === 'completed' || s === 'cancelled') return 'Contest ended';
@@ -67,19 +87,18 @@ const countdownLabel = computed(() => {
67
87
  const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
68
88
  const isPaused = computed(() => c.value?.status === 'paused');
69
89
  const isDraft = computed(() => c.value?.status === 'draft');
70
- // Live countdown only while the clock is actually running AND its target is still
71
- // in the future. Once the target passes (an upcoming contest whose open date has
72
- // arrived, or an active one past its close), fall back to a static date note.
73
- const showCountdown = computed(() => !isEnded.value && !isPaused.value && !isDraft.value && !!countdownTargetStr.value && !targetPassed.value);
90
+ // Live countdown only while the clock is actually running (client) AND its target
91
+ // is still in the future. Before mount the target hasn't been evaluated, so show
92
+ // nothing live yet (the static fallbacks below cover ended/paused/draft).
93
+ const showCountdown = computed(() => mounted.value && !isEnded.value && !isPaused.value && !isDraft.value && !!countdownTargetStr.value && !targetPassed.value);
74
94
 
75
95
  function fmtDate(s: string | null | undefined): string {
76
- if (!s) return '';
77
- return new Date(s).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
96
+ return mounted.value ? formatLocalDate(s) : '';
78
97
  }
79
98
  // Static date shown when the relevant target is in the past but the contest hasn't
80
99
  // been advanced yet (e.g. an upcoming contest whose open date arrived).
81
100
  const dateNote = computed<string | null>(() => {
82
- if (isEnded.value || isPaused.value || isDraft.value || !targetPassed.value) return null;
101
+ if (!mounted.value || isEnded.value || isPaused.value || isDraft.value || !targetPassed.value) return null;
83
102
  if (c.value?.status === 'upcoming') return c.value?.startDate ? `Opens ${fmtDate(c.value.startDate)}` : null;
84
103
  return c.value?.endDate ? `Closed ${fmtDate(c.value.endDate)}` : null;
85
104
  });
@@ -99,8 +118,8 @@ const tagline = computed<string>(() => {
99
118
  return markdownToExcerpt(c.value?.description) || '';
100
119
  });
101
120
 
102
- // Phase B1 — when a contest defines explicit stages, surface the current stage's
103
- // name beside the status pill (default-flow contests show nothing extra).
121
+ // When a contest defines explicit stages, surface the current stage's name beside
122
+ // the status pill (default-flow contests show nothing extra).
104
123
  const currentStageName = computed<string | null>(() => {
105
124
  const cv = c.value;
106
125
  if (!cv || !cv.stages || cv.stages.length === 0) return null;
@@ -109,115 +128,70 @@ const currentStageName = computed<string | null>(() => {
109
128
  });
110
129
 
111
130
  const dateRange = computed<string>(() => {
112
- const fmt = (d: string, withYear = false) =>
113
- new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(withYear ? { year: 'numeric' } : {}) });
114
- const start = c.value?.startDate ? fmt(c.value.startDate) : '';
115
- const end = c.value?.endDate ? fmt(c.value.endDate, true) : '';
116
- if (start && end) return `${start}, ${end}`;
131
+ if (!mounted.value) return '';
132
+ const start = c.value?.startDate ? formatLocalDate(c.value.startDate, { year: false }) : '';
133
+ const end = c.value?.endDate ? formatLocalDate(c.value.endDate) : '';
134
+ if (start && end) return `${start} to ${end}`;
117
135
  return start || end;
118
136
  });
137
+
138
+ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
119
139
  </script>
120
140
 
121
141
  <template>
122
142
  <div class="cpub-hero">
123
- <!-- Banner band — full-width image at the top, the same way other content
124
- pages render their hero banner (clean band, never overlaid by text). -->
143
+ <!-- Slim banner band (constrained) the hero image, not a tall block. -->
125
144
  <div v-if="c?.bannerUrl" class="cpub-hero-banner">
126
145
  <img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" />
127
146
  </div>
128
147
 
129
- <!-- Hero bodythe contest's dark, patterned section. Two columns:
130
- title + details on the left, the countdown on the right. -->
131
- <div class="cpub-hero-body">
132
- <div class="cpub-hero-pattern" aria-hidden="true">
133
- <div class="cpub-hero-dots"></div>
134
- <div class="cpub-hero-lines"></div>
135
- </div>
136
-
137
- <div class="cpub-hero-inner">
148
+ <!-- Compact bartitle + status + meta + actions in one tight, clean band
149
+ (replaces the old tall, dark, patterned hero). -->
150
+ <div class="cpub-hero-bar">
151
+ <div class="cpub-hero-bar-inner">
138
152
  <div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
139
153
  <i class="fa-solid fa-ban"></i> This contest has been cancelled.
140
154
  </div>
141
155
 
142
- <div class="cpub-hero-grid">
143
- <!-- LEFT: title + details + actions -->
144
- <div class="cpub-hero-main">
145
- <div class="cpub-hero-eyebrow">
146
- <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
147
- <span class="cpub-status-pill" :data-status="c?.status || 'upcoming'">{{ c?.status || 'upcoming' }}</span>
148
- <span v-if="currentStageName" class="cpub-stage-chip"><i class="fa-solid fa-diagram-project"></i> {{ currentStageName }}</span>
149
- </div>
150
-
151
- <h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
152
- <p v-if="tagline" class="cpub-hero-tagline">{{ tagline }}</p>
153
-
154
- <div class="cpub-hero-meta">
155
- <span v-if="dateRange" class="cpub-hero-meta-item"><i class="fa fa-calendar"></i> {{ dateRange }}</span>
156
- <span class="cpub-hero-meta-item"><i class="fa fa-folder-open"></i> {{ c?.entryCount ?? 0 }} {{ (c?.entryCount ?? 0) === 1 ? 'entry' : 'entries' }}</span>
157
- </div>
158
-
159
- <div class="cpub-hero-cta">
160
- <button v-if="isAuthenticated && c?.status === 'active'" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="emit('submit-entry')"><i class="fa fa-upload"></i> Submit Entry</button>
161
- <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
162
- </div>
163
-
164
- <!-- Admin controls — bidirectional, derived from the valid-transition map. -->
165
- <div v-if="isAdmin && c" class="cpub-admin-controls">
166
- <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Stage</span>
167
- <button
168
- v-for="t in availableTransitions"
169
- :key="t"
170
- class="cpub-btn cpub-btn-sm"
171
- :class="{ 'cpub-btn-cancel': t === 'cancelled' }"
172
- :disabled="transitioning"
173
- @click="emit('transition', t)"
174
- ><i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}</button>
175
- </div>
156
+ <div class="cpub-hero-top">
157
+ <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
158
+ <span class="cpub-status-pill" :data-status="c?.status || 'upcoming'">{{ c?.status || 'upcoming' }}</span>
159
+ <span v-if="currentStageName" class="cpub-stage-chip"><i class="fa-solid fa-diagram-project"></i> {{ currentStageName }}</span>
160
+
161
+ <span v-if="showCountdown" class="cpub-countdown-chip">
162
+ <i class="fa fa-clock"></i> {{ countdownLabel }} <strong>{{ compactCountdown }}</strong>
163
+ </span>
164
+ <span v-else-if="isPaused" class="cpub-countdown-chip cpub-countdown-chip-muted"><i class="fa-solid fa-circle-pause"></i> Submissions paused</span>
165
+ <span v-else-if="isDraft" class="cpub-countdown-chip cpub-countdown-chip-muted"><i class="fa-solid fa-pen-ruler"></i> Draft, not launched</span>
166
+ <span v-else-if="dateNote" class="cpub-countdown-chip cpub-countdown-chip-muted"><i class="fa-regular fa-calendar"></i> {{ dateNote }}</span>
167
+ <span v-else-if="isEnded" class="cpub-countdown-chip cpub-countdown-chip-muted"><i class="fa-solid fa-flag-checkered"></i> {{ countdownLabel }}</span>
168
+ </div>
169
+
170
+ <h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
171
+ <p v-if="tagline" class="cpub-hero-tagline">{{ tagline }}</p>
172
+
173
+ <div class="cpub-hero-foot">
174
+ <div class="cpub-hero-meta">
175
+ <span v-if="dateRange" class="cpub-hero-meta-item"><i class="fa fa-calendar"></i> {{ dateRange }}</span>
176
+ <span class="cpub-hero-meta-item"><i class="fa fa-folder-open"></i> {{ entryCount }} {{ entryCount === 1 ? 'entry' : 'entries' }}</span>
176
177
  </div>
178
+ <div class="cpub-hero-cta">
179
+ <button v-if="isAuthenticated && c?.status === 'active'" class="cpub-btn cpub-btn-primary" @click="emit('submit-entry')"><i class="fa fa-upload"></i> Submit Entry</button>
180
+ <button class="cpub-btn" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
181
+ </div>
182
+ </div>
177
183
 
178
- <!-- RIGHT: countdown -->
179
- <aside class="cpub-hero-side">
180
- <div v-if="showCountdown" class="cpub-countdown-section">
181
- <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
182
- <div class="cpub-countdown-row">
183
- <div class="cpub-countdown-block">
184
- <div class="cpub-countdown-val">{{ countdown.days }}</div>
185
- <div class="cpub-countdown-unit">Days</div>
186
- </div>
187
- <div class="cpub-countdown-sep">:</div>
188
- <div class="cpub-countdown-block">
189
- <div class="cpub-countdown-val">{{ countdown.hours }}</div>
190
- <div class="cpub-countdown-unit">Hours</div>
191
- </div>
192
- <div class="cpub-countdown-sep">:</div>
193
- <div class="cpub-countdown-block">
194
- <div class="cpub-countdown-val">{{ countdown.mins }}</div>
195
- <div class="cpub-countdown-unit">Minutes</div>
196
- </div>
197
- <div class="cpub-countdown-sep">:</div>
198
- <div class="cpub-countdown-block">
199
- <div class="cpub-countdown-val">{{ countdown.secs }}</div>
200
- <div class="cpub-countdown-unit">Seconds</div>
201
- </div>
202
- </div>
203
- </div>
204
- <div v-else-if="isPaused" class="cpub-countdown-ended">
205
- <i class="fa-solid fa-circle-pause"></i>
206
- <span>Submissions paused</span>
207
- </div>
208
- <div v-else-if="isDraft" class="cpub-countdown-ended">
209
- <i class="fa-solid fa-pen-ruler"></i>
210
- <span>Draft, not launched</span>
211
- </div>
212
- <div v-else-if="dateNote" class="cpub-countdown-ended">
213
- <i class="fa-regular fa-calendar"></i>
214
- <span>{{ dateNote }}</span>
215
- </div>
216
- <div v-else class="cpub-countdown-ended">
217
- <i class="fa-solid fa-flag-checkered"></i>
218
- <span>{{ countdownLabel }}</span>
219
- </div>
220
- </aside>
184
+ <!-- Admin controls — bidirectional, derived from the valid-transition map. -->
185
+ <div v-if="isAdmin && c" class="cpub-admin-controls">
186
+ <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Stage</span>
187
+ <button
188
+ v-for="t in availableTransitions"
189
+ :key="t"
190
+ class="cpub-btn cpub-btn-sm"
191
+ :class="{ 'cpub-btn-cancel': t === 'cancelled' }"
192
+ :disabled="transitioning"
193
+ @click="emit('transition', t)"
194
+ ><i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}</button>
221
195
  </div>
222
196
  </div>
223
197
  </div>
@@ -225,112 +199,66 @@ const dateRange = computed<string>(() => {
225
199
  </template>
226
200
 
227
201
  <style scoped>
228
- .cpub-hero {
229
- --hero-bg: var(--text);
230
- --hero-text: var(--color-text-inverse);
231
- --hero-text-dim: var(--text-faint);
232
- /* Alpha of the hero foreground so the structure lines/surfaces track the
233
- inverted hero in both themes (white-on-dark in light mode, dark-on-light
234
- in dark mode) instead of vanishing white-on-white. */
235
- --hero-border: color-mix(in srgb, var(--hero-text) 18%, transparent);
236
- --hero-surface: color-mix(in srgb, var(--hero-text) 7%, transparent);
237
- }
238
-
239
- /* ── BANNER BAND ── full-width, clean, like other content pages' hero banner. */
202
+ /* ── SLIM BANNER ── constrained hero image (was a tall ≤360px band). */
240
203
  .cpub-hero-banner {
241
204
  width: 100%;
242
- /* Match the 4:1 upload crop so the banner shows exactly as framed (WYSIWYG)
243
- instead of being re-cropped by a fixed height. */
244
205
  aspect-ratio: 4 / 1;
245
- max-height: 360px;
206
+ max-height: 220px;
246
207
  background: var(--surface2);
247
208
  border-bottom: var(--border-width-default) solid var(--border);
248
209
  overflow: hidden;
249
210
  }
250
- .cpub-hero-banner img {
251
- display: block;
252
- width: 100%;
253
- height: 100%;
254
- object-fit: cover;
255
- }
211
+ .cpub-hero-banner img { display: block; width: 100%; height: 100%; object-fit: cover; }
256
212
 
257
- /* ── HERO BODY ── the contest's dark, patterned section. */
258
- .cpub-hero-body {
259
- position: relative;
260
- overflow: hidden;
261
- background: var(--hero-bg);
262
- padding: 44px 0;
263
- }
264
- .cpub-hero-pattern { position: absolute; inset: 0; }
265
- .cpub-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(var(--accent-border) 1.5px, transparent 1.5px); background-size: 28px 28px; opacity: .3; }
266
- .cpub-hero-lines { position: absolute; inset: 0; background-image: linear-gradient(var(--accent-bg) 1px, transparent 1px), linear-gradient(90deg, var(--accent-bg) 1px, transparent 1px); background-size: 56px 56px; }
267
- .cpub-hero-inner { max-width: 1100px; margin: 0 auto; padding: 0 32px; position: relative; z-index: 1; }
213
+ /* ── COMPACT BAR ── one tight, clean band on a surface background. */
214
+ .cpub-hero-bar { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); }
215
+ .cpub-hero-bar-inner { max-width: 1100px; margin: 0 auto; padding: 20px 32px; }
268
216
 
269
- .cpub-cancelled-banner { background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); color: var(--red); padding: 10px 14px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 8px; margin-bottom: 20px; }
217
+ .cpub-cancelled-banner { background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); color: var(--red); padding: 10px 14px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
270
218
 
271
- /* 2-column: details (flex) + countdown (auto width). */
272
- .cpub-hero-grid { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 48px; align-items: start; }
273
- .cpub-hero-main { min-width: 0; }
274
-
275
- .cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
219
+ .cpub-hero-top { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
276
220
  .cpub-contest-badge { font-size: 9px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; font-family: var(--font-mono); color: var(--accent); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent); padding: 3px 10px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 5px; }
277
221
  .cpub-contest-badge i { font-size: 8px; }
278
- .cpub-status-pill { font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--hero-border); color: var(--hero-text-dim); }
279
- .cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); }
280
- .cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
281
- .cpub-status-pill[data-status="upcoming"] { color: var(--yellow); border-color: var(--yellow); }
282
- .cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
283
- .cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
284
- .cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
285
- .cpub-stage-chip { font-size: 9px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--accent); color: var(--accent); background: var(--accent-bg); display: inline-flex; align-items: center; gap: 5px; }
222
+ .cpub-status-pill { font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); }
223
+ .cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
224
+ .cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
225
+ .cpub-status-pill[data-status="upcoming"] { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
226
+ .cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
227
+ .cpub-status-pill[data-status="draft"] { color: var(--text-faint); border-color: var(--border2); border-style: dashed; }
228
+ .cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
229
+ .cpub-stage-chip { font-size: 9px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--accent-border); color: var(--accent); background: var(--accent-bg); display: inline-flex; align-items: center; gap: 5px; }
286
230
  .cpub-stage-chip i { font-size: 8px; }
287
231
 
288
- .cpub-hero-title { font-size: 34px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin: 0 0 10px; color: var(--hero-text); }
289
- .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 600px; margin: 0 0 20px; display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
290
- .cpub-hero-meta { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 11px; color: var(--hero-text-dim); font-family: var(--font-mono); margin-bottom: 24px; }
291
- .cpub-hero-meta-item { display: flex; align-items: center; gap: 6px; }
232
+ /* Inline countdown chip (pushed to the right of the top row). */
233
+ .cpub-countdown-chip { margin-left: auto; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); background: var(--surface2); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 4px 10px; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
234
+ .cpub-countdown-chip i { color: var(--accent); }
235
+ .cpub-countdown-chip strong { color: var(--accent); font-weight: 700; }
236
+ .cpub-countdown-chip-muted i { color: var(--text-faint); }
237
+
238
+ .cpub-hero-title { font-size: 26px; font-weight: 800; letter-spacing: -.02em; line-height: 1.15; margin: 0 0 6px; color: var(--text); }
239
+ .cpub-hero-tagline { font-size: 13px; color: var(--text-dim); line-height: 1.55; max-width: 680px; margin: 0 0 14px; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
292
240
 
293
- .cpub-hero-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
294
- .cpub-btn-lg { padding: 10px 22px; font-size: 13px; }
295
- .cpub-btn-dark { background: var(--hero-surface); color: var(--hero-text); border-color: var(--hero-border); }
296
- .cpub-btn-dark:hover { background: var(--hero-surface); }
241
+ .cpub-hero-foot { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
242
+ .cpub-hero-meta { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
243
+ .cpub-hero-meta-item { display: flex; align-items: center; gap: 6px; }
244
+ .cpub-hero-cta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
297
245
  .cpub-btn-cancel { color: var(--red); border-color: var(--red-border); }
298
246
  .cpub-btn-cancel:hover { background: var(--red-bg); }
299
247
 
300
- .cpub-admin-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
248
+ .cpub-admin-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 14px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
301
249
  .cpub-admin-controls-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-right: 4px; font-family: var(--font-mono); }
302
250
 
303
- /* ── COUNTDOWN (right column) ── */
304
- .cpub-hero-side { display: flex; flex-direction: column; }
305
- .cpub-countdown-section { background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 16px 18px; }
306
- .cpub-countdown-label { font-size: 10px; font-family: var(--font-mono); color: var(--hero-text-dim); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
307
- .cpub-countdown-label i { color: var(--accent); }
308
- .cpub-countdown-row { display: flex; align-items: flex-start; gap: 8px; }
309
- .cpub-countdown-block { display: flex; flex-direction: column; align-items: center; background: var(--hero-bg); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 10px 14px; min-width: 56px; }
310
- .cpub-countdown-val { font-size: 24px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); line-height: 1; margin-bottom: 4px; }
311
- .cpub-countdown-unit { font-size: 8px; text-transform: uppercase; letter-spacing: .1em; color: var(--hero-text-dim); font-family: var(--font-mono); }
312
- .cpub-countdown-sep { font-size: 20px; font-weight: 700; color: var(--hero-border); font-family: var(--font-mono); padding-top: 10px; }
313
- .cpub-countdown-ended { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .08em; color: var(--hero-text-dim); background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 14px 18px; }
314
- .cpub-countdown-ended i { color: var(--accent); }
315
-
316
- /* ── RESPONSIVE ── stack the countdown below the details. */
317
- @media (max-width: 900px) {
318
- .cpub-hero-grid { grid-template-columns: 1fr; gap: 28px; }
319
- .cpub-hero-side { align-items: flex-start; }
320
- }
321
251
  @media (max-width: 768px) {
322
- .cpub-hero-body { padding: 32px 0; }
323
- .cpub-hero-inner { padding: 0 16px; }
324
- .cpub-hero-banner { max-height: 200px; }
325
- .cpub-hero-title { font-size: 24px; }
326
- .cpub-hero-meta { gap: 10px; }
252
+ .cpub-hero-banner { max-height: 160px; }
253
+ .cpub-hero-bar-inner { padding: 16px; }
254
+ .cpub-hero-title { font-size: 21px; }
255
+ .cpub-hero-meta { gap: 12px; }
256
+ .cpub-countdown-chip { margin-left: 0; }
327
257
  }
328
258
  @media (max-width: 480px) {
329
- .cpub-hero-title { font-size: 20px; }
330
- .cpub-hero-tagline { font-size: 12px; margin-bottom: 16px; }
331
- .cpub-hero-cta { flex-direction: column; align-items: stretch; }
332
- .cpub-countdown-row { flex-wrap: wrap; justify-content: center; }
333
- .cpub-countdown-block { min-width: 48px; padding: 8px 12px; }
334
- .cpub-countdown-val { font-size: 20px; }
259
+ .cpub-hero-title { font-size: 19px; }
260
+ .cpub-hero-foot { align-items: stretch; }
261
+ .cpub-hero-cta { width: 100%; }
262
+ .cpub-hero-cta .cpub-btn { flex: 1; justify-content: center; }
335
263
  }
336
264
  </style>
@@ -30,12 +30,14 @@ function handleSearch(): void {
30
30
  searchTimeout = setTimeout(async () => {
31
31
  searching.value = true;
32
32
  try {
33
- const data = await ($fetch as Function)('/api/admin/users', {
34
- query: { search: searchQuery.value, limit: 8 },
35
- }) as { items: Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }> };
33
+ // Contest-scoped, non-admin user search (public fields only). Avoids the
34
+ // admin-only /api/admin/users that 403'd for non-admin contest owners.
35
+ const data = await ($fetch as Function)(`/api/contests/${props.contestSlug}/user-search`, {
36
+ query: { q: searchQuery.value, limit: 8 },
37
+ }) as Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>;
36
38
  // Filter out users who are already judges
37
39
  const judgeIds = new Set((judges.value ?? []).map((j: ContestJudgeItem) => j.userId));
38
- searchResults.value = data.items.filter((u: { id: string }) => !judgeIds.has(u.id));
40
+ searchResults.value = data.filter((u: { id: string }) => !judgeIds.has(u.id));
39
41
  } catch {
40
42
  searchResults.value = [];
41
43
  } finally {
@@ -7,9 +7,7 @@ const props = defineProps<{
7
7
  compact?: boolean;
8
8
  }>();
9
9
 
10
- const totalWeight = computed(() =>
11
- props.criteria.reduce((sum, c) => sum + (c.weight ?? 0), 0),
12
- );
10
+ const totalWeight = computed(() => props.criteria.reduce((sum, c) => sum + (c.weight ?? 0), 0));
13
11
  const hasWeights = computed(() => props.criteria.some((c) => (c.weight ?? 0) > 0));
14
12
  </script>
15
13
 
@@ -20,16 +18,9 @@ const hasWeights = computed(() => props.criteria.some((c) => (c.weight ?? 0) > 0
20
18
  <span v-if="hasWeights" class="cpub-sec-sub">{{ totalWeight }} pts total</span>
21
19
  </div>
22
20
  <div class="cpub-criteria-card">
23
- <div v-for="(crit, i) in criteria" :key="i" class="cpub-criterion">
24
- <div class="cpub-criterion-head">
25
- <span class="cpub-criterion-label">{{ crit.label }}</span>
26
- <span v-if="crit.weight != null && crit.weight > 0" class="cpub-criterion-weight">{{ crit.weight }} pts</span>
27
- </div>
28
- <p v-if="crit.description" class="cpub-criterion-desc">{{ crit.description }}</p>
29
- <div v-if="hasWeights && crit.weight" class="cpub-criterion-bar">
30
- <div class="cpub-criterion-bar-fill" :style="{ width: `${Math.min(100, (crit.weight / Math.max(totalWeight, 1)) * 100)}%` }"></div>
31
- </div>
32
- </div>
21
+ <!-- The one shared weighting visual: thin seamless bar + legend (with the
22
+ per-criterion descriptions). -->
23
+ <CpubCriteriaBar :items="criteria" />
33
24
  </div>
34
25
  </div>
35
26
  </template>
@@ -39,13 +30,6 @@ const hasWeights = computed(() => props.criteria.some((c) => (c.weight ?? 0) > 0
39
30
  .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
40
31
  .cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
41
32
 
42
- .cpub-criteria-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; box-shadow: var(--shadow-md); display: flex; flex-direction: column; gap: 14px; }
33
+ .cpub-criteria-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; box-shadow: var(--shadow-md); }
43
34
  .cpub-criteria-compact .cpub-criteria-card { box-shadow: none; border-style: dashed; margin-bottom: 0; }
44
-
45
- .cpub-criterion-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
46
- .cpub-criterion-label { font-size: 13px; font-weight: 600; color: var(--text); }
47
- .cpub-criterion-weight { font-size: 10px; font-family: var(--font-mono); font-weight: 700; color: var(--accent); white-space: nowrap; }
48
- .cpub-criterion-desc { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 4px 0 0; }
49
- .cpub-criterion-bar { height: 4px; background: var(--surface2); border: var(--border-width-default) solid var(--border); margin-top: 8px; overflow: hidden; }
50
- .cpub-criterion-bar-fill { height: 100%; background: var(--accent); }
51
35
  </style>