@commonpub/layer 0.29.0 → 0.31.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.
- package/components/ContentCard.vue +13 -3
- package/components/CpubMarkdown.vue +46 -0
- package/components/NotificationItem.vue +45 -14
- package/components/contest/ContestEntries.vue +6 -3
- package/components/contest/ContestHero.vue +28 -2
- package/components/contest/ContestPrizes.vue +7 -3
- package/components/contest/ContestRules.vue +9 -9
- package/composables/useFeatures.ts +8 -0
- package/nuxt.config.ts +1 -0
- package/package.json +9 -9
- package/pages/contests/[slug]/edit.vue +88 -15
- package/pages/contests/[slug]/index.vue +4 -3
- package/pages/contests/[slug]/results.vue +20 -5
- package/pages/contests/create.vue +31 -13
- package/pages/contests/index.vue +30 -2
- package/pages/events/[slug]/index.vue +1 -1
- package/pages/notifications.vue +9 -0
- package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
- package/server/api/admin/api-keys/[id].delete.ts +1 -1
- package/server/api/admin/api-keys/index.get.ts +1 -1
- package/server/api/admin/api-keys/index.post.ts +1 -1
- package/server/api/admin/audit.get.ts +1 -1
- package/server/api/admin/categories/[id].delete.ts +1 -1
- package/server/api/admin/categories/[id].patch.ts +1 -1
- package/server/api/admin/categories/index.get.ts +1 -1
- package/server/api/admin/categories/index.post.ts +1 -1
- package/server/api/admin/content/[id].delete.ts +1 -1
- package/server/api/admin/content/[id].patch.ts +1 -1
- package/server/api/admin/content/bulk-editorial.post.ts +1 -1
- package/server/api/admin/features/index.get.ts +1 -1
- package/server/api/admin/features/index.put.ts +1 -1
- package/server/api/admin/federation/activity.get.ts +1 -1
- package/server/api/admin/federation/clients.get.ts +1 -1
- package/server/api/admin/federation/clients.post.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
- package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
- package/server/api/admin/federation/mirrors/index.get.ts +1 -1
- package/server/api/admin/federation/mirrors/index.post.ts +1 -1
- package/server/api/admin/federation/pending.get.ts +1 -1
- package/server/api/admin/federation/refederate.post.ts +1 -1
- package/server/api/admin/federation/repair-types.post.ts +1 -1
- package/server/api/admin/federation/retry.post.ts +1 -1
- package/server/api/admin/federation/stats.get.ts +1 -1
- package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
- package/server/api/admin/federation/trusted-instances.get.ts +1 -1
- package/server/api/admin/federation/trusted-instances.post.ts +1 -1
- package/server/api/admin/homepage/sections.get.ts +1 -1
- package/server/api/admin/homepage/sections.put.ts +1 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
- package/server/api/admin/layouts/[id].delete.ts +1 -1
- package/server/api/admin/layouts/[id].get.ts +1 -1
- package/server/api/admin/layouts/[id].put.ts +1 -1
- package/server/api/admin/layouts/index.get.ts +1 -1
- package/server/api/admin/layouts/index.post.ts +1 -1
- package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
- package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
- package/server/api/admin/navigation/items.get.ts +1 -1
- package/server/api/admin/navigation/items.put.ts +1 -1
- package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
- package/server/api/admin/reports.get.ts +1 -1
- package/server/api/admin/search/reindex.post.ts +1 -1
- package/server/api/admin/settings.get.ts +1 -1
- package/server/api/admin/settings.put.ts +1 -1
- package/server/api/admin/stats.get.ts +1 -1
- package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
- package/server/api/admin/themes/[id].delete.ts +1 -1
- package/server/api/admin/themes/[id].get.ts +1 -1
- package/server/api/admin/themes/[id].put.ts +1 -1
- package/server/api/admin/themes/discover.get.ts +1 -1
- package/server/api/admin/themes/index.get.ts +1 -1
- package/server/api/admin/themes/index.post.ts +1 -1
- package/server/api/admin/users/[id]/role.put.ts +1 -1
- package/server/api/admin/users/[id]/status.put.ts +1 -1
- package/server/api/admin/users/[id].delete.ts +1 -1
- package/server/api/admin/users.get.ts +1 -1
- package/server/api/contests/[slug]/entries.get.ts +3 -1
- package/server/api/contests/[slug]/index.delete.ts +4 -1
- package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
- package/server/api/contests/[slug]/judges/index.post.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/index.get.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/index.post.ts +1 -1
- package/server/api/docs/migrate-content.post.ts +1 -1
- package/server/api/events/[slug].delete.ts +1 -1
- package/server/api/events/[slug].put.ts +1 -1
- package/server/api/layouts/by-route.get.ts +1 -1
- package/server/api/products/[id].delete.ts +1 -1
- package/server/api/videos/categories/[id].delete.ts +1 -1
- package/server/api/videos/categories/[id].put.ts +1 -1
- package/server/api/videos/categories.post.ts +1 -1
- package/server/middleware/auth.ts +22 -0
- package/server/utils/auth.ts +12 -5
- package/server/utils/permissions.ts +97 -0
- package/server/utils/requirePermission.ts +102 -0
|
@@ -51,7 +51,7 @@ interface Tab { key: string; label: string; icon: string; count?: number }
|
|
|
51
51
|
const tabs = computed<Tab[]>(() => {
|
|
52
52
|
const t: Tab[] = [{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' }];
|
|
53
53
|
if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
|
|
54
|
-
if (c.value?.prizes?.length) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
|
|
54
|
+
if (c.value?.prizes?.length || c.value?.prizesDescription) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
|
|
55
55
|
t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
|
|
56
56
|
if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
|
|
57
57
|
if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
|
|
@@ -259,7 +259,8 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
259
259
|
<div class="cpub-about-section">
|
|
260
260
|
<div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
|
|
261
261
|
<div class="cpub-about-card">
|
|
262
|
-
<
|
|
262
|
+
<CpubMarkdown v-if="c?.description" :source="c.description" />
|
|
263
|
+
<p v-else>No description available for this contest.</p>
|
|
263
264
|
</div>
|
|
264
265
|
</div>
|
|
265
266
|
<ContestJudgingCriteria v-if="c?.judgingCriteria?.length" :criteria="c.judgingCriteria" />
|
|
@@ -272,7 +273,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
272
273
|
|
|
273
274
|
<!-- PRIZES -->
|
|
274
275
|
<div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
|
|
275
|
-
<ContestPrizes v-if="c?.prizes?.length" :prizes="c
|
|
276
|
+
<ContestPrizes v-if="c?.prizes?.length || c?.prizesDescription" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
|
|
276
277
|
</div>
|
|
277
278
|
|
|
278
279
|
<!-- ENTRIES -->
|
|
@@ -5,7 +5,14 @@ const route = useRoute();
|
|
|
5
5
|
const slug = route.params.slug as string;
|
|
6
6
|
|
|
7
7
|
const { data: contest } = useLazyFetch<Serialized<ContestDetail>>(`/api/contests/${slug}`);
|
|
8
|
-
|
|
8
|
+
// Full standings: rank-ordered (not submit-ordered) + a high cap so every
|
|
9
|
+
// finalist surfaces, not just the 20 most-recent entries.
|
|
10
|
+
const { data: entriesData } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(
|
|
11
|
+
`/api/contests/${slug}/entries`,
|
|
12
|
+
{ query: { order: 'rank', limit: 100 } },
|
|
13
|
+
);
|
|
14
|
+
const totalEntries = computed(() => entriesData.value?.total ?? 0);
|
|
15
|
+
const shownEntries = computed(() => rankedEntries.value.length);
|
|
9
16
|
const { data: votesData } = useLazyFetch<ContestEntryVoteInfo[]>(`/api/contests/${slug}/votes`);
|
|
10
17
|
|
|
11
18
|
useSeoMeta({
|
|
@@ -43,8 +50,8 @@ const leaderboard = computed(() => rankedEntries.value);
|
|
|
43
50
|
|
|
44
51
|
const prizes = computed(() => contest.value?.prizes ?? []);
|
|
45
52
|
|
|
46
|
-
function prizeForRank(rank: number): { title
|
|
47
|
-
const prize = prizes.value.find((p: { place?: number; title
|
|
53
|
+
function prizeForRank(rank: number): { title?: string; value?: string } | null {
|
|
54
|
+
const prize = prizes.value.find((p: { place?: number; title?: string; value?: string }) => p.place === rank);
|
|
48
55
|
return prize ?? null;
|
|
49
56
|
}
|
|
50
57
|
|
|
@@ -120,7 +127,13 @@ function medalColor(rank: number): string {
|
|
|
120
127
|
|
|
121
128
|
<!-- LEADERBOARD -->
|
|
122
129
|
<div v-if="leaderboard.length > 0" class="cpub-leaderboard">
|
|
123
|
-
<
|
|
130
|
+
<div class="cpub-leaderboard-head">
|
|
131
|
+
<h2 class="cpub-leaderboard-title">Full Standings</h2>
|
|
132
|
+
<span class="cpub-leaderboard-count">
|
|
133
|
+
{{ totalEntries }} {{ totalEntries === 1 ? 'entry' : 'entries' }}
|
|
134
|
+
<template v-if="shownEntries < totalEntries"> · showing top {{ shownEntries }}</template>
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
124
137
|
<div class="cpub-leaderboard-scroll">
|
|
125
138
|
<table class="cpub-leaderboard-table">
|
|
126
139
|
<thead>
|
|
@@ -191,7 +204,9 @@ function medalColor(rank: number): string {
|
|
|
191
204
|
|
|
192
205
|
/* LEADERBOARD */
|
|
193
206
|
.cpub-leaderboard { margin-bottom: 32px; }
|
|
194
|
-
.cpub-leaderboard-
|
|
207
|
+
.cpub-leaderboard-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
|
|
208
|
+
.cpub-leaderboard-title { font-size: 16px; font-weight: 700; }
|
|
209
|
+
.cpub-leaderboard-count { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); }
|
|
195
210
|
/* Horizontal scroll on narrow screens instead of overflowing the page. */
|
|
196
211
|
.cpub-leaderboard-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
197
212
|
.cpub-leaderboard-table { width: 100%; border-collapse: collapse; font-size: 12px; min-width: 420px; }
|
|
@@ -8,6 +8,7 @@ const { extract: extractError } = useApiError();
|
|
|
8
8
|
const saving = ref(false);
|
|
9
9
|
|
|
10
10
|
const title = ref('');
|
|
11
|
+
const subheading = ref('');
|
|
11
12
|
const description = ref('');
|
|
12
13
|
const rules = ref('');
|
|
13
14
|
const bannerUrl = ref('');
|
|
@@ -46,6 +47,7 @@ interface Prize {
|
|
|
46
47
|
value: string;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
const prizesDescription = ref('');
|
|
49
51
|
const prizes = ref<Prize[]>([
|
|
50
52
|
{ place: 1, category: '', title: '1st Place', description: '', value: '' },
|
|
51
53
|
{ place: 2, category: '', title: '2nd Place', description: '', value: '' },
|
|
@@ -90,6 +92,7 @@ async function handleCreate(): Promise<void> {
|
|
|
90
92
|
method: 'POST',
|
|
91
93
|
body: {
|
|
92
94
|
title: title.value,
|
|
95
|
+
subheading: subheading.value || undefined,
|
|
93
96
|
description: description.value || undefined,
|
|
94
97
|
rules: rules.value || undefined,
|
|
95
98
|
bannerUrl: bannerUrl.value || undefined,
|
|
@@ -102,14 +105,15 @@ async function handleCreate(): Promise<void> {
|
|
|
102
105
|
visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
|
|
103
106
|
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
104
107
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
108
|
+
prizesDescription: prizesDescription.value || undefined,
|
|
105
109
|
prizes: prizes.value
|
|
106
|
-
.filter(p => p.title.trim())
|
|
110
|
+
.filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
107
111
|
.map(p => ({
|
|
108
112
|
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
109
113
|
category: p.category.trim() || undefined,
|
|
110
|
-
title: p.title,
|
|
111
|
-
description: p.description || undefined,
|
|
112
|
-
value: p.value || undefined,
|
|
114
|
+
title: p.title.trim() || undefined,
|
|
115
|
+
description: p.description.trim() || undefined,
|
|
116
|
+
value: p.value.trim() || undefined,
|
|
113
117
|
})),
|
|
114
118
|
judgingCriteria: criteria.value
|
|
115
119
|
.filter(c => c.label.trim())
|
|
@@ -129,10 +133,13 @@ async function handleCreate(): Promise<void> {
|
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
function prizeLabel(prize: Prize
|
|
136
|
+
function prizeLabel(prize: Prize): string {
|
|
133
137
|
if (prize.category.trim()) return prize.category;
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
if (prize.place && prize.place > 0) {
|
|
139
|
+
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
140
|
+
return `${labels[prize.place - 1] || `${prize.place}th`} Place`;
|
|
141
|
+
}
|
|
142
|
+
return 'Prize';
|
|
136
143
|
}
|
|
137
144
|
</script>
|
|
138
145
|
|
|
@@ -149,17 +156,23 @@ function prizeLabel(prize: Prize, idx: number): string {
|
|
|
149
156
|
<label for="contest-title" class="cpub-form-label">Title</label>
|
|
150
157
|
<input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
|
|
151
158
|
</div>
|
|
159
|
+
<div class="cpub-form-field">
|
|
160
|
+
<label for="contest-subheading" class="cpub-form-label">Subheading</label>
|
|
161
|
+
<input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
162
|
+
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
163
|
+
</div>
|
|
152
164
|
<div class="cpub-form-field">
|
|
153
165
|
<label for="contest-desc" class="cpub-form-label">Description</label>
|
|
154
|
-
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="
|
|
166
|
+
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" placeholder="Describe your contest. Supports Markdown — # headings, - lists, **bold**, [links](url)…" />
|
|
167
|
+
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
155
168
|
</div>
|
|
156
169
|
<div class="cpub-form-field">
|
|
157
170
|
<label for="contest-rules" class="cpub-form-label">Rules</label>
|
|
158
|
-
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="
|
|
171
|
+
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" placeholder="Contest rules and requirements. Supports Markdown — one rule per line, or full Markdown." />
|
|
172
|
+
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
159
173
|
</div>
|
|
160
174
|
<div class="cpub-form-field">
|
|
161
|
-
<
|
|
162
|
-
<input id="contest-banner" v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
|
|
175
|
+
<ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
|
|
163
176
|
</div>
|
|
164
177
|
</section>
|
|
165
178
|
|
|
@@ -274,11 +287,16 @@ function prizeLabel(prize: Prize, idx: number): string {
|
|
|
274
287
|
</button>
|
|
275
288
|
</div>
|
|
276
289
|
|
|
277
|
-
<p class="cpub-form-hint">Use <strong>place</strong> for ranked prizes (1st/2nd/3rd)
|
|
290
|
+
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
|
|
291
|
+
<div class="cpub-form-field">
|
|
292
|
+
<label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
|
|
293
|
+
<textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
294
|
+
<p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
|
|
295
|
+
</div>
|
|
278
296
|
<div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
|
|
279
297
|
<div class="cpub-prize-header">
|
|
280
298
|
<span class="cpub-prize-place">
|
|
281
|
-
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize
|
|
299
|
+
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize) }}
|
|
282
300
|
</span>
|
|
283
301
|
<button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
|
|
284
302
|
<i class="fa-solid fa-xmark"></i>
|
package/pages/contests/index.vue
CHANGED
|
@@ -4,6 +4,25 @@ useSeoMeta({ title: `Contests — ${useSiteName()}` });
|
|
|
4
4
|
const { data: contests } = await useFetch('/api/contests');
|
|
5
5
|
const { isAuthenticated, isAdmin, user } = useAuth();
|
|
6
6
|
|
|
7
|
+
// Card blurb: prefer the short subheading; otherwise a plain-text, markdown-
|
|
8
|
+
// stripped excerpt of the (possibly long Markdown) description — so listing
|
|
9
|
+
// cards never dump a raw `## ...` wall.
|
|
10
|
+
function cardBlurb(c: { subheading?: string | null; description?: string | null }): string {
|
|
11
|
+
if (c.subheading?.trim()) return c.subheading.trim();
|
|
12
|
+
const d = (c.description ?? '').trim();
|
|
13
|
+
if (!d) return '';
|
|
14
|
+
return d
|
|
15
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
16
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
17
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
18
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
19
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
20
|
+
.replace(/^\s*[-*+>]\s+/gm, '')
|
|
21
|
+
.replace(/(\*\*|__|~~|\*|_)/g, '')
|
|
22
|
+
.replace(/\s+/g, ' ')
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
const config = useRuntimeConfig();
|
|
8
27
|
const contestCreation = config.public.contestCreation as string || 'admin';
|
|
9
28
|
const canCreateContest = computed(() => {
|
|
@@ -36,8 +55,8 @@ const canCreateContest = computed(() => {
|
|
|
36
55
|
{{ contest.title }}
|
|
37
56
|
</NuxtLink>
|
|
38
57
|
</h3>
|
|
39
|
-
<p v-if="contest
|
|
40
|
-
{{ contest
|
|
58
|
+
<p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb" style="font-size: 12px; color: var(--text-dim); margin-bottom: 12px">
|
|
59
|
+
{{ cardBlurb(contest) }}
|
|
41
60
|
</p>
|
|
42
61
|
<div v-if="contest.endDate" style="margin-top: 8px">
|
|
43
62
|
<CountdownTimer :target-date="contest.endDate" />
|
|
@@ -63,6 +82,15 @@ const canCreateContest = computed(() => {
|
|
|
63
82
|
.cpub-card:hover { box-shadow: var(--shadow-lg); transform: translate(-1px, -1px); }
|
|
64
83
|
.cpub-card-body { padding: 16px; }
|
|
65
84
|
|
|
85
|
+
.cpub-contest-card-blurb {
|
|
86
|
+
display: -webkit-box;
|
|
87
|
+
-webkit-line-clamp: 3;
|
|
88
|
+
line-clamp: 3;
|
|
89
|
+
-webkit-box-orient: vertical;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
line-height: 1.5;
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
@media (max-width: 768px) {
|
|
67
95
|
.cpub-contests-page { padding: 16px; }
|
|
68
96
|
.cpub-grid-3 { grid-template-columns: 1fr; gap: 14px; }
|
package/pages/notifications.vue
CHANGED
|
@@ -27,6 +27,14 @@ async function deleteNotification(id: string): Promise<void> {
|
|
|
27
27
|
await $fetch(`/api/notifications/${id}`, { method: 'DELETE' });
|
|
28
28
|
refresh();
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
// Fired when a row is clicked/navigated — mark it read so the unread highlight
|
|
32
|
+
// + nav badge clear. Fire-and-forget: navigation proceeds regardless.
|
|
33
|
+
function onNotificationRead(id: string): void {
|
|
34
|
+
$fetch('/api/notifications/read', { method: 'POST', body: { notificationId: id } })
|
|
35
|
+
.then(() => refresh())
|
|
36
|
+
.catch(() => {});
|
|
37
|
+
}
|
|
30
38
|
</script>
|
|
31
39
|
|
|
32
40
|
<template>
|
|
@@ -59,6 +67,7 @@ async function deleteNotification(id: string): Promise<void> {
|
|
|
59
67
|
v-for="n in filteredNotifications"
|
|
60
68
|
:key="n.id"
|
|
61
69
|
:notification="n"
|
|
70
|
+
@read="onNotificationRead"
|
|
62
71
|
/>
|
|
63
72
|
<div v-if="!filteredNotifications.length" class="cpub-empty-state">
|
|
64
73
|
<div class="cpub-empty-state-icon"><i class="fa-solid fa-bell-slash"></i></div>
|
|
@@ -6,7 +6,7 @@ const querySchema = z.object({
|
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
|
-
|
|
9
|
+
requirePermission(event, 'apikeys.manage');
|
|
10
10
|
const id = getRouterParam(event, 'id');
|
|
11
11
|
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
12
12
|
const parsed = querySchema.safeParse(getQuery(event));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { revokeApiKey, createAuditEntry } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
|
-
const user =
|
|
4
|
+
const user = requirePermission(event, 'apikeys.manage');
|
|
5
5
|
const id = getRouterParam(event, 'id');
|
|
6
6
|
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
7
7
|
const db = useDB();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { listApiKeys } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
|
-
|
|
4
|
+
requirePermission(event, 'apikeys.manage');
|
|
5
5
|
const query = getQuery(event);
|
|
6
6
|
const includeRevoked = query.includeRevoked === 'true' || query.includeRevoked === '1';
|
|
7
7
|
const db = useDB();
|
|
@@ -14,7 +14,7 @@ import { createApiKeySchema } from '@commonpub/schema';
|
|
|
14
14
|
* land in the metadata column.
|
|
15
15
|
*/
|
|
16
16
|
export default defineEventHandler(async (event) => {
|
|
17
|
-
const user =
|
|
17
|
+
const user = requirePermission(event, 'apikeys.manage');
|
|
18
18
|
const body = await readBody(event);
|
|
19
19
|
const parsed = createApiKeySchema.safeParse(body);
|
|
20
20
|
if (!parsed.success) {
|
|
@@ -9,7 +9,7 @@ const auditQuerySchema = z.object({
|
|
|
9
9
|
|
|
10
10
|
export default defineEventHandler(async (event): Promise<PaginatedResponse<AuditLogItem>> => {
|
|
11
11
|
requireFeature('admin');
|
|
12
|
-
|
|
12
|
+
requirePermission(event, 'audit.read');
|
|
13
13
|
const db = useDB();
|
|
14
14
|
const filters = parseQueryParams(event, auditQuerySchema);
|
|
15
15
|
|
|
@@ -5,7 +5,7 @@ import { deleteContentCategory } from '@commonpub/server';
|
|
|
5
5
|
* Delete a content category (admin only). System categories cannot be deleted.
|
|
6
6
|
*/
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
8
|
-
|
|
8
|
+
requirePermission(event, 'categories.manage');
|
|
9
9
|
const db = useDB();
|
|
10
10
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
11
11
|
|
|
@@ -6,7 +6,7 @@ import { updateContentCategorySchema } from '@commonpub/schema';
|
|
|
6
6
|
* Update a content category (admin only).
|
|
7
7
|
*/
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
|
-
|
|
9
|
+
requirePermission(event, 'categories.manage');
|
|
10
10
|
const db = useDB();
|
|
11
11
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
12
12
|
const body = await parseBody(event, updateContentCategorySchema);
|
|
@@ -5,7 +5,7 @@ import { listContentCategories } from '@commonpub/server';
|
|
|
5
5
|
* List all content categories (admin).
|
|
6
6
|
*/
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
8
|
-
|
|
8
|
+
requirePermission(event, 'categories.manage');
|
|
9
9
|
const db = useDB();
|
|
10
10
|
return listContentCategories(db);
|
|
11
11
|
});
|
|
@@ -6,7 +6,7 @@ import { createContentCategorySchema } from '@commonpub/schema';
|
|
|
6
6
|
* Create a new content category (admin only).
|
|
7
7
|
*/
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
|
-
|
|
9
|
+
requirePermission(event, 'categories.manage');
|
|
10
10
|
const db = useDB();
|
|
11
11
|
const body = await parseBody(event, createContentCategorySchema);
|
|
12
12
|
return createContentCategory(db, body);
|
|
@@ -2,7 +2,7 @@ import { removeContent, removeFederatedContent } from '@commonpub/server';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event): Promise<void> => {
|
|
4
4
|
requireFeature('admin');
|
|
5
|
-
const admin =
|
|
5
|
+
const admin = requirePermission(event, 'content.moderate');
|
|
6
6
|
const db = useDB();
|
|
7
7
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
8
8
|
|
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
* Update admin-managed content fields (featured, editorial, category).
|
|
8
8
|
*/
|
|
9
9
|
export default defineEventHandler(async (event) => {
|
|
10
|
-
|
|
10
|
+
requirePermission(event, 'content.editorial');
|
|
11
11
|
|
|
12
12
|
const { id: contentId } = parseParams(event, { id: 'uuid' });
|
|
13
13
|
const body = await parseBody(event, z.object({
|
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
* Bulk update editorial status on multiple content items (admin only).
|
|
8
8
|
*/
|
|
9
9
|
export default defineEventHandler(async (event) => {
|
|
10
|
-
|
|
10
|
+
requirePermission(event, 'content.editorial');
|
|
11
11
|
|
|
12
12
|
const body = await parseBody(event, z.object({
|
|
13
13
|
ids: z.array(z.string().uuid()).min(1).max(100),
|
|
@@ -6,7 +6,7 @@ import type { FeatureFlags } from '@commonpub/config';
|
|
|
6
6
|
* Returns current feature flags with metadata about defaults vs overrides.
|
|
7
7
|
*/
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
|
-
|
|
9
|
+
requirePermission(event, 'settings.manage');
|
|
10
10
|
|
|
11
11
|
const db = useDB();
|
|
12
12
|
const config = useConfig();
|
|
@@ -15,7 +15,7 @@ const updateFeaturesSchema = z.object({
|
|
|
15
15
|
* To remove an override, omit the key from overrides.
|
|
16
16
|
*/
|
|
17
17
|
export default defineEventHandler(async (event) => {
|
|
18
|
-
const user =
|
|
18
|
+
const user = requirePermission(event, 'settings.manage');
|
|
19
19
|
|
|
20
20
|
const body = await parseBody(event, updateFeaturesSchema);
|
|
21
21
|
const db = useDB();
|
|
@@ -5,7 +5,7 @@ const VALID_STATUSES = ['pending', 'delivered', 'failed', 'processed'] as const;
|
|
|
5
5
|
|
|
6
6
|
export default defineEventHandler(async (event) => {
|
|
7
7
|
requireFeature('admin');
|
|
8
|
-
|
|
8
|
+
requirePermission(event, 'federation.manage');
|
|
9
9
|
const db = useDB();
|
|
10
10
|
|
|
11
11
|
const query = getQuery(event);
|
|
@@ -2,7 +2,7 @@ import { listOAuthClients } from '@commonpub/server';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
requireFeature('federation');
|
|
5
|
-
|
|
5
|
+
requirePermission(event, 'federation.manage');
|
|
6
6
|
const db = useDB();
|
|
7
7
|
|
|
8
8
|
return listOAuthClients(db);
|
|
@@ -8,7 +8,7 @@ const registerSchema = z.object({
|
|
|
8
8
|
|
|
9
9
|
export default defineEventHandler(async (event) => {
|
|
10
10
|
requireFeature('federation');
|
|
11
|
-
|
|
11
|
+
requirePermission(event, 'federation.manage');
|
|
12
12
|
const db = useDB();
|
|
13
13
|
const { instanceDomain, redirectUris } = await parseBody(event, registerSchema);
|
|
14
14
|
|
|
@@ -3,7 +3,7 @@ import { backfillHubFromOutbox, fetchRemoteHubFollowers, repairFederatedHubPostA
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
requireFeature('federation');
|
|
5
5
|
requireFeature('federateHubs');
|
|
6
|
-
|
|
6
|
+
requirePermission(event, 'federation.manage');
|
|
7
7
|
|
|
8
8
|
const db = useDB();
|
|
9
9
|
const config = useConfig();
|
|
@@ -3,7 +3,7 @@ import { listFederatedHubs } from '@commonpub/server';
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
requireFeature('federation');
|
|
5
5
|
requireFeature('federateHubs');
|
|
6
|
-
|
|
6
|
+
requirePermission(event, 'federation.manage');
|
|
7
7
|
|
|
8
8
|
const db = useDB();
|
|
9
9
|
return listFederatedHubs(db);
|
|
@@ -8,7 +8,7 @@ const bodySchema = z.object({
|
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
9
|
requireFeature('federation');
|
|
10
10
|
requireFeature('federateHubs');
|
|
11
|
-
|
|
11
|
+
requirePermission(event, 'federation.manage');
|
|
12
12
|
|
|
13
13
|
const db = useDB();
|
|
14
14
|
const config = useConfig();
|
|
@@ -11,7 +11,7 @@ function extractDomain(url: string): string {
|
|
|
11
11
|
* Admin only.
|
|
12
12
|
*/
|
|
13
13
|
export default defineEventHandler(async (event) => {
|
|
14
|
-
|
|
14
|
+
requirePermission(event, 'federation.manage');
|
|
15
15
|
|
|
16
16
|
const config = useConfig();
|
|
17
17
|
if (!config.features.federation) {
|
|
@@ -2,7 +2,7 @@ import { cancelMirror } from '@commonpub/server';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
requireFeature('federation');
|
|
5
|
-
|
|
5
|
+
requirePermission(event, 'federation.manage');
|
|
6
6
|
const db = useDB();
|
|
7
7
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@ import { getMirror } from '@commonpub/server';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
requireFeature('federation');
|
|
5
|
-
|
|
5
|
+
requirePermission(event, 'federation.manage');
|
|
6
6
|
const db = useDB();
|
|
7
7
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
8
8
|
|
|
@@ -7,7 +7,7 @@ const updateMirrorSchema = z.object({
|
|
|
7
7
|
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
9
|
requireFeature('federation');
|
|
10
|
-
|
|
10
|
+
requirePermission(event, 'federation.manage');
|
|
11
11
|
const db = useDB();
|
|
12
12
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
13
13
|
const { action } = await parseBody(event, updateMirrorSchema);
|
|
@@ -11,7 +11,7 @@ const createMirrorSchema = z.object({
|
|
|
11
11
|
|
|
12
12
|
export default defineEventHandler(async (event) => {
|
|
13
13
|
requireFeature('federation');
|
|
14
|
-
|
|
14
|
+
requirePermission(event, 'federation.manage');
|
|
15
15
|
const db = useDB();
|
|
16
16
|
const input = await parseBody(event, createMirrorSchema);
|
|
17
17
|
|
|
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
|
|
18
18
|
if (cliSecret && cliSecret === runtimeConfig.authSecret) {
|
|
19
19
|
// Authorized via shared secret
|
|
20
20
|
} else {
|
|
21
|
-
|
|
21
|
+
requirePermission(event, 'federation.manage');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const config = useConfig();
|
|
@@ -7,7 +7,7 @@ import { repairFederatedContentTypes } from '@commonpub/server';
|
|
|
7
7
|
*/
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
9
|
requireFeature('admin');
|
|
10
|
-
|
|
10
|
+
requirePermission(event, 'federation.manage');
|
|
11
11
|
const db = useDB();
|
|
12
12
|
|
|
13
13
|
return repairFederatedContentTypes(db);
|
|
@@ -12,7 +12,7 @@ const retrySchema = z.object({
|
|
|
12
12
|
* Optionally filter by activity ID.
|
|
13
13
|
*/
|
|
14
14
|
export default defineEventHandler(async (event) => {
|
|
15
|
-
|
|
15
|
+
requirePermission(event, 'federation.manage');
|
|
16
16
|
|
|
17
17
|
const config = useConfig();
|
|
18
18
|
if (!config.features.federation) {
|
|
@@ -3,7 +3,7 @@ import { activities, followRelationships } from '@commonpub/schema';
|
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
requireFeature('admin');
|
|
6
|
-
|
|
6
|
+
requirePermission(event, 'federation.manage');
|
|
7
7
|
const db = useDB();
|
|
8
8
|
|
|
9
9
|
const [inbound, outbound, pending, failed, followers, following] = await Promise.all([
|
|
@@ -7,7 +7,7 @@ const removeSchema = z.object({
|
|
|
7
7
|
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
9
|
requireFeature('admin');
|
|
10
|
-
|
|
10
|
+
requirePermission(event, 'federation.manage');
|
|
11
11
|
const db = useDB();
|
|
12
12
|
const { domain } = await parseBody(event, removeSchema);
|
|
13
13
|
|
|
@@ -2,7 +2,7 @@ import { getStoredTrustedInstances } from '@commonpub/server';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
requireFeature('admin');
|
|
5
|
-
|
|
5
|
+
requirePermission(event, 'federation.manage');
|
|
6
6
|
const db = useDB();
|
|
7
7
|
const config = useConfig();
|
|
8
8
|
|
|
@@ -7,7 +7,7 @@ const addSchema = z.object({
|
|
|
7
7
|
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
9
|
requireFeature('admin');
|
|
10
|
-
|
|
10
|
+
requirePermission(event, 'federation.manage');
|
|
11
11
|
const db = useDB();
|
|
12
12
|
const { domain } = await parseBody(event, addSchema);
|
|
13
13
|
|