@commonpub/layer 0.82.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.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +8 -2
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +3 -2
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +41 -19
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +9 -9
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +1 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -769
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +93 -7
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- 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">{{
|
|
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
|
|
71
|
-
// in the future.
|
|
72
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
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
|
-
<!--
|
|
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
|
-
<!--
|
|
130
|
-
|
|
131
|
-
<div class="cpub-hero-
|
|
132
|
-
<div class="cpub-hero-
|
|
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 bar — title + 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-
|
|
143
|
-
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
</
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/* ──
|
|
258
|
-
.cpub-hero-
|
|
259
|
-
|
|
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:
|
|
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
|
-
|
|
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(--
|
|
279
|
-
.cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background:
|
|
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:
|
|
283
|
-
.cpub-status-pill[data-status="draft"] { color: var(--
|
|
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
|
-
|
|
289
|
-
.cpub-
|
|
290
|
-
.cpub-
|
|
291
|
-
.cpub-
|
|
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-
|
|
294
|
-
.cpub-
|
|
295
|
-
.cpub-
|
|
296
|
-
.cpub-
|
|
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:
|
|
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-
|
|
323
|
-
.cpub-hero-inner { padding:
|
|
324
|
-
.cpub-hero-
|
|
325
|
-
.cpub-hero-
|
|
326
|
-
.cpub-
|
|
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:
|
|
330
|
-
.cpub-hero-
|
|
331
|
-
.cpub-hero-cta {
|
|
332
|
-
.cpub-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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);
|
|
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>
|