@commonpub/layer 0.8.2 → 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 (76) 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/ArticleEditor.vue +19 -1
  12. package/components/editors/BlogEditor.vue +1 -1
  13. package/components/editors/DocsPageTree.vue +10 -0
  14. package/components/hub/HubHero.vue +1 -1
  15. package/composables/useSanitize.ts +112 -9
  16. package/layouts/default.vue +7 -7
  17. package/middleware/feature-gate.global.ts +24 -0
  18. package/package.json +8 -8
  19. package/pages/[type]/index.vue +4 -3
  20. package/pages/admin/audit.vue +3 -2
  21. package/pages/admin/federation.vue +9 -1
  22. package/pages/admin/index.vue +7 -1
  23. package/pages/admin/reports.vue +152 -36
  24. package/pages/admin/settings.vue +17 -5
  25. package/pages/admin/theme.vue +5 -3
  26. package/pages/auth/forgot-password.vue +35 -35
  27. package/pages/auth/login.vue +6 -5
  28. package/pages/auth/reset-password.vue +44 -32
  29. package/pages/contests/[slug]/edit.vue +238 -56
  30. package/pages/contests/[slug]/index.vue +54 -450
  31. package/pages/contests/[slug]/judge.vue +141 -53
  32. package/pages/contests/[slug]/results.vue +182 -0
  33. package/pages/contests/create.vue +64 -64
  34. package/pages/contests/index.vue +2 -1
  35. package/pages/docs/[siteSlug]/[...pagePath].vue +6 -5
  36. package/pages/docs/[siteSlug]/edit.vue +58 -2
  37. package/pages/docs/[siteSlug]/index.vue +6 -5
  38. package/pages/federated-hubs/[id]/posts/[postId].vue +2 -2
  39. package/pages/hubs/index.vue +3 -2
  40. package/pages/index.vue +25 -7
  41. package/pages/learn/index.vue +1 -1
  42. package/pages/mirror/[id].vue +3 -3
  43. package/pages/notifications.vue +15 -1
  44. package/pages/settings/notifications.vue +7 -1
  45. package/pages/tags/[slug].vue +3 -2
  46. package/pages/tags/index.vue +3 -2
  47. package/pages/videos/[id].vue +18 -0
  48. package/server/api/admin/content/[id].patch.ts +1 -1
  49. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  50. package/server/api/admin/federation/refederate.post.ts +7 -3
  51. package/server/api/admin/federation/repair-types.post.ts +2 -45
  52. package/server/api/admin/federation/retry.post.ts +7 -4
  53. package/server/api/admin/reports.get.ts +1 -0
  54. package/server/api/auth/sign-in-username.post.ts +42 -0
  55. package/server/api/content/[id]/products-sync.post.ts +7 -6
  56. package/server/api/contests/[slug]/entries/[entryId].delete.ts +14 -0
  57. package/server/api/contests/[slug]/entries.get.ts +6 -1
  58. package/server/api/contests/[slug]/judge.post.ts +8 -2
  59. package/server/api/docs/[siteSlug]/nav.get.ts +1 -1
  60. package/server/api/docs/[siteSlug]/pages/[pageId]/duplicate.post.ts +16 -0
  61. package/server/api/docs/[siteSlug]/pages/reorder.post.ts +4 -1
  62. package/server/api/docs/migrate-content.post.ts +1 -7
  63. package/server/api/federation/hub-follow-status.get.ts +2 -18
  64. package/server/api/federation/hub-follow.post.ts +9 -27
  65. package/server/api/federation/hub-post-like.post.ts +9 -98
  66. package/server/api/federation/hub-post-likes.get.ts +3 -13
  67. package/server/api/notifications/read.post.ts +6 -1
  68. package/server/api/search/index.get.ts +2 -2
  69. package/server/api/search/trending.get.ts +3 -3
  70. package/server/api/users/index.get.ts +9 -2
  71. package/server/middleware/content-ap.ts +2 -2
  72. package/server/routes/.well-known/webfinger.ts +2 -2
  73. package/theme/base.css +23 -0
  74. package/components/EditorPropertiesPanel.vue +0 -393
  75. package/components/views/BlogView.vue +0 -735
  76. package/server/api/resolve-identity.post.ts +0 -34
@@ -3,34 +3,65 @@ definePageMeta({ middleware: 'auth' });
3
3
 
4
4
  const route = useRoute();
5
5
  const slug = route.params.slug as string;
6
+ const { user } = useAuth();
6
7
 
7
8
  import type { Serialized, ContestDetail, ContestEntryItem } from '@commonpub/server';
8
9
 
9
10
  const { data: contest } = useLazyFetch<Serialized<ContestDetail>>(`/api/contests/${slug}`);
