@commonpub/layer 0.53.0 → 0.54.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.
@@ -20,11 +20,6 @@ const props = defineProps<{
20
20
 
21
21
  const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
22
22
 
23
- function newId(): string {
24
- const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
25
- return c?.randomUUID?.() ?? `s-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
26
- }
27
-
28
23
  // datetime-local <-> ISO (mirrors the rest of the contest forms' convention).
29
24
  function toLocal(iso?: string): string {
30
25
  if (!iso) return '';
@@ -50,39 +45,28 @@ function onDate(i: number, field: 'startsAt' | 'endsAt', e: Event): void {
50
45
  setField(i, { [field]: v } as Partial<ContestStage>);
51
46
  }
52
47
 
48
+ // Array operations live as pure functions in utils/contestStages.ts (unit-tested).
53
49
  function addStage(): void {
54
- commit([...stages.value, { id: newId(), name: '', kind: 'custom' }]);
50
+ commit(withStageAdded(stages.value));
55
51
  }
56
52
 
57
53
  function duplicateStage(i: number): void {
58
- const src = stages.value[i];
59
- if (!src) return;
60
- const copy: ContestStage = { ...src, id: newId(), name: `${src.name} (copy)`, core: false };
61
- commit([...stages.value.slice(0, i + 1), copy, ...stages.value.slice(i + 1)]);
54
+ commit(withStageDuplicated(stages.value, i));
62
55
  }
63
56
 
64
57
  function removeStage(i: number): void {
65
58
  const removed = stages.value[i];
66
- commit(stages.value.filter((_, idx) => idx !== i));
59
+ commit(withStageRemoved(stages.value, i));
67
60
  if (removed && currentId.value === removed.id) currentId.value = null;
68
61
  }
69
62
 
70
63
  function move(i: number, dir: -1 | 1): void {
71
- const j = i + dir;
72
- if (j < 0 || j >= stages.value.length) return;
73
- const next = [...stages.value];
74
- [next[i], next[j]] = [next[j]!, next[i]!];
75
- commit(next);
64
+ commit(withStageMoved(stages.value, i, dir));
76
65
  }
77
66
 
78
67
  // Seed the editor with the standard three stages so the owner has a starting point.
79
68
  function customize(): void {
80
- const iso = (d?: string | null) => (d ? new Date(d).toISOString() : undefined);
81
- commit([
82
- { id: newId(), name: 'Submissions', kind: 'submission', startsAt: iso(props.startDate), endsAt: iso(props.endDate) },
83
- { id: newId(), name: 'Judging', kind: 'review', endsAt: iso(props.judgingEndDate) ?? iso(props.endDate) },
84
- { id: newId(), name: 'Results', kind: 'results' },
85
- ]);
69
+ commit(seedStandardStages(props));
86
70
  }
87
71
 
88
72
  function resetToStandard(): void {
@@ -108,6 +92,13 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
108
92
  </div>
109
93
 
110
94
  <template v-else>
95
+ <div class="cpub-stage-tophead">
96
+ <span class="cpub-stage-count">{{ stages.length }} stage{{ stages.length === 1 ? '' : 's' }}</span>
97
+ <div class="cpub-stage-toolbar">
98
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addStage"><i class="fa-solid fa-plus"></i> Add stage</button>
99
+ <button type="button" class="cpub-btn cpub-btn-sm cpub-stage-reset" @click="resetToStandard"><i class="fa-solid fa-rotate-left"></i> Reset to standard</button>
100
+ </div>
101
+ </div>
111
102
  <p v-if="missingSubmission" class="cpub-form-error" role="alert" style="margin: 0 0 10px;">
112
103
  Add at least one <strong>Submissions</strong> stage, or reset to the standard flow.
113
104
  </p>
@@ -193,7 +184,6 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
193
184
 
194
185
  <div class="cpub-stage-toolbar">
195
186
  <button type="button" class="cpub-btn cpub-btn-sm" @click="addStage"><i class="fa-solid fa-plus"></i> Add stage</button>
196
- <button type="button" class="cpub-btn cpub-btn-sm cpub-stage-reset" @click="resetToStandard"><i class="fa-solid fa-rotate-left"></i> Reset to standard flow</button>
197
187
  </div>
198
188
  </template>
199
189
  </div>
@@ -201,6 +191,8 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
201
191
 
202
192
  <style scoped>
203
193
  .cpub-stages-standard { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
194
+ .cpub-stage-tophead { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
195
+ .cpub-stage-count { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); }
204
196
  .cpub-stage-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
205
197
  .cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
206
198
  .cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.53.0",
3
+ "version": "0.54.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
- "@commonpub/learning": "0.5.2",
58
58
  "@commonpub/editor": "0.7.11",
59
- "@commonpub/schema": "0.30.0",
60
- "@commonpub/protocol": "0.13.0",
61
59
  "@commonpub/docs": "0.6.3",
60
+ "@commonpub/protocol": "0.13.0",
62
61
  "@commonpub/explainer": "0.7.15",
62
+ "@commonpub/learning": "0.5.2",
63
63
  "@commonpub/server": "2.77.0",
64
64
  "@commonpub/ui": "0.9.2",
65
- "@commonpub/auth": "0.8.0"
65
+ "@commonpub/schema": "0.30.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -268,6 +268,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
268
268
  </p>
269
269
 
270
270
  <form class="cpub-edit-form" @submit.prevent="handleSave">
271
+ <div class="cpub-edit-layout">
272
+ <div class="cpub-edit-main">
271
273
  <section class="cpub-form-section">
272
274
  <h2 class="cpub-form-section-title">Details</h2>
273
275
  <div class="cpub-form-field">
@@ -348,24 +350,6 @@ async function transitionStatus(newStatus: string): Promise<void> {
348
350
  </div>
349
351
  </section>
350
352
 
351
- <section class="cpub-form-section">
352
- <h2 class="cpub-form-section-title">Entries</h2>
353
- <div class="cpub-form-field">
354
- <span class="cpub-form-label">Eligible content types</span>
355
- <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
356
- <div class="cpub-type-options" role="group" aria-label="Eligible content types">
357
- <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
358
- <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
359
- <span>{{ t.label }}</span>
360
- </label>
361
- </div>
362
- </div>
363
- <div class="cpub-form-field">
364
- <label class="cpub-form-label">Max entries per person</label>
365
- <input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
366
- </div>
367
- </section>
368
-
369
353
  <section class="cpub-form-section">
370
354
  <h2 class="cpub-form-section-title">Prizes</h2>
371
355
  <label class="cpub-form-check" style="margin-bottom: 10px;">
@@ -482,6 +466,26 @@ async function transitionStatus(newStatus: string): Promise<void> {
482
466
  <p class="cpub-form-hint">Invited judges receive a notification and must accept before they can score.</p>
483
467
  <ContestJudgeManager :contest-slug="slug" :is-owner="true" />
484
468
  </section>
469
+ </div><!-- /cpub-edit-main -->
470
+
471
+ <aside class="cpub-edit-side">
472
+ <section class="cpub-form-section">
473
+ <h2 class="cpub-form-section-title">Entries</h2>
474
+ <div class="cpub-form-field">
475
+ <span class="cpub-form-label">Eligible content types</span>
476
+ <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
477
+ <div class="cpub-type-options" role="group" aria-label="Eligible content types">
478
+ <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
479
+ <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
480
+ <span>{{ t.label }}</span>
481
+ </label>
482
+ </div>
483
+ </div>
484
+ <div class="cpub-form-field">
485
+ <label class="cpub-form-label">Max entries per person</label>
486
+ <input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
487
+ </div>
488
+ </section>
485
489
 
486
490
  <section class="cpub-form-section">
487
491
  <h2 class="cpub-form-section-title">Stage &amp; Status</h2>
@@ -526,6 +530,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
526
530
  </button>
527
531
  </div>
528
532
  </section>
533
+ </aside><!-- /cpub-edit-side -->
534
+ </div><!-- /cpub-edit-layout -->
529
535
 
530
536
  <!-- Sticky save bar — always reachable without scrolling to the bottom. -->
531
537
  <div class="cpub-edit-actionbar">
@@ -545,7 +551,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
545
551
  </template>
546
552
 
547
553
  <style scoped>
548
- .cpub-contest-edit { max-width: 700px; margin: 0 auto; padding: 32px; }
554
+ .cpub-contest-edit { max-width: 1080px; margin: 0 auto; padding: 32px; }
549
555
  .cpub-back-link { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 16px; }
550
556
  .cpub-back-link:hover { color: var(--accent); }
551
557
  .cpub-edit-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
@@ -561,15 +567,16 @@ async function transitionStatus(newStatus: string): Promise<void> {
561
567
  .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
562
568
 
563
569
  .cpub-edit-form { display: flex; flex-direction: column; gap: 16px; }
570
+ /* Two-column editor: wide content column + a sticky meta rail (Stage & Status,
571
+ Entry rules, Danger Zone) so lifecycle controls stay reachable while editing. */
572
+ .cpub-edit-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; }
573
+ .cpub-edit-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
574
+ .cpub-edit-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 76px; }
564
575
  .cpub-form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
565
576
  .cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 14px; }
566
- .cpub-form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
567
- .cpub-form-field:last-child { margin-bottom: 0; }
568
- .cpub-form-label { font-size: 10px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
569
- .cpub-form-input, .cpub-form-textarea { padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; }
570
- .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; }
571
- .cpub-form-textarea { resize: vertical; }
572
- .cpub-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
577
+ /* `.cpub-form-input/-textarea/-field/-row/-label` are sourced from the global
578
+ theme (forms.css) so the extracted ContestStagesEditor inherits them across the
579
+ Vue scoped-style boundary don't re-scope them here. */
573
580
 
574
581
  .cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
575
582
  .cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
@@ -632,6 +639,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
632
639
  .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; }
633
640
  .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
634
641
 
642
+ /* Collapse the meta rail under the main column on narrower viewports. */
643
+ @media (max-width: 900px) {
644
+ .cpub-edit-layout { grid-template-columns: 1fr; }
645
+ .cpub-edit-side { position: static; }
646
+ }
635
647
  @media (max-width: 768px) {
636
648
  .cpub-contest-edit { padding: 16px; }
637
649
  .cpub-form-row { grid-template-columns: 1fr; }
@@ -403,13 +403,8 @@ function prizeLabel(prize: Prize): string {
403
403
  .cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
404
404
  .cpub-form-section-header .cpub-form-section-title { margin-bottom: 0; }
405
405
 
406
- .cpub-form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
407
- .cpub-form-field:last-child { margin-bottom: 0; }
408
- .cpub-form-label { font-size: 10px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
409
- .cpub-form-input, .cpub-form-textarea { padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; }
410
- .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; }
411
- .cpub-form-textarea { resize: vertical; }
412
- .cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
406
+ /* `.cpub-form-input/-textarea/-field/-row/-label` come from the global theme
407
+ (forms.css) shared with the extracted ContestStagesEditor. */
413
408
 
414
409
  .cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
415
410
  .cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
@@ -49,6 +49,50 @@ export function currentStageId(c: StageSource): string | null {
49
49
  }
50
50
  }
51
51
 
52
+ // ─── Pure stage-array operations (used by ContestStagesEditor; unit-tested) ───
53
+
54
+ export function newStageId(): string {
55
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
56
+ return c?.randomUUID?.() ?? `s-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
57
+ }
58
+
59
+ export function blankStage(): ContestStage {
60
+ return { id: newStageId(), name: '', kind: 'custom' };
61
+ }
62
+
63
+ /** The three standard stages seeded when an operator chooses to customise. */
64
+ export function seedStandardStages(c: { startDate?: string | null; endDate?: string | null; judgingEndDate?: string | null }): ContestStage[] {
65
+ const i = (d?: string | null): string | undefined => (d ? new Date(d).toISOString() : undefined);
66
+ return [
67
+ { id: newStageId(), name: 'Submissions', kind: 'submission', startsAt: i(c.startDate), endsAt: i(c.endDate) },
68
+ { id: newStageId(), name: 'Judging', kind: 'review', endsAt: i(c.judgingEndDate) ?? i(c.endDate) },
69
+ { id: newStageId(), name: 'Results', kind: 'results' },
70
+ ];
71
+ }
72
+
73
+ export function withStageAdded(stages: ContestStage[]): ContestStage[] {
74
+ return [...stages, blankStage()];
75
+ }
76
+
77
+ export function withStageDuplicated(stages: ContestStage[], i: number): ContestStage[] {
78
+ const src = stages[i];
79
+ if (!src) return stages;
80
+ const copy: ContestStage = { ...src, id: newStageId(), name: `${src.name} (copy)`, core: false };
81
+ return [...stages.slice(0, i + 1), copy, ...stages.slice(i + 1)];
82
+ }
83
+
84
+ export function withStageRemoved(stages: ContestStage[], i: number): ContestStage[] {
85
+ return stages.filter((_, idx) => idx !== i);
86
+ }
87
+
88
+ export function withStageMoved(stages: ContestStage[], i: number, dir: -1 | 1): ContestStage[] {
89
+ const j = i + dir;
90
+ if (j < 0 || j >= stages.length) return stages;
91
+ const next = [...stages];
92
+ [next[i], next[j]] = [next[j]!, next[i]!];
93
+ return next;
94
+ }
95
+
52
96
  /** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
53
97
  export const STAGE_KIND_ICON: Record<ContestStage['kind'], string> = {
54
98
  submission: 'fa-pen-to-square',