@commonpub/layer 0.25.1 → 0.26.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.
@@ -23,7 +23,11 @@ let countdownInterval: ReturnType<typeof setInterval> | null = null;
23
23
  function pad(n: number): string { return String(n).padStart(2, '0'); }
24
24
 
25
25
  function updateCountdown(): void {
26
- const target = c.value?.endDate ? new Date(c.value.endDate) : new Date();
26
+ // During judging, count down to the judging deadline (if set); otherwise the
27
+ // submission end date.
28
+ const isJudging = c.value?.status === 'judging';
29
+ const targetStr = isJudging ? (c.value?.judgingEndDate ?? c.value?.endDate) : c.value?.endDate;
30
+ const target = targetStr ? new Date(targetStr) : new Date();
27
31
  const now = new Date();
28
32
  let diff = Math.max(0, Math.floor((target.getTime() - now.getTime()) / 1000));
29
33
  const days = Math.floor(diff / 86400); diff %= 86400;
@@ -126,7 +130,7 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
126
130
  <div class="cpub-hero-stat-label">Entries</div>
127
131
  </div>
128
132
  <div class="cpub-hero-stat">
129
- <div class="cpub-hero-stat-val">{{ c?.status ?? 'draft' }}</div>
133
+ <div class="cpub-hero-stat-val">{{ c?.status ?? 'upcoming' }}</div>
130
134
  <div class="cpub-hero-stat-label">Status</div>
131
135
  </div>
132
136
  </div>
@@ -6,6 +6,8 @@ const props = defineProps<{
6
6
  isOwner: boolean;
7
7
  }>();
8
8
 
9
+ const emit = defineEmits<{ (e: 'changed'): void }>();
10
+
9
11
  const toast = useToast();
10
12
  const { data: judges, refresh } = useLazyFetch<ContestJudgeItem[]>(
11
13
  `/api/contests/${props.contestSlug}/judges`,
@@ -49,10 +51,11 @@ async function addJudge(userId: string): Promise<void> {
49
51
  method: 'POST',
50
52
  body: { userId, role: newJudgeRole.value },
51
53
  });
52
- toast.success('Judge added');
54
+ toast.success('Judge invited');
53
55
  searchQuery.value = '';
54
56
  searchResults.value = [];
55
57
  await refresh();
58
+ emit('changed');
56
59
  } catch {
57
60
  toast.error('Failed to add judge');
58
61
  } finally {
@@ -66,6 +69,7 @@ async function removeJudge(userId: string): Promise<void> {
66
69
  await ($fetch as Function)(`/api/contests/${props.contestSlug}/judges/${userId}`, { method: 'DELETE' });
67
70
  toast.success('Judge removed');
68
71
  await refresh();
72
+ emit('changed');
69
73
  } catch {
70
74
  toast.error('Failed to remove judge');
71
75
  }
@@ -1,35 +1,30 @@
1
1
  <script setup lang="ts">
2
- const props = defineProps<{
3
- judgeIds: string[];
4
- }>();
5
-
6
- interface JudgeInfo {
7
- id: string;
8
- username: string;
9
- displayName: string | null;
10
- avatarUrl: string | null;
11
- }
2
+ import type { ContestJudgeItem } from '@commonpub/server';
12
3
 
13
- const { data: judgesData } = useLazyFetch<{ items: JudgeInfo[] }>('/api/users', {
14
- query: computed(() => ({ ids: props.judgeIds.join(','), limit: 50 })),
15
- immediate: props.judgeIds.length > 0,
16
- });
4
+ defineProps<{
5
+ judges: ContestJudgeItem[];
6
+ }>();
17
7
 
18
- const judges = computed<JudgeInfo[]>(() => judgesData.value?.items ?? []);
8
+ const roleLabels: Record<string, string> = {
9
+ lead: 'Lead Judge',
10
+ judge: 'Judge',
11
+ guest: 'Guest Judge',
12
+ };
19
13
  </script>
20
14
 
21
15
  <template>
22
- <div v-if="judgeIds.length > 0" class="cpub-judges-section">
16
+ <div v-if="judges.length > 0" class="cpub-judges-section">
23
17
  <div class="cpub-sec-head">
24
18
  <h2><i class="fa-solid fa-gavel" style="color: var(--accent);"></i> Judges</h2>
25
19
  </div>
26
20
  <div class="cpub-judges-grid">
27
21
  <div v-for="judge in judges" :key="judge.id" class="cpub-judge-card">
28
22
  <div class="cpub-judge-av">
29
- <img v-if="judge.avatarUrl" :src="judge.avatarUrl" :alt="judge.displayName || judge.username" class="cpub-judge-av-img" />
30
- <span v-else>{{ (judge.displayName || judge.username || '?').charAt(0).toUpperCase() }}</span>
23
+ <img v-if="judge.userAvatar" :src="judge.userAvatar" :alt="judge.userName" class="cpub-judge-av-img" />
24
+ <span v-else>{{ (judge.userName || '?').charAt(0).toUpperCase() }}</span>
31
25
  </div>
32
- <NuxtLink :to="`/u/${judge.username}`" class="cpub-judge-name">{{ judge.displayName || judge.username }}</NuxtLink>
26
+ <NuxtLink :to="`/u/${judge.userUsername}`" class="cpub-judge-name">{{ judge.userName }}</NuxtLink>
27
+ <div class="cpub-judge-role">{{ roleLabels[judge.role] || judge.role }}</div>
33
28
  </div>
34
29
  </div>
35
30
  </div>
@@ -43,8 +38,9 @@ const judges = computed<JudgeInfo[]>(() => judgesData.value?.items ?? []);
43
38
  .cpub-judge-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 14px; text-align: center; box-shadow: var(--shadow-md); }
