@commonpub/layer 0.28.1 → 0.30.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 +23 -2
- package/components/contest/ContestPrizes.vue +2 -2
- package/components/contest/ContestRules.vue +9 -9
- package/components/contest/ContestStakeholderManager.vue +126 -0
- package/composables/useFeatures.ts +8 -0
- package/nuxt.config.ts +1 -0
- package/package.json +8 -8
- package/pages/contests/[slug]/edit.vue +119 -15
- package/pages/contests/[slug]/index.vue +61 -1
- package/pages/contests/[slug]/results.vue +20 -5
- package/pages/contests/create.vue +60 -13
- 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 +8 -2
- package/server/api/contests/[slug]/entries.post.ts +5 -1
- package/server/api/contests/[slug]/index.delete.ts +4 -1
- package/server/api/contests/[slug]/index.get.ts +7 -1
- package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
- package/server/api/contests/[slug]/judges/index.get.ts +4 -1
- package/server/api/contests/[slug]/judges/index.post.ts +1 -1
- package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +24 -0
- package/server/api/contests/[slug]/stakeholders/index.get.ts +21 -0
- package/server/api/contests/[slug]/stakeholders/index.post.ts +33 -0
- package/server/api/contests/[slug]/votes.get.ts +4 -1
- package/server/api/contests/index.get.ts +4 -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
|
@@ -294,6 +294,12 @@ function formatCount(n: number | undefined): string {
|
|
|
294
294
|
min-width: 0;
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/* Two render modes share .cpub-cc-av: <img class="cpub-cc-av cpub-cc-av--img">
|
|
298
|
+
* (author photo) and <span class="cpub-cc-av"> (initials fallback). `display:flex`
|
|
299
|
+
* MUST NOT apply to the <img> — a replaced element with display:flex silently
|
|
300
|
+
* drops `object-fit:cover` in Chromium, squishing portrait avatars into the box
|
|
301
|
+
* (the recurring deveco.io blog-card bug). Centering is scoped to the span; the
|
|
302
|
+
* img keeps object-fit only. See ArticleView.vue for the same fix. */
|
|
297
303
|
.cpub-cc-av {
|
|
298
304
|
width: 18px;
|
|
299
305
|
height: 18px;
|
|
@@ -303,14 +309,18 @@ function formatCount(n: number | undefined): string {
|
|
|
303
309
|
font-size: 8px;
|
|
304
310
|
font-weight: 700;
|
|
305
311
|
font-family: var(--font-mono);
|
|
312
|
+
flex-shrink: 0;
|
|
313
|
+
border-radius: 50%;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
span.cpub-cc-av {
|
|
306
317
|
display: flex;
|
|
307
318
|
align-items: center;
|
|
308
319
|
justify-content: center;
|
|
309
|
-
flex-shrink: 0;
|
|
310
|
-
border-radius: 50%;
|
|
311
320
|
}
|
|
312
321
|
|
|
313
|
-
.cpub-cc-av--img
|
|
322
|
+
.cpub-cc-av--img,
|
|
323
|
+
img.cpub-cc-av {
|
|
314
324
|
object-fit: cover;
|
|
315
325
|
}
|
|
316
326
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Renders a Markdown (or HTML-in-Markdown) string as formatted content, reusing
|
|
4
|
+
* the SAME pipeline articles/docs use: `markdownToBlockTuples` → the block
|
|
5
|
+
* content renderer. This is why a contest description like `## Mission` +
|
|
6
|
+
* `- **point**` + `[link](url)` renders as real headings/lists/links instead of
|
|
7
|
+
* the raw-markdown wall it used to show.
|
|
8
|
+
*
|
|
9
|
+
* Source is parsed once (computed) and memoised by Vue. Empty / parse-failure
|
|
10
|
+
* falls back to plain text so content never disappears.
|
|
11
|
+
*/
|
|
12
|
+
import { markdownToBlockTuples } from '@commonpub/editor';
|
|
13
|
+
import type { BlockTuple } from '@commonpub/editor';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
/** Markdown source (may contain inline/block HTML — passed through). */
|
|
17
|
+
source?: string | null;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const trimmed = computed(() => (props.source ?? '').trim());
|
|
21
|
+
|
|
22
|
+
const blocks = computed<BlockTuple[]>(() => {
|
|
23
|
+
if (!trimmed.value) return [];
|
|
24
|
+
try {
|
|
25
|
+
return markdownToBlockTuples(trimmed.value);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<BlocksBlockContentRenderer
|
|
34
|
+
v-if="blocks.length"
|
|
35
|
+
:blocks="(blocks as [string, Record<string, unknown>][])"
|
|
36
|
+
class="cpub-prose cpub-md"
|
|
37
|
+
/>
|
|
38
|
+
<p v-else-if="trimmed" class="cpub-md cpub-md-plain">{{ trimmed }}</p>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<style scoped>
|
|
42
|
+
.cpub-md-plain {
|
|
43
|
+
white-space: pre-wrap;
|
|
44
|
+
line-height: 1.7;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
defineProps<{
|
|
2
|
+
const props = defineProps<{
|
|
3
3
|
notification: {
|
|
4
4
|
id: string;
|
|
5
5
|
type: string;
|
|
@@ -13,6 +13,18 @@ defineProps<{
|
|
|
13
13
|
};
|
|
14
14
|
}>();
|
|
15
15
|
|
|
16
|
+
const emit = defineEmits<{ read: [id: string] }>();
|
|
17
|
+
|
|
18
|
+
// The whole row is the click target when there's somewhere to go (previously
|
|
19
|
+
// only the tiny right-hand arrow navigated). When there's a destination the
|
|
20
|
+
// root renders as a NuxtLink so keyboard / middle-click / open-in-new-tab all
|
|
21
|
+
// work; otherwise it stays a plain div.
|
|
22
|
+
const destination = computed(() => props.notification.link || props.notification.targetUrl || null);
|
|
23
|
+
|
|
24
|
+
function onActivate(): void {
|
|
25
|
+
if (!props.notification.read) emit('read', props.notification.id);
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
const iconMap: Record<string, string> = {
|
|
17
29
|
like: 'fa-solid fa-heart',
|
|
18
30
|
comment: 'fa-solid fa-comment',
|
|
@@ -28,7 +40,14 @@ const iconMap: Record<string, string> = {
|
|
|
28
40
|
</script>
|
|
29
41
|
|
|
30
42
|
<template>
|
|
31
|
-
<
|
|
43
|
+
<component
|
|
44
|
+
:is="destination ? 'NuxtLink' : 'div'"
|
|
45
|
+
:to="destination || undefined"
|
|
46
|
+
class="cpub-notif"
|
|
47
|
+
:class="{ 'cpub-notif-unread': !notification.read, 'cpub-notif-link-row': destination }"
|
|
48
|
+
:aria-label="destination ? `${notification.actorName ? notification.actorName + ' ' : ''}${notification.message}` : undefined"
|
|
49
|
+
@click="onActivate"
|
|
50
|
+
>
|
|
32
51
|
<div class="cpub-notif-avatar-wrap">
|
|
33
52
|
<img v-if="notification.actorAvatarUrl" :src="notification.actorAvatarUrl" :alt="notification.actorName ?? ''" class="cpub-notif-avatar" />
|
|
34
53
|
<div v-else class="cpub-notif-avatar cpub-notif-avatar-fallback">
|
|
@@ -47,10 +66,8 @@ const iconMap: Record<string, string> = {
|
|
|
47
66
|
{{ new Date(notification.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
|
|
48
67
|
</time>
|
|
49
68
|
</div>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
</NuxtLink>
|
|
53
|
-
</div>
|
|
69
|
+
<i v-if="destination" class="fa-solid fa-chevron-right cpub-notif-chevron" aria-hidden="true"></i>
|
|
70
|
+
</component>
|
|
54
71
|
</template>
|
|
55
72
|
|
|
56
73
|
<style scoped>
|
|
@@ -61,6 +78,18 @@ const iconMap: Record<string, string> = {
|
|
|
61
78
|
padding: 12px;
|
|
62
79
|
border: var(--border-width-default) solid transparent;
|
|
63
80
|
border-bottom: var(--border-width-default) solid var(--border2);
|
|
81
|
+
color: inherit;
|
|
82
|
+
text-decoration: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Whole-row link affordance: the entire item is the click target. */
|
|
86
|
+
.cpub-notif-link-row {
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
transition: background 0.12s ease;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.cpub-notif-link-row:hover {
|
|
92
|
+
background: var(--surface2);
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
.cpub-notif.cpub-notif-unread {
|
|
@@ -68,6 +97,10 @@ const iconMap: Record<string, string> = {
|
|
|
68
97
|
border-color: var(--accent-border);
|
|
69
98
|
}
|
|
70
99
|
|
|
100
|
+
.cpub-notif-link-row.cpub-notif-unread:hover {
|
|
101
|
+
background: var(--accent-bg-hover, var(--accent-bg));
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
.cpub-notif-avatar-wrap {
|
|
72
105
|
position: relative;
|
|
73
106
|
width: 32px;
|
|
@@ -126,18 +159,16 @@ const iconMap: Record<string, string> = {
|
|
|
126
159
|
font-family: var(--font-mono);
|
|
127
160
|
}
|
|
128
161
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
justify-content: center;
|
|
162
|
+
/* Decorative chevron — signals the whole row navigates (the row itself is the
|
|
163
|
+
link now; this is aria-hidden, not a separate tab stop). */
|
|
164
|
+
.cpub-notif-chevron {
|
|
165
|
+
align-self: center;
|
|
166
|
+
flex-shrink: 0;
|
|
135
167
|
color: var(--text-faint);
|
|
136
|
-
text-decoration: none;
|
|
137
168
|
font-size: 10px;
|
|
138
169
|
}
|
|
139
170
|
|
|
140
|
-
.cpub-notif-link:hover {
|
|
171
|
+
.cpub-notif-link-row:hover .cpub-notif-chevron {
|
|
141
172
|
color: var(--accent);
|
|
142
173
|
}
|
|
143
174
|
</style>
|
|
@@ -149,10 +149,13 @@ function confirmWithdraw(entryId: string): void {
|
|
|
149
149
|
.cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
|
150
150
|
.cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
/* Match the content-card grid: responsive auto-fill columns + a 4:3 cover
|
|
153
|
+
(was a rigid 2-col grid with a squat fixed 110px strip that over-cropped
|
|
154
|
+
the cover photo). */
|
|
155
|
+
.cpub-entry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-bottom: 12px; }
|
|
156
|
+
.cpub-entry-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-md); display: flex; flex-direction: column; }
|
|
154
157
|
.cpub-entry-card:hover { box-shadow: var(--shadow-accent); }
|
|
155
|
-
.cpub-entry-thumb {
|
|
158
|
+
.cpub-entry-thumb { aspect-ratio: 4 / 3; width: 100%; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
|
|
156
159
|
.cpub-entry-bg-light { background: var(--surface2); }
|
|
157
160
|
.cpub-entry-bg-dark { background: var(--surface3); }
|
|
158
161
|
.cpub-entry-grid-pat { position: absolute; inset: 0; background-image: linear-gradient(var(--border2) 1px, transparent 1px), linear-gradient(90deg, var(--border2) 1px, transparent 1px); background-size: 20px 20px; opacity: .3; }
|
|
@@ -53,6 +53,27 @@ const countdownLabel = computed(() => {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
|
|
56
|
+
|
|
57
|
+
// The hero shows the short `subheading` (a dedicated tagline field). For older
|
|
58
|
+
// contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
|
|
59
|
+
// the (possibly long Markdown) description — so the hero never dumps a raw
|
|
60
|
+
// `## ...` wall. The full formatted description renders in the About tab.
|
|
61
|
+
const tagline = computed<string>(() => {
|
|
62
|
+
const sub = (c.value?.subheading ?? '').trim();
|
|
63
|
+
if (sub) return sub;
|
|
64
|
+
const d = (c.value?.description ?? '').trim();
|
|
65
|
+
if (!d) return 'No description available.';
|
|
66
|
+
return d
|
|
67
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
68
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
69
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
70
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
71
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
72
|
+
.replace(/^\s*[-*+>]\s+/gm, '')
|
|
73
|
+
.replace(/(\*\*|__|~~|\*|_)/g, '')
|
|
74
|
+
.replace(/\s+/g, ' ')
|
|
75
|
+
.trim();
|
|
76
|
+
});
|
|
56
77
|
</script>
|
|
57
78
|
|
|
58
79
|
<template>
|
|
@@ -72,7 +93,7 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
|
|
|
72
93
|
</div>
|
|
73
94
|
|
|
74
95
|
<div class="cpub-hero-title">{{ c?.title || 'Contest' }}</div>
|
|
75
|
-
<div class="cpub-hero-tagline">{{
|
|
96
|
+
<div class="cpub-hero-tagline">{{ tagline }}</div>
|
|
76
97
|
|
|
77
98
|
<div class="cpub-hero-meta">
|
|
78
99
|
<span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-item">
|
|
@@ -158,7 +179,7 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
|
|
|
158
179
|
.cpub-contest-badge { font-size: 9px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; font-family: var(--font-mono); color: var(--accent); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent); padding: 3px 10px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 5px; }
|
|
159
180
|
.cpub-contest-badge i { font-size: 8px; }
|
|
160
181
|
.cpub-hero-title { font-size: 36px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin-bottom: 10px; color: var(--hero-text); }
|
|
161
|
-
.cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 580px; margin-bottom: 28px; }
|
|
182
|
+
.cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 580px; margin-bottom: 28px; display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
162
183
|
.cpub-hero-meta { display: flex; align-items: center; gap: 20px; font-size: 11px; color: var(--hero-text-dim); font-family: var(--font-mono); margin-bottom: 28px; }
|
|
163
184
|
.cpub-hero-meta-item { display: flex; align-items: center; gap: 5px; }
|
|
164
185
|
.cpub-hero-meta-sep { color: var(--hero-border); }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
interface Prize { place?: number; category?: string; title
|
|
2
|
+
interface Prize { place?: number; category?: string; title?: string; description?: string; value?: string }
|
|
3
3
|
defineProps<{
|
|
4
4
|
prizes: Prize[];
|
|
5
5
|
}>();
|
|
@@ -45,7 +45,7 @@ function prizeIcon(prize: Prize): string {
|
|
|
45
45
|
<div class="cpub-prize-rank" :class="`cpub-prize-rank-${prizeColor(prize)}`">{{ prizeLabel(prize) }}</div>
|
|
46
46
|
<div class="cpub-prize-icon" :class="`cpub-prize-icon-${prizeColor(prize)}`"><i class="fa-solid" :class="prizeIcon(prize)"></i></div>
|
|
47
47
|
<div v-if="prize.value" class="cpub-prize-amount" :class="`cpub-prize-amount-${prizeColor(prize)}`">{{ prize.value }}</div>
|
|
48
|
-
<div class="cpub-prize-title">{{ prize.title }}</div>
|
|
48
|
+
<div v-if="prize.title" class="cpub-prize-title">{{ prize.title }}</div>
|
|
49
49
|
<div v-if="prize.description" class="cpub-prize-desc">{{ prize.description }}</div>
|
|
50
50
|
</div>
|
|
51
51
|
</div>
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Rules are authored as Markdown (and may contain inline HTML) — rendered the
|
|
4
|
+
* same way as the contest description, so headings/lists/links/HTML all format
|
|
5
|
+
* properly. Plain prose and plain one-rule-per-line text render fine through the
|
|
6
|
+
* Markdown pipeline too (as paragraphs / a tight list), so there's no separate
|
|
7
|
+
* "numbered list" special-case anymore (that produced the odd forced-list look).
|
|
8
|
+
*/
|
|
9
|
+
defineProps<{
|
|
3
10
|
rules: string;
|
|
4
11
|
}>();
|
|
5
|
-
|
|
6
|
-
const ruleLines = computed(() =>
|
|
7
|
-
props.rules.split('\n').filter((line) => line.trim().length > 0),
|
|
8
|
-
);
|
|
9
12
|
</script>
|
|
10
13
|
|
|
11
14
|
<template>
|
|
@@ -14,10 +17,7 @@ const ruleLines = computed(() =>
|
|
|
14
17
|
<h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
|
|
15
18
|
</div>
|
|
16
19
|
<div class="cpub-rules-card">
|
|
17
|
-
<
|
|
18
|
-
<li v-for="(line, i) in ruleLines" :key="i" class="cpub-rule-item">{{ line }}</li>
|
|
19
|
-
</ol>
|
|
20
|
-
<div v-else class="cpub-rules-text">{{ rules }}</div>
|
|
20
|
+
<CpubMarkdown :source="rules" />
|
|
21
21
|
</div>
|
|
22
22
|
</div>
|
|
23
23
|
</template>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ContestStakeholderItem } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ contestSlug: string }>();
|
|
5
|
+
|
|
6
|
+
const toast = useToast();
|
|
7
|
+
const { data: stakeholders, refresh } = useLazyFetch<ContestStakeholderItem[]>(
|
|
8
|
+
`/api/contests/${props.contestSlug}/stakeholders`,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const searchQuery = ref('');
|
|
12
|
+
const searchResults = ref<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>([]);
|
|
13
|
+
const searching = ref(false);
|
|
14
|
+
const adding = ref(false);
|
|
15
|
+
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
16
|
+
|
|
17
|
+
function handleSearch(): void {
|
|
18
|
+
if (searchTimeout) clearTimeout(searchTimeout);
|
|
19
|
+
if (!searchQuery.value || searchQuery.value.length < 2) { searchResults.value = []; return; }
|
|
20
|
+
searchTimeout = setTimeout(async () => {
|
|
21
|
+
searching.value = true;
|
|
22
|
+
try {
|
|
23
|
+
const data = await ($fetch as Function)('/api/admin/users', { query: { search: searchQuery.value, limit: 8 } }) as { items: Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }> };
|
|
24
|
+
const existing = new Set((stakeholders.value ?? []).map((s) => s.userId));
|
|
25
|
+
searchResults.value = data.items.filter((u) => !existing.has(u.id));
|
|
26
|
+
} catch {
|
|
27
|
+
searchResults.value = [];
|
|
28
|
+
} finally {
|
|
29
|
+
searching.value = false;
|
|
30
|
+
}
|
|
31
|
+
}, 300);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function addStakeholder(userId: string): Promise<void> {
|
|
35
|
+
adding.value = true;
|
|
36
|
+
try {
|
|
37
|
+
await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId } });
|
|
38
|
+
toast.success('Reviewer added');
|
|
39
|
+
searchQuery.value = '';
|
|
40
|
+
searchResults.value = [];
|
|
41
|
+
await refresh();
|
|
42
|
+
} catch {
|
|
43
|
+
toast.error('Failed to add reviewer');
|
|
44
|
+
} finally {
|
|
45
|
+
adding.value = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function removeStakeholder(userId: string): Promise<void> {
|
|
50
|
+
if (!confirm('Remove this reviewer’s access?')) return;
|
|
51
|
+
try {
|
|
52
|
+
await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders/${userId}`, { method: 'DELETE' });
|
|
53
|
+
toast.success('Reviewer removed');
|
|
54
|
+
await refresh();
|
|
55
|
+
} catch {
|
|
56
|
+
toast.error('Failed to remove reviewer');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<div class="cpub-sh">
|
|
63
|
+
<p class="cpub-sh-hint">Reviewers can view this contest (even while private/unpublished) but can't edit it or judge.</p>
|
|
64
|
+
<div v-if="stakeholders?.length" class="cpub-sh-list">
|
|
65
|
+
<div v-for="s in stakeholders" :key="s.id" class="cpub-sh-row">
|
|
66
|
+
<NuxtLink :to="`/u/${s.userUsername}`" class="cpub-sh-link">
|
|
67
|
+
<span class="cpub-sh-av">
|
|
68
|
+
<img v-if="s.userAvatar" :src="s.userAvatar" :alt="s.userName" />
|
|
69
|
+
<span v-else>{{ s.userName.charAt(0) }}</span>
|
|
70
|
+
</span>
|
|
71
|
+
<span class="cpub-sh-name">{{ s.userName }}</span>
|
|
72
|
+
</NuxtLink>
|
|
73
|
+
<button class="cpub-sh-remove" :aria-label="`Remove ${s.userName}`" @click="removeStakeholder(s.userId)">
|
|
74
|
+
<i class="fa-solid fa-xmark"></i>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<p v-else class="cpub-sh-empty">No reviewers yet.</p>
|
|
79
|
+
|
|
80
|
+
<div class="cpub-sh-search">
|
|
81
|
+
<input
|
|
82
|
+
v-model="searchQuery"
|
|
83
|
+
class="cpub-sh-input"
|
|
84
|
+
placeholder="Search users by name or username..."
|
|
85
|
+
aria-label="Search users to add as reviewers"
|
|
86
|
+
@input="handleSearch"
|
|
87
|
+
/>
|
|
88
|
+
<div v-if="searchResults.length" class="cpub-sh-dropdown">
|
|
89
|
+
<button v-for="u in searchResults" :key="u.id" class="cpub-sh-option" :disabled="adding" @click="addStakeholder(u.id)">
|
|
90
|
+
<span class="cpub-sh-av cpub-sh-av-sm">
|
|
91
|
+
<img v-if="u.avatarUrl" :src="u.avatarUrl" :alt="u.displayName || u.username" />
|
|
92
|
+
<span v-else>{{ (u.displayName || u.username).charAt(0) }}</span>
|
|
93
|
+
</span>
|
|
94
|
+
<span class="cpub-sh-opt-name">{{ u.displayName || u.username }}</span>
|
|
95
|
+
<span class="cpub-sh-opt-handle">@{{ u.username }}</span>
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
<div v-else-if="searching" class="cpub-sh-dropdown"><span class="cpub-sh-dropdown-empty">Searching...</span></div>
|
|
99
|
+
<div v-else-if="searchQuery.length >= 2" class="cpub-sh-dropdown"><span class="cpub-sh-dropdown-empty">No users found</span></div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</template>
|
|
103
|
+
|
|
104
|
+
<style scoped>
|
|
105
|
+
.cpub-sh-hint { font-size: 11px; color: var(--text-faint); margin: 0 0 12px; line-height: 1.5; }
|
|
106
|
+
.cpub-sh-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
|
107
|
+
.cpub-sh-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
|
|
108
|
+
.cpub-sh-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); flex: 1; min-width: 0; }
|
|
109
|
+
.cpub-sh-av { width: 24px; height: 24px; border-radius: 50%; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; overflow: hidden; flex-shrink: 0; }
|
|
110
|
+
.cpub-sh-av img { width: 100%; height: 100%; object-fit: cover; }
|
|
111
|
+
.cpub-sh-av-sm { width: 20px; height: 20px; font-size: 8px; }
|
|
112
|
+
.cpub-sh-name { font-size: 12px; font-weight: 600; }
|
|
113
|
+
.cpub-sh-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 6px; min-height: 28px; }
|
|
114
|
+
.cpub-sh-remove:hover { color: var(--red); }
|
|
115
|
+
.cpub-sh-empty { font-size: 12px; color: var(--text-faint); font-style: italic; margin-bottom: 12px; }
|
|
116
|
+
.cpub-sh-search { position: relative; }
|
|
117
|
+
.cpub-sh-input { font-size: 12px; padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; width: 100%; }
|
|
118
|
+
.cpub-sh-input:focus { border-color: var(--accent); }
|
|
119
|
+
.cpub-sh-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-top: 2px; max-height: 200px; overflow-y: auto; }
|
|
120
|
+
.cpub-sh-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: none; border: none; width: 100%; text-align: left; cursor: pointer; }
|
|
121
|
+
.cpub-sh-option:hover { background: var(--surface2); }
|
|
122
|
+
.cpub-sh-option:disabled { opacity: 0.5; cursor: default; }
|
|
123
|
+
.cpub-sh-opt-name { font-size: 12px; font-weight: 600; color: var(--text); }
|
|
124
|
+
.cpub-sh-opt-handle { font-size: 11px; color: var(--text-faint); margin-left: auto; }
|
|
125
|
+
.cpub-sh-dropdown-empty { display: block; padding: 8px 12px; font-size: 11px; color: var(--text-faint); }
|
|
126
|
+
</style>
|
|
@@ -34,6 +34,12 @@ export interface FeatureFlags {
|
|
|
34
34
|
* a default layout exists at scope ('route', '/'). Added session 158.
|
|
35
35
|
*/
|
|
36
36
|
layoutEngine: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Global RBAC (session 175). Client-advisory only — drives `useCan`'s
|
|
39
|
+
* button-hiding; the server resolver is the enforcement boundary. Default
|
|
40
|
+
* OFF. See docs/plans/rbac.md.
|
|
41
|
+
*/
|
|
42
|
+
rbac: boolean;
|
|
37
43
|
/**
|
|
38
44
|
* Cross-instance delegated authorization. All sub-flags default false.
|
|
39
45
|
* Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
|
|
@@ -55,6 +61,7 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|
|
55
61
|
editorial: true, federation: false, admin: false, emailNotifications: false,
|
|
56
62
|
publicApi: false, contentImport: true,
|
|
57
63
|
layoutEngine: false,
|
|
64
|
+
rbac: false,
|
|
58
65
|
identity: {
|
|
59
66
|
linkRemoteAccounts: false,
|
|
60
67
|
signInWithRemote: false,
|
|
@@ -157,6 +164,7 @@ export function useFeatures() {
|
|
|
157
164
|
publicApi: computed(() => flags.value.publicApi),
|
|
158
165
|
contentImport: computed(() => flags.value.contentImport),
|
|
159
166
|
layoutEngine: computed(() => flags.value.layoutEngine),
|
|
167
|
+
rbac: computed(() => flags.value.rbac),
|
|
160
168
|
identity: computed(() => flags.value.identity),
|
|
161
169
|
};
|
|
162
170
|
}
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/
|
|
56
|
+
"@commonpub/auth": "0.7.0",
|
|
57
57
|
"@commonpub/docs": "0.6.3",
|
|
58
|
-
"@commonpub/
|
|
59
|
-
"@commonpub/protocol": "0.12.0",
|
|
58
|
+
"@commonpub/config": "0.16.0",
|
|
60
59
|
"@commonpub/learning": "0.5.2",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/ui": "0.9.1",
|
|
60
|
+
"@commonpub/explainer": "0.7.15",
|
|
63
61
|
"@commonpub/editor": "0.7.11",
|
|
64
|
-
"@commonpub/
|
|
65
|
-
"@commonpub/
|
|
62
|
+
"@commonpub/schema": "0.23.0",
|
|
63
|
+
"@commonpub/ui": "0.9.1",
|
|
64
|
+
"@commonpub/protocol": "0.12.0",
|
|
65
|
+
"@commonpub/server": "2.64.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|