@commonpub/layer 0.3.11 → 0.3.13
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/ContentAttachments.vue +102 -0
- package/components/DiscussionItem.vue +22 -0
- package/components/MessageThread.vue +39 -0
- package/components/NotificationItem.vue +43 -8
- package/components/views/ArticleView.vue +3 -0
- package/components/views/BlogView.vue +28 -1
- package/components/views/ExplainerView.vue +21 -0
- package/components/views/ProjectView.vue +3 -0
- package/composables/useEngagement.ts +1 -0
- package/composables/useMirrorContent.ts +1 -0
- package/package.json +5 -5
- package/pages/dashboard.vue +2 -1
- package/pages/federated-hubs/[id]/posts/[postId].vue +4 -4
- package/pages/federation/users/[handle].vue +114 -11
- package/pages/hubs/[slug]/posts/[postId].vue +62 -1
- package/pages/messages/[conversationId].vue +1 -1
- package/pages/mirror/[id].vue +1 -0
- package/server/api/federated-hubs/[id]/feed.xml.get.ts +72 -0
- package/server/api/federation/dm.post.ts +28 -0
- package/server/api/hubs/[slug]/index.put.ts +10 -1
- package/server/api/hubs/[slug]/posts/[postId]/like.post.ts +10 -1
- package/server/api/hubs/[slug]/posts/[postId]/replies.post.ts +13 -3
- package/server/api/hubs/[slug]/posts/[postId].put.ts +30 -0
- package/server/api/hubs/[slug]/share.post.ts +48 -13
- package/server/api/hubs/index.post.ts +12 -2
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
attachments: Array<{ type: string; url: string; name?: string }>;
|
|
4
|
+
}>();
|
|
5
|
+
|
|
6
|
+
function iconForType(type: string): string {
|
|
7
|
+
if (type === 'Image') return 'fa-solid fa-image';
|
|
8
|
+
if (type === 'Video') return 'fa-solid fa-film';
|
|
9
|
+
if (type === 'Audio') return 'fa-solid fa-music';
|
|
10
|
+
return 'fa-solid fa-file';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fileName(att: { url: string; name?: string }): string {
|
|
14
|
+
if (att.name) return att.name;
|
|
15
|
+
try {
|
|
16
|
+
return new URL(att.url).pathname.split('/').pop() ?? 'attachment';
|
|
17
|
+
} catch {
|
|
18
|
+
return 'attachment';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div v-if="attachments.length > 0" class="cpub-attachments">
|
|
25
|
+
<div class="cpub-attachments-label">Attachments</div>
|
|
26
|
+
<div class="cpub-attachments-list">
|
|
27
|
+
<a
|
|
28
|
+
v-for="(att, i) in attachments"
|
|
29
|
+
:key="i"
|
|
30
|
+
:href="att.url"
|
|
31
|
+
target="_blank"
|
|
32
|
+
rel="noopener noreferrer"
|
|
33
|
+
class="cpub-attachment-item"
|
|
34
|
+
>
|
|
35
|
+
<img v-if="att.type === 'Image'" :src="att.url" :alt="att.name ?? ''" class="cpub-attachment-thumb" loading="lazy" />
|
|
36
|
+
<div v-else class="cpub-attachment-icon"><i :class="iconForType(att.type)"></i></div>
|
|
37
|
+
<span class="cpub-attachment-name">{{ fileName(att) }}</span>
|
|
38
|
+
</a>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<style scoped>
|
|
44
|
+
.cpub-attachments {
|
|
45
|
+
margin: 28px 0;
|
|
46
|
+
padding-top: 16px;
|
|
47
|
+
border-top: 1px solid var(--border);
|
|
48
|
+
}
|
|
49
|
+
.cpub-attachments-label {
|
|
50
|
+
font-size: 10px;
|
|
51
|
+
font-family: var(--font-mono);
|
|
52
|
+
color: var(--text-faint);
|
|
53
|
+
letter-spacing: 0.1em;
|
|
54
|
+
text-transform: uppercase;
|
|
55
|
+
margin-bottom: 10px;
|
|
56
|
+
}
|
|
57
|
+
.cpub-attachments-list {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-wrap: wrap;
|
|
60
|
+
gap: 8px;
|
|
61
|
+
}
|
|
62
|
+
.cpub-attachment-item {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: 8px;
|
|
66
|
+
padding: 8px 12px;
|
|
67
|
+
border: 1px solid var(--border);
|
|
68
|
+
background: var(--surface);
|
|
69
|
+
text-decoration: none;
|
|
70
|
+
color: var(--text-dim);
|
|
71
|
+
font-size: 12px;
|
|
72
|
+
transition: background var(--transition-fast);
|
|
73
|
+
}
|
|
74
|
+
.cpub-attachment-item:hover {
|
|
75
|
+
background: var(--surface2);
|
|
76
|
+
color: var(--accent);
|
|
77
|
+
}
|
|
78
|
+
.cpub-attachment-thumb {
|
|
79
|
+
width: 40px;
|
|
80
|
+
height: 40px;
|
|
81
|
+
object-fit: cover;
|
|
82
|
+
border: 1px solid var(--border2);
|
|
83
|
+
}
|
|
84
|
+
.cpub-attachment-icon {
|
|
85
|
+
width: 32px;
|
|
86
|
+
height: 32px;
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
background: var(--surface2);
|
|
91
|
+
border: 1px solid var(--border2);
|
|
92
|
+
color: var(--text-faint);
|
|
93
|
+
font-size: 14px;
|
|
94
|
+
}
|
|
95
|
+
.cpub-attachment-name {
|
|
96
|
+
font-family: var(--font-mono);
|
|
97
|
+
max-width: 200px;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
text-overflow: ellipsis;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const props = defineProps<{
|
|
3
3
|
title: string;
|
|
4
4
|
author: string;
|
|
5
|
+
authorAvatarUrl?: string | null;
|
|
5
6
|
replyCount: number;
|
|
6
7
|
voteCount: number;
|
|
7
8
|
lastReplyAt?: Date;
|
|
@@ -50,6 +51,8 @@ const lastReplyFormatted = computed((): string | null => {
|
|
|
50
51
|
</span>
|
|
51
52
|
</div>
|
|
52
53
|
<div class="cpub-discussion-meta">
|
|
54
|
+
<img v-if="authorAvatarUrl" :src="authorAvatarUrl" :alt="author" class="cpub-discussion-avatar" />
|
|
55
|
+
<div v-else class="cpub-discussion-avatar cpub-discussion-avatar-fallback">{{ author.charAt(0).toUpperCase() }}</div>
|
|
53
56
|
<span class="cpub-discussion-author">{{ author }}</span>
|
|
54
57
|
<span class="cpub-discussion-sep" aria-hidden="true">·</span>
|
|
55
58
|
<span class="cpub-discussion-replies">
|
|
@@ -170,6 +173,25 @@ const lastReplyFormatted = computed((): string | null => {
|
|
|
170
173
|
flex-wrap: wrap;
|
|
171
174
|
}
|
|
172
175
|
|
|
176
|
+
.cpub-discussion-avatar {
|
|
177
|
+
width: 16px;
|
|
178
|
+
height: 16px;
|
|
179
|
+
object-fit: cover;
|
|
180
|
+
border: 1px solid var(--border);
|
|
181
|
+
flex-shrink: 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.cpub-discussion-avatar-fallback {
|
|
185
|
+
display: flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
justify-content: center;
|
|
188
|
+
background: var(--surface2);
|
|
189
|
+
font-family: var(--font-mono);
|
|
190
|
+
font-size: 8px;
|
|
191
|
+
font-weight: 600;
|
|
192
|
+
color: var(--text-dim);
|
|
193
|
+
}
|
|
194
|
+
|
|
173
195
|
.cpub-discussion-author {
|
|
174
196
|
font-weight: var(--font-weight-medium);
|
|
175
197
|
color: var(--text-dim);
|
|
@@ -3,6 +3,8 @@ defineProps<{
|
|
|
3
3
|
messages: Array<{
|
|
4
4
|
id: string;
|
|
5
5
|
senderId: string;
|
|
6
|
+
senderName?: string | null;
|
|
7
|
+
senderAvatarUrl?: string | null;
|
|
6
8
|
body: string;
|
|
7
9
|
createdAt: string;
|
|
8
10
|
}>;
|
|
@@ -31,6 +33,11 @@ function handleSend(): void {
|
|
|
31
33
|
class="cpub-msg"
|
|
32
34
|
:class="{ own: msg.senderId === currentUserId }"
|
|
33
35
|
>
|
|
36
|
+
<div v-if="msg.senderId !== currentUserId" class="cpub-msg-sender">
|
|
37
|
+
<img v-if="msg.senderAvatarUrl" :src="msg.senderAvatarUrl" :alt="msg.senderName ?? ''" class="cpub-msg-avatar" />
|
|
38
|
+
<div v-else class="cpub-msg-avatar cpub-msg-avatar-fallback">{{ (msg.senderName ?? '?').charAt(0).toUpperCase() }}</div>
|
|
39
|
+
<span v-if="msg.senderName" class="cpub-msg-name">{{ msg.senderName }}</span>
|
|
40
|
+
</div>
|
|
34
41
|
<div class="cpub-msg-bubble">{{ msg.body }}</div>
|
|
35
42
|
<time class="cpub-msg-time">
|
|
36
43
|
{{ new Date(msg.createdAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }}
|
|
@@ -78,6 +85,38 @@ function handleSend(): void {
|
|
|
78
85
|
align-self: flex-end;
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
.cpub-msg-sender {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 6px;
|
|
92
|
+
margin-bottom: 3px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.cpub-msg-avatar {
|
|
96
|
+
width: 20px;
|
|
97
|
+
height: 20px;
|
|
98
|
+
object-fit: cover;
|
|
99
|
+
border: 1px solid var(--border);
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.cpub-msg-avatar-fallback {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: center;
|
|
107
|
+
background: var(--surface2);
|
|
108
|
+
font-family: var(--font-mono);
|
|
109
|
+
font-size: 9px;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
color: var(--text-dim);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.cpub-msg-name {
|
|
115
|
+
font-size: 11px;
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
color: var(--text-dim);
|
|
118
|
+
}
|
|
119
|
+
|
|
81
120
|
.cpub-msg-bubble {
|
|
82
121
|
padding: 8px 12px;
|
|
83
122
|
font-size: 13px;
|
|
@@ -5,6 +5,7 @@ defineProps<{
|
|
|
5
5
|
type: string;
|
|
6
6
|
message: string;
|
|
7
7
|
actorName?: string | null;
|
|
8
|
+
actorAvatarUrl?: string | null;
|
|
8
9
|
link?: string | null;
|
|
9
10
|
targetUrl?: string;
|
|
10
11
|
read: boolean;
|
|
@@ -23,8 +24,14 @@ const iconMap: Record<string, string> = {
|
|
|
23
24
|
|
|
24
25
|
<template>
|
|
25
26
|
<div class="cpub-notif" :class="{ unread: !notification.read }">
|
|
26
|
-
<div class="cpub-notif-
|
|
27
|
-
<
|
|
27
|
+
<div class="cpub-notif-avatar-wrap">
|
|
28
|
+
<img v-if="notification.actorAvatarUrl" :src="notification.actorAvatarUrl" :alt="notification.actorName ?? ''" class="cpub-notif-avatar" />
|
|
29
|
+
<div v-else class="cpub-notif-avatar cpub-notif-avatar-fallback">
|
|
30
|
+
{{ (notification.actorName ?? '?').charAt(0).toUpperCase() }}
|
|
31
|
+
</div>
|
|
32
|
+
<div class="cpub-notif-icon-badge">
|
|
33
|
+
<i :class="iconMap[notification.type] || 'fa-solid fa-bell'"></i>
|
|
34
|
+
</div>
|
|
28
35
|
</div>
|
|
29
36
|
<div class="cpub-notif-body">
|
|
30
37
|
<p class="cpub-notif-text">
|
|
@@ -56,17 +63,45 @@ const iconMap: Record<string, string> = {
|
|
|
56
63
|
border-color: var(--accent-border);
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
.cpub-notif-
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
.cpub-notif-avatar-wrap {
|
|
67
|
+
position: relative;
|
|
68
|
+
width: 32px;
|
|
69
|
+
height: 32px;
|
|
70
|
+
flex-shrink: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.cpub-notif-avatar {
|
|
74
|
+
width: 32px;
|
|
75
|
+
height: 32px;
|
|
76
|
+
object-fit: cover;
|
|
77
|
+
border: var(--border-width-default) solid var(--border);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.cpub-notif-avatar-fallback {
|
|
62
81
|
display: flex;
|
|
63
82
|
align-items: center;
|
|
64
83
|
justify-content: center;
|
|
65
84
|
background: var(--surface2);
|
|
66
|
-
|
|
67
|
-
font-size:
|
|
85
|
+
font-family: var(--font-mono);
|
|
86
|
+
font-size: 12px;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
color: var(--text-dim);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.cpub-notif-icon-badge {
|
|
92
|
+
position: absolute;
|
|
93
|
+
bottom: -3px;
|
|
94
|
+
right: -3px;
|
|
95
|
+
width: 16px;
|
|
96
|
+
height: 16px;
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
background: var(--surface);
|
|
101
|
+
border: 1px solid var(--border);
|
|
102
|
+
border-radius: 50%;
|
|
103
|
+
font-size: 8px;
|
|
68
104
|
color: var(--text-dim);
|
|
69
|
-
flex-shrink: 0;
|
|
70
105
|
}
|
|
71
106
|
|
|
72
107
|
.cpub-notif-body {
|
|
@@ -225,6 +225,9 @@ useJsonLd({
|
|
|
225
225
|
</NuxtLink>
|
|
226
226
|
</div>
|
|
227
227
|
|
|
228
|
+
<!-- ATTACHMENTS -->
|
|
229
|
+
<ContentAttachments v-if="content.attachments?.length" :attachments="content.attachments" />
|
|
230
|
+
|
|
228
231
|
<!-- COMMENTS SECTION -->
|
|
229
232
|
<CommentSection :target-type="content.type" :target-id="content.id" :federated-content-id="federatedId" />
|
|
230
233
|
|
|
@@ -43,6 +43,12 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
|
|
|
43
43
|
|
|
44
44
|
<template>
|
|
45
45
|
<div class="cpub-blog-view">
|
|
46
|
+
|
|
47
|
+
<!-- COVER IMAGE -->
|
|
48
|
+
<div v-if="content.coverImageUrl" class="cpub-cover">
|
|
49
|
+
<img :src="content.coverImageUrl" :alt="content.title" class="cpub-cover-img" />
|
|
50
|
+
</div>
|
|
51
|
+
|
|
46
52
|
<div class="cpub-blog-wrap">
|
|
47
53
|
|
|
48
54
|
<!-- TYPE BADGE -->
|
|
@@ -159,7 +165,8 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
|
|
|
159
165
|
|
|
160
166
|
<!-- AUTHOR CARD -->
|
|
161
167
|
<div v-if="content.author" class="cpub-author-card">
|
|
162
|
-
<
|
|
168
|
+
<img v-if="content.author.avatarUrl" :src="content.author.avatarUrl" :alt="content.author.displayName ?? content.author.username ?? ''" class="cpub-av cpub-av-xl" style="object-fit:cover;border:2px solid var(--border);" />
|
|
169
|
+
<div v-else class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
|
|
163
170
|
<div class="cpub-author-card-info">
|
|
164
171
|
<div class="cpub-author-card-label">Posted by</div>
|
|
165
172
|
<div class="cpub-author-card-name">{{ content.author.displayName || content.author.username }}</div>
|
|
@@ -175,6 +182,9 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
|
|
|
175
182
|
</div>
|
|
176
183
|
</div>
|
|
177
184
|
|
|
185
|
+
<!-- ATTACHMENTS -->
|
|
186
|
+
<ContentAttachments v-if="content.attachments?.length" :attachments="content.attachments" />
|
|
187
|
+
|
|
178
188
|
<!-- COMMENTS -->
|
|
179
189
|
<CommentSection :target-type="content.type" :target-id="content.id" :federated-content-id="federatedId" />
|
|
180
190
|
|
|
@@ -183,6 +193,23 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
|
|
|
183
193
|
</template>
|
|
184
194
|
|
|
185
195
|
<style scoped>
|
|
196
|
+
/* ── COVER IMAGE ── */
|
|
197
|
+
.cpub-cover {
|
|
198
|
+
width: 100%;
|
|
199
|
+
max-height: 420px;
|
|
200
|
+
overflow: hidden;
|
|
201
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
202
|
+
background: var(--surface);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.cpub-cover-img {
|
|
206
|
+
width: 100%;
|
|
207
|
+
height: 100%;
|
|
208
|
+
max-height: 420px;
|
|
209
|
+
object-fit: cover;
|
|
210
|
+
display: block;
|
|
211
|
+
}
|
|
212
|
+
|
|
186
213
|
/* ── BLOG WRAP ── */
|
|
187
214
|
.cpub-blog-wrap {
|
|
188
215
|
max-width: 720px;
|
|
@@ -240,6 +240,11 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
240
240
|
<main class="cpub-explainer-main">
|
|
241
241
|
<div class="cpub-section-viewport">
|
|
242
242
|
<div class="cpub-content-wrap" :key="activeSection">
|
|
243
|
+
<!-- Cover image (first section only) -->
|
|
244
|
+
<div v-if="activeSection === 0 && content.coverImageUrl" class="cpub-explainer-cover">
|
|
245
|
+
<img :src="content.coverImageUrl" :alt="content.title" />
|
|
246
|
+
</div>
|
|
247
|
+
|
|
243
248
|
<!-- Section Header (from sectionHeader block data) -->
|
|
244
249
|
<div v-if="currentSection?.tag" class="cpub-section-tag">{{ currentSection.tag }}</div>
|
|
245
250
|
<h1 class="cpub-section-title">{{ currentSection?.title || content.title }}</h1>
|
|
@@ -546,6 +551,22 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
546
551
|
min-height: calc(100vh - 51px - 80px);
|
|
547
552
|
}
|
|
548
553
|
|
|
554
|
+
/* ── COVER IMAGE ── */
|
|
555
|
+
.cpub-explainer-cover {
|
|
556
|
+
width: 100%;
|
|
557
|
+
max-height: 320px;
|
|
558
|
+
overflow: hidden;
|
|
559
|
+
margin-bottom: 20px;
|
|
560
|
+
border: var(--border-width-default) solid var(--border);
|
|
561
|
+
}
|
|
562
|
+
.cpub-explainer-cover img {
|
|
563
|
+
width: 100%;
|
|
564
|
+
height: 100%;
|
|
565
|
+
max-height: 320px;
|
|
566
|
+
object-fit: cover;
|
|
567
|
+
display: block;
|
|
568
|
+
}
|
|
569
|
+
|
|
549
570
|
/* ── SECTION TAG ── */
|
|
550
571
|
.cpub-section-tag {
|
|
551
572
|
font-family: var(--font-mono);
|
|
@@ -434,6 +434,9 @@ async function handleBuild(): Promise<void> {
|
|
|
434
434
|
</div>
|
|
435
435
|
</template>
|
|
436
436
|
</div>
|
|
437
|
+
|
|
438
|
+
<!-- ATTACHMENTS -->
|
|
439
|
+
<ContentAttachments v-if="content.attachments?.length" :attachments="content.attachments" />
|
|
437
440
|
</template>
|
|
438
441
|
|
|
439
442
|
<!-- BOM / PARTS & STEPS TAB -->
|
|
@@ -82,6 +82,7 @@ export interface ContentViewData {
|
|
|
82
82
|
seriesTotalParts?: number;
|
|
83
83
|
seriesPrev?: { title: string; url: string };
|
|
84
84
|
seriesNext?: { title: string; url: string };
|
|
85
|
+
attachments?: Array<{ type: string; url: string; name?: string }>;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export interface EngagementOptions {
|
|
@@ -83,6 +83,7 @@ export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>
|
|
|
83
83
|
},
|
|
84
84
|
buildCount: 0,
|
|
85
85
|
bookmarkCount: 0,
|
|
86
|
+
attachments: Array.isArray(fc.attachments) ? (fc.attachments as Array<{ type: string; url: string; name?: string }>) : [],
|
|
86
87
|
} satisfies ContentViewData;
|
|
87
88
|
});
|
|
88
89
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"@commonpub/auth": "0.5.0",
|
|
48
48
|
"@commonpub/config": "0.7.0",
|
|
49
49
|
"@commonpub/docs": "0.5.2",
|
|
50
|
-
"@commonpub/learning": "0.5.0",
|
|
51
50
|
"@commonpub/editor": "0.5.0",
|
|
51
|
+
"@commonpub/learning": "0.5.0",
|
|
52
52
|
"@commonpub/protocol": "0.9.4",
|
|
53
|
-
"@commonpub/schema": "0.8.
|
|
54
|
-
"@commonpub/
|
|
55
|
-
"@commonpub/
|
|
53
|
+
"@commonpub/schema": "0.8.11",
|
|
54
|
+
"@commonpub/server": "2.11.0",
|
|
55
|
+
"@commonpub/ui": "0.7.1"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {}
|
|
58
58
|
}
|
package/pages/dashboard.vue
CHANGED
|
@@ -208,11 +208,12 @@ async function deleteItem(id: string, title: string): Promise<void> {
|
|
|
208
208
|
<div class="cpub-dash-list">
|
|
209
209
|
<div v-for="bm in bookmarkData?.items ?? []" :key="bm.id" class="cpub-dash-row">
|
|
210
210
|
<template v-if="bm.content">
|
|
211
|
-
<NuxtLink :to="`/${bm.content.type}/${bm.content.slug}`" class="cpub-dash-row-title">
|
|
211
|
+
<NuxtLink :to="bm.isFederated ? `/mirror/${bm.targetId}` : `/${bm.content.type}/${bm.content.slug}`" class="cpub-dash-row-title">
|
|
212
212
|
{{ bm.content.title }}
|
|
213
213
|
</NuxtLink>
|
|
214
214
|
<span class="cpub-dash-row-meta">
|
|
215
215
|
<ContentTypeBadge :type="bm.content.type" />
|
|
216
|
+
<span v-if="bm.isFederated && bm.content.originDomain" class="cpub-dash-row-fed"><i class="fa-solid fa-globe"></i> {{ bm.content.originDomain }}</span>
|
|
216
217
|
<span v-if="bm.content.author">by {{ bm.content.author.displayName || bm.content.author.username }}</span>
|
|
217
218
|
</span>
|
|
218
219
|
</template>
|
|
@@ -175,14 +175,14 @@ if (hub.value?.url) {
|
|
|
175
175
|
</p>
|
|
176
176
|
</div>
|
|
177
177
|
|
|
178
|
-
<!--
|
|
178
|
+
<!-- Reply thread info -->
|
|
179
179
|
<div class="cpub-replies-section">
|
|
180
180
|
<div class="cpub-empty-state" style="padding: 32px 16px">
|
|
181
|
-
<p class="cpub-empty-state-title"
|
|
181
|
+
<p class="cpub-empty-state-title"><i class="fa-solid fa-globe"></i> Federated thread</p>
|
|
182
182
|
<p class="cpub-empty-state-desc">
|
|
183
|
-
|
|
183
|
+
Replies sent here are delivered to <strong>{{ hub?.originDomain }}</strong> via ActivityPub.
|
|
184
184
|
<a v-if="post.objectUri" :href="post.objectUri" target="_blank" rel="noopener noreferrer" class="cpub-inline-link">
|
|
185
|
-
View thread on origin <i class="fa-solid fa-arrow-up-right-from-square"></i>
|
|
185
|
+
View full thread on origin <i class="fa-solid fa-arrow-up-right-from-square"></i>
|
|
186
186
|
</a>
|
|
187
187
|
</p>
|
|
188
188
|
</div>
|
|
@@ -7,10 +7,34 @@ const route = useRoute();
|
|
|
7
7
|
const handle = computed(() => decodeURIComponent(route.params.handle as string));
|
|
8
8
|
|
|
9
9
|
const { searchResult, searchLoading, searchError, searchRemoteUser, followRemoteUser, unfollowRemoteUser } = useFederation();
|
|
10
|
+
const { user } = useAuth();
|
|
10
11
|
|
|
11
12
|
const content = ref<FederatedContentItem[]>([]);
|
|
12
13
|
const contentLoading = ref(false);
|
|
13
14
|
|
|
15
|
+
// DM state
|
|
16
|
+
const showDmForm = ref(false);
|
|
17
|
+
const dmBody = ref('');
|
|
18
|
+
const dmSending = ref(false);
|
|
19
|
+
const dmSent = ref(false);
|
|
20
|
+
|
|
21
|
+
async function sendDm(): Promise<void> {
|
|
22
|
+
if (!dmBody.value.trim() || !searchResult.value) return;
|
|
23
|
+
dmSending.value = true;
|
|
24
|
+
try {
|
|
25
|
+
const remoteHandle = `@${searchResult.value.preferredUsername}@${searchResult.value.instanceDomain}`;
|
|
26
|
+
await $fetch('/api/federation/dm', { method: 'POST', body: { handle: remoteHandle, body: dmBody.value } });
|
|
27
|
+
dmSent.value = true;
|
|
28
|
+
dmBody.value = '';
|
|
29
|
+
showDmForm.value = false;
|
|
30
|
+
setTimeout(() => { dmSent.value = false; }, 5000);
|
|
31
|
+
} catch {
|
|
32
|
+
// TODO: show error toast
|
|
33
|
+
} finally {
|
|
34
|
+
dmSending.value = false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
async function loadProfile() {
|
|
15
39
|
const h = handle.value.startsWith('@') ? handle.value : `@${handle.value}`;
|
|
16
40
|
await searchRemoteUser(h);
|
|
@@ -75,17 +99,45 @@ function stripHtml(html: string): string {
|
|
|
75
99
|
</div>
|
|
76
100
|
</div>
|
|
77
101
|
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
<div class="cpub-remote-profile__actions">
|
|
103
|
+
<button
|
|
104
|
+
class="cpub-remote-profile__follow-btn"
|
|
105
|
+
:class="{
|
|
106
|
+
'cpub-remote-profile__follow-btn--following': searchResult.isFollowing,
|
|
107
|
+
'cpub-remote-profile__follow-btn--pending': searchResult.isFollowPending,
|
|
108
|
+
}"
|
|
109
|
+
:disabled="searchResult.isFollowPending"
|
|
110
|
+
@click="searchResult.isFollowing ? onUnfollow() : onFollow()"
|
|
111
|
+
>
|
|
112
|
+
{{ searchResult.isFollowing ? 'Following' : searchResult.isFollowPending ? 'Pending' : 'Follow' }}
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
v-if="user"
|
|
116
|
+
class="cpub-remote-profile__follow-btn"
|
|
117
|
+
@click="showDmForm = !showDmForm"
|
|
118
|
+
>
|
|
119
|
+
<i class="fa-solid fa-envelope"></i> Message
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- DM Form -->
|
|
125
|
+
<div v-if="showDmForm" class="cpub-remote-profile__dm-form">
|
|
126
|
+
<textarea
|
|
127
|
+
v-model="dmBody"
|
|
128
|
+
class="cpub-remote-profile__dm-textarea"
|
|
129
|
+
placeholder="Write a message..."
|
|
130
|
+
rows="3"
|
|
131
|
+
></textarea>
|
|
132
|
+
<div class="cpub-remote-profile__dm-actions">
|
|
133
|
+
<button class="cpub-remote-profile__dm-send" :disabled="dmSending || !dmBody.trim()" @click="sendDm">
|
|
134
|
+
{{ dmSending ? 'Sending...' : 'Send' }}
|
|
135
|
+
</button>
|
|
136
|
+
<button class="cpub-remote-profile__dm-cancel" @click="showDmForm = false">Cancel</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div v-if="dmSent" class="cpub-remote-profile__dm-sent">
|
|
140
|
+
<i class="fa-solid fa-check"></i> Message sent via ActivityPub
|
|
89
141
|
</div>
|
|
90
142
|
|
|
91
143
|
<p v-if="searchResult.summary" class="cpub-remote-profile__bio">
|
|
@@ -218,4 +270,55 @@ function stripHtml(html: string): string {
|
|
|
218
270
|
flex-direction: column;
|
|
219
271
|
gap: var(--space-4);
|
|
220
272
|
}
|
|
273
|
+
.cpub-remote-profile__actions {
|
|
274
|
+
display: flex;
|
|
275
|
+
gap: var(--space-2);
|
|
276
|
+
flex-shrink: 0;
|
|
277
|
+
}
|
|
278
|
+
.cpub-remote-profile__dm-form {
|
|
279
|
+
margin-bottom: var(--space-4);
|
|
280
|
+
padding: var(--space-3);
|
|
281
|
+
border: var(--border-width-default) solid var(--border);
|
|
282
|
+
background: var(--surface-1, var(--surface));
|
|
283
|
+
}
|
|
284
|
+
.cpub-remote-profile__dm-textarea {
|
|
285
|
+
width: 100%;
|
|
286
|
+
padding: var(--space-2);
|
|
287
|
+
border: var(--border-width-default) solid var(--border);
|
|
288
|
+
background: var(--surface-2, var(--surface2));
|
|
289
|
+
color: var(--text);
|
|
290
|
+
font-family: var(--font-sans);
|
|
291
|
+
font-size: var(--font-size-sm);
|
|
292
|
+
resize: vertical;
|
|
293
|
+
margin-bottom: var(--space-2);
|
|
294
|
+
}
|
|
295
|
+
.cpub-remote-profile__dm-actions {
|
|
296
|
+
display: flex;
|
|
297
|
+
gap: var(--space-2);
|
|
298
|
+
}
|
|
299
|
+
.cpub-remote-profile__dm-send {
|
|
300
|
+
padding: var(--space-1) var(--space-3);
|
|
301
|
+
border: var(--border-width-default) solid var(--accent);
|
|
302
|
+
background: var(--accent);
|
|
303
|
+
color: var(--surface-1, var(--surface));
|
|
304
|
+
font-family: var(--font-mono);
|
|
305
|
+
font-size: var(--font-size-sm);
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
}
|
|
308
|
+
.cpub-remote-profile__dm-send:disabled { opacity: 0.5; cursor: default; }
|
|
309
|
+
.cpub-remote-profile__dm-cancel {
|
|
310
|
+
padding: var(--space-1) var(--space-3);
|
|
311
|
+
border: var(--border-width-default) solid var(--border);
|
|
312
|
+
background: transparent;
|
|
313
|
+
color: var(--text-2);
|
|
314
|
+
font-family: var(--font-mono);
|
|
315
|
+
font-size: var(--font-size-sm);
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
.cpub-remote-profile__dm-sent {
|
|
319
|
+
font-size: var(--font-size-sm);
|
|
320
|
+
color: var(--green, #22c55e);
|
|
321
|
+
font-weight: 600;
|
|
322
|
+
margin-bottom: var(--space-4);
|
|
323
|
+
}
|
|
221
324
|
</style>
|
|
@@ -92,6 +92,34 @@ async function deletePost(): Promise<void> {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Edit
|
|
96
|
+
const editing = ref(false);
|
|
97
|
+
const editContent = ref('');
|
|
98
|
+
const saving = ref(false);
|
|
99
|
+
|
|
100
|
+
function startEdit(): void {
|
|
101
|
+
editContent.value = post.value?.content ?? '';
|
|
102
|
+
editing.value = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function saveEdit(): Promise<void> {
|
|
106
|
+
if (!editContent.value.trim()) return;
|
|
107
|
+
saving.value = true;
|
|
108
|
+
try {
|
|
109
|
+
await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}`, {
|
|
110
|
+
method: 'PUT',
|
|
111
|
+
body: { content: editContent.value },
|
|
112
|
+
});
|
|
113
|
+
editing.value = false;
|
|
114
|
+
await refreshPost();
|
|
115
|
+
toast.success('Post updated');
|
|
116
|
+
} catch {
|
|
117
|
+
toast.error('Failed to update post');
|
|
118
|
+
} finally {
|
|
119
|
+
saving.value = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
95
123
|
function formatDate(d: string | Date): string {
|
|
96
124
|
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
97
125
|
}
|
|
@@ -121,7 +149,21 @@ useSeoMeta({
|
|
|
121
149
|
<span v-if="post.isLocked" class="cpub-post-locked"><i class="fa-solid fa-lock"></i> Locked</span>
|
|
122
150
|
</div>
|
|
123
151
|
|
|
124
|
-
<div class="cpub-post-
|
|
152
|
+
<div v-if="editing" class="cpub-post-edit-form">
|
|
153
|
+
<textarea v-model="editContent" class="cpub-post-edit-textarea" rows="4"></textarea>
|
|
154
|
+
<div class="cpub-post-edit-actions">
|
|
155
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving || !editContent.trim()" @click="saveEdit">
|
|
156
|
+
{{ saving ? 'Saving...' : 'Save' }}
|
|
157
|
+
</button>
|
|
158
|
+
<button class="cpub-btn cpub-btn-sm" @click="editing = false">Cancel</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div v-else class="cpub-post-content">
|
|
162
|
+
{{ post.content }}
|
|
163
|
+
<div v-if="post.updatedAt && post.updatedAt !== post.createdAt" class="cpub-post-edited">
|
|
164
|
+
<i class="fa-solid fa-pen"></i> edited
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
125
167
|
|
|
126
168
|
<div class="cpub-post-meta">
|
|
127
169
|
<div class="cpub-post-author">
|
|
@@ -147,6 +189,9 @@ useSeoMeta({
|
|
|
147
189
|
|
|
148
190
|
<!-- Mod actions -->
|
|
149
191
|
<div v-if="isMod || isAuthor" class="cpub-post-mod-bar">
|
|
192
|
+
<button v-if="isAuthor && !editing" class="cpub-btn cpub-btn-sm" @click="startEdit">
|
|
193
|
+
<i class="fa-solid fa-pen"></i> Edit
|
|
194
|
+
</button>
|
|
150
195
|
<button v-if="isMod" class="cpub-btn cpub-btn-sm" @click="togglePin">
|
|
151
196
|
<i class="fa-solid fa-thumbtack"></i> {{ post.isPinned ? 'Unpin' : 'Pin' }}
|
|
152
197
|
</button>
|
|
@@ -257,6 +302,19 @@ useSeoMeta({
|
|
|
257
302
|
font-size: 15px; line-height: 1.7; color: var(--text);
|
|
258
303
|
margin-bottom: 16px; white-space: pre-wrap;
|
|
259
304
|
}
|
|
305
|
+
.cpub-post-edited {
|
|
306
|
+
font-size: 10px; font-family: var(--font-mono); color: var(--text-faint);
|
|
307
|
+
margin-top: 4px;
|
|
308
|
+
}
|
|
309
|
+
.cpub-post-edit-form { margin-bottom: 16px; }
|
|
310
|
+
.cpub-post-edit-textarea {
|
|
311
|
+
width: 100%; padding: 10px 12px; background: var(--surface);
|
|
312
|
+
border: var(--border-width-default) solid var(--accent-border);
|
|
313
|
+
color: var(--text); font-size: 14px; font-family: var(--font-sans);
|
|
314
|
+
line-height: 1.6; resize: vertical;
|
|
315
|
+
}
|
|
316
|
+
.cpub-post-edit-textarea:focus { outline: none; border-color: var(--accent); }
|
|
317
|
+
.cpub-post-edit-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
260
318
|
|
|
261
319
|
.cpub-post-meta {
|
|
262
320
|
display: flex; align-items: center; justify-content: space-between;
|
|
@@ -294,6 +352,9 @@ useSeoMeta({
|
|
|
294
352
|
display: flex; gap: 6px;
|
|
295
353
|
}
|
|
296
354
|
|
|
355
|
+
.cpub-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
356
|
+
.cpub-btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
|
357
|
+
.cpub-btn-primary:disabled { opacity: 0.5; cursor: default; }
|
|
297
358
|
.cpub-btn-danger { color: var(--red); border-color: var(--red); }
|
|
298
359
|
.cpub-btn-danger:hover { background: var(--red); color: white; }
|
|
299
360
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const route = useRoute();
|
|
3
3
|
const conversationId = route.params.conversationId as string;
|
|
4
4
|
|
|
5
|
-
useSeoMeta({ title:
|
|
5
|
+
useSeoMeta({ title: () => `Message — ${participantLabel.value}` });
|
|
6
6
|
definePageMeta({ middleware: 'auth' });
|
|
7
7
|
|
|
8
8
|
const { user } = useAuth();
|
package/pages/mirror/[id].vue
CHANGED
|
@@ -118,6 +118,7 @@ useSeoMeta({
|
|
|
118
118
|
</div>
|
|
119
119
|
</div>
|
|
120
120
|
<div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
|
|
121
|
+
<ContentAttachments v-if="transformedContent.attachments?.length" :attachments="transformedContent.attachments" />
|
|
121
122
|
<div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
|
|
122
123
|
<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>
|
|
123
124
|
</div>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getFederatedHub, listFederatedHubPosts } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
function escapeXml(str: string): string {
|
|
4
|
+
return str
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stripHtml(html: string): string {
|
|
13
|
+
return html.replace(/<[^>]*>/g, '').trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default defineEventHandler(async (event) => {
|
|
17
|
+
requireFeature('federation');
|
|
18
|
+
requireFeature('federateHubs');
|
|
19
|
+
|
|
20
|
+
const db = useDB();
|
|
21
|
+
const config = useRuntimeConfig();
|
|
22
|
+
const siteUrl = config.public.siteUrl as string;
|
|
23
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
24
|
+
|
|
25
|
+
const hub = await getFederatedHub(db, id);
|
|
26
|
+
if (!hub) {
|
|
27
|
+
throw createError({ statusCode: 404, statusMessage: 'Federated hub not found' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { items } = await listFederatedHubPosts(db, id, { limit: 50 });
|
|
31
|
+
|
|
32
|
+
const lastBuildDate = items.length > 0 && items[0]!.publishedAt
|
|
33
|
+
? new Date(items[0]!.publishedAt).toUTCString()
|
|
34
|
+
: new Date().toUTCString();
|
|
35
|
+
|
|
36
|
+
const rssItems = items.map((item) => {
|
|
37
|
+
const title = item.sharedContentMeta?.title
|
|
38
|
+
? `Shared: ${item.sharedContentMeta.title}`
|
|
39
|
+
: stripHtml(item.content).slice(0, 100) || 'Post';
|
|
40
|
+
const link = item.objectUri;
|
|
41
|
+
const pubDate = item.publishedAt ? new Date(item.publishedAt).toUTCString() : new Date(item.receivedAt).toUTCString();
|
|
42
|
+
const authorName = item.author.displayName ?? item.author.preferredUsername ?? 'Unknown';
|
|
43
|
+
const desc = stripHtml(item.content).slice(0, 300);
|
|
44
|
+
|
|
45
|
+
return ` <item>
|
|
46
|
+
<title>${escapeXml(title)}</title>
|
|
47
|
+
<link>${escapeXml(link)}</link>
|
|
48
|
+
<guid isPermaLink="false">${escapeXml(link)}</guid>
|
|
49
|
+
<pubDate>${pubDate}</pubDate>
|
|
50
|
+
<description>${escapeXml(desc)}</description>
|
|
51
|
+
<author>${escapeXml(authorName)}@${escapeXml(item.author.instanceDomain)}</author>
|
|
52
|
+
<category>${escapeXml(item.postType)}</category>
|
|
53
|
+
</item>`;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
57
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
58
|
+
<channel>
|
|
59
|
+
<title>${escapeXml(hub.name)} (via ${escapeXml(hub.originDomain)})</title>
|
|
60
|
+
<link>${escapeXml(hub.url ?? `${siteUrl}/federated-hubs/${id}`)}</link>
|
|
61
|
+
<description>${escapeXml(hub.description ?? `Posts from ${hub.name} on ${hub.originDomain}`)}</description>
|
|
62
|
+
<language>en</language>
|
|
63
|
+
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
64
|
+
<atom:link href="${escapeXml(siteUrl)}/api/federated-hubs/${id}/feed.xml" rel="self" type="application/rss+xml"/>
|
|
65
|
+
${rssItems.join('\n')}
|
|
66
|
+
</channel>
|
|
67
|
+
</rss>`;
|
|
68
|
+
|
|
69
|
+
setResponseHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8');
|
|
70
|
+
setResponseHeader(event, 'Cache-Control', 'public, max-age=600, stale-while-revalidate=300');
|
|
71
|
+
return xml;
|
|
72
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { federateDirectMessage, resolveRemoteHandle } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const dmSchema = z.object({
|
|
5
|
+
handle: z.string().min(3),
|
|
6
|
+
body: z.string().min(1).max(10000),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
10
|
+
requireFeature('federation');
|
|
11
|
+
const user = requireAuth(event);
|
|
12
|
+
const db = useDB();
|
|
13
|
+
const config = useConfig();
|
|
14
|
+
const input = await parseBody(event, dmSchema);
|
|
15
|
+
|
|
16
|
+
const resolved = await resolveRemoteHandle(db, input.handle, config.instance.domain);
|
|
17
|
+
if (!resolved) {
|
|
18
|
+
throw createError({ statusCode: 404, statusMessage: 'Could not resolve remote user' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
await federateDirectMessage(db, user.id, resolved.actorUri, input.body, config.instance.domain);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
sent: true,
|
|
25
|
+
recipientActorUri: resolved.actorUri,
|
|
26
|
+
recipientName: resolved.displayName ?? resolved.preferredUsername ?? input.handle,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { updateHub, getHubBySlug } from '@commonpub/server';
|
|
1
|
+
import { updateHub, getHubBySlug, federateHubUpdate } from '@commonpub/server';
|
|
2
2
|
import type { HubDetail } from '@commonpub/server';
|
|
3
3
|
import { updateHubSchema } from '@commonpub/schema';
|
|
4
4
|
|
|
5
5
|
export default defineEventHandler(async (event): Promise<HubDetail> => {
|
|
6
6
|
const user = requireAuth(event);
|
|
7
7
|
const db = useDB();
|
|
8
|
+
const config = useConfig();
|
|
8
9
|
const { slug } = parseParams(event, { slug: 'string' });
|
|
9
10
|
|
|
10
11
|
const hub = await getHubBySlug(db, slug, user.id);
|
|
@@ -18,5 +19,13 @@ export default defineEventHandler(async (event): Promise<HubDetail> => {
|
|
|
18
19
|
if (!updated) {
|
|
19
20
|
throw createError({ statusCode: 403, statusMessage: 'Not authorized to update this hub' });
|
|
20
21
|
}
|
|
22
|
+
|
|
23
|
+
// Federate the hub metadata update (fire-and-forget)
|
|
24
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
25
|
+
federateHubUpdate(db, hub.id, config.instance.domain).catch((err) => {
|
|
26
|
+
console.error('[hub-federation] Failed to federate hub update:', err);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
return updated;
|
|
22
31
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { likePost, unlikePost, hasLikedPost, getHubBySlug, getPostById } from '@commonpub/server';
|
|
1
|
+
import { likePost, unlikePost, hasLikedPost, getHubBySlug, getPostById, federateHubPostLike } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
const user = requireAuth(event);
|
|
5
5
|
const db = useDB();
|
|
6
|
+
const config = useConfig();
|
|
6
7
|
const { slug, postId } = parseParams(event, { slug: 'string', postId: 'uuid' });
|
|
7
8
|
|
|
8
9
|
const community = await getHubBySlug(db, slug);
|
|
@@ -17,5 +18,13 @@ export default defineEventHandler(async (event) => {
|
|
|
17
18
|
return { liked: false };
|
|
18
19
|
}
|
|
19
20
|
await likePost(db, user.id, postId);
|
|
21
|
+
|
|
22
|
+
// Federate the like (fire-and-forget)
|
|
23
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
24
|
+
federateHubPostLike(db, user.id, postId, slug, config.instance.domain).catch((err) => {
|
|
25
|
+
console.error('[hub-federation] Failed to federate post like:', err);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
return { liked: true };
|
|
21
30
|
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { createReply } from '@commonpub/server';
|
|
1
|
+
import { createReply, federateHubPostReply } from '@commonpub/server';
|
|
2
2
|
import type { HubReplyItem } from '@commonpub/server';
|
|
3
3
|
import { createReplySchema } from '@commonpub/schema';
|
|
4
4
|
|
|
5
5
|
export default defineEventHandler(async (event): Promise<HubReplyItem> => {
|
|
6
6
|
const user = requireAuth(event);
|
|
7
7
|
const db = useDB();
|
|
8
|
-
const
|
|
8
|
+
const config = useConfig();
|
|
9
|
+
const { slug, postId } = parseParams(event, { slug: 'string', postId: 'uuid' });
|
|
9
10
|
const body = await readBody(event);
|
|
10
11
|
|
|
11
12
|
const parsed = createReplySchema.safeParse({ ...body, postId });
|
|
@@ -17,5 +18,14 @@ export default defineEventHandler(async (event): Promise<HubReplyItem> => {
|
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
const reply = await createReply(db, user.id, parsed.data);
|
|
22
|
+
|
|
23
|
+
// Federate the reply as Create(Note) with inReplyTo (fire-and-forget)
|
|
24
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
25
|
+
federateHubPostReply(db, user.id, parsed.data.content, postId, slug, config.instance.domain).catch((err) => {
|
|
26
|
+
console.error('[hub-federation] Failed to federate post reply:', err);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return reply;
|
|
21
31
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { editPost, getHubBySlug, federateHubPostUpdate } from '@commonpub/server';
|
|
2
|
+
import type { HubPostItem } from '@commonpub/server';
|
|
3
|
+
import { editPostSchema } from '@commonpub/schema';
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event): Promise<HubPostItem> => {
|
|
6
|
+
const user = requireAuth(event);
|
|
7
|
+
const db = useDB();
|
|
8
|
+
const config = useConfig();
|
|
9
|
+
const { slug, postId } = parseParams(event, { slug: 'string', postId: 'uuid' });
|
|
10
|
+
|
|
11
|
+
const community = await getHubBySlug(db, slug);
|
|
12
|
+
if (!community) {
|
|
13
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const input = await parseBody(event, editPostSchema);
|
|
17
|
+
const updated = await editPost(db, postId, user.id, input);
|
|
18
|
+
if (!updated) {
|
|
19
|
+
throw createError({ statusCode: 403, statusMessage: 'Not authorized to edit this post' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Federate the update (fire-and-forget)
|
|
23
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
24
|
+
federateHubPostUpdate(db, postId, community.id, config.instance.domain).catch((err) => {
|
|
25
|
+
console.error('[hub-federation] Failed to federate post update:', err);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return updated;
|
|
30
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { shareContent, getHubBySlug, federateHubShare, getContentSlugById, buildContentUri } from '@commonpub/server';
|
|
1
|
+
import { shareContent, getHubBySlug, getFederatedContent, createPost, federateHubShare, getContentSlugById, buildContentUri } from '@commonpub/server';
|
|
2
2
|
import type { HubPostItem } from '@commonpub/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
const shareContentSchema = z.object({
|
|
6
|
-
contentId: z.string().uuid(),
|
|
7
|
-
|
|
6
|
+
contentId: z.string().uuid().optional(),
|
|
7
|
+
federatedContentId: z.string().uuid().optional(),
|
|
8
|
+
}).refine((d) => d.contentId || d.federatedContentId, { message: 'Either contentId or federatedContentId is required' });
|
|
8
9
|
|
|
9
10
|
export default defineEventHandler(async (event): Promise<HubPostItem> => {
|
|
10
11
|
const user = requireAuth(event);
|
|
@@ -18,19 +19,53 @@ export default defineEventHandler(async (event): Promise<HubPostItem> => {
|
|
|
18
19
|
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
22
|
+
// Share local content
|
|
23
|
+
if (input.contentId) {
|
|
24
|
+
const post = await shareContent(db, user.id, hub.id, input.contentId);
|
|
25
|
+
if (!post) {
|
|
26
|
+
throw createError({ statusCode: 400, statusMessage: 'Cannot share. You must be a hub member and the content must exist.' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Federate the shared content as Announce from the hub Group actor
|
|
30
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
31
|
+
getContentSlugById(db, input.contentId).then((contentSlug) => {
|
|
32
|
+
if (contentSlug) {
|
|
33
|
+
const contentUri = buildContentUri(config.instance.domain, contentSlug);
|
|
34
|
+
return federateHubShare(db, contentUri, hub.id, config.instance.domain);
|
|
35
|
+
}
|
|
36
|
+
}).catch((err) => {
|
|
37
|
+
console.error('[hub-federation] Failed to federate share:', err);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return post;
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
//
|
|
44
|
+
// Share federated content
|
|
45
|
+
const fedContent = await getFederatedContent(db, input.federatedContentId!);
|
|
46
|
+
if (!fedContent) {
|
|
47
|
+
throw createError({ statusCode: 404, statusMessage: 'Federated content not found' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sharePayload = JSON.stringify({
|
|
51
|
+
federatedContentId: fedContent.id,
|
|
52
|
+
title: fedContent.title,
|
|
53
|
+
type: fedContent.cpubType ?? fedContent.apType ?? 'article',
|
|
54
|
+
coverImageUrl: fedContent.coverImageUrl ?? null,
|
|
55
|
+
description: fedContent.summary ?? null,
|
|
56
|
+
originUrl: fedContent.url ?? fedContent.objectUri,
|
|
57
|
+
originDomain: fedContent.originDomain,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const post = await createPost(db, user.id, {
|
|
61
|
+
hubId: hub.id,
|
|
62
|
+
type: 'share',
|
|
63
|
+
content: sharePayload,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Federate the share using the object URI of the federated content
|
|
27
67
|
if (config.features.federation && config.features.federateHubs) {
|
|
28
|
-
|
|
29
|
-
if (contentSlug) {
|
|
30
|
-
const contentUri = buildContentUri(config.instance.domain, contentSlug);
|
|
31
|
-
return federateHubShare(db, contentUri, hub.id, config.instance.domain);
|
|
32
|
-
}
|
|
33
|
-
}).catch((err) => {
|
|
68
|
+
federateHubShare(db, fedContent.objectUri, hub.id, config.instance.domain).catch((err) => {
|
|
34
69
|
console.error('[hub-federation] Failed to federate share:', err);
|
|
35
70
|
});
|
|
36
71
|
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import { createHub } from '@commonpub/server';
|
|
1
|
+
import { createHub, federateHubActor } from '@commonpub/server';
|
|
2
2
|
import type { HubDetail } from '@commonpub/server';
|
|
3
3
|
import { createHubSchema } from '@commonpub/schema';
|
|
4
4
|
|
|
5
5
|
export default defineEventHandler(async (event): Promise<HubDetail> => {
|
|
6
6
|
const user = requireAuth(event);
|
|
7
7
|
const db = useDB();
|
|
8
|
+
const config = useConfig();
|
|
8
9
|
const input = await parseBody(event, createHubSchema);
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
const hub = await createHub(db, user.id, input);
|
|
12
|
+
|
|
13
|
+
// Federate new hub as Group actor Announce (fire-and-forget)
|
|
14
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
15
|
+
federateHubActor(db, hub.id, config.instance.domain).catch((err) => {
|
|
16
|
+
console.error('[hub-federation] Failed to federate hub actor:', err);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return hub;
|
|
11
21
|
});
|