44
39
  .cpub-judge-av { width: 44px; height: 44px; border-radius: 50%; margin: 0 auto 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; font-family: var(--font-mono); border: var(--border-width-default) solid var(--border); background: var(--surface3); color: var(--text-dim); overflow: hidden; }
45
40
  .cpub-judge-av-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
46
- .cpub-judge-name { font-size: 11px; font-weight: 600; color: var(--text); text-decoration: none; }
41
+ .cpub-judge-name { font-size: 11px; font-weight: 600; color: var(--text); text-decoration: none; display: block; }
47
42
  .cpub-judge-name:hover { color: var(--accent); }
43
+ .cpub-judge-role { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); margin-top: 3px; }
48
44
 
49
45
  @media (max-width: 768px) { .cpub-judges-grid { grid-template-columns: 1fr 1fr; } }
50
46
  @media (max-width: 480px) { .cpub-judges-grid { grid-template-columns: 1fr; } }
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import type { ContestJudgingCriterion } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ criteria: ContestJudgingCriterion[];
6
+ /** Render in a denser, frameless layout (used as in-page guidance). */
7
+ compact?: boolean;
8
+ }>();
9
+
10
+ const totalWeight = computed(() =>
11
+ props.criteria.reduce((sum, c) => sum + (c.weight ?? 0), 0),
12
+ );
13
+ const hasWeights = computed(() => props.criteria.some((c) => (c.weight ?? 0) > 0));
14
+ </script>
15
+
16
+ <template>
17
+ <div v-if="criteria.length > 0" class="cpub-criteria-section" :class="{ 'cpub-criteria-compact': compact }">
18
+ <div v-if="!compact" class="cpub-sec-head">
19
+ <h2><i class="fa-solid fa-list-check" style="color: var(--teal);"></i> Judging Criteria</h2>
20
+ <span v-if="hasWeights" class="cpub-sec-sub">{{ totalWeight }} pts total</span>
21
+ </div>
22
+ <div class="cpub-criteria-card">
23
+ <div v-for="(crit, i) in criteria" :key="i" class="cpub-criterion">
24
+ <div class="cpub-criterion-head">
25
+ <span class="cpub-criterion-label">{{ crit.label }}</span>
26
+ <span v-if="crit.weight != null && crit.weight > 0" class="cpub-criterion-weight">{{ crit.weight }} pts</span>
27
+ </div>
28
+ <p v-if="crit.description" class="cpub-criterion-desc">{{ crit.description }}</p>
29
+ <div v-if="hasWeights && crit.weight" class="cpub-criterion-bar">
30
+ <div class="cpub-criterion-bar-fill" :style="{ width: `${Math.min(100, (crit.weight / Math.max(totalWeight, 1)) * 100)}%` }"></div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
39
+ .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
40
+ .cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
41
+
42
+ .cpub-criteria-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; box-shadow: var(--shadow-md); display: flex; flex-direction: column; gap: 14px; }
43
+ .cpub-criteria-compact .cpub-criteria-card { box-shadow: none; border-style: dashed; margin-bottom: 0; }
44
+
45
+ .cpub-criterion-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
46
+ .cpub-criterion-label { font-size: 13px; font-weight: 600; color: var(--text); }
47
+ .cpub-criterion-weight { font-size: 10px; font-family: var(--font-mono); font-weight: 700; color: var(--accent); white-space: nowrap; }
48
+ .cpub-criterion-desc { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 4px 0 0; }
49
+ .cpub-criterion-bar { height: 4px; background: var(--surface2); border: var(--border-width-default) solid var(--border); margin-top: 8px; overflow: hidden; }
50
+ .cpub-criterion-bar-fill { height: 100%; background: var(--accent); }
51
+ </style>
@@ -1,26 +1,31 @@
1
1
  <script setup lang="ts">
