@commonpub/layer 0.8.3 → 0.8.4

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.
Files changed (75) hide show
  1. package/components/ContentCard.vue +1 -1
  2. package/components/ImageUpload.vue +1 -1
  3. package/components/ShareToHubModal.vue +1 -1
  4. package/components/blocks/BlockCodeView.vue +26 -25
  5. package/components/contest/ContestEntries.vue +112 -0
  6. package/components/contest/ContestHero.vue +204 -0
  7. package/components/contest/ContestJudges.vue +51 -0
  8. package/components/contest/ContestPrizes.vue +82 -0
  9. package/components/contest/ContestRules.vue +34 -0
  10. package/components/contest/ContestSidebar.vue +83 -0
  11. package/components/editors/BlogEditor.vue +1 -1
  12. package/components/editors/DocsPageTree.vue +10 -0
  13. package/components/hub/HubHero.vue +1 -1
  14. package/composables/useSanitize.ts +112 -9
  15. package/layouts/default.vue +7 -7
  16. package/middleware/feature-gate.global.ts +24 -0
  17. package/package.json +9 -9
  18. package/pages/[type]/index.vue +4 -3
  19. package/pages/admin/audit.vue +3 -2
  20. package/pages/admin/federation.vue +9 -1
  21. package/pages/admin/index.vue +7 -1
  22. package/pages/admin/reports.vue +152 -36
  23. package/pages/admin/settings.vue +17 -5
  24. package/pages/admin/theme.vue +5 -3
  25. package/pages/auth/forgot-password.vue +35 -35
  26. package/pages/auth/login.vue +6 -5
  27. package/pages/auth/reset-password.vue +44 -32
  28. package/pages/contests/[slug]/edit.vue +238 -56
  29. package/pages/contests/[slug]/index.vue +54 -450
  30. package/pages/contests/[slug]/judge.vue +141 -53
  31. package/pages/contests/[slug]/results.vue +182 -0
  32. package/pages/contests/create.vue +64 -64
  33. package/pages/contests/index.vue +2 -1
  34. package/pages/docs/[siteSlug]/[...pagePath].vue +6 -5
  35. package/pages/docs/[siteSlug]/edit.vue +58 -2
  36. package/pages/docs/[siteSlug]/index.vue +6 -5
  37. package/pages/federated-hubs/[id]/posts/[postId].vue +2 -2
  38. package/pages/hubs/index.vue +3 -2
  39. package/pages/index.vue +25 -7
  40. package/pages/learn/index.vue +1 -1
  41. package/pages/mirror/[id].vue +3 -3
  42. package/pages/notifications.vue +15 -1
  43. package/pages/settings/notifications.vue +7 -1
  44. package/pages/tags/[slug].vue +3 -2
  45. package/pages/tags/index.vue +3 -2
  46. package/pages/videos/[id].vue +18 -0
  47. package/server/api/admin/content/[id].patch.ts +1 -1
  48. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  49. package/server/api/admin/federation/refederate.post.ts +7 -3
  50. package/server/api/admin/federation/repair-types.post.ts +2 -45
  51. package/server/api/admin/federation/retry.post.ts +7 -4
  52. package/server/api/admin/reports.get.ts +1 -0
  53. package/server/api/auth/sign-in-username.post.ts +42 -0
  54. package/server/api/content/[id]/products-sync.post.ts +7 -6
  55. package/server/api/contests/[slug]/entries/[entryId].delete.ts +14 -0
  56. package/server/api/contests/[slug]/entries.get.ts +6 -1
  57. package/server/api/contests/[slug]/judge.post.ts +8 -2
  58. package/server/api/docs/[siteSlug]/nav.get.ts +1 -1
  59. package/server/api/docs/[siteSlug]/pages/[pageId]/duplicate.post.ts +16 -0
  60. package/server/api/docs/[siteSlug]/pages/reorder.post.ts +4 -1
  61. package/server/api/docs/migrate-content.post.ts +1 -7
  62. package/server/api/federation/hub-follow-status.get.ts +2 -18
  63. package/server/api/federation/hub-follow.post.ts +9 -27
  64. package/server/api/federation/hub-post-like.post.ts +9 -98
  65. package/server/api/federation/hub-post-likes.get.ts +3 -13
  66. package/server/api/notifications/read.post.ts +6 -1
  67. package/server/api/search/index.get.ts +2 -2
  68. package/server/api/search/trending.get.ts +3 -3
  69. package/server/api/users/index.get.ts +9 -2
  70. package/server/middleware/content-ap.ts +2 -2
  71. package/server/routes/.well-known/webfinger.ts +2 -2
  72. package/theme/base.css +23 -0
  73. package/components/EditorPropertiesPanel.vue +0 -393
  74. package/components/views/BlogView.vue +0 -735
  75. package/server/api/resolve-identity.post.ts +0 -34
@@ -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-red': contest.status === 'completed',
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 via sanitizeHtml() before storage -->
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">
@@ -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="hubs.length" class="cpub-hubs-grid">
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="feed?.items?.length" class="cpub-content-grid">
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-stats-grid">
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 && communities?.items?.length" class="cpub-sb-card">
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>
@@ -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 -->
@@ -43,7 +43,7 @@ if (originUrl.value) {
43
43
  });
44
44
  }
45
45
 
46
- /** Strip HTML tags from remote actor bio (unsanitized XSS risk with v-html) */
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 (inboxHandlers.ts sanitizeHtml). Safe for v-html. -->
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>
@@ -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; }
@@ -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>
@@ -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="items.length" class="cpub-tag-grid">
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"
@@ -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="tags.length" class="tags-cloud">
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"
@@ -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 = getRouterParam(event, 'id')!;
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 = getRouterParam(event, 'id')!;
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 readBody(event);
29
- const contentId = body?.contentId as string | undefined;
30
- const hubsOnly = body?.hubsOnly === true;
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 { eq, and, isNull } from 'drizzle-orm';
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
- const rows = await db
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
  });