@commonpub/layer 0.83.1 → 0.84.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/ContestAdvancementPanel.vue +138 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +61 -128
- package/components/contest/ContestStageCard.vue +200 -0
- package/components/contest/ContestStageTemplateEditor.vue +191 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/composables/useContestEditor.ts +26 -1
- package/package.json +6 -6
- package/utils/contestStages.ts +80 -51
- package/utils/contestTemplates.ts +116 -0
- package/server/api/content/[id]/__tests__/versions.get.test.ts +0 -127
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { ContestStage
|
|
2
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
3
|
+
import ContestStageCard from './ContestStageCard.vue';
|
|
3
4
|
|
|
4
5
|
// Phase B1 — define an arbitrary, ordered stage timeline for a contest. Empty ⇒
|
|
5
6
|
// the contest uses the synthesized standard flow (Submissions → Judging → Results),
|
|
6
7
|
// so this editor is opt-in. `kind` drives display + how the stage maps to the coarse
|
|
7
8
|
// status; `name`/dates are arbitrary. Used by both create.vue and edit.vue.
|
|
9
|
+
//
|
|
10
|
+
// This component is the LIST orchestrator: it owns the stages array + the
|
|
11
|
+
// add/move/remove/duplicate/reset operations (pure helpers in utils/contestStages.ts)
|
|
12
|
+
// and renders one ContestStageCard per stage, applying the card's emitted intents.
|
|
8
13
|
|
|
9
14
|
const stages = defineModel<ContestStage[]>({ required: true });
|
|
10
15
|
// Local name `currentId` avoids colliding with the auto-imported `currentStageId`
|
|
@@ -18,81 +23,27 @@ const props = defineProps<{
|
|
|
18
23
|
judgingEndDate?: string | null;
|
|
19
24
|
}>();
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// datetime-local <-> ISO via the shared, offset-correct helpers (utils/datetime).
|
|
26
|
+
// Whole-array reassign on every edit (pure ops); keeps the parent v-model reactive.
|
|
24
27
|
function commit(next: ContestStage[]): void {
|
|
25
28
|
stages.value = next;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
function setField(i: number, patch: Partial<ContestStage>): void {
|
|
29
|
-
|
|
30
|
-
commit(next);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Per-round rubric (review stages) is edited by the shared ContestCriteriaEditor
|
|
34
|
-
// (same component as the contest-level Judging tab — one rubric editor, no dup).
|
|
35
|
-
function advanceCountInput(i: number, e: Event): void {
|
|
36
|
-
const v = (e.target as HTMLInputElement).value;
|
|
37
|
-
setField(i, { advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
|
|
32
|
+
commit(stages.value.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
//
|
|
41
|
-
// THIS stage's artifact (proposal vs prototype). Array ops live as pure
|
|
42
|
-
// functions in utils/contestStages.ts (unit-tested). Flag-gated (rule #2).
|
|
43
|
-
const { features } = useFeatures();
|
|
44
|
-
const templatesEnabled = computed(() => features.value.contestStageSubmissions !== false);
|
|
45
|
-
// Phase 4: the PII flag offers the agreement/address types + the per-field PII
|
|
46
|
-
// toggle; the proposals flag offers per-stage submission mode (attach vs proposal).
|
|
47
|
-
const piiEnabled = computed(() => features.value.contestPii === true);
|
|
48
|
-
const proposalsEnabled = computed(() => features.value.contestProposals === true);
|
|
49
|
-
const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
|
|
50
|
-
const base: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url', 'email', 'number', 'select', 'checkbox', 'date'];
|
|
51
|
-
if (piiEnabled.value) base.push('agreement', 'address');
|
|
52
|
-
return base;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
function addTemplateField(i: number): void {
|
|
56
|
-
commit(withTemplateFieldAdded(stages.value, i));
|
|
57
|
-
}
|
|
58
|
-
function setTemplateField(i: number, fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
|
|
59
|
-
commit(withTemplateFieldSet(stages.value, i, fi, patch));
|
|
60
|
-
}
|
|
61
|
-
function changeTemplateFieldType(i: number, fi: number, type: ContestSubmissionTemplateField['type']): void {
|
|
62
|
-
commit(withTemplateFieldTypeChanged(stages.value, i, fi, type));
|
|
63
|
-
}
|
|
64
|
-
function templateFieldLabelInput(i: number, fi: number, e: Event): void {
|
|
65
|
-
commit(withTemplateFieldLabelChanged(stages.value, i, fi, (e.target as HTMLInputElement).value));
|
|
66
|
-
}
|
|
67
|
-
function removeTemplateField(i: number, fi: number): void {
|
|
68
|
-
commit(withTemplateFieldRemoved(stages.value, i, fi));
|
|
69
|
-
}
|
|
70
|
-
// Select-option ops (pure helpers in utils/contestStages.ts).
|
|
71
|
-
function addOption(i: number, fi: number): void {
|
|
72
|
-
commit(withTemplateOptionAdded(stages.value, i, fi));
|
|
73
|
-
}
|
|
74
|
-
function setOption(i: number, fi: number, oi: number, patch: Partial<{ value: string; label: string }>): void {
|
|
75
|
-
commit(withTemplateOptionSet(stages.value, i, fi, oi, patch));
|
|
76
|
-
}
|
|
77
|
-
function removeOption(i: number, fi: number, oi: number): void {
|
|
78
|
-
commit(withTemplateOptionRemoved(stages.value, i, fi, oi));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Array operations live as pure functions in utils/contestStages.ts (unit-tested).
|
|
35
|
+
// Stage-array operations live as pure functions in utils/contestStages.ts (unit-tested).
|
|
82
36
|
function addStage(): void {
|
|
83
37
|
commit(withStageAdded(stages.value));
|
|
84
38
|
}
|
|
85
|
-
|
|
86
39
|
function duplicateStage(i: number): void {
|
|
87
40
|
commit(withStageDuplicated(stages.value, i));
|
|
88
41
|
}
|
|
89
|
-
|
|
90
42
|
function removeStage(i: number): void {
|
|
91
43
|
const removed = stages.value[i];
|
|
92
44
|
commit(withStageRemoved(stages.value, i));
|
|
93
45
|
if (removed && currentId.value === removed.id) currentId.value = null;
|
|
94
46
|
}
|
|
95
|
-
|
|
96
47
|
function move(i: number, dir: -1 | 1): void {
|
|
97
48
|
commit(withStageMoved(stages.value, i, dir));
|
|
98
49
|
}
|
|
@@ -101,7 +52,6 @@ function move(i: number, dir: -1 | 1): void {
|
|
|
101
52
|
function customize(): void {
|
|
102
53
|
commit(seedStandardStages(props));
|
|
103
54
|
}
|
|
104
|
-
|
|
105
55
|
function resetToStandard(): void {
|
|
106
56
|
currentId.value = null;
|
|
107
57
|
commit([]);
|
|
@@ -137,235 +87,20 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
137
87
|
</p>
|
|
138
88
|
|
|
139
89
|
<ol class="cpub-stage-list">
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<button type="button" class="cpub-stage-iconbtn" :disabled="i === stages.length - 1" aria-label="Move down" @click="move(i, 1)"><i class="fa-solid fa-arrow-down"></i></button>
|
|
155
|
-
<button type="button" class="cpub-stage-iconbtn" aria-label="Duplicate stage" @click="duplicateStage(i)"><i class="fa-solid fa-clone"></i></button>
|
|
156
|
-
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove stage" @click="removeStage(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
157
|
-
</div>
|
|
158
|
-
</div>
|
|
159
|
-
|
|
160
|
-
<div class="cpub-form-row">
|
|
161
|
-
<div class="cpub-form-field" style="flex: 2;">
|
|
162
|
-
<label :for="`stage-name-${i}`" class="cpub-form-label">Stage name</label>
|
|
163
|
-
<input
|
|
164
|
-
:id="`stage-name-${i}`"
|
|
165
|
-
:value="stage.name"
|
|
166
|
-
type="text"
|
|
167
|
-
class="cpub-form-input"
|
|
168
|
-
placeholder="e.g. Proposals Open"
|
|
169
|
-
@input="setField(i, { name: ($event.target as HTMLInputElement).value })"
|
|
170
|
-
/>
|
|
171
|
-
</div>
|
|
172
|
-
<div class="cpub-form-field" style="flex: 1;">
|
|
173
|
-
<label :for="`stage-type-${i}`" class="cpub-form-label">Type</label>
|
|
174
|
-
<select
|
|
175
|
-
:id="`stage-type-${i}`"
|
|
176
|
-
:value="stage.kind"
|
|
177
|
-
class="cpub-form-input"
|
|
178
|
-
@change="setField(i, { kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
|
|
179
|
-
>
|
|
180
|
-
<option v-for="k in KINDS" :key="k" :value="k">{{ STAGE_KIND_LABEL[k] }}</option>
|
|
181
|
-
</select>
|
|
182
|
-
</div>
|
|
183
|
-
</div>
|
|
184
|
-
|
|
185
|
-
<p class="cpub-stage-kind-help"><i class="fa-solid fa-circle-info"></i> {{ STAGE_KIND_HELP[stage.kind] }}</p>
|
|
186
|
-
|
|
187
|
-
<div class="cpub-form-row">
|
|
188
|
-
<CpubDateTimeField
|
|
189
|
-
label="Starts"
|
|
190
|
-
:model-value="stage.startsAt"
|
|
191
|
-
:max="stage.endsAt"
|
|
192
|
-
@update:model-value="setField(i, { startsAt: $event })"
|
|
193
|
-
/>
|
|
194
|
-
<CpubDateTimeField
|
|
195
|
-
label="Ends (countdown target)"
|
|
196
|
-
:model-value="stage.endsAt"
|
|
197
|
-
:min="stage.startsAt"
|
|
198
|
-
@update:model-value="setField(i, { endsAt: $event })"
|
|
199
|
-
/>
|
|
200
|
-
</div>
|
|
201
|
-
|
|
202
|
-
<div class="cpub-form-field">
|
|
203
|
-
<label :for="`stage-desc-${i}`" class="cpub-form-label">Description (optional)</label>
|
|
204
|
-
<input
|
|
205
|
-
:id="`stage-desc-${i}`"
|
|
206
|
-
:value="stage.description ?? ''"
|
|
207
|
-
type="text"
|
|
208
|
-
class="cpub-form-input"
|
|
209
|
-
placeholder="What happens, or what to submit/refine, this stage"
|
|
210
|
-
@input="setField(i, { description: ($event.target as HTMLInputElement).value || undefined })"
|
|
211
|
-
/>
|
|
212
|
-
</div>
|
|
213
|
-
|
|
214
|
-
<!-- Per-round config (review stages): how many advance + the rubric -->
|
|
215
|
-
<div v-if="stage.kind === 'review'" class="cpub-stage-criteria">
|
|
216
|
-
<div class="cpub-form-field" style="margin-bottom: 10px;">
|
|
217
|
-
<label :for="`stage-advance-${i}`" class="cpub-form-label">Advance the top N to the next stage</label>
|
|
218
|
-
<input :id="`stage-advance-${i}`" :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50, leave blank to decide at advance time" @input="advanceCountInput(i, $event)" />
|
|
219
|
-
</div>
|
|
220
|
-
<p class="cpub-form-hint" style="margin: 4px 0;">Optional, leave empty to use the contest’s default criteria. Set per-round criteria for multi-round contests (e.g. judge proposals on Feasibility, prototypes on Deployment readiness).</p>
|
|
221
|
-
<ContestCriteriaEditor
|
|
222
|
-
:model-value="(stage.criteria ?? [])"
|
|
223
|
-
label="Judging criteria, this round"
|
|
224
|
-
:show-total="false"
|
|
225
|
-
@update:model-value="setField(i, { criteria: ($event as ContestStage['criteria']) })"
|
|
226
|
-
/>
|
|
227
|
-
</div>
|
|
228
|
-
|
|
229
|
-
<!-- Submission mode (Phase 4): attach an existing project, or collect a
|
|
230
|
-
form-first proposal that seeds a draft placeholder project. -->
|
|
231
|
-
<div v-if="stage.kind === 'submission' && proposalsEnabled" class="cpub-form-field">
|
|
232
|
-
<label :for="`stage-mode-${i}`" class="cpub-form-label">How entrants submit</label>
|
|
233
|
-
<select
|
|
234
|
-
:id="`stage-mode-${i}`"
|
|
235
|
-
:value="stage.submissionMode ?? 'attach'"
|
|
236
|
-
class="cpub-form-input"
|
|
237
|
-
@change="setField(i, { submissionMode: (($event.target as HTMLSelectElement).value as 'attach' | 'proposal') })"
|
|
238
|
-
>
|
|
239
|
-
<option value="attach">Attach an existing published project</option>
|
|
240
|
-
<option value="proposal">Proposal form (creates a draft project)</option>
|
|
241
|
-
</select>
|
|
242
|
-
<p class="cpub-form-hint" style="margin: 4px 0;">Proposal mode lets entrants apply with just this form. The server creates a draft project they develop for later rounds.</p>
|
|
243
|
-
</div>
|
|
244
|
-
|
|
245
|
-
<!-- Per-stage submission template (submission stages): the artifact
|
|
246
|
-
fields entrants fill for THIS stage (proposal vs prototype). -->
|
|
247
|
-
<div v-if="stage.kind === 'submission' && templatesEnabled" class="cpub-stage-criteria">
|
|
248
|
-
<div class="cpub-stage-criteria-head">
|
|
249
|
-
<span class="cpub-form-label" style="margin: 0;">Submission form, this stage</span>
|
|
250
|
-
<button type="button" class="cpub-btn cpub-btn-sm" @click="addTemplateField(i)"><i class="fa-solid fa-plus"></i> Add field</button>
|
|
251
|
-
</div>
|
|
252
|
-
<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>
|
|
253
|
-
<div v-for="(tf, fi) in (stage.submissionTemplate ?? [])" :key="fi" class="cpub-stage-tfield">
|
|
254
|
-
<div class="cpub-stage-tfield-main">
|
|
255
|
-
<input
|
|
256
|
-
:value="tf.label"
|
|
257
|
-
type="text"
|
|
258
|
-
class="cpub-form-input"
|
|
259
|
-
placeholder="Field label (e.g. Repository URL)"
|
|
260
|
-
:aria-label="`Field ${fi + 1} label`"
|
|
261
|
-
@input="templateFieldLabelInput(i, fi, $event)"
|
|
262
|
-
/>
|
|
263
|
-
<select
|
|
264
|
-
:value="tf.type"
|
|
265
|
-
class="cpub-form-input cpub-stage-tfield-type"
|
|
266
|
-
:aria-label="`Field ${fi + 1} type`"
|
|
267
|
-
@change="changeTemplateFieldType(i, fi, ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'])"
|
|
268
|
-
>
|
|
269
|
-
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
|
|
270
|
-
</select>
|
|
271
|
-
<label class="cpub-stage-tfield-req">
|
|
272
|
-
<input
|
|
273
|
-
type="checkbox"
|
|
274
|
-
:checked="tf.required"
|
|
275
|
-
:aria-label="`Field ${fi + 1} required`"
|
|
276
|
-
@change="setTemplateField(i, fi, { required: ($event.target as HTMLInputElement).checked })"
|
|
277
|
-
/>
|
|
278
|
-
<span>Required</span>
|
|
279
|
-
</label>
|
|
280
|
-
<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>
|
|
281
|
-
</div>
|
|
282
|
-
<input
|
|
283
|
-
:value="tf.help ?? ''"
|
|
284
|
-
type="text"
|
|
285
|
-
class="cpub-form-input cpub-stage-tfield-help"
|
|
286
|
-
placeholder="Hint shown under the input (optional)"
|
|
287
|
-
:aria-label="`Field ${fi + 1} hint`"
|
|
288
|
-
@input="setTemplateField(i, fi, { help: ($event.target as HTMLInputElement).value || undefined })"
|
|
289
|
-
/>
|
|
290
|
-
|
|
291
|
-
<!-- select: the allowed options -->
|
|
292
|
-
<div v-if="tf.type === 'select'" class="cpub-stage-tfield-extra">
|
|
293
|
-
<span class="cpub-form-hint" style="margin: 0;">Choices</span>
|
|
294
|
-
<div v-for="(opt, oi) in (tf.options ?? [])" :key="oi" class="cpub-stage-opt-row">
|
|
295
|
-
<input
|
|
296
|
-
:value="opt.label"
|
|
297
|
-
type="text"
|
|
298
|
-
class="cpub-form-input"
|
|
299
|
-
placeholder="Label (shown to entrants)"
|
|
300
|
-
:aria-label="`Field ${fi + 1} option ${oi + 1} label`"
|
|
301
|
-
@input="setOption(i, fi, oi, { label: ($event.target as HTMLInputElement).value })"
|
|
302
|
-
/>
|
|
303
|
-
<input
|
|
304
|
-
:value="opt.value"
|
|
305
|
-
type="text"
|
|
306
|
-
class="cpub-form-input"
|
|
307
|
-
placeholder="Value (stored)"
|
|
308
|
-
:aria-label="`Field ${fi + 1} option ${oi + 1} value`"
|
|
309
|
-
@input="setOption(i, fi, oi, { value: ($event.target as HTMLInputElement).value })"
|
|
310
|
-
/>
|
|
311
|
-
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove option" @click="removeOption(i, fi, oi)"><i class="fa-solid fa-xmark"></i></button>
|
|
312
|
-
</div>
|
|
313
|
-
<button type="button" class="cpub-btn cpub-btn-sm" @click="addOption(i, fi)"><i class="fa-solid fa-plus"></i> Add choice</button>
|
|
314
|
-
</div>
|
|
315
|
-
|
|
316
|
-
<!-- agreement: terms the entrant must accept -->
|
|
317
|
-
<div v-if="tf.type === 'agreement'" class="cpub-stage-tfield-extra">
|
|
318
|
-
<textarea
|
|
319
|
-
:value="tf.terms ?? ''"
|
|
320
|
-
class="cpub-form-input cpub-form-textarea"
|
|
321
|
-
rows="3"
|
|
322
|
-
placeholder="Terms the entrant must accept (e.g. shipping the hardware to winners)"
|
|
323
|
-
:aria-label="`Field ${fi + 1} agreement terms`"
|
|
324
|
-
@input="setTemplateField(i, fi, { terms: ($event.target as HTMLTextAreaElement).value || undefined })"
|
|
325
|
-
></textarea>
|
|
326
|
-
<label class="cpub-stage-tfield-req">
|
|
327
|
-
<input
|
|
328
|
-
type="checkbox"
|
|
329
|
-
:checked="tf.mustAccept !== false"
|
|
330
|
-
:aria-label="`Field ${fi + 1} must accept`"
|
|
331
|
-
@change="setTemplateField(i, fi, { mustAccept: ($event.target as HTMLInputElement).checked })"
|
|
332
|
-
/>
|
|
333
|
-
<span>Must accept to submit</span>
|
|
334
|
-
</label>
|
|
335
|
-
</div>
|
|
336
|
-
|
|
337
|
-
<!-- address: structured + always personal data -->
|
|
338
|
-
<p v-if="tf.type === 'address'" class="cpub-form-hint" style="margin: 4px 0;">
|
|
339
|
-
Collected as a structured mailing address and stored as personal data. Visible only to staff with PII access and the entrant.
|
|
340
|
-
</p>
|
|
341
|
-
|
|
342
|
-
<!-- PII toggle (non-address, non-agreement scalar fields) -->
|
|
343
|
-
<label
|
|
344
|
-
v-if="piiEnabled && tf.type !== 'address' && tf.type !== 'agreement'"
|
|
345
|
-
class="cpub-stage-tfield-req cpub-stage-tfield-pii"
|
|
346
|
-
>
|
|
347
|
-
<input
|
|
348
|
-
type="checkbox"
|
|
349
|
-
:checked="tf.pii === true"
|
|
350
|
-
:aria-label="`Field ${fi + 1} is personal data`"
|
|
351
|
-
@change="setTemplateField(i, fi, { pii: ($event.target as HTMLInputElement).checked || undefined })"
|
|
352
|
-
/>
|
|
353
|
-
<span>Personal data (store privately, hide from the public listing)</span>
|
|
354
|
-
</label>
|
|
355
|
-
</div>
|
|
356
|
-
</div>
|
|
357
|
-
|
|
358
|
-
<div v-if="stage.kind === 'event'" class="cpub-form-row">
|
|
359
|
-
<div class="cpub-form-field">
|
|
360
|
-
<label :for="`stage-location-${i}`" class="cpub-form-label">Location</label>
|
|
361
|
-
<input :id="`stage-location-${i}`" :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField(i, { location: ($event.target as HTMLInputElement).value || undefined })" />
|
|
362
|
-
</div>
|
|
363
|
-
<div class="cpub-form-field">
|
|
364
|
-
<label :for="`stage-url-${i}`" class="cpub-form-label">Link</label>
|
|
365
|
-
<input :id="`stage-url-${i}`" :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField(i, { url: ($event.target as HTMLInputElement).value || undefined })" />
|
|
366
|
-
</div>
|
|
367
|
-
</div>
|
|
368
|
-
</li>
|
|
90
|
+
<ContestStageCard
|
|
91
|
+
v-for="(stage, i) in stages"
|
|
92
|
+
:key="stage.id"
|
|
93
|
+
:stage="stage"
|
|
94
|
+
:index="i"
|
|
95
|
+
:is-current="currentId === stage.id"
|
|
96
|
+
:is-first="i === 0"
|
|
97
|
+
:is-last="i === stages.length - 1"
|
|
98
|
+
@patch="setField(i, $event)"
|
|
99
|
+
@move="move(i, $event)"
|
|
100
|
+
@duplicate="duplicateStage(i)"
|
|
101
|
+
@remove="removeStage(i)"
|
|
102
|
+
@set-current="currentId = stage.id"
|
|
103
|
+
/>
|
|
369
104
|
</ol>
|
|
370
105
|
|
|
371
106
|
<div class="cpub-stage-toolbar">
|
|
@@ -376,47 +111,12 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
376
111
|
</template>
|
|
377
112
|
|
|
378
113
|
<style scoped>
|
|
379
|
-
/*
|
|
380
|
-
|
|
381
|
-
relying on the parent page. Mirrors the contest pages' controls. */
|
|
382
|
-
.cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
|
|
383
|
-
.cpub-form-field:last-child { margin-bottom: 0; }
|
|
384
|
-
.cpub-form-input, .cpub-form-textarea { 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); }
|
|
385
|
-
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
386
|
-
.cpub-form-textarea { resize: vertical; }
|
|
387
|
-
.cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-3); }
|
|
388
|
-
|
|
114
|
+
/* Only the list/orchestrator chrome lives here; the per-stage form controls travel
|
|
115
|
+
with ContestStageCard / ContestStageTemplateEditor (scoped CSS is per-component). */
|
|
389
116
|
.cpub-stages-standard { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
|
390
117
|
.cpub-stage-tophead { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
391
118
|
.cpub-stage-count { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); }
|
|
392
119
|
.cpub-stage-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
393
|
-
.cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
|
|
394
|
-
.cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
|
395
|
-
.cpub-stage-num { width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; font-family: var(--font-mono); background: var(--accent-bg); color: var(--accent); border: var(--border-width-default) solid var(--accent-border); }
|
|
396
|
-
.cpub-stage-current { 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; }
|
|
397
|
-
.cpub-stage-current input { width: 13px; height: 13px; }
|
|
398
|
-
.cpub-stage-row-actions { margin-left: auto; display: flex; gap: 4px; }
|
|
399
|
-
.cpub-stage-iconbtn { background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text-dim); cursor: pointer; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; }
|
|
400
|
-
.cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
401
|
-
.cpub-stage-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
|
|
402
|
-
.cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
|
|
403
120
|
.cpub-stage-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
|
404
121
|
.cpub-stage-reset { color: var(--text-faint); }
|
|
405
|
-
.cpub-stage-kind-help { font-size: 11px; color: var(--text-faint); line-height: 1.5; margin: 0 0 4px; display: flex; gap: 6px; }
|
|
406
|
-
.cpub-stage-kind-help i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
|
|
407
|
-
.cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
|
|
408
|
-
.cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
409
|
-
.cpub-stage-advn { max-width: 320px; }
|
|
410
|
-
.cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
|
|
411
|
-
.cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
|
|
412
|
-
.cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
413
|
-
.cpub-stage-tfield-main .cpub-form-input { flex: 2; min-width: 140px; margin: 0; }
|
|
414
|
-
.cpub-stage-tfield-type { flex: 1 !important; min-width: 110px !important; max-width: 150px; }
|
|
415
|
-
.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; }
|
|
416
|
-
.cpub-stage-tfield-req input { width: 13px; height: 13px; }
|
|
417
|
-
.cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
|
|
418
|
-
.cpub-stage-tfield-extra { margin-top: 6px; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); display: flex; flex-direction: column; gap: 6px; }
|
|
419
|
-
.cpub-stage-opt-row { display: flex; align-items: center; gap: 6px; }
|
|
420
|
-
.cpub-stage-opt-row .cpub-form-input { flex: 1; min-width: 100px; margin: 0; }
|
|
421
|
-
.cpub-stage-tfield-pii { margin-top: 6px; }
|
|
422
122
|
</style>
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { ref, computed, watch, nextTick, type Ref, type ComputedRef } from 'vue';
|
|
17
17
|
import type { ContestStage } from '@commonpub/schema';
|
|
18
|
+
import type { ContestTemplateSeed } from '../utils/contestTemplates';
|
|
18
19
|
|
|
19
20
|
export type ContestFormat = 'markdown' | 'html';
|
|
20
21
|
export type ContestVisibility = 'public' | 'unlisted' | 'private';
|
|
@@ -133,6 +134,9 @@ export interface UseContestEditor {
|
|
|
133
134
|
prizeLabel: (prize: ContestPrizeRow) => string;
|
|
134
135
|
// lifecycle
|
|
135
136
|
hydrate: (c: ContestEditorSource) => void;
|
|
137
|
+
/** Seed a starter template (create mode) into the stage/rubric/body refs without
|
|
138
|
+
* marking the form dirty. Only the provided fields are applied. */
|
|
139
|
+
applyTemplate: (seed: ContestTemplateSeed) => void;
|
|
136
140
|
buildPayload: () => Record<string, unknown>;
|
|
137
141
|
/** Persist the form. `silent` (autosave) skips the success toast + navigation +
|
|
138
142
|
* refresh, renames in place via `onRenamed`, and rethrows on failure so the
|
|
@@ -279,6 +283,27 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
|
|
|
279
283
|
void nextTick(() => { hydrating = false; });
|
|
280
284
|
}
|
|
281
285
|
|
|
286
|
+
// Seed a starter template into the stage/rubric/body refs (create mode). Guarded
|
|
287
|
+
// by `hydrating` so an untouched template doesn't flip `formDirty` — a freshly
|
|
288
|
+
// seeded create page should read "no unsaved changes". The orchestrator reseeds
|
|
289
|
+
// the body block editors afterward (their own `syncingBodies` guard suppresses the
|
|
290
|
+
// write-back), so seeding the body refs here is enough.
|
|
291
|
+
function applyTemplate(seed: ContestTemplateSeed): void {
|
|
292
|
+
hydrating = true;
|
|
293
|
+
stages.value = seed.stages;
|
|
294
|
+
currentStageId.value = seed.currentStageId;
|
|
295
|
+
criteria.value = seed.judgingCriteria.map((cr) => ({
|
|
296
|
+
label: cr.label,
|
|
297
|
+
weight: cr.weight ?? undefined,
|
|
298
|
+
description: cr.description ?? undefined,
|
|
299
|
+
}));
|
|
300
|
+
descriptionBlocks.value = seed.descriptionBlocks;
|
|
301
|
+
rulesBlocks.value = seed.rulesBlocks;
|
|
302
|
+
prizesBlocks.value = seed.prizesBlocks;
|
|
303
|
+
formDirty.value = false;
|
|
304
|
+
void nextTick(() => { hydrating = false; });
|
|
305
|
+
}
|
|
306
|
+
|
|
282
307
|
function buildPayload(): Record<string, unknown> {
|
|
283
308
|
const prizeData = prizes.value
|
|
284
309
|
.filter((p) => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
@@ -383,6 +408,6 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
|
|
|
383
408
|
visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId,
|
|
384
409
|
saving, formDirty, dateError, canSubmit,
|
|
385
410
|
slugify: slugifyContest, toggleType, toggleRole, addPrize, removePrize, prizeLabel,
|
|
386
|
-
hydrate, buildPayload, save,
|
|
411
|
+
hydrate, applyTemplate, buildPayload, save,
|
|
387
412
|
};
|
|
388
413
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -55,16 +55,16 @@
|
|
|
55
55
|
"vue-router": "^4.3.0",
|
|
56
56
|
"zod": "^4.3.6",
|
|
57
57
|
"@commonpub/config": "0.23.0",
|
|
58
|
+
"@commonpub/editor": "0.8.0",
|
|
59
|
+
"@commonpub/explainer": "0.8.0",
|
|
60
|
+
"@commonpub/learning": "0.5.2",
|
|
58
61
|
"@commonpub/protocol": "0.14.0",
|
|
59
|
-
"@commonpub/
|
|
62
|
+
"@commonpub/server": "2.90.0",
|
|
60
63
|
"@commonpub/docs": "0.6.3",
|
|
61
64
|
"@commonpub/schema": "0.46.0",
|
|
62
65
|
"@commonpub/theme-studio": "0.6.1",
|
|
63
|
-
"@commonpub/learning": "0.5.2",
|
|
64
|
-
"@commonpub/server": "2.90.0",
|
|
65
|
-
"@commonpub/editor": "0.8.0",
|
|
66
66
|
"@commonpub/ui": "0.13.1",
|
|
67
|
-
"@commonpub/
|
|
67
|
+
"@commonpub/auth": "0.8.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@testing-library/jest-dom": "^6.9.1",
|