@commonpub/layer 0.8.2 → 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 (76) 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/ArticleEditor.vue +19 -1
  12. package/components/editors/BlogEditor.vue +1 -1
  13. package/components/editors/DocsPageTree.vue +10 -0
  14. package/components/hub/HubHero.vue +1 -1
  15. package/composables/useSanitize.ts +112 -9
  16. package/layouts/default.vue +7 -7
  17. package/middleware/feature-gate.global.ts +24 -0
  18. package/package.json +8 -8
  19. package/pages/[type]/index.vue +4 -3
  20. package/pages/admin/audit.vue +3 -2
  21. package/pages/admin/federation.vue +9 -1
  22. package/pages/admin/index.vue +7 -1
  23. package/pages/admin/reports.vue +152 -36
  24. package/pages/admin/settings.vue +17 -5
  25. package/pages/admin/theme.vue +5 -3
  26. package/pages/auth/forgot-password.vue +35 -35
  27. package/pages/auth/login.vue +6 -5
  28. package/pages/auth/reset-password.vue +44 -32
  29. package/pages/contests/[slug]/edit.vue +238 -56
  30. package/pages/contests/[slug]/index.vue +54 -450
  31. package/pages/contests/[slug]/judge.vue +141 -53
  32. package/pages/contests/[slug]/results.vue +182 -0
  33. package/pages/contests/create.vue +64 -64
  34. package/pages/contests/index.vue +2 -1
  35. package/pages/docs/[siteSlug]/[...pagePath].vue +6 -5
  36. package/pages/docs/[siteSlug]/edit.vue +58 -2
  37. package/pages/docs/[siteSlug]/index.vue +6 -5
  38. package/pages/federated-hubs/[id]/posts/[postId].vue +2 -2
  39. package/pages/hubs/index.vue +3 -2
  40. package/pages/index.vue +25 -7
  41. package/pages/learn/index.vue +1 -1
  42. package/pages/mirror/[id].vue +3 -3
  43. package/pages/notifications.vue +15 -1
  44. package/pages/settings/notifications.vue +7 -1
  45. package/pages/tags/[slug].vue +3 -2
  46. package/pages/tags/index.vue +3 -2
  47. package/pages/videos/[id].vue +18 -0
  48. package/server/api/admin/content/[id].patch.ts +1 -1
  49. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  50. package/server/api/admin/federation/refederate.post.ts +7 -3
  51. package/server/api/admin/federation/repair-types.post.ts +2 -45
  52. package/server/api/admin/federation/retry.post.ts +7 -4
  53. package/server/api/admin/reports.get.ts +1 -0
  54. package/server/api/auth/sign-in-username.post.ts +42 -0
  55. package/server/api/content/[id]/products-sync.post.ts +7 -6
  56. package/server/api/contests/[slug]/entries/[entryId].delete.ts +14 -0
  57. package/server/api/contests/[slug]/entries.get.ts +6 -1
  58. package/server/api/contests/[slug]/judge.post.ts +8 -2
  59. package/server/api/docs/[siteSlug]/nav.get.ts +1 -1
  60. package/server/api/docs/[siteSlug]/pages/[pageId]/duplicate.post.ts +16 -0
  61. package/server/api/docs/[siteSlug]/pages/reorder.post.ts +4 -1
  62. package/server/api/docs/migrate-content.post.ts +1 -7
  63. package/server/api/federation/hub-follow-status.get.ts +2 -18
  64. package/server/api/federation/hub-follow.post.ts +9 -27
  65. package/server/api/federation/hub-post-like.post.ts +9 -98
  66. package/server/api/federation/hub-post-likes.get.ts +3 -13
  67. package/server/api/notifications/read.post.ts +6 -1
  68. package/server/api/search/index.get.ts +2 -2
  69. package/server/api/search/trending.get.ts +3 -3
  70. package/server/api/users/index.get.ts +9 -2
  71. package/server/middleware/content-ap.ts +2 -2
  72. package/server/routes/.well-known/webfinger.ts +2 -2
  73. package/theme/base.css +23 -0
  74. package/components/EditorPropertiesPanel.vue +0 -393
  75. package/components/views/BlogView.vue +0 -735
  76. 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>
@@ -97,6 +97,24 @@ const uploadedFiles = ref<UploadedAsset[]>([]);
97
97
  const uploadError = ref('');
98
98
  const uploading = ref(false);
99
99
 
