@commonpub/layer 0.3.18 → 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.
- package/components/MessageThread.vue +44 -7
- package/package.json +5 -5
- package/pages/auth/login.vue +190 -10
- package/pages/dashboard.vue +87 -6
- package/pages/explore.vue +134 -9
- package/pages/messages/[conversationId].vue +2 -3
- package/pages/messages/index.vue +176 -21
- package/pages/tags/[slug].vue +32 -8
- package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/auth/federated/callback.get.ts +35 -13
- package/server/api/auth/federated/link.post.ts +82 -0
- package/server/api/messages/index.get.ts +9 -5
|
@@ -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
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
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>
|
|
@@ -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
|
-
|
|
138
|
-
|
|
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 {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -44,15 +44,15 @@
|
|
|
44
44
|
"vue": "^3.4.0",
|
|
45
45
|
"vue-router": "^4.3.0",
|
|
46
46
|
"zod": "^4.3.6",
|
|
47
|
-
"@commonpub/config": "0.7.0",
|
|
48
47
|
"@commonpub/auth": "0.5.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
51
|
"@commonpub/learning": "0.5.0",
|
|
52
|
+
"@commonpub/protocol": "0.9.4",
|
|
53
|
+
"@commonpub/server": "2.13.0",
|
|
53
54
|
"@commonpub/schema": "0.8.12",
|
|
54
|
-
"@commonpub/ui": "0.7.1"
|
|
55
|
-
"@commonpub/server": "2.12.1"
|
|
55
|
+
"@commonpub/ui": "0.7.1"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {}
|
|
58
58
|
}
|
package/pages/auth/login.vue
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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>
|
package/pages/dashboard.vue
CHANGED
|
@@ -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
|
-
|
|
83
|
+
allPublished.value.reduce((sum, item) => sum + (item.viewCount ?? 0), 0),
|
|
51
84
|
);
|
|
52
85
|
const totalLikes = computed(() =>
|
|
53
|
-
|
|
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">{{
|
|
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">{{
|
|
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);
|
package/pages/explore.vue
CHANGED
|
@@ -13,11 +13,14 @@ const activeTab = ref<'content' | 'hubs' | 'learn' | 'people'>('content');
|
|
|
13
13
|
const contentType = ref('');
|
|
14
14
|
const sort = ref('recent');
|
|
15
15
|
|
|
16
|
+
const CONTENT_PAGE_SIZE = 20;
|
|
17
|
+
const TAB_PAGE_SIZE = 12;
|
|
18
|
+
|
|
16
19
|
const contentQuery = computed(() => ({
|
|
17
20
|
status: 'published',
|
|
18
21
|
type: contentType.value || undefined,
|
|
19
22
|
sort: sort.value,
|
|
20
|
-
limit:
|
|
23
|
+
limit: CONTENT_PAGE_SIZE,
|
|
21
24
|
}));
|
|
22
25
|
|
|
23
26
|
const { data: content, pending: contentPending, error: contentError, refresh: refreshContent } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
@@ -25,26 +28,123 @@ const { data: content, pending: contentPending, error: contentError, refresh: re
|
|
|
25
28
|
watch: [contentQuery],
|
|
26
29
|
});
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
// Reset content pagination when filters change
|
|
32
|
+
const contentAllLoaded = ref(false);
|
|
33
|
+
const contentLoadingMore = ref(false);
|
|
34
|
+
watch([contentType, sort], () => { contentAllLoaded.value = false; });
|
|
35
|
+
|
|
36
|
+
async function loadMoreContent(): Promise<void> {
|
|
37
|
+
if (!content.value?.items) return;
|
|
38
|
+
contentLoadingMore.value = true;
|
|
39
|
+
try {
|
|
40
|
+
const more = await $fetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
41
|
+
query: { ...contentQuery.value, offset: content.value.items.length },
|
|
42
|
+
});
|
|
43
|
+
if (more?.items?.length) {
|
|
44
|
+
content.value.items.push(...more.items);
|
|
45
|
+
}
|
|
46
|
+
if (!more?.items?.length || more.items.length < CONTENT_PAGE_SIZE) {
|
|
47
|
+
contentAllLoaded.value = true;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
contentAllLoaded.value = true;
|
|
51
|
+
} finally {
|
|
52
|
+
contentLoadingMore.value = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface HubItem { id: string; slug: string; name: string; description: string | null; hubType: string; memberCount: number }
|
|
57
|
+
const { data: hubsData } = await useFetch<{ items: HubItem[]; total: number }>('/api/hubs', {
|
|
58
|
+
query: { limit: TAB_PAGE_SIZE },
|
|
30
59
|
lazy: true,
|
|
31
60
|
});
|
|
32
61
|
|
|
33
|
-
const
|
|
34
|
-
|
|
62
|
+
const hubsAllLoaded = ref(false);
|
|
63
|
+
const hubsLoadingMore = ref(false);
|
|
64
|
+
|
|
65
|
+
async function loadMoreHubs(): Promise<void> {
|
|
66
|
+
if (!hubsData.value?.items) return;
|
|
67
|
+
hubsLoadingMore.value = true;
|
|
68
|
+
try {
|
|
69
|
+
const more = await $fetch<{ items: HubItem[]; total: number }>('/api/hubs', {
|
|
70
|
+
query: { limit: TAB_PAGE_SIZE, offset: hubsData.value.items.length },
|
|
71
|
+
});
|
|
72
|
+
if (more?.items?.length) {
|
|
73
|
+
hubsData.value.items.push(...more.items);
|
|
74
|
+
}
|
|
75
|
+
if (!more?.items?.length || more.items.length < TAB_PAGE_SIZE) {
|
|
76
|
+
hubsAllLoaded.value = true;
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
hubsAllLoaded.value = true;
|
|
80
|
+
} finally {
|
|
81
|
+
hubsLoadingMore.value = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface PathItem { id: string; slug: string; title: string; description: string | null; moduleCount: number; enrollmentCount: number }
|
|
86
|
+
const { data: pathsData } = await useFetch<{ items: PathItem[]; total: number }>('/api/learn', {
|
|
87
|
+
query: { status: 'published', limit: TAB_PAGE_SIZE },
|
|
35
88
|
lazy: true,
|
|
36
89
|
});
|
|
37
90
|
|
|
91
|
+
const pathsAllLoaded = ref(false);
|
|
92
|
+
const pathsLoadingMore = ref(false);
|
|
93
|
+
|
|
94
|
+
async function loadMorePaths(): Promise<void> {
|
|
95
|
+
if (!pathsData.value?.items) return;
|
|
96
|
+
pathsLoadingMore.value = true;
|
|
97
|
+
try {
|
|
98
|
+
const more = await $fetch<{ items: PathItem[]; total: number }>('/api/learn', {
|
|
99
|
+
query: { status: 'published', limit: TAB_PAGE_SIZE, offset: pathsData.value.items.length },
|
|
100
|
+
});
|
|
101
|
+
if (more?.items?.length) {
|
|
102
|
+
pathsData.value.items.push(...more.items);
|
|
103
|
+
}
|
|
104
|
+
if (!more?.items?.length || more.items.length < TAB_PAGE_SIZE) {
|
|
105
|
+
pathsAllLoaded.value = true;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
pathsAllLoaded.value = true;
|
|
109
|
+
} finally {
|
|
110
|
+
pathsLoadingMore.value = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
38
114
|
const { data: statsData } = await useFetch('/api/stats', {
|
|
39
115
|
lazy: true,
|
|
40
116
|
});
|
|
41
117
|
|
|
42
|
-
|
|
43
|
-
|
|
118
|
+
interface PersonItem { id: string; username: string; displayName: string | null; headline: string | null; avatarUrl: string | null; followerCount: number }
|
|
119
|
+
const { data: peopleData } = await useFetch<{ items: PersonItem[]; total: number }>('/api/users', {
|
|
120
|
+
query: { limit: TAB_PAGE_SIZE },
|
|
44
121
|
lazy: true,
|
|
45
|
-
default: () => ({ items: [] }),
|
|
122
|
+
default: () => ({ items: [], total: 0 }),
|
|
46
123
|
});
|
|
47
124
|
|
|
125
|
+
const peopleAllLoaded = ref(false);
|
|
126
|
+
const peopleLoadingMore = ref(false);
|
|
127
|
+
|
|
128
|
+
async function loadMorePeople(): Promise<void> {
|
|
129
|
+
if (!peopleData.value?.items) return;
|
|
130
|
+
peopleLoadingMore.value = true;
|
|
131
|
+
try {
|
|
132
|
+
const more = await $fetch<{ items: PersonItem[]; total: number }>('/api/users', {
|
|
133
|
+
query: { limit: TAB_PAGE_SIZE, offset: peopleData.value.items.length },
|
|
134
|
+
});
|
|
135
|
+
if (more?.items?.length) {
|
|
136
|
+
peopleData.value.items.push(...more.items);
|
|
137
|
+
}
|
|
138
|
+
if (!more?.items?.length || more.items.length < TAB_PAGE_SIZE) {
|
|
139
|
+
peopleAllLoaded.value = true;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
peopleAllLoaded.value = true;
|
|
143
|
+
} finally {
|
|
144
|
+
peopleLoadingMore.value = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
48
148
|
const contentTypes = computed(() => [
|
|
49
149
|
{ value: '', label: 'All' },
|
|
50
150
|
...enabledTypeMeta.value.map(m => ({ value: m.type, label: m.plural })),
|
|
@@ -125,6 +225,11 @@ const sortOptions = [
|
|
|
125
225
|
<div v-else class="cpub-empty-state">
|
|
126
226
|
<p class="cpub-empty-state-title">No content found</p>
|
|
127
227
|
</div>
|
|
228
|
+
<div v-if="!contentAllLoaded && (content?.items?.length ?? 0) >= CONTENT_PAGE_SIZE" class="cpub-explore-more">
|
|
229
|
+
<button class="cpub-btn" @click="loadMoreContent" :disabled="contentLoadingMore">
|
|
230
|
+
{{ contentLoadingMore ? 'Loading...' : 'Load More' }}
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
128
233
|
</div>
|
|
129
234
|
|
|
130
235
|
<!-- Hubs tab -->
|
|
@@ -152,6 +257,11 @@ const sortOptions = [
|
|
|
152
257
|
<div v-else class="cpub-empty-state">
|
|
153
258
|
<p class="cpub-empty-state-title">No hubs yet</p>
|
|
154
259
|
</div>
|
|
260
|
+
<div v-if="!hubsAllLoaded && (hubsData?.items?.length ?? 0) >= TAB_PAGE_SIZE" class="cpub-explore-more">
|
|
261
|
+
<button class="cpub-btn" @click="loadMoreHubs" :disabled="hubsLoadingMore">
|
|
262
|
+
{{ hubsLoadingMore ? 'Loading...' : 'Load More' }}
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
155
265
|
</div>
|
|
156
266
|
|
|
157
267
|
<!-- Learn tab -->
|
|
@@ -177,6 +287,11 @@ const sortOptions = [
|
|
|
177
287
|
<div v-else class="cpub-empty-state">
|
|
178
288
|
<p class="cpub-empty-state-title">No learning paths yet</p>
|
|
179
289
|
</div>
|
|
290
|
+
<div v-if="!pathsAllLoaded && (pathsData?.items?.length ?? 0) >= TAB_PAGE_SIZE" class="cpub-explore-more">
|
|
291
|
+
<button class="cpub-btn" @click="loadMorePaths" :disabled="pathsLoadingMore">
|
|
292
|
+
{{ pathsLoadingMore ? 'Loading...' : 'Load More' }}
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
180
295
|
</div>
|
|
181
296
|
|
|
182
297
|
<!-- People tab -->
|
|
@@ -188,7 +303,7 @@ const sortOptions = [
|
|
|
188
303
|
:to="`/u/${person.username}`"
|
|
189
304
|
class="cpub-explore-hub-card"
|
|
190
305
|
>
|
|
191
|
-
<div class="cpub-explore-hub-icon"
|
|
306
|
+
<div class="cpub-explore-hub-icon">
|
|
192
307
|
<img v-if="person.avatarUrl" :src="person.avatarUrl" :alt="person.displayName || person.username" class="cpub-explore-person-avatar-img" />
|
|
193
308
|
<span v-else style="font-weight: 700; font-family: var(--font-mono);">{{ (person.displayName || person.username).charAt(0).toUpperCase() }}</span>
|
|
194
309
|
</div>
|
|
@@ -204,6 +319,11 @@ const sortOptions = [
|
|
|
204
319
|
<div v-else class="cpub-empty-state">
|
|
205
320
|
<p class="cpub-empty-state-title">No makers yet</p>
|
|
206
321
|
</div>
|
|
322
|
+
<div v-if="!peopleAllLoaded && (peopleData?.items?.length ?? 0) >= TAB_PAGE_SIZE" class="cpub-explore-more">
|
|
323
|
+
<button class="cpub-btn" @click="loadMorePeople" :disabled="peopleLoadingMore">
|
|
324
|
+
{{ peopleLoadingMore ? 'Loading...' : 'Load More' }}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
207
327
|
</div>
|
|
208
328
|
</div>
|
|
209
329
|
</template>
|
|
@@ -414,6 +534,11 @@ const sortOptions = [
|
|
|
414
534
|
/* cpub-tag → global components.css */
|
|
415
535
|
.cpub-tag-xs { font-size: 9px; }
|
|
416
536
|
|
|
537
|
+
.cpub-explore-more {
|
|
538
|
+
text-align: center;
|
|
539
|
+
padding: 24px 0;
|
|
540
|
+
}
|
|
541
|
+
|
|
417
542
|
@media (max-width: 768px) {
|
|
418
543
|
.cpub-explore-grid { grid-template-columns: 1fr; }
|
|
419
544
|
.cpub-explore-hub-grid { grid-template-columns: 1fr; }
|
|
@@ -20,6 +20,7 @@ interface MessageItem {
|
|
|
20
20
|
senderAvatarUrl?: string | null;
|
|
21
21
|
body: string;
|
|
22
22
|
createdAt: string;
|
|
23
|
+
readAt?: string | null;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const { data: convInfo } = useLazyFetch<{ id: string; participants: ConvParticipant[] }>(`/api/messages/${conversationId}/info`, {
|
|
@@ -128,8 +129,6 @@ async function handleSend(text: string): Promise<void> {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
@media (max-width: 768px) {
|
|
131
|
-
.
|
|
132
|
-
.msg-scroll { height: calc(100vh - 160px); }
|
|
133
|
-
.msg-compose { padding: 8px; }
|
|
132
|
+
.cpub-message-view { height: calc(100vh - 100px); }
|
|
134
133
|
}
|
|
135
134
|
</style>
|
package/pages/messages/index.vue
CHANGED
|
@@ -14,26 +14,48 @@ interface ConversationItem {
|
|
|
14
14
|
lastMessage: string | null;
|
|
15
15
|
lastMessageAt: string;
|
|
16
16
|
createdAt: string;
|
|
17
|
-
|
|
17
|
+
unreadCount?: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const { user } = useAuth();
|
|
20
21
|
const { data: conversations, refresh } = await useFetch<ConversationItem[]>('/api/messages', {
|
|
21
22
|
default: () => [] as ConversationItem[],
|
|
22
23
|
});
|
|
23
24
|
|
|
25
|
+
/** Filter out the current user from participants for display */
|
|
26
|
+
function otherParticipants(conv: ConversationItem): ParticipantRef[] {
|
|
27
|
+
const others = conv.participants.filter(p => p.username !== user.value?.username);
|
|
28
|
+
return others.length > 0 ? others : conv.participants;
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
const showNewDialog = ref(false);
|
|
25
|
-
const
|
|
32
|
+
const newRecipientInput = ref('');
|
|
33
|
+
const newRecipients = ref<string[]>([]);
|
|
26
34
|
const newMessage = ref('');
|
|
27
35
|
|
|
28
36
|
const msgError = ref('');
|
|
29
37
|
|
|
38
|
+
function addRecipient(): void {
|
|
39
|
+
const val = newRecipientInput.value.trim();
|
|
40
|
+
if (!val) return;
|
|
41
|
+
if (newRecipients.value.includes(val)) return;
|
|
42
|
+
newRecipients.value.push(val);
|
|
43
|
+
newRecipientInput.value = '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function removeRecipient(idx: number): void {
|
|
47
|
+
newRecipients.value.splice(idx, 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
async function startConversation(): Promise<void> {
|
|
31
|
-
|
|
51
|
+
// Add any pending input as a recipient
|
|
52
|
+
if (newRecipientInput.value.trim()) addRecipient();
|
|
53
|
+
if (!newRecipients.value.length) return;
|
|
32
54
|
msgError.value = '';
|
|
33
55
|
try {
|
|
34
56
|
const conv = await $fetch<{ id: string }>('/api/messages', {
|
|
35
57
|
method: 'POST',
|
|
36
|
-
body: { participants:
|
|
58
|
+
body: { participants: newRecipients.value },
|
|
37
59
|
});
|
|
38
60
|
if (newMessage.value.trim()) {
|
|
39
61
|
await $fetch(`/api/messages/${conv.id}` as string, {
|
|
@@ -42,7 +64,8 @@ async function startConversation(): Promise<void> {
|
|
|
42
64
|
});
|
|
43
65
|
}
|
|
44
66
|
showNewDialog.value = false;
|
|
45
|
-
|
|
67
|
+
newRecipients.value = [];
|
|
68
|
+
newRecipientInput.value = '';
|
|
46
69
|
newMessage.value = '';
|
|
47
70
|
await navigateTo(`/messages/${conv.id}`);
|
|
48
71
|
} catch (err: unknown) {
|
|
@@ -62,7 +85,7 @@ async function startConversation(): Promise<void> {
|
|
|
62
85
|
</div>
|
|
63
86
|
|
|
64
87
|
<!-- New conversation dialog -->
|
|
65
|
-
<div v-if="showNewDialog" class="cpub-new-msg-overlay" @click.self="showNewDialog = false">
|
|
88
|
+
<div v-if="showNewDialog" class="cpub-new-msg-overlay" @click.self="showNewDialog = false" @keydown.escape="showNewDialog = false">
|
|
66
89
|
<div class="cpub-new-msg-dialog" role="dialog" aria-label="New message">
|
|
67
90
|
<div class="cpub-new-msg-header">
|
|
68
91
|
<h2 class="cpub-new-msg-title">New Conversation</h2>
|
|
@@ -71,9 +94,24 @@ async function startConversation(): Promise<void> {
|
|
|
71
94
|
</button>
|
|
72
95
|
</div>
|
|
73
96
|
<div class="cpub-new-msg-body">
|
|
97
|
+
<div v-if="msgError" class="cpub-new-msg-error" role="alert">{{ msgError }}</div>
|
|
74
98
|
<div class="cpub-new-msg-field">
|
|
75
|
-
<label class="cpub-new-msg-label">
|
|
76
|
-
<
|
|
99
|
+
<label class="cpub-new-msg-label">Recipients</label>
|
|
100
|
+
<div v-if="newRecipients.length" class="cpub-new-msg-chips">
|
|
101
|
+
<span v-for="(r, idx) in newRecipients" :key="idx" class="cpub-new-msg-chip">
|
|
102
|
+
{{ r }}
|
|
103
|
+
<button class="cpub-new-msg-chip-remove" @click="removeRecipient(idx)" :aria-label="`Remove ${r}`">×</button>
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
<input
|
|
107
|
+
v-model="newRecipientInput"
|
|
108
|
+
type="text"
|
|
109
|
+
class="cpub-new-msg-input"
|
|
110
|
+
placeholder="username or @user@remote-instance.com"
|
|
111
|
+
@keydown.enter.prevent="addRecipient"
|
|
112
|
+
@keydown.,.prevent="addRecipient"
|
|
113
|
+
/>
|
|
114
|
+
<p class="cpub-new-msg-hint">Press Enter to add multiple recipients</p>
|
|
77
115
|
</div>
|
|
78
116
|
<div class="cpub-new-msg-field">
|
|
79
117
|
<label class="cpub-new-msg-label">Message (optional)</label>
|
|
@@ -84,7 +122,7 @@ async function startConversation(): Promise<void> {
|
|
|
84
122
|
<button class="cpub-btn cpub-btn-sm" @click="showNewDialog = false">Cancel</button>
|
|
85
123
|
<button
|
|
86
124
|
class="cpub-btn cpub-btn-sm cpub-btn-primary"
|
|
87
|
-
:disabled="!
|
|
125
|
+
:disabled="!newRecipients.length && !newRecipientInput.trim()"
|
|
88
126
|
@click="startConversation"
|
|
89
127
|
>
|
|
90
128
|
Start Conversation
|
|
@@ -99,19 +137,32 @@ async function startConversation(): Promise<void> {
|
|
|
99
137
|
:key="conv.id"
|
|
100
138
|
:to="`/messages/${conv.id}`"
|
|
101
139
|
class="cpub-conversation-item"
|
|
102
|
-
:class="{ unread: conv.
|
|
140
|
+
:class="{ 'cpub-conv-unread': (conv.unreadCount ?? 0) > 0 }"
|
|
141
|
+
:aria-label="`Conversation with ${otherParticipants(conv).map(p => p.displayName || p.username).join(', ')}${(conv.unreadCount ?? 0) > 0 ? `, ${conv.unreadCount} unread` : ''}`"
|
|
103
142
|
>
|
|
104
|
-
<div class="cpub-conv-avatar">
|
|
105
|
-
<img v-if="conv
|
|
106
|
-
<span v-else>{{ (conv
|
|
143
|
+
<div v-if="otherParticipants(conv).length <= 1" class="cpub-conv-avatar">
|
|
144
|
+
<img v-if="otherParticipants(conv)[0]?.avatarUrl" :src="otherParticipants(conv)[0].avatarUrl!" :alt="otherParticipants(conv)[0].displayName ?? otherParticipants(conv)[0].username ?? ''" class="cpub-conv-avatar-img" />
|
|
145
|
+
<span v-else>{{ (otherParticipants(conv)[0]?.displayName || otherParticipants(conv)[0]?.username || '?').charAt(0).toUpperCase() }}</span>
|
|
146
|
+
</div>
|
|
147
|
+
<div v-else class="cpub-conv-avatar-group">
|
|
148
|
+
<div v-for="(p, idx) in otherParticipants(conv).slice(0, 4)" :key="idx" class="cpub-conv-avatar-mini">
|
|
149
|
+
<img v-if="p.avatarUrl" :src="p.avatarUrl" :alt="p.displayName || p.username" class="cpub-conv-avatar-img" />
|
|
150
|
+
<span v-else>{{ (p.displayName || p.username || '?').charAt(0).toUpperCase() }}</span>
|
|
151
|
+
</div>
|
|
107
152
|
</div>
|
|
108
153
|
<div class="cpub-conv-info">
|
|
109
|
-
<div class="cpub-conv-name">
|
|
154
|
+
<div class="cpub-conv-name">
|
|
155
|
+
{{ otherParticipants(conv).map(p => p.displayName || p.username).join(', ') }}
|
|
156
|
+
<span v-if="otherParticipants(conv).length > 2" class="cpub-conv-group-count">({{ otherParticipants(conv).length }})</span>
|
|
157
|
+
</div>
|
|
110
158
|
<div class="cpub-conv-preview">{{ conv.lastMessage ?? 'No messages yet' }}</div>
|
|
111
159
|
</div>
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
160
|
+
<div class="cpub-conv-meta">
|
|
161
|
+
<time class="cpub-conv-time">
|
|
162
|
+
{{ new Date(conv.lastMessageAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
|
|
163
|
+
</time>
|
|
164
|
+
<span v-if="(conv.unreadCount ?? 0) > 0" class="cpub-conv-badge" :aria-label="`${conv.unreadCount} unread messages`">{{ conv.unreadCount }}</span>
|
|
165
|
+
</div>
|
|
115
166
|
</NuxtLink>
|
|
116
167
|
|
|
117
168
|
<div v-if="!conversations.length" class="cpub-empty-state">
|
|
@@ -156,14 +207,13 @@ async function startConversation(): Promise<void> {
|
|
|
156
207
|
background: var(--surface2);
|
|
157
208
|
}
|
|
158
209
|
|
|
159
|
-
.cpub-conversation-item.unread {
|
|
210
|
+
.cpub-conversation-item.cpub-conv-unread {
|
|
160
211
|
background: var(--accent-bg);
|
|
161
212
|
}
|
|
162
213
|
|
|
163
214
|
.cpub-conv-avatar {
|
|
164
215
|
width: 36px;
|
|
165
216
|
height: 36px;
|
|
166
|
-
border-radius: 50%;
|
|
167
217
|
background: var(--surface3);
|
|
168
218
|
border: var(--border-width-default) solid var(--border);
|
|
169
219
|
display: flex;
|
|
@@ -177,7 +227,41 @@ async function startConversation(): Promise<void> {
|
|
|
177
227
|
overflow: hidden;
|
|
178
228
|
}
|
|
179
229
|
|
|
180
|
-
.cpub-conv-avatar-img { width: 100%; height: 100%; object-fit: cover;
|
|
230
|
+
.cpub-conv-avatar-img { width: 100%; height: 100%; object-fit: cover; }
|
|
231
|
+
|
|
232
|
+
/* Group avatar stack */
|
|
233
|
+
.cpub-conv-avatar-group {
|
|
234
|
+
display: grid;
|
|
235
|
+
grid-template-columns: 1fr 1fr;
|
|
236
|
+
grid-template-rows: 1fr 1fr;
|
|
237
|
+
width: 36px;
|
|
238
|
+
height: 36px;
|
|
239
|
+
gap: 1px;
|
|
240
|
+
flex-shrink: 0;
|
|
241
|
+
border: var(--border-width-default) solid var(--border);
|
|
242
|
+
overflow: hidden;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.cpub-conv-avatar-mini {
|
|
246
|
+
background: var(--surface3);
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
justify-content: center;
|
|
250
|
+
font-family: var(--font-mono);
|
|
251
|
+
font-size: 8px;
|
|
252
|
+
font-weight: 600;
|
|
253
|
+
color: var(--text-dim);
|
|
254
|
+
overflow: hidden;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.cpub-conv-avatar-mini .cpub-conv-avatar-img { width: 100%; height: 100%; object-fit: cover; }
|
|
258
|
+
|
|
259
|
+
.cpub-conv-group-count {
|
|
260
|
+
font-size: 10px;
|
|
261
|
+
color: var(--text-faint);
|
|
262
|
+
font-family: var(--font-mono);
|
|
263
|
+
font-weight: 400;
|
|
264
|
+
}
|
|
181
265
|
|
|
182
266
|
.cpub-conv-info {
|
|
183
267
|
flex: 1;
|
|
@@ -199,11 +283,33 @@ async function startConversation(): Promise<void> {
|
|
|
199
283
|
white-space: nowrap;
|
|
200
284
|
}
|
|
201
285
|
|
|
286
|
+
.cpub-conv-meta {
|
|
287
|
+
display: flex;
|
|
288
|
+
flex-direction: column;
|
|
289
|
+
align-items: flex-end;
|
|
290
|
+
gap: 4px;
|
|
291
|
+
flex-shrink: 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
202
294
|
.cpub-conv-time {
|
|
203
295
|
font-size: 10px;
|
|
204
296
|
color: var(--text-faint);
|
|
205
297
|
font-family: var(--font-mono);
|
|
206
|
-
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.cpub-conv-badge {
|
|
301
|
+
display: inline-flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
min-width: 18px;
|
|
305
|
+
height: 18px;
|
|
306
|
+
padding: 0 5px;
|
|
307
|
+
background: var(--accent);
|
|
308
|
+
color: var(--color-text-inverse);
|
|
309
|
+
font-family: var(--font-mono);
|
|
310
|
+
font-size: 10px;
|
|
311
|
+
font-weight: 700;
|
|
312
|
+
border-radius: var(--radius);
|
|
207
313
|
}
|
|
208
314
|
|
|
209
315
|
/* New message dialog */
|
|
@@ -288,6 +394,55 @@ async function startConversation(): Promise<void> {
|
|
|
288
394
|
border-color: var(--accent);
|
|
289
395
|
}
|
|
290
396
|
|
|
397
|
+
.cpub-new-msg-error {
|
|
398
|
+
padding: 8px 10px;
|
|
399
|
+
background: var(--red-bg);
|
|
400
|
+
color: var(--red);
|
|
401
|
+
border: var(--border-width-default) solid var(--red);
|
|
402
|
+
font-size: 12px;
|
|
403
|
+
border-radius: var(--radius);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.cpub-new-msg-chips {
|
|
407
|
+
display: flex;
|
|
408
|
+
flex-wrap: wrap;
|
|
409
|
+
gap: 4px;
|
|
410
|
+
margin-bottom: 4px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.cpub-new-msg-chip {
|
|
414
|
+
display: inline-flex;
|
|
415
|
+
align-items: center;
|
|
416
|
+
gap: 4px;
|
|
417
|
+
padding: 2px 8px;
|
|
418
|
+
background: var(--accent-bg);
|
|
419
|
+
border: var(--border-width-default) solid var(--accent-border, var(--border));
|
|
420
|
+
color: var(--text);
|
|
421
|
+
font-family: var(--font-mono);
|
|
422
|
+
font-size: 11px;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.cpub-new-msg-chip-remove {
|
|
426
|
+
background: none;
|
|
427
|
+
border: none;
|
|
428
|
+
color: var(--text-faint);
|
|
429
|
+
cursor: pointer;
|
|
430
|
+
font-size: 14px;
|
|
431
|
+
padding: 0 2px;
|
|
432
|
+
line-height: 1;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.cpub-new-msg-chip-remove:hover {
|
|
436
|
+
color: var(--red);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.cpub-new-msg-hint {
|
|
440
|
+
font-size: 10px;
|
|
441
|
+
color: var(--text-faint);
|
|
442
|
+
font-family: var(--font-mono);
|
|
443
|
+
margin-top: 2px;
|
|
444
|
+
}
|
|
445
|
+
|
|
291
446
|
.cpub-new-msg-footer {
|
|
292
447
|
display: flex;
|
|
293
448
|
justify-content: flex-end;
|
package/pages/tags/[slug].vue
CHANGED
|
@@ -9,23 +9,45 @@ useSeoMeta({
|
|
|
9
9
|
description: () => `Content tagged with "${tagSlug.value}" on CommonPub`,
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
12
|
+
const PAGE_SIZE = 20;
|
|
13
|
+
const loadingMore = ref(false);
|
|
14
|
+
const allLoaded = ref(false);
|
|
15
|
+
|
|
16
|
+
const { data: results } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
14
17
|
query: computed(() => ({
|
|
15
18
|
tag: tagSlug.value,
|
|
16
19
|
status: 'published',
|
|
17
|
-
limit:
|
|
18
|
-
offset: page.value * 20,
|
|
20
|
+
limit: PAGE_SIZE,
|
|
19
21
|
})),
|
|
22
|
+
watch: [tagSlug],
|
|
20
23
|
});
|
|
21
24
|
|
|
22
25
|
const items = computed(() => results.value?.items ?? []);
|
|
23
26
|
const total = computed(() => results.value?.total ?? 0);
|
|
24
|
-
const hasMore = computed(() => items.value.length < total.value);
|
|
27
|
+
const hasMore = computed(() => !allLoaded.value && items.value.length < total.value);
|
|
28
|
+
|
|
29
|
+
// Reset when tag changes
|
|
30
|
+
watch(tagSlug, () => { allLoaded.value = false; });
|
|
25
31
|
|
|
26
32
|
async function loadMore(): Promise<void> {
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
if (!results.value?.items) return;
|
|
34
|
+
loadingMore.value = true;
|
|
35
|
+
try {
|
|
36
|
+
const nextOffset = results.value.items.length;
|
|
37
|
+
const more = await $fetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
38
|
+
query: { tag: tagSlug.value, status: 'published', limit: PAGE_SIZE, offset: nextOffset },
|
|
39
|
+
});
|
|
40
|
+
if (more?.items?.length) {
|
|
41
|
+
results.value.items.push(...more.items);
|
|
42
|
+
}
|
|
43
|
+
if (!more?.items?.length || more.items.length < PAGE_SIZE) {
|
|
44
|
+
allLoaded.value = true;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
allLoaded.value = true;
|
|
48
|
+
} finally {
|
|
49
|
+
loadingMore.value = false;
|
|
50
|
+
}
|
|
29
51
|
}
|
|
30
52
|
</script>
|
|
31
53
|
|
|
@@ -53,7 +75,9 @@ async function loadMore(): Promise<void> {
|
|
|
53
75
|
</div>
|
|
54
76
|
|
|
55
77
|
<div v-if="hasMore" class="cpub-tag-more">
|
|
56
|
-
<button class="cpub-btn" @click="loadMore"
|
|
78
|
+
<button class="cpub-btn" @click="loadMore" :disabled="loadingMore">
|
|
79
|
+
{{ loadingMore ? 'Loading...' : 'Load more' }}
|
|
80
|
+
</button>
|
|
57
81
|
</div>
|
|
58
82
|
</div>
|
|
59
83
|
</template>
|
|
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
15
15
|
console.error('[hub-backfill] Failed:', err);
|
|
16
16
|
return { processed: 0, errors: 1 };
|
|
17
17
|
}),
|
|
18
|
-
fetchRemoteHubFollowers(db, id).catch((err) => {
|
|
18
|
+
fetchRemoteHubFollowers(db, id, config.instance.domain).catch((err) => {
|
|
19
19
|
console.error('[hub-followers] Failed:', err);
|
|
20
20
|
return { fetched: 0, errors: 1 };
|
|
21
21
|
}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount } from '@commonpub/server';
|
|
1
|
+
import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount, findUserByFederatedAccount, createFederatedSession, storePendingLink } from '@commonpub/server';
|
|
2
|
+
import type { H3Event } from 'h3';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
|
|
4
5
|
const callbackSchema = z.object({
|
|
@@ -6,16 +7,30 @@ const callbackSchema = z.object({
|
|
|
6
7
|
state: z.string(),
|
|
7
8
|
});
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Set the Better Auth session cookie after federated login.
|
|
12
|
+
*/
|
|
13
|
+
function setSessionCookie(event: H3Event, token: string, expiresAt: Date): void {
|
|
14
|
+
setCookie(event, 'better-auth.session_token', token, {
|
|
15
|
+
httpOnly: true,
|
|
16
|
+
secure: process.env.NODE_ENV === 'production',
|
|
17
|
+
sameSite: 'lax',
|
|
18
|
+
path: '/',
|
|
19
|
+
expires: expiresAt,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
/**
|
|
10
24
|
* OAuth2 callback handler for federated login.
|
|
11
|
-
* Exchanges authorization code for token, links federated account
|
|
25
|
+
* Exchanges authorization code for token, links federated account,
|
|
26
|
+
* creates a session, and redirects.
|
|
12
27
|
*/
|
|
13
28
|
export default defineEventHandler(async (event) => {
|
|
14
29
|
requireFeature('federation');
|
|
15
30
|
const db = useDB();
|
|
16
31
|
const { code, state: stateToken } = parseQueryParams(event, callbackSchema);
|
|
17
32
|
|
|
18
|
-
// Retrieve and consume the stored OAuth state (single-use,
|
|
33
|
+
// Retrieve and consume the stored OAuth state (single-use, atomic)
|
|
19
34
|
const oauthState = await consumeOAuthState(db, stateToken);
|
|
20
35
|
if (!oauthState) {
|
|
21
36
|
throw createError({
|
|
@@ -33,14 +48,19 @@ export default defineEventHandler(async (event) => {
|
|
|
33
48
|
});
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
const ipAddress = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
|
|
52
|
+
?? getRequestHeader(event, 'x-real-ip')
|
|
53
|
+
?? undefined;
|
|
54
|
+
const userAgent = getRequestHeader(event, 'user-agent') ?? undefined;
|
|
55
|
+
|
|
36
56
|
// Check if a local user is already linked to this federated account
|
|
37
|
-
const { findUserByFederatedAccount } = await import('@commonpub/server');
|
|
38
57
|
const existingLink = await findUserByFederatedAccount(db, tokenResult.user.actorUri);
|
|
39
58
|
|
|
40
59
|
if (existingLink) {
|
|
41
|
-
// User already linked — redirect to dashboard
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
// User already linked — create session and redirect to dashboard
|
|
61
|
+
const session = await createFederatedSession(db, existingLink.userId, ipAddress, userAgent);
|
|
62
|
+
setSessionCookie(event, session.sessionToken, session.expiresAt);
|
|
63
|
+
return sendRedirect(event, '/dashboard', 302);
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
// Check if the current user is logged in — if so, link to their account
|
|
@@ -55,13 +75,15 @@ export default defineEventHandler(async (event) => {
|
|
|
55
75
|
return sendRedirect(event, '/settings/account?federated=linked', 302);
|
|
56
76
|
}
|
|
57
77
|
|
|
58
|
-
// Not logged in and no existing link —
|
|
59
|
-
//
|
|
60
|
-
const
|
|
61
|
-
federated: 'true',
|
|
78
|
+
// Not logged in and no existing link — store verified identity in a server-side token.
|
|
79
|
+
// Only the opaque token is passed to the client; the actorUri is never exposed in the URL.
|
|
80
|
+
const linkToken = await storePendingLink(db, {
|
|
62
81
|
actorUri: tokenResult.user.actorUri,
|
|
63
82
|
username: tokenResult.user.username,
|
|
64
|
-
|
|
83
|
+
instanceDomain: oauthState.instanceDomain,
|
|
84
|
+
displayName: tokenResult.user.displayName ?? undefined,
|
|
85
|
+
avatarUrl: tokenResult.user.avatarUrl ?? undefined,
|
|
65
86
|
});
|
|
66
|
-
|
|
87
|
+
|
|
88
|
+
return sendRedirect(event, `/auth/login?federated=true&linkToken=${linkToken}`, 302);
|
|
67
89
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { linkFederatedAccount, consumePendingLink } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const linkSchema = z.object({
|
|
5
|
+
/** Local credentials */
|
|
6
|
+
identity: z.string().min(1),
|
|
7
|
+
password: z.string().min(1),
|
|
8
|
+
/** Server-side link token from the OAuth callback — proves the federated identity was verified */
|
|
9
|
+
linkToken: z.string().min(1),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Link a verified federated identity to a local account.
|
|
14
|
+
* The linkToken is a server-side opaque token stored during the OAuth callback.
|
|
15
|
+
* It proves the federated identity was authenticated — the actorUri is never
|
|
16
|
+
* sent from the client, preventing identity hijacking.
|
|
17
|
+
*/
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
requireFeature('federation');
|
|
20
|
+
const db = useDB();
|
|
21
|
+
const body = await parseBody(event, linkSchema);
|
|
22
|
+
|
|
23
|
+
// Step 1: Consume the pending link token (single-use, 10min TTL)
|
|
24
|
+
const pendingLink = await consumePendingLink(db, body.linkToken);
|
|
25
|
+
if (!pendingLink) {
|
|
26
|
+
throw createError({
|
|
27
|
+
statusCode: 400,
|
|
28
|
+
statusMessage: 'Link token is invalid or expired. Please start the federated login again.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Step 2: Resolve identity to email
|
|
33
|
+
const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
body: { identity: body.identity },
|
|
36
|
+
}).catch(() => {
|
|
37
|
+
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Step 3: Authenticate via Better Auth sign-in (also creates session + sets cookie)
|
|
41
|
+
const signInResponse = await $fetch<{ user?: { id: string }; session?: { token: string; expiresAt: string } }>(
|
|
42
|
+
'/api/auth/sign-in/email',
|
|
43
|
+
{
|
|
44
|
+
method: 'POST',
|
|
45
|
+
body: { email, password: body.password },
|
|
46
|
+
},
|
|
47
|
+
).catch(() => {
|
|
48
|
+
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!signInResponse?.user?.id || !signInResponse?.session?.token) {
|
|
52
|
+
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const userId = signInResponse.user.id;
|
|
56
|
+
|
|
57
|
+
// Step 4: Link the verified federated identity (from server-side token, NOT client input)
|
|
58
|
+
try {
|
|
59
|
+
await linkFederatedAccount(db, userId, pendingLink.actorUri, pendingLink.instanceDomain, {
|
|
60
|
+
preferredUsername: pendingLink.username,
|
|
61
|
+
displayName: pendingLink.displayName,
|
|
62
|
+
avatarUrl: pendingLink.avatarUrl,
|
|
63
|
+
});
|
|
64
|
+
} catch (err: unknown) {
|
|
65
|
+
const msg = err instanceof Error ? err.message : 'Failed to link account';
|
|
66
|
+
throw createError({ statusCode: 409, statusMessage: msg });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Step 5: Use the session Better Auth created — set cookie for the client
|
|
70
|
+
setCookie(event, 'better-auth.session_token', signInResponse.session.token, {
|
|
71
|
+
httpOnly: true,
|
|
72
|
+
secure: process.env.NODE_ENV === 'production',
|
|
73
|
+
sameSite: 'lax',
|
|
74
|
+
path: '/',
|
|
75
|
+
expires: new Date(signInResponse.session.expiresAt),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
linked: true,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listConversations } from '@commonpub/server';
|
|
1
|
+
import { listConversations, getConversationUnreadCounts } from '@commonpub/server';
|
|
2
2
|
import { users } from '@commonpub/schema';
|
|
3
3
|
import { inArray } from 'drizzle-orm';
|
|
4
4
|
|
|
@@ -6,11 +6,14 @@ export default defineEventHandler(async (event) => {
|
|
|
6
6
|
const db = useDB();
|
|
7
7
|
const user = requireAuth(event);
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const [conversationList, unreadCounts] = await Promise.all([
|
|
10
|
+
listConversations(db, user.id),
|
|
11
|
+
getConversationUnreadCounts(db, user.id),
|
|
12
|
+
]);
|
|
10
13
|
|
|
11
14
|
// Collect all unique participant IDs
|
|
12
15
|
const allIds = new Set<string>();
|
|
13
|
-
for (const conv of
|
|
16
|
+
for (const conv of conversationList) {
|
|
14
17
|
for (const id of (conv.participants ?? [])) {
|
|
15
18
|
allIds.add(id);
|
|
16
19
|
}
|
|
@@ -28,9 +31,10 @@ export default defineEventHandler(async (event) => {
|
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
// Replace participant IDs with resolved user objects
|
|
32
|
-
return
|
|
34
|
+
// Replace participant IDs with resolved user objects, include unread count
|
|
35
|
+
return conversationList.map((conv) => ({
|
|
33
36
|
...conv,
|
|
37
|
+
unreadCount: unreadCounts[conv.id] ?? 0,
|
|
34
38
|
participants: (conv.participants ?? []).map((id: string) => {
|
|
35
39
|
const u = userMap.get(id);
|
|
36
40
|
return u ? { username: u.username, displayName: u.displayName, avatarUrl: u.avatarUrl } : { username: id, displayName: null, avatarUrl: null };
|