@commonpub/layer 0.25.1 → 0.27.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.
@@ -10,41 +10,70 @@ const saving = ref(false);
10
10
  const title = ref('');
11
11
  const description = ref('');
12
12
  const rules = ref('');
13
+ const bannerUrl = ref('');
13
14
  const startDate = ref('');
14
15
  const endDate = ref('');
15
16
  const judgingEndDate = ref('');
17
+ const communityVotingEnabled = ref(false);
18
+ const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
19
+
20
+ // Entry rules
21
+ const { enabledTypeMeta } = useContentTypes();
22
+ const eligibleContentTypes = ref<string[]>([]); // empty = all types allowed
23
+ const maxEntriesPerUser = ref<number | null>(null);
24
+ function toggleType(type: string): void {
25
+ const i = eligibleContentTypes.value.indexOf(type);
26
+ if (i >= 0) eligibleContentTypes.value.splice(i, 1);
27
+ else eligibleContentTypes.value.push(type);
28
+ }
16
29
 
17
30
  // Prizes
18
31
  interface Prize {
19
- place: number;
32
+ place: number | null;
33
+ category: string;
20
34
  title: string;
21
35
  description: string;
22
36
  value: string;
23
37
  }
24
38
 
25
39
  const prizes = ref<Prize[]>([
26
- { place: 1, title: '1st Place', description: '', value: '' },
27
- { place: 2, title: '2nd Place', description: '', value: '' },
28
- { place: 3, title: '3rd Place', description: '', value: '' },
40
+ { place: 1, category: '', title: '1st Place', description: '', value: '' },
41
+ { place: 2, category: '', title: '2nd Place', description: '', value: '' },
42
+ { place: 3, category: '', title: '3rd Place', description: '', value: '' },
29
43
  ]);
30
44
 
31
45
  function addPrize(): void {
32
- prizes.value.push({
33
- place: prizes.value.length + 1,
34
- title: `${prizes.value.length + 1}th Place`,
35
- description: '',
36
- value: '',
37
- });
46
+ prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
38
47
  }
39
48
 
40
49
  function removePrize(index: number): void {
41
50
  prizes.value.splice(index, 1);
42
- // Renumber
43
- prizes.value.forEach((p, i) => { p.place = i + 1; });
44
51
  }
45
52
 
53
+ // Judging criteria (rubric)
54
+ interface Criterion { label: string; weight: number | null; description: string }
55
+ const criteria = ref<Criterion[]>([]);
56
+ function addCriterion(): void {
57
+ criteria.value.push({ label: '', weight: null, description: '' });
58
+ }
59
+ function removeCriterion(index: number): void {
60
+ criteria.value.splice(index, 1);
61
+ }
62
+ const criteriaTotal = computed(() => criteria.value.reduce((s, c) => s + (c.weight ?? 0), 0));
63
+
64
+ const dateError = computed(() => {
65
+ if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
66
+ return 'End date must be after the start date.';
67
+ }
68
+ if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
69
+ return 'Judging end date must be on or after the end date.';
70
+ }
71
+ return '';
72
+ });
73
+
46
74
  async function handleCreate(): Promise<void> {
47
75
  if (!title.value.trim() || !startDate.value || !endDate.value) return;
76
+ if (dateError.value) { toast.error(dateError.value); return; }
48
77
  saving.value = true;
49
78
  try {
50
79
  const result = await $fetch<{ slug: string }>('/api/contests', {
@@ -53,10 +82,30 @@ async function handleCreate(): Promise<void> {
53
82
  title: title.value,
54
83
  description: description.value || undefined,
55
84
  rules: rules.value || undefined,
85
+ bannerUrl: bannerUrl.value || undefined,
56
86
  startDate: new Date(startDate.value).toISOString(),
57
87
  endDate: new Date(endDate.value).toISOString(),
58
88
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
59
- prizes: prizes.value.filter(p => p.title.trim()),
89
+ communityVotingEnabled: communityVotingEnabled.value,
90
+ judgingVisibility: judgingVisibility.value,
91
+ eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
92
+ maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
93
+ prizes: prizes.value
94
+ .filter(p => p.title.trim())
95
+ .map(p => ({
96
+ place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
97
+ category: p.category.trim() || undefined,
98
+ title: p.title,
99
+ description: p.description || undefined,
100
+ value: p.value || undefined,
101
+ })),
102
+ judgingCriteria: criteria.value
103
+ .filter(c => c.label.trim())
104
+ .map(c => ({
105
+ label: c.label.trim(),
106
+ weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
107
+ description: c.description.trim() || undefined,
108
+ })),
60
109
  },
61
110
  });
62
111
  toast.success('Contest created!');
@@ -68,8 +117,11 @@ async function handleCreate(): Promise<void> {
68
117
  }
69
118
  }
70
119
 
71
- const placeLabels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
72
- const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--accent)', 'var(--accent)', 'var(--accent)'];
120
+ function prizeLabel(prize: Prize, idx: number): string {
121
+ if (prize.category.trim()) return prize.category;
122
+ const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
123
+ return `${labels[idx] || `${idx + 1}th`} Place`;
124
+ }
73
125
  </script>
