@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
package/pages/contests/index.vue
CHANGED
|
@@ -28,7 +28,8 @@ const canCreateContest = computed(() => {
|
|
|
28
28
|
<span class="cpub-badge" :class="{
|
|
29
29
|
'cpub-badge-green': contest.status === 'active',
|
|
30
30
|
'cpub-badge-yellow': contest.status === 'upcoming',
|
|
31
|
-
'cpub-badge-
|
|
31
|
+
'cpub-badge-accent': contest.status === 'judging',
|
|
32
|
+
'cpub-badge-red': contest.status === 'completed' || contest.status === 'cancelled',
|
|
32
33
|
}">{{ contest.status }}</span>
|
|
33
34
|
<h3 style="font-size: 15px; font-weight: 600; margin: 8px 0">
|
|
34
35
|
<NuxtLink :to="`/contests/${contest.slug}`" style="color: var(--text); text-decoration: none">
|
|
@@ -12,11 +12,11 @@ const pagePath = computed(() => {
|
|
|
12
12
|
const selectedVersion = ref('');
|
|
13
13
|
|
|
14
14
|
const { data: site } = useLazyFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions: Array<{ id: string; label: string; slug: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
|
|
15
|
-
const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
15
|
+
const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; sidebarLabel?: string | null; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
16
16
|
const base = `/api/docs/${siteSlug.value}/nav`;
|
|
17
17
|
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
18
18
|
});
|
|
19
|
-
const { data: pages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
19
|
+
const { data: pages } = useLazyFetch<Array<{ id: string; title: string; sidebarLabel?: string | null; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
20
20
|
const base = `/api/docs/${siteSlug.value}/pages`;
|
|
21
21
|
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
22
22
|
});
|
|
@@ -59,6 +59,7 @@ const isOwner = computed(() => site.value && user.value && site.value.ownerId ==
|
|
|
59
59
|
interface NavTreeNode {
|
|
60
60
|
id: string;
|
|
61
61
|
title: string;
|
|
62
|
+
sidebarLabel?: string | null;
|
|
62
63
|
slug: string;
|
|
63
64
|
sortOrder: number;
|
|
64
65
|
parentId: string | null;
|
|
@@ -67,7 +68,7 @@ interface NavTreeNode {
|
|
|
67
68
|
|
|
68
69
|
const navTree = computed<NavTreeNode[]>(() => {
|
|
69
70
|
if (!pages.value) return [];
|
|
70
|
-
const allPages = pages.value as Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>;
|
|
71
|
+
const allPages = pages.value as Array<{ id: string; title: string; sidebarLabel?: string | null; slug: string; sortOrder: number; parentId: string | null }>;
|
|
71
72
|
const byParent = new Map<string | null, typeof allPages>();
|
|
72
73
|
for (const p of allPages) {
|
|
73
74
|
const key = p.parentId ?? null;
|
|
@@ -270,7 +271,7 @@ useSeoMeta({
|
|
|
270
271
|
<div class="docs-nav-item" :class="{ active: node.slug === pagePath }">
|
|
271
272
|
<div class="docs-nav-row">
|
|
272
273
|
<NuxtLink :to="`/docs/${siteSlug}/${node.slug}`" class="docs-nav-link" @click="sidebarOpen = false">
|
|
273
|
-
{{ node.title }}
|
|
274
|
+
{{ node.sidebarLabel || node.title }}
|
|
274
275
|
</NuxtLink>
|
|
275
276
|
<button
|
|
276
277
|
v-if="node.children.length"
|
|
@@ -291,7 +292,7 @@ useSeoMeta({
|
|
|
291
292
|
:class="{ active: child.slug === pagePath }"
|
|
292
293
|
@click="sidebarOpen = false"
|
|
293
294
|
>
|
|
294
|
-
{{ child.title }}
|
|
295
|
+
{{ child.sidebarLabel || child.title }}
|
|
295
296
|
</NuxtLink>
|
|
296
297
|
</div>
|
|
297
298
|
</div>
|
|
@@ -31,6 +31,12 @@ watch(site, (s) => {
|
|
|
31
31
|
}
|
|
32
32
|
}, { immediate: true });
|
|
33
33
|
|
|
34
|
+
// Resolve selected version string → version UUID for write operations
|
|
35
|
+
const selectedVersionId = computed(() => {
|
|
36
|
+
if (!site.value?.versions?.length || !selectedVersion.value) return undefined;
|
|
37
|
+
return site.value.versions.find((v) => v.version === selectedVersion.value)?.id;
|
|
38
|
+
});
|
|
39
|
+
|
|
34
40
|
const { data: rawPages, refresh: refreshPages } = await useFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null; content: string | BlockTuple[] | null; format?: string }>>(() => {
|
|
35
41
|
const base = `/api/docs/${siteSlug.value}/pages`;
|
|
36
42
|
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
@@ -101,6 +107,8 @@ const selectedPage = computed<DocsPage | null>(() =>
|
|
|
101
107
|
|
|
102
108
|
// Page properties (right panel)
|
|
103
109
|
const pageSlug = ref('');
|
|
110
|
+
const pageSidebarLabel = ref('');
|
|
111
|
+
const pageDescription = ref('');
|
|
104
112
|
const pageStatus = ref<'draft' | 'published'>('draft');
|
|
105
113
|
const savingPage = ref(false);
|
|
106
114
|
const autoSaveTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -139,6 +147,8 @@ async function selectPage(pageId: string): Promise<void> {
|
|
|
139
147
|
|
|
140
148
|
// Load properties
|
|
141
149
|
pageSlug.value = page.slug ?? '';
|
|
150
|
+
pageSidebarLabel.value = (page as unknown as Record<string, unknown>).sidebarLabel as string ?? '';
|
|
151
|
+
pageDescription.value = (page as unknown as Record<string, unknown>).description as string ?? '';
|
|
142
152
|
pageStatus.value = ((page as unknown as Record<string, unknown>).status as 'draft' | 'published') || 'draft';
|
|
143
153
|
isDirty.value = false;
|
|
144
154
|
autoSaveStatus.value = 'idle';
|
|
@@ -160,6 +170,8 @@ async function saveCurrentPage(): Promise<void> {
|
|
|
160
170
|
body: {
|
|
161
171
|
title: selectedPage.value?.title,
|
|
162
172
|
slug: pageSlug.value,
|
|
173
|
+
sidebarLabel: pageSidebarLabel.value || null,
|
|
174
|
+
description: pageDescription.value || null,
|
|
163
175
|
content: blockEditor.toBlockTuples(),
|
|
164
176
|
},
|
|
165
177
|
});
|
|
@@ -222,7 +234,7 @@ watch(() => blockEditor.blocks.value, () => {
|
|
|
222
234
|
scheduleAutoSave();
|
|
223
235
|
}, { deep: true });
|
|
224
236
|
|
|
225
|
-
watch(pageSlug, () => {
|
|
237
|
+
watch([pageSlug, pageSidebarLabel, pageDescription], () => {
|
|
226
238
|
if (isLoadingPage.value) return;
|
|
227
239
|
isDirty.value = true;
|
|
228
240
|
scheduleAutoSave();
|
|
@@ -281,6 +293,7 @@ async function handleCreatePage(parentId: string | null, title: string): Promise
|
|
|
281
293
|
content: [['paragraph', { html: '' }]],
|
|
282
294
|
parentId: parentId ?? undefined,
|
|
283
295
|
sortOrder: (pages.value?.length ?? 0) + 1,
|
|
296
|
+
versionId: selectedVersionId.value,
|
|
284
297
|
},
|
|
285
298
|
});
|
|
286
299
|
await refreshPages();
|
|
@@ -306,6 +319,21 @@ async function handleRenamePage(pageId: string, newTitle: string): Promise<void>
|
|
|
306
319
|
}
|
|
307
320
|
}
|
|
308
321
|
|
|
322
|
+
async function handleDuplicatePage(pageId: string): Promise<void> {
|
|
323
|
+
try {
|
|
324
|
+
const result = await $fetch(`/api/docs/${siteSlug.value}/pages/${pageId}/duplicate`, {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
});
|
|
327
|
+
await refreshPages();
|
|
328
|
+
if (result && typeof result === 'object' && 'id' in result) {
|
|
329
|
+
selectPage((result as { id: string }).id);
|
|
330
|
+
}
|
|
331
|
+
toast('Page duplicated', 'success');
|
|
332
|
+
} catch (err: unknown) {
|
|
333
|
+
toast(err instanceof Error ? err.message : 'Failed to duplicate page', 'error');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
309
337
|
async function handleDeletePage(pageId: string): Promise<void> {
|
|
310
338
|
try {
|
|
311
339
|
await $fetch(`/api/docs/${siteSlug.value}/pages/${pageId}`, { method: 'DELETE' });
|
|
@@ -325,7 +353,7 @@ async function handleReorder(pageIds: string[]): Promise<void> {
|
|
|
325
353
|
try {
|
|
326
354
|
await $fetch(`/api/docs/${siteSlug.value}/pages/reorder`, {
|
|
327
355
|
method: 'POST',
|
|
328
|
-
body: { pageIds },
|
|
356
|
+
body: { pageIds, version: selectedVersion.value || undefined },
|
|
329
357
|
});
|
|
330
358
|
await refreshPages();
|
|
331
359
|
} catch {
|
|
@@ -535,6 +563,7 @@ async function createVersion(): Promise<void> {
|
|
|
535
563
|
@select="selectPage"
|
|
536
564
|
@create="handleCreatePage"
|
|
537
565
|
@rename="handleRenamePage"
|
|
566
|
+
@duplicate="handleDuplicatePage"
|
|
538
567
|
@delete="handleDeletePage"
|
|
539
568
|
@reorder="handleReorder"
|
|
540
569
|
@reparent="handleReparent"
|
|
@@ -612,6 +641,17 @@ async function createVersion(): Promise<void> {
|
|
|
612
641
|
<input v-model="pageSlug" class="cpub-docs-field-input" placeholder="page-slug" />
|
|
613
642
|
</div>
|
|
614
643
|
|
|
644
|
+
<div class="cpub-docs-field">
|
|
645
|
+
<label class="cpub-docs-field-label">Sidebar Label</label>
|
|
646
|
+
<input v-model="pageSidebarLabel" class="cpub-docs-field-input" placeholder="Short label for nav" maxlength="128" />
|
|
647
|
+
<span class="cpub-docs-field-hint">Shown in sidebar instead of title when set</span>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
<div class="cpub-docs-field">
|
|
651
|
+
<label class="cpub-docs-field-label">Description</label>
|
|
652
|
+
<textarea v-model="pageDescription" class="cpub-docs-field-textarea" placeholder="Brief page description" rows="2" maxlength="2000" />
|
|
653
|
+
</div>
|
|
654
|
+
|
|
615
655
|
<div class="cpub-docs-field">
|
|
616
656
|
<label class="cpub-docs-field-label">Status</label>
|
|
617
657
|
<div class="cpub-docs-status-row">
|
|
@@ -973,6 +1013,22 @@ async function createVersion(): Promise<void> {
|
|
|
973
1013
|
outline: none;
|
|
974
1014
|
}
|
|
975
1015
|
|
|
1016
|
+
.cpub-docs-field-textarea {
|
|
1017
|
+
padding: 6px 8px;
|
|
1018
|
+
background: var(--surface2);
|
|
1019
|
+
border: var(--border-width-default) solid var(--border);
|
|
1020
|
+
color: var(--text);
|
|
1021
|
+
font-size: 12px;
|
|
1022
|
+
font-family: var(--font-sans);
|
|
1023
|
+
resize: vertical;
|
|
1024
|
+
min-height: 40px;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
.cpub-docs-field-textarea:focus {
|
|
1028
|
+
border-color: var(--accent);
|
|
1029
|
+
outline: none;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
976
1032
|
.cpub-docs-field-hint {
|
|
977
1033
|
font-size: 10px;
|
|
978
1034
|
color: var(--text-faint);
|
|
@@ -5,11 +5,11 @@ const siteSlug = computed(() => route.params.siteSlug as string);
|
|
|
5
5
|
const selectedVersion = ref('');
|
|
6
6
|
|
|
7
7
|
const { data: site, pending: sitePending, error: siteError, refresh: refreshSite } = useLazyFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions: Array<{ id: string; label: string; slug: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
|
|
8
|
-
const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
8
|
+
const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; sidebarLabel?: string | null; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
9
9
|
const base = `/api/docs/${siteSlug.value}/nav`;
|
|
10
10
|
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
11
11
|
});
|
|
12
|
-
const { data: pages, refresh: refreshPages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
12
|
+
const { data: pages, refresh: refreshPages } = useLazyFetch<Array<{ id: string; title: string; sidebarLabel?: string | null; slug: string; sortOrder: number; parentId: string | null }>>(() => {
|
|
13
13
|
const base = `/api/docs/${siteSlug.value}/pages`;
|
|
14
14
|
return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
|
|
15
15
|
});
|
|
@@ -21,6 +21,7 @@ const isOwner = computed(() => site.value && user.value && site.value.ownerId ==
|
|
|
21
21
|
interface NavTreeNode {
|
|
22
22
|
id: string;
|
|
23
23
|
title: string;
|
|
24
|
+
sidebarLabel?: string | null;
|
|
24
25
|
slug: string;
|
|
25
26
|
sortOrder: number;
|
|
26
27
|
parentId: string | null;
|
|
@@ -29,7 +30,7 @@ interface NavTreeNode {
|
|
|
29
30
|
|
|
30
31
|
const navTree = computed<NavTreeNode[]>(() => {
|
|
31
32
|
if (!pages.value) return [];
|
|
32
|
-
const allPages = pages.value as Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>;
|
|
33
|
+
const allPages = pages.value as Array<{ id: string; title: string; sidebarLabel?: string | null; slug: string; sortOrder: number; parentId: string | null }>;
|
|
33
34
|
const byParent = new Map<string | null, typeof allPages>();
|
|
34
35
|
for (const p of allPages) {
|
|
35
36
|
const key = p.parentId ?? null;
|
|
@@ -162,7 +163,7 @@ useSeoMeta({
|
|
|
162
163
|
<div class="docs-nav-item">
|
|
163
164
|
<div class="docs-nav-row">
|
|
164
165
|
<NuxtLink :to="`/docs/${siteSlug}/${node.slug}`" class="docs-nav-link" @click="sidebarOpen = false">
|
|
165
|
-
{{ node.title }}
|
|
166
|
+
{{ node.sidebarLabel || node.title }}
|
|
166
167
|
</NuxtLink>
|
|
167
168
|
<button
|
|
168
169
|
v-if="node.children.length"
|
|
@@ -182,7 +183,7 @@ useSeoMeta({
|
|
|
182
183
|
class="docs-nav-link docs-nav-child"
|
|
183
184
|
@click="sidebarOpen = false"
|
|
184
185
|
>
|
|
185
|
-
{{ child.title }}
|
|
186
|
+
{{ child.sidebarLabel || child.title }}
|
|
186
187
|
</NuxtLink>
|
|
187
188
|
</div>
|
|
188
189
|
</div>
|
|
@@ -137,9 +137,9 @@ useHead({
|
|
|
137
137
|
<span class="cpub-post-type-badge">{{ post.postType }}</span>
|
|
138
138
|
</div>
|
|
139
139
|
|
|
140
|
-
<!-- Content is sanitized server-side
|
|
140
|
+
<!-- Content is sanitized server-side on ingest AND client-side here (defense-in-depth) -->
|
|
141
141
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
142
|
-
<div class="cpub-post-content cpub-prose" v-html="post.content"></div>
|
|
142
|
+
<div class="cpub-post-content cpub-prose" v-html="sanitizeBlockHtml(post.content || '')"></div>
|
|
143
143
|
|
|
144
144
|
<div class="cpub-post-meta">
|
|
145
145
|
<div class="cpub-post-author">
|
package/pages/hubs/index.vue
CHANGED
|
@@ -4,7 +4,7 @@ useSeoMeta({
|
|
|
4
4
|
description: 'Browse and join maker communities.',
|
|
5
5
|
});
|
|
6
6
|
|
|
7
|
-
const { data } = await useFetch('/api/hubs');
|
|
7
|
+
const { data, pending } = await useFetch('/api/hubs');
|
|
8
8
|
const { isAuthenticated } = useAuth();
|
|
9
9
|
|
|
10
10
|
const hubs = computed(() => data.value?.items ?? []);
|
|
@@ -31,7 +31,8 @@ function hubLink(hub: Record<string, unknown>): string {
|
|
|
31
31
|
</NuxtLink>
|
|
32
32
|
</div>
|
|
33
33
|
|
|
34
|
-
<div v-if="
|
|
34
|
+
<div v-if="pending" class="cpub-empty-state"><p><i class="fa-solid fa-circle-notch fa-spin"></i> Loading hubs...</p></div>
|
|
35
|
+
<div v-else-if="hubs.length" class="cpub-hubs-grid">
|
|
35
36
|
<NuxtLink
|
|
36
37
|
v-for="hub in hubs"
|
|
37
38
|
:key="hub.id"
|
package/pages/index.vue
CHANGED
|
@@ -28,7 +28,7 @@ const contentQuery = computed(() => ({
|
|
|
28
28
|
limit: 12,
|
|
29
29
|
}));
|
|
30
30
|
|
|
31
|
-
const { data: feed } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
31
|
+
const { data: feed, pending: feedPending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
32
32
|
query: contentQuery,
|
|
33
33
|
watch: [contentQuery],
|
|
34
34
|
});
|
|
@@ -38,13 +38,13 @@ const { data: featured } = await useFetch<PaginatedResponse<Serialized<ContentLi
|
|
|
38
38
|
query: { status: 'published', featured: true, limit: 1 },
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
const { data: stats } = await useFetch('/api/stats');
|
|
41
|
+
const { data: stats, pending: statsPending } = await useFetch('/api/stats');
|
|
42
42
|
|
|
43
|
-
const { data: communities } = await useFetch('/api/hubs', {
|
|
43
|
+
const { data: communities, pending: communitiesPending } = await useFetch('/api/hubs', {
|
|
44
44
|
query: { limit: 4 },
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
const { data: contests } = await useFetch('/api/contests', {
|
|
47
|
+
const { data: contests, pending: contestsPending } = await useFetch('/api/contests', {
|
|
48
48
|
query: { limit: 3 },
|
|
49
49
|
});
|
|
50
50
|
|
|
@@ -254,7 +254,10 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
254
254
|
</NuxtLink>
|
|
255
255
|
|
|
256
256
|
<!-- Content grid (2-col) -->
|
|
257
|
-
<div v-if="
|
|
257
|
+
<div v-if="feedPending" class="cpub-loading-state">
|
|
258
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading content...
|
|
259
|
+
</div>
|
|
260
|
+
<div v-else-if="feed?.items?.length" class="cpub-content-grid">
|
|
258
261
|
<ContentCard v-for="item in feed.items" :key="item.id" :item="item" />
|
|
259
262
|
</div>
|
|
260
263
|
<div v-else class="cpub-empty-state">
|
|
@@ -288,7 +291,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
288
291
|
<!-- Platform Stats -->
|
|
289
292
|
<div class="cpub-sb-card">
|
|
290
293
|
<div class="cpub-sb-head">Platform Stats</div>
|
|
291
|
-
<div class="cpub-
|
|
294
|
+
<div v-if="statsPending" class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
|
|
295
|
+
<div v-else class="cpub-stats-grid">
|
|
292
296
|
<div class="cpub-stat-block">
|
|
293
297
|
<span class="cpub-stat-num">{{ stats?.content?.byType?.project ?? 0 }}</span>
|
|
294
298
|
<span class="cpub-stat-lbl">Projects</span>
|
|
@@ -324,7 +328,11 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
324
328
|
</div>
|
|
325
329
|
|
|
326
330
|
<!-- Trending Hubs -->
|
|
327
|
-
<div v-if="hubsEnabled &&
|
|
331
|
+
<div v-if="hubsEnabled && communitiesPending" class="cpub-sb-card">
|
|
332
|
+
<div class="cpub-sb-head">Trending Hubs</div>
|
|
333
|
+
<div class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
|
|
334
|
+
</div>
|
|
335
|
+
<div v-else-if="hubsEnabled && communities?.items?.length" class="cpub-sb-card">
|
|
328
336
|
<div class="cpub-sb-head">Trending Hubs <NuxtLink to="/hubs">Browse</NuxtLink></div>
|
|
329
337
|
<div v-for="hub in communities.items" :key="hub.id" class="cpub-hub-item">
|
|
330
338
|
<div class="cpub-hub-icon">
|
|
@@ -1051,4 +1059,14 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
1051
1059
|
padding: 16px;
|
|
1052
1060
|
}
|
|
1053
1061
|
}
|
|
1062
|
+
|
|
1063
|
+
.cpub-loading-state {
|
|
1064
|
+
display: flex;
|
|
1065
|
+
align-items: center;
|
|
1066
|
+
justify-content: center;
|
|
1067
|
+
gap: var(--space-2, 8px);
|
|
1068
|
+
padding: var(--space-8, 32px);
|
|
1069
|
+
color: var(--text-faint);
|
|
1070
|
+
font-size: var(--font-size-sm, 14px);
|
|
1071
|
+
}
|
|
1054
1072
|
</style>
|
package/pages/learn/index.vue
CHANGED
|
@@ -138,7 +138,7 @@ const activeDifficultyFilter = ref('');
|
|
|
138
138
|
|
|
139
139
|
<!-- Loading -->
|
|
140
140
|
<div v-if="loadingPaths" style="padding: 24px 0; text-align: center; color: var(--text-faint); font-size: 12px;">
|
|
141
|
-
Loading paths...
|
|
141
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading paths...
|
|
142
142
|
</div>
|
|
143
143
|
|
|
144
144
|
<!-- Real data -->
|
package/pages/mirror/[id].vue
CHANGED
|
@@ -43,7 +43,7 @@ if (originUrl.value) {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** Strip HTML tags from remote actor bio
|
|
46
|
+
/** Strip HTML tags from remote actor bio for plain-text display */
|
|
47
47
|
function stripHtml(html: string): string {
|
|
48
48
|
return html.replace(/<[^>]*>/g, '').trim();
|
|
49
49
|
}
|
|
@@ -116,8 +116,8 @@ useSeoMeta({
|
|
|
116
116
|
<p v-if="transformedContent.author.bio" class="cpub-mirror-bio">{{ stripHtml(transformedContent.author.bio) }}</p>
|
|
117
117
|
</div>
|
|
118
118
|
</div>
|
|
119
|
-
<!-- Content is sanitized on ingest
|
|
120
|
-
<div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
|
|
119
|
+
<!-- Content is sanitized on ingest AND client-side here (defense-in-depth) -->
|
|
120
|
+
<div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="sanitizeBlockHtml(transformedContent.content)" />
|
|
121
121
|
<ContentAttachments v-if="transformedContent.attachments?.length" :attachments="transformedContent.attachments" />
|
|
122
122
|
<div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
|
|
123
123
|
<NuxtLink v-for="tag in transformedContent.tags" :key="tag.name" :to="`/tags/${tag.slug || tag.name.toLowerCase().replace(/\s+/g, '-')}`" class="cpub-mirror-tag">{{ tag.name }}</NuxtLink>
|
package/pages/notifications.vue
CHANGED
|
@@ -10,7 +10,7 @@ const notifQuery = computed(() => ({
|
|
|
10
10
|
limit: 50,
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
-
const { data: notifData, refresh } = await useFetch('/api/notifications', {
|
|
13
|
+
const { data: notifData, pending, refresh } = await useFetch('/api/notifications', {
|
|
14
14
|
query: notifQuery,
|
|
15
15
|
watch: [notifQuery],
|
|
16
16
|
default: () => ({ items: [], total: 0 }),
|
|
@@ -51,6 +51,10 @@ async function deleteNotification(id: string): Promise<void> {
|
|
|
51
51
|
</div>
|
|
52
52
|
|
|
53
53
|
<div class="cpub-notif-list">
|
|
54
|
+
<div v-if="pending" class="cpub-notif-loading">
|
|
55
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading notifications...
|
|
56
|
+
</div>
|
|
57
|
+
<template v-else>
|
|
54
58
|
<NotificationItem
|
|
55
59
|
v-for="n in filteredNotifications"
|
|
56
60
|
:key="n.id"
|
|
@@ -61,6 +65,7 @@ async function deleteNotification(id: string): Promise<void> {
|
|
|
61
65
|
<p class="cpub-empty-state-title">No notifications</p>
|
|
62
66
|
<p class="cpub-empty-state-desc">You're all caught up!</p>
|
|
63
67
|
</div>
|
|
68
|
+
</template>
|
|
64
69
|
</div>
|
|
65
70
|
</div>
|
|
66
71
|
</template>
|
|
@@ -84,6 +89,15 @@ async function deleteNotification(id: string): Promise<void> {
|
|
|
84
89
|
background: var(--surface);
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
.cpub-notif-loading {
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
justify-content: center;
|
|
96
|
+
gap: 8px;
|
|
97
|
+
padding: var(--space-8, 32px);
|
|
98
|
+
color: var(--text-faint);
|
|
99
|
+
}
|
|
100
|
+
|
|
87
101
|
@media (max-width: 768px) {
|
|
88
102
|
.cpub-notifications-page { padding: 16px 12px; }
|
|
89
103
|
.cpub-notif-header { flex-wrap: wrap; gap: 8px; }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const route = useRoute();
|
|
3
3
|
const slug = route.params.slug as string;
|
|
4
4
|
|
|
5
|
-
const { data: product } = useLazyFetch(`/api/products/${slug}`) as { data: Ref<Record<string, any> | null> };
|
|
5
|
+
const { data: product, pending } = useLazyFetch(`/api/products/${slug}`) as { data: Ref<Record<string, any> | null>; pending: Ref<boolean> };
|
|
6
6
|
const { data: projectsUsing } = useLazyFetch(`/api/products/${slug}/content`) as { data: Ref<any[] | null> };
|
|
7
7
|
|
|
8
8
|
useSeoMeta({
|
|
@@ -12,7 +12,10 @@ useSeoMeta({
|
|
|
12
12
|
</script>
|
|
13
13
|
|
|
14
14
|
<template>
|
|
15
|
-
<div v-if="
|
|
15
|
+
<div v-if="pending" style="padding: 32px; text-align: center; color: var(--text-faint);">
|
|
16
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading product...
|
|
17
|
+
</div>
|
|
18
|
+
<div v-else-if="product" class="product-detail">
|
|
16
19
|
<NuxtLink to="/products" class="cpub-back-link"><i class="fa-solid fa-arrow-left"></i> Products</NuxtLink>
|
|
17
20
|
|
|
18
21
|
<div class="product-layout">
|
|
@@ -15,7 +15,7 @@ const prefs = ref({
|
|
|
15
15
|
// Load current preferences from profile
|
|
16
16
|
import type { Serialized, UserProfile } from '@commonpub/server';
|
|
17
17
|
|
|
18
|
-
const { data: profile } = await useFetch<Serialized<UserProfile> & { notificationPrefs?: Record<string, boolean> }>('/api/profile');
|
|
18
|
+
const { data: profile, pending } = await useFetch<Serialized<UserProfile> & { notificationPrefs?: Record<string, boolean> }>('/api/profile');
|
|
19
19
|
if (profile.value?.notificationPrefs) {
|
|
20
20
|
const saved = profile.value.notificationPrefs;
|
|
21
21
|
for (const key of Object.keys(prefs.value)) {
|
|
@@ -54,6 +54,11 @@ async function handleSave(): Promise<void> {
|
|
|
54
54
|
<div>
|
|
55
55
|
<h2 class="cpub-section-title-lg">Notification Preferences</h2>
|
|
56
56
|
|
|
57
|
+
<div v-if="pending" style="padding: 32px; text-align: center; color: var(--text-faint);">
|
|
58
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i> Loading preferences...
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<template v-else>
|
|
57
62
|
<div v-for="(val, key) in prefs" :key="key" style="margin-bottom: 12px">
|
|
58
63
|
<label class="cpub-checkbox">
|
|
59
64
|
<input type="checkbox" v-model="prefs[key as keyof typeof prefs]" />
|
|
@@ -64,5 +69,6 @@ async function handleSave(): Promise<void> {
|
|
|
64
69
|
<button class="cpub-btn cpub-btn-primary cpub-btn-sm" style="margin-top: 16px" :disabled="saving" @click="handleSave">
|
|
65
70
|
{{ saving ? 'Saving...' : 'Save Preferences' }}
|
|
66
71
|
</button>
|
|
72
|
+
</template>
|
|
67
73
|
</div>
|
|
68
74
|
</template>
|
package/pages/tags/[slug].vue
CHANGED
|
@@ -13,7 +13,7 @@ const PAGE_SIZE = 20;
|
|
|
13
13
|
const loadingMore = ref(false);
|
|
14
14
|
const allLoaded = ref(false);
|
|
15
15
|
|
|
16
|
-
const { data: results } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
16
|
+
const { data: results, pending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
17
17
|
query: computed(() => ({
|
|
18
18
|
tag: tagSlug.value,
|
|
19
19
|
status: 'published',
|
|
@@ -61,7 +61,8 @@ async function loadMore(): Promise<void> {
|
|
|
61
61
|
</div>
|
|
62
62
|
</div>
|
|
63
63
|
|
|
64
|
-
<div v-if="
|
|
64
|
+
<div v-if="pending" class="cpub-empty-state"><p><i class="fa-solid fa-circle-notch fa-spin"></i> Loading...</p></div>
|
|
65
|
+
<div v-else-if="items.length" class="cpub-tag-grid">
|
|
65
66
|
<ContentCard
|
|
66
67
|
v-for="item in items"
|
|
67
68
|
:key="item.id"
|
package/pages/tags/index.vue
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
useSeoMeta({ title: `Tags — ${useSiteName()}`, description: 'Browse content by tags.' });
|
|
3
3
|
|
|
4
|
-
const { data: trending } = await useFetch<any>('/api/search/trending');
|
|
4
|
+
const { data: trending, pending } = await useFetch<any>('/api/search/trending');
|
|
5
5
|
|
|
6
6
|
// Extract unique tags from trending content
|
|
7
7
|
const tags = computed(() => {
|
|
@@ -27,7 +27,8 @@ const tags = computed(() => {
|
|
|
27
27
|
<h1 class="tags-title">Tags</h1>
|
|
28
28
|
<p class="tags-subtitle">Browse content by topic.</p>
|
|
29
29
|
|
|
30
|
-
<div v-if="
|
|
30
|
+
<div v-if="pending" class="tags-empty"><p><i class="fa-solid fa-circle-notch fa-spin"></i> Loading tags...</p></div>
|
|
31
|
+
<div v-else-if="tags.length" class="tags-cloud">
|
|
31
32
|
<NuxtLink
|
|
32
33
|
v-for="tag in tags"
|
|
33
34
|
:key="tag.name"
|
package/pages/videos/[id].vue
CHANGED
|
@@ -68,6 +68,15 @@ const authorInitial = computed(() => {
|
|
|
68
68
|
</div>
|
|
69
69
|
<p v-if="video.description" class="cpub-video-desc">{{ video.description }}</p>
|
|
70
70
|
|
|
71
|
+
<!-- Engagement -->
|
|
72
|
+
<EngagementBar
|
|
73
|
+
target-type="video"
|
|
74
|
+
:target-id="video.id"
|
|
75
|
+
:target-title="video.title"
|
|
76
|
+
:like-count="video.likeCount ?? 0"
|
|
77
|
+
:comment-count="video.commentCount ?? 0"
|
|
78
|
+
/>
|
|
79
|
+
|
|
71
80
|
<!-- Author -->
|
|
72
81
|
<div v-if="video.author" class="cpub-video-author">
|
|
73
82
|
<div class="cpub-video-author-av">
|
|
@@ -85,6 +94,11 @@ const authorInitial = computed(() => {
|
|
|
85
94
|
</div>
|
|
86
95
|
</div>
|
|
87
96
|
</div>
|
|
97
|
+
|
|
98
|
+
<!-- Comments -->
|
|
99
|
+
<div class="cpub-video-comments">
|
|
100
|
+
<CommentSection target-type="video" :target-id="video.id" />
|
|
101
|
+
</div>
|
|
88
102
|
</div>
|
|
89
103
|
<div v-else class="cpub-empty-state" style="padding: 64px">
|
|
90
104
|
<p class="cpub-empty-state-title">Video not found</p>
|
|
@@ -201,6 +215,10 @@ const authorInitial = computed(() => {
|
|
|
201
215
|
color: var(--accent);
|
|
202
216
|
}
|
|
203
217
|
|
|
218
|
+
.cpub-video-comments {
|
|
219
|
+
margin-top: 20px;
|
|
220
|
+
}
|
|
221
|
+
|
|
204
222
|
.cpub-link {
|
|
205
223
|
color: var(--accent);
|
|
206
224
|
text-decoration: none;
|
|
@@ -9,7 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
export default defineEventHandler(async (event) => {
|
|
10
10
|
requireAdmin(event);
|
|
11
11
|
|
|
12
|
-
const contentId =
|
|
12
|
+
const { id: contentId } = parseParams(event, { id: 'uuid' });
|
|
13
13
|
const body = await parseBody(event, z.object({
|
|
14
14
|
isFeatured: z.boolean().optional(),
|
|
15
15
|
}));
|
|
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
|
|
18
18
|
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const mirrorId =
|
|
21
|
+
const { id: mirrorId } = parseParams(event, { id: 'uuid' });
|
|
22
22
|
const db = useDB();
|
|
23
23
|
|
|
24
24
|
const mirror = await getMirror(db, mirrorId);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { contentItems, hubs, hubPosts } from '@commonpub/schema';
|
|
2
2
|
import { federateContent, federateHubPost, federateHubActor } from '@commonpub/server';
|
|
3
3
|
import { eq, isNull } from 'drizzle-orm';
|
|
4
|
+
import { z } from 'zod';
|
|
4
5
|
import { extractDomain } from '../../../utils/inbox';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -25,9 +26,12 @@ export default defineEventHandler(async (event) => {
|
|
|
25
26
|
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const body = await
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const body = await parseBody(event, z.object({
|
|
30
|
+
contentId: z.string().uuid().optional(),
|
|
31
|
+
hubsOnly: z.boolean().optional(),
|
|
32
|
+
}));
|
|
33
|
+
const contentId = body.contentId;
|
|
34
|
+
const hubsOnly = body.hubsOnly === true;
|
|
31
35
|
|
|
32
36
|
const db = useDB();
|
|
33
37
|
const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { federatedContent } from '@commonpub/schema';
|
|
1
|
+
import { repairFederatedContentTypes } from '@commonpub/server';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* POST /api/admin/federation/repair-types
|
|
@@ -11,47 +10,5 @@ export default defineEventHandler(async (event) => {
|
|
|
11
10
|
requireAdmin(event);
|
|
12
11
|
const db = useDB();
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
.select({
|
|
16
|
-
id: federatedContent.id,
|
|
17
|
-
objectUri: federatedContent.objectUri,
|
|
18
|
-
})
|
|
19
|
-
.from(federatedContent)
|
|
20
|
-
.where(and(
|
|
21
|
-
isNull(federatedContent.cpubType),
|
|
22
|
-
isNull(federatedContent.deletedAt),
|
|
23
|
-
))
|
|
24
|
-
.limit(500);
|
|
25
|
-
|
|
26
|
-
let updated = 0;
|
|
27
|
-
let errors = 0;
|
|
28
|
-
|
|
29
|
-
for (const row of rows) {
|
|
30
|
-
try {
|
|
31
|
-
const controller = new AbortController();
|
|
32
|
-
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
33
|
-
const response = await fetch(row.objectUri, {
|
|
34
|
-
headers: { Accept: 'application/activity+json, application/ld+json' },
|
|
35
|
-
signal: controller.signal,
|
|
36
|
-
});
|
|
37
|
-
clearTimeout(timeout);
|
|
38
|
-
|
|
39
|
-
if (!response.ok) { errors++; continue; }
|
|
40
|
-
|
|
41
|
-
const object = await response.json() as Record<string, unknown>;
|
|
42
|
-
const cpubType = typeof object['cpub:type'] === 'string' ? object['cpub:type'] : null;
|
|
43
|
-
|
|
44
|
-
if (cpubType) {
|
|
45
|
-
await db
|
|
46
|
-
.update(federatedContent)
|
|
47
|
-
.set({ cpubType, updatedAt: new Date() })
|
|
48
|
-
.where(eq(federatedContent.id, row.id));
|
|
49
|
-
updated++;
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
errors++;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return { total: rows.length, updated, errors };
|
|
13
|
+
return repairFederatedContentTypes(db);
|
|
57
14
|
});
|