@commonpub/layer 0.70.1 → 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.
- package/components/contest/ContestEntries.vue +9 -1
- package/components/contest/ContestStageSubmission.vue +173 -0
- package/components/contest/ContestStagesEditor.vue +77 -1
- package/composables/useFeatures.ts +5 -1
- package/nuxt.config.ts +1 -0
- package/package.json +9 -9
- package/pages/admin/features.vue +1 -0
- package/pages/contests/[slug]/entries/[entryId].vue +171 -0
- package/pages/contests/[slug]/entries/__tests__/entryDetail.test.ts +150 -0
- package/pages/contests/[slug]/index.vue +28 -0
- package/pages/contests/[slug]/judge.vue +54 -0
- package/server/api/contests/[slug]/entries/[entryId]/index.get.ts +49 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +35 -0
- package/server/api/contests/[slug]/entries.get.ts +8 -0
- package/utils/contestStages.ts +69 -1
|
@@ -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="
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "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/editor": "0.7.11",
|
|
57
|
-
"@commonpub/docs": "0.6.3",
|
|
58
|
-
"@commonpub/config": "0.20.0",
|
|
59
|
-
"@commonpub/learning": "0.5.2",
|
|
60
56
|
"@commonpub/auth": "0.8.0",
|
|
57
|
+
"@commonpub/config": "0.21.0",
|
|
58
|
+
"@commonpub/docs": "0.6.3",
|
|
59
|
+
"@commonpub/editor": "0.7.11",
|
|
61
60
|
"@commonpub/protocol": "0.13.0",
|
|
62
|
-
"@commonpub/server": "2.83.0",
|
|
63
|
-
"@commonpub/theme-studio": "0.5.1",
|
|
64
|
-
"@commonpub/ui": "0.12.2",
|
|
65
61
|
"@commonpub/explainer": "0.7.15",
|
|
66
|
-
"@commonpub/
|
|
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",
|
package/pages/admin/features.vue
CHANGED
|
@@ -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/utils/contestStages.ts
CHANGED
|
@@ -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',
|