@commonpub/layer 0.83.2 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/contest/ContestAdvancementPanel.vue +138 -0
- package/components/contest/ContestBannerAdjust.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +94 -132
- package/components/contest/ContestHero.vue +5 -1
- package/components/contest/ContestProposalForm.vue +3 -0
- package/components/contest/ContestStageCard.vue +207 -0
- package/components/contest/ContestStageSubmission.vue +3 -0
- package/components/contest/ContestStageTemplateEditor.vue +374 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/components/contest/blocks/JudgesShowcaseBlock.vue +113 -6
- package/composables/useContestEditor.ts +40 -4
- package/package.json +9 -9
- package/pages/contests/[slug]/index.vue +4 -1
- package/pages/contests/index.vue +1 -0
- package/utils/contestBlocks.ts +10 -0
- package/utils/contestBody.ts +3 -3
- package/utils/contestImage.ts +35 -0
- package/utils/contestStages.ts +80 -51
- package/utils/contestSubmissionTemplates.ts +165 -0
- package/utils/contestTemplates.ts +119 -0
|
@@ -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.
|
|
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/
|
|
57
|
+
"@commonpub/config": "0.23.0",
|
|
58
58
|
"@commonpub/editor": "0.8.0",
|
|
59
|
-
"@commonpub/
|
|
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/
|
|
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
|
-
|
|
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); }
|
package/pages/contests/index.vue
CHANGED
|
@@ -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>
|
package/utils/contestBlocks.ts
CHANGED
|
@@ -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. */
|
package/utils/contestBody.ts
CHANGED
|
@@ -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', {
|
|
18
|
+
if (legacyFormat === 'html') return [['markdown', { source: text }]];
|
|
19
19
|
try {
|
|
20
20
|
const parsed = markdownToBlockTuples(text);
|
|
21
|
-
return parsed.length ? parsed : [['markdown', {
|
|
21
|
+
return parsed.length ? parsed : [['markdown', { source: text }]];
|
|
22
22
|
} catch {
|
|
23
|
-
return [['markdown', {
|
|
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
|
+
}
|
package/utils/contestStages.ts
CHANGED
|
@@ -109,23 +109,20 @@ export function blankTemplateField(): ContestSubmissionTemplateField {
|
|
|
109
109
|
return { key: '', label: '', type: 'text', required: false };
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
112
|
+
type FieldType = ContestSubmissionTemplateField['type'];
|
|
113
|
+
type TemplateField = ContestSubmissionTemplateField;
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
+
}
|