@commonpub/layer 0.70.0 → 0.71.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.
@@ -78,6 +78,14 @@ function confirmWithdraw(entryId: string): void {
78
78
  emit('withdraw', entryId);
79
79
  }
80
80
  }
81
+
82
+ // Cards link to the entry detail page (content summary + per-stage artifacts)
83
+ // when we know the contest slug; otherwise straight to the content item.
84
+ function entryLink(entry: Serialized<ContestEntryItem>): string {
85
+ return props.contestSlug
86
+ ? `/contests/${props.contestSlug}/entries/${entry.id}`
87
+ : `/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`;
88
+ }
81
89
  </script>
82
90
 
83
91
  <template>
@@ -104,7 +112,7 @@ function confirmWithdraw(entryId: string): void {
104
112
  <span v-else-if="entry.stageState && entry.stageState.some((s) => s.status === 'advanced')" class="cpub-entry-cohort cpub-cohort-in"><i class="fa-solid fa-circle-check"></i> Advanced</span>
105
113
  </div>
106
114
  <div class="cpub-entry-body">
107
- <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
115
+ <NuxtLink :to="entryLink(entry)" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
108
116
  <div class="cpub-entry-author">
109
117
  <div class="cpub-entry-av">
110
118
  <img v-if="entry.authorAvatarUrl" :src="entry.authorAvatarUrl" :alt="entry.authorName || entry.authorUsername" class="cpub-entry-av-img" />
@@ -0,0 +1,173 @@
1
+ <script setup lang="ts">
2
+ import type { ContestStage, ContestStageSubmission } from '@commonpub/schema';
3
+
4
+ // Per-stage artifact form: an entrant with an entry fills the CURRENT
5
+ // submission stage's template fields (a proposal, a prototype's links, ...).
6
+ // Driven entirely by `stage.submissionTemplate`; saving PUTs the artifact and
7
+ // the server re-validates ownership, stage state, the cohort gate, and fields.
8
+
9
+ interface EntryLite {
10
+ id: string;
11
+ contentTitle: string;
12
+ eliminated: boolean;
13
+ stageSubmissions?: ContestStageSubmission[];
14
+ }
15
+
16
+ const props = defineProps<{
17
+ contestSlug: string;
18
+ /** The contest's current `submission` stage (must carry a template). */
19
+ stage: ContestStage;
20
+ /** The viewer's OWN entries (parent filters by userId). */
21
+ entries: EntryLite[];
22
+ }>();
23
+
24
+ const emit = defineEmits<{ (e: 'saved'): void }>();
25
+
26
+ const toast = useToast();
27
+ const { extract: extractError } = useApiError();
28
+
29
+ const template = computed(() => props.stage.submissionTemplate ?? []);
30
+ // Eliminated entries are out of later rounds; don't offer the form for them.
31
+ const eligibleEntries = computed(() => props.entries.filter((e) => !e.eliminated));
32
+ const selectedEntryId = ref<string>('');
33
+ watch(eligibleEntries, (list) => {
34
+ if (!list.some((e) => e.id === selectedEntryId.value)) selectedEntryId.value = list[0]?.id ?? '';
35
+ }, { immediate: true });
36
+ const selectedEntry = computed(() => eligibleEntries.value.find((e) => e.id === selectedEntryId.value) ?? null);
37
+
38
+ const existing = computed<ContestStageSubmission | null>(
39
+ () => selectedEntry.value?.stageSubmissions?.find((s) => s.stageId === props.stage.id) ?? null,
40
+ );
41
+
42
+ // Field values, seeded from the already-submitted artifact (if any) so the
43
+ // entrant edits in place rather than retyping.
44
+ const values = ref<Record<string, string>>({});
45
+ watch([existing, template], () => {
46
+ const next: Record<string, string> = {};
47
+ for (const f of template.value) next[f.key] = existing.value?.fields[f.key] ?? '';
48
+ values.value = next;
49
+ }, { immediate: true });
50
+
51
+ const dirty = computed(() =>
52
+ template.value.some((f) => (values.value[f.key] ?? '') !== (existing.value?.fields[f.key] ?? '')),
53
+ );
54
+ const missingRequired = computed(() =>
55
+ template.value.filter((f) => f.required && !(values.value[f.key] ?? '').trim()).map((f) => f.label),
56
+ );
57
+
58
+ const saving = ref(false);
59
+ async function save(): Promise<void> {
60
+ if (!selectedEntryId.value || saving.value) return;
61
+ if (missingRequired.value.length) {
62
+ toast.error(`Please fill: ${missingRequired.value.join(', ')}`);
63
+ return;
64
+ }
65
+ saving.value = true;
66
+ try {
67
+ const fields: Record<string, string> = {};
68
+ for (const f of template.value) {
69
+ const v = (values.value[f.key] ?? '').trim();
70
+ if (v) fields[f.key] = v;
71
+ }
72
+ await $fetch(`/api/contests/${props.contestSlug}/entries/${selectedEntryId.value}/submission`, {
73
+ method: 'PUT',
74
+ body: { stageId: props.stage.id, fields },
75
+ });
76
+ toast.success(existing.value ? `${props.stage.name} submission updated` : `${props.stage.name} submission received`);
77
+ emit('saved');
78
+ } catch (err: unknown) {
79
+ toast.error(extractError(err));
80
+ } finally {
81
+ saving.value = false;
82
+ }
83
+ }
84
+
85
+ function submittedAtLabel(iso: string): string {
86
+ return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
87
+ }
88
+ </script>
89
+
90
+ <template>
91
+ <section v-if="eligibleEntries.length && template.length" class="cpub-stagesub" :aria-label="`${stage.name} submission form`">
92
+ <div class="cpub-stagesub-head">
93
+ <h3 class="cpub-stagesub-title"><i class="fa-solid fa-file-pen"></i> {{ stage.name }}: your submission</h3>
94
+ <span v-if="existing" class="cpub-stagesub-badge cpub-stagesub-done">
95
+ <i class="fa-solid fa-circle-check"></i> Submitted {{ submittedAtLabel(existing.submittedAt) }}
96
+ </span>
97
+ <span v-else class="cpub-stagesub-badge cpub-stagesub-todo">
98
+ <i class="fa-solid fa-circle-exclamation"></i> Not submitted yet
99
+ </span>
100
+ </div>
101
+ <p v-if="stage.description" class="cpub-stagesub-desc">{{ stage.description }}</p>
102
+
103
+ <div v-if="eligibleEntries.length > 1" class="cpub-stagesub-field">
104
+ <label class="cpub-stagesub-label" for="cpub-stagesub-entry">Entry</label>
105
+ <select id="cpub-stagesub-entry" v-model="selectedEntryId" class="cpub-stagesub-input">
106
+ <option v-for="e in eligibleEntries" :key="e.id" :value="e.id">{{ e.contentTitle }}</option>
107
+ </select>
108
+ </div>
109
+
110
+ <div v-for="f in template" :key="f.key" class="cpub-stagesub-field">
111
+ <label class="cpub-stagesub-label" :for="`cpub-stagesub-${f.key}`">
112
+ {{ f.label }} <span v-if="f.required" class="cpub-stagesub-req" aria-hidden="true">*</span>
113
+ <span v-if="f.required" class="cpub-sr-only">(required)</span>
114
+ </label>
115
+ <textarea
116
+ v-if="f.type === 'textarea'"
117
+ :id="`cpub-stagesub-${f.key}`"
118
+ v-model="values[f.key]"
119
+ class="cpub-stagesub-input cpub-stagesub-textarea"
120
+ rows="4"
121
+ maxlength="4000"
122
+ :required="f.required"
123
+ :aria-describedby="f.help ? `cpub-stagesub-${f.key}-help` : undefined"
124
+ ></textarea>
125
+ <input
126
+ v-else
127
+ :id="`cpub-stagesub-${f.key}`"
128
+ v-model="values[f.key]"
129
+ :type="f.type === 'url' ? 'url' : 'text'"
130
+ class="cpub-stagesub-input"
131
+ maxlength="4000"
132
+ :placeholder="f.type === 'url' ? 'https://' : undefined"
133
+ :required="f.required"
134
+ :aria-describedby="f.help ? `cpub-stagesub-${f.key}-help` : undefined"
135
+ />
136
+ <p v-if="f.help" :id="`cpub-stagesub-${f.key}-help`" class="cpub-stagesub-help">{{ f.help }}</p>
137
+ </div>
138
+
139
+ <div class="cpub-stagesub-actions">
140
+ <button
141
+ type="button"
142
+ class="cpub-btn cpub-btn-primary"
143
+ :disabled="saving || !dirty || !selectedEntryId"
144
+ @click="save"
145
+ >
146
+ <i class="fa-solid fa-paper-plane"></i>
147
+ {{ saving ? 'Saving...' : existing ? 'Update submission' : 'Submit' }}
148
+ </button>
149
+ <span v-if="dirty && existing" class="cpub-stagesub-unsaved">Unsaved changes</span>
150
+ </div>
151
+ </section>
152
+ </template>
153
+
154
+ <style scoped>
155
+ .cpub-stagesub { border: var(--border-width-default) solid var(--accent-border); background: var(--accent-bg); padding: 16px 20px; margin-bottom: 18px; }
156
+ .cpub-stagesub-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
157
+ .cpub-stagesub-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
158
+ .cpub-stagesub-title i { color: var(--accent); }
159
+ .cpub-stagesub-badge { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; padding: 3px 8px; border: var(--border-width-default) solid; }
160
+ .cpub-stagesub-done { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
161
+ .cpub-stagesub-todo { color: var(--text-dim); border-color: var(--border2); background: var(--surface2); }
162
+ .cpub-stagesub-desc { font-size: 12px; color: var(--text-dim); margin: 0 0 12px; line-height: 1.6; }
163
+ .cpub-stagesub-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
164
+ .cpub-stagesub-label { font-size: 11px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); }
165
+ .cpub-stagesub-req { color: var(--red); }
166
+ .cpub-stagesub-input { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
167
+ .cpub-stagesub-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
168
+ .cpub-stagesub-textarea { resize: vertical; }
169
+ .cpub-stagesub-help { font-size: 11px; color: var(--text-faint); margin: 0; }
170
+ .cpub-stagesub-actions { display: flex; align-items: center; gap: 10px; }
171
+ .cpub-stagesub-unsaved { font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
172
+ .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; }
173
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { ContestStage } from '@commonpub/schema';
2
+ import type { ContestStage, ContestSubmissionTemplateField } from '@commonpub/schema';
3
3
 
4
4
  // Phase B1 — define an arbitrary, ordered stage timeline for a contest. Empty ⇒
5
5
  // the contest uses the synthesized standard flow (Submissions → Judging → Results),
@@ -68,6 +68,26 @@ function advanceCountInput(i: number, e: Event): void {
68
68
  setField(i, { advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
69
69
  }
70
70
 
71
+ // Per-stage submission template (submission stages): what entrants fill for
72
+ // THIS stage's artifact (proposal vs prototype). Array ops live as pure
73
+ // functions in utils/contestStages.ts (unit-tested). Flag-gated (rule #2).
74
+ const { features } = useFeatures();
75
+ const templatesEnabled = computed(() => features.value.contestStageSubmissions !== false);
76
+ const FIELD_TYPES: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url'];
77
+
78
+ function addTemplateField(i: number): void {
79
+ commit(withTemplateFieldAdded(stages.value, i));
80
+ }
81
+ function setTemplateField(i: number, fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
82
+ commit(withTemplateFieldSet(stages.value, i, fi, patch));
83
+ }
84
+ function templateFieldLabelInput(i: number, fi: number, e: Event): void {
85
+ commit(withTemplateFieldLabelChanged(stages.value, i, fi, (e.target as HTMLInputElement).value));
86
+ }
87
+ function removeTemplateField(i: number, fi: number): void {
88
+ commit(withTemplateFieldRemoved(stages.value, i, fi));
89
+ }
90
+
71
91
  // Array operations live as pure functions in utils/contestStages.ts (unit-tested).
72
92
  function addStage(): void {
73
93
  commit(withStageAdded(stages.value));
@@ -212,6 +232,54 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
212
232
  </div>
213
233
  </div>
214
234
 
235
+ <!-- Per-stage submission template (submission stages): the artifact
236
+ fields entrants fill for THIS stage (proposal vs prototype). -->
237
+ <div v-if="stage.kind === 'submission' && templatesEnabled" class="cpub-stage-criteria">
238
+ <div class="cpub-stage-criteria-head">
239
+ <span class="cpub-form-label" style="margin: 0;">Submission form, this stage</span>
240
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addTemplateField(i)"><i class="fa-solid fa-plus"></i> Add field</button>
241
+ </div>
242
+ <p class="cpub-form-hint" style="margin: 4px 0;">Optional. Add fields entrants must fill for this stage (e.g. a proposal summary, or a repository link for a prototype round). Leave empty if entering a project is enough.</p>
243
+ <div v-for="(tf, fi) in (stage.submissionTemplate ?? [])" :key="fi" class="cpub-stage-tfield">
244
+ <div class="cpub-stage-tfield-main">
245
+ <input
246
+ :value="tf.label"
247
+ type="text"
248
+ class="cpub-form-input"
249
+ placeholder="Field label (e.g. Repository URL)"
250
+ :aria-label="`Field ${fi + 1} label`"
251
+ @input="templateFieldLabelInput(i, fi, $event)"
252
+ />
253
+ <select
254
+ :value="tf.type"
255
+ class="cpub-form-input cpub-stage-tfield-type"
256
+ :aria-label="`Field ${fi + 1} type`"
257
+ @change="setTemplateField(i, fi, { type: ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'] })"
258
+ >
259
+ <option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
260
+ </select>
261
+ <label class="cpub-stage-tfield-req">
262
+ <input
263
+ type="checkbox"
264
+ :checked="tf.required"
265
+ :aria-label="`Field ${fi + 1} required`"
266
+ @change="setTemplateField(i, fi, { required: ($event.target as HTMLInputElement).checked })"
267
+ />
268
+ <span>Required</span>
269
+ </label>
270
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove field" @click="removeTemplateField(i, fi)"><i class="fa-solid fa-xmark"></i></button>
271
+ </div>
272
+ <input
273
+ :value="tf.help ?? ''"
274
+ type="text"
275
+ class="cpub-form-input cpub-stage-tfield-help"
276
+ placeholder="Hint shown under the input (optional)"
277
+ :aria-label="`Field ${fi + 1} hint`"
278
+ @input="setTemplateField(i, fi, { help: ($event.target as HTMLInputElement).value || undefined })"
279
+ />
280
+ </div>
281
+ </div>
282
+
215
283
  <div v-if="stage.kind === 'event'" class="cpub-form-row">
216
284
  <div class="cpub-form-field">
217
285
  <label class="cpub-form-label">Location</label>
@@ -267,4 +335,12 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
267
335
  .cpub-stage-crit-row .cpub-form-input { margin: 0; }
268
336
  .cpub-stage-crit-pts { max-width: 70px; flex-shrink: 0; }
269
337
  .cpub-stage-advn { max-width: 320px; }
338
+ .cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
339
+ .cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
340
+ .cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
341
+ .cpub-stage-tfield-main .cpub-form-input { flex: 2; min-width: 140px; margin: 0; }
342
+ .cpub-stage-tfield-type { flex: 1 !important; min-width: 110px !important; max-width: 150px; }
343
+ .cpub-stage-tfield-req { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); cursor: pointer; flex-shrink: 0; }
344
+ .cpub-stage-tfield-req input { width: 13px; height: 13px; }
345
+ .cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
270
346
  </style>
@@ -17,6 +17,9 @@ export interface FeatureFlags {
17
17
  docs: boolean;
18
18
  video: boolean;
19
19
  contests: boolean;
20
+ /** Per-stage submission artifacts for multi-round contests. Default ON;
21
+ * inert until a stage defines a submissionTemplate. */
22
+ contestStageSubmissions: boolean;
20
23
  events: boolean;
21
24
  learning: boolean;
22
25
  explainers: boolean;
@@ -65,7 +68,7 @@ let hydrated = false;
65
68
  // `flags.value.X` access would crash at runtime.
66
69
  export const DEFAULT_FLAGS: FeatureFlags = {
67
70
  content: true, social: true, hubs: true, docs: true, video: true,
68
- contests: false, events: false, learning: true, explainers: true,
71
+ contests: false, contestStageSubmissions: true, events: false, learning: true, explainers: true,
69
72
  editorial: true, federation: false, admin: false, themeStudio: true, emailNotifications: false,
70
73
  publicApi: false, contentImport: true,
71
74
  layoutEngine: false,
@@ -165,6 +168,7 @@ export function useFeatures() {
165
168
  docs: computed(() => flags.value.docs),
166
169
  video: computed(() => flags.value.video),
167
170
  contests: computed(() => flags.value.contests),
171
+ contestStageSubmissions: computed(() => flags.value.contestStageSubmissions),
168
172
  events: computed(() => flags.value.events),
169
173
  learning: computed(() => flags.value.learning),
170
174
  explainers: computed(() => flags.value.explainers),
package/nuxt.config.ts CHANGED
@@ -99,6 +99,7 @@ export default defineNuxtConfig({
99
99
  docs: true,
100
100
  video: true,
101
101
  contests: false,
102
+ contestStageSubmissions: true,
102
103
  events: false,
103
104
  learning: true,
104
105
  explainers: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,17 +53,17 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/config": "0.20.0",
57
56
  "@commonpub/auth": "0.8.0",
57
+ "@commonpub/config": "0.21.0",
58
58
  "@commonpub/docs": "0.6.3",
59
+ "@commonpub/editor": "0.7.11",
59
60
  "@commonpub/protocol": "0.13.0",
60
- "@commonpub/learning": "0.5.2",
61
61
  "@commonpub/explainer": "0.7.15",
62
- "@commonpub/schema": "0.38.0",
63
- "@commonpub/server": "2.83.0",
64
- "@commonpub/theme-studio": "0.5.0",
65
- "@commonpub/ui": "0.12.1",
66
- "@commonpub/editor": "0.7.11"
62
+ "@commonpub/server": "2.84.0",
63
+ "@commonpub/schema": "0.39.0",
64
+ "@commonpub/theme-studio": "0.5.1",
65
+ "@commonpub/learning": "0.5.2",
66
+ "@commonpub/ui": "0.12.2"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@testing-library/jest-dom": "^6.9.1",
@@ -24,6 +24,7 @@ const flagMeta: Record<string, { label: string; description: string; icon: strin
24
24
  docs: { label: 'Docs', description: 'Documentation sites with versioning', icon: 'fa-solid fa-book' },
25
25
  video: { label: 'Video', description: 'Video content and categories', icon: 'fa-solid fa-video' },
26
26
  contests: { label: 'Contests', description: 'Contest system with judging', icon: 'fa-solid fa-trophy' },
27
+ contestStageSubmissions: { label: 'Contest Stage Submissions', description: 'Per-stage submission forms for multi-round contests', icon: 'fa-solid fa-file-pen' },
27
28
  learning: { label: 'Learning', description: 'Learning paths and courses', icon: 'fa-solid fa-graduation-cap' },
28
29
  explainers: { label: 'Explainers', description: 'Interactive explainer modules', icon: 'fa-solid fa-lightbulb' },
29
30
  editorial: { label: 'Editorial', description: 'Staff picks and content categories', icon: 'fa-solid fa-pen-fancy' },
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ import type { Serialized, ContestDetail, ContestEntryItem } from '@commonpub/server';
3
+ import type { ContestStage } from '@commonpub/schema';
4
+
5
+ // Entry detail: the content summary plus the entry's per-stage artifacts in a
6
+ // stage timeline (the proposal, then the prototype, ...), with scores when the
7
+ // viewer is privileged. Artifacts are server-gated: judges/owner/admin and the
8
+ // entrant get them; everyone else sees the content card only.
9
+
10
+ const route = useRoute();
11
+ const slug = route.params.slug as string;
12
+ const entryId = route.params.entryId as string;
13
+
14
+ const { data: contest } = useLazyFetch<Serialized<ContestDetail>>(`/api/contests/${slug}`);
15
+ const { data: entry, error } = useLazyFetch<Serialized<ContestEntryItem>>(`/api/contests/${slug}/entries/${entryId}`);
16
+
17
+ useSeoMeta({
18
+ title: () => `${entry.value?.contentTitle || 'Entry'}, ${contest.value?.title || 'Contest'}, ${useSiteName()}`,
19
+ });
20
+
21
+ const stages = computed<ContestStage[]>(() => {
22
+ const c = contest.value;
23
+ if (!c) return [];
24
+ return normalizeStages({
25
+ status: c.status,
26
+ startDate: c.startDate,
27
+ endDate: c.endDate,
28
+ judgingEndDate: c.judgingEndDate ?? null,
29
+ stages: c.stages,
30
+ currentStageId: c.currentStageId,
31
+ });
32
+ });
33
+
34
+ // One timeline item per stage the entry has an artifact for, in stage order
35
+ // (artifacts for stages no longer in the timeline trail at the end, just in case).
36
+ const artifactTimeline = computed(() => {
37
+ const subs = entry.value?.stageSubmissions ?? [];
38
+ if (!subs.length) return [];
39
+ const order = new Map(stages.value.map((s, i) => [s.id, i]));
40
+ return [...subs]
41
+ .sort((a, b) => (order.get(a.stageId) ?? 999) - (order.get(b.stageId) ?? 999))
42
+ .map((sub) => {
43
+ const stage = stages.value.find((s) => s.id === sub.stageId);
44
+ const template = stage?.submissionTemplate ?? [];
45
+ const known = new Set(template.map((f) => f.key));
46
+ // Label values via the template; values whose field was later removed
47
+ // from the template still render (key as the label) — never drop data.
48
+ const rows = [
49
+ ...template.filter((f) => sub.fields[f.key]).map((f) => ({ key: f.key, label: f.label, type: f.type, value: sub.fields[f.key]! })),
50
+ ...Object.entries(sub.fields).filter(([k]) => !known.has(k)).map(([k, v]) => ({ key: k, label: k, type: 'text' as const, value: v })),
51
+ ];
52
+ return { stageId: sub.stageId, stageName: stage?.name ?? sub.stageId, submittedAt: sub.submittedAt, rows };
53
+ });
54
+ });
55
+
56
+ const contentLink = computed(() =>
57
+ entry.value ? `/u/${entry.value.authorUsername}/${entry.value.contentType}/${entry.value.contentSlug}` : '#',
58
+ );
59
+
60
+ function fmtDate(iso: string): string {
61
+ return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
62
+ }
63
+ </script>
64
+
65
+ <template>
66
+ <div class="cpub-entry-detail">
67
+ <NuxtLink :to="`/contests/${slug}`" class="cpub-ed-back">
68
+ <i class="fa-solid fa-arrow-left"></i> Back to {{ contest?.title || 'contest' }}
69
+ </NuxtLink>
70
+
71
+ <div v-if="error" class="cpub-ed-empty">
72
+ <i class="fa-solid fa-circle-exclamation"></i>
73
+ <p>Entry not found.</p>
74
+ </div>
75
+ <div v-else-if="!entry" class="cpub-ed-empty"><p>Loading...</p></div>
76
+
77
+ <template v-else>
78
+ <!-- Content summary card -->
79
+ <header class="cpub-ed-head">
80
+ <div class="cpub-ed-thumb">
81
+ <img v-if="entry.contentCoverImageUrl" :src="entry.contentCoverImageUrl" :alt="entry.contentTitle" />
82
+ <i v-else class="fa-solid fa-microchip"></i>
83
+ </div>
84
+ <div class="cpub-ed-headinfo">
85
+ <h1 class="cpub-ed-title">{{ entry.contentTitle }}</h1>
86
+ <div class="cpub-ed-meta">
87
+ <NuxtLink :to="`/u/${entry.authorUsername}`" class="cpub-ed-author">
88
+ <img v-if="entry.authorAvatarUrl" :src="entry.authorAvatarUrl" :alt="entry.authorName" class="cpub-ed-av" />
89
+ <span v-else class="cpub-ed-av cpub-ed-av-init">{{ (entry.authorName || '?').charAt(0).toUpperCase() }}</span>
90
+ {{ entry.authorName }}
91
+ </NuxtLink>
92
+ <span class="cpub-ed-date">Entered {{ fmtDate(entry.submittedAt) }}</span>
93
+ <span v-if="entry.eliminated" class="cpub-ed-badge cpub-ed-out"><i class="fa-solid fa-circle-minus"></i> Not advanced</span>
94
+ <span v-else-if="entry.stageState?.some((s) => s.status === 'advanced')" class="cpub-ed-badge cpub-ed-in"><i class="fa-solid fa-circle-check"></i> Advanced</span>
95
+ <span v-if="entry.rank" class="cpub-ed-badge cpub-ed-rank">#{{ entry.rank }}</span>
96
+ <span v-if="entry.score != null" class="cpub-ed-badge cpub-ed-score">Score {{ entry.score }}</span>
97
+ </div>
98
+ <NuxtLink :to="contentLink" class="cpub-btn cpub-btn-sm" style="margin-top: 10px;">
99
+ <i class="fa-solid fa-arrow-up-right-from-square"></i> View the project
100
+ </NuxtLink>
101
+ </div>
102
+ </header>
103
+
104
+ <!-- Per-stage artifact timeline -->
105
+ <section v-if="artifactTimeline.length" class="cpub-ed-stages" aria-label="Stage submissions">
106
+ <h2 class="cpub-ed-sechead"><i class="fa-solid fa-file-pen"></i> Stage submissions</h2>
107
+ <ol class="cpub-ed-timeline">
108
+ <li v-for="item in artifactTimeline" :key="item.stageId" class="cpub-ed-stage">
109
+ <div class="cpub-ed-stagehead">
110
+ <span class="cpub-ed-stagename">{{ item.stageName }}</span>
111
+ <span class="cpub-ed-stagedate">{{ fmtDate(item.submittedAt) }}</span>
112
+ </div>
113
+ <dl class="cpub-ed-fields">
114
+ <template v-for="row in item.rows" :key="row.key">
115
+ <dt>{{ row.label }}</dt>
116
+ <dd>
117
+ <a v-if="row.type === 'url'" :href="row.value" target="_blank" rel="noopener noreferrer nofollow">{{ row.value }}</a>
118
+ <span v-else>{{ row.value }}</span>
119
+ </dd>
120
+ </template>
121
+ </dl>
122
+ </li>
123
+ </ol>
124
+ </section>
125
+ </template>
126
+ </div>
127
+ </template>
128
+
129
+ <style scoped>
130
+ .cpub-entry-detail { max-width: 800px; margin: 0 auto; padding: 32px 24px; }
131
+ .cpub-ed-back { font-size: 12px; color: var(--text-faint); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 16px; }
132
+ .cpub-ed-back:hover { color: var(--accent); }
133
+ .cpub-ed-empty { text-align: center; padding: 48px 0; color: var(--text-faint); font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
134
+ .cpub-ed-empty i { font-size: 24px; }
135
+
136
+ .cpub-ed-head { display: flex; gap: 18px; align-items: flex-start; padding: 18px; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-bottom: 22px; }
137
+ .cpub-ed-thumb { width: 160px; aspect-ratio: 4 / 3; flex-shrink: 0; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; color: var(--text-faint); font-size: 22px; overflow: hidden; }
138
+ .cpub-ed-thumb img { width: 100%; height: 100%; object-fit: cover; }
139
+ .cpub-ed-headinfo { min-width: 0; }
140
+ .cpub-ed-title { font-size: 18px; font-weight: 700; margin: 0 0 6px; line-height: 1.3; }
141
+ .cpub-ed-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
142
+ .cpub-ed-author { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); text-decoration: none; }
143
+ .cpub-ed-author:hover { color: var(--accent); }
144
+ .cpub-ed-av { width: 18px; height: 18px; border-radius: 50%; border: var(--border-width-default) solid var(--border); object-fit: cover; }
145
+ .cpub-ed-av-init { display: inline-flex; align-items: center; justify-content: center; background: var(--surface3); color: var(--text-faint); font-size: 8px; }
146
+ .cpub-ed-date { color: var(--text-faint); }
147
+ .cpub-ed-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border: var(--border-width-default) solid var(--border2); background: var(--surface2); color: var(--text-dim); }
148
+ .cpub-ed-in { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
149
+ .cpub-ed-out { color: var(--text-faint); }
150
+ .cpub-ed-rank { color: var(--yellow); border-color: var(--yellow); background: var(--yellow-bg); }
151
+ .cpub-ed-score { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
152
+
153
+ .cpub-ed-sechead { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0 0 14px; }
154
+ .cpub-ed-sechead i { color: var(--accent); }
155
+ .cpub-ed-timeline { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
156
+ .cpub-ed-stage { border: var(--border-width-default) solid var(--border); background: var(--surface); box-shadow: var(--shadow-md); }
157
+ .cpub-ed-stagehead { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: var(--border-width-default) solid var(--border); background: var(--surface2); }
158
+ .cpub-ed-stagename { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--accent); }
159
+ .cpub-ed-stagedate { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
160
+ .cpub-ed-fields { margin: 0; padding: 14px 16px; display: grid; grid-template-columns: minmax(120px, 180px) 1fr; gap: 8px 16px; }
161
+ .cpub-ed-fields dt { font-size: 11px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); }
162
+ .cpub-ed-fields dd { margin: 0; font-size: 13px; color: var(--text); line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
163
+ .cpub-ed-fields dd a { color: var(--accent); }
164
+
165
+ @media (max-width: 600px) {
166
+ .cpub-ed-head { flex-direction: column; }
167
+ .cpub-ed-thumb { width: 100%; }
168
+ .cpub-ed-fields { grid-template-columns: 1fr; gap: 2px 0; }
169
+ .cpub-ed-fields dd { margin-bottom: 8px; }
170
+ }
171
+ </style>
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Component tests for the contest entry-detail page (artifact timeline).
3
+ *
4
+ * Locks: the content summary card, the stage-ordered artifact timeline with
5
+ * template-labelled fields, url fields rendered as safe links, orphaned values
6
+ * (template field later removed) still rendering, artifact section hidden when
7
+ * the server stripped artifacts (unprivileged viewer), and an axe scan.
8
+ *
9
+ * Page uses Nuxt auto-imports (useRoute, useLazyFetch, useSeoMeta, useSiteName,
10
+ * plus the auto-imported contestStages utils) — stub them on globalThis.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+ import { render } from '@testing-library/vue';
14
+ import { defineComponent, h, ref } from 'vue';
15
+ import axe from 'axe-core';
16
+ import EntryDetailPage from '../[entryId].vue';
17
+ import { normalizeStages, currentStageId } from '../../../../../utils/contestStages';
18
+
19
+ const NuxtLink = defineComponent({
20
+ name: 'NuxtLink',
21
+ props: { to: String },
22
+ setup(props, { slots }) {
23
+ return () => h('a', { href: props.to }, slots.default?.());
24
+ },
25
+ });
26
+ const stubs = { NuxtLink };
27
+
28
+ const STAGES = [
29
+ { id: 'prop', name: 'Proposals', kind: 'submission', submissionTemplate: [
30
+ { key: 'summary', label: 'Summary', type: 'textarea', required: true },
31
+ ] },
32
+ { id: 'rev1', name: 'Screening', kind: 'review' },
33
+ { id: 'proto', name: 'Prototype', kind: 'submission', submissionTemplate: [
34
+ { key: 'repo_url', label: 'Repository URL', type: 'url', required: true },
35
+ ] },
36
+ ];
37
+
38
+ function makeContest(overrides: Record<string, unknown> = {}) {
39
+ return {
40
+ title: 'Resilient America',
41
+ status: 'active',
42
+ startDate: '2026-04-01T00:00:00.000Z',
43
+ endDate: '2026-08-01T00:00:00.000Z',
44
+ judgingEndDate: null,
45
+ stages: STAGES,
46
+ currentStageId: 'proto',
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ function makeEntry(overrides: Record<string, unknown> = {}) {
52
+ return {
53
+ id: 'e1',
54
+ contestId: 'c1',
55
+ contentId: 'ct1',
56
+ userId: 'u1',
57
+ score: 88,
58
+ rank: 2,
59
+ stageState: [{ stageId: 'rev1', status: 'advanced' }],
60
+ eliminated: false,
61
+ stageSubmissions: [
62
+ // Deliberately out of stage order — the timeline must sort by stage.
63
+ { stageId: 'proto', fields: { repo_url: 'https://github.com/x/y' }, submittedAt: '2026-07-01T12:00:00.000Z' },
64
+ { stageId: 'prop', fields: { summary: 'A mesh network.', legacy_field: 'kept' }, submittedAt: '2026-05-01T12:00:00.000Z' },
65
+ ],
66
+ submittedAt: '2026-04-20T12:00:00.000Z',
67
+ contentTitle: 'Solar Mesh Node',
68
+ contentSlug: 'solar-mesh-node',
69
+ contentType: 'project',
70
+ contentCoverImageUrl: null,
71
+ authorName: 'Ada Maker',
72
+ authorUsername: 'ada',
73
+ authorAvatarUrl: null,
74
+ };
75
+ }
76
+
77
+ let contestData: Record<string, unknown> | null = makeContest();
78
+ let entryData: Record<string, unknown> | null = makeEntry();
79
+
80
+ Object.assign(globalThis, {
81
+ useRoute: () => ({ params: { slug: 'resilient', entryId: 'e1' } }),
82
+ useLazyFetch: vi.fn((url: string) => ({
83
+ data: ref(String(url).includes('/entries/') ? entryData : contestData),
84
+ error: ref(null),
85
+ })),
86
+ useSeoMeta: () => {},
87
+ useSiteName: () => 'Test',
88
+ normalizeStages,
89
+ currentStageId,
90
+ });
91
+
92
+ function mount() {
93
+ return render(EntryDetailPage, { global: { stubs } });
94
+ }
95
+
96
+ beforeEach(() => {
97
+ contestData = makeContest();
98
+ entryData = makeEntry();
99
+ });
100
+
101
+ describe('entry detail page', () => {
102
+ it('shows the content summary with author, status badges, and a project link', () => {
103
+ const { container } = mount();
104
+ expect(container.querySelector('.cpub-ed-title')?.textContent).toBe('Solar Mesh Node');
105
+ expect(container.textContent).toContain('Ada Maker');
106
+ expect(container.textContent).toContain('Advanced');
107
+ expect(container.textContent).toContain('#2');
108
+ expect(container.textContent).toContain('Score 88');
109
+ const projectLink = Array.from(container.querySelectorAll('a')).find((a) => a.textContent?.includes('View the project'));
110
+ expect(projectLink?.getAttribute('href')).toBe('/u/ada/project/solar-mesh-node');
111
+ });
112
+
113
+ it('renders the artifact timeline in stage order with template labels', () => {
114
+ const { container } = mount();
115
+ const names = Array.from(container.querySelectorAll('.cpub-ed-stagename')).map((n) => n.textContent);
116
+ expect(names).toEqual(['Proposals', 'Prototype']); // stage order, not submit order
117
+ expect(container.textContent).toContain('Summary');
118
+ expect(container.textContent).toContain('A mesh network.');
119
+ });
120
+
121
+ it('renders url fields as hardened external links', () => {
122
+ const { container } = mount();
123
+ const link = Array.from(container.querySelectorAll('.cpub-ed-fields a')).find((a) => a.textContent === 'https://github.com/x/y');
124
+ expect(link).toBeTruthy();
125
+ expect(link!.getAttribute('rel')).toContain('noopener');
126
+ });
127
+
128
+ it('still renders values whose template field was later removed (never drop data)', () => {
129
+ const { container } = mount();
130
+ expect(container.textContent).toContain('legacy_field');
131
+ expect(container.textContent).toContain('kept');
132
+ });
133
+
134
+ it('hides the artifact section entirely when the server stripped artifacts', () => {
135
+ // The route handler `delete`s the key for unprivileged viewers — mirror that.
136
+ const stripped = makeEntry();
137
+ delete (stripped as Record<string, unknown>).stageSubmissions;
138
+ entryData = stripped;
139
+ const { container } = mount();
140
+ expect(container.querySelector('.cpub-ed-stages')).toBeNull();
141
+ // The content card still shows — the page is useful to the public.
142
+ expect(container.querySelector('.cpub-ed-title')?.textContent).toBe('Solar Mesh Node');
143
+ });
144
+
145
+ it('passes an axe scan', async () => {
146
+ const { container } = mount();
147
+ const results = await axe.run(container);
148
+ expect(results.violations).toEqual([]);
149
+ });
150
+ });
@@ -127,6 +127,27 @@ const { data: userContent } = useFetch('/api/content', {
127
127
  });
128
128
  const enteredContentIds = computed(() => new Set(entries.value.map((e) => e.contentId)));
129
129
 
130
+ // Per-stage submission artifact (proposal/prototype). When the CURRENT stage
131
+ // is a submission stage carrying a template, entrants with a live entry get
132
+ // the fill-in form on the Entries tab. Flag-gated (rule #2).
133
+ const { features } = useFeatures();
134
+ const currentSubmissionStage = computed(() => {
135
+ if (!c.value || features.value.contestStageSubmissions === false) return null;
136
+ if (c.value.status !== 'active') return null;
137
+ const source = {
138
+ status: c.value.status,
139
+ startDate: c.value.startDate,
140
+ endDate: c.value.endDate,
141
+ judgingEndDate: c.value.judgingEndDate ?? null,
142
+ stages: c.value.stages,
143
+ currentStageId: c.value.currentStageId,
144
+ };
145
+ const cid = currentStageId(source);
146
+ const stage = normalizeStages(source).find((s) => s.id === cid);
147
+ return stage && stage.kind === 'submission' && stage.submissionTemplate?.length ? stage : null;
148
+ });
149
+ const myEntries = computed(() => entries.value.filter((e) => e.userId === user.value?.id));
150
+
130
151
  // Restrict the submit picker to the contest's eligible content types (if set).
131
152
  const eligibleTypes = computed<string[]>(() => (c.value?.eligibleContentTypes as string[] | undefined) ?? []);
132
153
  const submittableContent = computed(() => {
@@ -308,6 +329,13 @@ async function withdrawEntry(entryId: string): Promise<void> {
308
329
 
309
330
  <!-- ENTRIES -->
310
331
  <div v-show="activeTab === 'entries'" id="cpub-panel-entries" role="tabpanel" aria-labelledby="cpub-tab-entries" tabindex="0">
332
+ <ContestStageSubmission
333
+ v-if="currentSubmissionStage && myEntries.length"
334
+ :contest-slug="slug"
335
+ :stage="currentSubmissionStage"
336
+ :entries="myEntries"
337
+ @saved="refreshEntries"
338
+ />
311
339
  <div v-if="c?.status === 'active'" class="cpub-entries-cta">
312
340
  <div class="cpub-entries-cta-text">
313
341
  <p class="cpub-entries-cta-title"><i class="fa-solid fa-trophy"></i> Enter this contest</p>
@@ -37,6 +37,22 @@ const currentRoundId = computed<string | null>(() => {
37
37
  return st && st.kind === 'review' ? st.id : null;
38
38
  });
39
39
 
40
+ // The artifact judges review THIS round: the nearest `submission` stage (with a
41
+ // template) preceding the current review stage — round 1 reviews the proposal,
42
+ // round 2 the prototype. Null for classic contests (no templates), which keeps
43
+ // the page byte-identical to pre-artifact behaviour.
44
+ const artifactStage = computed(() => {
45
+ const c = contest.value;
46
+ if (!c || !currentRoundId.value) return null;
47
+ const stages = normalizeStages(c);
48
+ const idx = stages.findIndex((s) => s.id === currentRoundId.value);
49
+ for (let i = idx - 1; i >= 0; i--) {
50
+ const s = stages[i]!;
51
+ if (s.kind === 'submission' && s.submissionTemplate?.length) return s;
52
+ }
53
+ return null;
54
+ });
55
+
40
56
  // Judging rubric: per-round criteria if the current review stage defines them,
41
57
  // else the contest-level rubric. Judges score each criterion (0..max); the overall
42
58
  // is the normalized weighted sum (computed server-side).
@@ -79,6 +95,16 @@ const entryList = computed(() => {
79
95
  const items = (entriesData.value?.items ?? []).filter((e) => !e.eliminated);
80
96
  return items.map((entry) => {
81
97
  const myScore = entry.judgeScores?.find((s) => s.judgeId === user.value?.id && (s.roundId ?? null) === currentRoundId.value);
98
+ // This round's artifact, labelled via the stage template (missing optional
99
+ // fields simply don't appear).
100
+ const sub = artifactStage.value
101
+ ? entry.stageSubmissions?.find((s) => s.stageId === artifactStage.value!.id) ?? null
102
+ : null;
103
+ const artifactRows = sub
104
+ ? (artifactStage.value!.submissionTemplate ?? [])
105
+ .filter((f) => sub.fields[f.key])
106
+ .map((f) => ({ key: f.key, label: f.label, type: f.type, value: sub.fields[f.key]! }))
107
+ : [];
82
108
  return {
83
109
  id: entry.id,
84
110
  contentId: entry.contentId,
@@ -92,6 +118,8 @@ const entryList = computed(() => {
92
118
  myScore: myScore?.score ?? null,
93
119
  myFeedback: myScore?.feedback ?? '',
94
120
  myCriteriaScores: myScore?.criteriaScores ?? null,
121
+ artifactRows,
122
+ hasArtifact: !!sub,
95
123
  };
96
124
  });
97
125
  });
@@ -263,6 +291,24 @@ async function submitScore(entryId: string): Promise<void> {
263
291
  <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-judge-entry-link" target="_blank">
264
292
  <i class="fa-solid fa-arrow-up-right-from-square"></i> View entry
265
293
  </NuxtLink>
294
+ <NuxtLink :to="`/contests/${slug}/entries/${entry.id}`" class="cpub-judge-entry-link" target="_blank" style="margin-left: 10px;">
295
+ <i class="fa-solid fa-file-lines"></i> All submissions
296
+ </NuxtLink>
297
+
298
+ <!-- This round's artifact (the proposal / prototype fields) -->
299
+ <div v-if="artifactStage" class="cpub-judge-artifact">
300
+ <div class="cpub-judge-artifact-head">{{ artifactStage.name }} submission</div>
301
+ <dl v-if="entry.hasArtifact && entry.artifactRows.length" class="cpub-judge-artifact-fields">
302
+ <template v-for="row in entry.artifactRows" :key="row.key">
303
+ <dt>{{ row.label }}</dt>
304
+ <dd>
305
+ <a v-if="row.type === 'url'" :href="row.value" target="_blank" rel="noopener noreferrer nofollow">{{ row.value }}</a>
306
+ <span v-else>{{ row.value }}</span>
307
+ </dd>
308
+ </template>
309
+ </dl>
310
+ <p v-else class="cpub-judge-artifact-none">Nothing submitted for this stage.</p>
311
+ </div>
266
312
  </div>
267
313
  <div class="cpub-judge-entry-scoring">
268
314
  <div v-if="entry.myScore !== null" class="cpub-judge-current-score">
@@ -364,6 +410,14 @@ async function submitScore(entryId: string): Promise<void> {
364
410
  .cpub-judge-entry-link { font-size: 10px; color: var(--accent); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; margin-top: 4px; }
365
411
  .cpub-judge-entry-link:hover { text-decoration: underline; }
366
412
 
413
+ .cpub-judge-artifact { margin-top: 10px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); }
414
+ .cpub-judge-artifact-head { font-size: 9px; font-family: var(--font-mono); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); padding: 6px 10px; border-bottom: var(--border-width-default) dashed var(--border2); }
415
+ .cpub-judge-artifact-fields { margin: 0; padding: 8px 10px; display: grid; grid-template-columns: minmax(90px, 130px) 1fr; gap: 4px 10px; }
416
+ .cpub-judge-artifact-fields dt { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); }
417
+ .cpub-judge-artifact-fields dd { margin: 0; font-size: 12px; color: var(--text); line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; }
418
+ .cpub-judge-artifact-fields dd a { color: var(--accent); }
419
+ .cpub-judge-artifact-none { font-size: 11px; color: var(--text-faint); margin: 0; padding: 8px 10px; }
420
+
367
421
  .cpub-judge-entry-scoring { display: flex; flex-direction: column; gap: 8px; flex-shrink: 0; min-width: 220px; }
368
422
  .cpub-judge-current-score { text-align: center; }
369
423
  .cpub-judge-score-label { display: block; font-family: var(--font-mono); font-size: 9px; color: var(--text-faint); text-transform: uppercase; }
@@ -0,0 +1,49 @@
1
+ import { getContestBySlug, getContestEntry, canViewContest, isContestJudge, shouldRevealScores } from '@commonpub/server';
2
+ import type { ContestEntryItem } from '@commonpub/server';
3
+
4
+ /**
5
+ * GET /api/contests/:slug/entries/:entryId
6
+ * One enriched entry for the detail / judge views. Per-stage artifacts and
7
+ * per-judge scores are privilege-gated (owner / admin / panel judge), with the
8
+ * entrant always able to see their own artifacts. Aggregate score visibility
9
+ * honours the contest's judgingVisibility, same as the entries listing.
10
+ */
11
+ export default defineEventHandler(async (event): Promise<ContestEntryItem> => {
12
+ requireFeature('contests');
13
+ const db = useDB();
14
+ const { slug, entryId } = parseParams(event, { slug: 'string', entryId: 'uuid' });
15
+
16
+ const contest = await getContestBySlug(db, slug);
17
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
18
+ const user = getOptionalUser(event);
19
+ if (!(await canViewContest(db, contest, user))) {
20
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
21
+ }
22
+
23
+ const entry = await getContestEntry(db, entryId);
24
+ if (!entry || entry.contestId !== contest.id) {
25
+ throw createError({ statusCode: 404, statusMessage: 'Entry not found' });
26
+ }
27
+
28
+ let privileged = false;
29
+ if (user) {
30
+ privileged =
31
+ user.id === contest.createdById ||
32
+ hasPermission(event, 'contest.manage') ||
33
+ (await isContestJudge(db, contest.id, user.id));
34
+ }
35
+ const isEntrant = !!user && user.id === entry.userId;
36
+ const config = useConfig();
37
+ const artifactsOn = (config.features as unknown as Record<string, boolean>).contestStageSubmissions !== false;
38
+
39
+ if (!shouldRevealScores(contest.judgingVisibility, contest.status, privileged)) {
40
+ entry.score = null;
41
+ }
42
+ if (!privileged) {
43
+ delete entry.judgeScores;
44
+ }
45
+ if (!artifactsOn || !(privileged || isEntrant)) {
46
+ delete entry.stageSubmissions;
47
+ }
48
+ return entry;
49
+ });
@@ -0,0 +1,35 @@
1
+ import { submitStageArtifact, getContestBySlug, getContestEntry, canViewContest } from '@commonpub/server';
2
+ import type { ContestStageSubmission } from '@commonpub/schema';
3
+ import { stageSubmissionSchema } from '@commonpub/schema';
4
+
5
+ /**
6
+ * PUT /api/contests/:slug/entries/:entryId/submission
7
+ * Submit (or update) the entrant's per-stage artifact for the contest's
8
+ * current submission stage. Owner-only; the server re-validates ownership,
9
+ * stage state, the cohort gate, and the fields against the stage template.
10
+ */
11
+ export default defineEventHandler(async (event): Promise<{ submitted: boolean; stageSubmissions: ContestStageSubmission[] }> => {
12
+ requireFeature('contests');
13
+ requireFeature('contestStageSubmissions');
14
+ const user = requireAuth(event);
15
+ const db = useDB();
16
+ const { slug, entryId } = parseParams(event, { slug: 'string', entryId: 'uuid' });
17
+
18
+ const contest = await getContestBySlug(db, slug);
19
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
20
+ if (!(await canViewContest(db, contest, user))) {
21
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
22
+ }
23
+ // The entry must belong to THIS contest — gates were checked against this slug.
24
+ const entry = await getContestEntry(db, entryId);
25
+ if (!entry || entry.contestId !== contest.id) {
26
+ throw createError({ statusCode: 404, statusMessage: 'Entry not found' });
27
+ }
28
+
29
+ const input = await parseBody(event, stageSubmissionSchema);
30
+ const result = await submitStageArtifact(db, entryId, input.stageId, input.fields, user.id);
31
+ if (!result.submitted) {
32
+ throw createError({ statusCode: 400, statusMessage: result.error ?? 'Could not submit' });
33
+ }
34
+ return { submitted: true, stageSubmissions: result.stageSubmissions ?? [] };
35
+ });
@@ -34,11 +34,19 @@ export default defineEventHandler(async (event): Promise<{ items: ContestEntryIt
34
34
  (await isContestJudge(db, contest.id, user.id));
35
35
  }
36
36
 
37
+ // Per-stage artifacts ride along for privileged viewers (judge/owner views)
38
+ // and for the entrant's OWN entries (pre-filling their submit form). Gated
39
+ // by the contestStageSubmissions flag (rule #2).
40
+ const config = useConfig();
41
+ const artifactsOn = (config.features as unknown as Record<string, boolean>).contestStageSubmissions !== false;
42
+
37
43
  return listContestEntries(db, contest.id, {
38
44
  limit: query.limit,
39
45
  offset: query.offset,
40
46
  orderBy: query.order,
41
47
  includeJudgeScores: privileged && query.includeJudgeScores,
48
+ includeStageSubmissions: privileged && artifactsOn,
49
+ stageSubmissionsViewerId: artifactsOn ? user?.id : undefined,
42
50
  revealScores: shouldRevealScores(contest.judgingVisibility, contest.status, privileged),
43
51
  });
44
52
  });
package/theme/base.css CHANGED
@@ -209,6 +209,12 @@
209
209
  --shadow-lg: 6px 6px 0 var(--border);
210
210
  --shadow-xl: 8px 8px 0 var(--border);
211
211
  --shadow-accent: 4px 4px 0 var(--accent);
212
+ /* Component surface shadow. Built-in themes deliberately do NOT override these
213
+ * (only --shadow-* above), so buttons/cards keep the offset-block signature.
214
+ * Theme Studio emits them per the recipe's shadowStyle so a custom theme's
215
+ * buttons/cards reflect its archetype (neumorphic relief, soft blur, etc.). */
216
+ --shadow-block: 4px 4px 0 var(--border);
217
+ --shadow-block-sm: 2px 2px 0 var(--border);
212
218
 
213
219
  /* === TRANSITIONS === */
214
220
  --transition-fast: 0.1s ease;
@@ -27,11 +27,11 @@
27
27
  .cpub-btn-primary {
28
28
  background: var(--accent);
29
29
  color: var(--color-text-inverse);
30
- box-shadow: 4px 4px 0 var(--border);
30
+ box-shadow: var(--shadow-block);
31
31
  }
32
32
 
33
33
  .cpub-btn-primary:hover {
34
- box-shadow: 2px 2px 0 var(--border);
34
+ box-shadow: var(--shadow-block-sm);
35
35
  }
36
36
 
37
37
  .cpub-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -40,9 +40,9 @@
40
40
  background: var(--secondary);
41
41
  color: var(--color-on-secondary);
42
42
  border-color: var(--secondary);
43
- box-shadow: 4px 4px 0 var(--border);
43
+ box-shadow: var(--shadow-block);
44
44
  }
45
- .cpub-btn-secondary:hover { background: var(--secondary-hover); box-shadow: 2px 2px 0 var(--border); }
45
+ .cpub-btn-secondary:hover { background: var(--secondary-hover); box-shadow: var(--shadow-block-sm); }
46
46
  .cpub-btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
47
47
 
48
48
  .cpub-btn-sm { padding: 4px 10px; font-size: 11px; min-height: 44px; }
@@ -89,7 +89,7 @@
89
89
  border: var(--border-width-default) solid var(--border);
90
90
  padding: 16px;
91
91
  margin-bottom: 12px;
92
- box-shadow: 4px 4px 0 var(--border);
92
+ box-shadow: var(--shadow-block);
93
93
  }
94
94
 
95
95
  .cpub-sb-title {
@@ -164,7 +164,7 @@
164
164
  border-color: var(--accent);
165
165
  background: var(--accent-bg);
166
166
  color: var(--accent);
167
- box-shadow: 2px 2px 0 var(--border);
167
+ box-shadow: var(--shadow-block-sm);
168
168
  }
169
169
 
170
170
  .cpub-page-ellipsis {
@@ -1,4 +1,4 @@
1
- import type { ContestStage } from '@commonpub/schema';
1
+ import type { ContestStage, ContestSubmissionTemplateField } from '@commonpub/schema';
2
2
 
3
3
  // Client mirror of the pure stage helpers in @commonpub/server `contest.ts`
4
4
  // (synthesizeStages / normalizeStages / currentStage). Deliberately duplicated —
@@ -93,6 +93,74 @@ export function withStageMoved(stages: ContestStage[], i: number, dir: -1 | 1):
93
93
  return next;
94
94
  }
95
95
 
96
+ // ─── Submission-template field operations (per-stage artifacts) ───
97
+
98
+ /** Derive a stable machine key (`^[a-z0-9_]+$`, max 40) from a human label. */
99
+ export function fieldKeyFromLabel(label: string): string {
100
+ const key = label
101
+ .toLowerCase()
102
+ .replace(/[^a-z0-9]+/g, '_')
103
+ .replace(/^_+|_+$/g, '')
104
+ .slice(0, 40);
105
+ return key || 'field';
106
+ }
107
+
108
+ export function blankTemplateField(): ContestSubmissionTemplateField {
109
+ return { key: '', label: '', type: 'text', required: false };
110
+ }
111
+
112
+ function withTemplate(stages: ContestStage[], i: number, template: ContestSubmissionTemplateField[]): ContestStage[] {
113
+ return stages.map((s, idx) => (idx === i ? { ...s, submissionTemplate: template.length ? template : undefined } : s));
114
+ }
115
+
116
+ export function withTemplateFieldAdded(stages: ContestStage[], i: number): ContestStage[] {
117
+ const cur = stages[i]?.submissionTemplate ?? [];
118
+ return withTemplate(stages, i, [...cur, blankTemplateField()]);
119
+ }
120
+
121
+ export function withTemplateFieldSet(
122
+ stages: ContestStage[],
123
+ i: number,
124
+ fi: number,
125
+ patch: Partial<ContestSubmissionTemplateField>,
126
+ ): ContestStage[] {
127
+ const cur = (stages[i]?.submissionTemplate ?? []).map((f, idx) => (idx === fi ? { ...f, ...patch } : f));
128
+ return withTemplate(stages, i, cur);
129
+ }
130
+
131
+ /**
132
+ * Set a field's label, keeping the machine key in sync while it still "tracks"
133
+ * the label (empty, or equal to the auto-key of the previous label). A key the
134
+ * organizer edited by hand is left alone — once entrants have submitted, keys
135
+ * are what artifact values hang off, so they must stay stable.
136
+ */
137
+ export function withTemplateFieldLabelChanged(
138
+ stages: ContestStage[],
139
+ i: number,
140
+ fi: number,
141
+ label: string,
142
+ ): ContestStage[] {
143
+ const field = stages[i]?.submissionTemplate?.[fi];
144
+ if (!field) return stages;
145
+ const tracksLabel = !field.key || field.key === fieldKeyFromLabel(field.label);
146
+ const patch: Partial<ContestSubmissionTemplateField> = tracksLabel
147
+ ? { label, key: fieldKeyFromLabel(label) }
148
+ : { label };
149
+ return withTemplateFieldSet(stages, i, fi, patch);
150
+ }
151
+
152
+ export function withTemplateFieldRemoved(stages: ContestStage[], i: number, fi: number): ContestStage[] {
153
+ const cur = (stages[i]?.submissionTemplate ?? []).filter((_, idx) => idx !== fi);
154
+ return withTemplate(stages, i, cur);
155
+ }
156
+
157
+ /** Human label for each template field type (for the editor dropdown). */
158
+ export const TEMPLATE_FIELD_TYPE_LABEL: Record<ContestSubmissionTemplateField['type'], string> = {
159
+ text: 'Short text',
160
+ textarea: 'Long text',
161
+ url: 'Link (URL)',
162
+ };
163
+
96
164
  /** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
97
165
  export const STAGE_KIND_ICON: Record<ContestStage['kind'], string> = {
98
166
  submission: 'fa-pen-to-square',