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