@commonpub/layer 0.8.3 → 0.8.4

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.
Files changed (75) hide show
  1. package/components/ContentCard.vue +1 -1
  2. package/components/ImageUpload.vue +1 -1
  3. package/components/ShareToHubModal.vue +1 -1
  4. package/components/blocks/BlockCodeView.vue +26 -25
  5. package/components/contest/ContestEntries.vue +112 -0
  6. package/components/contest/ContestHero.vue +204 -0
  7. package/components/contest/ContestJudges.vue +51 -0
  8. package/components/contest/ContestPrizes.vue +82 -0
  9. package/components/contest/ContestRules.vue +34 -0
  10. package/components/contest/ContestSidebar.vue +83 -0
  11. package/components/editors/BlogEditor.vue +1 -1
  12. package/components/editors/DocsPageTree.vue +10 -0
  13. package/components/hub/HubHero.vue +1 -1
  14. package/composables/useSanitize.ts +112 -9
  15. package/layouts/default.vue +7 -7
  16. package/middleware/feature-gate.global.ts +24 -0
  17. package/package.json +9 -9
  18. package/pages/[type]/index.vue +4 -3
  19. package/pages/admin/audit.vue +3 -2
  20. package/pages/admin/federation.vue +9 -1
  21. package/pages/admin/index.vue +7 -1
  22. package/pages/admin/reports.vue +152 -36
  23. package/pages/admin/settings.vue +17 -5
  24. package/pages/admin/theme.vue +5 -3
  25. package/pages/auth/forgot-password.vue +35 -35
  26. package/pages/auth/login.vue +6 -5
  27. package/pages/auth/reset-password.vue +44 -32
  28. package/pages/contests/[slug]/edit.vue +238 -56
  29. package/pages/contests/[slug]/index.vue +54 -450
  30. package/pages/contests/[slug]/judge.vue +141 -53
  31. package/pages/contests/[slug]/results.vue +182 -0
  32. package/pages/contests/create.vue +64 -64
  33. package/pages/contests/index.vue +2 -1
  34. package/pages/docs/[siteSlug]/[...pagePath].vue +6 -5
  35. package/pages/docs/[siteSlug]/edit.vue +58 -2
  36. package/pages/docs/[siteSlug]/index.vue +6 -5
  37. package/pages/federated-hubs/[id]/posts/[postId].vue +2 -2
  38. package/pages/hubs/index.vue +3 -2
  39. package/pages/index.vue +25 -7
  40. package/pages/learn/index.vue +1 -1
  41. package/pages/mirror/[id].vue +3 -3
  42. package/pages/notifications.vue +15 -1
  43. package/pages/settings/notifications.vue +7 -1
  44. package/pages/tags/[slug].vue +3 -2
  45. package/pages/tags/index.vue +3 -2
  46. package/pages/videos/[id].vue +18 -0
  47. package/server/api/admin/content/[id].patch.ts +1 -1
  48. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  49. package/server/api/admin/federation/refederate.post.ts +7 -3
  50. package/server/api/admin/federation/repair-types.post.ts +2 -45
  51. package/server/api/admin/federation/retry.post.ts +7 -4
  52. package/server/api/admin/reports.get.ts +1 -0
  53. package/server/api/auth/sign-in-username.post.ts +42 -0
  54. package/server/api/content/[id]/products-sync.post.ts +7 -6
  55. package/server/api/contests/[slug]/entries/[entryId].delete.ts +14 -0
  56. package/server/api/contests/[slug]/entries.get.ts +6 -1
  57. package/server/api/contests/[slug]/judge.post.ts +8 -2
  58. package/server/api/docs/[siteSlug]/nav.get.ts +1 -1
  59. package/server/api/docs/[siteSlug]/pages/[pageId]/duplicate.post.ts +16 -0
  60. package/server/api/docs/[siteSlug]/pages/reorder.post.ts +4 -1
  61. package/server/api/docs/migrate-content.post.ts +1 -7
  62. package/server/api/federation/hub-follow-status.get.ts +2 -18
  63. package/server/api/federation/hub-follow.post.ts +9 -27
  64. package/server/api/federation/hub-post-like.post.ts +9 -98
  65. package/server/api/federation/hub-post-likes.get.ts +3 -13
  66. package/server/api/notifications/read.post.ts +6 -1
  67. package/server/api/search/index.get.ts +2 -2
  68. package/server/api/search/trending.get.ts +3 -3
  69. package/server/api/users/index.get.ts +9 -2
  70. package/server/middleware/content-ap.ts +2 -2
  71. package/server/routes/.well-known/webfinger.ts +2 -2
  72. package/theme/base.css +23 -0
  73. package/components/EditorPropertiesPanel.vue +0 -393
  74. package/components/views/BlogView.vue +0 -735
  75. package/server/api/resolve-identity.post.ts +0 -34
