@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.
@@ -15,96 +15,106 @@ const saving = ref(false);
15
15
  const title = ref('');
16
16
  const description = ref('');
17
17
  const rules = ref('');
18
+ const bannerUrl = ref('');
18
19
  const startDate = ref('');
19
20
  const endDate = ref('');
20
21
  const judgingEndDate = ref('');
21
- const prizes = ref<Array<{ place: number; title: string; description: string; value: string }>>([]);
22
- const judgeIds = ref<string[]>([]);
23
- const judgeSearch = ref('');
24
- const judgeSearchResults = ref<Array<{ id: string; username: string; displayName: string | null }>>([]);
25
- const searchingJudges = ref(false);
22
+ const communityVotingEnabled = ref(false);
23
+ const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
26
24
 
27
- interface JudgeDisplay { id: string; username: string; displayName: string | null }
28
- const resolvedJudges = ref<JudgeDisplay[]>([]);
25
+ const { enabledTypeMeta } = useContentTypes();
26
+ const eligibleContentTypes = ref<string[]>([]);
27
+ const maxEntriesPerUser = ref<number | null>(null);
28
+ function toggleType(type: string): void {
29
+ const i = eligibleContentTypes.value.indexOf(type);
30
+ if (i >= 0) eligibleContentTypes.value.splice(i, 1);
31
+ else eligibleContentTypes.value.push(type);
32
+ }
33
+
34
+ interface Prize { place: number | null; category: string; title: string; description: string; value: string }
35
+ const prizes = ref<Prize[]>([]);
36
+
37
+ interface Criterion { label: string; weight: number | null; description: string }
38
+ const criteria = ref<Criterion[]>([]);
29
39
 
30
40
  // Load current data
