@commonpub/layer 0.8.3 → 0.8.5
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 +1 -1
- package/components/ImageUpload.vue +1 -1
- package/components/ShareToHubModal.vue +1 -1
- package/components/blocks/BlockCodeView.vue +26 -25
- package/components/contest/ContestEntries.vue +112 -0
- package/components/contest/ContestHero.vue +204 -0
- package/components/contest/ContestJudges.vue +51 -0
- package/components/contest/ContestPrizes.vue +82 -0
- package/components/contest/ContestRules.vue +34 -0
- package/components/contest/ContestSidebar.vue +83 -0
- package/components/editors/BlogEditor.vue +1 -1
- package/components/editors/DocsPageTree.vue +10 -0
- package/components/hub/HubHero.vue +1 -1
- package/composables/useSanitize.ts +112 -9
- package/composables/useTheme.ts +8 -0
- package/layouts/default.vue +7 -7
- package/middleware/feature-gate.global.ts +24 -0
- package/package.json +6 -6
- package/pages/[type]/index.vue +4 -3
- package/pages/admin/audit.vue +3 -2
- package/pages/admin/federation.vue +33 -13
- package/pages/admin/index.vue +7 -1
- package/pages/admin/reports.vue +152 -36
- package/pages/admin/settings.vue +17 -5
- package/pages/admin/theme.vue +5 -3
- package/pages/auth/forgot-password.vue +35 -35
- package/pages/auth/login.vue +6 -5
- package/pages/auth/reset-password.vue +44 -32
- package/pages/contests/[slug]/edit.vue +238 -56
- package/pages/contests/[slug]/index.vue +54 -450
- package/pages/contests/[slug]/judge.vue +141 -53
- package/pages/contests/[slug]/results.vue +182 -0
- package/pages/contests/create.vue +64 -64
- package/pages/contests/index.vue +2 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +6 -5
- package/pages/docs/[siteSlug]/edit.vue +58 -2
- package/pages/docs/[siteSlug]/index.vue +6 -5
- package/pages/federated-hubs/[id]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +3 -2
- package/pages/index.vue +25 -7
- package/pages/learn/index.vue +1 -1
- package/pages/mirror/[id].vue +3 -3
- package/pages/notifications.vue +15 -1
- package/pages/products/[slug].vue +5 -2
- package/pages/settings/notifications.vue +7 -1
- package/pages/tags/[slug].vue +3 -2
- package/pages/tags/index.vue +3 -2
- package/pages/videos/[id].vue +18 -0
- package/server/api/admin/content/[id].patch.ts +1 -1
- package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/admin/federation/refederate.post.ts +7 -3
- package/server/api/admin/federation/repair-types.post.ts +2 -45
- package/server/api/admin/federation/retry.post.ts +7 -4
- package/server/api/admin/reports.get.ts +1 -0
- package/server/api/auth/federated/login.post.ts +22 -2
- package/server/api/auth/sign-in-username.post.ts +42 -0
- package/server/api/content/[id]/products-sync.post.ts +7 -6
- package/server/api/contests/[slug]/entries/[entryId].delete.ts +14 -0
- package/server/api/contests/[slug]/entries.get.ts +6 -1
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/docs/[siteSlug]/nav.get.ts +1 -1
- package/server/api/docs/[siteSlug]/pages/[pageId]/duplicate.post.ts +16 -0
- package/server/api/docs/[siteSlug]/pages/reorder.post.ts +4 -1
- package/server/api/docs/migrate-content.post.ts +1 -7
- package/server/api/federation/hub-follow-status.get.ts +2 -18
- package/server/api/federation/hub-follow.post.ts +9 -27
- package/server/api/federation/hub-post-like.post.ts +9 -98
- package/server/api/federation/hub-post-likes.get.ts +3 -13
- package/server/api/notifications/read.post.ts +6 -1
- package/server/api/profile/theme.put.ts +23 -0
- package/server/api/search/index.get.ts +2 -2
- package/server/api/search/trending.get.ts +3 -3
- package/server/api/users/index.get.ts +9 -2
- package/server/middleware/content-ap.ts +2 -2
- package/server/routes/.well-known/webfinger.ts +2 -2
- package/theme/base.css +23 -0
- package/components/EditorPropertiesPanel.vue +0 -393
- package/components/views/BlogView.vue +0 -735
- package/server/api/resolve-identity.post.ts +0 -34
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Serialized, ContestDetail } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
contest: Serialized<ContestDetail> | null;
|
|
6
|
+
isOwner?: boolean;
|
|
7
|
+
isJudge?: boolean;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
(e: 'copy-link'): void;
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
function statusClass(status: string): string {
|
|
15
|
+
const map: Record<string, string> = {
|
|
16
|
+
upcoming: 'cpub-status-upcoming',
|
|
17
|
+
active: 'cpub-status-active',
|
|
18
|
+
judging: 'cpub-status-judging',
|
|
19
|
+
completed: 'cpub-status-completed',
|
|
20
|
+
cancelled: 'cpub-status-cancelled',
|
|
21
|
+
};
|
|
22
|
+
return map[status] ?? '';
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="cpub-sidebar">
|
|
28
|
+
<!-- STATUS -->
|
|
29
|
+
<div class="cpub-sb-card">
|
|
30
|
+
<div class="cpub-sb-title"><i class="fa-solid fa-circle-info"></i> Status</div>
|
|
31
|
+
<div class="cpub-sb-body">
|
|
32
|
+
<div class="cpub-sb-row">
|
|
33
|
+
<strong>Status:</strong>
|
|
34
|
+
<span class="cpub-sb-status" :class="statusClass(contest?.status ?? '')">{{ contest?.status ?? 'unknown' }}</span>
|
|
35
|
+
</div>
|
|
36
|
+
<div v-if="contest?.startDate" class="cpub-sb-row"><strong>Starts:</strong> {{ new Date(contest.startDate).toLocaleDateString() }}</div>
|
|
37
|
+
<div v-if="contest?.endDate" class="cpub-sb-row"><strong>Ends:</strong> {{ new Date(contest.endDate).toLocaleDateString() }}</div>
|
|
38
|
+
<div v-if="contest?.judgingEndDate" class="cpub-sb-row"><strong>Judging ends:</strong> {{ new Date(contest.judgingEndDate).toLocaleDateString() }}</div>
|
|
39
|
+
<div class="cpub-sb-row"><strong>Entries:</strong> {{ contest?.entryCount ?? 0 }}</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- LINKS -->
|
|
44
|
+
<div class="cpub-sb-card">
|
|
45
|
+
<div class="cpub-sb-title"><i class="fa-solid fa-share-nodes"></i> Share</div>
|
|
46
|
+
<div class="cpub-sb-actions">
|
|
47
|
+
<button class="cpub-btn cpub-btn-sm cpub-sb-btn" @click="emit('copy-link')"><i class="fa fa-link"></i> Copy Link</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<NuxtLink v-if="isOwner" :to="`/contests/${contest?.slug}/edit`" class="cpub-btn cpub-sb-link">
|
|
52
|
+
<i class="fa-solid fa-pen-to-square"></i> Edit Contest
|
|
53
|
+
</NuxtLink>
|
|
54
|
+
|
|
55
|
+
<NuxtLink v-if="isJudge && (contest?.status === 'judging')" :to="`/contests/${contest?.slug}/judge`" class="cpub-btn cpub-sb-link cpub-sb-judge">
|
|
56
|
+
<i class="fa-solid fa-gavel"></i> Judge Entries
|
|
57
|
+
</NuxtLink>
|
|
58
|
+
|
|
59
|
+
<NuxtLink v-if="contest?.status === 'completed'" :to="`/contests/${contest.slug}/results`" class="cpub-btn cpub-sb-link">
|
|
60
|
+
<i class="fa-solid fa-ranking-star"></i> View Results
|
|
61
|
+
</NuxtLink>
|
|
62
|
+
|
|
63
|
+
<NuxtLink to="/contests" class="cpub-btn cpub-sb-link"><i class="fa fa-arrow-left"></i> All Contests</NuxtLink>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 14px; margin-bottom: 12px; box-shadow: var(--shadow-md); }
|
|
69
|
+
.cpub-sb-title { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; display: flex; align-items: center; gap: 5px; }
|
|
70
|
+
.cpub-sb-body { font-size: 12px; color: var(--text-dim); display: flex; flex-direction: column; gap: 8px; }
|
|
71
|
+
.cpub-sb-row { display: flex; align-items: center; gap: 6px; }
|
|
72
|
+
.cpub-sb-status { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; }
|
|
73
|
+
.cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
|
|
74
|
+
.cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
|
|
75
|
+
.cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
|
|
76
|
+
.cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
|
|
77
|
+
.cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
|
|
78
|
+
|
|
79
|
+
.cpub-sb-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
80
|
+
.cpub-sb-btn { flex: 1; justify-content: center; }
|
|
81
|
+
.cpub-sb-link { width: 100%; text-align: center; display: block; margin-top: 12px; }
|
|
82
|
+
.cpub-sb-judge { color: var(--accent); border-color: var(--accent-border); }
|
|
83
|
+
</style>
|
|
@@ -549,7 +549,7 @@ const canvasMaxWidth = computed(() => {
|
|
|
549
549
|
background: var(--text-faint); top: 2px; left: 2px; transition: all 0.15s;
|
|
550
550
|
}
|
|
551
551
|
.cpub-be-toggle-switch input:checked + .cpub-be-toggle-track { background: var(--accent); border-color: var(--border); }
|
|
552
|
-
.cpub-be-toggle-switch input:checked + .cpub-be-toggle-track::after { left: 16px; background:
|
|
552
|
+
.cpub-be-toggle-switch input:checked + .cpub-be-toggle-track::after { left: 16px; background: var(--color-text-inverse); }
|
|
553
553
|
.cpub-be-toggle-label { font-size: 12px; color: var(--text-dim); }
|
|
554
554
|
|
|
555
555
|
/* Author row */
|
|
@@ -21,6 +21,7 @@ const emit = defineEmits<{
|
|
|
21
21
|
select: [pageId: string];
|
|
22
22
|
create: [parentId: string | null, title: string];
|
|
23
23
|
rename: [pageId: string, title: string];
|
|
24
|
+
duplicate: [pageId: string];
|
|
24
25
|
delete: [pageId: string];
|
|
25
26
|
reorder: [pageIds: string[]];
|
|
26
27
|
reparent: [pageId: string, newParentId: string | null];
|
|
@@ -145,6 +146,12 @@ function contextDelete(): void {
|
|
|
145
146
|
closeContext();
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
function contextDuplicate(): void {
|
|
150
|
+
if (!contextMenu.value) return;
|
|
151
|
+
emit('duplicate', contextMenu.value.pageId);
|
|
152
|
+
closeContext();
|
|
153
|
+
}
|
|
154
|
+
|
|
148
155
|
function contextAddChild(): void {
|
|
149
156
|
if (!contextMenu.value) return;
|
|
150
157
|
const parentId = contextMenu.value.pageId;
|
|
@@ -333,6 +340,9 @@ onUnmounted(() => {
|
|
|
333
340
|
<button class="cpub-tree-context-item" @click="contextRename">
|
|
334
341
|
<i class="fa-solid fa-pen" /> Rename
|
|
335
342
|
</button>
|
|
343
|
+
<button class="cpub-tree-context-item" @click="contextDuplicate">
|
|
344
|
+
<i class="fa-solid fa-copy" /> Duplicate
|
|
345
|
+
</button>
|
|
336
346
|
<button class="cpub-tree-context-item" @click="contextAddChild">
|
|
337
347
|
<i class="fa-solid fa-folder-plus" /> Add Child
|
|
338
348
|
</button>
|
|
@@ -63,7 +63,7 @@ const isCompanyHub = computed(() => hubType.value === 'company');
|
|
|
63
63
|
|
|
64
64
|
.cpub-hub-banner {
|
|
65
65
|
height: 180px;
|
|
66
|
-
background: linear-gradient(135deg, var(--accent) 0%,
|
|
66
|
+
background: linear-gradient(135deg, var(--accent) 0%, var(--teal) 50%, var(--accent-border) 100%);
|
|
67
67
|
position: relative;
|
|
68
68
|
overflow: hidden;
|
|
69
69
|
border-bottom: var(--border-width-default) solid var(--border);
|
|
@@ -1,15 +1,118 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Client-side HTML sanitizer for v-html bindings.
|
|
3
|
+
*
|
|
4
|
+
* Defense-in-depth: content is also sanitized server-side at ingest
|
|
5
|
+
* (protocol/sanitize.ts for federated content, TipTap for local content).
|
|
6
|
+
* This provides a second barrier in case of any server-side bypass.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the allowlist from @commonpub/protocol/sanitize.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ALLOWED_ELEMENTS = new Set([
|
|
12
|
+
'p', 'br', 'hr',
|
|
13
|
+
'a',
|
|
14
|
+
'img',
|
|
15
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
16
|
+
'ul', 'ol', 'li',
|
|
17
|
+
'blockquote',
|
|
18
|
+
'pre', 'code',
|
|
19
|
+
'em', 'strong', 'b', 'i', 'u', 's', 'del', 'ins',
|
|
20
|
+
'sub', 'sup',
|
|
21
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
22
|
+
'dl', 'dt', 'dd',
|
|
23
|
+
'details', 'summary',
|
|
24
|
+
'span',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const ALLOWED_ATTRIBUTES: Record<string, Set<string>> = {
|
|
28
|
+
a: new Set(['href', 'rel', 'title', 'class']),
|
|
29
|
+
img: new Set(['src', 'alt', 'title', 'width', 'height', 'class']),
|
|
30
|
+
td: new Set(['colspan', 'rowspan']),
|
|
31
|
+
th: new Set(['colspan', 'rowspan', 'scope']),
|
|
32
|
+
ol: new Set(['start', 'type']),
|
|
33
|
+
code: new Set(['class']),
|
|
34
|
+
span: new Set(['class']),
|
|
35
|
+
pre: new Set(['class']),
|
|
36
|
+
blockquote: new Set(['class']),
|
|
37
|
+
p: new Set(['class']),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SAFE_URL_SCHEMES = new Set(['https:', 'http:', 'mailto:']);
|
|
41
|
+
const DANGEROUS_URL_PATTERNS = [/^javascript:/i, /^vbscript:/i, /^blob:/i];
|
|
42
|
+
const SAFE_DATA_URI_PATTERN = /^data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,/i;
|
|
43
|
+
|
|
44
|
+
function isSafeUrl(url: string): boolean {
|
|
45
|
+
const trimmed = url.trim();
|
|
46
|
+
for (const pattern of DANGEROUS_URL_PATTERNS) {
|
|
47
|
+
if (pattern.test(trimmed)) return false;
|
|
48
|
+
}
|
|
49
|
+
if (/^data:/i.test(trimmed)) {
|
|
50
|
+
return SAFE_DATA_URI_PATTERN.test(trimmed);
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const parsed = new URL(trimmed, 'https://placeholder.invalid');
|
|
54
|
+
if (!SAFE_URL_SCHEMES.has(parsed.protocol)) return false;
|
|
55
|
+
} catch {
|
|
56
|
+
// Relative URLs are fine
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function escapeAttrValue(value: string): string {
|
|
62
|
+
return value
|
|
63
|
+
.replace(/&/g, '&')
|
|
64
|
+
.replace(/"/g, '"')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>');
|
|
67
|
+
}
|
|
9
68
|
|
|
10
69
|
/** Sanitize HTML for safe rendering via v-html */
|
|
11
70
|
export function sanitizeBlockHtml(html: string): string {
|
|
12
|
-
return
|
|
71
|
+
if (!html || typeof html !== 'string') return '';
|
|
72
|
+
|
|
73
|
+
let result = html;
|
|
74
|
+
|
|
75
|
+
// Strip HTML comments
|
|
76
|
+
result = result.replace(/<!--[\s\S]*?-->/g, '');
|
|
77
|
+
|
|
78
|
+
// Process all HTML tags
|
|
79
|
+
result = result.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)?\/?>/g, (match, tagName, attrs) => {
|
|
80
|
+
const tag = (tagName as string).toLowerCase();
|
|
81
|
+
|
|
82
|
+
if (!ALLOWED_ELEMENTS.has(tag)) return '';
|
|
83
|
+
if (match.startsWith('</')) return `</${tag}>`;
|
|
84
|
+
|
|
85
|
+
const allowedAttrs = ALLOWED_ATTRIBUTES[tag];
|
|
86
|
+
if (!attrs || !allowedAttrs) {
|
|
87
|
+
const selfClose = match.endsWith('/>') ? ' /' : '';
|
|
88
|
+
return `<${tag}${selfClose}>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const safeAttrs: string[] = [];
|
|
92
|
+
const attrRegex = /([a-zA-Z_][\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
93
|
+
let attrMatch: RegExpExecArray | null;
|
|
94
|
+
|
|
95
|
+
while ((attrMatch = attrRegex.exec(attrs as string)) !== null) {
|
|
96
|
+
const attrName = attrMatch[1]!.toLowerCase();
|
|
97
|
+
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
|
|
98
|
+
|
|
99
|
+
if (attrName.startsWith('on')) continue;
|
|
100
|
+
if (attrName === 'style') continue;
|
|
101
|
+
if (!allowedAttrs.has(attrName)) continue;
|
|
102
|
+
|
|
103
|
+
if ((attrName === 'href' || attrName === 'src') && !isSafeUrl(attrValue)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
safeAttrs.push(`${attrName}="${escapeAttrValue(attrValue)}"`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const attrsStr = safeAttrs.length > 0 ? ' ' + safeAttrs.join(' ') : '';
|
|
111
|
+
const selfClose = match.endsWith('/>') ? ' /' : '';
|
|
112
|
+
return `<${tag}${attrsStr}${selfClose}>`;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return result;
|
|
13
116
|
}
|
|
14
117
|
|
|
15
118
|
/** Composable wrapper for template use */
|
package/composables/useTheme.ts
CHANGED
|
@@ -48,6 +48,14 @@ export function useTheme(): {
|
|
|
48
48
|
|
|
49
49
|
if (import.meta.client) {
|
|
50
50
|
applyThemeToElement(document.documentElement, newTheme);
|
|
51
|
+
|
|
52
|
+
// Persist to DB for cross-device sync (fire-and-forget, cookie is primary)
|
|
53
|
+
$fetch('/api/profile/theme', {
|
|
54
|
+
method: 'PUT',
|
|
55
|
+
body: { themeId: newTheme },
|
|
56
|
+
}).catch(() => {
|
|
57
|
+
// Not logged in or network error — cookie preference is sufficient
|
|
58
|
+
});
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
|
package/layouts/default.vue
CHANGED
|
@@ -85,7 +85,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
85
85
|
|
|
86
86
|
<!-- Learn dropdown -->
|
|
87
87
|
<div v-if="learning || docs" class="cpub-nav-dropdown">
|
|
88
|
-
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'learn' }" @click.stop="toggleDropdown('learn')">
|
|
88
|
+
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'learn' }" aria-haspopup="true" :aria-expanded="openDropdown === 'learn'" @click.stop="toggleDropdown('learn')">
|
|
89
89
|
<i class="fa-solid fa-graduation-cap"></i> Learn <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
90
90
|
</button>
|
|
91
91
|
<div v-if="openDropdown === 'learn'" class="cpub-nav-panel">
|
|
@@ -97,7 +97,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
97
97
|
|
|
98
98
|
<!-- Build dropdown -->
|
|
99
99
|
<div class="cpub-nav-dropdown">
|
|
100
|
-
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'build' }" @click.stop="toggleDropdown('build')">
|
|
100
|
+
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'build' }" aria-haspopup="true" :aria-expanded="openDropdown === 'build'" @click.stop="toggleDropdown('build')">
|
|
101
101
|
<i class="fa-solid fa-hammer"></i> Build <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
102
102
|
</button>
|
|
103
103
|
<div v-if="openDropdown === 'build'" class="cpub-nav-panel">
|
|
@@ -108,7 +108,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
108
108
|
|
|
109
109
|
<!-- Read dropdown -->
|
|
110
110
|
<div class="cpub-nav-dropdown">
|
|
111
|
-
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'read' }" @click.stop="toggleDropdown('read')">
|
|
111
|
+
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'read' }" aria-haspopup="true" :aria-expanded="openDropdown === 'read'" @click.stop="toggleDropdown('read')">
|
|
112
112
|
<i class="fa-solid fa-newspaper"></i> Read <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
113
113
|
</button>
|
|
114
114
|
<div v-if="openDropdown === 'read'" class="cpub-nav-panel">
|
|
@@ -118,7 +118,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
118
118
|
|
|
119
119
|
<!-- Watch dropdown -->
|
|
120
120
|
<div v-if="video" class="cpub-nav-dropdown">
|
|
121
|
-
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'watch' }" @click.stop="toggleDropdown('watch')">
|
|
121
|
+
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'watch' }" aria-haspopup="true" :aria-expanded="openDropdown === 'watch'" @click.stop="toggleDropdown('watch')">
|
|
122
122
|
<i class="fa-solid fa-play"></i> Watch <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
123
123
|
</button>
|
|
124
124
|
<div v-if="openDropdown === 'watch'" class="cpub-nav-panel">
|
|
@@ -145,11 +145,11 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
145
145
|
<template v-if="isAuthenticated">
|
|
146
146
|
<NuxtLink to="/messages" class="cpub-icon-btn" title="Messages" aria-label="Messages">
|
|
147
147
|
<i class="fa-solid fa-envelope"></i>
|
|
148
|
-
<span v-if="unreadMessages > 0" class="cpub-notif-
|
|
148
|
+
<span v-if="unreadMessages > 0" class="cpub-notif-badge" aria-label="unread messages">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
|
|
149
149
|
</NuxtLink>
|
|
150
150
|
<NuxtLink to="/notifications" class="cpub-icon-btn" title="Notifications" aria-label="Notifications">
|
|
151
151
|
<i class="fa-solid fa-bell"></i>
|
|
152
|
-
<span v-if="unreadCount > 0" class="cpub-notif-
|
|
152
|
+
<span v-if="unreadCount > 0" class="cpub-notif-badge" aria-label="unread notifications">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
|
153
153
|
</NuxtLink>
|
|
154
154
|
<NuxtLink to="/create" class="cpub-btn-new" aria-label="Create new content">
|
|
155
155
|
<i class="fa-solid fa-plus"></i> <span class="cpub-new-text">New</span>
|
|
@@ -336,7 +336,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
336
336
|
|
|
337
337
|
.cpub-icon-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: transparent; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 13px; position: relative; transition: all 0.15s; text-decoration: none; }
|
|
338
338
|
.cpub-icon-btn:hover { background: var(--surface2); border-color: var(--border); color: var(--text); }
|
|
339
|
-
.cpub-notif-
|
|
339
|
+
.cpub-notif-badge { position: absolute; top: 2px; right: 0; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px; background: var(--accent); color: var(--color-text-inverse, #000); font-size: 9px; font-weight: 700; font-family: var(--font-mono); line-height: 14px; text-align: center; border: 1.5px solid var(--surface); }
|
|
340
340
|
|
|
341
341
|
.cpub-btn-new { display: flex; align-items: center; gap: 6px; padding: 6px 14px; background: var(--accent); border: var(--border-width-default) solid var(--border); color: var(--color-text-inverse); font-size: 12px; font-weight: 600; transition: all 0.15s; box-shadow: var(--shadow-sm); text-decoration: none; cursor: pointer; }
|
|
342
342
|
.cpub-btn-new:hover { box-shadow: var(--shadow-md); transform: translate(-1px, -1px); }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Global client-side middleware that mirrors server/middleware/features.ts.
|
|
2
|
+
// Prevents client-side navigation to feature-gated pages when the flag is disabled.
|
|
3
|
+
|
|
4
|
+
const ROUTE_FEATURE_MAP: Record<string, keyof import('../composables/useFeatures').FeatureFlags> = {
|
|
5
|
+
'/learn': 'learning',
|
|
6
|
+
'/docs': 'docs',
|
|
7
|
+
'/videos': 'video',
|
|
8
|
+
'/admin': 'admin',
|
|
9
|
+
'/contests': 'contests',
|
|
10
|
+
'/explainer': 'explainers',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
14
|
+
for (const [prefix, feature] of Object.entries(ROUTE_FEATURE_MAP)) {
|
|
15
|
+
if (to.path === prefix || to.path.startsWith(prefix + '/')) {
|
|
16
|
+
const config = useRuntimeConfig();
|
|
17
|
+
const flags = config.public.features as Record<string, boolean>;
|
|
18
|
+
if (!flags[feature]) {
|
|
19
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@aws-sdk/client-s3": "^3.1010.0",
|
|
31
|
+
"@commonpub/explainer": "^0.7.11",
|
|
32
|
+
"@commonpub/schema": "^0.9.11",
|
|
33
|
+
"@commonpub/server": "^2.29.1",
|
|
31
34
|
"@tiptap/core": "^2.11.0",
|
|
32
35
|
"@tiptap/extension-bold": "^2.11.0",
|
|
33
36
|
"@tiptap/extension-bullet-list": "^2.11.0",
|
|
@@ -50,15 +53,12 @@
|
|
|
50
53
|
"vue": "^3.4.0",
|
|
51
54
|
"vue-router": "^4.3.0",
|
|
52
55
|
"zod": "^4.3.6",
|
|
56
|
+
"@commonpub/auth": "0.5.1",
|
|
53
57
|
"@commonpub/editor": "0.7.9",
|
|
54
|
-
"@commonpub/auth": "0.5.0",
|
|
55
58
|
"@commonpub/docs": "0.6.2",
|
|
59
|
+
"@commonpub/protocol": "0.9.9",
|
|
56
60
|
"@commonpub/config": "0.9.1",
|
|
57
|
-
"@commonpub/protocol": "0.9.8",
|
|
58
|
-
"@commonpub/schema": "0.9.6",
|
|
59
|
-
"@commonpub/server": "2.28.0",
|
|
60
61
|
"@commonpub/learning": "0.5.0",
|
|
61
|
-
"@commonpub/explainer": "0.7.10",
|
|
62
62
|
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
package/pages/[type]/index.vue
CHANGED
|
@@ -14,7 +14,7 @@ useSeoMeta({
|
|
|
14
14
|
const sortBy = ref('recent');
|
|
15
15
|
const sortOptions = ['recent', 'popular'] as const;
|
|
16
16
|
|
|
17
|
-
const { data } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
17
|
+
const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
18
18
|
query: computed(() => ({
|
|
19
19
|
status: 'published',
|
|
20
20
|
type: contentType.value,
|
|
@@ -38,10 +38,11 @@ const { data } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>(
|
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
40
40
|
|
|
41
|
-
<
|
|
41
|
+
<p v-if="pending" class="cpub-listing-empty"><i class="fa-solid fa-circle-notch fa-spin"></i> Loading...</p>
|
|
42
|
+
<div v-else-if="data?.items?.length" class="cpub-listing-grid">
|
|
42
43
|
<ContentCard v-for="item in data.items" :key="item.id" :item="item" />
|
|
43
44
|
</div>
|
|
44
|
-
<p class="cpub-listing-empty"
|
|
45
|
+
<p v-else class="cpub-listing-empty">No {{ contentType }}s published yet.</p>
|
|
45
46
|
</div>
|
|
46
47
|
</template>
|
|
47
48
|
|
package/pages/admin/audit.vue
CHANGED
|
@@ -3,7 +3,7 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
|
3
3
|
|
|
4
4
|
useSeoMeta({ title: `Audit Log — Admin — ${useSiteName()}` });
|
|
5
5
|
|
|
6
|
-
const { data: logsData } = await useFetch('/api/admin/audit');
|
|
6
|
+
const { data: logsData, pending } = await useFetch('/api/admin/audit');
|
|
7
7
|
|
|
8
8
|
interface AuditEntry {
|
|
9
9
|
id: string;
|
|
@@ -27,7 +27,8 @@ const logs = computed<AuditEntry[]>(() => {
|
|
|
27
27
|
<div class="admin-audit">
|
|
28
28
|
<h1 class="admin-page-title">Audit Log</h1>
|
|
29
29
|
|
|
30
|
-
<
|
|
30
|
+
<p v-if="pending" class="admin-empty"><i class="fa-solid fa-circle-notch fa-spin"></i> Loading audit log...</p>
|
|
31
|
+
<table class="admin-table" v-else-if="logs.length">
|
|
31
32
|
<thead>
|
|
32
33
|
<tr>
|
|
33
34
|
<th>Action</th>
|
|
@@ -4,7 +4,7 @@ useSeoMeta({ title: `Federation — Admin — ${useSiteName()}` });
|
|
|
4
4
|
|
|
5
5
|
const activeTab = ref<'activity' | 'mirrors' | 'clients' | 'trusted' | 'tools'>('activity');
|
|
6
6
|
|
|
7
|
-
const { data: statsData } = await useFetch('/api/admin/federation/stats', {
|
|
7
|
+
const { data: statsData, pending } = await useFetch('/api/admin/federation/stats', {
|
|
8
8
|
default: () => ({ inbound: 0, outbound: 0, pending: 0, failed: 0, followers: 0, following: 0 }),
|
|
9
9
|
});
|
|
10
10
|
|
|
@@ -46,11 +46,15 @@ async function addTrusted(): Promise<void> {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function removeTrusted(domain: string): Promise<void> {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
try {
|
|
50
|
+
await $fetch('/api/admin/federation/trusted-instances', {
|
|
51
|
+
method: 'DELETE',
|
|
52
|
+
body: { domain },
|
|
53
|
+
});
|
|
54
|
+
await refreshTrusted();
|
|
55
|
+
} catch {
|
|
56
|
+
alert('Failed to remove trusted instance');
|
|
57
|
+
}
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// Mirror creation
|
|
@@ -79,16 +83,24 @@ async function createMirror(): Promise<void> {
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
async function toggleMirror(id: string, currentStatus: string): Promise<void> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
try {
|
|
87
|
+
await $fetch(`/api/admin/federation/mirrors/${id}`, {
|
|
88
|
+
method: 'PUT',
|
|
89
|
+
body: { action: currentStatus === 'active' ? 'pause' : 'resume' },
|
|
90
|
+
});
|
|
91
|
+
await refreshMirrors();
|
|
92
|
+
} catch {
|
|
93
|
+
alert('Failed to update mirror');
|
|
94
|
+
}
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
async function deleteMirror(id: string): Promise<void> {
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
try {
|
|
99
|
+
await $fetch(`/api/admin/federation/mirrors/${id}`, { method: 'DELETE' });
|
|
100
|
+
await refreshMirrors();
|
|
101
|
+
} catch {
|
|
102
|
+
alert('Failed to delete mirror');
|
|
103
|
+
}
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
// Backfill
|
|
@@ -178,6 +190,12 @@ async function refederate(): Promise<void> {
|
|
|
178
190
|
<div>
|
|
179
191
|
<h1 class="cpub-admin-title">Federation</h1>
|
|
180
192
|
|
|
193
|
+
<!-- Loading -->
|
|
194
|
+
<div v-if="pending" class="cpub-fed-loading">
|
|
195
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading...
|
|
196
|
+
</div>
|
|
197
|
+
<template v-else>
|
|
198
|
+
|
|
181
199
|
<!-- Stats -->
|
|
182
200
|
<div class="cpub-fed-stats">
|
|
183
201
|
<div class="cpub-fed-stat">
|
|
@@ -395,10 +413,12 @@ async function refederate(): Promise<void> {
|
|
|
395
413
|
</div>
|
|
396
414
|
</div>
|
|
397
415
|
</div>
|
|
416
|
+
</template>
|
|
398
417
|
</div>
|
|
399
418
|
</template>
|
|
400
419
|
|
|
401
420
|
<style scoped>
|
|
421
|
+
.cpub-fed-loading { display: flex; align-items: center; gap: 8px; padding: 32px; color: var(--text-faint); font-family: var(--font-mono); font-size: 0.8125rem; }
|
|
402
422
|
.cpub-admin-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
403
423
|
|
|
404
424
|
.cpub-fed-stats {
|
package/pages/admin/index.vue
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
3
3
|
useSeoMeta({ title: `Admin Dashboard — ${useSiteName()}` });
|
|
4
4
|
|
|
5
|
-
const { data: stats } = await useFetch('/api/admin/stats');
|
|
5
|
+
const { data: stats, pending } = await useFetch('/api/admin/stats');
|
|
6
6
|
</script>
|
|
7
7
|
|
|
8
8
|
<template>
|
|
9
9
|
<div class="cpub-admin-dashboard">
|
|
10
10
|
<h1 class="cpub-admin-title">Platform Dashboard</h1>
|
|
11
11
|
|
|
12
|
+
<div v-if="pending" class="cpub-admin-loading">
|
|
13
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading...
|
|
14
|
+
</div>
|
|
15
|
+
<template v-else>
|
|
12
16
|
<div class="cpub-stats-grid" v-if="stats">
|
|
13
17
|
<div class="cpub-stat-card" v-for="stat in [
|
|
14
18
|
{ label: 'Users', value: stats.users?.total ?? 0, icon: 'fa-solid fa-users' },
|
|
@@ -43,10 +47,12 @@ const { data: stats } = await useFetch('/api/admin/stats');
|
|
|
43
47
|
</NuxtLink>
|
|
44
48
|
</div>
|
|
45
49
|
</div>
|
|
50
|
+
</template>
|
|
46
51
|
</div>
|
|
47
52
|
</template>
|
|
48
53
|
|
|
49
54
|
<style scoped>
|
|
55
|
+
.cpub-admin-loading { display: flex; align-items: center; gap: 8px; padding: var(--space-8, 32px); color: var(--text-faint); }
|
|
50
56
|
.cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin-bottom: var(--space-6); }
|
|
51
57
|
.cpub-stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
|
|
52
58
|
.cpub-stat-card { padding: var(--space-5); background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); display: flex; flex-direction: column; align-items: center; gap: var(--space-2); }
|