@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.
- package/components/MessageThread.vue +45 -8
- package/components/views/ArticleView.vue +4 -4
- package/components/views/BlogView.vue +4 -4
- package/components/views/ExplainerView.vue +24 -15
- package/package.json +6 -6
- 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/mirror/[id].vue +1 -1
- 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
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/mirror/[id].vue
CHANGED
|
@@ -162,7 +162,7 @@ useSeoMeta({
|
|
|
162
162
|
.cpub-mirror-title { font-size: 2rem; font-weight: 800; line-height: 1.2; margin-bottom: 12px; }
|
|
163
163
|
.cpub-mirror-desc { font-size: 1.0625rem; color: var(--text-dim); line-height: 1.6; margin-bottom: 16px; }
|
|
164
164
|
.cpub-mirror-author { font-size: 0.875rem; color: var(--text-dim); margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 12px; }
|
|
165
|
-
.cpub-mirror-author-avatar { width: 40px; height: 40px;
|
|
165
|
+
.cpub-mirror-author-avatar { width: 40px; height: 40px; object-fit: cover; border: var(--border-width-default) solid var(--border); flex-shrink: 0; }
|
|
166
166
|
.cpub-mirror-bio { font-size: 0.8125rem; color: var(--text-faint); line-height: 1.5; margin-top: 4px; }
|
|
167
167
|
.cpub-mirror-bio :deep(a) { color: var(--accent); }
|
|
168
168
|
.cpub-mirror-handle { color: var(--text-faint); margin-left: 6px; }
|
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
|
}),
|