@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.
@@ -14,7 +14,8 @@
14
14
  * round-trip bug and no per-field re-conversion at save (the Phase 1 datetime fix).
15
15
  */
16
16
  import { ref, computed, watch, nextTick, type Ref, type ComputedRef } from 'vue';
17
- import type { ContestStage } from '@commonpub/schema';
17
+ import type { ContestStage, ContestImageMeta } from '@commonpub/schema';
18
+ import type { ContestTemplateSeed } from '../utils/contestTemplates';
18
19
 
19
20
  export type ContestFormat = 'markdown' | 'html';
20
21
  export type ContestVisibility = 'public' | 'unlisted' | 'private';
@@ -51,6 +52,8 @@ export interface ContestEditorSource {
51
52
  prizesDescriptionFormat?: string | null;
52
53
  bannerUrl?: string | null;
53
54
  coverImageUrl?: string | null;
55
+ bannerMeta?: ContestImageMeta | null;
56
+ coverMeta?: ContestImageMeta | null;
54
57
  startDate?: string | null;
55
58
  endDate?: string | null;
56
59
  judgingEndDate?: string | null;
@@ -104,6 +107,8 @@ export interface UseContestEditor {
104
107
  prizesDescriptionFormat: Ref<ContestFormat>;
105
108
  bannerUrl: Ref<string>;
106
109
  coverImageUrl: Ref<string>;
110
+ bannerMeta: Ref<ContestImageMeta | null>;
111
+ coverMeta: Ref<ContestImageMeta | null>;
107
112
  startDate: Ref<string>;
108
113
  endDate: Ref<string>;
109
114
  judgingEndDate: Ref<string>;
@@ -133,6 +138,9 @@ export interface UseContestEditor {
133
138
  prizeLabel: (prize: ContestPrizeRow) => string;
134
139
  // lifecycle
135
140
  hydrate: (c: ContestEditorSource) => void;
141
+ /** Seed a starter template (create mode) into the stage/rubric/body refs without
142
+ * marking the form dirty. Only the provided fields are applied. */
143
+ applyTemplate: (seed: ContestTemplateSeed) => void;
136
144
  buildPayload: () => Record<string, unknown>;
137
145
  /** Persist the form. `silent` (autosave) skips the success toast + navigation +
138
146
  * refresh, renames in place via `onRenamed`, and rethrows on failure so the
@@ -168,6 +176,8 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
168
176
  const prizesDescriptionFormat = ref<ContestFormat>('markdown');
169
177
  const bannerUrl = ref('');
170
178
  const coverImageUrl = ref('');
179
+ const bannerMeta = ref<ContestImageMeta | null>(null);
180
+ const coverMeta = ref<ContestImageMeta | null>(null);
171
181
  const startDate = ref('');
172
182
  const endDate = ref('');
173
183
  const judgingEndDate = ref('');
@@ -248,6 +258,8 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
248
258
  prizesDescriptionFormat.value = asFormat(c.prizesDescriptionFormat);
249
259
  bannerUrl.value = c.bannerUrl ?? '';
250
260
  coverImageUrl.value = c.coverImageUrl ?? '';
261
+ bannerMeta.value = c.bannerMeta ?? null;
262
+ coverMeta.value = c.coverMeta ?? null;
251
263
  // ISO instants stored verbatim; CpubDateTimeField renders them in local time.
252
264
  startDate.value = c.startDate ?? '';
253
265
  endDate.value = c.endDate ?? '';
@@ -279,6 +291,27 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
279
291
  void nextTick(() => { hydrating = false; });
280
292
  }
281
293
 
294
+ // Seed a starter template into the stage/rubric/body refs (create mode). Guarded
295
+ // by `hydrating` so an untouched template doesn't flip `formDirty` — a freshly
296
+ // seeded create page should read "no unsaved changes". The orchestrator reseeds
297
+ // the body block editors afterward (their own `syncingBodies` guard suppresses the
298
+ // write-back), so seeding the body refs here is enough.
299
+ function applyTemplate(seed: ContestTemplateSeed): void {
300
+ hydrating = true;
301
+ stages.value = seed.stages;
302
+ currentStageId.value = seed.currentStageId;
303
+ criteria.value = seed.judgingCriteria.map((cr) => ({
304
+ label: cr.label,
305
+ weight: cr.weight ?? undefined,
306
+ description: cr.description ?? undefined,
307
+ }));
308
+ descriptionBlocks.value = seed.descriptionBlocks;
309
+ rulesBlocks.value = seed.rulesBlocks;
310
+ prizesBlocks.value = seed.prizesBlocks;
311
+ formDirty.value = false;
312
+ void nextTick(() => { hydrating = false; });
313
+ }
314
+
282
315
  function buildPayload(): Record<string, unknown> {
283
316
  const prizeData = prizes.value
284
317
  .filter((p) => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
@@ -310,6 +343,9 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
310
343
  prizesDescriptionFormat: prizesDescriptionFormat.value,
311
344
  bannerUrl: bannerUrl.value || undefined,
312
345
  coverImageUrl: coverImageUrl.value || undefined,
346
+ // Clear the framing when the image is removed; else send it (or leave as-is).
347
+ bannerMeta: bannerUrl.value ? (bannerMeta.value ?? undefined) : null,
348
+ coverMeta: coverImageUrl.value ? (coverMeta.value ?? undefined) : null,
313
349
  startDate: startDate.value || undefined,
314
350
  endDate: endDate.value || undefined,
315
351
  judgingEndDate: judgingEndDate.value || undefined,
@@ -364,7 +400,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
364
400
  // Any post-hydration edit flips the dirty flag (drives the topbar "unsaved" cue).
365
401
  watch(
366
402
  [title, slugInput, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks, rules,
367
- descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate, endDate,
403
+ descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, startDate, endDate,
368
404
  judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
369
405
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId],
370
406
  () => { if (!hydrating) formDirty.value = true; },
@@ -378,11 +414,11 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
378
414
 
379
415
  return {
380
416
  title, slugInput, slugTouched, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks,
381
- rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate,
417
+ rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, startDate,
382
418
  endDate, judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
383
419
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId,
384
420
  saving, formDirty, dateError, canSubmit,
385
421
  slugify: slugifyContest, toggleType, toggleRole, addPrize, removePrize, prizeLabel,
386
- hydrate, buildPayload, save,
422
+ hydrate, applyTemplate, buildPayload, save,
387
423
  };
388
424
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.83.2",
3
+ "version": "0.85.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,17 +54,17 @@
54
54
  "vue-advanced-cropper": "^2.8.9",
55
55
  "vue-router": "^4.3.0",
56
56
  "zod": "^4.3.6",
57
- "@commonpub/auth": "0.8.0",
57
+ "@commonpub/config": "0.23.0",
58
58
  "@commonpub/editor": "0.8.0",
59
- "@commonpub/learning": "0.5.2",
60
- "@commonpub/explainer": "0.8.0",
59
+ "@commonpub/auth": "0.8.0",
61
60
  "@commonpub/docs": "0.6.3",
62
- "@commonpub/protocol": "0.14.0",
63
- "@commonpub/schema": "0.46.0",
64
- "@commonpub/server": "2.90.0",
65
- "@commonpub/ui": "0.13.1",
66
61
  "@commonpub/theme-studio": "0.6.1",
67
- "@commonpub/config": "0.23.0"
62
+ "@commonpub/protocol": "0.14.0",
63
+ "@commonpub/schema": "0.47.0",
64
+ "@commonpub/learning": "0.5.2",
65
+ "@commonpub/server": "2.91.0",
66
+ "@commonpub/explainer": "0.8.0",
67
+ "@commonpub/ui": "0.13.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@testing-library/jest-dom": "^6.9.1",
@@ -506,7 +506,10 @@ async function withdrawEntry(entryId: string): Promise<void> {
506
506
  .cpub-submit-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: var(--border-width-default) solid var(--border); }
507
507
 
508
508
  /* LAYOUT */
509
- .cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 32px; }
509
+ /* Top padding intentionally tighter than the sides: the hero bar already sits
510
+ directly above with its own 20px bottom padding + border, so a full 32px here
511
+ stacked to a ~52px hero→tabs gap. ~18px lands the tabbar close under the hero. */
512
+ .cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 18px 32px 32px; }
510
513
 
511
514
  .cpub-entries-tools { display: flex; justify-content: flex-end; margin-bottom: 12px; }
512
515
  .cpub-entries-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; margin-bottom: 18px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
@@ -65,6 +65,7 @@ const canCreateContest = computed(() => {
65
65
  :alt="contest.title"
66
66
  class="cpub-contest-cover"
67
67
  :class="{ 'cpub-contest-cover--contain': !contest.coverImageUrl && !!contest.bannerUrl }"
68
+ :style="contest.coverImageUrl ? imageFramingStyle(contest.coverMeta) : undefined"
68
69
  loading="lazy"
69
70
  />
70
71
  <template v-else>
@@ -19,6 +19,16 @@ export const CONTEST_RUBRIC_KEY: InjectionKey<Ref<ContestRubricCriterion[]>> = S
19
19
  * "pull from schedule" seed. Absent (null) outside the contest editor. */
20
20
  export const CONTEST_SCHEDULE_KEY: InjectionKey<Ref<RoadmapItem[]>> = Symbol('contestSchedule');
21
21
 
22
+ /** A panel judge as the Judges Showcase block cares about it (for "Import panel
23
+ * judges": seed showcase rows from the real scoring panel). */
24
+ export interface ContestPanelJudge { name: string; avatarUrl?: string; title?: string; link?: string }
25
+
26
+ /** ContestEditor `provide`s an async loader for the contest's scoring-panel
27
+ * judges under this key, so the Judges Showcase block can offer a one-click
28
+ * "import panel judges" seed (name + account avatar). Resolves to [] in create
29
+ * mode (no slug yet); absent (null) outside the contest editor. */
30
+ export const CONTEST_JUDGES_KEY: InjectionKey<() => Promise<ContestPanelJudge[]>> = Symbol('contestJudges');
31
+
22
32
  /** A stage as the roadmap cares about it (structural subset of ContestStage). */
23
33
  export interface RoadmapStageSource { name: string; kind?: string; startsAt?: string; endsAt?: string; description?: string }
24
34
  /** The three core schedule dates, when there are no custom stages. */
@@ -15,11 +15,11 @@ export function seedBodyBlocks(
15
15
  if (Array.isArray(blocks) && blocks.length) return blocks as BlockTuple[];
16
16
  const text = (legacy ?? '').trim();
17
17
  if (!text) return [];
18
- if (legacyFormat === 'html') return [['markdown', { content: text }]];
18
+ if (legacyFormat === 'html') return [['markdown', { source: text }]];
19
19
  try {
20
20
  const parsed = markdownToBlockTuples(text);
21
- return parsed.length ? parsed : [['markdown', { content: text }]];
21
+ return parsed.length ? parsed : [['markdown', { source: text }]];
22
22
  } catch {
23
- return [['markdown', { content: text }]];
23
+ return [['markdown', { source: text }]];
24
24
  }
25
25
  }
@@ -0,0 +1,35 @@
1
+ import type { CSSProperties } from 'vue';
2
+ import type { ContestImageMeta } from '@commonpub/schema';
3
+
4
+ /**
5
+ * Map a contest banner/cover `ContestImageMeta` to CSS for the <img> (P4). The
6
+ * framing is NON-DESTRUCTIVE — the original upload is never re-cropped; this only
7
+ * drives object-fit / transform / object-position. Shared by ContestHero (public
8
+ * render), the editor preview, and ContestBannerAdjust so all three agree.
9
+ *
10
+ * - `null`/absent ⇒ `cover` with NO transform — the legacy fit, so existing
11
+ * contests look identical until an organiser touches the framing (back-compat).
12
+ * - `zoom <= 0` ⇒ `contain` — perfect fit, the whole image visible (letterboxed).
13
+ * - `zoom > 0` ⇒ `cover` + `scale(1 + zoom)` + `object-position: x% y%`.
14
+ */
15
+ /** A `:style` object (a Vue CSSProperties subset) for an <img>. */
16
+ export type ImageFraming = CSSProperties;
17
+
18
+ export function imageFramingStyle(meta: ContestImageMeta | null | undefined): CSSProperties {
19
+ if (!meta) return { objectFit: 'cover' };
20
+ const x = clampPct(meta.x);
21
+ const y = clampPct(meta.y);
22
+ if (meta.zoom <= 0) return { objectFit: 'contain', objectPosition: `${x}% ${y}%` };
23
+ const zoom = Math.min(4, meta.zoom);
24
+ return { objectFit: 'cover', transform: `scale(${1 + zoom})`, objectPosition: `${x}% ${y}%` };
25
+ }
26
+
27
+ function clampPct(n: number): number {
28
+ if (!Number.isFinite(n)) return 50;
29
+ return Math.max(0, Math.min(100, Math.round(n)));
30
+ }
31
+
32
+ /** The default framing an organiser starts from when they first adjust an image. */
33
+ export function defaultImageMeta(): ContestImageMeta {
34
+ return { zoom: 0, x: 50, y: 50 };
35
+ }
@@ -109,23 +109,20 @@ export function blankTemplateField(): ContestSubmissionTemplateField {
109
109
  return { key: '', label: '', type: 'text', required: false };
110
110
  }
111
111
 
112
- function withTemplate(stages: ContestStage[], i: number, template: ContestSubmissionTemplateField[]): ContestStage[] {
113
- return stages.map((s, idx) => (idx === i ? { ...s, submissionTemplate: template.length ? template : undefined } : s));
114
- }
112
+ type FieldType = ContestSubmissionTemplateField['type'];
113
+ type TemplateField = ContestSubmissionTemplateField;
115
114
 
116
- export function withTemplateFieldAdded(stages: ContestStage[], i: number): ContestStage[] {
117
- const cur = stages[i]?.submissionTemplate ?? [];
118
- return withTemplate(stages, i, [...cur, blankTemplateField()]);
115
+ // ─── Array-level template ops (operate on ONE stage's submissionTemplate) ───
116
+ // The extracted ContestStageTemplateEditor works on a plain field array; the
117
+ // stage-indexed `withTemplate*` wrappers below delegate to these so both surfaces
118
+ // share one implementation (and the existing unit tests still exercise it).
119
+
120
+ export function templateFieldAdded(t: TemplateField[]): TemplateField[] {
121
+ return [...t, blankTemplateField()];
119
122
  }
120
123
 
121
- export function withTemplateFieldSet(
122
- stages: ContestStage[],
123
- i: number,
124
- fi: number,
125
- patch: Partial<ContestSubmissionTemplateField>,
126
- ): ContestStage[] {
127
- const cur = (stages[i]?.submissionTemplate ?? []).map((f, idx) => (idx === fi ? { ...f, ...patch } : f));
128
- return withTemplate(stages, i, cur);
124
+ export function templateFieldSet(t: TemplateField[], fi: number, patch: Partial<TemplateField>): TemplateField[] {
125
+ return t.map((f, idx) => (idx === fi ? { ...f, ...patch } : f));
129
126
  }
130
127
 
131
128
  /**
@@ -134,28 +131,17 @@ export function withTemplateFieldSet(
134
131
  * organizer edited by hand is left alone — once entrants have submitted, keys
135
132
  * are what artifact values hang off, so they must stay stable.
136
133
  */
137
- export function withTemplateFieldLabelChanged(
138
- stages: ContestStage[],
139
- i: number,
140
- fi: number,
141
- label: string,
142
- ): ContestStage[] {
143
- const field = stages[i]?.submissionTemplate?.[fi];
144
- if (!field) return stages;
134
+ export function templateFieldLabelChanged(t: TemplateField[], fi: number, label: string): TemplateField[] {
135
+ const field = t[fi];
136
+ if (!field) return t;
145
137
  const tracksLabel = !field.key || field.key === fieldKeyFromLabel(field.label);
146
- const patch: Partial<ContestSubmissionTemplateField> = tracksLabel
147
- ? { label, key: fieldKeyFromLabel(label) }
148
- : { label };
149
- return withTemplateFieldSet(stages, i, fi, patch);
138
+ return templateFieldSet(t, fi, tracksLabel ? { label, key: fieldKeyFromLabel(label) } : { label });
150
139
  }
151
140
 
152
- export function withTemplateFieldRemoved(stages: ContestStage[], i: number, fi: number): ContestStage[] {
153
- const cur = (stages[i]?.submissionTemplate ?? []).filter((_, idx) => idx !== fi);
154
- return withTemplate(stages, i, cur);
141
+ export function templateFieldRemoved(t: TemplateField[], fi: number): TemplateField[] {
142
+ return t.filter((_, idx) => idx !== fi);
155
143
  }
156
144
 
157
- type FieldType = ContestSubmissionTemplateField['type'];
158
-
159
145
  /**
160
146
  * Change a template field's type AND normalize the type-specific ancillary props
161
147
  * so the stored field stays coherent (Phase 4): `address` forces `pii`; leaving
@@ -163,15 +149,10 @@ type FieldType = ContestSubmissionTemplateField['type'];
163
149
  * `mustAccept`; entering `select` seeds one blank option; entering `agreement`
164
150
  * defaults `mustAccept` true.
165
151
  */
166
- export function withTemplateFieldTypeChanged(
167
- stages: ContestStage[],
168
- i: number,
169
- fi: number,
170
- type: FieldType,
171
- ): ContestStage[] {
172
- const field = stages[i]?.submissionTemplate?.[fi];
173
- if (!field) return stages;
174
- const patch: Partial<ContestSubmissionTemplateField> = { type };
152
+ export function templateFieldTypeChanged(t: TemplateField[], fi: number, type: FieldType): TemplateField[] {
153
+ const field = t[fi];
154
+ if (!field) return t;
155
+ const patch: Partial<TemplateField> = { type };
175
156
  patch.options = type === 'select' ? (field.options?.length ? field.options : [{ value: '', label: '' }]) : undefined;
176
157
  if (type === 'agreement') {
177
158
  patch.mustAccept = field.mustAccept ?? true;
@@ -181,13 +162,64 @@ export function withTemplateFieldTypeChanged(
181
162
  patch.mustAccept = undefined;
182
163
  }
183
164
  if (type === 'address') patch.pii = true;
184
- return withTemplateFieldSet(stages, i, fi, patch);
165
+ return templateFieldSet(t, fi, patch);
166
+ }
167
+
168
+ export function templateOptionAdded(t: TemplateField[], fi: number): TemplateField[] {
169
+ const field = t[fi];
170
+ if (!field) return t;
171
+ return templateFieldSet(t, fi, { options: [...(field.options ?? []), { value: '', label: '' }] });
172
+ }
173
+
174
+ export function templateOptionSet(
175
+ t: TemplateField[],
176
+ fi: number,
177
+ oi: number,
178
+ patch: Partial<{ value: string; label: string }>,
179
+ ): TemplateField[] {
180
+ const field = t[fi];
181
+ if (!field) return t;
182
+ const options = (field.options ?? []).map((o, idx) => (idx === oi ? { ...o, ...patch } : o));
183
+ return templateFieldSet(t, fi, { options });
184
+ }
185
+
186
+ export function templateOptionRemoved(t: TemplateField[], fi: number, oi: number): TemplateField[] {
187
+ const field = t[fi];
188
+ if (!field) return t;
189
+ return templateFieldSet(t, fi, { options: (field.options ?? []).filter((_, idx) => idx !== oi) });
190
+ }
191
+
192
+ // ─── Stage-indexed wrappers (delegate to the array-level ops above) ───
193
+
194
+ function withTemplate(stages: ContestStage[], i: number, template: TemplateField[]): ContestStage[] {
195
+ return stages.map((s, idx) => (idx === i ? { ...s, submissionTemplate: template.length ? template : undefined } : s));
196
+ }
197
+
198
+ export function withTemplateFieldAdded(stages: ContestStage[], i: number): ContestStage[] {
199
+ return withTemplate(stages, i, templateFieldAdded(stages[i]?.submissionTemplate ?? []));
200
+ }
201
+
202
+ export function withTemplateFieldSet(stages: ContestStage[], i: number, fi: number, patch: Partial<TemplateField>): ContestStage[] {
203
+ return withTemplate(stages, i, templateFieldSet(stages[i]?.submissionTemplate ?? [], fi, patch));
204
+ }
205
+
206
+ export function withTemplateFieldLabelChanged(stages: ContestStage[], i: number, fi: number, label: string): ContestStage[] {
207
+ if (!stages[i]?.submissionTemplate?.[fi]) return stages;
208
+ return withTemplate(stages, i, templateFieldLabelChanged(stages[i]!.submissionTemplate!, fi, label));
209
+ }
210
+
211
+ export function withTemplateFieldRemoved(stages: ContestStage[], i: number, fi: number): ContestStage[] {
212
+ return withTemplate(stages, i, templateFieldRemoved(stages[i]?.submissionTemplate ?? [], fi));
213
+ }
214
+
215
+ export function withTemplateFieldTypeChanged(stages: ContestStage[], i: number, fi: number, type: FieldType): ContestStage[] {
216
+ if (!stages[i]?.submissionTemplate?.[fi]) return stages;
217
+ return withTemplate(stages, i, templateFieldTypeChanged(stages[i]!.submissionTemplate!, fi, type));
185
218
  }
186
219
 
187
220
  export function withTemplateOptionAdded(stages: ContestStage[], i: number, fi: number): ContestStage[] {
188
- const field = stages[i]?.submissionTemplate?.[fi];
189
- if (!field) return stages;
190
- return withTemplateFieldSet(stages, i, fi, { options: [...(field.options ?? []), { value: '', label: '' }] });
221
+ if (!stages[i]?.submissionTemplate?.[fi]) return stages;
222
+ return withTemplate(stages, i, templateOptionAdded(stages[i]!.submissionTemplate!, fi));
191
223
  }
192
224
 
193
225
  export function withTemplateOptionSet(
@@ -197,16 +229,13 @@ export function withTemplateOptionSet(
197
229
  oi: number,
198
230
  patch: Partial<{ value: string; label: string }>,
199
231
  ): ContestStage[] {
200
- const field = stages[i]?.submissionTemplate?.[fi];
201
- if (!field) return stages;
202
- const options = (field.options ?? []).map((o, idx) => (idx === oi ? { ...o, ...patch } : o));
203
- return withTemplateFieldSet(stages, i, fi, { options });
232
+ if (!stages[i]?.submissionTemplate?.[fi]) return stages;
233
+ return withTemplate(stages, i, templateOptionSet(stages[i]!.submissionTemplate!, fi, oi, patch));
204
234
  }
205
235
 
206
236
  export function withTemplateOptionRemoved(stages: ContestStage[], i: number, fi: number, oi: number): ContestStage[] {
207
- const field = stages[i]?.submissionTemplate?.[fi];
208
- if (!field) return stages;
209
- return withTemplateFieldSet(stages, i, fi, { options: (field.options ?? []).filter((_, idx) => idx !== oi) });
237
+ if (!stages[i]?.submissionTemplate?.[fi]) return stages;
238
+ return withTemplate(stages, i, templateOptionRemoved(stages[i]!.submissionTemplate!, fi, oi));
210
239
  }
211
240
 
212
241
  /** Human label for each template field type (for the editor dropdown). */
@@ -0,0 +1,165 @@
1
+ import type { ContestSubmissionTemplateField } from '@commonpub/schema';
2
+ import { fieldKeyFromLabel } from './contestStages';
3
+
4
+ /**
5
+ * Field presets + whole-form templates for the submission-form builder (P2). Pure
6
+ * data + helpers so they unit-test in isolation; the builder UI
7
+ * (ContestStageTemplateEditor) appends a preset or replaces the whole form via the
8
+ * `templatePreset*`/template builders here. Keys are derived from the label and
9
+ * uniquified against the existing template so two "Email" fields don't collide
10
+ * (template field keys must be unique — `contestStageSchema`).
11
+ *
12
+ * Address/Agreement presets + the address/shipping templates are PII-gated
13
+ * (`features.contestPii`): the agreement/address field types are only offered in
14
+ * the builder when that flag is on, so the UI hides them otherwise. The pure
15
+ * builders take an explicit `{ pii }` so they degrade the same way in isolation.
16
+ */
17
+ type TemplateField = ContestSubmissionTemplateField;
18
+
19
+ /** Default terms an organiser can keep or edit; shared by the preset + template. */
20
+ export const RULES_AGREEMENT_TERMS =
21
+ 'By entering, I confirm this submission is my own original work and I agree to the contest rules and code of conduct.';
22
+
23
+ /** Make `base` unique within `taken` by appending `_2`, `_3`, … */
24
+ function uniqueKey(taken: Set<string>, base: string): string {
25
+ if (!taken.has(base)) return base;
26
+ let n = 2;
27
+ while (taken.has(`${base}_${n}`)) n += 1;
28
+ return `${base}_${n}`;
29
+ }
30
+
31
+ /** Stamp keys on a set of keyless fields, keeping them unique among themselves. */
32
+ function withKeys(fields: Array<Omit<TemplateField, 'key'> & { key?: string }>): TemplateField[] {
33
+ const taken = new Set<string>();
34
+ return fields.map((f) => {
35
+ const key = uniqueKey(taken, f.key || fieldKeyFromLabel(f.label));
36
+ taken.add(key);
37
+ return { ...f, key };
38
+ });
39
+ }
40
+
41
+ // ─── One-click field presets (the "Add field" menu) ───
42
+
43
+ export interface FieldPreset {
44
+ id: string;
45
+ /** Menu label. */
46
+ label: string;
47
+ /** FontAwesome icon (no `fa-solid` prefix). */
48
+ icon: string;
49
+ /** Requires `features.contestPii` (agreement/address field types). */
50
+ pii?: boolean;
51
+ /** The field this preset seeds (key derived from the label at add time). */
52
+ field: Omit<TemplateField, 'key'>;
53
+ }
54
+
55
+ export const FIELD_PRESETS: FieldPreset[] = [
56
+ { id: 'text', label: 'Short text', icon: 'fa-font', field: { label: 'Short answer', type: 'text', required: false } },
57
+ { id: 'textarea', label: 'Long text', icon: 'fa-align-left', field: { label: 'Details', type: 'textarea', required: false } },
58
+ { id: 'url', label: 'Link (URL)', icon: 'fa-link', field: { label: 'Link', type: 'url', required: false, help: 'Include the full https:// address.' } },
59
+ { id: 'email', label: 'Email', icon: 'fa-envelope', field: { label: 'Email address', type: 'email', required: false } },
60
+ { id: 'number', label: 'Number', icon: 'fa-hashtag', field: { label: 'Number', type: 'number', required: false } },
61
+ { id: 'select', label: 'Dropdown', icon: 'fa-list', field: { label: 'Choose one', type: 'select', required: false, options: [{ value: '', label: '' }] } },
62
+ { id: 'checkbox', label: 'Checkbox', icon: 'fa-square-check', field: { label: 'Confirm', type: 'checkbox', required: false } },
63
+ { id: 'date', label: 'Date', icon: 'fa-calendar', field: { label: 'Date', type: 'date', required: false } },
64
+ {
65
+ id: 'address',
66
+ label: 'Mailing address',
67
+ icon: 'fa-location-dot',
68
+ pii: true,
69
+ field: { label: 'Mailing address', type: 'address', required: false, pii: true, help: 'Stored privately. Only staff with PII access and the entrant can read it.' },
70
+ },
71
+ {
72
+ id: 'agreement',
73
+ label: 'Agreement',
74
+ icon: 'fa-file-signature',
75
+ pii: true,
76
+ field: { label: 'Agreement', type: 'agreement', required: true, mustAccept: true, terms: RULES_AGREEMENT_TERMS },
77
+ },
78
+ ];
79
+
80
+ /** Presets offered for the builder, gated by whether PII field types are enabled. */
81
+ export function availableFieldPresets(pii: boolean): FieldPreset[] {
82
+ return pii ? FIELD_PRESETS : FIELD_PRESETS.filter((p) => !p.pii);
83
+ }
84
+
85
+ /** Append a preset field, deriving a unique machine key from its label. */
86
+ export function templatePresetAdded(t: TemplateField[], preset: FieldPreset): TemplateField[] {
87
+ const taken = new Set(t.map((f) => f.key));
88
+ const key = uniqueKey(taken, fieldKeyFromLabel(preset.field.label));
89
+ return [...t, { ...preset.field, key }];
90
+ }
91
+
92
+ // ─── Whole-form templates (the "Start from template" picker) ───
93
+
94
+ export interface SubmissionFormTemplate {
95
+ id: string;
96
+ label: string;
97
+ description: string;
98
+ /** Requires `features.contestPii` to seed its address/agreement fields. */
99
+ pii?: boolean;
100
+ /** Build the field array; flag-adaptive so it degrades when PII is off. */
101
+ build(opts: { pii: boolean }): TemplateField[];
102
+ }
103
+
104
+ const SHIPPING_AGREEMENT_TERMS =
105
+ 'If selected, I agree to provide a valid shipping address and accept responsibility for any hardware sent to me.';
106
+
107
+ export const SUBMISSION_FORM_TEMPLATES: SubmissionFormTemplate[] = [
108
+ {
109
+ id: 'standard',
110
+ label: 'Standard proposal',
111
+ description: 'Name, summary, description, approach (and a rules agreement when PII is on).',
112
+ build({ pii }): TemplateField[] {
113
+ const fields: Array<Omit<TemplateField, 'key'>> = [
114
+ { label: 'Project name', type: 'text', required: true },
115
+ { label: 'One-line summary', type: 'text', required: true, help: 'A single sentence describing your idea.' },
116
+ { label: 'Description', type: 'textarea', required: true, help: 'What you are building and the problem it solves.' },
117
+ { label: 'Approach', type: 'textarea', required: false, help: 'How you plan to build it (optional).' },
118
+ ];
119
+ if (pii) fields.push({ label: 'Contest rules', type: 'agreement', required: true, terms: RULES_AGREEMENT_TERMS, mustAccept: true });
120
+ return withKeys(fields);
121
+ },
122
+ },
123
+ {
124
+ id: 'hardware',
125
+ label: 'Hardware / shipping',
126
+ description: 'Standard proposal plus a mailing address and a shipping agreement (PII).',
127
+ pii: true,
128
+ build({ pii }): TemplateField[] {
129
+ const fields: Array<Omit<TemplateField, 'key'>> = [
130
+ { label: 'Project name', type: 'text', required: true },
131
+ { label: 'One-line summary', type: 'text', required: true, help: 'A single sentence describing your idea.' },
132
+ { label: 'Description', type: 'textarea', required: true, help: 'What you are building and the problem it solves.' },
133
+ ];
134
+ if (pii) {
135
+ fields.push({ label: 'Mailing address', type: 'address', required: true, pii: true, help: 'Stored privately. Only staff with PII access and the entrant can read it.' });
136
+ fields.push({ label: 'Shipping agreement', type: 'agreement', required: true, terms: SHIPPING_AGREEMENT_TERMS, mustAccept: true });
137
+ }
138
+ return withKeys(fields);
139
+ },
140
+ },
141
+ {
142
+ id: 'minimal',
143
+ label: 'Minimal',
144
+ description: 'Just a project name and a link.',
145
+ build(): TemplateField[] {
146
+ return withKeys([
147
+ { label: 'Project name', type: 'text', required: true },
148
+ { label: 'Link', type: 'url', required: false, help: 'Include the full https:// address.' },
149
+ ]);
150
+ },
151
+ },
152
+ {
153
+ id: 'blank',
154
+ label: 'Blank',
155
+ description: 'Start with no fields.',
156
+ build(): TemplateField[] {
157
+ return [];
158
+ },
159
+ },
160
+ ];
161
+
162
+ /** Templates offered for the builder, gated by whether PII field types are enabled. */
163
+ export function availableFormTemplates(pii: boolean): SubmissionFormTemplate[] {
164
+ return pii ? SUBMISSION_FORM_TEMPLATES : SUBMISSION_FORM_TEMPLATES.filter((t) => !t.pii);
165
+ }