@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/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: 20,
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
- const { data: hubsData } = await useFetch('/api/hubs', {
29
- query: { limit: 12 },
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 { data: pathsData } = await useFetch('/api/learn', {
34
- query: { status: 'published', limit: 12 },
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
- const { data: peopleData } = await useFetch('/api/users', {
43
- query: { limit: 20 },
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" style="border-radius: 50%">
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
- .msg-page { padding: 12px; }
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>
@@ -14,26 +14,48 @@ interface ConversationItem {
14
14
  lastMessage: string | null;
15
15
  lastMessageAt: string;
16
16
  createdAt: string;
17
- unread?: boolean;
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 newRecipient = ref('');
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
- if (!newRecipient.value.trim()) return;
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: [newRecipient.value.trim()] },
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
- newRecipient.value = '';
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">Recipient username</label>
76
- <input v-model="newRecipient" type="text" class="cpub-new-msg-input" placeholder="username or @user@remote-instance.com" />
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}`">&times;</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="!newRecipient.trim()"
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.unread }"
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.participants?.[0]?.avatarUrl" :src="conv.participants[0].avatarUrl" :alt="conv.participants[0].displayName || conv.participants[0].username" class="cpub-conv-avatar-img" />
106
- <span v-else>{{ (conv.participants?.[0]?.displayName || conv.participants?.[0]?.username || '?').charAt(0).toUpperCase() }}</span>
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">{{ conv.participants?.map(p => p.displayName || p.username).join(', ') ?? 'Unknown' }}</div>
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
- <time class="cpub-conv-time">
113
- {{ new Date(conv.lastMessageAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
114
- </time>
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; border-radius: inherit; }
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
- flex-shrink: 0;
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;
@@ -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; border-radius: 50%; object-fit: cover; border: var(--border-width-default) solid var(--border); flex-shrink: 0; }
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; }
@@ -9,23 +9,45 @@ useSeoMeta({
9
9
  description: () => `Content tagged with "${tagSlug.value}" on CommonPub`,
10
10
  });
11
11
 
12
- const page = ref(0);
13
- const { data: results, refresh } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
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: 20,
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
- page.value++;
28
- await refresh();
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">Load more</button>
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
  }),