10
- const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
11
+ const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: (Serialized<ContestEntryItem> & { judgeScores?: Array<{ judgeId: string; score: number; feedback?: string }> })[]; total: number }>(
12
+ `/api/contests/${slug}/entries`,
13
+ { query: { includeJudgeScores: true } },
14
+ );
11
15
 
12
16
  useSeoMeta({ title: () => `Judge: ${contest.value?.title || 'Contest'} — ${useSiteName()}` });
13
17
 
18
+ const isJudge = computed(() => {
19
+ if (!contest.value || !user.value) return false;
20
+ return ((contest.value.judges ?? []) as string[]).includes(user.value.id);
21
+ });
22
+
14
23
  const entryList = computed(() => {
15
24
  const items = entriesData.value?.items ?? [];
16
- return items.map((entry) => ({
17
- id: entry.id,
18
- contentId: entry.contentId,
19
- contentSlug: entry.contentSlug,
20
- contentType: entry.contentType,
21
- contentTitle: entry.contentTitle,
22
- authorName: entry.authorName,
23
- authorUsername: entry.authorUsername,
24
- score: entry.score ?? null,
25
- rank: entry.rank ?? null,
26
- }));
25
+ return items.map((entry) => {
26
+ const myScore = entry.judgeScores?.find((s) => s.judgeId === user.value?.id);
27
+ return {
28
+ id: entry.id,
29
+ contentId: entry.contentId,
30
+ contentSlug: entry.contentSlug,
31
+ contentType: entry.contentType,
32
+ contentTitle: entry.contentTitle,
33
+ authorName: entry.authorName,
34
+ authorUsername: entry.authorUsername,
35
+ score: entry.score ?? null,
36
+ rank: entry.rank ?? null,
37
+ myScore: myScore?.score ?? null,
38
+ myFeedback: myScore?.feedback ?? '',
39
+ };
40
+ });
27
41
  });
28
42
 
43
+ const scoredCount = computed(() => entryList.value.filter((e) => e.myScore !== null).length);
44
+ const totalCount = computed(() => entryList.value.length);
45
+ const progressPct = computed(() => totalCount.value > 0 ? Math.round((scoredCount.value / totalCount.value) * 100) : 0);
46
+
29
47
  const scoring = ref<Record<string, number>>({});
48
+ const feedback = ref<Record<string, string>>({});
30
49
  const submitting = ref<string | null>(null);
31
50
  const error = ref('');
32
51
  const success = ref('');
33
52
 