2
+ interface Prize { place?: number; category?: string; title: string; description?: string; value?: string }
2
3
  defineProps<{
3
- prizes: Array<{ place: number; title: string; description?: string; value?: string }>;
4
+ prizes: Prize[];
4
5
  }>();
5
6
 
6
- function placeLabel(place: number): string {
7
+ function prizeLabel(prize: Prize): string {
8
+ if (prize.category) return prize.category.toUpperCase();
9
+ const place = prize.place;
7
10
  if (place === 1) return '1ST PLACE';
8
11
  if (place === 2) return '2ND PLACE';
9
12
  if (place === 3) return '3RD PLACE';
10
- return `${place}TH PLACE`;
13
+ if (place) return `${place}TH PLACE`;
14
+ return 'PRIZE';
11
15
  }
12
16
 
13
- function placeColor(place: number): string {
14
- if (place === 1) return 'gold';
15
- if (place === 2) return 'silver';
16
- if (place === 3) return 'bronze';
17
+ function prizeColor(prize: Prize): string {
18
+ if (prize.category) return 'default';
19
+ if (prize.place === 1) return 'gold';
20
+ if (prize.place === 2) return 'silver';
21
+ if (prize.place === 3) return 'bronze';
17
22
  return 'default';
18
23
  }
19
24
 
20
- function placeIcon(place: number): string {
21
- if (place === 1) return 'fa-trophy';
22
- if (place === 2) return 'fa-medal';
23
- if (place === 3) return 'fa-award';
25
+ function prizeIcon(prize: Prize): string {
26
+ if (prize.place === 1) return 'fa-trophy';
27
+ if (prize.place === 2) return 'fa-medal';
28
+ if (prize.place === 3) return 'fa-award';
24
29
  return 'fa-star';
25
30
  }
26
31
  </script>
@@ -32,14 +37,14 @@ function placeIcon(place: number): string {
32
37
  </div>
33
38
  <div class="cpub-prize-grid">
34
39
  <div
35
- v-for="prize in prizes"
36
- :key="prize.place"
40
+ v-for="(prize, i) in prizes"
41
+ :key="i"
37
42
  class="cpub-prize-card"
38
- :class="`cpub-prize-${placeColor(prize.place)}`"
43
+ :class="`cpub-prize-${prizeColor(prize)}`"
39
44
  >
40
- <div class="cpub-prize-rank" :class="`cpub-prize-rank-${placeColor(prize.place)}`">{{ placeLabel(prize.place) }}</div>
41
- <div class="cpub-prize-icon" :class="`cpub-prize-icon-${placeColor(prize.place)}`"><i class="fa-solid" :class="placeIcon(prize.place)"></i></div>
42
- <div v-if="prize.value" class="cpub-prize-amount" :class="`cpub-prize-amount-${placeColor(prize.place)}`">{{ prize.value }}</div>
45
+ <div class="cpub-prize-rank" :class="`cpub-prize-rank-${prizeColor(prize)}`">{{ prizeLabel(prize) }}</div>
46
+ <div class="cpub-prize-icon" :class="`cpub-prize-icon-${prizeColor(prize)}`"><i class="fa-solid" :class="prizeIcon(prize)"></i></div>
47
+ <div v-if="prize.value" class="cpub-prize-amount" :class="`cpub-prize-amount-${prizeColor(prize)}`">{{ prize.value }}</div>
43
48
  <div class="cpub-prize-title">{{ prize.title }}</div>
44
49
  <div v-if="prize.description" class="cpub-prize-desc">{{ prize.description }}</div>
45
50
  </div>
@@ -1,16 +1,43 @@
1
1
  <script setup lang="ts">
2
2
  import type { Serialized, ContestDetail } from '@commonpub/server';
3
3
 
4
- defineProps<{
4
+ const props = defineProps<{
5
5
  contest: Serialized<ContestDetail> | null;
6
6
  isOwner?: boolean;
7
- isJudge?: boolean;
7
+ /** True when the viewer is an accepted, non-guest judge able to score. */
8
+ canJudge?: boolean;
8
9
  }>();
9
10
 
10
11
  const emit = defineEmits<{
11
12
  (e: 'copy-link'): void;
12
13
  }>();
13
14
 
15
+ type StepState = 'done' | 'current' | 'upcoming';
16
+ interface TimelineStep { label: string; date: string | null; state: StepState; icon: string }
17
+
18
+ function fmt(d: string | null | undefined): string | null {
19
+ if (!d) return null;
20
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
21
+ }
22
+
23
+ // Ordinal position of each status along the lifecycle, used to mark steps
24
+ // done / current / upcoming.
25
+ const STATUS_ORDER: Record<string, number> = { upcoming: 0, active: 1, judging: 2, completed: 3 };
26
+
27
+ const timeline = computed<TimelineStep[]>(() => {
28
+ const c = props.contest;
29
+ if (!c || c.status === 'cancelled') return [];
30
+ const pos = STATUS_ORDER[c.status] ?? 0;
31
+ const stepState = (idx: number): StepState => (idx < pos ? 'done' : idx === pos ? 'current' : 'upcoming');
32
+ const steps: TimelineStep[] = [
33
+ { label: 'Opens', date: fmt(c.startDate), state: stepState(0), icon: 'fa-flag' },
34
+ { label: 'Submissions close', date: fmt(c.endDate), state: stepState(1), icon: 'fa-pen-to-square' },
35
+ { label: 'Judging', date: fmt(c.judgingEndDate) ?? fmt(c.endDate), state: stepState(2), icon: 'fa-gavel' },
36
+ { label: 'Results', date: null, state: stepState(3), icon: 'fa-ranking-star' },
37
+ ];
38
+ return steps;
39
+ });
40
+
14
41
  function statusClass(status: string): string {
15
42
  const map: Record<string, string> = {
16
43
  upcoming: 'cpub-status-upcoming',
@@ -25,7 +52,7 @@ function statusClass(status: string): string {
25
52
 
26
53
  <template>
27
54
  <div class="cpub-sidebar">
28
- <!-- STATUS -->
55
+ <!-- STATUS + TIMELINE -->
29
56
  <div class="cpub-sb-card">
30
57
  <div class="cpub-sb-title"><i class="fa-solid fa-circle-info"></i> Status</div>
31
58
  <div class="cpub-sb-body">
@@ -33,11 +60,24 @@ function statusClass(status: string): string {
33
60
  <strong>Status:</strong>
34
61
  <span class="cpub-sb-status" :class="statusClass(contest?.status ?? '')">{{ contest?.status ?? 'unknown' }}</span>
35
62
  </div>
36
- <div v-if="contest?.startDate" class="cpub-sb-row"><strong>Starts:</strong> {{ new Date(contest.startDate).toLocaleDateString() }}</div>
37
- <div v-if="contest?.endDate" class="cpub-sb-row"><strong>Ends:</strong> {{ new Date(contest.endDate).toLocaleDateString() }}</div>
38
- <div v-if="contest?.judgingEndDate" class="cpub-sb-row"><strong>Judging ends:</strong> {{ new Date(contest.judgingEndDate).toLocaleDateString() }}</div>
39
63
  <div class="cpub-sb-row"><strong>Entries:</strong> {{ contest?.entryCount ?? 0 }}</div>
40
64
  </div>
65
+
66
+ <ol v-if="timeline.length" class="cpub-timeline">
67
+ <li
68
+ v-for="step in timeline"
69
+ :key="step.label"
70
+ class="cpub-tl-step"
71
+ :class="`cpub-tl-${step.state}`"
72
+ >
73
+ <span class="cpub-tl-dot"><i class="fa-solid" :class="step.icon"></i></span>
74
+ <div class="cpub-tl-content">
75
+ <div class="cpub-tl-label">{{ step.label }}<span v-if="step.state === 'current'" class="cpub-tl-now">Now</span></div>
76
+ <div v-if="step.date" class="cpub-tl-date">{{ step.date }}</div>
77
+ </div>
78
+ </li>
79
+ </ol>
80
+ <p v-else-if="contest?.status === 'cancelled'" class="cpub-sb-cancelled">This contest was cancelled.</p>
41
81
  </div>
42
82
 
43
83
  <!-- LINKS -->
@@ -52,7 +92,7 @@ function statusClass(status: string): string {
52
92
  <i class="fa-solid fa-pen-to-square"></i> Edit Contest
53
93
  </NuxtLink>
54
94
 
55
- <NuxtLink v-if="isJudge && (contest?.status === 'judging')" :to="`/contests/${contest?.slug}/judge`" class="cpub-btn cpub-sb-link cpub-sb-judge">
95
+ <NuxtLink v-if="canJudge && (contest?.status === 'judging')" :to="`/contests/${contest?.slug}/judge`" class="cpub-btn cpub-sb-link cpub-sb-judge">
56
96
  <i class="fa-solid fa-gavel"></i> Judge Entries
57
97
  </NuxtLink>
58
98
 
@@ -76,6 +116,23 @@ function statusClass(status: string): string {
76
116
  .cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
77
117
  .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
78
118
 
119
+ /* TIMELINE */
120
+ .cpub-timeline { list-style: none; margin: 14px 0 0; padding: 0; }
121
+ .cpub-tl-step { display: flex; gap: 10px; position: relative; padding-bottom: 14px; }
122
+ .cpub-tl-step:not(:last-child)::before { content: ''; position: absolute; left: 11px; top: 22px; bottom: 0; width: var(--border-width-default); background: var(--border2); }
123
+ .cpub-tl-dot { width: 23px; height: 23px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border: var(--border-width-default) solid var(--border2); background: var(--surface); color: var(--text-faint); font-size: 9px; border-radius: 50%; z-index: 1; }
124
+ .cpub-tl-content { padding-top: 2px; }
125
+ .cpub-tl-label { font-size: 12px; font-weight: 600; color: var(--text-dim); display: flex; align-items: center; gap: 6px; }
126
+ .cpub-tl-date { font-size: 10px; font-family: var(--font-mono); color: var(--text-faint); margin-top: 1px; }
127
+ .cpub-tl-now { font-size: 8px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .08em; color: var(--accent); border: var(--border-width-default) solid var(--accent-border); background: var(--accent-bg); padding: 1px 5px; }
128
+
129
+ .cpub-tl-done .cpub-tl-dot { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
130
+ .cpub-tl-done .cpub-tl-label { color: var(--text); }
131
+ .cpub-tl-current .cpub-tl-dot { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
132
+ .cpub-tl-current .cpub-tl-label { color: var(--text); font-weight: 700; }
133
+
134
+ .cpub-sb-cancelled { font-size: 11px; color: var(--red); margin: 10px 0 0; }
135
+
79
136
  .cpub-sb-actions { display: flex; gap: 6px; flex-wrap: wrap; }
80
137
  .cpub-sb-btn { flex: 1; justify-content: center; }
81
138
  .cpub-sb-link { width: 100%; text-align: center; display: block; margin-top: 12px; }
@@ -4,7 +4,12 @@ import type { HomepageSectionConfig } from '@commonpub/server';
4
4
  const props = defineProps<{ config: HomepageSectionConfig }>();
5
5
 
6
6
  const limit = computed(() => props.config.limit ?? 3);
7
- const { data: contests } = await useFetch('/api/contests', { query: { limit }, lazy: true });
7
+ // Only surface contests that are open for entries the card is titled
8
+ // "Active Contests" and links to "Enter Contest".
9
+ const { data: contests } = await useFetch('/api/contests', {
10
+ query: computed(() => ({ limit: limit.value, status: 'active' })),
11
+ lazy: true,
12
+ });
8
13
  </script>
9
14
 
10
15
  <template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.25.1",
3
+ "version": "0.26.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/config": "0.15.0",
57
- "@commonpub/editor": "0.7.11",
58
- "@commonpub/schema": "0.18.0",
59
- "@commonpub/server": "2.59.0",
60
- "@commonpub/ui": "0.9.1",
56
+ "@commonpub/auth": "0.6.0",
61
57
  "@commonpub/docs": "0.6.3",
58
+ "@commonpub/editor": "0.7.11",
62
59
  "@commonpub/learning": "0.5.2",
63
- "@commonpub/auth": "0.6.0",
60
+ "@commonpub/protocol": "0.12.0",
64
61
  "@commonpub/explainer": "0.7.15",
65
- "@commonpub/protocol": "0.12.0"
62
+ "@commonpub/ui": "0.9.1",
63
+ "@commonpub/schema": "0.19.0",
64
+ "@commonpub/server": "2.60.0",
65
+ "@commonpub/config": "0.15.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",