@@ -194,7 +194,7 @@ function formatCount(n: number | undefined): string {
194
194
  align-items: center;
195
195
  gap: 4px;
196
196
  background: var(--color-badge-overlay, rgba(0, 0, 0, 0.75));
197
- color: #fff;
197
+ color: var(--color-text-inverse);
198
198
  backdrop-filter: blur(4px);
199
199
  }
200
200
 
@@ -174,7 +174,7 @@ function clearImage(): void {
174
174
  display: flex;
175
175
  align-items: center;
176
176
  justify-content: center;
177
- color: #fff;
177
+ color: var(--color-text-inverse);
178
178
  font-family: var(--font-mono);
179
179
  font-size: 0.6875rem;
180
180
  font-weight: 600;
@@ -38,7 +38,7 @@ async function handleShare(): Promise<void> {
38
38
  <div class="cpub-modal-content">
39
39
  <div class="cpub-modal-header">
40
40
  <h3 class="cpub-modal-title">Share to Hub</h3>
41
- <button class="cpub-modal-close" @click="emit('close')"><i class="fa-solid fa-xmark"></i></button>
41
+ <button class="cpub-modal-close" aria-label="Close" @click="emit('close')"><i class="fa-solid fa-xmark"></i></button>
42
42
  </div>
43
43
 
44
44
  <p class="cpub-modal-desc">Share "{{ contentTitle }}" to one of your hubs.</p>
@@ -106,39 +106,40 @@ async function copyCode(): Promise<void> {
106
106
  </template>
107
107
 
108
108
  <style>
109
- /* highlight.js github-dark theme — MUST be unscoped so hljs classes apply to v-html content */
109
+ /* highlight.js theme — MUST be unscoped so hljs classes apply to v-html content */
110
+ /* Colors use --hljs-* / --code-* tokens from base.css — overridable by themes */
110
111
  pre.hljs {
111
- background: #0d1117;
112
- color: #e6edf3;
112
+ background: var(--code-bg);
113
+ color: var(--code-text);
113
114
  }
114
115
  pre.hljs .hljs-comment,
115
- pre.hljs .hljs-quote { color: #8b949e; font-style: italic; }
116
+ pre.hljs .hljs-quote { color: var(--hljs-comment); font-style: italic; }
116
117
  pre.hljs .hljs-keyword,
117
118
  pre.hljs .hljs-selector-tag,
118
- pre.hljs .hljs-type { color: #ff7b72; }
119
+ pre.hljs .hljs-type { color: var(--hljs-keyword); }
119
120
  pre.hljs .hljs-literal,
120
121
  pre.hljs .hljs-number,
121
- pre.hljs .hljs-tag .hljs-attr { color: #79c0ff; }
122
+ pre.hljs .hljs-tag .hljs-attr { color: var(--hljs-literal); }
122
123
  pre.hljs .hljs-string,
123
- pre.hljs .hljs-addition { color: #a5d6ff; }
124
- pre.hljs .hljs-deletion { color: #ffa198; }
124
+ pre.hljs .hljs-addition { color: var(--hljs-string); }
125
+ pre.hljs .hljs-deletion { color: var(--hljs-deletion); }
125
126
  pre.hljs .hljs-regexp,
126
- pre.hljs .hljs-link { color: #a5d6ff; }
127
- pre.hljs .hljs-meta { color: #d2a8ff; }
127
+ pre.hljs .hljs-link { color: var(--hljs-string); }
128
+ pre.hljs .hljs-meta { color: var(--hljs-meta); }
128
129
  pre.hljs .hljs-title,
129
130
  pre.hljs .hljs-section,
130
- pre.hljs .hljs-built_in { color: #d2a8ff; }
131
+ pre.hljs .hljs-built_in { color: var(--hljs-meta); }
131
132
  pre.hljs .hljs-name,
132
133
  pre.hljs .hljs-selector-id,
133
- pre.hljs .hljs-selector-class { color: #7ee787; }
134
+ pre.hljs .hljs-selector-class { color: var(--hljs-name); }
134
135
  pre.hljs .hljs-variable,
135
- pre.hljs .hljs-template-variable { color: #ffa657; }
136
- pre.hljs .hljs-attribute { color: #79c0ff; }
136
+ pre.hljs .hljs-template-variable { color: var(--hljs-variable); }
137
+ pre.hljs .hljs-attribute { color: var(--hljs-literal); }
137
138
  pre.hljs .hljs-symbol,
138
- pre.hljs .hljs-bullet { color: #ffa657; }
139
- pre.hljs .hljs-subst { color: #e6edf3; }
140
- pre.hljs .hljs-title.function_ { color: #d2a8ff; }
141
- pre.hljs .hljs-title.class_ { color: #ffa657; }
139
+ pre.hljs .hljs-bullet { color: var(--hljs-variable); }
140
+ pre.hljs .hljs-subst { color: var(--code-text); }
141
+ pre.hljs .hljs-title.function_ { color: var(--hljs-meta); }
142
+ pre.hljs .hljs-title.class_ { color: var(--hljs-variable); }
142
143
  </style>
143
144
 
144
145
  <style scoped>
@@ -153,7 +154,7 @@ pre.hljs .hljs-title.class_ { color: #ffa657; }
153
154
  align-items: center;
154
155
  gap: 8px;
155
156
  padding: 8px 14px;
156
- background: #161b22;
157
+ background: var(--code-header-bg);
157
158
  border-bottom: var(--border-width-default) solid var(--border);
158
159
  }
159
160
 
@@ -163,22 +164,22 @@ pre.hljs .hljs-title.class_ { color: #ffa657; }
163
164
  font-weight: 600;
164
165
  text-transform: uppercase;
165
166
  letter-spacing: 0.06em;
166
- color: #7ee787;
167
+ color: var(--code-green);
167
168
  }
168
169
 
169
170
  .cpub-code-filename {
170
171
  font-family: var(--font-mono);
171
172
  font-size: 10px;
172
- color: #8b949e;
173
+ color: var(--code-muted);
173
174
  flex: 1;
174
175
  }
175
176
 
176
177
  .cpub-code-copy {
177
178
  font-family: var(--font-mono);
178
179
  font-size: 10px;
179
- color: #8b949e;
180
+ color: var(--code-muted);
180
181
  background: transparent;
181
- border: var(--border-width-default) solid #30363d;
182
+ border: var(--border-width-default) solid var(--code-border);
182
183
  padding: 3px 8px;
183
184
  cursor: pointer;
184
185
  display: flex;
@@ -189,8 +190,8 @@ pre.hljs .hljs-title.class_ { color: #ffa657; }
189
190
  }
190
191
 
191
192
  .cpub-code-copy:hover {
192
- color: #e6edf3;
193
- border-color: #8b949e;
193
+ color: var(--code-text);
194
+ border-color: var(--code-muted);
194
195
  }
195
196
 
196
197
  .cpub-code-body {
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import type { Serialized, ContestEntryItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ entries: Serialized<ContestEntryItem>[];
6
+ contestStatus?: string;
7
+ currentUserId?: string;
8
+ }>();
9
+
10
+ const emit = defineEmits<{
11
+ (e: 'withdraw', entryId: string): void;
12
+ }>();
13
+
14
+ function confirmWithdraw(entryId: string): void {
15
+ if (confirm('Withdraw this entry? This cannot be undone.')) {
16
+ emit('withdraw', entryId);
17
+ }
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <div class="cpub-entries-section">
23
+ <div class="cpub-sec-head">
24
+ <h2><i class="fa fa-box-open" style="color: var(--teal);"></i> Submitted Entries</h2>
25
+ <span class="cpub-sec-sub">{{ entries.length }} entries</span>
26
+ </div>
27
+ <div v-if="entries.length" class="cpub-entry-grid">
28
+ <div
29
+ v-for="(entry, i) in entries"
30
+ :key="entry.id"
31
+ class="cpub-entry-card"
32
+ >
33
+ <div class="cpub-entry-thumb" :class="i % 2 === 0 ? 'cpub-entry-bg-light' : 'cpub-entry-bg-dark'">
34
+ <img v-if="entry.contentCoverImageUrl" :src="entry.contentCoverImageUrl" :alt="entry.contentTitle" class="cpub-entry-cover-img" />
35
+ <template v-else>
36
+ <div class="cpub-entry-grid-pat"></div>
37
+ <div class="cpub-entry-icon"><i class="fa-solid fa-microchip"></i></div>
38
+ </template>
39
+ <span v-if="entry.rank" class="cpub-entry-rank" :class="`cpub-rank-${entry.rank <= 3 ? entry.rank : 'other'}`">#{{ entry.rank }}</span>
40
+ </div>
41
+ <div class="cpub-entry-body">
42
+ <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
43
+ <div class="cpub-entry-author">
44
+ <div class="cpub-entry-av">
45
+ <img v-if="entry.authorAvatarUrl" :src="entry.authorAvatarUrl" :alt="entry.authorName || entry.authorUsername" class="cpub-entry-av-img" />
46
+ <span v-else>{{ (entry.authorName || entry.authorUsername || '?').charAt(0).toUpperCase() }}</span>
47
+ </div>
48
+ <NuxtLink v-if="entry.authorUsername" :to="`/u/${entry.authorUsername}`" class="cpub-entry-author-link">{{ entry.authorName }}</NuxtLink>
49
+ <span class="cpub-entry-meta">{{ new Date(entry.submittedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}</span>
50
+ </div>
51
+ <div class="cpub-entry-footer">
52
+ <span v-if="entry.score != null" class="cpub-entry-score">Score: {{ entry.score }}</span>
53
+ <button
54
+ v-if="currentUserId && entry.userId === currentUserId && contestStatus === 'active'"
55
+ class="cpub-withdraw-btn"
56
+ @click.prevent="confirmWithdraw(entry.id)"
57
+ ><i class="fa-solid fa-trash-can"></i> Withdraw</button>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ <div v-else class="cpub-empty-state">
63
+ <div class="cpub-empty-state-icon"><i class="fa-solid fa-box-open"></i></div>
64
+ <p class="cpub-empty-state-title">No entries yet</p>
65
+ <p v-if="contestStatus === 'active'" class="cpub-empty-state-desc">Be the first to submit an entry!</p>
66
+ <p v-else-if="contestStatus === 'cancelled'" class="cpub-empty-state-desc">This contest was cancelled.</p>
67
+ <p v-else-if="contestStatus === 'completed'" class="cpub-empty-state-desc">No entries were submitted.</p>
68
+ <p v-else class="cpub-empty-state-desc">Submissions are not open yet.</p>
69
+ </div>
70
+ </div>
71
+ </template>
72
+
73
+ <style scoped>
74
+ .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
75
+ .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
76
+ .cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
77
+
78
+ .cpub-entry-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
79
+ .cpub-entry-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-md); }
80
+ .cpub-entry-card:hover { box-shadow: var(--shadow-accent); }
81
+ .cpub-entry-thumb { height: 110px; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
82
+ .cpub-entry-bg-light { background: var(--surface2); }
83
+ .cpub-entry-bg-dark { background: var(--surface3); }
84
+ .cpub-entry-grid-pat { position: absolute; inset: 0; background-image: linear-gradient(var(--border2) 1px, transparent 1px), linear-gradient(90deg, var(--border2) 1px, transparent 1px); background-size: 20px 20px; opacity: .3; }
85
+ .cpub-entry-icon { position: relative; z-index: 1; font-size: 22px; opacity: .65; color: var(--accent); }
86
+ .cpub-entry-rank { position: absolute; top: 8px; left: 8px; z-index: 2; font-size: 10px; font-family: var(--font-mono); font-weight: 700; padding: 2px 7px; border-radius: var(--radius); }
87
+ .cpub-rank-1 { background: var(--yellow-bg); color: var(--yellow); border: var(--border-width-default) solid var(--yellow); }
88
+ .cpub-rank-2 { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--text-faint); }
89
+ .cpub-rank-3 { background: var(--surface2); color: var(--bronze); border: var(--border-width-default) solid var(--bronze); }
90
+ .cpub-rank-other { background: var(--surface2); color: var(--text-dim); border: var(--border-width-default) solid var(--border); }
91
+ .cpub-entry-body { padding: 10px 12px; }
92
+ .cpub-entry-title { font-size: 12px; font-weight: 600; margin-bottom: 3px; line-height: 1.3; color: var(--text); text-decoration: none; }
93
+ .cpub-entry-title:hover { color: var(--accent); }
94
+ .cpub-entry-cover-img { width: 100%; height: 100%; object-fit: cover; }
95
+ .cpub-entry-av { width: 18px; height: 18px; border-radius: 50%; background: var(--surface3); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 7px; font-family: var(--font-mono); color: var(--text-faint); flex-shrink: 0; overflow: hidden; }
96
+ .cpub-entry-av-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
97
+ .cpub-entry-author { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
98
+ .cpub-entry-author-link { color: var(--text-dim); text-decoration: none; }
99
+ .cpub-entry-author-link:hover { color: var(--accent); }
100
+ .cpub-entry-meta { color: var(--text-faint); }
101
+ .cpub-entry-footer { display: flex; align-items: center; gap: 6px; }
102
+ .cpub-entry-score { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); display: flex; align-items: center; gap: 3px; }
103
+ .cpub-withdraw-btn { display: flex; align-items: center; gap: 4px; font-size: 10px; font-family: var(--font-mono); padding: 3px 8px; border-radius: var(--radius); border: var(--border-width-default) solid var(--red-border); background: var(--surface); color: var(--red); cursor: pointer; margin-left: auto; }
104
+ .cpub-withdraw-btn:hover { background: var(--red-bg); }
105
+
106
+ .cpub-empty-state { text-align: center; padding: 32px 0; }
107
+ .cpub-empty-state-icon { font-size: 24px; color: var(--text-faint); margin-bottom: 8px; }
108
+ .cpub-empty-state-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
109
+ .cpub-empty-state-desc { font-size: 12px; color: var(--text-dim); }
110
+
111
+ @media (max-width: 768px) { .cpub-entry-grid { grid-template-columns: 1fr; } }
112
+ </style>
@@ -0,0 +1,204 @@
1
+ <script setup lang="ts">
2
+ import type { Serialized, ContestDetail } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ contest: Serialized<ContestDetail> | null;
6
+ isAdmin: boolean;
7
+ isAuthenticated: boolean;
8
+ transitioning: boolean;
9
+ }>();
10
+
11
+ const emit = defineEmits<{
12
+ (e: 'submit-entry'): void;
13
+ (e: 'transition', status: string): void;
14
+ (e: 'copy-link'): void;
15
+ }>();
16
+
17
+ const c = computed(() => props.contest);
18
+
19
+ // Countdown timer
20
+ const countdown = ref({ days: '00', hours: '00', mins: '00', secs: '00' });
21
+ let countdownInterval: ReturnType<typeof setInterval> | null = null;
22
+
23
+ function pad(n: number): string { return String(n).padStart(2, '0'); }
24
+
25
+ function updateCountdown(): void {
26
+ const target = c.value?.endDate ? new Date(c.value.endDate) : new Date();
27
+ const now = new Date();
28
+ let diff = Math.max(0, Math.floor((target.getTime() - now.getTime()) / 1000));
29
+ const days = Math.floor(diff / 86400); diff %= 86400;
30
+ const hours = Math.floor(diff / 3600); diff %= 3600;
31
+ const mins = Math.floor(diff / 60);
32
+ const secs = diff % 60;
33
+ countdown.value = { days: pad(days), hours: pad(hours), mins: pad(mins), secs: pad(secs) };
34
+ }
35
+
36
+ onMounted(() => {
37
+ updateCountdown();
38
+ countdownInterval = setInterval(updateCountdown, 1000);
39
+ });
40
+
41
+ onUnmounted(() => {
42
+ if (countdownInterval) clearInterval(countdownInterval);
43
+ });
44
+
45
+ const countdownLabel = computed(() => {
46
+ if (c.value?.status === 'completed' || c.value?.status === 'cancelled') return 'Contest ended';
47
+ if (c.value?.status === 'judging') return 'Judging ends in';
48
+ return 'Submissions close in';
49
+ });
50
+
51
+ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
52
+ </script>
53
+
54
+ <template>
55
+ <div class="cpub-hero">
56
+ <div class="cpub-hero-pattern">
57
+ <div class="cpub-hero-dots"></div>
58
+ <div class="cpub-hero-lines"></div>
59
+ </div>
60
+
61
+ <div class="cpub-hero-inner">
62
+ <div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
63
+ <i class="fa-solid fa-ban"></i> This contest has been cancelled.
64
+ </div>
65
+
66
+ <div class="cpub-hero-eyebrow">
67
+ <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
68
+ </div>
69
+
70
+ <div class="cpub-hero-title">{{ c?.title || 'Contest' }}</div>
71
+ <div class="cpub-hero-tagline">{{ c?.description || 'No description available.' }}</div>
72
+
73
+ <div class="cpub-hero-meta">
74
+ <span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-item">
75
+ <i class="fa fa-calendar"></i>
76
+ {{ c?.startDate ? new Date(c.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '' }}{{ c?.startDate && c?.endDate ? ' — ' : '' }}{{ c?.endDate ? new Date(c.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '' }}
77
+ </span>
78
+ <span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-sep">|</span>
79
+ <span class="cpub-hero-meta-item"><i class="fa fa-folder-open"></i> {{ c?.entryCount ?? 0 }} entries</span>
80
+ </div>
81
+
82
+ <!-- COUNTDOWN -->
83
+ <div v-if="!isEnded" class="cpub-countdown-section">
84
+ <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
85
+ <div class="cpub-countdown-row">
86
+ <div class="cpub-countdown-block">
87
+ <div class="cpub-countdown-val">{{ countdown.days }}</div>
88
+ <div class="cpub-countdown-unit">Days</div>
89
+ </div>
90
+ <div class="cpub-countdown-sep">:</div>
91
+ <div class="cpub-countdown-block">
92
+ <div class="cpub-countdown-val">{{ countdown.hours }}</div>
93
+ <div class="cpub-countdown-unit">Hours</div>
94
+ </div>
95
+ <div class="cpub-countdown-sep">:</div>
96
+ <div class="cpub-countdown-block">
97
+ <div class="cpub-countdown-val">{{ countdown.mins }}</div>
98
+ <div class="cpub-countdown-unit">Minutes</div>
99
+ </div>
100
+ <div class="cpub-countdown-sep">:</div>
101
+ <div class="cpub-countdown-block">
102
+ <div class="cpub-countdown-val">{{ countdown.secs }}</div>
103
+ <div class="cpub-countdown-unit">Seconds</div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <div class="cpub-hero-cta">
109
+ <button v-if="isAuthenticated && c?.status === 'active'" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="emit('submit-entry')"><i class="fa fa-upload"></i> Submit Entry</button>
110
+ <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
111
+ </div>
112
+
113
+ <!-- Admin controls -->
114
+ <div v-if="isAdmin && c" class="cpub-admin-controls">
115
+ <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Admin</span>
116
+ <button v-if="c.status === 'upcoming'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'active')"><i class="fa-solid fa-play"></i> Activate</button>
117
+ <button v-if="c.status === 'active'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'judging')"><i class="fa-solid fa-gavel"></i> Start Judging</button>
118
+ <button v-if="c.status === 'judging'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'completed')"><i class="fa-solid fa-check"></i> Complete</button>
119
+ <button v-if="c.status !== 'completed' && c.status !== 'cancelled'" class="cpub-btn cpub-btn-sm cpub-btn-cancel" :disabled="transitioning" @click="emit('transition', 'cancelled')"><i class="fa-solid fa-ban"></i> Cancel</button>
120
+ <span class="cpub-admin-status">Status: <strong>{{ c.status }}</strong></span>
121
+ </div>
122
+
123
+ <div class="cpub-hero-stats">
124
+ <div class="cpub-hero-stat">
125
+ <div class="cpub-hero-stat-val">{{ c?.entryCount ?? 0 }}</div>
126
+ <div class="cpub-hero-stat-label">Entries</div>
127
+ </div>
128
+ <div class="cpub-hero-stat">
129
+ <div class="cpub-hero-stat-val">{{ c?.status ?? 'draft' }}</div>
130
+ <div class="cpub-hero-stat-label">Status</div>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </template>
136
+
137
+ <style scoped>
138
+ .cpub-hero {
139
+ --hero-bg: var(--text);
140
+ --hero-text: var(--color-text-inverse);
141
+ --hero-text-dim: var(--text-faint);
142
+ --hero-border: rgba(255, 255, 255, 0.15);
143
+ --hero-surface: rgba(255, 255, 255, 0.06);
144
+ position: relative; overflow: hidden; background: var(--hero-bg); padding: 56px 0 48px;
145
+ }
146
+ .cpub-hero-pattern { position: absolute; inset: 0; }
147
+ .cpub-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(var(--accent-border) 1.5px, transparent 1.5px); background-size: 28px 28px; opacity: .3; }
148
+ .cpub-hero-lines { position: absolute; inset: 0; background-image: linear-gradient(var(--accent-bg) 1px, transparent 1px), linear-gradient(90deg, var(--accent-bg) 1px, transparent 1px); background-size: 56px 56px; }
149
+ .cpub-hero-inner { max-width: 1100px; margin: 0 auto; padding: 0 32px; position: relative; z-index: 1; }
150
+ .cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
151
+ .cpub-contest-badge { font-size: 9px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; font-family: var(--font-mono); color: var(--accent); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent); padding: 3px 10px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 5px; }
152
+ .cpub-contest-badge i { font-size: 8px; }
153
+ .cpub-hero-title { font-size: 36px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin-bottom: 10px; color: var(--hero-text); }
154
+ .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 580px; margin-bottom: 28px; }
155
+ .cpub-hero-meta { display: flex; align-items: center; gap: 20px; font-size: 11px; color: var(--hero-text-dim); font-family: var(--font-mono); margin-bottom: 28px; }
156
+ .cpub-hero-meta-item { display: flex; align-items: center; gap: 5px; }
157
+ .cpub-hero-meta-sep { color: var(--hero-border); }
158
+
159
+ .cpub-countdown-section { margin-bottom: 28px; }
160
+ .cpub-countdown-label { font-size: 10px; font-family: var(--font-mono); color: var(--hero-text-dim); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 4px; }
161
+ .cpub-countdown-label i { color: var(--accent); }
162
+ .cpub-countdown-row { display: flex; align-items: center; gap: 8px; }
163
+ .cpub-countdown-block { display: flex; flex-direction: column; align-items: center; background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 10px 16px; min-width: 60px; box-shadow: 4px 4px 0 var(--hero-surface); }
164
+ .cpub-countdown-val { font-size: 26px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); line-height: 1; margin-bottom: 4px; }
165
+ .cpub-countdown-unit { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; color: var(--hero-text-dim); font-family: var(--font-mono); }
166
+ .cpub-countdown-sep { font-size: 20px; font-weight: 700; color: var(--hero-border); margin-top: -8px; font-family: var(--font-mono); }
167
+
168
+ .cpub-cancelled-banner { background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); color: var(--red); padding: 10px 14px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
169
+
170
+ .cpub-hero-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
171
+ .cpub-btn-lg { padding: 10px 22px; font-size: 13px; }
172
+ .cpub-btn-dark { background: var(--hero-surface); color: var(--hero-text); border-color: var(--hero-border); }
173
+ .cpub-btn-dark:hover { background: var(--hero-surface); }
174
+ .cpub-btn-cancel { color: var(--red); border-color: var(--red-border); }
175
+ .cpub-btn-cancel:hover { background: var(--red-bg); }
176
+
177
+ .cpub-hero-stats { display: flex; gap: 24px; margin-top: 28px; padding-top: 24px; border-top: var(--border-width-default) solid var(--hero-border); }
178
+ .cpub-hero-stat { display: flex; flex-direction: column; }
179
+ .cpub-hero-stat-val { font-size: 20px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); }
180
+ .cpub-hero-stat-label { font-size: 10px; color: var(--hero-text-dim); text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono); }
181
+
182
+ .cpub-admin-controls { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
183
+ .cpub-admin-controls-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-right: 4px; font-family: var(--font-mono); }
184
+ .cpub-admin-status { font-size: 11px; color: var(--text-dim); margin-left: auto; font-family: var(--font-mono); }
185
+ .cpub-admin-status strong { color: var(--accent); text-transform: capitalize; }
186
+
187
+ @media (max-width: 768px) {
188
+ .cpub-hero { padding: 32px 0 28px; }
189
+ .cpub-hero-inner { padding: 0 16px; }
190
+ .cpub-hero-title { font-size: 24px; }
191
+ .cpub-hero-tagline { font-size: 13px; }
192
+ .cpub-hero-meta { flex-wrap: wrap; gap: 10px; }
193
+ .cpub-countdown-block { padding: 8px 12px; min-width: 48px; }
194
+ .cpub-countdown-val { font-size: 20px; }
195
+ }
196
+ @media (max-width: 480px) {
197
+ .cpub-hero-title { font-size: 20px; }
198
+ .cpub-hero-tagline { font-size: 12px; margin-bottom: 16px; }
199
+ .cpub-hero-stats { flex-wrap: wrap; gap: 16px; }
200
+ .cpub-hero-stat-val { font-size: 16px; }
201
+ .cpub-hero-cta { flex-direction: column; align-items: stretch; }
202
+ .cpub-countdown-row { flex-wrap: wrap; justify-content: center; }
203
+ }
204
+ </style>
@@ -0,0 +1,51 @@
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
+ }
12
+
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
+ });
17
+
18
+ const judges = computed<JudgeInfo[]>(() => judgesData.value?.items ?? []);
19
+ </script>
20
+
21
+ <template>
22
+ <div v-if="judgeIds.length > 0" class="cpub-judges-section">
23
+ <div class="cpub-sec-head">
24
+ <h2><i class="fa-solid fa-gavel" style="color: var(--accent);"></i> Judges</h2>
25
+ </div>
26
+ <div class="cpub-judges-grid">
27
+ <div v-for="judge in judges" :key="judge.id" class="cpub-judge-card">
28
+ <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>
31
+ </div>
32
+ <NuxtLink :to="`/u/${judge.username}`" class="cpub-judge-name">{{ judge.displayName || judge.username }}</NuxtLink>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <style scoped>
39
+ .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
40
+ .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
41
+
42
+ .cpub-judges-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; }
43
+ .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
+ .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
+ .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; }
47
+ .cpub-judge-name:hover { color: var(--accent); }
48
+
49
+ @media (max-width: 768px) { .cpub-judges-grid { grid-template-columns: 1fr 1fr; } }
50
+ @media (max-width: 480px) { .cpub-judges-grid { grid-template-columns: 1fr; } }
51
+ </style>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ prizes: Array<{ place: number; title: string; description?: string; value?: string }>;
4
+ }>();
5
+
6
+ function placeLabel(place: number): string {
7
+ if (place === 1) return '1ST PLACE';
8
+ if (place === 2) return '2ND PLACE';
9
+ if (place === 3) return '3RD PLACE';
10
+ return `${place}TH PLACE`;
11
+ }
12
+
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
+ return 'default';
18
+ }
19
+
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';
24
+ return 'fa-star';
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div class="cpub-prizes-section">
30
+ <div class="cpub-sec-head">
31
+ <h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
32
+ </div>
33
+ <div class="cpub-prize-grid">
34
+ <div
35
+ v-for="prize in prizes"
36
+ :key="prize.place"
37
+ class="cpub-prize-card"
38
+ :class="`cpub-prize-${placeColor(prize.place)}`"
39
+ >
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>
43
+ <div class="cpub-prize-title">{{ prize.title }}</div>
44
+ <div v-if="prize.description" class="cpub-prize-desc">{{ prize.description }}</div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </template>
49
+
50
+ <style scoped>
51
+ .cpub-prizes-section { }
52
+
53
+ .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
54
+ .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
55
+
56
+ .cpub-prize-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px; }
57
+ .cpub-prize-card { border-radius: var(--radius); padding: 20px; text-align: center; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); }
58
+ .cpub-prize-gold { box-shadow: var(--shadow-accent); }
59
+
60
+ .cpub-prize-rank { font-size: 11px; font-family: var(--font-mono); font-weight: 600; letter-spacing: .08em; margin-bottom: 8px; }
61
+ .cpub-prize-rank-gold { color: var(--yellow); }
62
+ .cpub-prize-rank-silver { color: var(--silver); }
63
+ .cpub-prize-rank-bronze { color: var(--bronze); }
64
+ .cpub-prize-rank-default { color: var(--text-dim); }
65
+
66
+ .cpub-prize-icon { font-size: 28px; margin-bottom: 8px; }
67
+ .cpub-prize-icon-gold { color: var(--yellow); }
68
+ .cpub-prize-icon-silver { color: var(--silver); }
69
+ .cpub-prize-icon-bronze { color: var(--bronze); }
70
+ .cpub-prize-icon-default { color: var(--text-dim); }
71
+
72
+ .cpub-prize-amount { font-size: 24px; font-weight: 800; font-family: var(--font-mono); margin-bottom: 4px; }
73
+ .cpub-prize-amount-gold { color: var(--yellow); }
74
+ .cpub-prize-amount-silver { color: var(--silver); }
75
+ .cpub-prize-amount-bronze { color: var(--bronze); }
76
+ .cpub-prize-amount-default { color: var(--text-dim); }
77
+
78
+ .cpub-prize-title { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
79
+ .cpub-prize-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; }
80
+
81
+ @media (max-width: 768px) { .cpub-prize-grid { grid-template-columns: 1fr; } }
82
+ </style>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ rules: string;
4
+ }>();
5
+
6
+ const ruleLines = computed(() =>
7
+ props.rules.split('\n').filter((line) => line.trim().length > 0),
8
+ );
9
+ </script>
10
+
11
+ <template>
12
+ <div class="cpub-rules-section">
13
+ <div class="cpub-sec-head">
14
+ <h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
15
+ </div>
16
+ <div class="cpub-rules-card">
17
+ <ol v-if="ruleLines.length > 1" class="cpub-rules-list">
18
+ <li v-for="(line, i) in ruleLines" :key="i" class="cpub-rule-item">{{ line }}</li>
19
+ </ol>
20
+ <div v-else class="cpub-rules-text">{{ rules }}</div>
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <style scoped>
26
+ .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
27
+ .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
28
+
29
+ .cpub-rules-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; box-shadow: var(--shadow-md); }
30
+ .cpub-rules-list { padding-left: 20px; margin: 0; }
31
+ .cpub-rule-item { font-size: 12px; color: var(--text-dim); line-height: 1.7; margin-bottom: 6px; }
32
+ .cpub-rule-item:last-child { margin-bottom: 0; }
33
+ .cpub-rules-text { font-size: 12px; color: var(--text-dim); line-height: 1.7; white-space: pre-line; }
34
+ </style>