@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.
@@ -0,0 +1,374 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ContestStageTemplateEditor — the per-stage SUBMISSION FORM builder, extracted
4
+ * from ContestStagesEditor so the (heaviest, flag-gated) part of the stage card is
5
+ * its own cohesive unit. Operates on ONE stage's `submissionTemplate` array and
6
+ * emits the whole new array (`update:template`); the pure array ops live in
7
+ * utils/contestStages.ts. P2 added (a) one-click field PRESETS, (b) whole-form
8
+ * TEMPLATES, and (c) a block INTRO rendered above the fields on the public form.
9
+ *
10
+ * The intro is authored as markdown but STORED as `instructionsBlocks` (BlockTuple[]
11
+ * — the same shape the contest bodies use), so it renders through BlockContentRenderer
12
+ * identically in this preview and on the public submission form. Full drag-drop block
13
+ * editing of the intro is deferred (markdown + live preview is enough, same call the
14
+ * plan made for agreement terms); the storage already supports upgrading later.
15
+ *
16
+ * The agreement/address field types + the per-field PII toggle are gated behind
17
+ * `features.contestPii` (rule #2); PII *access* is always gated server-side by the
18
+ * `contest.pii` permission regardless.
19
+ */
20
+ import type { ContestSubmissionTemplateField } from '@commonpub/schema';
21
+ import { markdownToBlockTuples, blockTuplesToMarkdown, type BlockTuple } from '@commonpub/editor';
22
+ import {
23
+ availableFieldPresets,
24
+ availableFormTemplates,
25
+ templatePresetAdded,
26
+ type FieldPreset,
27
+ type SubmissionFormTemplate,
28
+ } from '../../utils/contestSubmissionTemplates';
29
+
30
+ const props = defineProps<{
31
+ template: ContestSubmissionTemplateField[];
32
+ /** This stage's block intro (rendered above the fields on the public form). */
33
+ instructions?: BlockTuple[];
34
+ }>();
35
+ const emit = defineEmits<{
36
+ 'update:template': [template: ContestSubmissionTemplateField[]];
37
+ 'update:instructions': [blocks: BlockTuple[]];
38
+ }>();
39
+
40
+ const { features } = useFeatures();
41
+ const piiEnabled = computed(() => features.value.contestPii === true);
42
+ const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
43
+ const base: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url', 'email', 'number', 'select', 'checkbox', 'date'];
44
+ if (piiEnabled.value) base.push('agreement', 'address');
45
+ return base;
46
+ });
47
+
48
+ const fieldPresets = computed(() => availableFieldPresets(piiEnabled.value));
49
+ const formTemplates = computed(() => availableFormTemplates(piiEnabled.value));
50
+
51
+ // ─── Two small dropdown menus (Add field · Use a template) ───
52
+ const menuWrap = ref<HTMLElement | null>(null);
53
+ const openMenu = ref<'add' | 'template' | null>(null);
54
+ function toggleMenu(which: 'add' | 'template'): void {
55
+ openMenu.value = openMenu.value === which ? null : which;
56
+ }
57
+ function closeMenu(): void {
58
+ openMenu.value = null;
59
+ }
60
+ function onDocPointer(e: PointerEvent): void {
61
+ if (openMenu.value && menuWrap.value && !menuWrap.value.contains(e.target as Node)) closeMenu();
62
+ }
63
+ function onDocKey(e: KeyboardEvent): void {
64
+ if (e.key === 'Escape' && openMenu.value) closeMenu();
65
+ }
66
+ onMounted(() => {
67
+ document.addEventListener('pointerdown', onDocPointer);
68
+ document.addEventListener('keydown', onDocKey);
69
+ });
70
+ onUnmounted(() => {
71
+ document.removeEventListener('pointerdown', onDocPointer);
72
+ document.removeEventListener('keydown', onDocKey);
73
+ });
74
+
75
+ function addPreset(preset: FieldPreset): void {
76
+ emit('update:template', templatePresetAdded(props.template, preset));
77
+ closeMenu();
78
+ }
79
+ function applyFormTemplate(tpl: SubmissionFormTemplate): void {
80
+ closeMenu();
81
+ // Replacing a non-empty form is destructive — confirm before clobbering.
82
+ if (props.template.length && typeof window !== 'undefined' && !window.confirm(`Replace the current ${props.template.length} field(s) with the "${tpl.label}" template?`)) {
83
+ return;
84
+ }
85
+ emit('update:template', tpl.build({ pii: piiEnabled.value }));
86
+ }
87
+
88
+ // ─── Per-field edits (delegate to the pure array ops) ───
89
+ function labelInput(fi: number, e: Event): void {
90
+ emit('update:template', templateFieldLabelChanged(props.template, fi, (e.target as HTMLInputElement).value));
91
+ }
92
+ function setField(fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
93
+ emit('update:template', templateFieldSet(props.template, fi, patch));
94
+ }
95
+ function changeType(fi: number, type: ContestSubmissionTemplateField['type']): void {
96
+ emit('update:template', templateFieldTypeChanged(props.template, fi, type));
97
+ }
98
+ function removeField(fi: number): void {
99
+ emit('update:template', templateFieldRemoved(props.template, fi));
100
+ }
101
+ function addOption(fi: number): void {
102
+ emit('update:template', templateOptionAdded(props.template, fi));
103
+ }
104
+ function setOption(fi: number, oi: number, patch: Partial<{ value: string; label: string }>): void {
105
+ emit('update:template', templateOptionSet(props.template, fi, oi, patch));
106
+ }
107
+ function removeOption(fi: number, oi: number): void {
108
+ emit('update:template', templateOptionRemoved(props.template, fi, oi));
109
+ }
110
+
111
+ // ─── Block intro (markdown ⇄ BlockTuple[]) ───
112
+ const showIntro = ref((props.instructions?.length ?? 0) > 0);
113
+ const introText = ref(blockTuplesToMarkdown(props.instructions ?? []));
114
+ const introPreview = computed<BlockTuple[]>(() => markdownToBlockTuples(introText.value));
115
+ // Re-sync only on a GENUINELY external change (a form-template reset, a reorder
116
+ // reusing this instance). Our own keystroke emits the same blocks straight back;
117
+ // re-deriving markdown from them isn't char-exact (round-trip normalisation), so a
118
+ // naive `md !== introText` resync would fight the caret. Compare BLOCKS instead:
119
+ // if the incoming blocks already match what our current text produces, it's our
120
+ // echo — skip.
121
+ watch(
122
+ () => props.instructions,
123
+ (b) => {
124
+ const incoming = JSON.stringify(b ?? []);
125
+ if (incoming === JSON.stringify(introPreview.value)) return; // our own echo
126
+ introText.value = blockTuplesToMarkdown(b ?? []);
127
+ if ((b?.length ?? 0) > 0) showIntro.value = true;
128
+ },
129
+ );
130
+ function onIntroInput(e: Event): void {
131
+ introText.value = (e.target as HTMLTextAreaElement).value;
132
+ emit('update:instructions', introText.value.trim() ? introPreview.value : []);
133
+ }
134
+ function toggleIntro(): void {
135
+ showIntro.value = !showIntro.value;
136
+ if (!showIntro.value && introText.value.trim()) {
137
+ introText.value = '';
138
+ emit('update:instructions', []);
139
+ }
140
+ }
141
+ </script>
142
+
143
+ <template>
144
+ <div class="cpub-stage-criteria">
145
+ <div class="cpub-stage-criteria-head">
146
+ <span class="cpub-form-label" style="margin: 0;">Submission form, this stage</span>
147
+ <div ref="menuWrap" class="cpub-stf-menus">
148
+ <!-- Use a template -->
149
+ <div class="cpub-stf-menu">
150
+ <button
151
+ type="button"
152
+ class="cpub-btn cpub-btn-sm"
153
+ aria-haspopup="menu"
154
+ :aria-expanded="openMenu === 'template'"
155
+ @click="toggleMenu('template')"
156
+ >
157
+ <i class="fa-solid fa-wand-magic-sparkles"></i> Use a template <i class="fa-solid fa-chevron-down"></i>
158
+ </button>
159
+ <div v-if="openMenu === 'template'" class="cpub-stf-dropdown" role="menu" aria-label="Submission form templates">
160
+ <button
161
+ v-for="tpl in formTemplates"
162
+ :key="tpl.id"
163
+ type="button"
164
+ role="menuitem"
165
+ class="cpub-stf-item"
166
+ @click="applyFormTemplate(tpl)"
167
+ >
168
+ <span class="cpub-stf-item-label">{{ tpl.label }}</span>
169
+ <span class="cpub-stf-item-desc">{{ tpl.description }}</span>
170
+ </button>
171
+ </div>
172
+ </div>
173
+ <!-- Add a field (presets) -->
174
+ <div class="cpub-stf-menu">
175
+ <button
176
+ type="button"
177
+ class="cpub-btn cpub-btn-sm"
178
+ aria-haspopup="menu"
179
+ :aria-expanded="openMenu === 'add'"
180
+ @click="toggleMenu('add')"
181
+ >
182
+ <i class="fa-solid fa-plus"></i> Add field <i class="fa-solid fa-chevron-down"></i>
183
+ </button>
184
+ <div v-if="openMenu === 'add'" class="cpub-stf-dropdown" role="menu" aria-label="Field presets">
185
+ <button
186
+ v-for="preset in fieldPresets"
187
+ :key="preset.id"
188
+ type="button"
189
+ role="menuitem"
190
+ class="cpub-stf-item cpub-stf-item-row"
191
+ @click="addPreset(preset)"
192
+ >
193
+ <i class="fa-solid cpub-stf-item-icon" :class="preset.icon"></i>
194
+ <span class="cpub-stf-item-label">{{ preset.label }}</span>
195
+ </button>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ <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>
201
+
202
+ <!-- Block intro: rich instructions shown above the fields on the public form. -->
203
+ <div class="cpub-stf-intro">
204
+ <label class="cpub-stage-tfield-req">
205
+ <input type="checkbox" :checked="showIntro" aria-label="Add instructions above the form" @change="toggleIntro" />
206
+ <span>Add instructions above the form</span>
207
+ </label>
208
+ <div v-if="showIntro" class="cpub-stf-intro-edit">
209
+ <textarea
210
+ :value="introText"
211
+ class="cpub-form-input cpub-form-textarea"
212
+ rows="3"
213
+ placeholder="Markdown instructions shown above the form (what to submit, tips, links)."
214
+ aria-label="Form instructions (markdown)"
215
+ @input="onIntroInput"
216
+ ></textarea>
217
+ <div v-if="introPreview.length" class="cpub-stf-intro-preview">
218
+ <span class="cpub-form-hint" style="margin: 0 0 4px;">Preview</span>
219
+ <BlocksBlockContentRenderer :blocks="introPreview" class="cpub-prose cpub-md" />
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ <div v-for="(tf, fi) in template" :key="fi" class="cpub-stage-tfield">
225
+ <div class="cpub-stage-tfield-main">
226
+ <input
227
+ :value="tf.label"
228
+ type="text"
229
+ class="cpub-form-input"
230
+ placeholder="Field label (e.g. Repository URL)"
231
+ :aria-label="`Field ${fi + 1} label`"
232
+ @input="labelInput(fi, $event)"
233
+ />
234
+ <select
235
+ :value="tf.type"
236
+ class="cpub-form-input cpub-stage-tfield-type"
237
+ :aria-label="`Field ${fi + 1} type`"
238
+ @change="changeType(fi, ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'])"
239
+ >
240
+ <option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
241
+ </select>
242
+ <label class="cpub-stage-tfield-req">
243
+ <input
244
+ type="checkbox"
245
+ :checked="tf.required"
246
+ :aria-label="`Field ${fi + 1} required`"
247
+ @change="setField(fi, { required: ($event.target as HTMLInputElement).checked })"
248
+ />
249
+ <span>Required</span>
250
+ </label>
251
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove field" @click="removeField(fi)"><i class="fa-solid fa-xmark"></i></button>
252
+ </div>
253
+ <input
254
+ :value="tf.help ?? ''"
255
+ type="text"
256
+ class="cpub-form-input cpub-stage-tfield-help"
257
+ placeholder="Hint shown under the input (optional)"
258
+ :aria-label="`Field ${fi + 1} hint`"
259
+ @input="setField(fi, { help: ($event.target as HTMLInputElement).value || undefined })"
260
+ />
261
+
262
+ <!-- select: the allowed options -->
263
+ <div v-if="tf.type === 'select'" class="cpub-stage-tfield-extra">
264
+ <span class="cpub-form-hint" style="margin: 0;">Choices</span>
265
+ <div v-for="(opt, oi) in (tf.options ?? [])" :key="oi" class="cpub-stage-opt-row">
266
+ <input
267
+ :value="opt.label"
268
+ type="text"
269
+ class="cpub-form-input"
270
+ placeholder="Label (shown to entrants)"
271
+ :aria-label="`Field ${fi + 1} option ${oi + 1} label`"
272
+ @input="setOption(fi, oi, { label: ($event.target as HTMLInputElement).value })"
273
+ />
274
+ <input
275
+ :value="opt.value"
276
+ type="text"
277
+ class="cpub-form-input"
278
+ placeholder="Value (stored)"
279
+ :aria-label="`Field ${fi + 1} option ${oi + 1} value`"
280
+ @input="setOption(fi, oi, { value: ($event.target as HTMLInputElement).value })"
281
+ />
282
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove option" @click="removeOption(fi, oi)"><i class="fa-solid fa-xmark"></i></button>
283
+ </div>
284
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addOption(fi)"><i class="fa-solid fa-plus"></i> Add choice</button>
285
+ </div>
286
+
287
+ <!-- agreement: terms the entrant must accept -->
288
+ <div v-if="tf.type === 'agreement'" class="cpub-stage-tfield-extra">
289
+ <textarea
290
+ :value="tf.terms ?? ''"
291
+ class="cpub-form-input cpub-form-textarea"
292
+ rows="3"
293
+ placeholder="Terms the entrant must accept (e.g. shipping the hardware to winners)"
294
+ :aria-label="`Field ${fi + 1} agreement terms`"
295
+ @input="setField(fi, { terms: ($event.target as HTMLTextAreaElement).value || undefined })"
296
+ ></textarea>
297
+ <label class="cpub-stage-tfield-req">
298
+ <input
299
+ type="checkbox"
300
+ :checked="tf.mustAccept !== false"
301
+ :aria-label="`Field ${fi + 1} must accept`"
302
+ @change="setField(fi, { mustAccept: ($event.target as HTMLInputElement).checked })"
303
+ />
304
+ <span>Must accept to submit</span>
305
+ </label>
306
+ </div>
307
+
308
+ <!-- address: structured + always personal data -->
309
+ <p v-if="tf.type === 'address'" class="cpub-form-hint" style="margin: 4px 0;">
310
+ Collected as a structured mailing address and stored as personal data. Visible only to staff with PII access and the entrant.
311
+ </p>
312
+
313
+ <!-- PII toggle (non-address, non-agreement scalar fields) -->
314
+ <label
315
+ v-if="piiEnabled && tf.type !== 'address' && tf.type !== 'agreement'"
316
+ class="cpub-stage-tfield-req cpub-stage-tfield-pii"
317
+ >
318
+ <input
319
+ type="checkbox"
320
+ :checked="tf.pii === true"
321
+ :aria-label="`Field ${fi + 1} is personal data`"
322
+ @change="setField(fi, { pii: ($event.target as HTMLInputElement).checked || undefined })"
323
+ />
324
+ <span>Personal data (store privately, hide from the public listing)</span>
325
+ </label>
326
+ </div>
327
+ </div>
328
+ </template>
329
+
330
+ <style scoped>
331
+ /* Scoped CSS doesn't cross component boundaries — the form-control + template-field
332
+ styles travel with this extracted markup (the global theme only provides
333
+ .cpub-form-label/.cpub-form-hint/.cpub-btn). */
334
+ .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); }
335
+ .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
336
+ .cpub-form-textarea { resize: vertical; }
337
+
338
+ .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; }
339
+ .cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
340
+ .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
341
+
342
+ .cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
343
+ .cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
344
+
345
+ /* Add-field / template dropdown menus */
346
+ .cpub-stf-menus { display: flex; gap: 6px; }
347
+ .cpub-stf-menu { position: relative; }
348
+ .cpub-stf-dropdown { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 220px; max-height: 320px; overflow-y: auto; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); display: flex; flex-direction: column; }
349
+ .cpub-stf-item { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; text-align: left; padding: 8px 10px; background: transparent; border: none; border-bottom: var(--border-width-default) solid var(--border2); cursor: pointer; color: var(--text); }
350
+ .cpub-stf-item:last-child { border-bottom: none; }
351
+ .cpub-stf-item:hover { background: var(--accent-bg); }
352
+ .cpub-stf-item-row { flex-direction: row; align-items: center; gap: 8px; }
353
+ .cpub-stf-item-icon { color: var(--accent); width: 16px; text-align: center; }
354
+ .cpub-stf-item-label { font-size: var(--text-sm); font-weight: 600; }
355
+ .cpub-stf-item-desc { font-size: var(--text-xs); color: var(--text-faint); line-height: 1.4; }
356
+
357
+ /* Block intro */
358
+ .cpub-stf-intro { margin: 8px 0; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); }
359
+ .cpub-stf-intro-edit { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
360
+ .cpub-stf-intro-preview { border-top: var(--border-width-default) dashed var(--border2); padding-top: 8px; }
361
+
362
+ .cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
363
+ .cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
364
+ .cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
365
+ .cpub-stage-tfield-main .cpub-form-input { flex: 2; min-width: 140px; margin: 0; }
366
+ .cpub-stage-tfield-type { flex: 1 !important; min-width: 110px !important; max-width: 150px; }
367
+ .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; }
368
+ .cpub-stage-tfield-req input { width: 13px; height: 13px; }
369
+ .cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
370
+ .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; }
371
+ .cpub-stage-opt-row { display: flex; align-items: center; gap: 6px; }
372
+ .cpub-stage-opt-row .cpub-form-input { flex: 1; min-width: 100px; margin: 0; }
373
+ .cpub-stage-tfield-pii { margin-top: 6px; }
374
+ </style>