@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
|
@@ -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>
|
|
@@ -63,6 +84,10 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
|
|
|
63
84
|
</div>
|
|
64
85
|
|
|
65
86
|
<div class="cpub-hero-inner">
|
|
87
|
+
<!-- Banner image: a clean band at the top of the hero. Title/tagline sit
|
|
88
|
+
BELOW it (never overlaid) so text stays legible regardless of image. -->
|
|
89
|
+
<img v-if="c?.bannerUrl" :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" class="cpub-hero-banner" />
|
|
90
|
+
|
|
66
91
|
<div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
|
|
67
92
|
<i class="fa-solid fa-ban"></i> This contest has been cancelled.
|
|
68
93
|
</div>
|
|
@@ -72,7 +97,7 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
|
|
|
72
97
|
</div>
|
|
73
98
|
|
|
74
99
|
<div class="cpub-hero-title">{{ c?.title || 'Contest' }}</div>
|
|
75
|
-
<div class="cpub-hero-tagline">{{
|
|
100
|
+
<div class="cpub-hero-tagline">{{ tagline }}</div>
|
|
76
101
|
|
|
77
102
|
<div class="cpub-hero-meta">
|
|
78
103
|
<span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-item">
|
|
@@ -154,11 +179,12 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
|
|
|
154
179
|
.cpub-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(var(--accent-border) 1.5px, transparent 1.5px); background-size: 28px 28px; opacity: .3; }
|
|
155
180
|
.cpub-hero-lines { position: absolute; inset: 0; background-image: linear-gradient(var(--accent-bg) 1px, transparent 1px), linear-gradient(90deg, var(--accent-bg) 1px, transparent 1px); background-size: 56px 56px; }
|
|
156
181
|
.cpub-hero-inner { max-width: 1100px; margin: 0 auto; padding: 0 32px; position: relative; z-index: 1; }
|
|
182
|
+
.cpub-hero-banner { display: block; width: 100%; max-height: 260px; object-fit: cover; margin-bottom: 28px; border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); }
|
|
157
183
|
.cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
|
158
184
|
.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
185
|
.cpub-contest-badge i { font-size: 8px; }
|
|
160
186
|
.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; }
|
|
187
|
+
.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
188
|
.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
189
|
.cpub-hero-meta-item { display: flex; align-items: center; gap: 5px; }
|
|
164
190
|
.cpub-hero-meta-sep { color: var(--hero-border); }
|
|
@@ -1,7 +1,9 @@
|
|
|
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
|
+
/** Optional Markdown intro shown above the prize cards (section-level, not per-prize). */
|
|
6
|
+
description?: string | null;
|
|
5
7
|
}>();
|
|
6
8
|
|
|
7
9
|
function prizeLabel(prize: Prize): string {
|
|
@@ -35,7 +37,8 @@ function prizeIcon(prize: Prize): string {
|
|
|
35
37
|
<div class="cpub-sec-head">
|
|
36
38
|
<h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
|
|
37
39
|
</div>
|
|
38
|
-
<
|
|
40
|
+
<CpubMarkdown v-if="description" :source="description" class="cpub-prizes-intro" />
|
|
41
|
+
<div v-if="prizes.length" class="cpub-prize-grid">
|
|
39
42
|
<div
|
|
40
43
|
v-for="(prize, i) in prizes"
|
|
41
44
|
:key="i"
|
|
@@ -45,7 +48,7 @@ function prizeIcon(prize: Prize): string {
|
|
|
45
48
|
<div class="cpub-prize-rank" :class="`cpub-prize-rank-${prizeColor(prize)}`">{{ prizeLabel(prize) }}</div>
|
|
46
49
|
<div class="cpub-prize-icon" :class="`cpub-prize-icon-${prizeColor(prize)}`"><i class="fa-solid" :class="prizeIcon(prize)"></i></div>
|
|
47
50
|
<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>
|
|
51
|
+
<div v-if="prize.title" class="cpub-prize-title">{{ prize.title }}</div>
|
|
49
52
|
<div v-if="prize.description" class="cpub-prize-desc">{{ prize.description }}</div>
|
|
50
53
|
</div>
|
|
51
54
|
</div>
|
|
@@ -58,6 +61,7 @@ function prizeIcon(prize: Prize): string {
|
|
|
58
61
|
.cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
|
59
62
|
.cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
|
60
63
|
|
|
64
|
+
.cpub-prizes-intro { margin-bottom: 16px; }
|
|
61
65
|
.cpub-prize-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
62
66
|
.cpub-prize-card { border-radius: var(--radius); padding: 20px; text-align: center; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); }
|
|
63
67
|
.cpub-prize-gold { box-shadow: var(--shadow-accent); }
|
|
@@ -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>
|
|
@@ -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.31.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/auth": "0.
|
|
57
|
-
"@commonpub/config": "0.15.0",
|
|
58
|
-
"@commonpub/docs": "0.6.3",
|
|
56
|
+
"@commonpub/auth": "0.7.0",
|
|
59
57
|
"@commonpub/explainer": "0.7.15",
|
|
60
|
-
"@commonpub/
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/server": "2.63.0",
|
|
63
|
-
"@commonpub/ui": "0.9.1",
|
|
58
|
+
"@commonpub/config": "0.16.0",
|
|
59
|
+
"@commonpub/docs": "0.6.3",
|
|
64
60
|
"@commonpub/editor": "0.7.11",
|
|
65
|
-
"@commonpub/protocol": "0.12.0"
|
|
61
|
+
"@commonpub/protocol": "0.12.0",
|
|
62
|
+
"@commonpub/schema": "0.24.0",
|
|
63
|
+
"@commonpub/server": "2.65.0",
|
|
64
|
+
"@commonpub/learning": "0.5.2",
|
|
65
|
+
"@commonpub/ui": "0.9.1"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -13,6 +13,7 @@ useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'} — ${useS
|
|
|
13
13
|
|
|
14
14
|
const saving = ref(false);
|
|
15
15
|
const title = ref('');
|
|
16
|
+
const subheading = ref('');
|
|
16
17
|
const description = ref('');
|
|
17
18
|
const rules = ref('');
|
|
18
19
|
const bannerUrl = ref('');
|
|
@@ -40,6 +41,7 @@ function toggleRole(r: string): void {
|
|
|
40
41
|
else visibleToRoles.value.push(r);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
const prizesDescription = ref('');
|
|
43
45
|
interface Prize { place: number | null; category: string; title: string; description: string; value: string }
|
|
44
46
|
const prizes = ref<Prize[]>([]);
|
|
45
47
|
|
|
@@ -50,6 +52,7 @@ const criteria = ref<Criterion[]>([]);
|
|
|
50
52
|
watch(contest, (c) => {
|
|
51
53
|
if (!c) return;
|
|
52
54
|
title.value = c.title ?? '';
|
|
55
|
+
subheading.value = c.subheading ?? '';
|
|
53
56
|
description.value = c.description ?? '';
|
|
54
57
|
rules.value = c.rules ?? '';
|
|
55
58
|
bannerUrl.value = c.bannerUrl ?? '';
|
|
@@ -62,10 +65,11 @@ watch(contest, (c) => {
|
|
|
62
65
|
maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
|
|
63
66
|
visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
|
|
64
67
|
visibleToRoles.value = [...(c.visibleToRoles ?? [])];
|
|
65
|
-
|
|
68
|
+
prizesDescription.value = c.prizesDescription ?? '';
|
|
69
|
+
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
|
|
66
70
|
place: p.place ?? null,
|
|
67
71
|
category: p.category ?? '',
|
|
68
|
-
title: p.title,
|
|
72
|
+
title: p.title ?? '',
|
|
69
73
|
description: p.description ?? '',
|
|
70
74
|
value: p.value ?? '',
|
|
71
75
|
}));
|
|
@@ -82,10 +86,15 @@ function addPrize(): void {
|
|
|
82
86
|
function removePrize(index: number): void {
|
|
83
87
|
prizes.value.splice(index, 1);
|
|
84
88
|
}
|
|
85
|
-
function prizeLabel(prize: Prize
|
|
89
|
+
function prizeLabel(prize: Prize): string {
|
|
86
90
|
if (prize.category.trim()) return prize.category;
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
if (prize.place && prize.place > 0) {
|
|
92
|
+
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
93
|
+
return `${labels[prize.place - 1] || `${prize.place}th`} Place`;
|
|
94
|
+
}
|
|
95
|
+
// No place + no category: a flexible/description-only prize — don't invent
|
|
96
|
+
// a placement (the old code labelled these "Nth Place" by row index).
|
|
97
|
+
return 'Prize';
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
function addCriterion(): void {
|
|
@@ -111,13 +120,13 @@ async function handleSave(): Promise<void> {
|
|
|
111
120
|
saving.value = true;
|
|
112
121
|
try {
|
|
113
122
|
const prizeData = prizes.value
|
|
114
|
-
.filter((p) => p.title.trim())
|
|
123
|
+
.filter((p) => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
115
124
|
.map((p) => ({
|
|
116
125
|
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
117
126
|
category: p.category.trim() || undefined,
|
|
118
|
-
title: p.title,
|
|
119
|
-
description: p.description || undefined,
|
|
120
|
-
value: p.value || undefined,
|
|
127
|
+
title: p.title.trim() || undefined,
|
|
128
|
+
description: p.description.trim() || undefined,
|
|
129
|
+
value: p.value.trim() || undefined,
|
|
121
130
|
}));
|
|
122
131
|
const criteriaData = criteria.value
|
|
123
132
|
.filter((c) => c.label.trim())
|
|
@@ -131,6 +140,7 @@ async function handleSave(): Promise<void> {
|
|
|
131
140
|
method: 'PUT',
|
|
132
141
|
body: {
|
|
133
142
|
title: title.value,
|
|
143
|
+
subheading: subheading.value || undefined,
|
|
134
144
|
description: description.value || undefined,
|
|
135
145
|
rules: rules.value || undefined,
|
|
136
146
|
bannerUrl: bannerUrl.value || undefined,
|
|
@@ -143,6 +153,7 @@ async function handleSave(): Promise<void> {
|
|
|
143
153
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
144
154
|
visibility: visibility.value,
|
|
145
155
|
visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
|
|
156
|
+
prizesDescription: prizesDescription.value || undefined,
|
|
146
157
|
prizes: prizeData,
|
|
147
158
|
judgingCriteria: criteriaData,
|
|
148
159
|
},
|
|
@@ -156,6 +167,20 @@ async function handleSave(): Promise<void> {
|
|
|
156
167
|
}
|
|
157
168
|
}
|
|
158
169
|
|
|
170
|
+
const deleting = ref(false);
|
|
171
|
+
async function handleDelete(): Promise<void> {
|
|
172
|
+
if (!confirm('Permanently delete this contest? All entries, judges, and reviewers are removed. This cannot be undone.')) return;
|
|
173
|
+
deleting.value = true;
|
|
174
|
+
try {
|
|
175
|
+
await $fetch(`/api/contests/${slug}`, { method: 'DELETE' });
|
|
176
|
+
toast.success('Contest deleted');
|
|
177
|
+
await navigateTo('/contests');
|
|
178
|
+
} catch (err: unknown) {
|
|
179
|
+
toast.error(extractError(err));
|
|
180
|
+
deleting.value = false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
159
184
|
async function transitionStatus(newStatus: string): Promise<void> {
|
|
160
185
|
const msg = newStatus === 'cancelled'
|
|
161
186
|
? 'Cancel this contest? This cannot be undone.'
|
|
@@ -190,17 +215,23 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
190
215
|
<label class="cpub-form-label">Title</label>
|
|
191
216
|
<input v-model="title" type="text" class="cpub-form-input" />
|
|
192
217
|
</div>
|
|
218
|
+
<div class="cpub-form-field">
|
|
219
|
+
<label class="cpub-form-label">Subheading</label>
|
|
220
|
+
<input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
221
|
+
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
222
|
+
</div>
|
|
193
223
|
<div class="cpub-form-field">
|
|
194
224
|
<label class="cpub-form-label">Description</label>
|
|
195
|
-
<textarea v-model="description" class="cpub-form-textarea" rows="
|
|
225
|
+
<textarea v-model="description" class="cpub-form-textarea" rows="4" />
|
|
226
|
+
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
196
227
|
</div>
|
|
197
228
|
<div class="cpub-form-field">
|
|
198
229
|
<label class="cpub-form-label">Rules</label>
|
|
199
|
-
<textarea v-model="rules" class="cpub-form-textarea" rows="
|
|
230
|
+
<textarea v-model="rules" class="cpub-form-textarea" rows="6" placeholder="One rule per line, or full Markdown" />
|
|
231
|
+
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
200
232
|
</div>
|
|
201
233
|
<div class="cpub-form-field">
|
|
202
|
-
<
|
|
203
|
-
<input v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
|
|
234
|
+
<ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
|
|
204
235
|
</div>
|
|
205
236
|
</section>
|
|
206
237
|
|
|
@@ -243,9 +274,15 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
243
274
|
|
|
244
275
|
<section class="cpub-form-section">
|
|
245
276
|
<h2 class="cpub-form-section-title">Prizes</h2>
|
|
277
|
+
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong> — whatever fits. Cash value is optional.</p>
|
|
278
|
+
<div class="cpub-form-field">
|
|
279
|
+
<label class="cpub-form-label">Prizes overview (optional)</label>
|
|
280
|
+
<textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
281
|
+
<p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
|
|
282
|
+
</div>
|
|
246
283
|
<div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
|
|
247
284
|
<div class="cpub-prize-header">
|
|
248
|
-
<span class="cpub-prize-label">{{ prizeLabel(prize
|
|
285
|
+
<span class="cpub-prize-label">{{ prizeLabel(prize) }}</span>
|
|
249
286
|
<button type="button" class="cpub-prize-remove" aria-label="Remove prize" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
|
|
250
287
|
</div>
|
|
251
288
|
<div class="cpub-form-row">
|
|
@@ -336,6 +373,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
336
373
|
<div class="cpub-subhead">
|
|
337
374
|
<h3 class="cpub-form-subtitle">Reviewers</h3>
|
|
338
375
|
</div>
|
|
376
|
+
<p class="cpub-form-hint">Reviewers can view this contest (even while it's private or in draft) without being a judge or an admin — view access scoped to this contest only. They can't edit or score entries.</p>
|
|
339
377
|
<ContestStakeholderManager :contest-slug="slug" />
|
|
340
378
|
</section>
|
|
341
379
|
|
|
@@ -348,6 +386,14 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
348
386
|
|
|
349
387
|
<section class="cpub-form-section">
|
|
350
388
|
<h2 class="cpub-form-section-title">Status Transitions</h2>
|
|
389
|
+
<p class="cpub-form-hint">
|
|
390
|
+
A contest moves through a lifecycle:
|
|
391
|
+
<strong>Upcoming</strong> → <strong>Active</strong> (accepting entries) →
|
|
392
|
+
<strong>Judging</strong> (entries closed, judges scoring) →
|
|
393
|
+
<strong>Completed</strong> (results & rankings published). You can cancel at any
|
|
394
|
+
point before it completes. Current status:
|
|
395
|
+
<span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
|
|
396
|
+
</p>
|
|
351
397
|
<div class="cpub-status-actions">
|
|
352
398
|
<button v-if="contest.status === 'upcoming'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-activate" @click="transitionStatus('active')">
|
|
353
399
|
<i class="fa-solid fa-play"></i> Start Contest
|
|
@@ -356,7 +402,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
356
402
|
<i class="fa-solid fa-gavel"></i> Begin Judging
|
|
357
403
|
</button>
|
|
358
404
|
<button v-if="contest.status === 'judging'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-complete" @click="transitionStatus('completed')">
|
|
359
|
-
<i class="fa-solid fa-flag-checkered"></i> Complete
|
|
405
|
+
<i class="fa-solid fa-flag-checkered"></i> Complete & Publish Results
|
|
360
406
|
</button>
|
|
361
407
|
<button
|
|
362
408
|
v-if="contest.status !== 'completed' && contest.status !== 'cancelled'"
|
|
@@ -366,12 +412,29 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
366
412
|
>
|
|
367
413
|
<i class="fa-solid fa-ban"></i> Cancel Contest
|
|
368
414
|
</button>
|
|
415
|
+
<p v-if="contest.status === 'completed' || contest.status === 'cancelled'" class="cpub-status-terminal">
|
|
416
|
+
<i class="fa-solid fa-circle-check"></i>
|
|
417
|
+
This contest is {{ contest.status }} — no further status changes are available.
|
|
418
|
+
</p>
|
|
369
419
|
</div>
|
|
370
420
|
</section>
|
|
371
421
|
|
|
372
422
|
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
373
423
|
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
|
|
374
424
|
</button>
|
|
425
|
+
|
|
426
|
+
<section class="cpub-form-section cpub-danger-zone">
|
|
427
|
+
<h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
|
|
428
|
+
<div class="cpub-danger-row">
|
|
429
|
+
<div>
|
|
430
|
+
<p class="cpub-danger-label">Delete this contest</p>
|
|
431
|
+
<p class="cpub-form-hint">Permanently removes the contest and all of its entries, judges, and reviewers. This cannot be undone.</p>
|
|
432
|
+
</div>
|
|
433
|
+
<button type="button" class="cpub-btn cpub-btn-danger cpub-danger-btn" :disabled="deleting" @click="handleDelete">
|
|
434
|
+
<i class="fa-solid fa-trash"></i> {{ deleting ? 'Deleting...' : 'Delete Contest' }}
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
</section>
|
|
375
438
|
</form>
|
|
376
439
|
</div>
|
|
377
440
|
<div v-else class="cpub-not-found"><p>Contest not found</p></div>
|
|
@@ -426,6 +489,16 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
426
489
|
.cpub-transition-complete { color: var(--accent); border-color: var(--accent-border); }
|
|
427
490
|
.cpub-transition-cancel { color: var(--red); border-color: var(--red-border); }
|
|
428
491
|
|
|
492
|
+
.cpub-status-terminal { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 8px; margin: 0; }
|
|
493
|
+
.cpub-status-terminal i { color: var(--green); }
|
|
494
|
+
|
|
495
|
+
.cpub-danger-zone { border-color: var(--red-border); }
|
|
496
|
+
.cpub-danger-title { color: var(--red); }
|
|
497
|
+
.cpub-danger-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
|
498
|
+
.cpub-danger-label { font-size: 13px; font-weight: 600; margin: 0 0 2px; }
|
|
499
|
+
.cpub-danger-btn { color: var(--red); border-color: var(--red-border); flex-shrink: 0; }
|
|
500
|
+
.cpub-danger-btn:hover:not(:disabled) { background: var(--red-bg); }
|
|
501
|
+
|
|
429
502
|
.cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
430
503
|
|
|
431
504
|
@media (max-width: 768px) {
|