100
+ // Load existing uploads on mount
101
+ onMounted(async () => {
102
+ try {
103
+ const files = await $fetch<Array<{ originalName: string; sizeBytes: number; mimeType: string; url: string }>>('/api/files/mine?limit=30');
104
+ uploadedFiles.value = files.map((f) => ({
105
+ name: f.originalName,
106
+ size: f.sizeBytes < 1024 * 1024
107
+ ? `${(f.sizeBytes / 1024).toFixed(0)} KB`
108
+ : `${(f.sizeBytes / 1024 / 1024).toFixed(1)} MB`,
109
+ type: f.mimeType.startsWith('image/') ? 'image' as const : 'file' as const,
110
+ url: f.url,
111
+ mimeType: f.mimeType,
112
+ }));
113
+ } catch {
114
+ // Not logged in or API unavailable — empty assets is fine
115
+ }
116
+ });
117
+
100
118
  function onAssetUpload(event: Event): void {
101
119
  const input = event.target as HTMLInputElement;
102
120
  if (!input.files?.length) return;
@@ -143,7 +161,7 @@ function insertAsset(asset: UploadedAsset): void {
143
161
  const idx = props.blockEditor.selectedBlockId.value
144
162
  ? props.blockEditor.getBlockIndex(props.blockEditor.selectedBlockId.value) + 1
145
163
  : undefined;
146
- props.blockEditor.addBlock('image', { url: asset.url, alt: asset.name }, idx);
164
+ props.blockEditor.addBlock('image', { src: asset.url, alt: asset.name }, idx);
147
165
  } else {
148
166
  // Copy URL to clipboard for non-image files
149
167
  navigator.clipboard.writeText(asset.url).catch(() => {});
@@ -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: #fff; }
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%, #006b6b 50%, var(--accent-border) 100%);
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
- // HTML sanitization for v-html bindings.
2
- //
3
- // Content is sanitized at the API/storage layer:
4
- // - Local content: structured blocks via TipTap (no raw HTML injection)
5
- // - Federated content: sanitized on ingest (inboxHandlers.ts sanitizeHtml)
6
- //
7
- // This composable provides the interface for components that use v-html,
8
- // passing content through since it's already clean.
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, '&amp;')
64
+ .replace(/"/g, '&quot;')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;');
67
+ }
9
68
 
10
69
  /** Sanitize HTML for safe rendering via v-html */
11
70
  export function sanitizeBlockHtml(html: string): string {
12
- return html;
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 */
@@ -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-dot" />
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-dot" />
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-dot { position: absolute; top: 5px; right: 5px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); border: 1.5px solid var(--surface); }
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.2",
3
+ "version": "0.8.4",
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",
53
- "@commonpub/editor": "0.7.9",
56
+ "@commonpub/auth": "0.5.1",
54
57
  "@commonpub/config": "0.9.1",
55
- "@commonpub/explainer": "0.7.10",
56
- "@commonpub/docs": "0.6.2",
57
- "@commonpub/auth": "0.5.0",
58
+ "@commonpub/protocol": "0.9.9",
58
59
  "@commonpub/ui": "0.8.5",
59
- "@commonpub/schema": "0.9.6",
60
- "@commonpub/protocol": "0.9.8",
61
- "@commonpub/server": "2.28.0",
60
+ "@commonpub/editor": "0.7.9",
61
+ "@commonpub/docs": "0.6.2",
62
62
  "@commonpub/learning": "0.5.0"
63
63
  },
64
64
  "devDependencies": {
@@ -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
- <div class="cpub-listing-grid" v-if="data?.items?.length">
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" v-else>No {{ contentType }}s published yet.</p>
45
+ <p v-else class="cpub-listing-empty">No {{ contentType }}s published yet.</p>
45
46
  </div>
46
47
  </template>
47
48
 
@@ -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
- <table class="admin-table" v-if="logs.length">
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
 
@@ -178,6 +178,12 @@ async function refederate(): Promise<void> {
178
178
  <div>
179
179
  <h1 class="cpub-admin-title">Federation</h1>
180
180
 
181
+ <!-- Loading -->
182
+ <div v-if="pending" class="cpub-fed-loading">
183
+ <i class="fa-solid fa-circle-notch fa-spin"></i> Loading...
184
+ </div>
185
+ <template v-else>
186
+
181
187
  <!-- Stats -->
182
188
  <div class="cpub-fed-stats">
183
189
  <div class="cpub-fed-stat">
@@ -395,10 +401,12 @@ async function refederate(): Promise<void> {
395
401
  </div>
396
402
  </div>
397
403
  </div>
404
+ </template>
398
405
  </div>
399
406
  </template>
400
407
 
401
408
  <style scoped>
409
+ .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
410
  .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
411
 
404
412
  .cpub-fed-stats {
@@ -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); }