@commonpub/layer 0.83.2 → 0.85.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/ContestBannerAdjust.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +94 -132
- package/components/contest/ContestHero.vue +5 -1
- package/components/contest/ContestProposalForm.vue +3 -0
- package/components/contest/ContestStageCard.vue +207 -0
- package/components/contest/ContestStageSubmission.vue +3 -0
- package/components/contest/ContestStageTemplateEditor.vue +374 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/components/contest/blocks/JudgesShowcaseBlock.vue +113 -6
- package/composables/useContestEditor.ts +40 -4
- package/package.json +9 -9
- package/pages/contests/[slug]/index.vue +4 -1
- package/pages/contests/index.vue +1 -0
- package/utils/contestBlocks.ts +10 -0
- package/utils/contestBody.ts +3 -3
- package/utils/contestImage.ts +35 -0
- package/utils/contestStages.ts +80 -51
- package/utils/contestSubmissionTemplates.ts +165 -0
- package/utils/contestTemplates.ts +119 -0
|
@@ -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>
|
|
@@ -2,14 +2,29 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Edit component for the `judgesShowcase` contest block (avatar + name + title +
|
|
4
4
|
* bio cards). Provided to BlockCanvas via BLOCK_COMPONENTS_KEY by the contest
|
|
5
|
-
* editor
|
|
6
|
-
* immutable list ops.
|
|
5
|
+
* editor. Follows the house block-edit contract: `content` in, `update` out,
|
|
6
|
+
* immutable list ops.
|
|
7
|
+
*
|
|
8
|
+
* P6 de-friction: avatars now upload via the contest editor's UPLOAD_HANDLER_KEY
|
|
9
|
+
* (URL still accepted), rows reorder, and "Import panel judges" seeds rows from
|
|
10
|
+
* the real scoring panel (CONTEST_JUDGES_KEY) in one click. The Judges Showcase
|
|
11
|
+
* is the curated PUBLIC face (custom photos/titles); the scoring panel (People
|
|
12
|
+
* rail) is the real accounts who score — two distinct concepts, hence the note.
|
|
7
13
|
*/
|
|
14
|
+
import { inject, ref } from 'vue';
|
|
15
|
+
import { UPLOAD_HANDLER_KEY } from '@commonpub/editor/vue';
|
|
16
|
+
import { CONTEST_JUDGES_KEY } from '../../../utils/contestBlocks';
|
|
8
17
|
import type { JudgeShowcaseEntry, JudgesShowcaseContent } from '../../../types/contestBlocks';
|
|
9
18
|
|
|
10
19
|
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
11
20
|
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
12
21
|
|
|
22
|
+
const uploadHandler = inject(UPLOAD_HANDLER_KEY, undefined);
|
|
23
|
+
const loadPanelJudges = inject(CONTEST_JUDGES_KEY, null);
|
|
24
|
+
const uploadingIndex = ref<number | null>(null);
|
|
25
|
+
const importing = ref(false);
|
|
26
|
+
const importNote = ref('');
|
|
27
|
+
|
|
13
28
|
const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
|
|
14
29
|
const judges = computed<JudgeShowcaseEntry[]>(() =>
|
|
15
30
|
Array.isArray(props.content.judges) ? (props.content.judges as JudgeShowcaseEntry[]) : [],
|
|
@@ -30,6 +45,50 @@ function setJudge(i: number, field: keyof JudgeShowcaseEntry, v: string): void {
|
|
|
30
45
|
function removeJudge(i: number): void {
|
|
31
46
|
commit({ judges: judges.value.filter((_, idx) => idx !== i) });
|
|
32
47
|
}
|
|
48
|
+
function moveJudge(i: number, dir: -1 | 1): void {
|
|
49
|
+
const j = i + dir;
|
|
50
|
+
if (j < 0 || j >= judges.value.length) return;
|
|
51
|
+
const next = [...judges.value];
|
|
52
|
+
[next[i], next[j]] = [next[j]!, next[i]!];
|
|
53
|
+
commit({ judges: next });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function onFile(i: number, event: Event): Promise<void> {
|
|
57
|
+
const input = event.target as HTMLInputElement;
|
|
58
|
+
const file = input.files?.[0];
|
|
59
|
+
input.value = '';
|
|
60
|
+
if (!file || !uploadHandler) return;
|
|
61
|
+
uploadingIndex.value = i;
|
|
62
|
+
try {
|
|
63
|
+
const res = await uploadHandler(file);
|
|
64
|
+
setJudge(i, 'avatarUrl', res.url);
|
|
65
|
+
} finally {
|
|
66
|
+
uploadingIndex.value = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function importPanelJudges(): Promise<void> {
|
|
71
|
+
if (!loadPanelJudges || importing.value) return;
|
|
72
|
+
importing.value = true;
|
|
73
|
+
importNote.value = '';
|
|
74
|
+
try {
|
|
75
|
+
const panel = await loadPanelJudges();
|
|
76
|
+
const have = new Set(judges.value.map((j) => (j.name ?? '').trim().toLowerCase()).filter(Boolean));
|
|
77
|
+
const additions: JudgeShowcaseEntry[] = panel
|
|
78
|
+
.filter((p) => p.name.trim() && !have.has(p.name.trim().toLowerCase()))
|
|
79
|
+
.map((p) => ({ name: p.name, avatarUrl: p.avatarUrl, title: p.title, link: p.link }));
|
|
80
|
+
if (!additions.length) {
|
|
81
|
+
importNote.value = panel.length ? 'All panel judges are already shown.' : 'No panel judges to import yet.';
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
commit({ judges: [...judges.value, ...additions] });
|
|
85
|
+
importNote.value = `Imported ${additions.length} judge${additions.length === 1 ? '' : 's'}. Add photos and titles below.`;
|
|
86
|
+
} catch {
|
|
87
|
+
importNote.value = 'Could not load the judges panel.';
|
|
88
|
+
} finally {
|
|
89
|
+
importing.value = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
33
92
|
</script>
|
|
34
93
|
|
|
35
94
|
<template>
|
|
@@ -38,10 +97,25 @@ function removeJudge(i: number): void {
|
|
|
38
97
|
<div class="cpub-jedit-icon"><i class="fa-solid fa-user-group"></i></div>
|
|
39
98
|
<span class="cpub-jedit-title">Judges Showcase</span>
|
|
40
99
|
<span class="cpub-jedit-count">{{ judges.length }} {{ judges.length === 1 ? 'person' : 'people' }}</span>
|
|
100
|
+
<button
|
|
101
|
+
v-if="loadPanelJudges"
|
|
102
|
+
type="button"
|
|
103
|
+
class="cpub-jedit-add"
|
|
104
|
+
:disabled="importing"
|
|
105
|
+
@click="importPanelJudges"
|
|
106
|
+
>
|
|
107
|
+
<i class="fa-solid fa-user-plus"></i> {{ importing ? 'Importing...' : 'Import panel judges' }}
|
|
108
|
+
</button>
|
|
41
109
|
<button type="button" class="cpub-jedit-add" @click="addJudge"><i class="fa-solid fa-plus"></i> Add person</button>
|
|
42
110
|
</div>
|
|
43
111
|
|
|
44
112
|
<div class="cpub-jedit-body">
|
|
113
|
+
<p class="cpub-jedit-explain">
|
|
114
|
+
<i class="fa-solid fa-circle-info"></i>
|
|
115
|
+
These are the curated public faces (custom photos and titles). The scoring panel, who actually
|
|
116
|
+
rate entries, is managed under People. Use Import panel judges to start from that list.
|
|
117
|
+
</p>
|
|
118
|
+
|
|
45
119
|
<input
|
|
46
120
|
class="cpub-jedit-input cpub-jedit-heading"
|
|
47
121
|
type="text"
|
|
@@ -51,13 +125,30 @@ function removeJudge(i: number): void {
|
|
|
51
125
|
@input="setHeading(($event.target as HTMLInputElement).value)"
|
|
52
126
|
/>
|
|
53
127
|
|
|
128
|
+
<p v-if="importNote" class="cpub-jedit-note" role="status">{{ importNote }}</p>
|
|
129
|
+
|
|
54
130
|
<div v-for="(j, i) in judges" :key="i" class="cpub-jedit-row">
|
|
55
131
|
<div class="cpub-jedit-row-main">
|
|
56
132
|
<input class="cpub-jedit-input" type="text" :value="j.name" placeholder="Name" :aria-label="`Person ${i + 1} name`" @input="setJudge(i, 'name', ($event.target as HTMLInputElement).value)" />
|
|
57
133
|
<input class="cpub-jedit-input" type="text" :value="j.title ?? ''" placeholder="Title / affiliation" :aria-label="`Person ${i + 1} title`" @input="setJudge(i, 'title', ($event.target as HTMLInputElement).value)" />
|
|
134
|
+
<button type="button" class="cpub-jedit-iconbtn" :disabled="i === 0" :aria-label="`Move person ${i + 1} up`" @click="moveJudge(i, -1)"><i class="fa-solid fa-arrow-up"></i></button>
|
|
135
|
+
<button type="button" class="cpub-jedit-iconbtn" :disabled="i === judges.length - 1" :aria-label="`Move person ${i + 1} down`" @click="moveJudge(i, 1)"><i class="fa-solid fa-arrow-down"></i></button>
|
|
58
136
|
<button type="button" class="cpub-jedit-remove" :aria-label="`Remove person ${i + 1}`" @click="removeJudge(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
59
137
|
</div>
|
|
60
|
-
|
|
138
|
+
|
|
139
|
+
<div class="cpub-jedit-avatar-row">
|
|
140
|
+
<span class="cpub-jedit-avatar-prev">
|
|
141
|
+
<img v-if="j.avatarUrl" :src="j.avatarUrl" :alt="`${j.name || 'Judge'} photo`" />
|
|
142
|
+
<i v-else class="fa-solid fa-user"></i>
|
|
143
|
+
</span>
|
|
144
|
+
<input class="cpub-jedit-input" type="url" :value="j.avatarUrl ?? ''" placeholder="Photo URL (https://…)" :aria-label="`Person ${i + 1} photo URL`" @input="setJudge(i, 'avatarUrl', ($event.target as HTMLInputElement).value)" />
|
|
145
|
+
<label v-if="uploadHandler" class="cpub-jedit-upload" :class="{ 'cpub-jedit-upload-busy': uploadingIndex === i }">
|
|
146
|
+
<i class="fa-solid" :class="uploadingIndex === i ? 'fa-spinner fa-spin' : 'fa-arrow-up-from-bracket'"></i>
|
|
147
|
+
<span>{{ uploadingIndex === i ? 'Uploading' : 'Upload' }}</span>
|
|
148
|
+
<input type="file" accept="image/*" class="cpub-jedit-file" :aria-label="`Upload photo for person ${i + 1}`" @change="onFile(i, $event)" />
|
|
149
|
+
</label>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
61
152
|
<input class="cpub-jedit-input" type="url" :value="j.link ?? ''" placeholder="Profile / link (https://…, optional)" :aria-label="`Person ${i + 1} link`" @input="setJudge(i, 'link', ($event.target as HTMLInputElement).value)" />
|
|
62
153
|
<textarea class="cpub-jedit-input cpub-jedit-bio" rows="2" :value="j.bio ?? ''" placeholder="Short bio (optional)" :aria-label="`Person ${i + 1} bio`" @input="setJudge(i, 'bio', ($event.target as HTMLTextAreaElement).value)" />
|
|
63
154
|
</div>
|
|
@@ -71,14 +162,18 @@ function removeJudge(i: number): void {
|
|
|
71
162
|
|
|
72
163
|
<style scoped>
|
|
73
164
|
.cpub-jedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
74
|
-
.cpub-jedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
|
|
165
|
+
.cpub-jedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); flex-wrap: wrap; }
|
|
75
166
|
.cpub-jedit-icon { font-size: 12px; color: var(--accent); }
|
|
76
167
|
.cpub-jedit-title { font-size: 12px; font-weight: 600; }
|
|
77
168
|
.cpub-jedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
|
|
78
169
|
.cpub-jedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
|
79
|
-
.cpub-jedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
170
|
+
.cpub-jedit-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
|
|
171
|
+
.cpub-jedit-add:disabled { opacity: .5; cursor: default; }
|
|
80
172
|
|
|
81
173
|
.cpub-jedit-body { padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
174
|
+
.cpub-jedit-explain { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.5; display: flex; gap: 6px; }
|
|
175
|
+
.cpub-jedit-explain i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
|
|
176
|
+
.cpub-jedit-note { margin: 0; font-size: 11px; color: var(--accent); font-family: var(--font-mono); }
|
|
82
177
|
.cpub-jedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
|
|
83
178
|
.cpub-jedit-input:focus { border-color: var(--accent); }
|
|
84
179
|
.cpub-jedit-input::placeholder { color: var(--text-faint); }
|
|
@@ -86,11 +181,23 @@ function removeJudge(i: number): void {
|
|
|
86
181
|
.cpub-jedit-bio { resize: vertical; font-family: inherit; }
|
|
87
182
|
|
|
88
183
|
.cpub-jedit-row { border: var(--border-width-default) dashed var(--border2); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
89
|
-
.cpub-jedit-row-main { display: flex; gap: 6px; }
|
|
184
|
+
.cpub-jedit-row-main { display: flex; gap: 6px; align-items: center; }
|
|
90
185
|
.cpub-jedit-row-main .cpub-jedit-input { flex: 1; }
|
|
186
|
+
.cpub-jedit-iconbtn { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; width: 26px; height: 26px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
|
|
187
|
+
.cpub-jedit-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
188
|
+
.cpub-jedit-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
|
|
91
189
|
.cpub-jedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
|
|
92
190
|
.cpub-jedit-remove:hover { border-color: var(--red-border); color: var(--red); }
|
|
93
191
|
|
|
192
|
+
.cpub-jedit-avatar-row { display: flex; gap: 6px; align-items: center; }
|
|
193
|
+
.cpub-jedit-avatar-row .cpub-jedit-input { flex: 1; }
|
|
194
|
+
.cpub-jedit-avatar-prev { width: 30px; height: 30px; flex-shrink: 0; border: var(--border-width-default) solid var(--border); background: var(--surface2); display: inline-flex; align-items: center; justify-content: center; color: var(--text-faint); font-size: 12px; overflow: hidden; }
|
|
195
|
+
.cpub-jedit-avatar-prev img { width: 100%; height: 100%; object-fit: cover; }
|
|
196
|
+
.cpub-jedit-upload { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .04em; padding: 6px 8px; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; flex-shrink: 0; white-space: nowrap; }
|
|
197
|
+
.cpub-jedit-upload:hover { border-color: var(--accent); color: var(--accent); }
|
|
198
|
+
.cpub-jedit-upload-busy { opacity: .7; cursor: default; }
|
|
199
|
+
.cpub-jedit-file { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
200
|
+
|
|
94
201
|
.cpub-jedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
|
|
95
202
|
.cpub-jedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
96
203
|
</style>
|