@commonpub/layer 0.3.6 → 0.3.8

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.
@@ -24,22 +24,22 @@ const { user } = useAuth();
24
24
 
25
25
  const isFederated = computed(() => !!props.federatedContentId);
26
26
  const commentLimit = 20;
27
+ const replySent = ref(false);
27
28
 
28
- const queryParams = computed(() => ({
29
- targetType: props.targetType,
30
- targetId: props.targetId,
31
- limit: commentLimit,
32
- }));
29
+ const commentUrl = computed(() =>
30
+ isFederated.value
31
+ ? `/api/federation/content/${props.federatedContentId}/replies`
32
+ : '/api/social/comments',
33
+ );
33
34
 
34
- // Skip comment fetch for federated content — local comments table has no record for remote IDs
35
- const { data: comments, refresh } = await useFetch<Comment[]>('/api/social/comments', {
36
- query: queryParams,
37
- lazy: true,
38
- immediate: !props.federatedContentId,
39
- });
35
+ const queryParams = computed(() =>
36
+ isFederated.value ? undefined : { targetType: props.targetType, targetId: props.targetId, limit: commentLimit },
37
+ );
38
+
39
+ const { data: comments, refresh } = await useFetch<Comment[]>(commentUrl, { query: queryParams, lazy: true });
40
40
 
41
41
  const allCommentsLoaded = ref(false);
42
- const hasMore = computed(() => !allCommentsLoaded.value && (comments.value?.length ?? 0) >= commentLimit);
42
+ const hasMore = computed(() => !isFederated.value && !allCommentsLoaded.value && (comments.value?.length ?? 0) >= commentLimit);
43
43
  const loadingMore = ref(false);
44
44
 
45
45
  async function loadMoreComments(): Promise<void> {
@@ -91,9 +91,11 @@ async function submitComment(): Promise<void> {
91
91
  });
92
92
  }
93
93
  newComment.value = '';
94
- if (!props.federatedContentId) {
95
- await refresh();
94
+ if (props.federatedContentId) {
95
+ replySent.value = true;
96
+ setTimeout(() => { replySent.value = false; }, 5000);
96
97
  }
98
+ await refresh();
97
99
  } finally {
98
100
  submitting.value = false;
99
101
  }
