@commonpub/layer 0.84.0 → 0.86.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.
@@ -4,16 +4,38 @@
4
4
  * from ContestStagesEditor so the (heaviest, flag-gated) part of the stage card is
5
5
  * its own cohesive unit. Operates on ONE stage's `submissionTemplate` array and
6
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.
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.
10
19
  */
11
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';
12
29
 
13
30
  const props = defineProps<{
14
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[]];
15
38
  }>();
16
- const emit = defineEmits<{ 'update:template': [template: ContestSubmissionTemplateField[]] }>();
17
39
 
18
40
  const { features } = useFeatures();
19
41
  const piiEnabled = computed(() => features.value.contestPii === true);
@@ -23,9 +45,47 @@ const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
23
45
  return base;
24
46
  });
25
47
 
26
- function addField(): void {
27
- emit('update:template', templateFieldAdded(props.template));
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();
28
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) ───
29
89
  function labelInput(fi: number, e: Event): void {
30
90
  emit('update:template', templateFieldLabelChanged(props.template, fi, (e.target as HTMLInputElement).value));
31
91
  }
@@ -47,15 +107,120 @@ function setOption(fi: number, oi: number, patch: Partial<{ value: string; label
47
107
  function removeOption(fi: number, oi: number): void {
48
108
  emit('update:template', templateOptionRemoved(props.template, fi, oi));
49
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
+ }
50
141
  </script>
51
142
 
52
143
  <template>
53
144
  <div class="cpub-stage-criteria">
54
145
  <div class="cpub-stage-criteria-head">
55
146
  <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>
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>
57
199
  </div>
58
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
+
59
224
  <div v-for="(tf, fi) in template" :key="fi" class="cpub-stage-tfield">
60
225
  <div class="cpub-stage-tfield-main">
61
226
  <input
@@ -175,7 +340,25 @@ function removeOption(fi: number, oi: number): void {
175
340
  .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
176
341
 
177
342
  .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; }
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
+
179
362
  .cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
180
363
  .cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
181
364
  .cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
@@ -2,14 +2,29 @@
2
2
  /**
3
3
  * Edit component for the `judgesShowcase` contest block (avatar + name + title +
4
4
  * bio cards). Provided to BlockCanvas via BLOCK_COMPONENTS_KEY by the contest
5
- * editor (2e). Follows the house block-edit contract: `content` in, `update` out,
6
- * immutable list ops. Avatars are URLs here; 2e can swap to <ImageUpload>.
5
+ * editor. Follows the house block-edit contract: `content` in, `update` out,
6
+ * immutable list ops.
7
+ *
8
+ * P6 de-friction: avatars now upload via the contest editor's UPLOAD_HANDLER_KEY
9
+ * (URL still accepted), rows reorder, and "Import panel judges" seeds rows from
10
+ * the real scoring panel (CONTEST_JUDGES_KEY) in one click. The Judges Showcase
11
+ * is the curated PUBLIC face (custom photos/titles); the scoring panel (People
12
+ * rail) is the real accounts who score — two distinct concepts, hence the note.
7
13
  */
14
+ import { inject, ref } from 'vue';
15
+ import { UPLOAD_HANDLER_KEY } from '@commonpub/editor/vue';
16
+ import { CONTEST_JUDGES_KEY } from '../../../utils/contestBlocks';
8
17
  import type { JudgeShowcaseEntry, JudgesShowcaseContent } from '../../../types/contestBlocks';
9
18
 
10
19
  const props = defineProps<{ content: Record<string, unknown> }>();
11
20
  const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
12
21
 
22
+ const uploadHandler = inject(UPLOAD_HANDLER_KEY, undefined);
23
+ const loadPanelJudges = inject(CONTEST_JUDGES_KEY, null);
24
+ const uploadingIndex = ref<number | null>(null);
25
+ const importing = ref(false);
26
+ const importNote = ref('');
27
+
13
28
  const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
14
29
  const judges = computed<JudgeShowcaseEntry[]>(() =>
15
30
  Array.isArray(props.content.judges) ? (props.content.judges as JudgeShowcaseEntry[]) : [],
@@ -30,6 +45,50 @@ function setJudge(i: number, field: keyof JudgeShowcaseEntry, v: string): void {
30
45
  function removeJudge(i: number): void {
31
46
  commit({ judges: judges.value.filter((_, idx) => idx !== i) });
32
47
  }
48
+ function moveJudge(i: number, dir: -1 | 1): void {
49
+ const j = i + dir;
50
+ if (j < 0 || j >= judges.value.length) return;
51
+ const next = [...judges.value];
52
+ [next[i], next[j]] = [next[j]!, next[i]!];
53
+ commit({ judges: next });
54
+ }
55
+
56
+ async function onFile(i: number, event: Event): Promise<void> {
57
+ const input = event.target as HTMLInputElement;
58
+ const file = input.files?.[0];
59
+ input.value = '';
60
+ if (!file || !uploadHandler) return;
61
+ uploadingIndex.value = i;
62
+ try {
63
+ const res = await uploadHandler(file);
64
+ setJudge(i, 'avatarUrl', res.url);
65
+ } finally {
66
+ uploadingIndex.value = null;
67
+ }
68
+ }
69
+
70
+ async function importPanelJudges(): Promise<void> {
71
+ if (!loadPanelJudges || importing.value) return;
72
+ importing.value = true;
73
+ importNote.value = '';
74
+ try {
75
+ const panel = await loadPanelJudges();
76
+ const have = new Set(judges.value.map((j) => (j.name ?? '').trim().toLowerCase()).filter(Boolean));
77
+ const additions: JudgeShowcaseEntry[] = panel
78
+ .filter((p) => p.name.trim() && !have.has(p.name.trim().toLowerCase()))
79
+ .map((p) => ({ name: p.name, avatarUrl: p.avatarUrl, title: p.title, link: p.link }));
80
+ if (!additions.length) {
81
+ importNote.value = panel.length ? 'All panel judges are already shown.' : 'No panel judges to import yet.';
82
+ return;
83
+ }
84
+ commit({ judges: [...judges.value, ...additions] });
85
+ importNote.value = `Imported ${additions.length} judge${additions.length === 1 ? '' : 's'}. Add photos and titles below.`;
86
+ } catch {
87
+ importNote.value = 'Could not load the judges panel.';
88
+ } finally {
89
+ importing.value = false;
90
+ }
91
+ }
33
92
  </script>
34
93
 
35
94
  <template>
@@ -38,10 +97,25 @@ function removeJudge(i: number): void {
38
97
  <div class="cpub-jedit-icon"><i class="fa-solid fa-user-group"></i></div>
39
98
  <span class="cpub-jedit-title">Judges Showcase</span>
40
99
  <span class="cpub-jedit-count">{{ judges.length }} {{ judges.length === 1 ? 'person' : 'people' }}</span>
100
+ <button
101
+ v-if="loadPanelJudges"
102
+ type="button"
103
+ class="cpub-jedit-add"
104
+ :disabled="importing"
105
+ @click="importPanelJudges"
106
+ >
107
+ <i class="fa-solid fa-user-plus"></i> {{ importing ? 'Importing...' : 'Import panel judges' }}
108
+ </button>
41
109
  <button type="button" class="cpub-jedit-add" @click="addJudge"><i class="fa-solid fa-plus"></i> Add person</button>
42
110
  </div>
43
111
 
44
112
  <div class="cpub-jedit-body">
113
+ <p class="cpub-jedit-explain">
114
+ <i class="fa-solid fa-circle-info"></i>
115
+ These are the curated public faces (custom photos and titles). The scoring panel, who actually
116
+ rate entries, is managed under People. Use Import panel judges to start from that list.
117
+ </p>
118
+
45
119
  <input
46
120
  class="cpub-jedit-input cpub-jedit-heading"
47
121
  type="text"
@@ -51,13 +125,30 @@ function removeJudge(i: number): void {
51
125
  @input="setHeading(($event.target as HTMLInputElement).value)"
52
126
  />
53
127
 
128
+ <p v-if="importNote" class="cpub-jedit-note" role="status">{{ importNote }}</p>
129
+
54
130
  <div v-for="(j, i) in judges" :key="i" class="cpub-jedit-row">
55
131
  <div class="cpub-jedit-row-main">
56
132
  <input class="cpub-jedit-input" type="text" :value="j.name" placeholder="Name" :aria-label="`Person ${i + 1} name`" @input="setJudge(i, 'name', ($event.target as HTMLInputElement).value)" />
57
133
  <input class="cpub-jedit-input" type="text" :value="j.title ?? ''" placeholder="Title / affiliation" :aria-label="`Person ${i + 1} title`" @input="setJudge(i, 'title', ($event.target as HTMLInputElement).value)" />
134
+ <button type="button" class="cpub-jedit-iconbtn" :disabled="i === 0" :aria-label="`Move person ${i + 1} up`" @click="moveJudge(i, -1)"><i class="fa-solid fa-arrow-up"></i></button>
135
+ <button type="button" class="cpub-jedit-iconbtn" :disabled="i === judges.length - 1" :aria-label="`Move person ${i + 1} down`" @click="moveJudge(i, 1)"><i class="fa-solid fa-arrow-down"></i></button>
58
136
  <button type="button" class="cpub-jedit-remove" :aria-label="`Remove person ${i + 1}`" @click="removeJudge(i)"><i class="fa-solid fa-xmark"></i></button>
59
137
  </div>
60
- <input class="cpub-jedit-input" type="url" :value="j.avatarUrl ?? ''" placeholder="Avatar image URL (https://…)" :aria-label="`Person ${i + 1} avatar URL`" @input="setJudge(i, 'avatarUrl', ($event.target as HTMLInputElement).value)" />
138
+
139
+ <div class="cpub-jedit-avatar-row">
140
+ <span class="cpub-jedit-avatar-prev">
141
+ <img v-if="j.avatarUrl" :src="j.avatarUrl" :alt="`${j.name || 'Judge'} photo`" />
142
+ <i v-else class="fa-solid fa-user"></i>
143
+ </span>
144
+ <input class="cpub-jedit-input" type="url" :value="j.avatarUrl ?? ''" placeholder="Photo URL (https://…)" :aria-label="`Person ${i + 1} photo URL`" @input="setJudge(i, 'avatarUrl', ($event.target as HTMLInputElement).value)" />
145
+ <label v-if="uploadHandler" class="cpub-jedit-upload" :class="{ 'cpub-jedit-upload-busy': uploadingIndex === i }">
146
+ <i class="fa-solid" :class="uploadingIndex === i ? 'fa-spinner fa-spin' : 'fa-arrow-up-from-bracket'"></i>
147
+ <span>{{ uploadingIndex === i ? 'Uploading' : 'Upload' }}</span>
148
+ <input type="file" accept="image/*" class="cpub-jedit-file" :aria-label="`Upload photo for person ${i + 1}`" @change="onFile(i, $event)" />
149
+ </label>
150
+ </div>
151
+
61
152
  <input class="cpub-jedit-input" type="url" :value="j.link ?? ''" placeholder="Profile / link (https://…, optional)" :aria-label="`Person ${i + 1} link`" @input="setJudge(i, 'link', ($event.target as HTMLInputElement).value)" />
62
153
  <textarea class="cpub-jedit-input cpub-jedit-bio" rows="2" :value="j.bio ?? ''" placeholder="Short bio (optional)" :aria-label="`Person ${i + 1} bio`" @input="setJudge(i, 'bio', ($event.target as HTMLTextAreaElement).value)" />
63
154
  </div>
@@ -71,14 +162,18 @@ function removeJudge(i: number): void {
71
162
 
72
163
  <style scoped>
73
164
  .cpub-jedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
74
- .cpub-jedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
165
+ .cpub-jedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); flex-wrap: wrap; }
75
166
  .cpub-jedit-icon { font-size: 12px; color: var(--accent); }
76
167
  .cpub-jedit-title { font-size: 12px; font-weight: 600; }
77
168
  .cpub-jedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
78
169
  .cpub-jedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
79
- .cpub-jedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
170
+ .cpub-jedit-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
171
+ .cpub-jedit-add:disabled { opacity: .5; cursor: default; }
80
172
 
81
173
  .cpub-jedit-body { padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; }
174
+ .cpub-jedit-explain { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.5; display: flex; gap: 6px; }
175
+ .cpub-jedit-explain i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
176
+ .cpub-jedit-note { margin: 0; font-size: 11px; color: var(--accent); font-family: var(--font-mono); }
82
177
  .cpub-jedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
83
178
  .cpub-jedit-input:focus { border-color: var(--accent); }
84
179
  .cpub-jedit-input::placeholder { color: var(--text-faint); }
@@ -86,11 +181,23 @@ function removeJudge(i: number): void {
86
181
  .cpub-jedit-bio { resize: vertical; font-family: inherit; }
87
182
 
88
183
  .cpub-jedit-row { border: var(--border-width-default) dashed var(--border2); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
89
- .cpub-jedit-row-main { display: flex; gap: 6px; }
184
+ .cpub-jedit-row-main { display: flex; gap: 6px; align-items: center; }
90
185
  .cpub-jedit-row-main .cpub-jedit-input { flex: 1; }
186
+ .cpub-jedit-iconbtn { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; width: 26px; height: 26px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
187
+ .cpub-jedit-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
188
+ .cpub-jedit-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
91
189
  .cpub-jedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
92
190
  .cpub-jedit-remove:hover { border-color: var(--red-border); color: var(--red); }
93
191
 
192
+ .cpub-jedit-avatar-row { display: flex; gap: 6px; align-items: center; }
193
+ .cpub-jedit-avatar-row .cpub-jedit-input { flex: 1; }
194
+ .cpub-jedit-avatar-prev { width: 30px; height: 30px; flex-shrink: 0; border: var(--border-width-default) solid var(--border); background: var(--surface2); display: inline-flex; align-items: center; justify-content: center; color: var(--text-faint); font-size: 12px; overflow: hidden; }
195
+ .cpub-jedit-avatar-prev img { width: 100%; height: 100%; object-fit: cover; }
196
+ .cpub-jedit-upload { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .04em; padding: 6px 8px; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; flex-shrink: 0; white-space: nowrap; }
197
+ .cpub-jedit-upload:hover { border-color: var(--accent); color: var(--accent); }
198
+ .cpub-jedit-upload-busy { opacity: .7; cursor: default; }
199
+ .cpub-jedit-file { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
200
+
94
201
  .cpub-jedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
95
202
  .cpub-jedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
96
203
  </style>
@@ -14,7 +14,7 @@
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, ContestCoverPlacement } from '@commonpub/schema';
18
18
  import type { ContestTemplateSeed } from '../utils/contestTemplates';
19
19
 
20
20
  export type ContestFormat = 'markdown' | 'html';
@@ -52,6 +52,9 @@ export interface ContestEditorSource {
52
52
  prizesDescriptionFormat?: string | null;
53
53
  bannerUrl?: string | null;
54
54
  coverImageUrl?: string | null;
55
+ bannerMeta?: ContestImageMeta | null;
56
+ coverMeta?: ContestImageMeta | null;
57
+ coverPlacement?: ContestCoverPlacement | null;
55
58
  startDate?: string | null;
56
59
  endDate?: string | null;
57
60
  judgingEndDate?: string | null;
@@ -105,6 +108,9 @@ export interface UseContestEditor {
105
108
  prizesDescriptionFormat: Ref<ContestFormat>;
106
109
  bannerUrl: Ref<string>;
107
110
  coverImageUrl: Ref<string>;
111
+ bannerMeta: Ref<ContestImageMeta | null>;
112
+ coverMeta: Ref<ContestImageMeta | null>;
113
+ coverPlacement: Ref<ContestCoverPlacement | null>;
108
114
  startDate: Ref<string>;
109
115
  endDate: Ref<string>;
110
116
  judgingEndDate: Ref<string>;
@@ -172,6 +178,9 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
172
178
  const prizesDescriptionFormat = ref<ContestFormat>('markdown');
173
179
  const bannerUrl = ref('');
174
180
  const coverImageUrl = ref('');
181
+ const bannerMeta = ref<ContestImageMeta | null>(null);
182
+ const coverMeta = ref<ContestImageMeta | null>(null);
183
+ const coverPlacement = ref<ContestCoverPlacement | null>(null);
175
184
  const startDate = ref('');
176
185
  const endDate = ref('');
177
186
  const judgingEndDate = ref('');
@@ -252,6 +261,9 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
252
261
  prizesDescriptionFormat.value = asFormat(c.prizesDescriptionFormat);
253
262
  bannerUrl.value = c.bannerUrl ?? '';
254
263
  coverImageUrl.value = c.coverImageUrl ?? '';
264
+ bannerMeta.value = c.bannerMeta ?? null;
265
+ coverMeta.value = c.coverMeta ?? null;
266
+ coverPlacement.value = c.coverPlacement ?? null;
255
267
  // ISO instants stored verbatim; CpubDateTimeField renders them in local time.
256
268
  startDate.value = c.startDate ?? '';
257
269
  endDate.value = c.endDate ?? '';
@@ -335,6 +347,10 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
335
347
  prizesDescriptionFormat: prizesDescriptionFormat.value,
336
348
  bannerUrl: bannerUrl.value || undefined,
337
349
  coverImageUrl: coverImageUrl.value || undefined,
350
+ // Clear the framing when the image is removed; else send it (or leave as-is).
351
+ bannerMeta: bannerUrl.value ? (bannerMeta.value ?? undefined) : null,
352
+ coverMeta: coverImageUrl.value ? (coverMeta.value ?? undefined) : null,
353
+ coverPlacement: coverImageUrl.value ? (coverPlacement.value ?? undefined) : null,
338
354
  startDate: startDate.value || undefined,
339
355
  endDate: endDate.value || undefined,
340
356
  judgingEndDate: judgingEndDate.value || undefined,
@@ -389,7 +405,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
389
405
  // Any post-hydration edit flips the dirty flag (drives the topbar "unsaved" cue).
390
406
  watch(
391
407
  [title, slugInput, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks, rules,
392
- descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate, endDate,
408
+ descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, coverPlacement, startDate, endDate,
393
409
  judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
394
410
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId],
395
411
  () => { if (!hydrating) formDirty.value = true; },
@@ -403,7 +419,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
403
419
 
404
420
  return {
405
421
  title, slugInput, slugTouched, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks,
406
- rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate,
422
+ rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, coverPlacement, startDate,
407
423
  endDate, judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
408
424
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId,
409
425
  saving, formDirty, dateError, canSubmit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.84.0",
3
+ "version": "0.86.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/config": "0.23.0",
58
- "@commonpub/editor": "0.8.0",
59
- "@commonpub/explainer": "0.8.0",
57
+ "@commonpub/auth": "0.8.0",
60
58
  "@commonpub/learning": "0.5.2",
59
+ "@commonpub/explainer": "0.8.0",
60
+ "@commonpub/schema": "0.48.0",
61
+ "@commonpub/server": "2.92.0",
61
62
  "@commonpub/protocol": "0.14.0",
62
- "@commonpub/server": "2.90.0",
63
- "@commonpub/docs": "0.6.3",
64
- "@commonpub/schema": "0.46.0",
65
63
  "@commonpub/theme-studio": "0.6.1",
64
+ "@commonpub/config": "0.23.0",
66
65
  "@commonpub/ui": "0.13.1",
67
- "@commonpub/auth": "0.8.0"
66
+ "@commonpub/editor": "0.9.0",
67
+ "@commonpub/docs": "0.6.3"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@testing-library/jest-dom": "^6.9.1",