@commonpub/layer 0.6.1 → 0.7.0
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/CommentSection.vue +143 -26
- package/components/hub/HubMembers.vue +64 -14
- package/package.json +6 -6
- package/pages/federated-hubs/[id]/index.vue +14 -7
- package/pages/hubs/[slug]/index.vue +21 -4
- package/pages/hubs/[slug]/posts/[postId].vue +23 -7
- package/server/api/federation/hub-follow-status.get.ts +37 -0
- package/server/api/federation/hub-follow.post.ts +21 -5
- package/server/api/hubs/[slug]/members.get.ts +13 -4
- package/server/middleware/content-redirect.ts +1 -1
- package/pages/[type]/[slug]/edit.vue +0 -36
- package/pages/[type]/[slug]/index.vue +0 -34
|
@@ -10,7 +10,9 @@ interface Comment {
|
|
|
10
10
|
id: string;
|
|
11
11
|
content: string;
|
|
12
12
|
createdAt: string;
|
|
13
|
+
parentId: string | null;
|
|
13
14
|
author: CommentAuthor | null;
|
|
15
|
+
replies?: Comment[];
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const props = defineProps<{
|
|
@@ -64,8 +66,23 @@ async function loadMoreComments(): Promise<void> {
|
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Reply state
|
|
67
70
|
const newComment = ref('');
|
|
68
71
|
const submitting = ref(false);
|
|
72
|
+
const replyingTo = ref<{ id: string; username: string } | null>(null);
|
|
73
|
+
|
|
74
|
+
function startReply(comment: Comment): void {
|
|
75
|
+
replyingTo.value = {
|
|
76
|
+
id: comment.id,
|
|
77
|
+
username: comment.author?.username ?? 'someone',
|
|
78
|
+
};
|
|
79
|
+
newComment.value = `@${comment.author?.username ?? 'someone'} `;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cancelReply(): void {
|
|
83
|
+
replyingTo.value = null;
|
|
84
|
+
newComment.value = '';
|
|
85
|
+
}
|
|
69
86
|
|
|
70
87
|
async function submitComment(): Promise<void> {
|
|
71
88
|
if (!newComment.value.trim()) return;
|
|
@@ -87,10 +104,12 @@ async function submitComment(): Promise<void> {
|
|
|
87
104
|
targetType: props.targetType,
|
|
88
105
|
targetId: props.targetId,
|
|
89
106
|
content: newComment.value,
|
|
107
|
+
parentId: replyingTo.value?.id || undefined,
|
|
90
108
|
},
|
|
91
109
|
});
|
|
92
110
|
}
|
|
93
111
|
newComment.value = '';
|
|
112
|
+
replyingTo.value = null;
|
|
94
113
|
if (props.federatedContentId) {
|
|
95
114
|
replySent.value = true;
|
|
96
115
|
setTimeout(() => { replySent.value = false; }, 5000);
|
|
@@ -121,10 +140,14 @@ async function deleteComment(id: string): Promise<void> {
|
|
|
121
140
|
|
|
122
141
|
<!-- New comment form -->
|
|
123
142
|
<div v-if="user" class="cpub-comment-form">
|
|
143
|
+
<div v-if="replyingTo" class="cpub-replying-to">
|
|
144
|
+
Replying to @{{ replyingTo.username }}
|
|
145
|
+
<button class="cpub-cancel-reply" @click="cancelReply"><i class="fa-solid fa-xmark"></i></button>
|
|
146
|
+
</div>
|
|
124
147
|
<textarea
|
|
125
148
|
v-model="newComment"
|
|
126
149
|
class="cpub-textarea"
|
|
127
|
-
:placeholder="isFederated ? 'Write a reply (will be sent to the original instance)...' : 'Write a comment...'"
|
|
150
|
+
:placeholder="replyingTo ? `Reply to @${replyingTo.username}...` : isFederated ? 'Write a reply (will be sent to the original instance)...' : 'Write a comment...'"
|
|
128
151
|
rows="3"
|
|
129
152
|
aria-label="Write a comment"
|
|
130
153
|
></textarea>
|
|
@@ -133,7 +156,7 @@ async function deleteComment(id: string): Promise<void> {
|
|
|
133
156
|
:disabled="!newComment.trim() || submitting"
|
|
134
157
|
@click="submitComment"
|
|
135
158
|
>
|
|
136
|
-
{{ submitting ? 'Posting...' : isFederated ? 'Send Reply' : 'Post Comment' }}
|
|
159
|
+
{{ submitting ? 'Posting...' : replyingTo ? 'Reply' : isFederated ? 'Send Reply' : 'Post Comment' }}
|
|
137
160
|
</button>
|
|
138
161
|
</div>
|
|
139
162
|
<p v-else class="cpub-comment-login">
|
|
@@ -145,33 +168,72 @@ async function deleteComment(id: string): Promise<void> {
|
|
|
145
168
|
<i class="fa-solid fa-check-circle"></i> Reply sent to the original instance.
|
|
146
169
|
</div>
|
|
147
170
|
|
|
148
|
-
<!-- Comments list -->
|
|
171
|
+
<!-- Comments list (threaded) -->
|
|
149
172
|
<div class="cpub-comment-list">
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<
|
|
173
|
+
<template v-for="comment in (comments || [])" :key="comment.id">
|
|
174
|
+
<!-- Root comment -->
|
|
175
|
+
<div class="cpub-comment">
|
|
176
|
+
<div class="cpub-comment-avatar">
|
|
177
|
+
<img v-if="comment.author?.avatarUrl" :src="comment.author.avatarUrl" :alt="comment.author?.displayName || comment.author?.username" class="cpub-comment-avatar-img" />
|
|
178
|
+
<span v-else>{{ (comment.author?.displayName || comment.author?.username || 'U').charAt(0).toUpperCase() }}</span>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="cpub-comment-body">
|
|
181
|
+
<div class="cpub-comment-header">
|
|
182
|
+
<NuxtLink :to="`/u/${comment.author?.username}`" class="cpub-comment-author">
|
|
183
|
+
{{ comment.author?.displayName || comment.author?.username }}
|
|
184
|
+
</NuxtLink>
|
|
185
|
+
<time class="cpub-comment-time">
|
|
186
|
+
{{ new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
|
|
187
|
+
</time>
|
|
188
|
+
</div>
|
|
189
|
+
<p class="cpub-comment-text"><MentionText :text="comment.content" /></p>
|
|
190
|
+
<div class="cpub-comment-actions">
|
|
191
|
+
<button v-if="user && !isFederated" class="cpub-comment-action-btn" @click="startReply(comment)">
|
|
192
|
+
<i class="fa-solid fa-reply"></i> Reply
|
|
193
|
+
</button>
|
|
194
|
+
<button
|
|
195
|
+
v-if="user?.id === comment.author?.id"
|
|
196
|
+
class="cpub-comment-delete"
|
|
197
|
+
@click="deleteComment(comment.id)"
|
|
198
|
+
aria-label="Delete comment"
|
|
199
|
+
>
|
|
200
|
+
Delete
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
154
204
|
</div>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
{{
|
|
162
|
-
</
|
|
205
|
+
|
|
206
|
+
<!-- Child replies (threaded) -->
|
|
207
|
+
<div v-if="comment.replies?.length" class="cpub-comment-thread">
|
|
208
|
+
<div v-for="child in comment.replies" :key="child.id" class="cpub-comment cpub-comment-nested">
|
|
209
|
+
<div class="cpub-comment-avatar">
|
|
210
|
+
<img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-comment-avatar-img" />
|
|
211
|
+
<span v-else>{{ (child.author?.displayName || child.author?.username || 'U').charAt(0).toUpperCase() }}</span>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="cpub-comment-body">
|
|
214
|
+
<div class="cpub-comment-header">
|
|
215
|
+
<NuxtLink :to="`/u/${child.author?.username}`" class="cpub-comment-author">
|
|
216
|
+
{{ child.author?.displayName || child.author?.username }}
|
|
217
|
+
</NuxtLink>
|
|
218
|
+
<time class="cpub-comment-time">
|
|
219
|
+
{{ new Date(child.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
|
|
220
|
+
</time>
|
|
221
|
+
</div>
|
|
222
|
+
<p class="cpub-comment-text"><MentionText :text="child.content" /></p>
|
|
223
|
+
<div class="cpub-comment-actions">
|
|
224
|
+
<button
|
|
225
|
+
v-if="user?.id === child.author?.id"
|
|
226
|
+
class="cpub-comment-delete"
|
|
227
|
+
@click="deleteComment(child.id)"
|
|
228
|
+
aria-label="Delete comment"
|
|
229
|
+
>
|
|
230
|
+
Delete
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
163
234
|
</div>
|
|
164
|
-
<p class="cpub-comment-text"><MentionText :text="comment.content" /></p>
|
|
165
|
-
<button
|
|
166
|
-
v-if="user?.id === comment.author?.id"
|
|
167
|
-
class="cpub-comment-delete"
|
|
168
|
-
@click="deleteComment(comment.id)"
|
|
169
|
-
aria-label="Delete comment"
|
|
170
|
-
>
|
|
171
|
-
Delete
|
|
172
|
-
</button>
|
|
173
235
|
</div>
|
|
174
|
-
</
|
|
236
|
+
</template>
|
|
175
237
|
<p v-if="!comments?.length" class="cpub-comments-empty">{{ isFederated ? 'No replies received yet.' : 'No comments yet. Be the first!' }}</p>
|
|
176
238
|
<div v-if="hasMore" class="cpub-comments-more">
|
|
177
239
|
<button class="cpub-btn cpub-btn-sm" :disabled="loadingMore" @click="loadMoreComments">
|
|
@@ -219,6 +281,29 @@ async function deleteComment(id: string): Promise<void> {
|
|
|
219
281
|
width: 100%;
|
|
220
282
|
}
|
|
221
283
|
|
|
284
|
+
.cpub-replying-to {
|
|
285
|
+
width: 100%;
|
|
286
|
+
font-size: 12px;
|
|
287
|
+
color: var(--text-dim);
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
gap: 8px;
|
|
291
|
+
padding: 6px 10px;
|
|
292
|
+
background: var(--surface2);
|
|
293
|
+
border: 1px solid var(--border);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.cpub-cancel-reply {
|
|
297
|
+
margin-left: auto;
|
|
298
|
+
background: none;
|
|
299
|
+
border: none;
|
|
300
|
+
color: var(--text-faint);
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
padding: 0 4px;
|
|
303
|
+
font-size: 12px;
|
|
304
|
+
}
|
|
305
|
+
.cpub-cancel-reply:hover { color: var(--text); }
|
|
306
|
+
|
|
222
307
|
.cpub-comment-fed-notice {
|
|
223
308
|
font-size: 12px;
|
|
224
309
|
color: var(--text-dim);
|
|
@@ -250,6 +335,19 @@ async function deleteComment(id: string): Promise<void> {
|
|
|
250
335
|
gap: 10px;
|
|
251
336
|
}
|
|
252
337
|
|
|
338
|
+
.cpub-comment-thread {
|
|
339
|
+
padding-left: 38px;
|
|
340
|
+
border-left: 2px solid var(--border);
|
|
341
|
+
margin-left: 14px;
|
|
342
|
+
display: flex;
|
|
343
|
+
flex-direction: column;
|
|
344
|
+
gap: var(--space-3);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.cpub-comment-nested {
|
|
348
|
+
padding-left: 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
253
351
|
.cpub-comment-avatar {
|
|
254
352
|
width: 28px;
|
|
255
353
|
height: 28px;
|
|
@@ -309,13 +407,32 @@ async function deleteComment(id: string): Promise<void> {
|
|
|
309
407
|
line-height: 1.6;
|
|
310
408
|
}
|
|
311
409
|
|
|
410
|
+
.cpub-comment-actions {
|
|
411
|
+
display: flex;
|
|
412
|
+
align-items: center;
|
|
413
|
+
gap: 12px;
|
|
414
|
+
margin-top: 4px;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.cpub-comment-action-btn {
|
|
418
|
+
font-size: 10px;
|
|
419
|
+
color: var(--text-faint);
|
|
420
|
+
background: none;
|
|
421
|
+
border: none;
|
|
422
|
+
cursor: pointer;
|
|
423
|
+
padding: 0;
|
|
424
|
+
display: flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
gap: 4px;
|
|
427
|
+
}
|
|
428
|
+
.cpub-comment-action-btn:hover { color: var(--accent); }
|
|
429
|
+
|
|
312
430
|
.cpub-comment-delete {
|
|
313
431
|
font-size: 10px;
|
|
314
432
|
color: var(--text-faint);
|
|
315
433
|
background: none;
|
|
316
434
|
border: none;
|
|
317
435
|
cursor: pointer;
|
|
318
|
-
margin-top: 4px;
|
|
319
436
|
padding: 0;
|
|
320
437
|
}
|
|
321
438
|
|
|
@@ -1,25 +1,52 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { HubMemberViewModel } from '../../types/hub';
|
|
3
3
|
|
|
4
|
+
export interface RemoteMemberVM {
|
|
5
|
+
actorUri: string;
|
|
6
|
+
name: string;
|
|
7
|
+
instanceDomain: string;
|
|
8
|
+
avatarUrl: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
defineProps<{
|
|
5
|
-
members: HubMemberViewModel[]
|
|
12
|
+
members: HubMemberViewModel[];
|
|
13
|
+
remoteMembers?: RemoteMemberVM[];
|
|
6
14
|
}>();
|
|
7
15
|
</script>
|
|
8
16
|
|
|
9
17
|
<template>
|
|
10
|
-
<div
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<
|
|
18
|
+
<div>
|
|
19
|
+
<div v-if="members?.length" class="cpub-members-grid">
|
|
20
|
+
<MemberCard
|
|
21
|
+
v-for="member in members"
|
|
22
|
+
:key="member.username"
|
|
23
|
+
:username="member.username"
|
|
24
|
+
:display-name="member.name"
|
|
25
|
+
:role="(member.role as 'owner' | 'moderator' | 'member') || 'member'"
|
|
26
|
+
:joined-at="new Date(member.joinedAt)"
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div v-if="remoteMembers?.length" class="cpub-remote-members-section">
|
|
31
|
+
<h4 class="cpub-remote-members-title"><i class="fa-solid fa-globe"></i> Federated Members</h4>
|
|
32
|
+
<div class="cpub-members-grid">
|
|
33
|
+
<div v-for="rm in remoteMembers" :key="rm.actorUri" class="cpub-remote-member-card">
|
|
34
|
+
<div class="cpub-remote-member-avatar">
|
|
35
|
+
<img v-if="rm.avatarUrl" :src="rm.avatarUrl" :alt="rm.name" class="cpub-remote-member-avatar-img" />
|
|
36
|
+
<span v-else>{{ rm.name.charAt(0).toUpperCase() }}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="cpub-remote-member-info">
|
|
39
|
+
<span class="cpub-remote-member-name">{{ rm.name }}</span>
|
|
40
|
+
<span class="cpub-remote-member-domain">{{ rm.instanceDomain }}</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div v-if="!members?.length && !remoteMembers?.length" class="cpub-empty-state">
|
|
47
|
+
<div class="cpub-empty-state-icon"><i class="fa-solid fa-users"></i></div>
|
|
48
|
+
<p class="cpub-empty-state-title">No members yet</p>
|
|
49
|
+
</div>
|
|
23
50
|
</div>
|
|
24
51
|
</template>
|
|
25
52
|
|
|
@@ -30,6 +57,29 @@ defineProps<{
|
|
|
30
57
|
gap: 12px;
|
|
31
58
|
}
|
|
32
59
|
|
|
60
|
+
.cpub-remote-members-section { margin-top: 24px; }
|
|
61
|
+
.cpub-remote-members-title {
|
|
62
|
+
font-family: var(--font-mono); font-size: 11px; text-transform: uppercase;
|
|
63
|
+
letter-spacing: 0.05em; color: var(--text-dim); margin-bottom: 12px;
|
|
64
|
+
display: flex; align-items: center; gap: 6px;
|
|
65
|
+
}
|
|
66
|
+
.cpub-remote-members-title > i { color: var(--accent); }
|
|
67
|
+
|
|
68
|
+
.cpub-remote-member-card {
|
|
69
|
+
display: flex; align-items: center; gap: 10px;
|
|
70
|
+
padding: 10px 12px; background: var(--surface);
|
|
71
|
+
border: var(--border-width-default) solid var(--border);
|
|
72
|
+
}
|
|
73
|
+
.cpub-remote-member-avatar {
|
|
74
|
+
width: 32px; height: 32px; border-radius: 50%; overflow: hidden;
|
|
75
|
+
background: var(--accent-bg); display: flex; align-items: center; justify-content: center;
|
|
76
|
+
font-size: 13px; font-weight: 600; color: var(--accent); flex-shrink: 0;
|
|
77
|
+
}
|
|
78
|
+
.cpub-remote-member-avatar-img { width: 100%; height: 100%; object-fit: cover; }
|
|
79
|
+
.cpub-remote-member-info { display: flex; flex-direction: column; min-width: 0; }
|
|
80
|
+
.cpub-remote-member-name { font-size: 13px; font-weight: 500; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
81
|
+
.cpub-remote-member-domain { font-size: 11px; color: var(--text-dim); }
|
|
82
|
+
|
|
33
83
|
@media (max-width: 1024px) {
|
|
34
84
|
.cpub-members-grid { grid-template-columns: repeat(2, 1fr); }
|
|
35
85
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,15 +50,15 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
+
"@commonpub/auth": "0.5.0",
|
|
54
|
+
"@commonpub/config": "0.8.0",
|
|
53
55
|
"@commonpub/docs": "0.6.0",
|
|
56
|
+
"@commonpub/explainer": "0.7.1",
|
|
54
57
|
"@commonpub/editor": "0.5.0",
|
|
55
58
|
"@commonpub/learning": "0.5.0",
|
|
56
|
-
"@commonpub/auth": "0.5.0",
|
|
57
|
-
"@commonpub/schema": "0.8.18",
|
|
58
59
|
"@commonpub/protocol": "0.9.7",
|
|
59
|
-
"@commonpub/
|
|
60
|
-
"@commonpub/server": "2.
|
|
61
|
-
"@commonpub/explainer": "0.7.1",
|
|
60
|
+
"@commonpub/schema": "0.9.0",
|
|
61
|
+
"@commonpub/server": "2.27.0",
|
|
62
62
|
"@commonpub/ui": "0.8.4"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
@@ -202,9 +202,16 @@ async function handleDiscPost(): Promise<void> {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
// --- Instance mirror status
|
|
205
|
+
// --- Instance mirror status ---
|
|
206
206
|
const mirrorStatus = computed(() => hub.value?.followStatus ?? 'pending');
|
|
207
207
|
|
|
208
|
+
// --- Per-user join state ---
|
|
209
|
+
const { data: userFollowState, refresh: refreshFollowState } = useLazyFetch<{ joined: boolean; status: string | null }>(
|
|
210
|
+
() => `/api/federation/hub-follow-status?federatedHubId=${id}`,
|
|
211
|
+
{ default: () => ({ joined: false, status: null }) },
|
|
212
|
+
);
|
|
213
|
+
const userJoined = computed(() => userFollowState.value?.joined ?? false);
|
|
214
|
+
|
|
208
215
|
const remoteFollowRef = ref<{ show: () => void } | null>(null);
|
|
209
216
|
const hubFollowing = ref(false);
|
|
210
217
|
const hubFollowStatus = computed(() => hub.value?.followStatus ?? '');
|
|
@@ -219,8 +226,8 @@ async function handleJoinHub(): Promise<void> {
|
|
|
219
226
|
method: 'POST',
|
|
220
227
|
body: { federatedHubId: hub.value.id },
|
|
221
228
|
});
|
|
222
|
-
toast.success(result.status === '
|
|
223
|
-
await refreshHub();
|
|
229
|
+
toast.success(result.status === 'joined' ? 'Now following this hub' : 'Follow request sent — it may take a moment to be accepted');
|
|
230
|
+
await Promise.all([refreshHub(), refreshFollowState()]);
|
|
224
231
|
} catch (err: unknown) {
|
|
225
232
|
const msg = err instanceof Error ? err.message : 'Failed to follow hub';
|
|
226
233
|
toast.error(msg);
|
|
@@ -298,12 +305,12 @@ async function handlePostVote(postId: string): Promise<void> {
|
|
|
298
305
|
<span v-if="mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-mirrored">
|
|
299
306
|
<i class="fa-solid fa-globe"></i> Mirrored
|
|
300
307
|
</span>
|
|
301
|
-
<
|
|
302
|
-
<i class="fa-solid fa-user-plus"></i> {{ hubFollowing ? 'Following...' : hubFollowStatus === 'pending' ? 'Follow pending...' : 'Join from your instance' }}
|
|
303
|
-
</button>
|
|
304
|
-
<span v-else-if="hub?.actorUri && mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-joined">
|
|
308
|
+
<span v-if="userJoined" class="cpub-member-badge cpub-member-badge-joined">
|
|
305
309
|
<i class="fa-solid fa-check"></i> Joined
|
|
306
310
|
</span>
|
|
311
|
+
<button v-else-if="hub?.actorUri" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="hubFollowing || userFollowState?.status === 'pending'" @click="handleJoinHub">
|
|
312
|
+
<i class="fa-solid fa-user-plus"></i> {{ hubFollowing ? 'Following...' : userFollowState?.status === 'pending' ? 'Follow pending...' : 'Join Hub' }}
|
|
313
|
+
</button>
|
|
307
314
|
<a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm">
|
|
308
315
|
<i class="fa-solid fa-arrow-up-right-from-square"></i> Visit original
|
|
309
316
|
</a>
|
|
@@ -5,10 +5,16 @@ import type { HubViewModel, HubPostViewModel, HubMemberViewModel, HubTabDef } fr
|
|
|
5
5
|
const route = useRoute();
|
|
6
6
|
const slug = computed(() => route.params.slug as string);
|
|
7
7
|
|
|
8
|
+
function remoteDomain(uri: string | undefined): string | null {
|
|
9
|
+
if (!uri) return null;
|
|
10
|
+
try { return new URL(uri).hostname; } catch { return 'fediverse'; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
8
14
|
// --- Data fetching (unchanged) ---
|
|
9
15
|
const { data: hub, pending: hubPending, error: hubError, refresh: refreshHub } = useLazyFetch<Serialized<HubDetail>>(() => `/api/hubs/${slug.value}`);
|
|
10
16
|
const { data: posts, refresh: refreshPosts } = useLazyFetch<Serialized<PaginatedResponse<HubPostItem>>>(() => `/api/hubs/${slug.value}/posts`, { default: () => ({ items: [], total: 0 }) });
|
|
11
|
-
const { data: membersData } = useLazyFetch<{ items: Serialized<HubMemberItem>[]; total: number }>(() => `/api/hubs/${slug.value}/members`);
|
|
17
|
+
const { data: membersData } = useLazyFetch<{ items: Serialized<HubMemberItem>[]; total: number; remoteMembers?: Array<{ followerActorUri: string; displayName: string | null; preferredUsername: string | null; avatarUrl: string | null; instanceDomain: string; joinedAt: string }> }>(() => `/api/hubs/${slug.value}/members`);
|
|
12
18
|
const { data: gallery, refresh: refreshGallery } = useLazyFetch<PaginatedResponse<Serialized<ContentListItem>>>(() => `/api/hubs/${slug.value}/gallery`, { default: () => ({ items: [], total: 0 }) });
|
|
13
19
|
|
|
14
20
|
const hubType = computed(() => hub.value?.hubType ?? 'community');
|
|
@@ -55,8 +61,8 @@ const postsVM = computed<HubPostViewModel[]>(() => {
|
|
|
55
61
|
type: p.type,
|
|
56
62
|
content: p.content || '',
|
|
57
63
|
author: {
|
|
58
|
-
name: p.author?.displayName || p.author?.username || 'Unknown',
|
|
59
|
-
handle: null,
|
|
64
|
+
name: p.author?.displayName || p.author?.username || p.remoteActorName || 'Unknown',
|
|
65
|
+
handle: p.author ? null : remoteDomain(p.remoteActorUri ?? undefined),
|
|
60
66
|
avatarUrl: p.author?.avatarUrl ?? null,
|
|
61
67
|
},
|
|
62
68
|
createdAt: p.createdAt,
|
|
@@ -89,6 +95,17 @@ const membersVM = computed<HubMemberViewModel[]>(() => {
|
|
|
89
95
|
}));
|
|
90
96
|
});
|
|
91
97
|
|
|
98
|
+
const remoteMembersVM = computed(() => {
|
|
99
|
+
const raw = membersData.value?.remoteMembers;
|
|
100
|
+
if (!raw?.length) return [];
|
|
101
|
+
return raw.map(rm => ({
|
|
102
|
+
actorUri: rm.followerActorUri,
|
|
103
|
+
name: rm.displayName || rm.preferredUsername || 'Unknown',
|
|
104
|
+
instanceDomain: rm.instanceDomain,
|
|
105
|
+
avatarUrl: rm.avatarUrl,
|
|
106
|
+
}));
|
|
107
|
+
});
|
|
108
|
+
|
|
92
109
|
const moderators = computed(() => {
|
|
93
110
|
return membersVM.value.filter((m) => m.role === 'owner' || m.role === 'moderator');
|
|
94
111
|
});
|
|
@@ -333,7 +350,7 @@ async function onRefreshGallery(): Promise<void> {
|
|
|
333
350
|
</HubDiscussions>
|
|
334
351
|
|
|
335
352
|
<!-- Members tab -->
|
|
336
|
-
<HubMembers v-else-if="activeTab === 'members'" :members="membersVM" />
|
|
353
|
+
<HubMembers v-else-if="activeTab === 'members'" :members="membersVM" :remote-members="remoteMembersVM" />
|
|
337
354
|
|
|
338
355
|
<!-- Overview tab -->
|
|
339
356
|
<template v-else-if="activeTab === 'overview'">
|
|
@@ -122,6 +122,11 @@ async function saveEdit(): Promise<void> {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
function replyDisplayName(reply: { author?: { displayName?: string | null; username?: string } | null; remoteActorName?: string | null }): string {
|
|
126
|
+
if (reply.author) return reply.author.displayName || reply.author.username || 'U';
|
|
127
|
+
return reply.remoteActorName || 'Someone';
|
|
128
|
+
}
|
|
129
|
+
|
|
125
130
|
function formatDate(d: string | Date): string {
|
|
126
131
|
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
127
132
|
}
|
|
@@ -171,9 +176,12 @@ useSeoMeta({
|
|
|
171
176
|
<div class="cpub-post-author">
|
|
172
177
|
<div class="cpub-post-avatar">
|
|
173
178
|
<img v-if="post.author?.avatarUrl" :src="post.author.avatarUrl" :alt="post.author?.displayName || post.author?.username" class="cpub-post-avatar-img" />
|
|
174
|
-
<span v-else>{{ (post.author?.displayName || post.author?.username || 'U').charAt(0).toUpperCase() }}</span>
|
|
179
|
+
<span v-else>{{ (post.author?.displayName || post.author?.username || post.remoteActorName || 'U').charAt(0).toUpperCase() }}</span>
|
|
175
180
|
</div>
|
|
176
|
-
<NuxtLink :to="`/u/${post.author
|
|
181
|
+
<NuxtLink v-if="post.author" :to="`/u/${post.author.username}`" class="cpub-post-author-name">{{ post.author.displayName || post.author.username }}</NuxtLink>
|
|
182
|
+
<span v-else class="cpub-post-author-name cpub-reply-remote">
|
|
183
|
+
<i class="fa-solid fa-globe" title="Federated post"></i> {{ post.remoteActorName || 'Someone' }}
|
|
184
|
+
</span>
|
|
177
185
|
<span class="cpub-post-sep">·</span>
|
|
178
186
|
<time class="cpub-post-time">{{ formatDate(post.createdAt) }}</time>
|
|
179
187
|
</div>
|
|
@@ -229,15 +237,18 @@ useSeoMeta({
|
|
|
229
237
|
<div class="cpub-reply-author">
|
|
230
238
|
<div class="cpub-reply-avatar">
|
|
231
239
|
<img v-if="reply.author?.avatarUrl" :src="reply.author.avatarUrl" :alt="reply.author?.displayName || reply.author?.username" class="cpub-reply-avatar-img" />
|
|
232
|
-
<span v-else>{{ (reply
|
|
240
|
+
<span v-else>{{ (replyDisplayName(reply)).charAt(0).toUpperCase() }}</span>
|
|
233
241
|
</div>
|
|
234
|
-
<NuxtLink :to="`/u/${reply.author
|
|
242
|
+
<NuxtLink v-if="reply.author" :to="`/u/${reply.author.username}`" class="cpub-reply-author-name">{{ reply.author.displayName || reply.author.username }}</NuxtLink>
|
|
243
|
+
<span v-else class="cpub-reply-author-name cpub-reply-remote">
|
|
244
|
+
<i class="fa-solid fa-globe" title="Federated reply"></i> {{ reply.remoteActorName || 'Someone' }}
|
|
245
|
+
</span>
|
|
235
246
|
<span class="cpub-post-sep">·</span>
|
|
236
247
|
<time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
|
|
237
248
|
</div>
|
|
238
249
|
<div class="cpub-reply-content"><MentionText :text="reply.content" /></div>
|
|
239
250
|
<div class="cpub-reply-actions">
|
|
240
|
-
<button v-if="isAuthenticated && hub?.currentUserRole && !post.isLocked" class="cpub-reply-btn" @click="replyingTo = reply.id; replyContent = `@${reply.author?.username} `">
|
|
251
|
+
<button v-if="isAuthenticated && hub?.currentUserRole && !post.isLocked" class="cpub-reply-btn" @click="replyingTo = reply.id; replyContent = `@${reply.author?.username ?? reply.remoteActorName ?? ''} `">
|
|
241
252
|
<i class="fa-solid fa-reply"></i> Reply
|
|
242
253
|
</button>
|
|
243
254
|
</div>
|
|
@@ -248,9 +259,12 @@ useSeoMeta({
|
|
|
248
259
|
<div class="cpub-reply-author">
|
|
249
260
|
<div class="cpub-reply-avatar">
|
|
250
261
|
<img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-reply-avatar-img" />
|
|
251
|
-
<span v-else>{{ (child
|
|
262
|
+
<span v-else>{{ (replyDisplayName(child)).charAt(0).toUpperCase() }}</span>
|
|
252
263
|
</div>
|
|
253
|
-
<NuxtLink :to="`/u/${child.author
|
|
264
|
+
<NuxtLink v-if="child.author" :to="`/u/${child.author.username}`" class="cpub-reply-author-name">{{ child.author.displayName || child.author.username }}</NuxtLink>
|
|
265
|
+
<span v-else class="cpub-reply-author-name cpub-reply-remote">
|
|
266
|
+
<i class="fa-solid fa-globe" title="Federated reply"></i> {{ child.remoteActorName || 'Someone' }}
|
|
267
|
+
</span>
|
|
254
268
|
<span class="cpub-post-sep">·</span>
|
|
255
269
|
<time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
|
|
256
270
|
</div>
|
|
@@ -403,6 +417,8 @@ useSeoMeta({
|
|
|
403
417
|
|
|
404
418
|
.cpub-reply-author-name { font-weight: 500; color: var(--text-dim); text-decoration: none; }
|
|
405
419
|
.cpub-reply-author-name:hover { color: var(--accent); }
|
|
420
|
+
.cpub-reply-remote { display: inline-flex; align-items: center; gap: 4px; }
|
|
421
|
+
.cpub-reply-remote > i { font-size: 10px; color: var(--accent); }
|
|
406
422
|
|
|
407
423
|
.cpub-reply-content { font-size: 13px; line-height: 1.6; color: var(--text); }
|
|
408
424
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { userFederatedHubFollows } from '@commonpub/schema';
|
|
2
|
+
import { eq, and } from 'drizzle-orm';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const querySchema = z.object({
|
|
6
|
+
federatedHubId: z.string().uuid(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export default defineEventHandler(async (event): Promise<{ joined: boolean; status: string | null }> => {
|
|
10
|
+
requireFeature('federation');
|
|
11
|
+
requireFeature('federateHubs');
|
|
12
|
+
|
|
13
|
+
const user = getOptionalUser(event);
|
|
14
|
+
if (!user) {
|
|
15
|
+
return { joined: false, status: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const db = useDB();
|
|
19
|
+
const { federatedHubId } = parseQueryParams(event, querySchema);
|
|
20
|
+
|
|
21
|
+
const [record] = await db
|
|
22
|
+
.select({ status: userFederatedHubFollows.status })
|
|
23
|
+
.from(userFederatedHubFollows)
|
|
24
|
+
.where(
|
|
25
|
+
and(
|
|
26
|
+
eq(userFederatedHubFollows.userId, user.id),
|
|
27
|
+
eq(userFederatedHubFollows.federatedHubId, federatedHubId),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
.limit(1);
|
|
31
|
+
|
|
32
|
+
if (!record) {
|
|
33
|
+
return { joined: false, status: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { joined: record.status === 'joined', status: record.status };
|
|
37
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { sendHubFollow, getFederatedHub } from '@commonpub/server';
|
|
2
|
+
import { userFederatedHubFollows } from '@commonpub/schema';
|
|
3
|
+
import { eq, and } from 'drizzle-orm';
|
|
2
4
|
import { z } from 'zod';
|
|
3
5
|
|
|
4
6
|
const schema = z.object({
|
|
@@ -8,7 +10,7 @@ const schema = z.object({
|
|
|
8
10
|
export default defineEventHandler(async (event): Promise<{ success: boolean; status: string }> => {
|
|
9
11
|
requireFeature('federation');
|
|
10
12
|
requireFeature('federateHubs');
|
|
11
|
-
requireAuth(event);
|
|
13
|
+
const user = requireAuth(event);
|
|
12
14
|
const db = useDB();
|
|
13
15
|
const config = useConfig();
|
|
14
16
|
const { federatedHubId } = await parseBody(event, schema);
|
|
@@ -18,10 +20,24 @@ export default defineEventHandler(async (event): Promise<{ success: boolean; sta
|
|
|
18
20
|
throw createError({ statusCode: 404, statusMessage: 'Federated hub not found' });
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
// Create per-user follow record (upsert)
|
|
24
|
+
const userStatus = hub.followStatus === 'accepted' ? 'joined' : 'pending';
|
|
25
|
+
await db
|
|
26
|
+
.insert(userFederatedHubFollows)
|
|
27
|
+
.values({
|
|
28
|
+
userId: user.id,
|
|
29
|
+
federatedHubId,
|
|
30
|
+
status: userStatus,
|
|
31
|
+
})
|
|
32
|
+
.onConflictDoUpdate({
|
|
33
|
+
target: [userFederatedHubFollows.userId, userFederatedHubFollows.federatedHubId],
|
|
34
|
+
set: { status: userStatus, joinedAt: new Date() },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Send instance-level Follow if not already accepted
|
|
38
|
+
if (hub.followStatus !== 'accepted') {
|
|
39
|
+
await sendHubFollow(db, hub.actorUri, config.instance.domain);
|
|
23
40
|
}
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
return { success: true, status: 'pending' };
|
|
42
|
+
return { success: true, status: userStatus };
|
|
27
43
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { listMembers, getHubBySlug } from '@commonpub/server';
|
|
2
|
-
import type { HubMemberItem } from '@commonpub/server';
|
|
1
|
+
import { listMembers, listRemoteMembers, getHubBySlug } from '@commonpub/server';
|
|
2
|
+
import type { HubMemberItem, RemoteHubMember } from '@commonpub/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
const membersQuerySchema = z.object({
|
|
@@ -7,8 +7,9 @@ const membersQuerySchema = z.object({
|
|
|
7
7
|
offset: z.coerce.number().int().min(0).optional(),
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[]; total: number }> => {
|
|
10
|
+
export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[]; total: number; remoteMembers?: RemoteHubMember[] }> => {
|
|
11
11
|
const db = useDB();
|
|
12
|
+
const config = useConfig();
|
|
12
13
|
const { slug } = parseParams(event, { slug: 'string' });
|
|
13
14
|
const query = parseQueryParams(event, membersQuerySchema);
|
|
14
15
|
const community = await getHubBySlug(db, slug);
|
|
@@ -16,5 +17,13 @@ export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[
|
|
|
16
17
|
throw createError({ statusCode: 404, statusMessage: 'Community not found' });
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
const result = await listMembers(db, community.id, query);
|
|
21
|
+
|
|
22
|
+
// Include remote followers if hub federation is enabled
|
|
23
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
24
|
+
const remoteMembers = await listRemoteMembers(db, community.id);
|
|
25
|
+
return { ...result, remoteMembers };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return result;
|
|
20
29
|
});
|
|
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
15
15
|
const path = getRequestURL(event).pathname;
|
|
16
16
|
|
|
17
17
|
// Match /{type}/{slug} or /{type}/{slug}/edit — only for known content types
|
|
18
|
-
const match = path.match(/^\/([a-z]+)\/([a-z0-9][a-z0-
|
|
18
|
+
const match = path.match(/^\/([a-z]+)\/([a-z0-9][a-z0-9_-]*)(\/(edit))?$/);
|
|
19
19
|
if (!match) return;
|
|
20
20
|
|
|
21
21
|
const type = match[1]!;
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Legacy edit route — redirects to /u/{username}/{type}/{slug}/edit.
|
|
4
|
-
* For "new" content, redirects using the current user's username.
|
|
5
|
-
*/
|
|
6
|
-
definePageMeta({ layout: false, middleware: 'auth' });
|
|
7
|
-
|
|
8
|
-
const route = useRoute();
|
|
9
|
-
const contentType = computed(() => route.params.type as string);
|
|
10
|
-
const slug = computed(() => route.params.slug as string);
|
|
11
|
-
const { user } = useAuth();
|
|
12
|
-
|
|
13
|
-
if (slug.value === 'new') {
|
|
14
|
-
// Creating new content — redirect to new URL using current user
|
|
15
|
-
if (user.value?.username) {
|
|
16
|
-
await navigateTo(
|
|
17
|
-
`/u/${user.value.username}/${contentType.value}/new/edit${route.query.hub ? `?hub=${route.query.hub}` : ''}`,
|
|
18
|
-
{ redirectCode: 301, replace: true },
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
} else {
|
|
22
|
-
// Editing existing content — look up author
|
|
23
|
-
const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
|
|
24
|
-
const { data } = await useFetch(() => `/api/content/${slug.value}`, { headers: reqHeaders });
|
|
25
|
-
if (data.value) {
|
|
26
|
-
const d = data.value as { author?: { username: string }; type: string; slug: string };
|
|
27
|
-
if (d.author?.username) {
|
|
28
|
-
await navigateTo(`/u/${d.author.username}/${d.type}/${d.slug}/edit`, { redirectCode: 301, replace: true });
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
</script>
|
|
33
|
-
|
|
34
|
-
<template>
|
|
35
|
-
<div class="cpub-loading" aria-live="polite">Redirecting...</div>
|
|
36
|
-
</template>
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Legacy content route — redirects to /u/{username}/{type}/{slug}.
|
|
4
|
-
* Kept for backwards compatibility with existing links and bookmarks.
|
|
5
|
-
*/
|
|
6
|
-
import type { Serialized, ContentDetail } from '@commonpub/server';
|
|
7
|
-
|
|
8
|
-
const route = useRoute();
|
|
9
|
-
const contentType = computed(() => route.params.type as string);
|
|
10
|
-
const slug = computed(() => route.params.slug as string);
|
|
11
|
-
|
|
12
|
-
const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
|
|
13
|
-
const { data: content } = await useFetch<Serialized<ContentDetail>>(() => `/api/content/${slug.value}`, { headers: reqHeaders });
|
|
14
|
-
|
|
15
|
-
// Redirect to new user-scoped URL if we can resolve the author
|
|
16
|
-
if (content.value?.author?.username) {
|
|
17
|
-
await navigateTo(`/u/${content.value.author.username}/${content.value.type}/${content.value.slug}`, { redirectCode: 301, replace: true });
|
|
18
|
-
}
|
|
19
|
-
</script>
|
|
20
|
-
|
|
21
|
-
<template>
|
|
22
|
-
<div v-if="!content" class="cpub-not-found">
|
|
23
|
-
<h1>Content not found</h1>
|
|
24
|
-
<p>The requested content could not be found.</p>
|
|
25
|
-
</div>
|
|
26
|
-
</template>
|
|
27
|
-
|
|
28
|
-
<style scoped>
|
|
29
|
-
.cpub-not-found {
|
|
30
|
-
text-align: center;
|
|
31
|
-
padding: var(--space-16) 0;
|
|
32
|
-
color: var(--text-dim);
|
|
33
|
-
}
|
|
34
|
-
</style>
|