74
126
 
75
127
  <template>
@@ -91,7 +143,11 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
91
143
  </div>
92
144
  <div class="cpub-form-field">
93
145
  <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..." />
146
+ <textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="4" placeholder="Contest rules and requirements (one per line)..." />
147
+ </div>
148
+ <div class="cpub-form-field">
149
+ <label for="contest-banner" class="cpub-form-label">Banner Image URL</label>
150
+ <input id="contest-banner" v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
95
151
  </div>
96
152
  </section>
97
153
 
@@ -112,6 +168,65 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
112
168
  <input id="judging-date" v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
113
169
  </div>
114
170
  </div>
171
+ <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
172
+ </section>
173
+
174
+ <!-- Entry Rules -->
175
+ <section class="cpub-form-section">
176
+ <h2 class="cpub-form-section-title">Entries</h2>
177
+ <div class="cpub-form-field">
178
+ <span class="cpub-form-label">Eligible content types</span>
179
+ <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
180
+ <div class="cpub-type-options" role="group" aria-label="Eligible content types">
181
+ <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
182
+ <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
183
+ <span>{{ t.label }}</span>
184
+ </label>
185
+ </div>
186
+ </div>
187
+ <div class="cpub-form-field">
188
+ <label for="max-entries" class="cpub-form-label">Max entries per person</label>
189
+ <input id="max-entries" v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
190
+ </div>
191
+ </section>
192
+
193
+ <!-- Judging -->
194
+ <section class="cpub-form-section">
195
+ <h2 class="cpub-form-section-title">Judging</h2>
196
+ <div class="cpub-form-field">
197
+ <label for="judging-visibility" class="cpub-form-label">Score Visibility</label>
198
+ <select id="judging-visibility" v-model="judgingVisibility" class="cpub-form-input">
199
+ <option value="judges-only">Judges only — scores hidden until results</option>
200
+ <option value="public">Public — show scores during judging</option>
201
+ <option value="private">Private — scores stay with organizers</option>
202
+ </select>
203
+ </div>
204
+ <label class="cpub-form-check">
205
+ <input v-model="communityVotingEnabled" type="checkbox" />
206
+ <span>Enable community voting (let signed-in members upvote entries)</span>
207
+ </label>
208
+
209
+ <div class="cpub-form-section-header" style="margin-top: 16px;">
210
+ <h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
211
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
212
+ </div>
213
+ <p v-if="!criteria.length" class="cpub-form-hint">Optional rubric shown to entrants and judges (e.g. Documentation — 20 pts).</p>
214
+ <div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
215
+ <div class="cpub-form-row">
216
+ <div class="cpub-form-field" style="flex: 3">
217
+ <label class="cpub-form-label">Criterion</label>
218
+ <input v-model="crit.label" type="text" class="cpub-form-input" placeholder="e.g. Documentation" />
219
+ </div>
220
+ <div class="cpub-form-field" style="flex: 1">
221
+ <label class="cpub-form-label">Points</label>
222
+ <input v-model.number="crit.weight" type="number" min="0" max="100" class="cpub-form-input" placeholder="20" />
223
+ </div>
224
+ <button type="button" class="cpub-delete-btn cpub-criterion-del" aria-label="Remove criterion" @click="removeCriterion(ci)"><i class="fa-solid fa-xmark"></i></button>
225
+ </div>
226
+ <div class="cpub-form-field">
227
+ <input v-model="crit.description" type="text" class="cpub-form-input" placeholder="What judges look for (optional)" />
228
+ </div>
229
+ </div>
115
230
  </section>