@@ -138,6 +140,11 @@ async function deleteComment(id: string): Promise<void> {
138
140
  <NuxtLink to="/auth/login" class="cpub-link">Log in</NuxtLink> to comment.
139
141
  </p>
140
142
 
143
+ <!-- Reply sent confirmation -->
144
+ <div v-if="replySent" class="cpub-reply-sent">
145
+ <i class="fa-solid fa-check-circle"></i> Reply sent to the original instance.
146
+ </div>
147
+
141
148
  <!-- Comments list -->
142
149
  <div class="cpub-comment-list">
143
150
  <div v-for="comment in (comments || [])" :key="comment.id" class="cpub-comment">
@@ -165,7 +172,7 @@ async function deleteComment(id: string): Promise<void> {
165
172
  </button>
166
173
  </div>
167
174
  </div>
168
- <p v-if="!comments?.length" class="cpub-comments-empty">No comments yet. Be the first!</p>
175
+ <p v-if="!comments?.length" class="cpub-comments-empty">{{ isFederated ? 'No replies received yet.' : 'No comments yet. Be the first!' }}</p>
169
176
  <div v-if="hasMore" class="cpub-comments-more">
170
177
  <button class="cpub-btn cpub-btn-sm" :disabled="loadingMore" @click="loadMoreComments">
171
178
  {{ loadingMore ? 'Loading...' : 'Load more comments' }}
@@ -327,4 +334,16 @@ async function deleteComment(id: string): Promise<void> {
327
334
  text-align: center;
328
335
  padding: var(--space-4) 0;
329
336
  }
337
+
338
+ .cpub-reply-sent {
339
+ font-size: 12px;
340
+ color: var(--green, #22c55e);
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 6px;
344
+ margin-bottom: 12px;
345
+ padding: 8px 12px;
346
+ background: var(--green-bg, rgba(34, 197, 94, 0.08));
347
+ border: 1px solid var(--green-border, rgba(34, 197, 94, 0.2));
348
+ }
330
349
  </style>
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ /** The AP actor URI to follow (user or Group hub) */
4
+ actorUri: string;
5
+ /** Label for what's being followed */
6
+ label?: string;
7
+ }>();
8
+
9
+ const open = ref(false);
10
+ const handle = ref('');
11
+ const error = ref('');
12
+
13
+ function show(): void {
14
+ open.value = true;
15
+ handle.value = '';
16
+ error.value = '';
17
+ }
18
+
19
+ function close(): void {
20
+ open.value = false;
21
+ }
22
+
23
+ function submit(): void {
24
+ error.value = '';
25
+ const input = handle.value.trim().replace(/^@/, '');
26
+ const parts = input.split('@');
27
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
28
+ error.value = 'Enter a valid handle like @user@instance.social';
29
+ return;
30
+ }
31
+ const domain = parts[1];
32
+ // Standard Mastodon/AP remote interaction endpoint
33
+ const url = `https://${domain}/authorize_interaction?uri=${encodeURIComponent(props.actorUri)}`;
34
+ window.open(url, '_blank', 'noopener,noreferrer');
35
+ close();
36
+ }
37
+
38
+ defineExpose({ show });
39
+ </script>
40
+
41
+ <template>
42
+ <Teleport to="body">
43
+ <div v-if="open" class="cpub-rfd-overlay" @click.self="close">
44
+ <div class="cpub-rfd-dialog" role="dialog" aria-modal="true">
45
+ <div class="cpub-rfd-header">
46
+ <h3>Follow from your instance</h3>
47
+ <button class="cpub-rfd-close" aria-label="Close" @click="close">
48
+ <i class="fa-solid fa-xmark"></i>
49
+ </button>
50
+ </div>
51
+ <p class="cpub-rfd-desc">
52
+ Enter your fediverse handle to follow {{ label || 'this account' }} from your instance.
53
+ </p>
54
+ <form class="cpub-rfd-form" @submit.prevent="submit">
55
+ <input
56
+ v-model="handle"
57
+ type="text"
58
+ class="cpub-input"
59
+ placeholder="@you@your-instance.social"
60
+ aria-label="Your fediverse handle"
61
+ autofocus
62
+ />
63
+ <p v-if="error" class="cpub-rfd-error">{{ error }}</p>
64
+ <div class="cpub-rfd-actions">
65
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="close">Cancel</button>
66
+ <button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="!handle.trim()">
67
+ <i class="fa-solid fa-arrow-up-right-from-square"></i> Follow
68
+ </button>
69
+ </div>
70
+ </form>
71
+ </div>
72
+ </div>
73
+ </Teleport>
74
+ </template>
75
+
76
+ <style scoped>
77
+ .cpub-rfd-overlay {
78
+ position: fixed; inset: 0; z-index: 9999;
79
+ background: rgba(0, 0, 0, 0.5); display: flex;
80
+ align-items: center; justify-content: center;
81
+ padding: 16px;
82
+ }
83
+ .cpub-rfd-dialog {
84
+ background: var(--bg); border: var(--border-width-default) solid var(--border);
85
+ width: 100%; max-width: 420px; padding: 24px;
86
+ box-shadow: 4px 4px 0 var(--shadow);
87
+ }
88
+ .cpub-rfd-header {
89
+ display: flex; align-items: center; justify-content: space-between;
90
+ margin-bottom: 12px;
91
+ }
92
+ .cpub-rfd-header h3 { font-size: 14px; font-weight: 700; margin: 0; }
93
+ .cpub-rfd-close {
94
+ background: none; border: none; cursor: pointer;
95
+ color: var(--text-dim); font-size: 14px; padding: 4px;
96
+ }
97
+ .cpub-rfd-close:hover { color: var(--text); }
98
+ .cpub-rfd-desc { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin-bottom: 16px; }
99
+ .cpub-rfd-form { display: flex; flex-direction: column; gap: 12px; }
100
+ .cpub-rfd-form .cpub-input {
101
+ width: 100%; font-size: 13px; padding: 8px 12px;
102
+ border: var(--border-width-default) solid var(--border);
103
+ background: var(--surface); color: var(--text);
104
+ }
105
+ .cpub-rfd-error { font-size: 11px; color: var(--red, #ef4444); margin: 0; }
106
+ .cpub-rfd-actions { display: flex; justify-content: flex-end; gap: 8px; }
107
+ </style>
@@ -15,6 +15,12 @@ onMounted(() => {
15
15
  fetchInitialState(props.content?.likeCount ?? 0);
16
16
  });
17
17
 
18
+ const authorUrl = computed(() =>
19
+ isFederated.value && props.content.author?.profileUrl
20
+ ? props.content.author.profileUrl
21
+ : `/u/${props.content.author?.username}`,
22
+ );
23
+
18
24
  // Extract headings from block content for TOC
19
25
  const tocHeadings = computed(() => {
20
26
  const blocks = props.content?.content;
@@ -99,12 +105,12 @@ useJsonLd({
99
105
 
100
106
  <!-- AUTHOR ROW -->
101
107
  <div class="cpub-author-row">
102
- <NuxtLink v-if="content.author" :to="`/u/${content.author.username}`" style="text-decoration:none;">
108
+ <NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" style="text-decoration:none;">
103
109
  <img v-if="content.author?.avatarUrl" :src="content.author.avatarUrl" :alt="content.author?.displayName ?? content.author?.username ?? ''" class="cpub-av cpub-av-lg" style="object-fit:cover;border:2px solid var(--border);" />
104
110
  <div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
105
111
  </NuxtLink>
106
112
  <div class="cpub-author-info">
107
- <NuxtLink v-if="content.author" :to="`/u/${content.author.username}`" class="cpub-author-name">
113
+ <NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-author-name">
108
114
  {{ content.author.displayName || content.author.username }}
109
115
  <i v-if="content.author.verified" class="fa-solid fa-circle-check cpub-verified" title="Verified"></i>
110
116
  </NuxtLink>
@@ -116,7 +122,7 @@ useJsonLd({
116
122
  <span><i class="fa-regular fa-clock"></i> {{ content.readTime || '5 min read' }}</span>
117
123
  <template v-if="content.tags?.length">
118
124
  <span class="cpub-sep">·</span>
119
- <span class="cpub-tag cpub-tag-teal">{{ content.tags[0]?.name || content.tags[0] }}</span>
125
+ <NuxtLink :to="`/tags/${content.tags[0]?.slug || (content.tags[0]?.name || String(content.tags[0])).toLowerCase().replace(/\s+/g, '-')}`" class="cpub-tag cpub-tag-teal">{{ content.tags[0]?.name || content.tags[0] }}</NuxtLink>
120
126
  </template>
121
127
  </div>
122
128
  </div>
@@ -157,14 +163,15 @@ useJsonLd({
157
163
  <!-- TAGS -->
158
164
  <div v-if="content.tags?.length" class="cpub-tags-row">
159
165
  <div class="cpub-tags-label">Filed under</div>
160
- <span
166
+ <NuxtLink
161
167
  v-for="(tag, i) in content.tags"
162
168
  :key="tag.id || tag.name || i"
169
+ :to="`/tags/${tag.slug || (tag.name || String(tag)).toLowerCase().replace(/\s+/g, '-')}`"
163
170
  class="cpub-tag"
164
171
  :class="{ 'cpub-tag-accent': i === 0 }"
165
172
  >
166
173
  {{ tag.name || tag }}
167
- </span>
174
+ </NuxtLink>
168
175
  </div>
169
176
 
170
177
  <!-- AUTHOR CARD -->
@@ -412,7 +419,9 @@ useJsonLd({
412
419
  border: 1px solid var(--border2);
413
420
  color: var(--text-dim);
414
421
  background: var(--surface2);
422
+ text-decoration: none;
415
423
  }
424
+ .cpub-tag:hover { color: var(--accent); border-color: var(--accent-border); }
416
425
 
417
426
  .cpub-tag-accent {
418
427
  border-color: var(--accent-border);
@@ -15,6 +15,12 @@ onMounted(() => {
15
15
  fetchInitialState(props.content?.likeCount ?? 0);
16
16
  });
17
17
 
18
+ const authorUrl = computed(() =>
19
+ isFederated.value && props.content.author?.profileUrl
20
+ ? props.content.author.profileUrl
21
+ : `/u/${props.content.author?.username}`,
22
+ );
23
+
18
24
  const config = useRuntimeConfig();
19
25
  useJsonLd({
20
26
  type: 'article',
@@ -47,12 +53,12 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
47
53
 
48
54
  <!-- AUTHOR ROW -->
49
55
  <div class="cpub-author-row">
50
- <NuxtLink v-if="content.author" :to="`/u/${content.author.username}`" style="text-decoration:none;">
56
+ <NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" style="text-decoration:none;">
51
57
  <img v-if="content.author?.avatarUrl" :src="content.author.avatarUrl" :alt="content.author?.displayName ?? content.author?.username ?? ''" class="cpub-av cpub-av-lg" style="object-fit:cover;border:2px solid var(--border);" />
52
58
  <div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
53
59
  </NuxtLink>
54
60
  <div class="cpub-author-info">
55
- <NuxtLink v-if="content.author" :to="`/u/${content.author.username}`" class="cpub-author-name">
61
+ <NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-author-name">
56
62
  {{ content.author.displayName || content.author.username }}
57
63
  </NuxtLink>
58
64
  <div class="cpub-author-meta">
@@ -140,14 +146,15 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
140
146
  <!-- TAGS -->
141
147
  <div v-if="content.tags?.length" class="cpub-tags-row">
142
148
  <div class="cpub-tags-label">Tags</div>
143
- <span
149
+ <NuxtLink
144
150
  v-for="(tag, i) in content.tags"
145
151
  :key="tag.id || tag.name || i"
152
+ :to="`/tags/${tag.slug || (tag.name || String(tag)).toLowerCase().replace(/\s+/g, '-')}`"
146
153
  class="cpub-tag"
147
154
  :class="{ 'cpub-tag-pink': i === 0 }"
148
155
  >
149
156
  {{ tag.name || tag }}
150
- </span>
157
+ </NuxtLink>
151
158
  </div>
152
159
 
153
160
  <!-- AUTHOR CARD -->
@@ -276,7 +283,9 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
276
283
  border: 1px solid var(--border2);
277
284
  color: var(--text-dim);
278
285
  background: var(--surface2);
286
+ text-decoration: none;
279
287
  }
288
+ .cpub-tag:hover { color: var(--accent); border-color: var(--accent-border); }
280
289
 
281
290
  .cpub-tag-pink {
282
291
  border-color: var(--pink-border);
@@ -80,11 +80,21 @@ const completedSections = ref<Set<number>>(new Set());
80
80
  const contentId = computed(() => props.content?.id);
81
81
  const contentType = computed(() => props.content?.type ?? 'explainer');
82
82
  const fedId = computed(() => props.federatedId);
83
- const { bookmarked, toggleBookmark, share } = useEngagement({ contentId, contentType, federatedContentId: fedId });
83
+ const { liked, bookmarked, likeCount, isFederated, toggleLike, toggleBookmark, share, fetchInitialState } = useEngagement({ contentId, contentType, federatedContentId: fedId });
84
+
85
+ onMounted(() => {
86
+ fetchInitialState(props.content?.likeCount ?? 0);
87
+ });
84
88
 
85
89
  const { user } = useAuth();
86
90
  const isOwner = computed(() => user.value?.id === props.content?.author?.id);
87
91
 
92
+ const authorUrl = computed(() =>
93
+ isFederated.value && props.content.author?.profileUrl
94
+ ? props.content.author.profileUrl
95
+ : `/u/${props.content.author?.username}`,
96
+ );
97
+
88
98
  const runtimeConfig = useRuntimeConfig();
89
99
  useJsonLd({
90
100
  type: 'article',
@@ -166,6 +176,9 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
166
176
  </button>
167
177
  </div>
168
178
  <div class="cpub-topbar-divider"></div>
179
+ <button class="cpub-icon-btn" :class="{ active: liked }" title="Like" @click="toggleLike">
180
+ <i :class="liked ? 'fa-solid fa-heart' : 'fa-regular fa-heart'"></i>
181
+ </button>
169
182
  <button class="cpub-icon-btn" :class="{ active: bookmarked }" title="Bookmark" @click="toggleBookmark">
170
183
  <i :class="bookmarked ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark'"></i>
171
184
  </button>
@@ -208,12 +221,12 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
208
221
 
209
222
  <!-- Author info -->
210
223
  <div v-if="content.author" class="cpub-sidebar-author">
211
- <NuxtLink :to="`/u/${content.author.username}`" class="cpub-sidebar-author-avatar">
224
+ <NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-sidebar-author-avatar">
212
225
  <img v-if="content.author.avatarUrl" :src="content.author.avatarUrl" :alt="content.author.displayName || content.author.username" />
213
226
  <span v-else class="cpub-sidebar-author-initials">{{ (content.author.displayName || content.author.username).charAt(0).toUpperCase() }}</span>
214
227
  </NuxtLink>
215
228
  <div class="cpub-sidebar-author-info">
216
- <NuxtLink :to="`/u/${content.author.username}`" class="cpub-sidebar-author-name">
229
+ <NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-sidebar-author-name">
217
230
  {{ content.author.displayName || content.author.username }}
218
231
  </NuxtLink>
219
232
  <time class="cpub-sidebar-author-date" :datetime="new Date(content.publishedAt || content.createdAt).toISOString()">
@@ -233,7 +246,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
233
246
 
234
247
  <!-- Author byline (mobile only — desktop shows in sidebar) -->
235
248
  <div v-if="activeSection === 0 && content.author" class="cpub-mobile-author">
236
- <NuxtLink :to="`/u/${content.author.username}`" class="cpub-mobile-author-link">
249
+ <NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-mobile-author-link">
237
250
  {{ content.author.displayName || content.author.username }}
238
251
  </NuxtLink>
239
252
  <span class="cpub-mobile-author-sep">&middot;</span>
@@ -66,6 +66,12 @@ const difficultyLevel = computed(() => {
66
66
  return 3;
67
67
  });
68
68
 
69
+ const authorUrl = computed(() =>
70
+ isFederated.value && props.content.author?.profileUrl
71
+ ? props.content.author.profileUrl
72
+ : `/u/${props.content.author?.username}`,
73
+ );
74
+
69
75
  const formattedDate = computed(() => {
70
76
  const date = props.content?.publishedAt || props.content?.createdAt;
71
77
  if (!date) return '';
@@ -258,7 +264,10 @@ const forking = ref(false);
258
264
  async function handleFork(): Promise<void> {
259
265
  forking.value = true;
260
266
  try {
261
- const result = await $fetch<{ slug: string; type: string }>(`/api/content/${props.content.id}/fork`, { method: 'POST' });
267
+ const url = isFederated.value
268
+ ? `/api/federation/content/${props.federatedId}/fork`
269
+ : `/api/content/${props.content.id}/fork`;
270
+ const result = await $fetch<{ slug: string; type: string }>(url, { method: 'POST' });
262
271
  await navigateTo(`/${result.type}/${result.slug}/edit`);
263
272
  } catch {
264
273
  // fork failed silently
@@ -274,7 +283,10 @@ const buildToggling = ref(false);
274
283
  async function handleBuild(): Promise<void> {
275
284
  buildToggling.value = true;
276
285
  try {
277
- const result = await $fetch<{ marked: boolean; count: number }>(`/api/content/${props.content.id}/build`, { method: 'POST' });
286
+ const url = isFederated.value
287
+ ? `/api/federation/content/${props.federatedId}/build`
288
+ : `/api/content/${props.content.id}/build`;
289
+ const result = await $fetch<{ marked: boolean; count: number }>(url, { method: 'POST' });
278
290
  buildMarked.value = result.marked;
279
291
  localBuildCount.value = result.count;
280
292
  } catch {
@@ -326,7 +338,7 @@ async function handleBuild(): Promise<void> {
326
338
 
327
339
  <!-- Author Row -->
328
340
  <div class="cpub-author-row">
329
- <NuxtLink :to="`/u/${content.author?.username}`" class="cpub-av-link">
341
+ <NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-av-link">
330
342
  <img
331
343
  v-if="content.author?.avatarUrl"
332
344
  :src="content.author.avatarUrl"
@@ -336,7 +348,7 @@ async function handleBuild(): Promise<void> {
336
348
  <div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
337
349
  </NuxtLink>
338
350
  <div>
339
- <NuxtLink :to="`/u/${content.author?.username}`" class="cpub-author-name cpub-author-link">
351
+ <NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-author-name cpub-author-link">
340
352
  {{ content.author?.displayName || content.author?.username || 'Author' }}
341
353
  </NuxtLink>
342
354
  <div class="cpub-author-meta-row">
@@ -351,7 +363,7 @@ async function handleBuild(): Promise<void> {
351
363
  <a v-if="content.githubUrl" :href="content.githubUrl" target="_blank" rel="noopener" class="cpub-author-detail cpub-author-detail-link"><i class="fa-brands fa-github"></i> Source</a>
352
364
  <template v-if="content.tags?.length">
353
365
  <span class="cpub-meta-sep">&bull;</span>
354
- <span v-for="tag in content.tags.slice(0, 5)" :key="tag.id || tag.name || String(tag)" class="cpub-author-tag">{{ tag.name || tag }}</span>
366
+ <NuxtLink v-for="tag in content.tags.slice(0, 5)" :key="tag.id || tag.name || String(tag)" :to="`/tags/${tag.slug || (tag.name || String(tag)).toLowerCase().replace(/\s+/g, '-')}`" class="cpub-author-tag">{{ tag.name || tag }}</NuxtLink>
355
367
  </template>
356
368
  </div>
357
369
 
@@ -773,8 +785,9 @@ async function handleBuild(): Promise<void> {
773
785
  .cpub-author-tag {
774
786
  font-size: 9px; font-family: var(--font-mono); text-transform: uppercase;
775
787
  letter-spacing: 0.04em; color: var(--text-faint); padding: 1px 6px;
776
- border: 1px solid var(--border); background: var(--surface);
788
+ border: 1px solid var(--border); background: var(--surface); text-decoration: none;
777
789
  }
790
+ .cpub-author-tag:hover { color: var(--accent); border-color: var(--accent); }
778
791
 
779
792
  .cpub-fork-count {
780
793
  font-size: 11px;
@@ -55,6 +55,8 @@ export interface ContentViewData {
55
55
  username: string;
56
56
  displayName: string | null;
57
57
  avatarUrl: string | null;
58
+ /** Remote actor URI — set for federated content, used as external profile link */
59
+ profileUrl?: string | null;
58
60
  bio?: string | null;
59
61
  headline?: string | null;
60
62
  verified?: boolean;
@@ -130,8 +132,6 @@ export function useEngagement(opts: EngagementOptions) {
130
132
 
131
133
  async function toggleBookmark(): Promise<void> {
132
134
  if (!contentId.value) return;
133
- // Bookmarks are local-only — skip for federated content (no local record to target)
134
- if (isFederated.value) return;
135
135
  const prev = bookmarked.value;
136
136
  bookmarked.value = !bookmarked.value;
137
137
 
@@ -164,19 +164,25 @@ export function useEngagement(opts: EngagementOptions) {
164
164
  async function fetchInitialState(likes: number): Promise<void> {
165
165
  likeCount.value = likes;
166
166
  if (!contentId.value) return;
167
- // Skip state fetch for federated content — no local like/bookmark records
168
- if (isFederated.value) return;
169
167
  try {
170
- const [likeRes, bmRes] = await Promise.all([
171
- $fetch<{ liked: boolean }>('/api/social/like', {
172
- params: { targetType: contentType.value, targetId: contentId.value },
173
- }).catch(() => ({ liked: false })),
174
- $fetch<{ bookmarked: boolean }>('/api/social/bookmark', {
168
+ if (isFederated.value) {
169
+ // Federated: likes are tracked via AP (no local like record), but bookmarks are local
170
+ const bmRes = await $fetch<{ bookmarked: boolean }>('/api/social/bookmark', {
175
171
  params: { targetType: contentType.value, targetId: contentId.value },
176
- }).catch(() => ({ bookmarked: false })),
177
- ]);
178
- liked.value = likeRes.liked;
179
- bookmarked.value = bmRes.bookmarked;
172
+ }).catch(() => ({ bookmarked: false }));
173
+ bookmarked.value = bmRes.bookmarked;
174
+ } else {
175
+ const [likeRes, bmRes] = await Promise.all([
176
+ $fetch<{ liked: boolean }>('/api/social/like', {
177
+ params: { targetType: contentType.value, targetId: contentId.value },
178
+ }).catch(() => ({ liked: false })),
179
+ $fetch<{ bookmarked: boolean }>('/api/social/bookmark', {
180
+ params: { targetType: contentType.value, targetId: contentId.value },
181
+ }).catch(() => ({ bookmarked: false })),
182
+ ]);
183
+ liked.value = likeRes.liked;
184
+ bookmarked.value = bmRes.bookmarked;
185
+ }
180
186
  } catch {
181
187
  // Non-critical — default to false
182
188
  }
@@ -77,6 +77,7 @@ export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>
77
77
  username: (actor.value?.preferredUsername as string) || 'unknown',
78
78
  displayName: (actor.value?.displayName as string) || (actor.value?.preferredUsername as string) || 'Unknown',
79
79
  avatarUrl: (actor.value?.avatarUrl as string) || null,
80
+ profileUrl: (actor.value?.actorUri as string) || null,
80
81
  },
81
82
  buildCount: 0,
82
83
  bookmarkCount: 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -46,13 +46,13 @@
46
46
  "zod": "^4.3.6",
47
47
  "@commonpub/auth": "0.5.0",
48
48
  "@commonpub/config": "0.7.0",
49
- "@commonpub/docs": "0.5.2",
50
49
  "@commonpub/editor": "0.5.0",
50
+ "@commonpub/docs": "0.5.2",
51
51
  "@commonpub/learning": "0.5.0",
52
52
  "@commonpub/protocol": "0.9.4",
53
- "@commonpub/server": "2.7.0",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/schema": "0.8.8"
53
+ "@commonpub/server": "2.8.0",
54
+ "@commonpub/schema": "0.8.9",
55
+ "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -10,6 +10,18 @@ const { data: posts, refresh: refreshPosts } = useLazyFetch<{ items: FederatedHu
10
10
  default: () => ({ items: [], total: 0 }),
11
11
  });
12
12
 
13
+ interface FederatedMember {
14
+ actorUri: string;
15
+ preferredUsername: string | null;
16
+ displayName: string | null;
17
+ avatarUrl: string | null;
18
+ instanceDomain: string;
19
+ postCount: number;
20
+ }
21
+ const { data: members } = useLazyFetch<FederatedMember[]>(`/api/federated-hubs/${id}/members`, {
22
+ default: () => [],
23
+ });
24
+
13
25
  useSeoMeta({
14
26
  title: () => hub.value ? `${hub.value.name} — ${useSiteName()}` : 'Federated Hub',
15
27
  description: () => hub.value?.description || '',
@@ -187,29 +199,10 @@ async function handleDiscPost(): Promise<void> {
187
199
  }
188
200
  }
189
201
 
190
- // --- Follow hub ---
191
- const followStatus = computed(() => hub.value?.followStatus ?? 'pending');
192
- const following = ref(false);
202
+ // --- Instance mirror status (not user-level follow) ---
203
+ const mirrorStatus = computed(() => hub.value?.followStatus ?? 'pending');
193
204
 
194
- async function handleFollowHub(): Promise<void> {
195
- if (!isAuthenticated.value) {
196
- await navigateTo(`/auth/login?redirect=/federated-hubs/${id}`);
197
- return;
198
- }
199
- following.value = true;
200
- try {
201
- const result = await $fetch<{ status: string }>('/api/federation/hub-follow' as string, {
202
- method: 'POST',
203
- body: { federatedHubId: id },
204
- });
205
- if (hub.value) (hub.value as unknown as Record<string, unknown>).followStatus = result.status;
206
- toast.success(result.status === 'accepted' ? 'Following!' : 'Follow request sent');
207
- } catch {
208
- toast.error('Failed to follow hub');
209
- } finally {
210
- following.value = false;
211
- }
212
- }
205
+ const remoteFollowRef = ref<InstanceType<typeof RemoteFollowDialog> | null>(null);
213
206
 
214
207
  // --- Like state tracking ---
215
208
  const likedPostIds = ref<Set<string>>(new Set());
@@ -273,20 +266,14 @@ async function handlePostVote(postId: string): Promise<void> {
273
266
  </div>
274
267
  </template>
275
268
  <template #actions>
276
- <button
277
- v-if="followStatus !== 'accepted'"
278
- class="cpub-btn cpub-btn-primary"
279
- :disabled="following || followStatus === 'pending'"
280
- @click="handleFollowHub"
281
- >
282
- <i class="fa-solid fa-rss"></i>
283
- {{ followStatus === 'pending' ? 'Follow Pending...' : 'Follow Hub' }}
284
- </button>
285
- <span v-else class="cpub-member-badge">
286
- <i class="fa-solid fa-check"></i> Following
269
+ <span v-if="mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-mirrored">
270
+ <i class="fa-solid fa-globe"></i> Mirrored
287
271
  </span>
272
+ <button v-if="hub?.actorUri" class="cpub-btn cpub-btn-primary cpub-btn-sm" @click="remoteFollowRef?.show()">
273
+ <i class="fa-solid fa-user-plus"></i> Join from your instance
274
+ </button>
288
275
  <a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm">
289
- <i class="fa-solid fa-arrow-up-right-from-square"></i> Visit
276
+ <i class="fa-solid fa-arrow-up-right-from-square"></i> Visit original
290
277
  </a>
291
278
  </template>
292
279
  <template #badges>
@@ -389,18 +376,35 @@ async function handlePostVote(postId: string): Promise<void> {
389
376
 
390
377
  <!-- Members tab -->
391
378
  <div v-else-if="activeTab === 'members'" class="cpub-hub-members-tab">
392
- <div class="cpub-fed-members-info">
393
- <div class="cpub-fed-members-stat">
394
- <i class="fa-solid fa-users"></i>
395
- <strong>{{ hub?.memberCount ?? 0 }}</strong> members
396
- </div>
397
- <p class="cpub-fed-members-note">
398
- Members are managed on <strong>{{ hub?.originDomain }}</strong>. Visit the original hub to see the full member list and join.
399
- </p>
400
- <a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm" style="margin-top: 12px; display: inline-flex">
401
- <i class="fa-solid fa-arrow-up-right-from-square"></i> View Members on {{ hub?.originDomain }}
379
+ <div class="cpub-fed-members-stat">
380
+ <i class="fa-solid fa-users"></i>
381
+ <strong>{{ hub?.memberCount ?? 0 }}</strong> members on {{ hub?.originDomain }}
382
+ <span v-if="members?.length" class="cpub-fed-members-known">&middot; {{ members.length }} known</span>
383
+ </div>
384
+
385
+ <div v-if="members?.length" class="cpub-fed-members-list">
386
+ <a
387
+ v-for="m in members"
388
+ :key="m.actorUri"
389
+ :href="m.actorUri"
390
+ target="_blank"
391
+ rel="noopener noreferrer"
392
+ class="cpub-fed-member"
393
+ >
394
+ <img v-if="m.avatarUrl" :src="m.avatarUrl" :alt="m.displayName || m.preferredUsername || ''" class="cpub-fed-member-avatar" />
395
+ <div v-else class="cpub-fed-member-avatar cpub-fed-member-avatar-fallback">{{ (m.displayName || m.preferredUsername || '?').charAt(0).toUpperCase() }}</div>
396
+ <div class="cpub-fed-member-info">
397
+ <div class="cpub-fed-member-name">{{ m.displayName || m.preferredUsername || 'Unknown' }}</div>
398
+ <div class="cpub-fed-member-handle">@{{ m.preferredUsername || 'unknown' }}@{{ m.instanceDomain }}</div>
399
+ </div>
400
+ <div class="cpub-fed-member-posts">{{ m.postCount }} {{ m.postCount === 1 ? 'post' : 'posts' }}</div>
402
401
  </a>
403
402
  </div>
403
+ <p v-else class="cpub-fed-members-empty">No known members yet. Members appear here as they post to the hub.</p>
404
+
405
+ <a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm cpub-fed-members-origin-link">
406
+ <i class="fa-solid fa-arrow-up-right-from-square"></i> View all members on {{ hub?.originDomain }}
407
+ </a>
404
408
  </div>
405
409
 
406
410
  <!-- Overview tab (product/company hubs) -->
@@ -450,6 +454,8 @@ async function handlePostVote(postId: string): Promise<void> {
450
454
  </HubSidebar>
451
455
  </template>
452
456
  </HubLayout>
457
+
458
+ <RemoteFollowDialog v-if="hub?.actorUri" ref="remoteFollowRef" :actor-uri="hub.actorUri" :label="hub?.name" />
453
459
  </template>
454
460
 
455
461
  <style scoped>
@@ -539,18 +545,39 @@ async function handlePostVote(postId: string): Promise<void> {
539
545
 
540
546
  /* Members tab (federated) */
541
547
  .cpub-hub-members-tab { padding: 0; }
542
- .cpub-fed-members-info {
543
- background: var(--surface); border: var(--border-width-default) solid var(--border);
544
- padding: 32px; text-align: center;
545
- }
546
548
  .cpub-fed-members-stat {
547
- font-size: 20px; font-weight: 700; color: var(--text);
548
- display: flex; align-items: center; justify-content: center; gap: 8px;
549
- margin-bottom: 12px;
549
+ font-size: 14px; font-weight: 600; color: var(--text-dim);
550
+ display: flex; align-items: center; gap: 8px;
551
+ margin-bottom: 16px; padding: 0 4px;
550
552
  }
551
553
  .cpub-fed-members-stat i { color: var(--accent); }
552
- .cpub-fed-members-note {
553
- font-size: 13px; color: var(--text-dim); line-height: 1.5; max-width: 400px; margin: 0 auto;
554
+ .cpub-fed-members-known { color: var(--text-faint); font-weight: 400; }
555
+ .cpub-fed-members-list { display: flex; flex-direction: column; gap: 2px; }
556
+ .cpub-fed-member {
557
+ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
558
+ text-decoration: none; color: var(--text); border: var(--border-width-default) solid transparent;
559
+ transition: border-color 0.15s;
560
+ }
561
+ .cpub-fed-member:hover { border-color: var(--border); background: var(--surface); }
562
+ .cpub-fed-member-avatar {
563
+ width: 36px; height: 36px; border-radius: 50%; object-fit: cover;
564
+ border: var(--border-width-default) solid var(--border); flex-shrink: 0;
565
+ }
566
+ .cpub-fed-member-avatar-fallback {
567
+ display: flex; align-items: center; justify-content: center;
568
+ background: var(--surface2); font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--text-dim);
569
+ }
570
+ .cpub-fed-member-info { flex: 1; min-width: 0; }
571
+ .cpub-fed-member-name { font-size: 13px; font-weight: 600; }
572
+ .cpub-fed-member-handle { font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
573
+ .cpub-fed-member-posts { font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); white-space: nowrap; }
574
+ .cpub-fed-members-empty { font-size: 13px; color: var(--text-faint); text-align: center; padding: 32px 16px; }
575
+ .cpub-fed-members-origin-link { margin-top: 16px; display: inline-flex; }
576
+ .cpub-member-badge-mirrored {
577
+ display: inline-flex; align-items: center; gap: 4px;
578
+ font-size: 0.6875rem; font-weight: 600;
579
+ color: var(--accent); background: var(--accent-bg);
580
+ padding: 4px 12px; border: 1px solid var(--accent-border);
554
581
  }
555
582
 
556
583
  /* Sidebar */
@@ -586,6 +613,6 @@ async function handlePostVote(postId: string): Promise<void> {
586
613
  .cpub-fed-indicator { padding: 6px 16px; font-size: 11px; flex-wrap: wrap; }
587
614
  .cpub-compose-bar { padding: 10px 12px; }
588
615
  .cpub-shared-grid { grid-template-columns: 1fr; }
589
- .cpub-fed-members-info { padding: 24px 16px; }
616
+ .cpub-fed-member { padding: 10px 8px; }
590
617
  }
591
618
  </style>
@@ -1,9 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { FederatedContentItem } from '@commonpub/server';
3
3
 
4
- definePageMeta({
5
- middleware: 'auth',
6
- });
4
+ definePageMeta({});
7
5
 
8
6
  useHead({
9
7
  title: 'Federated Timeline',
@@ -1,7 +1,5 @@
1
1
  <script setup lang="ts">
2
- definePageMeta({
3
- middleware: 'auth',
4
- });
2
+ definePageMeta({});
5
3
 
6
4
  useHead({
7
5
  title: 'Find Remote Users — Federation',
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { RemoteActorProfile, FederatedContentItem } from '@commonpub/server';
3
3
 
4
- definePageMeta({ middleware: 'auth' });
4
+ definePageMeta({});
5
5
 
6
6
  const route = useRoute();
7
7
  const handle = computed(() => decodeURIComponent(route.params.handle as string));
@@ -8,12 +8,33 @@ const { data: fedContent, error, pending } = await useFetch<Record<string, unkno
8
8
 
9
9
  const {
10
10
  contentType,
11
+ actor,
11
12
  transformedContent,
12
13
  originDomain,
13
14
  originUrl,
14
15
  authorHandle,
15
16
  } = useMirrorContent(fedContent);
16
17
 
18
+ const { user } = useAuth();
19
+ const following = ref(false);
20
+ const followState = ref<'idle' | 'sent' | 'error'>('idle');
21
+ const remoteFollowRef = ref<InstanceType<typeof RemoteFollowDialog> | null>(null);
22
+
23
+ async function followAuthor(): Promise<void> {
24
+ const uri = actor.value?.actorUri as string | undefined;
25
+ if (!uri) return;
26
+ following.value = true;
27
+ followState.value = 'idle';
28
+ try {
29
+ await $fetch('/api/federation/follow', { method: 'POST', body: { actorUri: uri } });
30
+ followState.value = 'sent';
31
+ } catch {
32
+ followState.value = 'error';
33
+ } finally {
34
+ following.value = false;
35
+ }
36
+ }
37
+
17
38
  // SEO
18
39
  if (originUrl.value) {
19
40
  useHead({
@@ -45,6 +66,20 @@ useSeoMeta({
45
66
  Federated from <strong>{{ originDomain }}</strong>
46
67
  <span v-if="authorHandle" class="cpub-fed-banner-handle">{{ authorHandle }}</span>
47
68
  </span>
69
+ <button
70
+ v-if="user && actor?.actorUri && followState !== 'sent'"
71
+ class="cpub-fed-banner-follow"
72
+ :class="{ 'cpub-fed-banner-follow-error': followState === 'error' }"
73
+ :disabled="following"
74
+ @click="followAuthor"
75
+ >
76
+ <i :class="followState === 'error' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-user-plus'"></i>
77
+ {{ following ? 'Following...' : followState === 'error' ? 'Retry' : 'Follow' }}
78
+ </button>
79
+ <span v-if="followState === 'sent'" class="cpub-fed-banner-followed"><i class="fa-solid fa-check"></i> Follow sent</span>
80
+ <button v-if="actor?.actorUri && !user" class="cpub-fed-banner-follow" @click="remoteFollowRef?.show()">
81
+ <i class="fa-solid fa-user-plus"></i> Follow from your instance
82
+ </button>
48
83
  <a v-if="originUrl" :href="originUrl" target="_blank" rel="noopener noreferrer" class="cpub-fed-banner-link">
49
84
  View Original <i class="fa-solid fa-arrow-up-right-from-square"></i>
50
85
  </a>
@@ -69,11 +104,13 @@ useSeoMeta({
69
104
  </div>
70
105
  <div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
71
106
  <div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
72
- <span v-for="tag in transformedContent.tags" :key="tag.name" class="cpub-mirror-tag">{{ tag.name }}</span>
107
+ <NuxtLink v-for="tag in transformedContent.tags" :key="tag.name" :to="`/tags/${tag.slug || tag.name.toLowerCase().replace(/\s+/g, '-')}`" class="cpub-mirror-tag">{{ tag.name }}</NuxtLink>
73
108
  </div>
74
109
  </div>
75
110
  </article>
76
111
  </template>
112
+
113
+ <RemoteFollowDialog v-if="actor?.actorUri" ref="remoteFollowRef" :actor-uri="(actor.actorUri as string)" :label="transformedContent?.author?.displayName || authorHandle" />
77
114
  </template>
78
115
 
79
116
  <style scoped>
@@ -92,6 +129,14 @@ useSeoMeta({
92
129
  text-decoration: none; white-space: nowrap;
93
130
  display: flex; align-items: center; gap: 4px; font-size: 11px;
94
131
  }
132
+ .cpub-fed-banner-follow {
133
+ margin-left: auto; background: var(--accent); color: #fff; border: none;
134
+ font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer;
135
+ display: flex; align-items: center; gap: 4px; white-space: nowrap;
136
+ }
137
+ .cpub-fed-banner-follow:hover { opacity: 0.9; }
138
+ .cpub-fed-banner-follow:disabled { opacity: 0.6; cursor: default; }
139
+ .cpub-fed-banner-followed { margin-left: auto; font-size: 11px; color: var(--green, #22c55e); font-weight: 600; display: flex; align-items: center; gap: 4px; }
95
140
  .cpub-fed-banner-link:hover { text-decoration: underline; }
96
141
 
97
142
  /* Fallback for non-CommonPub content */
@@ -106,7 +151,8 @@ useSeoMeta({
106
151
  .cpub-mirror-body :deep(a) { color: var(--accent); }
107
152
  .cpub-mirror-body :deep(pre) { background: var(--surface2); padding: 12px; overflow-x: auto; }
108
153
  .cpub-mirror-tags { display: flex; flex-wrap: wrap; gap: 6px; }
109
- .cpub-mirror-tag { font-size: 0.75rem; padding: 3px 8px; background: var(--surface2); color: var(--text-dim); }
154
+ .cpub-mirror-tag { font-size: 0.75rem; padding: 3px 8px; background: var(--surface2); color: var(--text-dim); text-decoration: none; }
155
+ .cpub-mirror-tag:hover { color: var(--accent); }
110
156
 
111
157
  .cpub-not-found { text-align: center; padding: 60px 20px; color: var(--text-dim); }
112
158
  .cpub-not-found h1 { font-size: 1.5rem; color: var(--text); margin-bottom: 8px; }
@@ -11,7 +11,14 @@ import { extractDomain } from '../../../utils/inbox';
11
11
  * Body: { contentId?: string, hubsOnly?: boolean } — if omitted, re-federates ALL
12
12
  */
13
13
  export default defineEventHandler(async (event) => {
14
- requireAdmin(event);
14
+ // Allow CLI trigger via AUTH_SECRET header (for server-side automation)
15
+ const cliSecret = getRequestHeader(event, 'x-admin-secret');
16
+ const runtimeConfig = useRuntimeConfig();
17
+ if (cliSecret && cliSecret === runtimeConfig.authSecret) {
18
+ // Authorized via shared secret
19
+ } else {
20
+ requireAdmin(event);
21
+ }
15
22
 
16
23
  const config = useConfig();
17
24
  if (!config.features.federation) {
@@ -23,7 +30,6 @@ export default defineEventHandler(async (event) => {
23
30
  const hubsOnly = body?.hubsOnly === true;
24
31
 
25
32
  const db = useDB();
26
- const runtimeConfig = useRuntimeConfig();
27
33
  const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
28
34
 
29
35
  if (contentId) {
@@ -0,0 +1,11 @@
1
+ import { listFederatedHubMembers } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ requireFeature('federation');
5
+ requireFeature('federateHubs');
6
+
7
+ const db = useDB();
8
+ const { id } = parseParams(event, { id: 'uuid' });
9
+
10
+ return listFederatedHubMembers(db, id);
11
+ });
@@ -0,0 +1,10 @@
1
+ import { toggleFederatedBuildMark } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event): Promise<{ marked: boolean; count: number }> => {
4
+ requireFeature('federation');
5
+ const user = requireAuth(event);
6
+ const db = useDB();
7
+ const { id } = parseParams(event, { id: 'uuid' });
8
+
9
+ return toggleFederatedBuildMark(db, id, user.id);
10
+ });
@@ -0,0 +1,11 @@
1
+ import { forkFederatedContent } from '@commonpub/server';
2
+ import type { ContentDetail } from '@commonpub/server';
3
+
4
+ export default defineEventHandler(async (event): Promise<ContentDetail> => {
5
+ requireFeature('federation');
6
+ const user = requireAuth(event);
7
+ const db = useDB();
8
+ const { id } = parseParams(event, { id: 'uuid' });
9
+
10
+ return forkFederatedContent(db, id, user.id);
11
+ });
@@ -0,0 +1,30 @@
1
+ import { getFederatedContent, listRemoteReplies } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ requireFeature('federation');
5
+ const db = useDB();
6
+ const { id } = parseParams(event, { id: 'uuid' });
7
+
8
+ // Get the parent content to find its objectUri
9
+ const content = await getFederatedContent(db, id);
10
+ if (!content) {
11
+ throw createError({ statusCode: 404, statusMessage: 'Federated content not found' });
12
+ }
13
+
14
+ const replies = await listRemoteReplies(db, content.objectUri);
15
+
16
+ // Transform to comment-like shape for the UI
17
+ return replies.map((r) => ({
18
+ id: r.id,
19
+ content: r.content ?? r.summary ?? '',
20
+ createdAt: r.publishedAt ?? r.receivedAt,
21
+ author: r.actor ? {
22
+ id: '',
23
+ username: r.actor.preferredUsername ?? 'unknown',
24
+ displayName: r.actor.displayName ?? r.actor.preferredUsername ?? 'Unknown',
25
+ avatarUrl: r.actor.avatarUrl ?? null,
26
+ } : null,
27
+ federated: true,
28
+ originDomain: r.originDomain,
29
+ }));
30
+ });
@@ -8,12 +8,12 @@ const querySchema = z.object({
8
8
 
9
9
  export default defineEventHandler(async (event): Promise<RemoteActorProfile> => {
10
10
  requireFeature('federation');
11
- const user = requireAuth(event);
11
+ const user = getOptionalUser(event);
12
12
  const db = useDB();
13
13
  const config = useConfig();
14
14
  const { uri } = parseQueryParams(event, querySchema);
15
15
 
16
- const profile = await getRemoteActorProfile(db, uri, config.instance.domain, user.id);
16
+ const profile = await getRemoteActorProfile(db, uri, config.instance.domain, user?.id);
17
17
  if (!profile) {
18
18
  throw createError({ statusCode: 404, statusMessage: 'Remote actor not found' });
19
19
  }
@@ -8,10 +8,10 @@ const searchSchema = z.object({
8
8
 
9
9
  export default defineEventHandler(async (event): Promise<RemoteActorProfile | null> => {
10
10
  requireFeature('federation');
11
- const user = requireAuth(event);
11
+ const user = getOptionalUser(event);
12
12
  const db = useDB();
13
13
  const config = useConfig();
14
14
  const { query } = await parseBody(event, searchSchema);
15
15
 
16
- return searchRemoteActor(db, query, config.instance.domain, user.id);
16
+ return searchRemoteActor(db, query, config.instance.domain, user?.id);
17
17
  });
@@ -13,7 +13,6 @@ const querySchema = z.object({
13
13
  export default defineEventHandler(
14
14
  async (event): Promise<{ items: FederatedContentItem[]; total: number }> => {
15
15
  requireFeature('federation');
16
- requireAuth(event);
17
16
  const db = useDB();
18
17
  const opts = parseQueryParams(event, querySchema);
19
18