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