116
231
 
117
232
  <!-- Prizes -->
@@ -123,15 +238,26 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
123
238
  </button>
124
239
  </div>
125
240
 
241
+ <p class="cpub-form-hint">Use <strong>place</strong> for ranked prizes (1st/2nd/3rd) or a <strong>category</strong> for themed awards (e.g. "Best in Show").</p>
126
242
  <div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
127
243
  <div class="cpub-prize-header">
128
- <span class="cpub-prize-place" :style="{ color: placeColors[idx] || 'var(--accent)' }">
129
- <i class="fa-solid fa-trophy"></i> {{ placeLabels[idx] || `${idx + 1}th` }} Place
244
+ <span class="cpub-prize-place">
245
+ <i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize, idx) }}
130
246
  </span>
131
- <button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" @click="removePrize(idx)">
247
+ <button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
132
248
  <i class="fa-solid fa-xmark"></i>
133
249
  </button>
134
250
  </div>
251
+ <div class="cpub-form-row">
252
+ <div class="cpub-form-field" style="flex: 1">
253
+ <label class="cpub-form-label">Place</label>
254
+ <input v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
255
+ </div>
256
+ <div class="cpub-form-field" style="flex: 2">
257
+ <label class="cpub-form-label">Category (optional)</label>
258
+ <input v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
259
+ </div>
260
+ </div>
135
261
  <div class="cpub-form-row">
136
262
  <div class="cpub-form-field" style="flex: 2">
137
263
  <label class="cpub-form-label">Title</label>
@@ -149,7 +275,7 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
149
275
  </div>
150
276
  </section>
151
277
 
152
- <button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate">
278
+ <button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
153
279
  <i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating...' : 'Create Contest' }}
154
280
  </button>
155
281
  </form>
@@ -181,7 +307,20 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
181
307
 
182
308
  .cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
183
309
  .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; }
310
+ .cpub-prize-place { font-size: 12px; font-weight: 700; font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; color: var(--accent); }
311
+
312
+ .cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
313
+ .cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; margin-top: 4px; }
314
+ .cpub-form-check input { width: 14px; height: 14px; }
315
+ .cpub-type-options { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
316
+ .cpub-form-subtitle { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
317
+ .cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
318
+ .cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 8px 0; line-height: 1.5; }
319
+ .cpub-criterion-row { border: var(--border-width-default) solid var(--border); padding: 12px; margin-bottom: 8px; background: var(--surface2); }
320
+ .cpub-criterion-row .cpub-form-row { align-items: flex-end; }
321
+ .cpub-criterion-del { align-self: flex-end; margin-bottom: 12px; }
322
+ .cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
323
+ .cpub-delete-btn:hover { color: var(--red); }
185
324
 
186
325
  @media (max-width: 768px) {
187
326
  .cpub-contest-create { padding: 16px; }
@@ -8,7 +8,7 @@ export default defineEventHandler(async (event): Promise<{ withdrawn: boolean }>
8
8
 
9
9
  const result = await withdrawContestEntry(db, entryId, user.id);
10
10
  if (!result.withdrawn) {
11
- throw createError({ statusCode: 400, message: result.error ?? 'Cannot withdraw entry' });
11
+ throw createError({ statusCode: 400, statusMessage: result.error ?? 'Cannot withdraw entry' });
12
12
  }
13
13
  return { withdrawn: true };
14
14
  });
@@ -1,4 +1,4 @@
1
- import { listContestEntries, getContestBySlug } from '@commonpub/server';
1
+ import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores } from '@commonpub/server';
2
2
  import type { ContestEntryItem } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
