@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.58.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/auth": "0.8.0",
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-row">
348
- <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
349
- <div class="cpub-advance-ctl">
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-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-top: var(--border-width-default) solid var(--border); }
642
- .cpub-advance-row:first-of-type { border-top: 0; }
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-ctl { display: inline-flex; align-items: center; gap: 8px; }
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-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; }
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. */