@commonpub/layer 0.83.1 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ContestStageCard — one stage row in the ContestStagesEditor list, extracted so
4
+ * the orchestrator stays a thin list + toolbar. Presentational + intent-emitting:
5
+ * it renders one stage's fields and emits granular changes (`patch`, `move`,
6
+ * `duplicate`, `remove`, `set-current`); the parent applies them with the pure
7
+ * array ops in utils/contestStages.ts. Flag-gated extras (submission mode, the
8
+ * submission-form builder) re-derive `useFeatures()` here rather than prop-drilling.
9
+ */
10
+ import type { ContestStage } from '@commonpub/schema';
11
+ import ContestStageTemplateEditor from './ContestStageTemplateEditor.vue';
12
+
13
+ const props = defineProps<{
14
+ stage: ContestStage;
15
+ index: number;
16
+ isCurrent: boolean;
17
+ isFirst: boolean;
18
+ isLast: boolean;
19
+ }>();
20
+
21
+ const emit = defineEmits<{
22
+ patch: [patch: Partial<ContestStage>];
23
+ move: [dir: -1 | 1];
24
+ duplicate: [];
25
+ remove: [];
26
+ 'set-current': [];
27
+ }>();
28
+
29
+ const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
30
+
31
+ const { features } = useFeatures();
32
+ const templatesEnabled = computed(() => features.value.contestStageSubmissions !== false);
33
+ const proposalsEnabled = computed(() => features.value.contestProposals === true);
34
+
35
+ function setField(patch: Partial<ContestStage>): void {
36
+ emit('patch', patch);
37
+ }
38
+ function advanceCountInput(e: Event): void {
39
+ const v = (e.target as HTMLInputElement).value;
40
+ setField({ advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
41
+ }
42
+ // The template editor hands back the whole field array; mirror the stored shape
43
+ // (empty → undefined) so a cleared form drops the key entirely.
44
+ function onTemplateUpdate(template: ContestStage['submissionTemplate']): void {
45
+ setField({ submissionTemplate: template && template.length ? template : undefined });
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <li class="cpub-stage-row">
51
+ <div class="cpub-stage-row-head">
52
+ <span class="cpub-stage-num">{{ index + 1 }}</span>
53
+ <label class="cpub-stage-current" :title="isCurrent ? 'This is the current stage' : 'Mark as the current stage'">
54
+ <input
55
+ type="radio"
56
+ name="cpub-current-stage"
57
+ :checked="isCurrent"
58
+ @change="emit('set-current')"
59
+ />
60
+ <span>Current</span>
61
+ </label>
62
+ <div class="cpub-stage-row-actions">
63
+ <button type="button" class="cpub-stage-iconbtn" :disabled="isFirst" aria-label="Move up" @click="emit('move', -1)"><i class="fa-solid fa-arrow-up"></i></button>
64
+ <button type="button" class="cpub-stage-iconbtn" :disabled="isLast" aria-label="Move down" @click="emit('move', 1)"><i class="fa-solid fa-arrow-down"></i></button>
65
+ <button type="button" class="cpub-stage-iconbtn" aria-label="Duplicate stage" @click="emit('duplicate')"><i class="fa-solid fa-clone"></i></button>
66
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove stage" @click="emit('remove')"><i class="fa-solid fa-xmark"></i></button>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="cpub-form-row">
71
+ <div class="cpub-form-field" style="flex: 2;">
72
+ <label :for="`stage-name-${index}`" class="cpub-form-label">Stage name</label>
73
+ <input
74
+ :id="`stage-name-${index}`"
75
+ :value="stage.name"
76
+ type="text"
77
+ class="cpub-form-input"
78
+ placeholder="e.g. Proposals Open"
79
+ @input="setField({ name: ($event.target as HTMLInputElement).value })"
80
+ />
81
+ </div>
82
+ <div class="cpub-form-field" style="flex: 1;">
83
+ <label :for="`stage-type-${index}`" class="cpub-form-label">Type</label>
84
+ <select
85
+ :id="`stage-type-${index}`"
86
+ :value="stage.kind"
87
+ class="cpub-form-input"
88
+ @change="setField({ kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
89
+ >
90
+ <option v-for="k in KINDS" :key="k" :value="k">{{ STAGE_KIND_LABEL[k] }}</option>
91
+ </select>
92
+ </div>
93
+ </div>
94
+
95
+ <p class="cpub-stage-kind-help"><i class="fa-solid fa-circle-info"></i> {{ STAGE_KIND_HELP[stage.kind] }}</p>
96
+
97
+ <div class="cpub-form-row">
98
+ <CpubDateTimeField
99
+ label="Starts"
100
+ :model-value="stage.startsAt"
101
+ :max="stage.endsAt"
102
+ @update:model-value="setField({ startsAt: $event })"
103
+ />
104
+ <CpubDateTimeField
105
+ label="Ends (countdown target)"
106
+ :model-value="stage.endsAt"
107
+ :min="stage.startsAt"
108
+ @update:model-value="setField({ endsAt: $event })"
109
+ />
110
+ </div>
111
+
112
+ <div class="cpub-form-field">
113
+ <label :for="`stage-desc-${index}`" class="cpub-form-label">Description (optional)</label>
114
+ <input
115
+ :id="`stage-desc-${index}`"
116
+ :value="stage.description ?? ''"
117
+ type="text"
118
+ class="cpub-form-input"
119
+ placeholder="What happens, or what to submit/refine, this stage"
120
+ @input="setField({ description: ($event.target as HTMLInputElement).value || undefined })"
121
+ />
122
+ </div>
123
+
124
+ <!-- Per-round config (review stages): how many advance + the rubric -->
125
+ <div v-if="stage.kind === 'review'" class="cpub-stage-criteria">
126
+ <div class="cpub-form-field" style="margin-bottom: 10px;">
127
+ <label :for="`stage-advance-${index}`" class="cpub-form-label">Advance the top N to the next stage</label>
128
+ <input :id="`stage-advance-${index}`" :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($event)" />
129
+ </div>
130
+ <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>
131
+ <ContestCriteriaEditor
132
+ :model-value="(stage.criteria ?? [])"
133
+ label="Judging criteria, this round"
134
+ :show-total="false"
135
+ @update:model-value="setField({ criteria: ($event as ContestStage['criteria']) })"
136
+ />
137
+ </div>
138
+
139
+ <!-- Submission mode (Phase 4): attach an existing project, or collect a
140
+ form-first proposal that seeds a draft placeholder project. -->
141
+ <div v-if="stage.kind === 'submission' && proposalsEnabled" class="cpub-form-field">
142
+ <label :for="`stage-mode-${index}`" class="cpub-form-label">How entrants submit</label>
143
+ <select
144
+ :id="`stage-mode-${index}`"
145
+ :value="stage.submissionMode ?? 'attach'"
146
+ class="cpub-form-input"
147
+ @change="setField({ submissionMode: (($event.target as HTMLSelectElement).value as 'attach' | 'proposal') })"
148
+ >
149
+ <option value="attach">Attach an existing published project</option>
150
+ <option value="proposal">Proposal form (creates a draft project)</option>
151
+ </select>
152
+ <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>
153
+ </div>
154
+
155
+ <!-- Per-stage submission template (submission stages): the artifact fields
156
+ entrants fill for THIS stage (proposal vs prototype). -->
157
+ <ContestStageTemplateEditor
158
+ v-if="stage.kind === 'submission' && templatesEnabled"
159
+ :template="stage.submissionTemplate ?? []"
160
+ @update:template="onTemplateUpdate"
161
+ />
162
+
163
+ <div v-if="stage.kind === 'event'" class="cpub-form-row">
164
+ <div class="cpub-form-field">
165
+ <label :for="`stage-location-${index}`" class="cpub-form-label">Location</label>
166
+ <input :id="`stage-location-${index}`" :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField({ location: ($event.target as HTMLInputElement).value || undefined })" />
167
+ </div>
168
+ <div class="cpub-form-field">
169
+ <label :for="`stage-url-${index}`" class="cpub-form-label">Link</label>
170
+ <input :id="`stage-url-${index}`" :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField({ url: ($event.target as HTMLInputElement).value || undefined })" />
171
+ </div>
172
+ </div>
173
+ </li>
174
+ </template>
175
+
176
+ <style scoped>
177
+ /* Form controls + stage-row styles travel with this extracted markup (scoped CSS
178
+ doesn't cross component boundaries; the global theme only ships
179
+ .cpub-form-label/.cpub-form-hint/.cpub-btn). */
180
+ .cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
181
+ .cpub-form-field:last-child { margin-bottom: 0; }
182
+ .cpub-form-input { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
183
+ .cpub-form-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
184
+ .cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-3); }
185
+
186
+ .cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
187
+ .cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
188
+ .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); }
189
+ .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; }
190
+ .cpub-stage-current input { width: 13px; height: 13px; }
191
+ .cpub-stage-row-actions { margin-left: auto; display: flex; gap: 4px; }
192
+ .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; }
193
+ .cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
194
+ .cpub-stage-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
195
+ .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
196
+ .cpub-stage-kind-help { font-size: 11px; color: var(--text-faint); line-height: 1.5; margin: 0 0 4px; display: flex; gap: 6px; }
197
+ .cpub-stage-kind-help i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
198
+ .cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
199
+ .cpub-stage-advn { max-width: 320px; }
200
+ </style>
@@ -0,0 +1,191 @@
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. The agreement/address field types + the per-field PII
8
+ * toggle are gated behind `features.contestPii` (rule #2); PII *access* is always
9
+ * gated server-side by the `contest.pii` permission regardless.
10
+ */
11
+ import type { ContestSubmissionTemplateField } from '@commonpub/schema';
12
+
13
+ const props = defineProps<{
14
+ template: ContestSubmissionTemplateField[];
15
+ }>();
16
+ const emit = defineEmits<{ 'update:template': [template: ContestSubmissionTemplateField[]] }>();
17
+
18
+ const { features } = useFeatures();
19
+ const piiEnabled = computed(() => features.value.contestPii === true);
20
+ const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
21
+ const base: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url', 'email', 'number', 'select', 'checkbox', 'date'];
22
+ if (piiEnabled.value) base.push('agreement', 'address');
23
+ return base;
24
+ });
25
+
26
+ function addField(): void {
27
+ emit('update:template', templateFieldAdded(props.template));
28
+ }
29
+ function labelInput(fi: number, e: Event): void {
30
+ emit('update:template', templateFieldLabelChanged(props.template, fi, (e.target as HTMLInputElement).value));
31
+ }
32
+ function setField(fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
33
+ emit('update:template', templateFieldSet(props.template, fi, patch));
34
+ }
35
+ function changeType(fi: number, type: ContestSubmissionTemplateField['type']): void {
36
+ emit('update:template', templateFieldTypeChanged(props.template, fi, type));
37
+ }
38
+ function removeField(fi: number): void {
39
+ emit('update:template', templateFieldRemoved(props.template, fi));
40
+ }
41
+ function addOption(fi: number): void {
42
+ emit('update:template', templateOptionAdded(props.template, fi));
43
+ }
44
+ function setOption(fi: number, oi: number, patch: Partial<{ value: string; label: string }>): void {
45
+ emit('update:template', templateOptionSet(props.template, fi, oi, patch));
46
+ }
47
+ function removeOption(fi: number, oi: number): void {
48
+ emit('update:template', templateOptionRemoved(props.template, fi, oi));
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <div class="cpub-stage-criteria">
54
+ <div class="cpub-stage-criteria-head">
55
+ <span class="cpub-form-label" style="margin: 0;">Submission form, this stage</span>
56
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addField"><i class="fa-solid fa-plus"></i> Add field</button>
57
+ </div>
58
+ <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>
59
+ <div v-for="(tf, fi) in template" :key="fi" class="cpub-stage-tfield">
60
+ <div class="cpub-stage-tfield-main">
61
+ <input
62
+ :value="tf.label"
63
+ type="text"
64
+ class="cpub-form-input"
65
+ placeholder="Field label (e.g. Repository URL)"
66
+ :aria-label="`Field ${fi + 1} label`"
67
+ @input="labelInput(fi, $event)"
68
+ />
69
+ <select
70
+ :value="tf.type"
71
+ class="cpub-form-input cpub-stage-tfield-type"
72
+ :aria-label="`Field ${fi + 1} type`"
73
+ @change="changeType(fi, ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'])"
74
+ >
75
+ <option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
76
+ </select>
77
+ <label class="cpub-stage-tfield-req">
78
+ <input
79
+ type="checkbox"
80
+ :checked="tf.required"
81
+ :aria-label="`Field ${fi + 1} required`"
82
+ @change="setField(fi, { required: ($event.target as HTMLInputElement).checked })"
83
+ />
84
+ <span>Required</span>
85
+ </label>
86
+ <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>
87
+ </div>
88
+ <input
89
+ :value="tf.help ?? ''"
90
+ type="text"
91
+ class="cpub-form-input cpub-stage-tfield-help"
92
+ placeholder="Hint shown under the input (optional)"
93
+ :aria-label="`Field ${fi + 1} hint`"
94
+ @input="setField(fi, { help: ($event.target as HTMLInputElement).value || undefined })"
95
+ />
96
+
97
+ <!-- select: the allowed options -->
98
+ <div v-if="tf.type === 'select'" class="cpub-stage-tfield-extra">
99
+ <span class="cpub-form-hint" style="margin: 0;">Choices</span>
100
+ <div v-for="(opt, oi) in (tf.options ?? [])" :key="oi" class="cpub-stage-opt-row">
101
+ <input
102
+ :value="opt.label"
103
+ type="text"
104
+ class="cpub-form-input"
105
+ placeholder="Label (shown to entrants)"
106
+ :aria-label="`Field ${fi + 1} option ${oi + 1} label`"
107
+ @input="setOption(fi, oi, { label: ($event.target as HTMLInputElement).value })"
108
+ />
109
+ <input
110
+ :value="opt.value"
111
+ type="text"
112
+ class="cpub-form-input"
113
+ placeholder="Value (stored)"
114
+ :aria-label="`Field ${fi + 1} option ${oi + 1} value`"
115
+ @input="setOption(fi, oi, { value: ($event.target as HTMLInputElement).value })"
116
+ />
117
+ <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>
118
+ </div>
119
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addOption(fi)"><i class="fa-solid fa-plus"></i> Add choice</button>
120
+ </div>
121
+
122
+ <!-- agreement: terms the entrant must accept -->
123
+ <div v-if="tf.type === 'agreement'" class="cpub-stage-tfield-extra">
124
+ <textarea
125
+ :value="tf.terms ?? ''"
126
+ class="cpub-form-input cpub-form-textarea"
127
+ rows="3"
128
+ placeholder="Terms the entrant must accept (e.g. shipping the hardware to winners)"
129
+ :aria-label="`Field ${fi + 1} agreement terms`"
130
+ @input="setField(fi, { terms: ($event.target as HTMLTextAreaElement).value || undefined })"
131
+ ></textarea>
132
+ <label class="cpub-stage-tfield-req">
133
+ <input
134
+ type="checkbox"
135
+ :checked="tf.mustAccept !== false"
136
+ :aria-label="`Field ${fi + 1} must accept`"
137
+ @change="setField(fi, { mustAccept: ($event.target as HTMLInputElement).checked })"
138
+ />
139
+ <span>Must accept to submit</span>
140
+ </label>
141
+ </div>
142
+
143
+ <!-- address: structured + always personal data -->
144
+ <p v-if="tf.type === 'address'" class="cpub-form-hint" style="margin: 4px 0;">
145
+ Collected as a structured mailing address and stored as personal data. Visible only to staff with PII access and the entrant.
146
+ </p>
147
+
148
+ <!-- PII toggle (non-address, non-agreement scalar fields) -->
149
+ <label
150
+ v-if="piiEnabled && tf.type !== 'address' && tf.type !== 'agreement'"
151
+ class="cpub-stage-tfield-req cpub-stage-tfield-pii"
152
+ >
153
+ <input
154
+ type="checkbox"
155
+ :checked="tf.pii === true"
156
+ :aria-label="`Field ${fi + 1} is personal data`"
157
+ @change="setField(fi, { pii: ($event.target as HTMLInputElement).checked || undefined })"
158
+ />
159
+ <span>Personal data (store privately, hide from the public listing)</span>
160
+ </label>
161
+ </div>
162
+ </div>
163
+ </template>
164
+
165
+ <style scoped>
166
+ /* Scoped CSS doesn't cross component boundaries — the form-control + template-field
167
+ styles travel with this extracted markup (the global theme only provides
168
+ .cpub-form-label/.cpub-form-hint/.cpub-btn). */
169
+ .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); }
170
+ .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
171
+ .cpub-form-textarea { resize: vertical; }
172
+
173
+ .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; }
174
+ .cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
175
+ .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
176
+
177
+ .cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
178
+ .cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
179
+ .cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
180
+ .cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
181
+ .cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
182
+ .cpub-stage-tfield-main .cpub-form-input { flex: 2; min-width: 140px; margin: 0; }
183
+ .cpub-stage-tfield-type { flex: 1 !important; min-width: 110px !important; max-width: 150px; }
184
+ .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; }
185
+ .cpub-stage-tfield-req input { width: 13px; height: 13px; }
186
+ .cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
187
+ .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; }
188
+ .cpub-stage-opt-row { display: flex; align-items: center; gap: 6px; }
189
+ .cpub-stage-opt-row .cpub-form-input { flex: 1; min-width: 100px; margin: 0; }
190
+ .cpub-stage-tfield-pii { margin-top: 6px; }
191
+ </style>