@@ -15,9 +15,24 @@ export default defineEventHandler(async (event): Promise<{ items: ContestEntryIt
15
15
  const query = parseQueryParams(event, entriesQuerySchema);
16
16
  const contest = await getContestBySlug(db, slug);
17
17
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
18
+
19
+ // A privileged viewer is the contest owner, an admin, or a panel judge.
20
+ // Only privileged viewers may read per-judge scores + written feedback.
21
+ // Aggregate score visibility additionally honours the contest's
22
+ // judgingVisibility setting (public / judges-only / private).
23
+ const user = getOptionalUser(event);
24
+ let privileged = false;
25
+ if (user) {
26
+ privileged =
27
+ user.id === contest.createdById ||
28
+ user.role === 'admin' ||
29
+ (await isContestJudge(db, contest.id, user.id));
30
+ }
31
+
18
32
  return listContestEntries(db, contest.id, {
19
33
  limit: query.limit,
20
34
  offset: query.offset,
21
- includeJudgeScores: query.includeJudgeScores,
35
+ includeJudgeScores: privileged && query.includeJudgeScores,
36
+ revealScores: shouldRevealScores(contest.judgingVisibility, contest.status, privileged),
22
37
  });
23
38
  });
@@ -12,8 +12,9 @@ export default defineEventHandler(async (event): Promise<ContestEntryVoteInfo[]>
12
12
  const user = getOptionalUser(event);
13
13
 
14
14
  const contest = await getContestBySlug(db, slug);
15
- if (!contest || contest.status === 'upcoming') throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
- if (!contest.communityVotingEnabled) return [];
15
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ // Voting disabled, or contest not yet open (no entries) empty array, not an error.
17
+ if (!contest.communityVotingEnabled || contest.status === 'upcoming') return [];
17
18
 
18
19
  return getContestEntryVotes(db, contest.id, user?.id ?? null);
19
20
  });
@@ -1,4 +1,4 @@
1
- import { createContest } from '@commonpub/server';
1
+ import { createContest, getContestBySlug } from '@commonpub/server';
2
2
  import type { ContestDetail, CreateContestInput } from '@commonpub/server';
3
3
  import { createContestSchema } from '@commonpub/schema';
4
4
 
@@ -9,7 +9,7 @@ function slugify(text: string): string {
9
9
  .replace(/\s+/g, '-')
10
10
  .replace(/-+/g, '-')
11
11
  .replace(/^-|-$/g, '')
12
- .slice(0, 128);
12
+ .slice(0, 120);
13
13
  }
14
14
 
15
15
  export default defineEventHandler(async (event): Promise<ContestDetail> => {
@@ -19,7 +19,13 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
19
19
  const config = useConfig();
20
20
  const input = await parseBody(event, createContestSchema);
21
21
 
22
- const slug = slugify(input.title) || `contest-${Date.now()}`;
22
+ const base = slugify(input.title) || `contest-${Date.now()}`;
23
+ // Ensure slug uniqueness so a duplicate title returns a clean contest instead
24
+ // of a 500 from the unique-constraint violation.
25
+ let slug = base;
26
+ for (let n = 2; n <= 50 && (await getContestBySlug(db, slug)); n++) {
27
+ slug = `${base}-${n}`;
28
+ }
23
29
 
24
30
  return createContest(db, { ...input, slug, createdBy: user.id } as CreateContestInput, {
25
31
  userRole: user.role,