@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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestAdvancementPanel — the edit-only Top-N / manual cohort cut, extracted from
|
|
4
|
+
* ContestEditor. Crucially it operates on the PERSISTED review stages + REAL entries
|
|
5
|
+
* (not the editable `stages` model), so it self-fetches entries and takes the
|
|
6
|
+
* persisted review stages as a prop; the parent passes `contest.value.stages`
|
|
7
|
+
* filtered to review. Emits `advanced` after a cut so the parent refetches the
|
|
8
|
+
* contest. Mounted inside the Stages tab, below the stage editor.
|
|
9
|
+
*/
|
|
10
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
11
|
+
|
|
12
|
+
type ReviewStage = Pick<ContestStage, 'id' | 'name' | 'advanceCount'>;
|
|
13
|
+
interface EntryLite { id: string; contentTitle: string; score?: number | null; eliminated?: boolean }
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
slug: string;
|
|
17
|
+
reviewStages: ReviewStage[];
|
|
18
|
+
}>();
|
|
19
|
+
const emit = defineEmits<{ advanced: [] }>();
|
|
20
|
+
|
|
21
|
+
const toast = useToast();
|
|
22
|
+
const { extract: extractError } = useApiError();
|
|
23
|
+
|
|
24
|
+
const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: EntryLite[] }>(
|
|
25
|
+
() => `/api/contests/${props.slug}/entries`,
|
|
26
|
+
);
|
|
27
|
+
const eligibleEntries = computed(() => (entriesData.value?.items ?? []).filter((e) => !e.eliminated));
|
|
28
|
+
|
|
29
|
+
const advancing = ref<string | null>(null);
|
|
30
|
+
const advanceN = ref<Record<string, number>>({});
|
|
31
|
+
const advanceMode = ref<Record<string, 'topN' | 'manual'>>({});
|
|
32
|
+
const manualPick = ref<Record<string, string[]>>({});
|
|
33
|
+
|
|
34
|
+
function toggleManual(stageId: string, entryId: string): void {
|
|
35
|
+
const cur = manualPick.value[stageId] ?? [];
|
|
36
|
+
manualPick.value[stageId] = cur.includes(entryId) ? cur.filter((x) => x !== entryId) : [...cur, entryId];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function postAdvance(stageId: string, body: Record<string, unknown>): Promise<void> {
|
|
40
|
+
advancing.value = stageId;
|
|
41
|
+
try {
|
|
42
|
+
const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(
|
|
43
|
+
`/api/contests/${props.slug}/advance`,
|
|
44
|
+
{ method: 'POST', body },
|
|
45
|
+
);
|
|
46
|
+
toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
|
|
47
|
+
await refreshEntries();
|
|
48
|
+
emit('advanced');
|
|
49
|
+
} catch (err: unknown) {
|
|
50
|
+
toast.error(extractError(err));
|
|
51
|
+
} finally {
|
|
52
|
+
advancing.value = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function advanceStage(stageId: string): Promise<void> {
|
|
57
|
+
const topN = advanceN.value[stageId];
|
|
58
|
+
if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
|
|
59
|
+
if (!confirm(`Advance the top ${topN} entries from this stage? Entries below the cut are marked "not advanced" and drop out of later judging + final results. You can re-run this.`)) return;
|
|
60
|
+
await postAdvance(stageId, { reviewStageId: stageId, mode: 'topN', topN });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function advanceStageManual(stageId: string): Promise<void> {
|
|
64
|
+
const ids = manualPick.value[stageId] ?? [];
|
|
65
|
+
if (!ids.length) { toast.error('Select at least one entry to advance.'); return; }
|
|
66
|
+
if (!confirm(`Advance the ${ids.length} selected ${ids.length === 1 ? 'entry' : 'entries'}? The rest of the cohort is marked "not advanced" and drops out of later judging + final results.`)) return;
|
|
67
|
+
await postAdvance(stageId, { reviewStageId: stageId, mode: 'manual', advancedEntryIds: ids });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Prefill each review stage's Top-N from its persisted advanceCount, when present.
|
|
71
|
+
watch(() => props.reviewStages, (stages) => {
|
|
72
|
+
for (const s of stages) {
|
|
73
|
+
if (typeof s.advanceCount === 'number' && advanceN.value[s.id] === undefined) advanceN.value[s.id] = s.advanceCount;
|
|
74
|
+
}
|
|
75
|
+
}, { immediate: true });
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<div v-if="reviewStages.length" class="cpub-advance-section">
|
|
80
|
+
<h3 class="cpub-form-subtitle"><i class="fa-solid fa-arrow-up-right-dots"></i> Advancement</h3>
|
|
81
|
+
<p class="cpub-form-hint">After judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced". Re-running re-computes the cut. (Save any stage changes above first.)</p>
|
|
82
|
+
<div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-block">
|
|
83
|
+
<div class="cpub-advance-row">
|
|
84
|
+
<span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
|
|
85
|
+
<div class="cpub-advance-mode">
|
|
86
|
+
<label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="(advanceMode[rs.id] ?? 'topN') === 'topN'" @change="advanceMode[rs.id] = 'topN'" /> <span>Top N</span></label>
|
|
87
|
+
<label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="advanceMode[rs.id] === 'manual'" @change="advanceMode[rs.id] = 'manual'" /> <span>Pick manually</span></label>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div v-if="(advanceMode[rs.id] ?? 'topN') === 'topN'" class="cpub-advance-ctl">
|
|
91
|
+
<label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
|
|
92
|
+
<input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
|
|
93
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
|
|
94
|
+
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div v-else class="cpub-advance-manual">
|
|
98
|
+
<p v-if="!eligibleEntries.length" class="cpub-form-hint" style="margin: 0;">No entries in the current cohort to pick from yet.</p>
|
|
99
|
+
<template v-else>
|
|
100
|
+
<label v-for="e in eligibleEntries" :key="e.id" class="cpub-advance-pick">
|
|
101
|
+
<input type="checkbox" :checked="(manualPick[rs.id] ?? []).includes(e.id)" @change="toggleManual(rs.id, e.id)" />
|
|
102
|
+
<span class="cpub-advance-pick-title">{{ e.contentTitle }}</span>
|
|
103
|
+
<span v-if="e.score != null" class="cpub-advance-pick-score">{{ e.score }}</span>
|
|
104
|
+
</label>
|
|
105
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id || !(manualPick[rs.id] ?? []).length" @click="advanceStageManual(rs.id)">
|
|
106
|
+
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : `Advance ${(manualPick[rs.id] ?? []).length} selected` }}
|
|
107
|
+
</button>
|
|
108
|
+
</template>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<style scoped>
|
|
115
|
+
/* Form-control + advancement styles travel with the markup (scoped CSS is per
|
|
116
|
+
component; the global theme only ships .cpub-form-label/-hint/-btn). */
|
|
117
|
+
.cpub-form-input { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
118
|
+
.cpub-form-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
119
|
+
.cpub-form-subtitle { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; margin: 0 0 8px; }
|
|
120
|
+
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
|
121
|
+
.cpub-form-check input { width: 14px; height: 14px; flex-shrink: 0; }
|
|
122
|
+
|
|
123
|
+
.cpub-advance-section { margin-top: 20px; padding-top: 16px; border-top: var(--border-width-default) solid var(--border2); }
|
|
124
|
+
.cpub-advance-block { padding: 12px 0; border-top: var(--border-width-default) solid var(--border); }
|
|
125
|
+
.cpub-advance-block:first-of-type { border-top: 0; }
|
|
126
|
+
.cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
127
|
+
.cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
|
128
|
+
.cpub-advance-name i { color: var(--accent); font-size: 11px; }
|
|
129
|
+
.cpub-advance-mode { display: inline-flex; gap: 12px; }
|
|
130
|
+
.cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
131
|
+
.cpub-advance-ctl .cpub-form-label { margin: 0; }
|
|
132
|
+
.cpub-advance-n { width: 80px; }
|
|
133
|
+
.cpub-advance-manual { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
134
|
+
.cpub-advance-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); padding: 4px 8px; border: var(--border-width-default) solid var(--border); background: var(--surface2); cursor: pointer; }
|
|
135
|
+
.cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
136
|
+
.cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
|
|
137
|
+
.cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestBannerAdjust — non-destructive framing control for a contest banner or
|
|
4
|
+
* cover (P4). A live preview at the target aspect ratio + a zoom slider (0 = fit/
|
|
5
|
+
* contain) + drag-to-reposition. Writes a `ContestImageMeta` ({ zoom, x, y })
|
|
6
|
+
* v-model; the original image is never re-cropped. Render parity with the public
|
|
7
|
+
* hero is guaranteed by sharing `imageFramingStyle` (utils/contestImage.ts).
|
|
8
|
+
*/
|
|
9
|
+
import type { ContestImageMeta } from '@commonpub/schema';
|
|
10
|
+
import { imageFramingStyle, defaultImageMeta } from '../../utils/contestImage';
|
|
11
|
+
|
|
12
|
+
defineProps<{
|
|
13
|
+
imageUrl: string;
|
|
14
|
+
/** CSS aspect-ratio for the preview box, e.g. '4 / 1' (banner), '4 / 3' (cover). */
|
|
15
|
+
aspect?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
}>();
|
|
18
|
+
const meta = defineModel<ContestImageMeta | null>({ default: null });
|
|
19
|
+
|
|
20
|
+
const ZOOM_MAX = 1.5;
|
|
21
|
+
const current = computed<ContestImageMeta>(() => meta.value ?? defaultImageMeta());
|
|
22
|
+
const framing = computed(() => imageFramingStyle(meta.value));
|
|
23
|
+
|
|
24
|
+
function patch(p: Partial<ContestImageMeta>): void {
|
|
25
|
+
meta.value = { ...current.value, ...p };
|
|
26
|
+
}
|
|
27
|
+
function onZoom(e: Event): void {
|
|
28
|
+
patch({ zoom: Number((e.target as HTMLInputElement).value) });
|
|
29
|
+
}
|
|
30
|
+
function reset(): void {
|
|
31
|
+
meta.value = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Drag to reposition (sets x/y as percent) ───
|
|
35
|
+
const boxRef = ref<HTMLElement | null>(null);
|
|
36
|
+
const dragging = ref(false);
|
|
37
|
+
let startX = 0;
|
|
38
|
+
let startY = 0;
|
|
39
|
+
let startPosX = 50;
|
|
40
|
+
let startPosY = 50;
|
|
41
|
+
|
|
42
|
+
function onPointerDown(e: PointerEvent): void {
|
|
43
|
+
if (!boxRef.value) return;
|
|
44
|
+
dragging.value = true;
|
|
45
|
+
startX = e.clientX;
|
|
46
|
+
startY = e.clientY;
|
|
47
|
+
startPosX = current.value.x;
|
|
48
|
+
startPosY = current.value.y;
|
|
49
|
+
document.addEventListener('pointermove', onPointerMove);
|
|
50
|
+
document.addEventListener('pointerup', onPointerUp);
|
|
51
|
+
}
|
|
52
|
+
function onPointerMove(e: PointerEvent): void {
|
|
53
|
+
if (!dragging.value || !boxRef.value) return;
|
|
54
|
+
const rect = boxRef.value.getBoundingClientRect();
|
|
55
|
+
// Drag right reveals the LEFT of the image (object-position decreases), so invert.
|
|
56
|
+
const dx = ((e.clientX - startX) / Math.max(1, rect.width)) * 100;
|
|
57
|
+
const dy = ((e.clientY - startY) / Math.max(1, rect.height)) * 100;
|
|
58
|
+
patch({ x: clamp(startPosX - dx), y: clamp(startPosY - dy) });
|
|
59
|
+
}
|
|
60
|
+
function onPointerUp(): void {
|
|
61
|
+
dragging.value = false;
|
|
62
|
+
document.removeEventListener('pointermove', onPointerMove);
|
|
63
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
64
|
+
}
|
|
65
|
+
function clamp(n: number): number {
|
|
66
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
67
|
+
}
|
|
68
|
+
onUnmounted(() => {
|
|
69
|
+
document.removeEventListener('pointermove', onPointerMove);
|
|
70
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const zoomLabel = computed(() => (current.value.zoom <= 0 ? 'Fit' : `${Math.round(current.value.zoom * 100)}%`));
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<div class="cpub-ba">
|
|
78
|
+
<div
|
|
79
|
+
ref="boxRef"
|
|
80
|
+
class="cpub-ba-box"
|
|
81
|
+
:class="{ 'cpub-ba-drag': dragging }"
|
|
82
|
+
:style="{ aspectRatio: aspect ?? '4 / 1' }"
|
|
83
|
+
@pointerdown="onPointerDown"
|
|
84
|
+
>
|
|
85
|
+
<img :src="imageUrl" :alt="label ?? 'Image preview'" class="cpub-ba-img" :style="framing" draggable="false" />
|
|
86
|
+
<span class="cpub-ba-hint"><i class="fa-solid fa-up-down-left-right"></i> Drag to reposition</span>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="cpub-ba-controls">
|
|
89
|
+
<label class="cpub-ba-zoom">
|
|
90
|
+
<span class="cpub-ba-zoom-label">Zoom <strong>{{ zoomLabel }}</strong></span>
|
|
91
|
+
<input
|
|
92
|
+
type="range"
|
|
93
|
+
min="0"
|
|
94
|
+
:max="ZOOM_MAX"
|
|
95
|
+
step="0.05"
|
|
96
|
+
:value="current.zoom"
|
|
97
|
+
:aria-label="`${label ?? 'Image'} zoom (0 is fit)`"
|
|
98
|
+
@input="onZoom"
|
|
99
|
+
/>
|
|
100
|
+
</label>
|
|
101
|
+
<button type="button" class="cpub-btn cpub-btn-sm cpub-ba-reset" :disabled="!meta" @click="reset">
|
|
102
|
+
<i class="fa-solid fa-rotate-left"></i> Reset
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
<p class="cpub-ba-help">Zoom 0 fits the whole image. Increase to fill and crop; drag the preview to choose what shows.</p>
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
108
|
+
|
|
109
|
+
<style scoped>
|
|
110
|
+
.cpub-ba { display: flex; flex-direction: column; gap: 8px; }
|
|
111
|
+
.cpub-ba-box { position: relative; width: 100%; overflow: hidden; border: var(--border-width-default) solid var(--border); background: var(--surface2); cursor: grab; touch-action: none; }
|
|
112
|
+
.cpub-ba-drag { cursor: grabbing; }
|
|
113
|
+
.cpub-ba-img { display: block; width: 100%; height: 100%; user-select: none; -webkit-user-drag: none; }
|
|
114
|
+
.cpub-ba-hint { position: absolute; left: 8px; bottom: 8px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text); background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 3px 7px; display: inline-flex; align-items: center; gap: 5px; opacity: .85; pointer-events: none; }
|
|
115
|
+
.cpub-ba-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
116
|
+
.cpub-ba-zoom { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 180px; }
|
|
117
|
+
.cpub-ba-zoom-label { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); white-space: nowrap; }
|
|
118
|
+
.cpub-ba-zoom input[type='range'] { flex: 1; accent-color: var(--accent); }
|
|
119
|
+
.cpub-ba-reset { flex-shrink: 0; }
|
|
120
|
+
.cpub-ba-help { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.5; }
|
|
121
|
+
</style>
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { BlockCanvas, type BlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// 'stages' is a FORM tab (not a block body): it renders the `#stages` slot and has
|
|
17
|
+
// no Write/Preview/Code mode. The three block tabs share one BlockCanvas.
|
|
18
|
+
type BodyTab = 'overview' | 'rules' | 'prizes' | 'stages';
|
|
17
19
|
type BodyMode = 'write' | 'preview' | 'code';
|
|
18
20
|
|
|
19
21
|
const props = defineProps<{
|
|
@@ -32,7 +34,10 @@ const TABS: { key: BodyTab; label: string; icon: string }[] = [
|
|
|
32
34
|
{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' },
|
|
33
35
|
{ key: 'rules', label: 'Rules', icon: 'fa-file-lines' },
|
|
34
36
|
{ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' },
|
|
37
|
+
{ key: 'stages', label: 'Stages', icon: 'fa-diagram-project' },
|
|
35
38
|
];
|
|
39
|
+
// Block tabs share the canvas + the Write/Preview/Code switch; 'stages' is a form.
|
|
40
|
+
const isBlockTab = computed(() => props.activeTab !== 'stages');
|
|
36
41
|
const MODES: { key: BodyMode; label: string; icon: string }[] = [
|
|
37
42
|
{ key: 'write', label: 'Write', icon: 'fa-pen' },
|
|
38
43
|
{ key: 'preview', label: 'Preview', icon: 'fa-eye' },
|
|
@@ -86,7 +91,7 @@ function onTabKey(e: KeyboardEvent, key: BodyTab): void {
|
|
|
86
91
|
<i class="fa-solid" :class="t.icon"></i> {{ t.label }}
|
|
87
92
|
</button>
|
|
88
93
|
</div>
|
|
89
|
-
<div class="cpub-cbc-mode" role="group" aria-label="Body view mode">
|
|
94
|
+
<div v-if="isBlockTab" class="cpub-cbc-mode" role="group" aria-label="Body view mode">
|
|
90
95
|
<button
|
|
91
96
|
v-for="m in MODES"
|
|
92
97
|
:key="m.key"
|
|
@@ -102,18 +107,22 @@ function onTabKey(e: KeyboardEvent, key: BodyTab): void {
|
|
|
102
107
|
</div>
|
|
103
108
|
|
|
104
109
|
<div class="cpub-cbc-panel" role="tabpanel" tabindex="0" :aria-labelledby="`cpub-cbc-tab-${activeTab}`">
|
|
105
|
-
<!--
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
<!-- Stages: a form tab (timeline + submission forms + advancement). -->
|
|
111
|
+
<slot v-if="activeTab === 'stages'" name="stages" />
|
|
112
|
+
<template v-else>
|
|
113
|
+
<!-- Overview-only lead (inline banner + cover); the parent fills the slot. -->
|
|
114
|
+
<div v-if="activeTab === 'overview' && mode === 'write'" class="cpub-cbc-lead">
|
|
115
|
+
<slot name="overview-lead" />
|
|
116
|
+
</div>
|
|
117
|
+
<!-- One canvas, keyed by tab so each body gets a clean canvas instance; the
|
|
118
|
+
underlying block state persists in the parent's hoisted editors. -->
|
|
119
|
+
<BlockCanvas v-show="mode === 'write'" :key="activeTab" :block-editor="editor" :block-types="groups" />
|
|
120
|
+
<div v-if="mode === 'preview'" class="cpub-cbc-preview">
|
|
121
|
+
<BlocksBlockContentRenderer v-if="previewBlocks.length" :blocks="previewBlocks" class="cpub-prose cpub-md" />
|
|
122
|
+
<p v-else class="cpub-cbc-empty">Nothing to preview yet. Switch to Write and add some blocks.</p>
|
|
123
|
+
</div>
|
|
124
|
+
<pre v-else-if="mode === 'code'" class="cpub-cbc-code" aria-label="Block content as JSON"><code>{{ codeJson }}</code></pre>
|
|
125
|
+
</template>
|
|
117
126
|
</div>
|
|
118
127
|
</div>
|
|
119
128
|
</template>
|