@commonpub/layer 0.52.0 → 0.53.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.
@@ -91,6 +91,7 @@ function confirmWithdraw(entryId: string): void {
91
91
  v-for="(entry, i) in entries"
92
92
  :key="entry.id"
93
93
  class="cpub-entry-card"
94
+ :class="{ 'cpub-entry-out': entry.eliminated }"
94
95
  >
95
96
  <div class="cpub-entry-thumb" :class="i % 2 === 0 ? 'cpub-entry-bg-light' : 'cpub-entry-bg-dark'">
96
97
  <img v-if="entry.contentCoverImageUrl" :src="entry.contentCoverImageUrl" :alt="entry.contentTitle" class="cpub-entry-cover-img" />
@@ -99,6 +100,8 @@ function confirmWithdraw(entryId: string): void {
99
100
  <div class="cpub-entry-icon"><i class="fa-solid fa-microchip"></i></div>
100
101
  </template>
101
102
  <span v-if="entry.rank" class="cpub-entry-rank" :class="`cpub-rank-${entry.rank <= 3 ? entry.rank : 'other'}`">#{{ entry.rank }}</span>
103
+ <span v-if="entry.eliminated" class="cpub-entry-cohort cpub-cohort-out"><i class="fa-solid fa-circle-minus"></i> Not advanced</span>
104
+ <span v-else-if="entry.stageState && entry.stageState.some((s) => s.status === 'advanced')" class="cpub-entry-cohort cpub-cohort-in"><i class="fa-solid fa-circle-check"></i> Advanced</span>
102
105
  </div>
103
106
  <div class="cpub-entry-body">
104
107
  <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
@@ -165,6 +168,12 @@ function confirmWithdraw(entryId: string): void {
165
168
  .cpub-rank-2 { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--text-faint); }
166
169
  .cpub-rank-3 { background: var(--surface2); color: var(--bronze); border: var(--border-width-default) solid var(--bronze); }
167
170
  .cpub-rank-other { background: var(--surface2); color: var(--text-dim); border: var(--border-width-default) solid var(--border); }
171
+ .cpub-entry-cohort { position: absolute; top: 8px; right: 8px; z-index: 2; font-size: 9px; font-family: var(--font-mono); font-weight: 700; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 4px; }
172
+ .cpub-cohort-in { background: var(--green-bg); color: var(--green); border: var(--border-width-default) solid var(--green-border); }
173
+ .cpub-cohort-out { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--border2); }
174
+ .cpub-entry-cohort i { font-size: 8px; }
175
+ .cpub-entry-out { opacity: .6; }
176
+ .cpub-entry-out:hover { opacity: 1; }
168
177
  .cpub-entry-body { padding: 10px 12px; }
169
178
  .cpub-entry-title { font-size: 12px; font-weight: 600; margin-bottom: 3px; line-height: 1.3; color: var(--text); text-decoration: none; }
170
179
  .cpub-entry-title:hover { color: var(--accent); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.52.0",
3
+ "version": "0.53.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/editor": "0.7.11",
57
- "@commonpub/auth": "0.8.0",
58
56
  "@commonpub/config": "0.18.0",
59
- "@commonpub/docs": "0.6.3",
57
+ "@commonpub/learning": "0.5.2",
58
+ "@commonpub/editor": "0.7.11",
59
+ "@commonpub/schema": "0.30.0",
60
60
  "@commonpub/protocol": "0.13.0",
61
- "@commonpub/schema": "0.29.0",
62
- "@commonpub/server": "2.76.0",
61
+ "@commonpub/docs": "0.6.3",
62
+ "@commonpub/explainer": "0.7.15",
63
+ "@commonpub/server": "2.77.0",
63
64
  "@commonpub/ui": "0.9.2",
64
- "@commonpub/learning": "0.5.2",
65
- "@commonpub/explainer": "0.7.15"
65
+ "@commonpub/auth": "0.8.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -216,6 +216,30 @@ async function handleDelete(): Promise<void> {
216
216
  const availableTransitions = computed<string[]>(() => contestTransitionsFrom(contest.value?.status));
217
217
  const statusAction = contestStatusAction;
218
218
 
219
+ // Phase B2 — advancement cuts. Operates on the PERSISTED stages (contest.value),
220
+ // not the editable `stages` ref, since it acts on real entries.
221
+ const advancing = ref<string | null>(null);
222
+ const advanceN = ref<Record<string, number>>({});
223
+ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
224
+ async function advanceStage(stageId: string): Promise<void> {
225
+ const topN = advanceN.value[stageId];
226
+ if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
227
+ 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;
228
+ advancing.value = stageId;
229
+ try {
230
+ const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug}/advance`, {
231
+ method: 'POST',
232
+ body: { reviewStageId: stageId, mode: 'topN', topN },
233
+ });
234
+ toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
235
+ await refresh();
236
+ } catch (err: unknown) {
237
+ toast.error(extractError(err));
238
+ } finally {
239
+ advancing.value = null;
240
+ }
241
+ }
242
+
219
243
  async function transitionStatus(newStatus: string): Promise<void> {
220
244
  // Only the consequential transitions confirm; reversible nudges (pause/resume,
221
245
  // go-back) just apply.
@@ -309,6 +333,21 @@ async function transitionStatus(newStatus: string): Promise<void> {
309
333
  />
310
334
  </section>
311
335
 
336
+ <section v-if="reviewStages.length" class="cpub-form-section">
337
+ <h2 class="cpub-form-section-title">Advancement</h2>
338
+ <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>
339
+ <div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-row">
340
+ <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
341
+ <div class="cpub-advance-ctl">
342
+ <label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
343
+ <input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
344
+ <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
345
+ <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
346
+ </button>
347
+ </div>
348
+ </div>
349
+ </section>
350
+
312
351
  <section class="cpub-form-section">
313
352
  <h2 class="cpub-form-section-title">Entries</h2>
314
353
  <div class="cpub-form-field">
@@ -583,6 +622,13 @@ async function transitionStatus(newStatus: string): Promise<void> {
583
622
  border-top: 2px solid var(--border);
584
623
  box-shadow: var(--shadow-lg);
585
624
  }
625
+ .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); }
626
+ .cpub-advance-row:first-of-type { border-top: 0; }
627
+ .cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
628
+ .cpub-advance-name i { color: var(--accent); font-size: 11px; }
629
+ .cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; }
630
+ .cpub-advance-ctl .cpub-form-label { margin: 0; }
631
+ .cpub-advance-n { width: 80px; }
586
632
  .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; }
587
633
  .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
588
634
 
@@ -0,0 +1,22 @@
1
+ import { getContestBySlug, advanceContestStage } from '@commonpub/server';
2
+ import { contestAdvanceSchema } from '@commonpub/schema';
3
+
4
+ // Phase B2 — apply an advancement cut at a review stage (cull the cohort to top-N
5
+ // or a manual pick, snapshot scores, advance to the next stage). Owner-gated.
6
+ export default defineEventHandler(async (event): Promise<{ advanced: boolean; advancedCount: number; eliminatedCount: number }> => {
7
+ requireFeature('contests');
8
+ const db = useDB();
9
+ const user = requireAuth(event);
10
+ const { slug } = parseParams(event, { slug: 'string' });
11
+ const input = await parseBody(event, contestAdvanceSchema);
12
+
13
+ const contest = await getContestBySlug(db, slug);
14
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+
16
+ const result = await advanceContestStage(db, contest.id, user.id, input);
17
+ if (!result.advanced) {
18
+ const owner = /owner/i.test(result.error ?? '');
19
+ throw createError({ statusCode: owner ? 403 : 400, statusMessage: result.error || 'Advancement failed' });
20
+ }
21
+ return result;
22
+ });