@commonpub/layer 0.58.0 → 0.60.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/package.json +7 -7
- package/pages/contests/[slug]/edit.vue +92 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
+
"@commonpub/auth": "0.8.0",
|
|
56
57
|
"@commonpub/config": "0.18.0",
|
|
57
58
|
"@commonpub/editor": "0.7.11",
|
|
59
|
+
"@commonpub/docs": "0.6.3",
|
|
60
|
+
"@commonpub/protocol": "0.13.0",
|
|
61
|
+
"@commonpub/server": "2.80.0",
|
|
62
|
+
"@commonpub/ui": "0.9.2",
|
|
58
63
|
"@commonpub/explainer": "0.7.15",
|
|
59
|
-
"@commonpub/learning": "0.5.2",
|
|
60
64
|
"@commonpub/schema": "0.33.0",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/server": "2.80.0",
|
|
63
|
-
"@commonpub/protocol": "0.13.0",
|
|
64
|
-
"@commonpub/docs": "0.6.3",
|
|
65
|
-
"@commonpub/ui": "0.9.2"
|
|
65
|
+
"@commonpub/learning": "0.5.2"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ContestStage } from '@commonpub/schema';
|
|
3
|
+
import type { Serialized, ContestEntryItem } from '@commonpub/server';
|
|
3
4
|
|
|
4
5
|
definePageMeta({ middleware: 'auth' });
|
|
5
6
|
|
|
@@ -64,9 +65,17 @@ const currentStageIdRef = ref<string | null>(null);
|
|
|
64
65
|
const advancing = ref<string | null>(null);
|
|
65
66
|
const advanceN = ref<Record<string, number>>({});
|
|
66
67
|
|
|
68
|
+
// Dirty tracking: any edit after the contest loads flips this so the save bar
|
|
69
|
+
// shows "unsaved changes" — feedback that a change (e.g. checking an eligible
|
|
70
|
+
// type) registered. `hydratingForm` suppresses the watcher while the loader
|
|
71
|
+
// populates the fields from the fetched contest.
|
|
72
|
+
const formDirty = ref(false);
|
|
73
|
+
let hydratingForm = false;
|
|
74
|
+
|
|
67
75
|
// Load current data
|
|
68
76
|
watch(contest, (c) => {
|
|
69
77
|
if (!c) return;
|
|
78
|
+
hydratingForm = true;
|
|
70
79
|
title.value = c.title ?? '';
|
|
71
80
|
slugInput.value = c.slug ?? '';
|
|
72
81
|
subheading.value = c.subheading ?? '';
|
|
@@ -103,8 +112,20 @@ watch(contest, (c) => {
|
|
|
103
112
|
weight: cr.weight ?? null,
|
|
104
113
|
description: cr.description ?? '',
|
|
105
114
|
}));
|
|
115
|
+
// Let the field watchers settle from this hydration, then re-arm dirty tracking.
|
|
116
|
+
void nextTick(() => { hydratingForm = false; });
|
|
106
117
|
}, { immediate: true });
|
|
107
118
|
|
|
119
|
+
// Mark the form dirty on any post-hydration edit (gives the save bar its
|
|
120
|
+
// "unsaved changes" cue). Worst case (timing) is a harmless early "dirty".
|
|
121
|
+
watch(
|
|
122
|
+
[title, slugInput, subheading, description, rules, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
|
|
123
|
+
communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
|
|
124
|
+
showPrizes, stages, currentStageIdRef, prizesDescription, prizes, criteria],
|
|
125
|
+
() => { if (!hydratingForm) formDirty.value = true; },
|
|
126
|
+
{ deep: true },
|
|
127
|
+
);
|
|
128
|
+
|
|
108
129
|
function addPrize(): void {
|
|
109
130
|
prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
|
|
110
131
|
}
|
|
@@ -189,6 +210,7 @@ async function handleSave(): Promise<void> {
|
|
|
189
210
|
},
|
|
190
211
|
});
|
|
191
212
|
toast.success('Contest updated');
|
|
213
|
+
formDirty.value = false;
|
|
192
214
|
// Slug changed → the old URL no longer resolves. Navigate to the renamed
|
|
193
215
|
// contest's page — a different route component, so it loads fresh. (Navigating
|
|
194
216
|
// to the new /edit URL would reuse THIS component with its stale fetch key.)
|
|
@@ -226,6 +248,35 @@ const statusAction = contestStatusAction;
|
|
|
226
248
|
// Phase B2 — advancement cuts. Operates on the PERSISTED stages (contest.value),
|
|
227
249
|
// not the editable `stages` ref, since it acts on real entries.
|
|
228
250
|
const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
|
|
251
|
+
|
|
252
|
+
// Entries (the cohort) — for the manual advancement picker. The eligible set is
|
|
253
|
+
// everyone not already eliminated by a prior round's cut.
|
|
254
|
+
const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[] }>(`/api/contests/${slug}/entries`);
|
|
255
|
+
const eligibleEntries = computed(() => (entriesData.value?.items ?? []).filter((e) => !e.eliminated));
|
|
256
|
+
const advanceMode = ref<Record<string, 'topN' | 'manual'>>({});
|
|
257
|
+
const manualPick = ref<Record<string, string[]>>({});
|
|
258
|
+
function toggleManual(stageId: string, entryId: string): void {
|
|
259
|
+
const cur = manualPick.value[stageId] ?? [];
|
|
260
|
+
manualPick.value[stageId] = cur.includes(entryId) ? cur.filter((x) => x !== entryId) : [...cur, entryId];
|
|
261
|
+
}
|
|
262
|
+
async function advanceStageManual(stageId: string): Promise<void> {
|
|
263
|
+
const ids = manualPick.value[stageId] ?? [];
|
|
264
|
+
if (!ids.length) { toast.error('Select at least one entry to advance.'); return; }
|
|
265
|
+
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;
|
|
266
|
+
advancing.value = stageId;
|
|
267
|
+
try {
|
|
268
|
+
const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug}/advance`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
body: { reviewStageId: stageId, mode: 'manual', advancedEntryIds: ids },
|
|
271
|
+
});
|
|
272
|
+
toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
|
|
273
|
+
await Promise.all([refresh(), refreshEntries()]);
|
|
274
|
+
} catch (err: unknown) {
|
|
275
|
+
toast.error(extractError(err));
|
|
276
|
+
} finally {
|
|
277
|
+
advancing.value = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
229
280
|
async function advanceStage(stageId: string): Promise<void> {
|
|
230
281
|
const topN = advanceN.value[stageId];
|
|
231
282
|
if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
|
|
@@ -237,7 +288,7 @@ async function advanceStage(stageId: string): Promise<void> {
|
|
|
237
288
|
body: { reviewStageId: stageId, mode: 'topN', topN },
|
|
238
289
|
});
|
|
239
290
|
toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
|
|
240
|
-
await refresh();
|
|
291
|
+
await Promise.all([refresh(), refreshEntries()]);
|
|
241
292
|
} catch (err: unknown) {
|
|
242
293
|
toast.error(extractError(err));
|
|
243
294
|
} finally {
|
|
@@ -344,15 +395,36 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
344
395
|
<section v-if="reviewStages.length" class="cpub-form-section">
|
|
345
396
|
<h2 class="cpub-form-section-title">Advancement</h2>
|
|
346
397
|
<p class="cpub-form-hint">Multi-round contests: after judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced" and excluded from later judging + final results. Re-running re-computes the cut. (Save any stage changes above first.)</p>
|
|
347
|
-
<div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-
|
|
348
|
-
<
|
|
349
|
-
|
|
398
|
+
<div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-block">
|
|
399
|
+
<div class="cpub-advance-row">
|
|
400
|
+
<span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
|
|
401
|
+
<div class="cpub-advance-mode">
|
|
402
|
+
<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>
|
|
403
|
+
<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>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<div v-if="(advanceMode[rs.id] ?? 'topN') === 'topN'" class="cpub-advance-ctl">
|
|
350
408
|
<label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
|
|
351
409
|
<input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
|
|
352
410
|
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
|
|
353
411
|
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
|
|
354
412
|
</button>
|
|
355
413
|
</div>
|
|
414
|
+
|
|
415
|
+
<div v-else class="cpub-advance-manual">
|
|
416
|
+
<p v-if="!eligibleEntries.length" class="cpub-form-hint" style="margin: 0;">No entries in the current cohort to pick from yet.</p>
|
|
417
|
+
<template v-else>
|
|
418
|
+
<label v-for="e in eligibleEntries" :key="e.id" class="cpub-advance-pick">
|
|
419
|
+
<input type="checkbox" :checked="(manualPick[rs.id] ?? []).includes(e.id)" @change="toggleManual(rs.id, e.id)" />
|
|
420
|
+
<span class="cpub-advance-pick-title">{{ e.contentTitle }}</span>
|
|
421
|
+
<span v-if="e.score != null" class="cpub-advance-pick-score">{{ e.score }}</span>
|
|
422
|
+
</label>
|
|
423
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id || !(manualPick[rs.id] ?? []).length" @click="advanceStageManual(rs.id)">
|
|
424
|
+
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : `Advance ${(manualPick[rs.id] ?? []).length} selected` }}
|
|
425
|
+
</button>
|
|
426
|
+
</template>
|
|
427
|
+
</div>
|
|
356
428
|
</div>
|
|
357
429
|
</section>
|
|
358
430
|
|
|
@@ -543,11 +615,12 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
543
615
|
<div class="cpub-edit-actionbar">
|
|
544
616
|
<span class="cpub-edit-actionbar-status">
|
|
545
617
|
Status <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
|
|
618
|
+
<span v-if="formDirty" class="cpub-edit-dirty"><i class="fa-solid fa-circle"></i> Unsaved changes</span>
|
|
546
619
|
</span>
|
|
547
620
|
<div class="cpub-edit-actionbar-btns">
|
|
548
621
|
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-edit-cancel">Cancel</NuxtLink>
|
|
549
|
-
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
550
|
-
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : 'Save Changes' }}
|
|
622
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError || !formDirty">
|
|
623
|
+
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : formDirty ? 'Save Changes' : 'Saved' }}
|
|
551
624
|
</button>
|
|
552
625
|
</div>
|
|
553
626
|
</div>
|
|
@@ -638,14 +711,23 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
638
711
|
border-top: 2px solid var(--border);
|
|
639
712
|
box-shadow: var(--shadow-lg);
|
|
640
713
|
}
|
|
641
|
-
.cpub-advance-
|
|
642
|
-
.cpub-advance-
|
|
714
|
+
.cpub-advance-block { padding: 12px 0; border-top: var(--border-width-default) solid var(--border); }
|
|
715
|
+
.cpub-advance-block:first-of-type { border-top: 0; }
|
|
716
|
+
.cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
643
717
|
.cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
|
644
718
|
.cpub-advance-name i { color: var(--accent); font-size: 11px; }
|
|
645
|
-
.cpub-advance-
|
|
719
|
+
.cpub-advance-mode { display: inline-flex; gap: 12px; }
|
|
720
|
+
.cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
646
721
|
.cpub-advance-ctl .cpub-form-label { margin: 0; }
|
|
647
722
|
.cpub-advance-n { width: 80px; }
|
|
648
|
-
.cpub-
|
|
723
|
+
.cpub-advance-manual { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
724
|
+
.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; }
|
|
725
|
+
.cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
726
|
+
.cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
|
|
727
|
+
.cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
|
|
728
|
+
.cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
729
|
+
.cpub-edit-dirty { color: var(--accent); display: inline-flex; align-items: center; gap: 5px; }
|
|
730
|
+
.cpub-edit-dirty i { font-size: 6px; }
|
|
649
731
|
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
650
732
|
|
|
651
733
|
/* Collapse the meta rail under the main column on narrower viewports. */
|