@commonpub/layer 0.3.12 → 0.3.14
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 +115 -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 +7 -7
- 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 +66 -3
- package/pages/messages/[conversationId].vue +6 -6
- package/pages/mirror/[id].vue +3 -1
- package/server/api/federation/dm.post.ts +32 -0
- package/server/api/hubs/[slug]/index.put.ts +10 -1
- package/server/api/hubs/[slug]/posts/[postId]/like.post.ts +14 -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,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { attachments } = 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 isSafeUrl(url: string): boolean {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = new URL(url);
|
|
16
|
+
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fileName(att: { url: string; name?: string }): string {
|
|
23
|
+
if (att.name) return att.name;
|
|
24
|
+
try {
|
|
25
|
+
return new URL(att.url).pathname.split('/').pop() ?? 'attachment';
|
|
26
|
+
} catch {
|
|
27
|
+
return 'attachment';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const safeAttachments = computed(() =>
|
|
32
|
+
attachments.filter((att) => att.url && att.type && isSafeUrl(att.url)),
|
|
33
|
+
);
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div v-if="safeAttachments.length > 0" class="cpub-attachments">
|
|
38
|
+
<div class="cpub-attachments-label">Attachments</div>
|
|
39
|
+
<div class="cpub-attachments-list">
|
|
40
|
+
<a
|
|
41
|
+
v-for="(att, i) in safeAttachments"
|
|
42
|
+
:key="i"
|
|
43
|
+
:href="att.url"
|
|
44
|
+
target="_blank"
|
|
45
|
+
rel="noopener noreferrer"
|
|
46
|
+
class="cpub-attachment-item"
|
|
47
|
+
>
|
|
48
|
+
<img v-if="att.type === 'Image'" :src="att.url" :alt="att.name ?? ''" class="cpub-attachment-thumb" loading="lazy" />
|
|
49
|
+
<div v-else class="cpub-attachment-icon"><i :class="iconForType(att.type)"></i></div>
|
|
50
|
+
<span class="cpub-attachment-name">{{ fileName(att) }}</span>
|
|
51
|
+
</a>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style scoped>
|
|
57
|
+
.cpub-attachments {
|
|
58
|
+
margin: 28px 0;
|
|
59
|
+
padding-top: 16px;
|
|
60
|
+
border-top: 1px solid var(--border);
|
|
61
|
+
}
|
|
62
|
+
.cpub-attachments-label {
|
|
63
|
+
font-size: 10px;
|
|
64
|
+
font-family: var(--font-mono);
|
|
65
|
+
color: var(--text-faint);
|
|
66
|
+
letter-spacing: 0.1em;
|
|
67
|
+
text-transform: uppercase;
|
|
68
|
+
margin-bottom: 10px;
|
|
69
|
+
}
|
|
70
|
+
.cpub-attachments-list {
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-wrap: wrap;
|
|
73
|
+
gap: 8px;
|
|
74
|
+
}
|
|
75
|
+
.cpub-attachment-item {
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
gap: 8px;
|
|
79
|
+
padding: 8px 12px;
|
|
80
|
+
border: 1px solid var(--border);
|
|
81
|
+
background: var(--surface);
|
|
82
|
+
text-decoration: none;
|
|
83
|
+
color: var(--text-dim);
|
|
84
|
+
font-size: 12px;
|
|
85
|
+
transition: background var(--transition-fast);
|
|
86
|
+
}
|
|
87
|
+
.cpub-attachment-item:hover {
|
|
88
|
+
background: var(--surface2);
|
|
89
|
+
color: var(--accent);
|
|
90
|
+
}
|
|
91
|
+
.cpub-attachment-thumb {
|
|
92
|
+
width: 40px;
|
|
93
|
+
height: 40px;
|
|
94
|
+
object-fit: cover;
|
|
95
|
+
border: 1px solid var(--border2);
|
|
96
|
+
}
|
|
97
|
+
.cpub-attachment-icon {
|
|
98
|
+
width: 32px;
|
|
99
|
+
height: 32px;
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
background: var(--surface2);
|
|
104
|
+
border: 1px solid var(--border2);
|
|
105
|
+
color: var(--text-faint);
|
|
106
|
+
font-size: 14px;
|
|
107
|
+
}
|
|
108
|
+
.cpub-attachment-name {
|
|
109
|
+
font-family: var(--font-mono);
|
|
110
|
+
max-width: 200px;
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
text-overflow: ellipsis;
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
}
|
|
115
|
+
</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.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -44,15 +44,15 @@
|
|
|
44
44
|
"vue": "^3.4.0",
|
|
45
45
|
"vue-router": "^4.3.0",
|
|
46
46
|
"zod": "^4.3.6",
|
|
47
|
-
"@commonpub/auth": "0.5.0",
|
|
48
47
|
"@commonpub/docs": "0.5.2",
|
|
49
|
-
"@commonpub/config": "0.7.0",
|
|
50
|
-
"@commonpub/protocol": "0.9.4",
|
|
51
48
|
"@commonpub/editor": "0.5.0",
|
|
52
49
|
"@commonpub/learning": "0.5.0",
|
|
53
|
-
"@commonpub/
|
|
54
|
-
"@commonpub/
|
|
55
|
-
"@commonpub/
|
|
50
|
+
"@commonpub/protocol": "0.9.4",
|
|
51
|
+
"@commonpub/server": "2.11.1",
|
|
52
|
+
"@commonpub/schema": "0.8.11",
|
|
53
|
+
"@commonpub/auth": "0.5.0",
|
|
54
|
+
"@commonpub/config": "0.7.0",
|
|
55
|
+
"@commonpub/ui": "0.7.1"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {}
|
|
58
58
|
}
|
|
@@ -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>
|
|
@@ -62,20 +62,22 @@ async function toggleLike(): Promise<void> {
|
|
|
62
62
|
|
|
63
63
|
// Mod actions
|
|
64
64
|
async function togglePin(): Promise<void> {
|
|
65
|
+
const wasPinned = post.value?.isPinned;
|
|
65
66
|
try {
|
|
66
67
|
await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}/pin`, { method: 'POST' });
|
|
67
68
|
await refreshPost();
|
|
68
|
-
toast.success(
|
|
69
|
+
toast.success(wasPinned ? 'Post unpinned' : 'Post pinned');
|
|
69
70
|
} catch {
|
|
70
71
|
toast.error('Failed to toggle pin');
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
async function toggleLock(): Promise<void> {
|
|
76
|
+
const wasLocked = post.value?.isLocked;
|
|
75
77
|
try {
|
|
76
78
|
await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}/lock`, { method: 'POST' });
|
|
77
79
|
await refreshPost();
|
|
78
|
-
toast.success(
|
|
80
|
+
toast.success(wasLocked ? 'Post unlocked' : 'Post locked');
|
|
79
81
|
} catch {
|
|
80
82
|
toast.error('Failed to toggle lock');
|
|
81
83
|
}
|
|
@@ -92,6 +94,34 @@ async function deletePost(): Promise<void> {
|
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
// Edit
|
|
98
|
+
const editing = ref(false);
|
|
99
|
+
const editContent = ref('');
|
|
100
|
+
const saving = ref(false);
|
|
101
|
+
|
|
102
|
+
function startEdit(): void {
|
|
103
|
+
editContent.value = post.value?.content ?? '';
|
|
104
|
+
editing.value = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function saveEdit(): Promise<void> {
|
|
108
|
+
if (!editContent.value.trim()) return;
|
|
109
|
+
saving.value = true;
|
|
110
|
+
try {
|
|
111
|
+
await $fetch(`/api/hubs/${slug.value}/posts/${postId.value}`, {
|
|
112
|
+
method: 'PUT',
|
|
113
|
+
body: { content: editContent.value },
|
|
114
|
+
});
|
|
115
|
+
editing.value = false;
|
|
116
|
+
await refreshPost();
|
|
117
|
+
toast.success('Post updated');
|
|
118
|
+
} catch {
|
|
119
|
+
toast.error('Failed to update post');
|
|
120
|
+
} finally {
|
|
121
|
+
saving.value = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
95
125
|
function formatDate(d: string | Date): string {
|
|
96
126
|
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
97
127
|
}
|
|
@@ -121,7 +151,21 @@ useSeoMeta({
|
|
|
121
151
|
<span v-if="post.isLocked" class="cpub-post-locked"><i class="fa-solid fa-lock"></i> Locked</span>
|
|
122
152
|
</div>
|
|
123
153
|
|
|
124
|
-
<div class="cpub-post-
|
|
154
|
+
<div v-if="editing" class="cpub-post-edit-form">
|
|
155
|
+
<textarea v-model="editContent" class="cpub-post-edit-textarea" rows="4"></textarea>
|
|
156
|
+
<div class="cpub-post-edit-actions">
|
|
157
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving || !editContent.trim()" @click="saveEdit">
|
|
158
|
+
{{ saving ? 'Saving...' : 'Save' }}
|
|
159
|
+
</button>
|
|
160
|
+
<button class="cpub-btn cpub-btn-sm" @click="editing = false">Cancel</button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div v-else class="cpub-post-content">
|
|
164
|
+
{{ post.content }}
|
|
165
|
+
<div v-if="post.updatedAt && post.updatedAt !== post.createdAt" class="cpub-post-edited">
|
|
166
|
+
<i class="fa-solid fa-pen"></i> edited
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
125
169
|
|
|
126
170
|
<div class="cpub-post-meta">
|
|
127
171
|
<div class="cpub-post-author">
|
|
@@ -147,6 +191,9 @@ useSeoMeta({
|
|
|
147
191
|
|
|
148
192
|
<!-- Mod actions -->
|
|
149
193
|
<div v-if="isMod || isAuthor" class="cpub-post-mod-bar">
|
|
194
|
+
<button v-if="isAuthor && !editing" class="cpub-btn cpub-btn-sm" @click="startEdit">
|
|
195
|
+
<i class="fa-solid fa-pen"></i> Edit
|
|
196
|
+
</button>
|
|
150
197
|
<button v-if="isMod" class="cpub-btn cpub-btn-sm" @click="togglePin">
|
|
151
198
|
<i class="fa-solid fa-thumbtack"></i> {{ post.isPinned ? 'Unpin' : 'Pin' }}
|
|
152
199
|
</button>
|
|
@@ -257,6 +304,19 @@ useSeoMeta({
|
|
|
257
304
|
font-size: 15px; line-height: 1.7; color: var(--text);
|
|
258
305
|
margin-bottom: 16px; white-space: pre-wrap;
|
|
259
306
|
}
|
|
307
|
+
.cpub-post-edited {
|
|
308
|
+
font-size: 10px; font-family: var(--font-mono); color: var(--text-faint);
|
|
309
|
+
margin-top: 4px;
|
|
310
|
+
}
|
|
311
|
+
.cpub-post-edit-form { margin-bottom: 16px; }
|
|
312
|
+
.cpub-post-edit-textarea {
|
|
313
|
+
width: 100%; padding: 10px 12px; background: var(--surface);
|
|
314
|
+
border: var(--border-width-default) solid var(--accent-border);
|
|
315
|
+
color: var(--text); font-size: 14px; font-family: var(--font-sans);
|
|
316
|
+
line-height: 1.6; resize: vertical;
|
|
317
|
+
}
|
|
318
|
+
.cpub-post-edit-textarea:focus { outline: none; border-color: var(--accent); }
|
|
319
|
+
.cpub-post-edit-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
260
320
|
|
|
261
321
|
.cpub-post-meta {
|
|
262
322
|
display: flex; align-items: center; justify-content: space-between;
|
|
@@ -294,6 +354,9 @@ useSeoMeta({
|
|
|
294
354
|
display: flex; gap: 6px;
|
|
295
355
|
}
|
|
296
356
|
|
|
357
|
+
.cpub-btn-primary { background: var(--accent); color: var(--accent-text, #fff); border-color: var(--accent); }
|
|
358
|
+
.cpub-btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
|
359
|
+
.cpub-btn-primary:disabled { opacity: 0.5; cursor: default; }
|
|
297
360
|
.cpub-btn-danger { color: var(--red); border-color: var(--red); }
|
|
298
361
|
.cpub-btn-danger:hover { background: var(--red); color: white; }
|
|
299
362
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
const route = useRoute();
|
|
3
3
|
const conversationId = route.params.conversationId as string;
|
|
4
4
|
|
|
5
|
-
useSeoMeta({ title: 'Message -- devEco.io' });
|
|
6
5
|
definePageMeta({ middleware: 'auth' });
|
|
7
6
|
|
|
8
7
|
const { user } = useAuth();
|
|
@@ -57,17 +56,18 @@ const participantLabel = computed(() => {
|
|
|
57
56
|
return others.length > 0 ? others.join(', ') : 'Conversation';
|
|
58
57
|
});
|
|
59
58
|
|
|
59
|
+
useSeoMeta({ title: () => `Message — ${participantLabel.value}` });
|
|
60
|
+
|
|
60
61
|
async function handleSend(text: string): Promise<void> {
|
|
61
62
|
await $fetch(`/api/messages/${conversationId}` as string, {
|
|
62
63
|
method: 'POST',
|
|
63
64
|
body: { body: text },
|
|
64
65
|
});
|
|
65
66
|
// SSE will pick up the new message, but also do an immediate refresh for responsiveness
|
|
66
|
-
refresh()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
67
|
+
await refresh();
|
|
68
|
+
if (initialMessages.value) {
|
|
69
|
+
messages.value = [...initialMessages.value];
|
|
70
|
+
}
|
|
71
71
|
}
|
|
72
72
|
</script>
|
|
73
73
|
|
package/pages/mirror/[id].vue
CHANGED
|
@@ -117,7 +117,9 @@ useSeoMeta({
|
|
|
117
117
|
<p v-if="transformedContent.author.bio" class="cpub-mirror-bio">{{ stripHtml(transformedContent.author.bio) }}</p>
|
|
118
118
|
</div>
|
|
119
119
|
</div>
|
|
120
|
+
<!-- Content is sanitized on ingest (inboxHandlers.ts → sanitizeHtml). Safe for v-html. -->
|
|
120
121
|
<div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
|
|
122
|
+
<ContentAttachments v-if="transformedContent.attachments?.length" :attachments="transformedContent.attachments" />
|
|
121
123
|
<div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
|
|
122
124
|
<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
125
|
</div>
|
|
@@ -145,7 +147,7 @@ useSeoMeta({
|
|
|
145
147
|
display: flex; align-items: center; gap: 4px; font-size: 11px;
|
|
146
148
|
}
|
|
147
149
|
.cpub-fed-banner-follow {
|
|
148
|
-
margin-left: auto; background: var(--accent); color: #fff; border: none;
|
|
150
|
+
margin-left: auto; background: var(--accent); color: var(--accent-text, #fff); border: none;
|
|
149
151
|
font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer;
|
|
150
152
|
display: flex; align-items: center; gap: 4px; white-space: nowrap;
|
|
151
153
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { federateDirectMessage, resolveRemoteHandle } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const dmSchema = z.object({
|
|
5
|
+
handle: z.string().min(3).regex(/^@?[\w.-]+@[\w.-]+\.\w+$/, 'Invalid federation handle'),
|
|
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
|
+
try {
|
|
22
|
+
await federateDirectMessage(db, user.id, resolved.actorUri, input.body, config.instance.domain);
|
|
23
|
+
} catch {
|
|
24
|
+
throw createError({ statusCode: 502, statusMessage: 'Failed to deliver message to remote server' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
sent: true,
|
|
29
|
+
recipientActorUri: resolved.actorUri,
|
|
30
|
+
recipientName: resolved.displayName ?? resolved.preferredUsername ?? input.handle,
|
|
31
|
+
};
|
|
32
|
+
});
|
|
@@ -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, checkBan } 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);
|
|
@@ -11,11 +12,23 @@ export default defineEventHandler(async (event) => {
|
|
|
11
12
|
const post = await getPostById(db, postId);
|
|
12
13
|
if (!post || post.hubId !== community.id) throw createError({ statusCode: 404, statusMessage: 'Post not found' });
|
|
13
14
|
|
|
15
|
+
// Banned users cannot like posts
|
|
16
|
+
const ban = await checkBan(db, community.id, user.id);
|
|
17
|
+
if (ban) throw createError({ statusCode: 403, statusMessage: 'You are banned from this hub' });
|
|
18
|
+
|
|
14
19
|
const alreadyLiked = await hasLikedPost(db, user.id, postId);
|
|
15
20
|
if (alreadyLiked) {
|
|
16
21
|
await unlikePost(db, user.id, postId);
|
|
17
22
|
return { liked: false };
|
|
18
23
|
}
|
|
19
24
|
await likePost(db, user.id, postId);
|
|
25
|
+
|
|
26
|
+
// Federate the like (fire-and-forget)
|
|
27
|
+
if (config.features.federation && config.features.federateHubs) {
|
|
28
|
+
federateHubPostLike(db, user.id, postId, slug, config.instance.domain).catch((err) => {
|
|
29
|
+
console.error('[hub-federation] Failed to federate post like:', err);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
return { liked: true };
|
|
21
34
|
});
|
|
@@ -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, community.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
|
});
|