53
+ // Pre-fill from existing scores
54
+ watch(entryList, (list) => {
55
+ for (const entry of list) {
56
+ if (entry.myScore !== null && scoring.value[entry.id] === undefined) {
57
+ scoring.value[entry.id] = entry.myScore;
58
+ }
59
+ if (entry.myFeedback && feedback.value[entry.id] === undefined) {
60
+ feedback.value[entry.id] = entry.myFeedback;
61
+ }
62
+ }
63
+ }, { immediate: true });
64
+
34
65
  async function submitScore(entryId: string): Promise<void> {
35
66
  const score = scoring.value[entryId];
36
67
  if (score === undefined || score < 1 || score > 100) {
@@ -45,9 +76,13 @@ async function submitScore(entryId: string): Promise<void> {
45
76
  try {
46
77
  await $fetch(`/api/contests/${slug}/judge`, {
47
78
  method: 'POST',
48
- body: { entryId, score },
79
+ body: {
80
+ entryId,
81
+ score,
82
+ feedback: feedback.value[entryId] || undefined,
83
+ },
49
84
  });
50
- success.value = `Score submitted for entry.`;
85
+ success.value = 'Score submitted for entry.';
51
86
  await refreshEntries();
52
87
  } catch (err: unknown) {
53
88
  error.value = (err as { data?: { message?: string } })?.data?.message || 'Failed to submit score.';
@@ -67,51 +102,84 @@ async function submitScore(entryId: string): Promise<void> {
67
102
  <i class="fa-solid fa-gavel cpub-judge-icon"></i>
68
103
  Judge: {{ contest?.title || 'Contest' }}
69
104
  </h1>
70
- <p class="cpub-judge-desc">Score each entry from 1 to 100. Scores are saved immediately.</p>
105
+ <p class="cpub-judge-desc">Score each entry from 1 to 100. Add optional feedback. Scores are saved immediately.</p>
71
106
  </header>
72
107
 
73
- <div v-if="error" class="cpub-judge-alert cpub-judge-alert--error" role="alert">{{ error }}</div>
74
- <div v-if="success" class="cpub-judge-alert cpub-judge-alert--success">{{ success }}</div>
108
+ <!-- Loading -->
109
+ <div v-if="!contest" class="cpub-judge-empty">
110
+ <p>Loading...</p>
111
+ </div>
75
112
 
76
- <div v-if="entryList.length === 0" class="cpub-judge-empty">
77
- <i class="fa-solid fa-inbox" style="font-size: 24px; color: var(--text-faint);"></i>
78
- <p>No entries to judge yet.</p>
113
+ <!-- Auth guard -->
114
+ <div v-else-if="!isJudge" class="cpub-judge-unauthorized">
115
+ <i class="fa-solid fa-lock"></i>
116
+ <p>You are not a judge for this contest.</p>
117
+ <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
79
118
  </div>
80
119
 
81
- <div v-else class="cpub-judge-entries">
82
- <div v-for="entry in entryList" :key="entry.id" class="cpub-judge-entry">
83
- <div class="cpub-judge-entry-info">
84
- <div class="cpub-judge-entry-title">{{ entry.contentTitle }}</div>
85
- <div class="cpub-judge-entry-author">by {{ entry.authorName }}</div>
86
- <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-judge-entry-link" target="_blank">
87
- <i class="fa-solid fa-arrow-up-right-from-square"></i> View entry
88
- </NuxtLink>
120
+ <template v-else>
121
+ <!-- Progress bar -->
122
+ <div v-if="totalCount > 0" class="cpub-judge-progress">
123
+ <div class="cpub-judge-progress-label">
124
+ Scored <strong>{{ scoredCount }}</strong> / <strong>{{ totalCount }}</strong> entries
125
+ </div>
126
+ <div class="cpub-judge-progress-bar">
127
+ <div class="cpub-judge-progress-fill" :style="{ width: `${progressPct}%` }"></div>
89
128
  </div>
90
- <div class="cpub-judge-entry-scoring">
91
- <div v-if="entry.score !== null" class="cpub-judge-current-score">
92
- <span class="cpub-judge-score-label">Current</span>
93
- <span class="cpub-judge-score-value">{{ entry.score }}</span>
129
+ </div>
130
+
131
+ <div v-if="error" class="cpub-judge-alert cpub-judge-alert--error" role="alert">{{ error }}</div>
132
+ <div v-if="success" class="cpub-judge-alert cpub-judge-alert--success">{{ success }}</div>
133
+
134
+ <div v-if="entryList.length === 0" class="cpub-judge-empty">
135
+ <i class="fa-solid fa-inbox"></i>
136
+ <p>No entries to judge yet.</p>
137
+ </div>
138
+
139
+ <div v-else class="cpub-judge-entries">
140
+ <div v-for="entry in entryList" :key="entry.id" class="cpub-judge-entry">
141
+ <div class="cpub-judge-entry-info">
142
+ <div class="cpub-judge-entry-title">{{ entry.contentTitle }}</div>
143
+ <div class="cpub-judge-entry-author">by {{ entry.authorName }}</div>
144
+ <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-judge-entry-link" target="_blank">
145
+ <i class="fa-solid fa-arrow-up-right-from-square"></i> View entry
146
+ </NuxtLink>
94
147
  </div>
95
- <div class="cpub-judge-score-input-wrap">
96
- <input
97
- v-model.number="scoring[entry.id]"
98
- type="number"
99
- class="cpub-judge-score-input"
100
- min="1"
101
- max="100"
102
- placeholder="1-100"
103
- />
104
- <button
105
- class="cpub-judge-score-btn"
106
- :disabled="submitting === entry.id"
107
- @click="submitScore(entry.id)"
108
- >
109
- {{ submitting === entry.id ? '...' : 'Score' }}
110
- </button>
148
+ <div class="cpub-judge-entry-scoring">
149
+ <div v-if="entry.myScore !== null" class="cpub-judge-current-score">
150
+ <span class="cpub-judge-score-label">Your Score</span>
151
+ <span class="cpub-judge-score-value">{{ entry.myScore }}</span>
152
+ </div>
153
+ <div class="cpub-judge-score-controls">
154
+ <div class="cpub-judge-score-input-wrap">
155
+ <input
156
+ v-model.number="scoring[entry.id]"
157
+ type="number"
158
+ class="cpub-judge-score-input"
159
+ min="1"
160
+ max="100"
161
+ placeholder="1-100"
162
+ />
163
+ <button
164
+ class="cpub-judge-score-btn"
165
+ :disabled="submitting === entry.id"
166
+ @click="submitScore(entry.id)"
167
+ >
168
+ {{ submitting === entry.id ? '...' : entry.myScore !== null ? 'Update' : 'Score' }}
169
+ </button>
170
+ </div>
171
+ <textarea
172
+ v-model="feedback[entry.id]"
173
+ class="cpub-judge-feedback"
174
+ placeholder="Optional feedback (max 2000 chars)"
175
+ maxlength="2000"
176
+ rows="2"
177
+ ></textarea>
178
+ </div>
111
179
  </div>
112
180
  </div>
113
181
  </div>
114
- </div>
182
+ </template>
115
183
  </div>
116
184
  </template>
117
185
 
@@ -124,16 +192,25 @@ async function submitScore(entryId: string): Promise<void> {
124
192
  .cpub-judge-icon { color: var(--accent); font-size: 18px; }
125
193
  .cpub-judge-desc { font-size: 13px; color: var(--text-dim); margin-top: 6px; }
126
194
 
195
+ .cpub-judge-unauthorized { text-align: center; padding: 48px 0; color: var(--text-faint); font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
196
+ .cpub-judge-unauthorized i { font-size: 24px; }
197
+
198
+ .cpub-judge-progress { margin-bottom: 20px; }
199
+ .cpub-judge-progress-label { font-size: 12px; color: var(--text-dim); font-family: var(--font-mono); margin-bottom: 6px; }
200
+ .cpub-judge-progress-bar { height: 6px; background: var(--surface2); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); overflow: hidden; }
201
+ .cpub-judge-progress-fill { height: 100%; background: var(--accent); transition: width 0.3s ease; }
202
+
127
203
  .cpub-judge-alert { padding: 10px 14px; font-size: 12px; border: var(--border-width-default) solid; margin-bottom: 16px; }
128
204
  .cpub-judge-alert--error { background: var(--red-bg); color: var(--red); border-color: var(--red); }
129
205
  .cpub-judge-alert--success { background: var(--green-bg); color: var(--green); border-color: var(--green); }
130
206
 
131
207
  .cpub-judge-empty { text-align: center; padding: 48px 0; color: var(--text-faint); font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
208
+ .cpub-judge-empty i { font-size: 24px; }
132
209
 
133
- .cpub-judge-entries { display: flex; flex-direction: column; gap: 8px; }
210
+ .cpub-judge-entries { display: flex; flex-direction: column; gap: 12px; }
134
211
  .cpub-judge-entry {
135
- display: flex; align-items: center; justify-content: space-between; gap: 16px;
136
- padding: 14px 16px; background: var(--surface); border: var(--border-width-default) solid var(--border);
212
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
213
+ padding: 16px; background: var(--surface); border: var(--border-width-default) solid var(--border);
137
214
  box-shadow: var(--shadow-md);
138
215
  }
139
216
  .cpub-judge-entry-info { flex: 1; min-width: 0; }
@@ -142,10 +219,11 @@ async function submitScore(entryId: string): Promise<void> {
142
219
  .cpub-judge-entry-link { font-size: 10px; color: var(--accent); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; margin-top: 4px; }
143
220
  .cpub-judge-entry-link:hover { text-decoration: underline; }
144
221
 
145
- .cpub-judge-entry-scoring { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
222
+ .cpub-judge-entry-scoring { display: flex; flex-direction: column; gap: 8px; flex-shrink: 0; min-width: 220px; }
146
223
  .cpub-judge-current-score { text-align: center; }
147
224
  .cpub-judge-score-label { display: block; font-family: var(--font-mono); font-size: 9px; color: var(--text-faint); text-transform: uppercase; }
148
225
  .cpub-judge-score-value { font-size: 20px; font-weight: 700; color: var(--accent); font-family: var(--font-mono); }
226
+ .cpub-judge-score-controls { display: flex; flex-direction: column; gap: 6px; }
149
227
  .cpub-judge-score-input-wrap { display: flex; gap: 0; }
150
228
  .cpub-judge-score-input {
151
229
  width: 70px; padding: 6px 8px; border: var(--border-width-default) solid var(--border); background: var(--surface);
@@ -158,4 +236,14 @@ async function submitScore(entryId: string): Promise<void> {
158
236
  }
159
237
  .cpub-judge-score-btn:hover { opacity: 0.9; }
160
238
  .cpub-judge-score-btn:disabled { opacity: 0.6; cursor: not-allowed; }
239
+ .cpub-judge-feedback {
240
+ width: 100%; padding: 6px 8px; border: var(--border-width-default) solid var(--border); background: var(--surface);
241
+ color: var(--text); font-size: 11px; font-family: inherit; resize: vertical; outline: none;
242
+ }
243
+ .cpub-judge-feedback:focus { border-color: var(--accent); }
244
+
245
+ @media (max-width: 768px) {
246
+ .cpub-judge-entry { flex-direction: column; }
247
+ .cpub-judge-entry-scoring { min-width: 100%; }
248
+ }
161
249
  </style>
@@ -0,0 +1,182 @@
1
+ <script setup lang="ts">
2
+ import type { Serialized, ContestDetail, ContestEntryItem } from '@commonpub/server';
3
+
4
+ const route = useRoute();
5
+ const slug = route.params.slug as string;
6
+
7
+ const { data: contest } = useLazyFetch<Serialized<ContestDetail>>(`/api/contests/${slug}`);
8
+ const { data: entriesData } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
9
+
10
+ useSeoMeta({
11
+ title: () => `Results: ${contest.value?.title || 'Contest'} — ${useSiteName()}`,
12
+ });
13
+
14
+ const rankedEntries = computed(() => {
15
+ const items = [...(entriesData.value?.items ?? [])];
16
+ items.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
17
+ return items;
18
+ });
19
+
20
+ const podium = computed(() => rankedEntries.value.filter((e) => e.rank && e.rank <= 3));
21
+ const leaderboard = computed(() => rankedEntries.value);
22
+
23
+ const prizes = computed(() => contest.value?.prizes ?? []);
24
+
25
+ function prizeForRank(rank: number): { title: string; value?: string } | null {
26
+ const prize = prizes.value.find((p: { place: number; title: string; value?: string }) => p.place === rank);
27
+ return prize ?? null;
28
+ }
29
+
30
+ function medalIcon(rank: number): string {
31
+ if (rank === 1) return 'fa-trophy';
32
+ if (rank === 2) return 'fa-medal';
33
+ if (rank === 3) return 'fa-award';
34
+ return '';
35
+ }
36
+
37
+ function medalColor(rank: number): string {
38
+ if (rank === 1) return 'var(--gold)';
39
+ if (rank === 2) return 'var(--silver)';
40
+ if (rank === 3) return 'var(--bronze)';
41
+ return 'var(--text-dim)';
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <div class="cpub-results-page">
47
+ <header class="cpub-results-header">
48
+ <NuxtLink :to="`/contests/${slug}`" class="cpub-results-back">
49
+ <i class="fa-solid fa-arrow-left"></i> Back to contest
50
+ </NuxtLink>
51
+ <h1 class="cpub-results-title">
52
+ <i class="fa-solid fa-ranking-star" style="color: var(--yellow);"></i>
53
+ {{ contest?.title || 'Contest' }} — Results
54
+ </h1>
55
+ </header>
56
+
57
+ <!-- Not completed -->
58
+ <div v-if="contest && contest.status !== 'completed'" class="cpub-results-pending">
59
+ <i class="fa-solid fa-hourglass-half"></i>
60
+ <p>Results are not available yet. The contest is still <strong>{{ contest.status }}</strong>.</p>
61
+ <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
62
+ </div>
63
+
64
+ <template v-else-if="contest">
65
+ <!-- PODIUM -->
66
+ <div v-if="podium.length > 0" class="cpub-podium">
67
+ <div
68
+ v-for="entry in podium"
69
+ :key="entry.id"
70
+ class="cpub-podium-card"
71
+ :class="`cpub-podium-${entry.rank ?? 0}`"
72
+ >
73
+ <div class="cpub-podium-medal" :style="{ color: medalColor(entry.rank ?? 0) }">
74
+ <i class="fa-solid" :class="medalIcon(entry.rank ?? 0)"></i>
75
+ </div>
76
+ <div class="cpub-podium-rank">#{{ entry.rank }}</div>
77
+ <div class="cpub-podium-thumb">
78
+ <img v-if="entry.contentCoverImageUrl" :src="entry.contentCoverImageUrl" :alt="entry.contentTitle" />
79
+ <div v-else class="cpub-podium-placeholder"><i class="fa-solid fa-microchip"></i></div>
80
+ </div>
81
+ <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-podium-title">{{ entry.contentTitle }}</NuxtLink>
82
+ <NuxtLink :to="`/u/${entry.authorUsername}`" class="cpub-podium-author">{{ entry.authorName }}</NuxtLink>
83
+ <div class="cpub-podium-score">Score: {{ entry.score ?? '—' }}</div>
84
+ <template v-if="entry.rank && prizeForRank(entry.rank)">
85
+ <div class="cpub-podium-prize">
86
+ <i class="fa-solid fa-gift"></i> {{ prizeForRank(entry.rank)?.title }}
87
+ <span v-if="prizeForRank(entry.rank)?.value" class="cpub-podium-prize-val">{{ prizeForRank(entry.rank)?.value }}</span>
88
+ </div>
89
+ </template>
90
+ </div>
91
+ </div>
92
+
93
+ <!-- LEADERBOARD -->
94
+ <div v-if="leaderboard.length > 0" class="cpub-leaderboard">
95
+ <h2 class="cpub-leaderboard-title">Full Leaderboard</h2>
96
+ <table class="cpub-leaderboard-table">
97
+ <thead>
98
+ <tr>
99
+ <th>Rank</th>
100
+ <th>Entry</th>
101
+ <th>Author</th>
102
+ <th>Score</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody>
106
+ <tr v-for="entry in leaderboard" :key="entry.id" :class="{ 'cpub-lb-top3': entry.rank && entry.rank <= 3 }">
107
+ <td class="cpub-lb-rank">
108
+ <span v-if="entry.rank && entry.rank <= 3" :style="{ color: medalColor(entry.rank) }">
109
+ <i class="fa-solid" :class="medalIcon(entry.rank)"></i>
110
+ </span>
111
+ {{ entry.rank ?? '—' }}
112
+ </td>
113
+ <td>
114
+ <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-lb-entry-link">{{ entry.contentTitle }}</NuxtLink>
115
+ </td>
116
+ <td>
117
+ <NuxtLink :to="`/u/${entry.authorUsername}`" class="cpub-lb-author-link">{{ entry.authorName }}</NuxtLink>
118
+ </td>
119
+ <td class="cpub-lb-score">{{ entry.score ?? '—' }}</td>
120
+ </tr>
121
+ </tbody>
122
+ </table>
123
+ </div>
124
+
125
+ <div v-else class="cpub-results-empty">
126
+ <i class="fa-solid fa-inbox"></i>
127
+ <p>No entries were submitted to this contest.</p>
128
+ </div>
129
+ </template>
130
+ </div>
131
+ </template>
132
+
133
+ <style scoped>
134
+ .cpub-results-page { max-width: 900px; margin: 0 auto; padding: 32px 24px; }
135
+ .cpub-results-header { margin-bottom: 32px; }
136
+ .cpub-results-back { font-size: 12px; color: var(--text-faint); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 12px; }
137
+ .cpub-results-back:hover { color: var(--accent); }
138
+ .cpub-results-title { font-size: 22px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
139
+
140
+ .cpub-results-pending { text-align: center; padding: 48px 0; color: var(--text-faint); font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
141
+ .cpub-results-pending i { font-size: 24px; }
142
+
143
+ /* PODIUM */
144
+ .cpub-podium { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
145
+ .cpub-podium-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 20px; text-align: center; box-shadow: var(--shadow-md); }
146
+ .cpub-podium-1 { box-shadow: var(--shadow-accent); border-color: var(--yellow-border); }
147
+ .cpub-podium-medal { font-size: 28px; margin-bottom: 6px; }
148
+ .cpub-podium-rank { font-size: 11px; font-family: var(--font-mono); font-weight: 700; letter-spacing: .08em; margin-bottom: 12px; color: var(--text-dim); }
149
+ .cpub-podium-thumb { width: 100%; height: 100px; overflow: hidden; margin-bottom: 12px; border-radius: var(--radius); background: var(--surface2); display: flex; align-items: center; justify-content: center; }
150
+ .cpub-podium-thumb img { width: 100%; height: 100%; object-fit: cover; }
151
+ .cpub-podium-placeholder { font-size: 24px; color: var(--text-faint); opacity: .5; }
152
+ .cpub-podium-title { font-size: 13px; font-weight: 600; display: block; margin-bottom: 4px; color: var(--text); text-decoration: none; }
153
+ .cpub-podium-title:hover { color: var(--accent); }
154
+ .cpub-podium-author { font-size: 11px; color: var(--text-dim); text-decoration: none; display: block; margin-bottom: 6px; }
155
+ .cpub-podium-author:hover { color: var(--accent); }
156
+ .cpub-podium-score { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); margin-bottom: 6px; }
157
+ .cpub-podium-prize { font-size: 11px; font-family: var(--font-mono); color: var(--accent); display: flex; align-items: center; justify-content: center; gap: 4px; }
158
+ .cpub-podium-prize-val { font-weight: 700; color: var(--yellow); }
159
+
160
+ /* LEADERBOARD */
161
+ .cpub-leaderboard { margin-bottom: 32px; }
162
+ .cpub-leaderboard-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
163
+ .cpub-leaderboard-table { width: 100%; border-collapse: collapse; font-size: 12px; }
164
+ .cpub-leaderboard-table th { text-align: left; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border); }
165
+ .cpub-leaderboard-table td { padding: 10px 12px; border-bottom: var(--border-width-default) solid var(--border); }
166
+ .cpub-lb-top3 { background: var(--surface2); }
167
+ .cpub-lb-rank { font-family: var(--font-mono); font-weight: 700; display: flex; align-items: center; gap: 6px; }
168
+ .cpub-lb-score { font-family: var(--font-mono); font-weight: 600; color: var(--accent); }
169
+ .cpub-lb-entry-link { color: var(--text); text-decoration: none; font-weight: 500; }
170
+ .cpub-lb-entry-link:hover { color: var(--accent); }
171
+ .cpub-lb-author-link { color: var(--text-dim); text-decoration: none; }
172
+ .cpub-lb-author-link:hover { color: var(--accent); }
173
+
174
+ .cpub-results-empty { text-align: center; padding: 48px 0; color: var(--text-faint); font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
175
+ .cpub-results-empty i { font-size: 24px; }
176
+
177
+ @media (max-width: 768px) {
178
+ .cpub-podium { grid-template-columns: 1fr; }
179
+ .cpub-leaderboard-table { font-size: 11px; }
180
+ .cpub-leaderboard-table th, .cpub-leaderboard-table td { padding: 8px; }
181
+ }
182
+ </style>
@@ -69,82 +69,82 @@ async function handleCreate(): Promise<void> {
69
69
  }
70
70
 
71
71
  const placeLabels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
72
- const placeColors = ['var(--yellow)', 'var(--text-faint)', '#a0724a', 'var(--accent)', 'var(--accent)', 'var(--accent)'];
72
+ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--accent)', 'var(--accent)', 'var(--accent)'];
73
73
  </script>
74
74
 
75
75
  <template>
76
- <div class="contest-create">
76
+ <div class="cpub-contest-create">
77
77
  <NuxtLink to="/contests" class="cpub-back-link"><i class="fa-solid fa-arrow-left"></i> Contests</NuxtLink>
78
- <h1 class="page-title">Create Contest</h1>
78
+ <h1 class="cpub-page-title">Create Contest</h1>
79
79
 
80
- <form class="contest-form" @submit.prevent="handleCreate" aria-label="Create contest">
80
+ <form class="cpub-contest-form" @submit.prevent="handleCreate" aria-label="Create contest">
81
81
  <!-- Basic Info -->
82
- <section class="form-section">
83
- <h2 class="form-section-title">Contest Details</h2>
84
- <div class="form-field">
85
- <label for="contest-title" class="form-label">Title</label>
86
- <input id="contest-title" v-model="title" type="text" class="form-input" required placeholder="Maker Challenge 2026" />
82
+ <section class="cpub-form-section">
83
+ <h2 class="cpub-form-section-title">Contest Details</h2>
84
+ <div class="cpub-form-field">
85
+ <label for="contest-title" class="cpub-form-label">Title</label>
86
+ <input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
87
87
  </div>
88
- <div class="form-field">
89
- <label for="contest-desc" class="form-label">Description</label>
90
- <textarea id="contest-desc" v-model="description" class="form-textarea" rows="3" placeholder="Describe your contest..." />
88
+ <div class="cpub-form-field">
89
+ <label for="contest-desc" class="cpub-form-label">Description</label>
90
+ <textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="3" placeholder="Describe your contest..." />
91
91
  </div>
92
- <div class="form-field">
93
- <label for="contest-rules" class="form-label">Rules</label>
94
- <textarea id="contest-rules" v-model="rules" class="form-textarea" rows="4" placeholder="Contest rules and requirements..." />
92
+ <div class="cpub-form-field">
93
+ <label for="contest-rules" class="cpub-form-label">Rules</label>
94
+ <textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="4" placeholder="Contest rules and requirements..." />
95
95
  </div>
96
96
  </section>
97
97
 
98
98
  <!-- Dates -->
99
- <section class="form-section">
100
- <h2 class="form-section-title">Schedule</h2>
101
- <div class="form-row">
102
- <div class="form-field">
103
- <label for="start-date" class="form-label">Start Date</label>
104
- <input id="start-date" v-model="startDate" type="datetime-local" class="form-input" required />
99
+ <section class="cpub-form-section">
100
+ <h2 class="cpub-form-section-title">Schedule</h2>
101
+ <div class="cpub-form-row">
102
+ <div class="cpub-form-field">
103
+ <label for="start-date" class="cpub-form-label">Start Date</label>
104
+ <input id="start-date" v-model="startDate" type="datetime-local" class="cpub-form-input" required />
105
105
  </div>
106
- <div class="form-field">
107
- <label for="end-date" class="form-label">End Date</label>
108
- <input id="end-date" v-model="endDate" type="datetime-local" class="form-input" required />
106
+ <div class="cpub-form-field">
107
+ <label for="end-date" class="cpub-form-label">End Date</label>
108
+ <input id="end-date" v-model="endDate" type="datetime-local" class="cpub-form-input" required />
109
109
  </div>
110
- <div class="form-field">
111
- <label for="judging-date" class="form-label">Judging Ends</label>
112
- <input id="judging-date" v-model="judgingEndDate" type="datetime-local" class="form-input" />
110
+ <div class="cpub-form-field">
111
+ <label for="judging-date" class="cpub-form-label">Judging Ends</label>
112
+ <input id="judging-date" v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
113
113
  </div>
114
114
  </div>
115
115
  </section>
116
116
 
117
117
  <!-- Prizes -->
118
- <section class="form-section">
119
- <div class="form-section-header">
120
- <h2 class="form-section-title">Prizes</h2>
118
+ <section class="cpub-form-section">
119
+ <div class="cpub-form-section-header">
120
+ <h2 class="cpub-form-section-title">Prizes</h2>
121
121
  <button type="button" class="cpub-btn cpub-btn-sm" @click="addPrize">
122
122
  <i class="fa-solid fa-plus"></i> Add Prize
123
123
  </button>
124
124
  </div>
125
125
 
126
- <div v-for="(prize, idx) in prizes" :key="idx" class="prize-card">
127
- <div class="prize-header">
128
- <span class="prize-place" :style="{ color: placeColors[idx] || 'var(--accent)' }">
126
+ <div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
127
+ <div class="cpub-prize-header">
128
+ <span class="cpub-prize-place" :style="{ color: placeColors[idx] || 'var(--accent)' }">
129
129
  <i class="fa-solid fa-trophy"></i> {{ placeLabels[idx] || `${idx + 1}th` }} Place
130
130
  </span>
131
131
  <button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" @click="removePrize(idx)">
132
132
  <i class="fa-solid fa-xmark"></i>
133
133
  </button>
134
134
  </div>
135
- <div class="form-row">
136
- <div class="form-field" style="flex: 2">
137
- <label class="form-label">Title</label>
138
- <input v-model="prize.title" type="text" class="form-input" placeholder="Prize title" />
135
+ <div class="cpub-form-row">
136
+ <div class="cpub-form-field" style="flex: 2">
137
+ <label class="cpub-form-label">Title</label>
138
+ <input v-model="prize.title" type="text" class="cpub-form-input" placeholder="Prize title" />
139
139
  </div>
140
- <div class="form-field" style="flex: 1">
141
- <label class="form-label">Value</label>
142
- <input v-model="prize.value" type="text" class="form-input" placeholder="$500" />
140
+ <div class="cpub-form-field" style="flex: 1">
141
+ <label class="cpub-form-label">Value</label>
142
+ <input v-model="prize.value" type="text" class="cpub-form-input" placeholder="$500" />
143
143
  </div>
144
144
  </div>
145
- <div class="form-field">
146
- <label class="form-label">Description</label>
147
- <input v-model="prize.description" type="text" class="form-input" placeholder="What the winner receives..." />
145
+ <div class="cpub-form-field">
146
+ <label class="cpub-form-label">Description</label>
147
+ <input v-model="prize.description" type="text" class="cpub-form-input" placeholder="What the winner receives..." />
148
148
  </div>
149
149
  </div>
150
150
  </section>
@@ -157,36 +157,36 @@ const placeColors = ['var(--yellow)', 'var(--text-faint)', '#a0724a', 'var(--acc
157
157
  </template>
158
158
 
159
159
  <style scoped>
160
- .contest-create { max-width: 720px; margin: 0 auto; padding: 32px; }
160
+ .cpub-contest-create { max-width: 720px; margin: 0 auto; padding: 32px; }
161
161
 
162
162
  .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; }
163
163
  .cpub-back-link:hover { color: var(--accent); }
164
164
 
165
- .page-title { font-size: 24px; font-weight: 700; margin-bottom: 24px; letter-spacing: -0.02em; }
165
+ .cpub-page-title { font-size: 24px; font-weight: 700; margin-bottom: 24px; letter-spacing: -0.02em; }
166
166
 
167
- .contest-form { display: flex; flex-direction: column; gap: 20px; }
167
+ .cpub-contest-form { display: flex; flex-direction: column; gap: 20px; }
168
168
 
169
- .form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
170
- .form-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
171
- .form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
172
- .form-section-header .form-section-title { margin-bottom: 0; }
169
+ .cpub-form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
170
+ .cpub-form-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
171
+ .cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
172
+ .cpub-form-section-header .cpub-form-section-title { margin-bottom: 0; }
173
173
 
174
- .form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
175
- .form-field:last-child { margin-bottom: 0; }
176
- .form-label { font-size: 10px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
177
- .form-input, .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; }
178
- .form-input:focus, .form-textarea:focus { border-color: var(--accent); outline: none; }
179
- .form-textarea { resize: vertical; }
180
- .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
174
+ .cpub-form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
175
+ .cpub-form-field:last-child { margin-bottom: 0; }
176
+ .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); }
177
+ .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; }
178
+ .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; }
179
+ .cpub-form-textarea { resize: vertical; }
180
+ .cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
181
181
 
182
- .prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
183
- .prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
184
- .prize-place { font-size: 12px; font-weight: 700; font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; }
182
+ .cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
183
+ .cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
184
+ .cpub-prize-place { font-size: 12px; font-weight: 700; font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; }
185
185
 
186
186
  @media (max-width: 768px) {
187
- .contest-create { padding: 16px; }
188
- .page-title { font-size: 20px; }
189
- .form-section { padding: 14px; }
190
- .form-row { grid-template-columns: 1fr; }
187
+ .cpub-contest-create { padding: 16px; }
188
+ .cpub-page-title { font-size: 20px; }
189
+ .cpub-form-section { padding: 14px; }
190
+ .cpub-form-row { grid-template-columns: 1fr; }
191
191
  }
192
192
  </style>