@commonpub/layer 0.50.0 → 0.51.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.
|
@@ -18,18 +18,28 @@ const c = computed(() => props.contest);
|
|
|
18
18
|
|
|
19
19
|
// Countdown timer
|
|
20
20
|
const countdown = ref({ days: '00', hours: '00', mins: '00', secs: '00' });
|
|
21
|
+
const targetPassed = ref(false);
|
|
21
22
|
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
22
23
|
|
|
23
24
|
function pad(n: number): string { return String(n).padStart(2, '0'); }
|
|
24
25
|
|
|
26
|
+
// The countdown target depends on the lifecycle stage: an UPCOMING contest counts
|
|
27
|
+
// down to when it OPENS (startDate); while JUDGING, to the judging deadline;
|
|
28
|
+
// otherwise (active) to the submission close (endDate).
|
|
29
|
+
const countdownTargetStr = computed<string | null>(() => {
|
|
30
|
+
const s = c.value?.status;
|
|
31
|
+
if (s === 'judging') return c.value?.judgingEndDate ?? c.value?.endDate ?? null;
|
|
32
|
+
if (s === 'upcoming') return c.value?.startDate ?? null;
|
|
33
|
+
return c.value?.endDate ?? null;
|
|
34
|
+
});
|
|
35
|
+
|
|
25
36
|
function updateCountdown(): void {
|
|
26
|
-
|
|
27
|
-
// submission end date.
|
|
28
|
-
const isJudging = c.value?.status === 'judging';
|
|
29
|
-
const targetStr = isJudging ? (c.value?.judgingEndDate ?? c.value?.endDate) : c.value?.endDate;
|
|
37
|
+
const targetStr = countdownTargetStr.value;
|
|
30
38
|
const target = targetStr ? new Date(targetStr) : new Date();
|
|
31
39
|
const now = new Date();
|
|
32
|
-
|
|
40
|
+
const rawDiff = Math.floor((target.getTime() - now.getTime()) / 1000);
|
|
41
|
+
targetPassed.value = rawDiff <= 0;
|
|
42
|
+
let diff = Math.max(0, rawDiff);
|
|
33
43
|
const days = Math.floor(diff / 86400); diff %= 86400;
|
|
34
44
|
const hours = Math.floor(diff / 3600); diff %= 3600;
|
|
35
45
|
const mins = Math.floor(diff / 60);
|
|
@@ -47,16 +57,32 @@ onUnmounted(() => {
|
|
|
47
57
|
});
|
|
48
58
|
|
|
49
59
|
const countdownLabel = computed(() => {
|
|
50
|
-
|
|
51
|
-
if (
|
|
60
|
+
const s = c.value?.status;
|
|
61
|
+
if (s === 'completed' || s === 'cancelled') return 'Contest ended';
|
|
62
|
+
if (s === 'judging') return 'Judging ends in';
|
|
63
|
+
if (s === 'upcoming') return 'Opens in';
|
|
52
64
|
return 'Submissions close in';
|
|
53
65
|
});
|
|
54
66
|
|
|
55
67
|
const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
|
|
56
68
|
const isPaused = computed(() => c.value?.status === 'paused');
|
|
57
69
|
const isDraft = computed(() => c.value?.status === 'draft');
|
|
58
|
-
// Live countdown only
|
|
59
|
-
|
|
70
|
+
// Live countdown only while the clock is actually running AND its target is still
|
|
71
|
+
// in the future. Once the target passes (an upcoming contest whose open date has
|
|
72
|
+
// arrived, or an active one past its close), fall back to a static date note.
|
|
73
|
+
const showCountdown = computed(() => !isEnded.value && !isPaused.value && !isDraft.value && !!countdownTargetStr.value && !targetPassed.value);
|
|
74
|
+
|
|
75
|
+
function fmtDate(s: string | null | undefined): string {
|
|
76
|
+
if (!s) return '';
|
|
77
|
+
return new Date(s).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
78
|
+
}
|
|
79
|
+
// Static date shown when the relevant target is in the past but the contest hasn't
|
|
80
|
+
// been advanced yet (e.g. an upcoming contest whose open date arrived).
|
|
81
|
+
const dateNote = computed<string | null>(() => {
|
|
82
|
+
if (isEnded.value || isPaused.value || isDraft.value || !targetPassed.value) return null;
|
|
83
|
+
if (c.value?.status === 'upcoming') return c.value?.startDate ? `Opens ${fmtDate(c.value.startDate)}` : null;
|
|
84
|
+
return c.value?.endDate ? `Closed ${fmtDate(c.value.endDate)}` : null;
|
|
85
|
+
});
|
|
60
86
|
|
|
61
87
|
// Client-side mirror of the server VALID_TRANSITIONS map (server/src/contest/contest.ts).
|
|
62
88
|
// Keeps the inline admin controls in sync with what the API will actually accept —
|
|
@@ -194,6 +220,10 @@ const dateRange = computed<string>(() => {
|
|
|
194
220
|
<i class="fa-solid fa-pen-ruler"></i>
|
|
195
221
|
<span>Draft — not launched</span>
|
|
196
222
|
</div>
|
|
223
|
+
<div v-else-if="dateNote" class="cpub-countdown-ended">
|
|
224
|
+
<i class="fa-regular fa-calendar"></i>
|
|
225
|
+
<span>{{ dateNote }}</span>
|
|
226
|
+
</div>
|
|
197
227
|
<div v-else class="cpub-countdown-ended">
|
|
198
228
|
<i class="fa-solid fa-flag-checkered"></i>
|
|
199
229
|
<span>{{ countdownLabel }}</span>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
|
-
"@commonpub/docs": "0.6.3",
|
|
58
57
|
"@commonpub/config": "0.18.0",
|
|
59
|
-
"@commonpub/
|
|
58
|
+
"@commonpub/docs": "0.6.3",
|
|
60
59
|
"@commonpub/explainer": "0.7.15",
|
|
61
60
|
"@commonpub/editor": "0.7.11",
|
|
62
|
-
"@commonpub/
|
|
61
|
+
"@commonpub/learning": "0.5.2",
|
|
63
62
|
"@commonpub/schema": "0.28.0",
|
|
64
63
|
"@commonpub/protocol": "0.13.0",
|
|
65
|
-
"@commonpub/ui": "0.9.2"
|
|
64
|
+
"@commonpub/ui": "0.9.2",
|
|
65
|
+
"@commonpub/server": "2.75.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -473,10 +473,6 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
473
473
|
</div>
|
|
474
474
|
</section>
|
|
475
475
|
|
|
476
|
-
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
477
|
-
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
|
|
478
|
-
</button>
|
|
479
|
-
|
|
480
476
|
<section class="cpub-form-section cpub-danger-zone">
|
|
481
477
|
<h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
|
|
482
478
|
<div class="cpub-danger-row">
|
|
@@ -489,6 +485,19 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
489
485
|
</button>
|
|
490
486
|
</div>
|
|
491
487
|
</section>
|
|
488
|
+
|
|
489
|
+
<!-- Sticky save bar — always reachable without scrolling to the bottom. -->
|
|
490
|
+
<div class="cpub-edit-actionbar">
|
|
491
|
+
<span class="cpub-edit-actionbar-status">
|
|
492
|
+
Status <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
|
|
493
|
+
</span>
|
|
494
|
+
<div class="cpub-edit-actionbar-btns">
|
|
495
|
+
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-edit-cancel">Cancel</NuxtLink>
|
|
496
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
497
|
+
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : 'Save Changes' }}
|
|
498
|
+
</button>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
492
501
|
</form>
|
|
493
502
|
</div>
|
|
494
503
|
<div v-else class="cpub-not-found"><p>Contest not found</p></div>
|
|
@@ -557,8 +566,27 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
557
566
|
|
|
558
567
|
.cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
559
568
|
|
|
569
|
+
/* Sticky save bar — pinned to the viewport bottom while editing the long form. */
|
|
570
|
+
.cpub-edit-actionbar {
|
|
571
|
+
position: sticky;
|
|
572
|
+
bottom: 0;
|
|
573
|
+
z-index: 20;
|
|
574
|
+
display: flex;
|
|
575
|
+
align-items: center;
|
|
576
|
+
justify-content: space-between;
|
|
577
|
+
gap: 12px;
|
|
578
|
+
margin: 4px -32px -32px;
|
|
579
|
+
padding: 14px 32px;
|
|
580
|
+
background: var(--surface);
|
|
581
|
+
border-top: 2px solid var(--border);
|
|
582
|
+
box-shadow: var(--shadow-lg);
|
|
583
|
+
}
|
|
584
|
+
.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; }
|
|
585
|
+
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
586
|
+
|
|
560
587
|
@media (max-width: 768px) {
|
|
561
588
|
.cpub-contest-edit { padding: 16px; }
|
|
562
589
|
.cpub-form-row { grid-template-columns: 1fr; }
|
|
590
|
+
.cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
|
|
563
591
|
}
|
|
564
592
|
</style>
|
|
@@ -354,9 +354,15 @@ function prizeLabel(prize: Prize): string {
|
|
|
354
354
|
</div>
|
|
355
355
|
</section>
|
|
356
356
|
|
|
357
|
-
<
|
|
358
|
-
<
|
|
359
|
-
|
|
357
|
+
<div class="cpub-edit-actionbar">
|
|
358
|
+
<span class="cpub-edit-actionbar-hint">Required: title, start & end dates.</span>
|
|
359
|
+
<div class="cpub-edit-actionbar-btns">
|
|
360
|
+
<NuxtLink to="/contests" class="cpub-btn">Cancel</NuxtLink>
|
|
361
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
|
|
362
|
+
<i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating…' : 'Create Contest' }}
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
360
366
|
</form>
|
|
361
367
|
</div>
|
|
362
368
|
</template>
|
|
@@ -401,10 +407,29 @@ function prizeLabel(prize: Prize): string {
|
|
|
401
407
|
.cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
|
|
402
408
|
.cpub-delete-btn:hover { color: var(--red); }
|
|
403
409
|
|
|
410
|
+
/* Sticky create bar — Create button always reachable on the long form. */
|
|
411
|
+
.cpub-edit-actionbar {
|
|
412
|
+
position: sticky;
|
|
413
|
+
bottom: 0;
|
|
414
|
+
z-index: 20;
|
|
415
|
+
display: flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
justify-content: space-between;
|
|
418
|
+
gap: 12px;
|
|
419
|
+
margin: 4px -32px -32px;
|
|
420
|
+
padding: 14px 32px;
|
|
421
|
+
background: var(--surface);
|
|
422
|
+
border-top: 2px solid var(--border);
|
|
423
|
+
box-shadow: var(--shadow-lg);
|
|
424
|
+
}
|
|
425
|
+
.cpub-edit-actionbar-hint { font-size: 11px; color: var(--text-faint); }
|
|
426
|
+
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
427
|
+
|
|
404
428
|
@media (max-width: 768px) {
|
|
405
429
|
.cpub-contest-create { padding: 16px; }
|
|
406
430
|
.cpub-page-title { font-size: 20px; }
|
|
407
431
|
.cpub-form-section { padding: 14px; }
|
|
408
432
|
.cpub-form-row { grid-template-columns: 1fr; }
|
|
433
|
+
.cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
|
|
409
434
|
}
|
|
410
435
|
</style>
|