31
- watch(contest, async (c) => {
41
+ watch(contest, (c) => {
32
42
  if (!c) return;
33
43
  title.value = c.title ?? '';
34
44
  description.value = c.description ?? '';
35
45
  rules.value = c.rules ?? '';
46
+ bannerUrl.value = c.bannerUrl ?? '';
36
47
  startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
37
48
  endDate.value = c.endDate ? new Date(c.endDate).toISOString().slice(0, 16) : '';
38
49
  judgingEndDate.value = c.judgingEndDate ? new Date(c.judgingEndDate).toISOString().slice(0, 16) : '';
39
- prizes.value = (c.prizes ?? []).map((p: { place: number; title: string; description?: string; value?: string }) => ({
40
- place: p.place,
50
+ communityVotingEnabled.value = !!c.communityVotingEnabled;
51
+ judgingVisibility.value = (c.judgingVisibility as typeof judgingVisibility.value) ?? 'judges-only';
52
+ eligibleContentTypes.value = [...(c.eligibleContentTypes ?? [])];
53
+ maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
54
+ prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title: string; description?: string; value?: string }) => ({
55
+ place: p.place ?? null,
56
+ category: p.category ?? '',
41
57
  title: p.title,
42
58
  description: p.description ?? '',
43
59
  value: p.value ?? '',
44
60
  }));
45
- judgeIds.value = [...(c.judges ?? [])];
46
- // Resolve judge IDs to display info
47
- if (judgeIds.value.length > 0) {
48
- try {
49
- const data = await $fetch<{ items: JudgeDisplay[] }>('/api/users', { query: { ids: judgeIds.value.join(',') } });
50
- resolvedJudges.value = data.items;
51
- } catch { /* ignore */ }
52
- }
61
+ criteria.value = (c.judgingCriteria ?? []).map((cr: { label: string; weight?: number; description?: string }) => ({
62
+ label: cr.label,
63
+ weight: cr.weight ?? null,
64
+ description: cr.description ?? '',
65
+ }));
53
66
  }, { immediate: true });
54
67
 
55
- async function searchJudge(): Promise<void> {
56
- const q = judgeSearch.value.trim();
57
- if (!q || q.length < 2) { judgeSearchResults.value = []; return; }
58
- searchingJudges.value = true;
59
- try {
60
- const data = await $fetch<{ items: Array<{ id: string; username: string; displayName: string | null }> }>('/api/users', { query: { q, limit: 5 } });
61
- judgeSearchResults.value = data.items.filter((u) => !judgeIds.value.includes(u.id));
62
- } catch { judgeSearchResults.value = []; }
63
- finally { searchingJudges.value = false; }
64
- }
65
-
66
- function addJudge(u: { id: string; username: string; displayName: string | null }): void {
67
- if (!judgeIds.value.includes(u.id)) {
68
- judgeIds.value.push(u.id);
69
- resolvedJudges.value.push(u);
70
- }
71
- judgeSearch.value = '';
72
- judgeSearchResults.value = [];
73
- }
74
-
75
- function removeJudge(id: string): void {
76
- judgeIds.value = judgeIds.value.filter((jid) => jid !== id);
77
- resolvedJudges.value = resolvedJudges.value.filter((j) => j.id !== id);
78
- }
79
-
80
68
  function addPrize(): void {
81
- const nextPlace = prizes.value.length + 1;
82
- prizes.value.push({ place: nextPlace, title: '', description: '', value: '' });
69
+ prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
83
70
  }
84
-
85
71
  function removePrize(index: number): void {
86
72
  prizes.value.splice(index, 1);
87
- prizes.value.forEach((p, i) => { p.place = i + 1; });
73
+ }
74
+ function prizeLabel(prize: Prize, idx: number): string {
75
+ if (prize.category.trim()) return prize.category;
76
+ const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
77
+ return prize.place ? `${labels[prize.place - 1] || `${prize.place}th`} Place` : `${labels[idx] || `${idx + 1}th`} Place`;
88
78
  }
89
79
 
90
- function placeLabel(place: number): string {
91
- if (place === 1) return '1st Place';
92
- if (place === 2) return '2nd Place';
93
- if (place === 3) return '3rd Place';
94
- return `${place}th Place`;
80
+ function addCriterion(): void {
81
+ criteria.value.push({ label: '', weight: null, description: '' });
82
+ }
83
+ function removeCriterion(index: number): void {
84
+ criteria.value.splice(index, 1);
95
85
  }
86
+ const criteriaTotal = computed(() => criteria.value.reduce((s, c) => s + (c.weight ?? 0), 0));
87
+
88
+ const dateError = computed(() => {
89
+ if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
90
+ return 'End date must be after the start date.';
91
+ }
92
+ if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
93
+ return 'Judging end date must be on or after the end date.';
94
+ }
95
+ return '';
96
+ });
96
97
 
97
98
  async function handleSave(): Promise<void> {
99
+ if (dateError.value) { toast.error(dateError.value); return; }
98
100
  saving.value = true;
99
101
  try {
100
102
  const prizeData = prizes.value
101
103
  .filter((p) => p.title.trim())
102
104
  .map((p) => ({
103
- place: p.place,
105
+ place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
106
+ category: p.category.trim() || undefined,
104
107
  title: p.title,
105
108
  description: p.description || undefined,
106
109
  value: p.value || undefined,
107
110
  }));
111
+ const criteriaData = criteria.value
112
+ .filter((c) => c.label.trim())
113
+ .map((c) => ({
114
+ label: c.label.trim(),
115
+ weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
116
+ description: c.description.trim() || undefined,
117
+ }));
108
118
 
109
119
  await $fetch(`/api/contests/${slug}`, {
110
120
  method: 'PUT',
@@ -112,11 +122,16 @@ async function handleSave(): Promise<void> {
112
122
  title: title.value,
113
123
  description: description.value || undefined,
114
124
  rules: rules.value || undefined,
125
+ bannerUrl: bannerUrl.value || undefined,
115
126
  startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
116
127
  endDate: endDate.value ? new Date(endDate.value).toISOString() : undefined,
117
128
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
118
- prizes: prizeData.length > 0 ? prizeData : undefined,
119
- judges: judgeIds.value.length > 0 ? judgeIds.value : undefined,
129
+ communityVotingEnabled: communityVotingEnabled.value,
130
+ judgingVisibility: judgingVisibility.value,
131
+ eligibleContentTypes: eligibleContentTypes.value,
132
+ maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
133
+ prizes: prizeData,
134
+ judgingCriteria: criteriaData,
120
135
  },
121
136
  });
122
137
  toast.success('Contest updated');
@@ -134,10 +149,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
134
149
  : `Change contest status to "${newStatus}"?`;
135
150
  if (!confirm(msg)) return;
136
151
  try {
137
- await $fetch(`/api/contests/${slug}/transition`, {
138
- method: 'POST',
139
- body: { status: newStatus },
140
- });
152
+ await $fetch(`/api/contests/${slug}/transition`, { method: 'POST', body: { status: newStatus } });
141
153
  toast.success(`Status changed to ${newStatus}`);
142
154
  await refresh();
143
155
  } catch (err: unknown) {
@@ -173,6 +185,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
173
185
  <label class="cpub-form-label">Rules</label>
174
186
  <textarea v-model="rules" class="cpub-form-textarea" rows="4" placeholder="One rule per line" />
175
187
  </div>
188
+ <div class="cpub-form-field">
189
+ <label class="cpub-form-label">Banner Image URL</label>
190
+ <input v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
191
+ </div>
176
192
  </section>
177
193
 
178
194
  <section class="cpub-form-section">
@@ -191,14 +207,43 @@ async function transitionStatus(newStatus: string): Promise<void> {
191
207
  <label class="cpub-form-label">Judging End Date</label>
192
208
  <input v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
193
209
  </div>
210
+ <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
211
+ </section>
212
+
213
+ <section class="cpub-form-section">
214
+ <h2 class="cpub-form-section-title">Entries</h2>
215
+ <div class="cpub-form-field">
216
+ <span class="cpub-form-label">Eligible content types</span>
217
+ <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
218
+ <div class="cpub-type-options" role="group" aria-label="Eligible content types">
219
+ <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
220
+ <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
221
+ <span>{{ t.label }}</span>
222
+ </label>
223
+ </div>
224
+ </div>
225
+ <div class="cpub-form-field">
226
+ <label class="cpub-form-label">Max entries per person</label>
227
+ <input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
228
+ </div>
194
229
  </section>
195
230
 
196
231
  <section class="cpub-form-section">
197
232
  <h2 class="cpub-form-section-title">Prizes</h2>
198
233
  <div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
199
234
  <div class="cpub-prize-header">
200
- <span class="cpub-prize-label">{{ placeLabel(prize.place) }}</span>
201
- <button type="button" class="cpub-prize-remove" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
235
+ <span class="cpub-prize-label">{{ prizeLabel(prize, i) }}</span>
236
+ <button type="button" class="cpub-prize-remove" aria-label="Remove prize" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
237
+ </div>
238
+ <div class="cpub-form-row">
239
+ <div class="cpub-form-field">
240
+ <label class="cpub-form-label">Place</label>
241
+ <input v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
242
+ </div>
243
+ <div class="cpub-form-field">
244
+ <label class="cpub-form-label">Category (optional)</label>
245
+ <input v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
246
+ </div>
202
247
  </div>
203
248
  <div class="cpub-form-row">
204
249
  <div class="cpub-form-field">
@@ -219,37 +264,49 @@ async function transitionStatus(newStatus: string): Promise<void> {
219
264
  </section>
220
265
 
221
266
  <section class="cpub-form-section">
222
- <h2 class="cpub-form-section-title">Judges</h2>
223
- <div v-if="resolvedJudges.length" class="cpub-judge-list">
224
- <div v-for="judge in resolvedJudges" :key="judge.id" class="cpub-judge-chip">
225
- <span>{{ judge.displayName || judge.username }}</span>
226
- <button type="button" class="cpub-judge-chip-remove" @click="removeJudge(judge.id)"><i class="fa-solid fa-times"></i></button>
227
- </div>
267
+ <h2 class="cpub-form-section-title">Judging</h2>
268
+ <div class="cpub-form-field">
269
+ <label class="cpub-form-label">Score Visibility</label>
270
+ <select v-model="judgingVisibility" class="cpub-form-input">
271
+ <option value="judges-only">Judges only scores hidden until results</option>
272
+ <option value="public">Public — show scores during judging</option>
273
+ <option value="private">Private — scores stay with organizers</option>
274
+ </select>
275
+ </div>
276
+ <label class="cpub-form-check">
277
+ <input v-model="communityVotingEnabled" type="checkbox" />
278
+ <span>Enable community voting</span>
279
+ </label>
280
+
281
+ <div class="cpub-subhead">
282
+ <h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
283
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
228
284
  </div>
229
- <div v-else class="cpub-judge-empty">No judges assigned.</div>
230
- <div class="cpub-judge-search">
231
- <input
232
- v-model="judgeSearch"
233
- type="text"
234
- class="cpub-form-input"
235
- placeholder="Search users by name or username..."
236
- @input="searchJudge"
237
- />
238
- <div v-if="judgeSearchResults.length" class="cpub-judge-dropdown">
239
- <button
240
- v-for="u in judgeSearchResults"
241
- :key="u.id"
242
- type="button"
243
- class="cpub-judge-option"
244
- @click="addJudge(u)"
245
- >
246
- <strong>{{ u.displayName || u.username }}</strong>
247
- <span class="cpub-judge-option-username">@{{ u.username }}</span>
248
- </button>
285
+ <div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
286
+ <div class="cpub-form-row">
287
+ <div class="cpub-form-field" style="flex: 3">
288
+ <label class="cpub-form-label">Criterion</label>
289
+ <input v-model="crit.label" type="text" class="cpub-form-input" placeholder="e.g. Documentation" />
290
+ </div>
291
+ <div class="cpub-form-field" style="flex: 1">
292
+ <label class="cpub-form-label">Points</label>
293
+ <input v-model.number="crit.weight" type="number" min="0" max="100" class="cpub-form-input" placeholder="20" />
294
+ </div>
295
+ <button type="button" class="cpub-prize-remove cpub-criterion-del" aria-label="Remove criterion" @click="removeCriterion(ci)"><i class="fa-solid fa-times"></i></button>
296
+ </div>
297
+ <div class="cpub-form-field">
298
+ <input v-model="crit.description" type="text" class="cpub-form-input" placeholder="What judges look for (optional)" />
249
299
  </div>
250
300
  </div>
251
301
  </section>
252
302
 
303
+ <!-- Judge panel (single source of truth: contest_judges table) -->
304
+ <section class="cpub-form-section">
305
+ <h2 class="cpub-form-section-title">Judges</h2>
306
+ <p class="cpub-form-hint">Invited judges receive a notification and must accept before they can score.</p>
307
+ <ContestJudgeManager :contest-slug="slug" :is-owner="true" />
308
+ </section>
309
+
253
310
  <section class="cpub-form-section">
254
311
  <h2 class="cpub-form-section-title">Status Transitions</h2>
255
312
  <div class="cpub-status-actions">
@@ -273,7 +330,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
273
330
  </div>
274
331
  </section>
275
332
 
276
- <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim()">
333
+ <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
277
334
  <i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
278
335
  </button>
279
336
  </form>
@@ -306,11 +363,22 @@ async function transitionStatus(newStatus: string): Promise<void> {
306
363
  .cpub-form-textarea { resize: vertical; }
307
364
  .cpub-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
308
365
 
309
- .cpub-prize-row { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
366
+ .cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
367
+ .cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
368
+ .cpub-form-check input { width: 14px; height: 14px; }
369
+ .cpub-type-options { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
370
+ .cpub-subhead { display: flex; align-items: center; justify-content: space-between; margin: 18px 0 10px; }
371
+ .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; }
372
+ .cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
373
+ .cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 0 0 12px; line-height: 1.5; }
374
+
375
+ .cpub-prize-row, .cpub-criterion-row { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
310
376
  .cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
311
- .cpub-prize-label { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; }
377
+ .cpub-prize-label { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent); }
312
378
  .cpub-prize-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; }
313
379
  .cpub-prize-remove:hover { color: var(--red); }
380
+ .cpub-criterion-row .cpub-form-row { align-items: flex-end; }
381
+ .cpub-criterion-del { align-self: flex-end; margin-bottom: 12px; }
314
382
 
315
383
  .cpub-status-actions { display: flex; gap: 8px; flex-wrap: wrap; }
316
384
  .cpub-transition-btn { display: inline-flex; align-items: center; gap: 6px; }
@@ -319,17 +387,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
319
387
  .cpub-transition-complete { color: var(--accent); border-color: var(--accent-border); }
320
388
  .cpub-transition-cancel { color: var(--red); border-color: var(--red-border); }
321
389
 
322
- .cpub-judge-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
323
- .cpub-judge-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); font-size: 11px; font-family: var(--font-mono); color: var(--accent); }
324
- .cpub-judge-chip-remove { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 10px; padding: 0; }
325
- .cpub-judge-chip-remove:hover { color: var(--red); }
326
- .cpub-judge-empty { font-size: 12px; color: var(--text-faint); margin-bottom: 12px; }
327
- .cpub-judge-search { position: relative; }
328
- .cpub-judge-dropdown { position: absolute; z-index: 10; top: 100%; left: 0; right: 0; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-lg); max-height: 200px; overflow-y: auto; }
329
- .cpub-judge-option { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px; background: none; border: none; border-bottom: var(--border-width-default) solid var(--border); cursor: pointer; font-size: 12px; color: var(--text); text-align: left; }
330
- .cpub-judge-option:last-child { border-bottom: none; }
331
- .cpub-judge-option:hover { background: var(--surface2); }
332
- .cpub-judge-option-username { color: var(--text-faint); font-family: var(--font-mono); font-size: 11px; }
333
-
334
390
  .cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
391
+
392
+ @media (max-width: 768px) {
393
+ .cpub-contest-edit { padding: 16px; }
394
+ .cpub-form-row { grid-template-columns: 1fr; }
395
+ }
335
396
  </style>