@commonpub/layer 0.3.17 → 0.3.19

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.
@@ -7,6 +7,7 @@ defineProps<{
7
7
  senderAvatarUrl?: string | null;
8
8
  body: string;
9
9
  createdAt: string;
10
+ readAt?: string | null;
10
11
  }>;
11
12
  currentUserId: string;
12
13
  }>();
@@ -39,9 +40,15 @@ function handleSend(): void {
39
40
  <span v-if="msg.senderName" class="cpub-msg-name">{{ msg.senderName }}</span>
40
41
  </div>
41
42
  <div class="cpub-msg-bubble">{{ msg.body }}</div>
42
- <time class="cpub-msg-time">
43
- {{ new Date(msg.createdAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }}
44
- </time>
43
+ <div class="cpub-msg-meta">
44
+ <time class="cpub-msg-time">
45
+ {{ new Date(msg.createdAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }}
46
+ </time>
47
+ <span v-if="msg.senderId === currentUserId" class="cpub-msg-receipt" :class="{ 'cpub-msg-read': msg.readAt }" :title="msg.readAt ? `Read ${new Date(msg.readAt).toLocaleString()}` : 'Sent'">
48
+ <i :class="msg.readAt ? 'fa-solid fa-check-double' : 'fa-solid fa-check'" aria-hidden="true"></i>
49
+ <span class="sr-only">{{ msg.readAt ? 'Read' : 'Sent' }}</span>
50
+ </span>
51
+ </div>
45
52
  </div>
46
53
  <p v-if="!messages.length" class="cpub-thread-empty">No messages yet. Say hello!</p>
47
54
  </div>
@@ -54,7 +61,7 @@ function handleSend(): void {
54
61
  aria-label="Message"
55
62
  @keyup.enter="handleSend"
56
63
  />
57
- <button class="cpub-btn cpub-btn-primary" :disabled="!newMessage.trim()" @click="handleSend">
64
+ <button class="cpub-btn cpub-btn-primary" :disabled="!newMessage.trim()" aria-label="Send message" @click="handleSend">
58
65
  <i class="fa-solid fa-paper-plane"></i>
59
66
  </button>
60
67
  </div>
@@ -125,17 +132,47 @@ function handleSend(): void {
125
132
  line-height: 1.5;
126
133
  }
127
134
 
128
- .cpub-msg.own .cpub-msg-bubble {
135
+ .cpub-msg.cpub-msg-own .cpub-msg-bubble {
129
136
  background: var(--accent-bg);
130
- border-color: var(--accent-border);
137
+ border-color: var(--accent-border, var(--border));
138
+ }
139
+
140
+ .cpub-msg-meta {
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 4px;
144
+ margin-top: 2px;
145
+ }
146
+
147
+ .cpub-msg.cpub-msg-own .cpub-msg-meta {
148
+ justify-content: flex-end;
131
149
  }
132
150
 
133
151
  .cpub-msg-time {
134
152
  font-size: 9px;
135
153
  color: var(--text-faint);
136
154
  font-family: var(--font-mono);
137
- margin-top: 2px;
138
- display: block;
155
+ }
156
+
157
+ .cpub-msg-receipt {
158
+ font-size: 9px;
159
+ color: var(--text-faint);
160
+ }
161
+
162
+ .cpub-msg-receipt.cpub-msg-read {
163
+ color: var(--accent);
164
+ }
165
+
166
+ .sr-only {
167
+ position: absolute;
168
+ width: 1px;
169
+ height: 1px;
170
+ padding: 0;
171
+ margin: -1px;
172
+ overflow: hidden;
173
+ clip: rect(0, 0, 0, 0);
174
+ white-space: nowrap;
175
+ border-width: 0;
139
176
  }
140
177
 
141
178
  .cpub-thread-empty {
@@ -132,15 +132,15 @@ useJsonLd({
132
132
  <div class="cpub-engagement-row">
133
133
  <div class="cpub-eng-stat"><i class="fa-regular fa-eye"></i> {{ content.viewCount?.toLocaleString() || '0' }} views</div>
134
134
  <div class="cpub-eng-sep"></div>
135
- <button class="cpub-eng-btn" :class="{ liked }" @click="toggleLike">
135
+ <button class="cpub-eng-btn" :class="{ liked }" :aria-label="liked ? 'Unlike' : 'Like'" :aria-pressed="liked" @click="toggleLike">
136
136
  <i class="fa-solid fa-heart"></i> {{ likeCount }}
137
137
  </button>
138
- <button class="cpub-eng-btn" :class="{ bookmarked }" @click="toggleBookmark">
138
+ <button class="cpub-eng-btn" :class="{ bookmarked }" :aria-label="bookmarked ? 'Remove bookmark' : 'Bookmark'" :aria-pressed="bookmarked" @click="toggleBookmark">
139
139
  <i class="fa-solid fa-bookmark"></i> Bookmark
140
140
  </button>
141
141
  <div class="cpub-eng-spacer"></div>
142
- <button class="cpub-eng-btn" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
143
- <button class="cpub-eng-btn"><i class="fa-solid fa-ellipsis"></i></button>
142
+ <button class="cpub-eng-btn" aria-label="Share" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
143
+ <button class="cpub-eng-btn" aria-label="More options"><i class="fa-solid fa-ellipsis"></i></button>
144
144
  </div>
145
145
 
146
146
  <!-- ARTICLE BODY WITH TOC SIDEBAR -->
@@ -85,15 +85,15 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
85
85
  <div class="cpub-engagement-row">
86
86
  <div class="cpub-eng-stat"><i class="fa-regular fa-eye"></i> {{ content.viewCount?.toLocaleString() || '0' }} views</div>
87
87
  <div class="cpub-eng-sep"></div>
88
- <button class="cpub-eng-btn" :class="{ liked }" @click="toggleLike">
88
+ <button class="cpub-eng-btn" :class="{ liked }" :aria-label="liked ? 'Unlike' : 'Like'" :aria-pressed="liked" @click="toggleLike">
89
89
  <i class="fa-solid fa-heart"></i> {{ likeCount }}
90
90
  </button>
91
- <button class="cpub-eng-btn" :class="{ bookmarked }" @click="toggleBookmark">
91
+ <button class="cpub-eng-btn" :class="{ bookmarked }" :aria-label="bookmarked ? 'Remove bookmark' : 'Bookmark'" :aria-pressed="bookmarked" @click="toggleBookmark">
92
92
  <i class="fa-solid fa-bookmark"></i> Bookmark
93
93
  </button>
94
94
  <div class="cpub-eng-spacer"></div>
95
- <button class="cpub-eng-btn" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
96
- <button class="cpub-eng-btn"><i class="fa-solid fa-ellipsis"></i></button>
95
+ <button class="cpub-eng-btn" aria-label="Share" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
96
+ <button class="cpub-eng-btn" aria-label="More options"><i class="fa-solid fa-ellipsis"></i></button>
97
97
  </div>
98
98
 
99
99
  <!-- BLOG BODY (PROSE) -->
@@ -168,21 +168,21 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
168
168
  <span class="cpub-progress-text">Section {{ activeSection + 1 }} of {{ totalSections }}</span>
169
169
  <div class="cpub-topbar-divider"></div>
170
170
  <div class="cpub-nav-btn-group">
171
- <button class="cpub-icon-btn" :disabled="activeSection === 0" title="Previous section" @click="prevSection">
171
+ <button class="cpub-icon-btn" :disabled="activeSection === 0" aria-label="Previous section" @click="prevSection">
172
172
  <i class="fa-solid fa-arrow-left"></i>
173
173
  </button>
174
- <button class="cpub-icon-btn" :disabled="activeSection === totalSections - 1" title="Next section" @click="nextSection">
174
+ <button class="cpub-icon-btn" :disabled="activeSection === totalSections - 1" aria-label="Next section" @click="nextSection">
175
175
  <i class="fa-solid fa-arrow-right"></i>
176
176
  </button>
177
177
  </div>
178
178
  <div class="cpub-topbar-divider"></div>
179
- <button class="cpub-icon-btn" :class="{ active: liked }" title="Like" @click="toggleLike">
179
+ <button class="cpub-icon-btn" :class="{ active: liked }" :aria-label="liked ? 'Unlike' : 'Like'" :aria-pressed="liked" @click="toggleLike">
180
180
  <i :class="liked ? 'fa-solid fa-heart' : 'fa-regular fa-heart'"></i>
181
181
  </button>
182
- <button class="cpub-icon-btn" :class="{ active: bookmarked }" title="Bookmark" @click="toggleBookmark">
182
+ <button class="cpub-icon-btn" :class="{ active: bookmarked }" :aria-label="bookmarked ? 'Remove bookmark' : 'Bookmark'" :aria-pressed="bookmarked" @click="toggleBookmark">
183
183
  <i :class="bookmarked ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark'"></i>
184
184
  </button>
185
- <button class="cpub-icon-btn" title="Share" @click="share">
185
+ <button class="cpub-icon-btn" aria-label="Share" @click="share">
186
186
  <i class="fa-solid fa-arrow-up-from-bracket"></i>
187
187
  </button>
188
188
  <NuxtLink
@@ -208,14 +208,14 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
208
208
  class="cpub-toc-item"
209
209
  :class="{ completed: completedSections.has(i), active: activeSection === i }"
210
210
  >
211
- <a @click="goToSection(i)">
211
+ <button type="button" :aria-label="`Go to section ${i + 1}: ${section.title}`" @click="goToSection(i)">
212
212
  <span class="cpub-toc-icon">
213
213
  <i v-if="completedSections.has(i)" class="fa-solid fa-check"></i>
214
214
  <i v-else-if="activeSection === i" class="fa-solid fa-arrow-right"></i>
215
215
  </span>
216
216
  <span class="cpub-toc-num">{{ String(i + 1).padStart(2, '0') }}</span>
217
217
  <span class="cpub-toc-label">{{ section.title }}</span>
218
- </a>
218
+ </button>
219
219
  </li>
220
220
  </ul>
221
221
 
@@ -291,14 +291,17 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
291
291
  </button>
292
292
  <div v-else></div>
293
293
 
294
- <div class="cpub-progress-dots">
295
- <div
294
+ <div class="cpub-progress-dots" role="group" aria-label="Section progress">
295
+ <button
296
296
  v-for="(_, i) in totalSections"
297
297
  :key="i"
298
+ type="button"
298
299
  class="cpub-dot"
299
300
  :class="{ done: completedSections.has(i), active: i === activeSection }"
301
+ :aria-label="`Section ${i + 1}`"
302
+ :aria-current="i === activeSection ? 'step' : undefined"
300
303
  @click="goToSection(i)"
301
- ></div>
304
+ ></button>
302
305
  </div>
303
306
 
304
307
  <button v-if="activeSection < totalSections - 1" class="cpub-next-btn" @click="nextSection">
@@ -430,22 +433,26 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
430
433
  border-bottom: 1px solid var(--border);
431
434
  }
432
435
  .cpub-toc-list { list-style: none; padding: 6px 0; }
433
- .cpub-toc-item a {
436
+ .cpub-toc-item button {
434
437
  display: flex;
435
438
  align-items: center;
436
439
  gap: 8px;
437
440
  padding: 8px 14px;
438
- text-decoration: none;
441
+ width: 100%;
442
+ background: none;
443
+ border: none;
444
+ text-align: left;
439
445
  color: var(--text-dim);
440
446
  font-size: 12px;
447
+ font-family: inherit;
441
448
  line-height: 1.4;
442
449
  border-left: 3px solid transparent;
443
450
  transition: background 0.1s, color 0.1s, border-color 0.1s;
444
451
  cursor: pointer;
445
452
  }
446
- .cpub-toc-item a:hover { background: var(--surface2); color: var(--text); }
447
- .cpub-toc-item.active a { background: var(--accent-bg); border-left-color: var(--accent); color: var(--accent); font-weight: 500; }
448
- .cpub-toc-item.completed a { color: var(--text-dim); }
453
+ .cpub-toc-item button:hover { background: var(--surface2); color: var(--text); }
454
+ .cpub-toc-item.active button { background: var(--accent-bg); border-left-color: var(--accent); color: var(--accent); font-weight: 500; }
455
+ .cpub-toc-item.completed button { color: var(--text-dim); }
449
456
  .cpub-toc-icon { width: 14px; font-size: 10px; flex-shrink: 0; text-align: center; }
450
457
  .cpub-toc-item.completed .cpub-toc-icon { color: var(--green); }
451
458
  .cpub-toc-item.active .cpub-toc-icon { color: var(--accent); }
@@ -669,6 +676,8 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
669
676
  .cpub-dot {
670
677
  width: 7px;
671
678
  height: 7px;
679
+ padding: 0;
680
+ border: none;
672
681
  border-radius: 50%;
673
682
  background: var(--border2);
674
683
  transition: background 0.15s, transform 0.15s;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -45,14 +45,14 @@
45
45
  "vue-router": "^4.3.0",
46
46
  "zod": "^4.3.6",
47
47
  "@commonpub/auth": "0.5.0",
48
- "@commonpub/config": "0.7.0",
49
48
  "@commonpub/docs": "0.5.2",
49
+ "@commonpub/config": "0.7.0",
50
50
  "@commonpub/editor": "0.5.0",
51
- "@commonpub/protocol": "0.9.4",
52
- "@commonpub/server": "2.12.1",
53
51
  "@commonpub/learning": "0.5.0",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/schema": "0.8.12"
52
+ "@commonpub/protocol": "0.9.4",
53
+ "@commonpub/server": "2.13.0",
54
+ "@commonpub/schema": "0.8.12",
55
+ "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -7,6 +7,7 @@ useSeoMeta({
7
7
  });
8
8
 
9
9
  const { signIn } = useAuth();
10
+ const { federation } = useFeatures();
10
11
  const route = useRoute();
11
12
 
12
13
  const identity = ref('');
@@ -14,6 +15,18 @@ const password = ref('');
14
15
  const error = ref('');
15
16
  const loading = ref(false);
16
17
 
18
+ // Federated login state
19
+ const federatedDomain = ref('');
20
+ const federatedLoading = ref(false);
21
+ const federatedError = ref('');
22
+
23
+ // Federated callback context — present when redirected back from OAuth callback.
24
+ // Only an opaque linkToken is passed; the verified identity stays server-side.
25
+ const federatedLinkToken = computed(() => {
26
+ if (route.query.federated !== 'true') return null;
27
+ return (route.query.linkToken as string) || null;
28
+ });
29
+
17
30
  const redirectTo = computed(() => {
18
31
  const raw = (route.query.redirect as string) || '/';
19
32
  if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
@@ -25,15 +38,27 @@ async function handleSubmit(): Promise<void> {
25
38
  loading.value = true;
26
39
 
27
40
  try {
28
- // Step 1: Resolve username to email if needed (server-side)
29
- const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
30
- method: 'POST',
31
- body: { identity: identity.value },
32
- });
33
-
34
- // Step 2: Sign in with Better Auth (sets cookies directly)
35
- await signIn(email, password.value);
36
- await navigateTo(redirectTo.value);
41
+ if (federatedLinkToken.value) {
42
+ // Linking flow: authenticate + link federated account in one step
43
+ await $fetch('/api/auth/federated/link', {
44
+ method: 'POST',
45
+ body: {
46
+ identity: identity.value,
47
+ password: password.value,
48
+ linkToken: federatedLinkToken.value,
49
+ },
50
+ credentials: 'include',
51
+ });
52
+ await navigateTo('/dashboard');
53
+ } else {
54
+ // Normal login flow
55
+ const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
56
+ method: 'POST',
57
+ body: { identity: identity.value },
58
+ });
59
+ await signIn(email, password.value);
60
+ await navigateTo(redirectTo.value);
61
+ }
37
62
  } catch (err: unknown) {
38
63
  const fetchErr = err as { statusCode?: number; data?: { message?: string; statusMessage?: string } };
39
64
  if (fetchErr?.statusCode === 503) {
@@ -45,12 +70,48 @@ async function handleSubmit(): Promise<void> {
45
70
  loading.value = false;
46
71
  }
47
72
  }
73
+
74
+ async function handleFederatedLogin(): Promise<void> {
75
+ federatedError.value = '';
76
+ const domain = federatedDomain.value.trim().toLowerCase();
77
+ if (!domain) return;
78
+
79
+ federatedLoading.value = true;
80
+
81
+ try {
82
+ const result = await $fetch<{ authorizationUrl: string }>('/api/auth/federated/login', {
83
+ method: 'POST',
84
+ body: { instanceDomain: domain },
85
+ });
86
+ // Redirect to the remote instance's OAuth consent page
87
+ window.location.href = result.authorizationUrl;
88
+ } catch (err: unknown) {
89
+ const fetchErr = err as { statusCode?: number; data?: { message?: string; statusMessage?: string } };
90
+ if (fetchErr?.statusCode === 403) {
91
+ federatedError.value = `${domain} is not a trusted instance.`;
92
+ } else if (fetchErr?.statusCode === 502) {
93
+ federatedError.value = `Could not connect to ${domain}. Check the domain and try again.`;
94
+ } else {
95
+ federatedError.value = fetchErr?.data?.statusMessage || fetchErr?.data?.message || 'Failed to initiate federated login.';
96
+ }
97
+ } finally {
98
+ federatedLoading.value = false;
99
+ }
100
+ }
48
101
  </script>
49
102
 
50
103
  <template>
51
104
  <div class="login-page">
52
105
  <h1 class="login-title">Log in</h1>
53
106
 
107
+ <!-- Federated context banner — shown when linking an account -->
108
+ <div v-if="federatedLinkToken" class="cpub-federated-banner" role="status">
109
+ <p class="cpub-federated-banner-text">
110
+ Link your federated identity to a local account.
111
+ Log in below to complete the link.
112
+ </p>
113
+ </div>
114
+
54
115
  <form class="login-form" @submit.prevent="handleSubmit" aria-label="Login form">
55
116
  <div v-if="error" class="form-error" role="alert">{{ error }}</div>
56
117
 
@@ -81,12 +142,44 @@ async function handleSubmit(): Promise<void> {
81
142
  </div>
82
143
 
83
144
  <button type="submit" class="submit-btn" :disabled="loading">
84
- {{ loading ? 'Logging in...' : 'Log in' }}
145
+ {{ loading
146
+ ? 'Logging in...'
147
+ : federatedLinkToken
148
+ ? 'Log in & Link Account'
149
+ : 'Log in'
150
+ }}
85
151
  </button>
86
152
 
87
153
  <NuxtLink to="/auth/forgot-password" class="forgot-link">Forgot your password?</NuxtLink>
88
154
  </form>
89
155
 
156
+ <!-- Federated login section — only shown when federation is enabled -->
157
+ <div v-if="federation && !federatedLinkToken" class="cpub-federated-section">
158
+ <div class="cpub-federated-divider">
159
+ <span class="cpub-federated-divider-text">or</span>
160
+ </div>
161
+
162
+ <form class="cpub-federated-form" @submit.prevent="handleFederatedLogin" aria-label="Sign in with another instance">
163
+ <div v-if="federatedError" class="form-error" role="alert">{{ federatedError }}</div>
164
+
165
+ <label for="federated-domain" class="field-label">Sign in with another instance</label>
166
+ <div class="cpub-federated-input-group">
167
+ <input
168
+ id="federated-domain"
169
+ v-model="federatedDomain"
170
+ type="text"
171
+ class="field-input"
172
+ placeholder="instance.example.com"
173
+ required
174
+ autocomplete="off"
175
+ />
176
+ <button type="submit" class="cpub-federated-btn" :disabled="federatedLoading" aria-label="Sign in with remote instance">
177
+ {{ federatedLoading ? 'Connecting...' : 'Go' }}
178
+ </button>
179
+ </div>
180
+ </form>
181
+ </div>
182
+
90
183
  <p class="login-footer">
91
184
  Don't have an account?
92
185
  <NuxtLink to="/auth/register">Register</NuxtLink>
@@ -213,4 +306,91 @@ async function handleSubmit(): Promise<void> {
213
306
  color: var(--accent);
214
307
  text-decoration: underline;
215
308
  }
309
+
310
+ /* Federated login */
311
+ .cpub-federated-banner {
312
+ padding: var(--space-3);
313
+ background: var(--blue-bg, var(--surface-raised));
314
+ border: var(--border-width-default) solid var(--accent);
315
+ border-radius: var(--radius);
316
+ margin-bottom: var(--space-4);
317
+ }
318
+
319
+ .cpub-federated-banner-text {
320
+ font-size: 13px;
321
+ color: var(--text);
322
+ margin: 0;
323
+ line-height: 1.5;
324
+ }
325
+
326
+ .cpub-federated-banner-text code {
327
+ font-family: var(--font-mono);
328
+ font-size: 12px;
329
+ }
330
+
331
+ .cpub-federated-section {
332
+ margin-top: var(--space-5);
333
+ }
334
+
335
+ .cpub-federated-divider {
336
+ display: flex;
337
+ align-items: center;
338
+ gap: var(--space-3);
339
+ margin-bottom: var(--space-4);
340
+ }
341
+
342
+ .cpub-federated-divider::before,
343
+ .cpub-federated-divider::after {
344
+ content: '';
345
+ flex: 1;
346
+ height: 1px;
347
+ background: var(--border);
348
+ }
349
+
350
+ .cpub-federated-divider-text {
351
+ font-size: 11px;
352
+ font-family: var(--font-mono);
353
+ text-transform: uppercase;
354
+ letter-spacing: 0.06em;
355
+ color: var(--text-faint);
356
+ }
357
+
358
+ .cpub-federated-form {
359
+ display: flex;
360
+ flex-direction: column;
361
+ gap: var(--space-3);
362
+ }
363
+
364
+ .cpub-federated-input-group {
365
+ display: flex;
366
+ gap: var(--space-2);
367
+ }
368
+
369
+ .cpub-federated-input-group .field-input {
370
+ flex: 1;
371
+ }
372
+
373
+ .cpub-federated-btn {
374
+ padding: 7px 14px;
375
+ background: var(--surface-raised);
376
+ color: var(--text);
377
+ border: var(--border-width-default) solid var(--border);
378
+ border-radius: var(--radius);
379
+ font-size: 13px;
380
+ font-weight: 500;
381
+ font-family: var(--font-sans);
382
+ cursor: pointer;
383
+ white-space: nowrap;
384
+ transition: all 0.15s;
385
+ }
386
+
387
+ .cpub-federated-btn:hover:not(:disabled) {
388
+ border-color: var(--accent);
389
+ color: var(--accent);
390
+ }
391
+
392
+ .cpub-federated-btn:disabled {
393
+ opacity: 0.7;
394
+ cursor: not-allowed;
395
+ }
216
396
  </style>
@@ -8,9 +8,12 @@ useSeoMeta({
8
8
 
9
9
  const { user } = useAuth();
10
10
  const { learning: learningEnabled } = useFeatures();
11
+ const { enabledTypeMeta } = useContentTypes();
11
12
  const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
12
13
 
13
14
  const activeTab = ref<'content' | 'bookmarks' | 'learning'>('content');
15
+ const contentSort = ref<'newest' | 'oldest' | 'popular'>('newest');
16
+ const contentTypeFilter = ref('');
14
17
 
15
18
  // My content (all statuses)
16
19
  const { data: myContent, status: contentStatus } = await useFetch('/api/content', {
@@ -18,6 +21,11 @@ const { data: myContent, status: contentStatus } = await useFetch('/api/content'
18
21
  headers: reqHeaders,
19
22
  });
20
23
 
24
+ const contentTypeOptions = computed(() => [
25
+ { value: '', label: 'All types' },
26
+ ...enabledTypeMeta.value.map(m => ({ value: m.type, label: m.plural })),
27
+ ]);
28
+
21
29
  // Bookmarks
22
30
  const { data: bookmarkData } = await useFetch('/api/social/bookmarks', {
23
31
  query: { limit: 10 },
@@ -40,17 +48,42 @@ const { data: notifCount } = await useFetch('/api/notifications/count', {
40
48
 
41
49
  const toast = useToast();
42
50
 
51
+ function filterByType<T extends { type: string }>(items: T[]): T[] {
52
+ if (!contentTypeFilter.value) return items;
53
+ return items.filter((i) => i.type === contentTypeFilter.value);
54
+ }
55
+
56
+ function sortItems<T extends { createdAt: string; viewCount?: number; likeCount?: number }>(items: T[]): T[] {
57
+ const sorted = [...items];
58
+ if (contentSort.value === 'oldest') {
59
+ sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
60
+ } else if (contentSort.value === 'popular') {
61
+ sorted.sort((a, b) => ((b.viewCount ?? 0) + (b.likeCount ?? 0)) - ((a.viewCount ?? 0) + (a.likeCount ?? 0)));
62
+ } else {
63
+ sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
64
+ }
65
+ return sorted;
66
+ }
67
+
43
68
  const drafts = computed(() =>
44
- (myContent.value?.items ?? []).filter((i) => i.status === 'draft'),
69
+ sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'))),
45
70
  );
46
71
  const published = computed(() =>
47
- (myContent.value?.items ?? []).filter((i) => i.status === 'published'),
72
+ sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'))),
73
+ );
74
+
75
+ // Stats use ALL items (unfiltered) so totals don't change with filter selection
76
+ const allPublished = computed(() =>
77
+ (myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'),
78
+ );
79
+ const allDrafts = computed(() =>
80
+ (myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'),
48
81
  );
49
82
  const totalViews = computed(() =>
50
- published.value.reduce((sum, item) => sum + (item.viewCount ?? 0), 0),
83
+ allPublished.value.reduce((sum, item) => sum + (item.viewCount ?? 0), 0),
51
84
  );
52
85
  const totalLikes = computed(() =>
53
- published.value.reduce((sum, item) => sum + (item.likeCount ?? 0), 0),
86
+ allPublished.value.reduce((sum, item) => sum + (item.likeCount ?? 0), 0),
54
87
  );
55
88
 
56
89
  // Content actions
@@ -97,11 +130,11 @@ async function deleteItem(id: string, title: string): Promise<void> {
97
130
  <!-- Stats row -->
98
131
  <div class="cpub-dash-stats">
99
132
  <div class="cpub-dash-stat">
100
- <span class="cpub-dash-stat-n">{{ published.length }}</span>
133
+ <span class="cpub-dash-stat-n">{{ allPublished.length }}</span>
101
134
  <span class="cpub-dash-stat-l">Published</span>
102
135
  </div>
103
136
  <div class="cpub-dash-stat">
104
- <span class="cpub-dash-stat-n">{{ drafts.length }}</span>
137
+ <span class="cpub-dash-stat-n">{{ allDrafts.length }}</span>
105
138
  <span class="cpub-dash-stat-l">Drafts</span>
106
139
  </div>
107
140
  <div class="cpub-dash-stat">
@@ -149,6 +182,21 @@ async function deleteItem(id: string, title: string): Promise<void> {
149
182
 
150
183
  <!-- Content tab -->
151
184
  <div v-else-if="activeTab === 'content'" class="cpub-dash-panel">
185
+ <!-- Sort & filter controls -->
186
+ <div class="cpub-dash-controls">
187
+ <div class="cpub-dash-controls-left">
188
+ <select v-model="contentTypeFilter" class="cpub-dash-select" aria-label="Filter by content type">
189
+ <option v-for="opt in contentTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
190
+ </select>
191
+ </div>
192
+ <div class="cpub-dash-controls-right">
193
+ <select v-model="contentSort" class="cpub-dash-select" aria-label="Sort order">
194
+ <option value="newest">Newest first</option>
195
+ <option value="oldest">Oldest first</option>
196
+ <option value="popular">Most popular</option>
197
+ </select>
198
+ </div>
199
+ </div>
152
200
  <!-- Drafts section -->
153
201
  <div v-if="drafts.length" class="cpub-dash-section">
154
202
  <h2 class="cpub-dash-section-title">Drafts</h2>
@@ -359,6 +407,39 @@ async function deleteItem(id: string, title: string): Promise<void> {
359
407
  border-bottom-color: var(--accent);
360
408
  }
361
409
 
410
+ /* Controls */
411
+ .cpub-dash-controls {
412
+ display: flex;
413
+ align-items: center;
414
+ justify-content: space-between;
415
+ padding: 10px 16px;
416
+ border-bottom: var(--border-width-default) solid var(--border);
417
+ gap: 8px;
418
+ }
419
+
420
+ .cpub-dash-controls-left,
421
+ .cpub-dash-controls-right {
422
+ display: flex;
423
+ align-items: center;
424
+ gap: 8px;
425
+ }
426
+
427
+ .cpub-dash-select {
428
+ font-family: var(--font-mono);
429
+ font-size: 10px;
430
+ padding: 4px 8px;
431
+ border: var(--border-width-default) solid var(--border);
432
+ background: var(--surface);
433
+ color: var(--text-dim);
434
+ cursor: pointer;
435
+ outline: none;
436
+ border-radius: var(--radius);
437
+ }
438
+
439
+ .cpub-dash-select:focus {
440
+ border-color: var(--accent);
441
+ }
442
+
362
443
  /* Panel */
363
444
  .cpub-dash-panel {
364
445
  background: var(--surface);