@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(
|
|
50
|
+
commit(withStageAdded(stages.value));
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
function duplicateStage(i: number): void {
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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 & 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:
|
|
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
|
-
|
|
567
|
-
.
|
|
568
|
-
|
|
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
|
-
|
|
407
|
-
.
|
|
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; }
|
package/utils/contestStages.ts
CHANGED
|
@@ -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',
|