@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.
@@ -1,10 +1,15 @@
1
1
  <script setup lang="ts">
2
- import type { ContestStage, ContestSubmissionTemplateField } from '@commonpub/schema';
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
- const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
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
- const next = stages.value.map((s, idx) => (idx === i ? { ...s, ...patch } : s));
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
- // Per-stage submission template (submission stages): what entrants fill for
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
- <li v-for="(stage, i) in stages" :key="stage.id" class="cpub-stage-row">
141
- <div class="cpub-stage-row-head">
142
- <span class="cpub-stage-num">{{ i + 1 }}</span>
143
- <label class="cpub-stage-current" :title="currentId === stage.id ? 'This is the current stage' : 'Mark as the current stage'">
144
- <input
145
- type="radio"
146
- name="cpub-current-stage"
147
- :checked="currentId === stage.id"
148
- @change="currentId = stage.id"
149
- />
150
- <span>Current</span>
151
- </label>
152
- <div class="cpub-stage-row-actions">
153
- <button type="button" class="cpub-stage-iconbtn" :disabled="i === 0" aria-label="Move up" @click="move(i, -1)"><i class="fa-solid fa-arrow-up"></i></button>
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
- /* Self-contained form-control styles (tokenised) Vue scoped CSS doesn't cross
380
- component boundaries, so this extracted editor styles its own inputs rather than
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 (2e). Follows the house block-edit contract: `content` in, `update` out,
6
- * immutable list ops. Avatars are URLs here; 2e can swap to <ImageUpload>.
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
- <input class="cpub-jedit-input" type="url" :value="j.avatarUrl ?? ''" placeholder="Avatar image URL (https://…)" :aria-label="`Person ${i + 1} avatar URL`" @input="setJudge(i, 'avatarUrl', ($event.target as HTMLInputElement).value)" />
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>