@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.
- package/components/blocks/BlockEmbedView.vue +14 -2
- package/components/blocks/BlockVideoView.vue +19 -2
- package/components/contest/ContestBannerAdjust.vue +157 -0
- package/components/contest/ContestEditor.vue +52 -6
- package/components/contest/ContestHero.vue +31 -3
- package/components/contest/ContestProposalForm.vue +3 -0
- package/components/contest/ContestStageCard.vue +7 -0
- package/components/contest/ContestStageSubmission.vue +3 -0
- package/components/contest/ContestStageTemplateEditor.vue +191 -8
- package/components/contest/blocks/JudgesShowcaseBlock.vue +113 -6
- package/composables/useContestEditor.ts +19 -3
- package/package.json +8 -8
- package/pages/contests/[slug]/index.vue +54 -30
- package/pages/contests/index.vue +1 -0
- package/utils/contestBlocks.ts +10 -0
- package/utils/contestBody.ts +3 -3
- package/utils/contestImage.ts +45 -0
- package/utils/contestSubmissionTemplates.ts +165 -0
- package/utils/contestTemplates.ts +3 -0
|
@@ -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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
<
|
|
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
|
|
6
|
-
* immutable list ops.
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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/
|
|
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",
|