@commonpub/layer 0.5.7 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ContentCard.vue +2 -6
- package/components/hub/HubFeed.vue +1 -1
- package/components/views/ArticleView.vue +4 -3
- package/components/views/BlogView.vue +1 -1
- package/components/views/ExplainerView.vue +3 -3
- package/components/views/ProjectView.vue +3 -2
- package/composables/useContentSave.ts +17 -5
- package/composables/useContentUrl.ts +62 -0
- package/package.json +6 -6
- package/pages/[type]/[slug]/edit.vue +22 -800
- package/pages/[type]/[slug]/index.vue +11 -280
- package/pages/[type]/index.vue +2 -1
- package/pages/admin/content.vue +1 -1
- package/pages/contests/[slug]/index.vue +1 -1
- package/pages/contests/[slug]/judge.vue +2 -1
- package/pages/create.vue +2 -1
- package/pages/dashboard.vue +5 -5
- package/pages/federated-hubs/[id]/index.vue +7 -5
- package/pages/hubs/[slug]/index.vue +1 -0
- package/pages/index.vue +1 -1
- package/pages/learn/[slug]/[lessonSlug]/edit.vue +1 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +1 -1
- package/pages/u/[username]/[type]/[slug]/edit.vue +783 -0
- package/pages/u/[username]/[type]/[slug]/index.vue +309 -0
- package/server/api/content/[id]/index.get.ts +4 -1
- package/server/api/hubs/[slug]/feed.xml.get.ts +1 -1
- package/server/api/hubs/[slug]/share.post.ts +3 -4
- package/server/api/social/like.post.ts +3 -4
- package/server/api/users/[username]/feed.xml.get.ts +2 -2
- package/server/routes/feed.xml.ts +1 -1
- package/server/routes/hubs/[slug]/posts/[postId].ts +3 -3
- package/server/routes/sitemap.xml.ts +3 -1
- package/server/routes/u/[username]/[type]/[slug].ts +73 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { BlockTuple } from '@commonpub/editor';
|
|
3
|
+
import type { Serialized, ContentDetail, ContentListItem, PaginatedResponse } from '@commonpub/server';
|
|
4
|
+
|
|
5
|
+
const route = useRoute();
|
|
6
|
+
const username = computed(() => route.params.username as string);
|
|
7
|
+
const contentType = computed(() => route.params.type as string);
|
|
8
|
+
const slug = computed(() => route.params.slug as string);
|
|
9
|
+
|
|
10
|
+
// Pass cookies so SSR can resolve auth (needed to view own drafts)
|
|
11
|
+
// Include author param to disambiguate user-scoped slugs
|
|
12
|
+
const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
|
|
13
|
+
const { data: content, pending: contentPending } = useLazyFetch<Serialized<ContentDetail>>(
|
|
14
|
+
() => `/api/content/${slug.value}?author=${encodeURIComponent(username.value)}`,
|
|
15
|
+
{ headers: reqHeaders },
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
// Explainer view uses its own topbar + full-height layout — disable the default layout
|
|
19
|
+
// to avoid double-topbar and layout conflicts. Must also reset when navigating away
|
|
20
|
+
// from an explainer (same page component is reused for all content types).
|
|
21
|
+
watch(() => content.value?.type, (type) => {
|
|
22
|
+
if (type === 'explainer') {
|
|
23
|
+
setPageLayout('default');
|
|
24
|
+
} else if (type) {
|
|
25
|
+
setPageLayout('default');
|
|
26
|
+
}
|
|
27
|
+
}, { immediate: true });
|
|
28
|
+
|
|
29
|
+
useSeoMeta({
|
|
30
|
+
title: () => content.value?.title ? `${content.value.title} — ${useSiteName()}` : useSiteName(),
|
|
31
|
+
description: () => content.value?.seoDescription || content.value?.description || '',
|
|
32
|
+
ogImage: () => content.value?.coverImageUrl || '/og-default.png',
|
|
33
|
+
ogTitle: () => content.value?.title || useSiteName(),
|
|
34
|
+
ogDescription: () => content.value?.seoDescription || content.value?.description || '',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const { user } = useAuth();
|
|
38
|
+
const { contentEditPath } = useContentUrl();
|
|
39
|
+
const isOwner = computed(() => user.value?.id === content.value?.author?.id);
|
|
40
|
+
|
|
41
|
+
// Estimate reading time (~200 words per minute)
|
|
42
|
+
const readTime = computed(() => {
|
|
43
|
+
if (!content.value?.content) return undefined;
|
|
44
|
+
const blocks = content.value.content as BlockTuple[];
|
|
45
|
+
let words = 0;
|
|
46
|
+
for (const [type, data] of blocks) {
|
|
47
|
+
// Extract text from any string field in the block data
|
|
48
|
+
for (const val of Object.values(data)) {
|
|
49
|
+
if (typeof val === 'string' && val.trim()) {
|
|
50
|
+
// Strip HTML tags before counting
|
|
51
|
+
const text = val.replace(/<[^>]*>/g, '').trim();
|
|
52
|
+
if (text) words += text.split(/\s+/).length;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const minutes = Math.max(1, Math.round(words / 200));
|
|
57
|
+
return `${minutes} min read`;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Enrich content with computed readTime
|
|
61
|
+
const enrichedContent = computed(() => {
|
|
62
|
+
if (!content.value) return null;
|
|
63
|
+
return { ...content.value, readTime: readTime.value };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Content type used for conditional view component rendering in template
|
|
67
|
+
|
|
68
|
+
// Related content
|
|
69
|
+
const { data: related } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
70
|
+
query: { status: 'published', type: contentType.value, limit: 3, sort: 'recent' },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Track view
|
|
74
|
+
onMounted(() => {
|
|
75
|
+
if (content.value?.id) {
|
|
76
|
+
$fetch(`/api/content/${content.value.id}/view`, { method: 'POST' }).catch(() => {});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<div v-if="contentPending" class="cpub-loading" aria-live="polite">Loading content...</div>
|
|
83
|
+
<div v-else-if="enrichedContent">
|
|
84
|
+
<!-- Edit button overlay -->
|
|
85
|
+
<div v-if="isOwner && enrichedContent.type !== 'explainer'" class="cpub-view-edit-bar">
|
|
86
|
+
<NuxtLink :to="contentEditPath(username, enrichedContent.type, enrichedContent.slug)" class="cpub-edit-btn" aria-label="Edit">
|
|
87
|
+
<i class="fa-solid fa-pen"></i> Edit
|
|
88
|
+
</NuxtLink>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<!-- Specialized view by content type -->
|
|
92
|
+
<ViewsProjectView v-if="contentType === 'project'" :content="(enrichedContent as any)" />
|
|
93
|
+
<ViewsArticleView v-else-if="contentType === 'article'" :content="(enrichedContent as any)" />
|
|
94
|
+
<ViewsBlogView v-else-if="contentType === 'blog'" :content="(enrichedContent as any)" />
|
|
95
|
+
<ViewsExplainerView v-else-if="contentType === 'explainer'" :content="(enrichedContent as any)" />
|
|
96
|
+
|
|
97
|
+
<!-- Fallback: generic view for unknown types -->
|
|
98
|
+
<article v-else class="cpub-view">
|
|
99
|
+
<div class="cpub-cover" v-if="enrichedContent.coverImageUrl">
|
|
100
|
+
<img :src="enrichedContent.coverImageUrl" :alt="enrichedContent.title" />
|
|
101
|
+
</div>
|
|
102
|
+
<div class="cpub-cover cpub-cover-placeholder" v-else>
|
|
103
|
+
<span class="cpub-cover-label">{{ contentType }}</span>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div class="cpub-view-container">
|
|
107
|
+
<header class="cpub-view-header">
|
|
108
|
+
<ContentTypeBadge :type="enrichedContent.type" />
|
|
109
|
+
<h1 class="cpub-view-title">{{ enrichedContent.title }}</h1>
|
|
110
|
+
<p class="cpub-view-subtitle" v-if="enrichedContent.subtitle || enrichedContent.description">
|
|
111
|
+
{{ enrichedContent.subtitle || enrichedContent.description }}
|
|
112
|
+
</p>
|
|
113
|
+
<AuthorRow
|
|
114
|
+
:author="enrichedContent.author"
|
|
115
|
+
:date="enrichedContent.publishedAt || enrichedContent.createdAt"
|
|
116
|
+
:read-time="readTime"
|
|
117
|
+
/>
|
|
118
|
+
<EngagementBar
|
|
119
|
+
:target-type="enrichedContent.type"
|
|
120
|
+
:target-id="enrichedContent.id"
|
|
121
|
+
:like-count="enrichedContent.likeCount ?? 0"
|
|
122
|
+
:comment-count="enrichedContent.commentCount ?? 0"
|
|
123
|
+
/>
|
|
124
|
+
</header>
|
|
125
|
+
|
|
126
|
+
<div class="cpub-view-body">
|
|
127
|
+
<template v-if="enrichedContent.content && Array.isArray(enrichedContent.content) && (enrichedContent.content as unknown[]).length > 0">
|
|
128
|
+
<BlocksBlockContentRenderer :blocks="(enrichedContent.content as [string, Record<string, unknown>][])" />
|
|
129
|
+
</template>
|
|
130
|
+
<p v-else class="cpub-view-empty">No content body yet.</p>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div class="cpub-view-tags" v-if="enrichedContent.tags?.length">
|
|
134
|
+
<span class="cpub-view-tag" v-for="tag in enrichedContent.tags" :key="tag.id || tag.name">{{ tag.name || tag }}</span>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<AuthorCard :author="enrichedContent.author" />
|
|
138
|
+
<CommentSection :target-type="enrichedContent.type" :target-id="enrichedContent.id" />
|
|
139
|
+
</div>
|
|
140
|
+
</article>
|
|
141
|
+
|
|
142
|
+
<!-- Related content (shown for all types) -->
|
|
143
|
+
<section class="cpub-view-related" v-if="related?.items?.length">
|
|
144
|
+
<div class="cpub-view-related-inner">
|
|
145
|
+
<h2 class="cpub-view-related-title">Related {{ contentType }}s</h2>
|
|
146
|
+
<div class="cpub-view-related-grid">
|
|
147
|
+
<ContentCard
|
|
148
|
+
v-for="item in related.items.filter((i: Record<string, unknown>) => i.id !== enrichedContent?.id).slice(0, 3)"
|
|
149
|
+
:key="item.id"
|
|
150
|
+
:item="item"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
</div>
|
|
156
|
+
<div v-else class="cpub-not-found">
|
|
157
|
+
<h1>Content not found</h1>
|
|
158
|
+
<p>The requested content could not be found.</p>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<style scoped>
|
|
163
|
+
.cpub-view-edit-bar {
|
|
164
|
+
display: flex;
|
|
165
|
+
justify-content: flex-end;
|
|
166
|
+
padding: var(--space-2) var(--space-6);
|
|
167
|
+
border-bottom: var(--border-width-default) solid var(--border2);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.cpub-edit-btn {
|
|
171
|
+
padding: var(--space-1) var(--space-3);
|
|
172
|
+
border: var(--border-width-default) solid var(--border);
|
|
173
|
+
font-size: var(--text-xs);
|
|
174
|
+
color: var(--text);
|
|
175
|
+
text-decoration: none;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.cpub-edit-btn:hover {
|
|
179
|
+
background: var(--surface2);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.cpub-view {
|
|
183
|
+
max-width: 100%;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.cpub-cover {
|
|
187
|
+
width: 100%;
|
|
188
|
+
height: 300px;
|
|
189
|
+
position: relative;
|
|
190
|
+
overflow: hidden;
|
|
191
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.cpub-cover img {
|
|
195
|
+
width: 100%;
|
|
196
|
+
height: 100%;
|
|
197
|
+
object-fit: cover;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.cpub-cover-placeholder {
|
|
201
|
+
background: var(--surface2);
|
|
202
|
+
display: flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
justify-content: center;
|
|
205
|
+
background-image:
|
|
206
|
+
linear-gradient(var(--border2) 1px, transparent 1px),
|
|
207
|
+
linear-gradient(90deg, var(--border2) 1px, transparent 1px);
|
|
208
|
+
background-size: 40px 40px;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.cpub-cover-label {
|
|
212
|
+
font-family: var(--font-mono);
|
|
213
|
+
font-size: var(--text-sm);
|
|
214
|
+
text-transform: uppercase;
|
|
215
|
+
letter-spacing: var(--tracking-widest);
|
|
216
|
+
color: var(--text-faint);
|
|
217
|
+
background: var(--surface);
|
|
218
|
+
padding: var(--space-2) var(--space-4);
|
|
219
|
+
border: var(--border-width-default) solid var(--border);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.cpub-view-container {
|
|
223
|
+
max-width: var(--content-max-width);
|
|
224
|
+
margin: 0 auto;
|
|
225
|
+
padding: var(--space-8) var(--space-6);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.cpub-view-header {
|
|
229
|
+
margin-bottom: var(--space-6);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.cpub-view-title {
|
|
233
|
+
font-size: var(--text-2xl);
|
|
234
|
+
font-weight: var(--font-weight-bold);
|
|
235
|
+
margin-top: var(--space-3);
|
|
236
|
+
margin-bottom: var(--space-2);
|
|
237
|
+
line-height: var(--leading-tight);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.cpub-view-subtitle {
|
|
241
|
+
font-size: var(--text-md);
|
|
242
|
+
color: var(--text-dim);
|
|
243
|
+
margin-bottom: var(--space-4);
|
|
244
|
+
line-height: var(--leading-relaxed);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.cpub-view-body {
|
|
248
|
+
margin-bottom: var(--space-8);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.cpub-view-empty {
|
|
252
|
+
color: var(--text-faint);
|
|
253
|
+
font-style: italic;
|
|
254
|
+
padding: var(--space-10) 0;
|
|
255
|
+
text-align: center;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.cpub-view-tags {
|
|
259
|
+
display: flex;
|
|
260
|
+
gap: var(--space-2);
|
|
261
|
+
flex-wrap: wrap;
|
|
262
|
+
margin-bottom: var(--space-8);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.cpub-view-tag {
|
|
266
|
+
padding: var(--space-1) var(--space-3);
|
|
267
|
+
background: var(--surface2);
|
|
268
|
+
font-family: var(--font-mono);
|
|
269
|
+
font-size: 10px;
|
|
270
|
+
font-weight: var(--font-weight-medium);
|
|
271
|
+
color: var(--text-dim);
|
|
272
|
+
text-transform: lowercase;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.cpub-view-related {
|
|
276
|
+
border-top: var(--border-width-default) solid var(--border2);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.cpub-view-related-inner {
|
|
280
|
+
max-width: var(--content-max-width);
|
|
281
|
+
margin: 0 auto;
|
|
282
|
+
padding: var(--space-8) var(--space-6);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.cpub-view-related-title {
|
|
286
|
+
font-size: var(--text-lg);
|
|
287
|
+
font-weight: var(--font-weight-bold);
|
|
288
|
+
margin-bottom: var(--space-5);
|
|
289
|
+
text-transform: capitalize;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.cpub-view-related-grid {
|
|
293
|
+
display: grid;
|
|
294
|
+
grid-template-columns: repeat(3, 1fr);
|
|
295
|
+
gap: var(--space-4);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.cpub-not-found {
|
|
299
|
+
text-align: center;
|
|
300
|
+
padding: var(--space-16) 0;
|
|
301
|
+
color: var(--text-dim);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@media (max-width: 768px) {
|
|
305
|
+
.cpub-view-related-grid {
|
|
306
|
+
grid-template-columns: 1fr;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
</style>
|
|
@@ -6,8 +6,11 @@ export default defineEventHandler(async (event): Promise<ContentDetail> => {
|
|
|
6
6
|
// Param is named 'id' (directory name) but the value is a slug for GET requests
|
|
7
7
|
const { id: slugOrId } = parseParams(event, { id: 'string' });
|
|
8
8
|
const user = getOptionalUser(event);
|
|
9
|
+
// Optional author query param for disambiguating user-scoped slugs
|
|
10
|
+
const query = getQuery(event);
|
|
11
|
+
const authorUsername = typeof query.author === 'string' ? query.author : undefined;
|
|
9
12
|
|
|
10
|
-
const content = await getContentBySlug(db, slugOrId, user?.id);
|
|
13
|
+
const content = await getContentBySlug(db, slugOrId, user?.id, authorUsername);
|
|
11
14
|
if (!content) {
|
|
12
15
|
throw createError({ statusCode: 404, statusMessage: 'Content not found' });
|
|
13
16
|
}
|
|
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
|
|
28
28
|
: new Date().toUTCString();
|
|
29
29
|
|
|
30
30
|
const rssItems = items.map((item) => {
|
|
31
|
-
const link = `${siteUrl}/${item.type}/${item.slug}`;
|
|
31
|
+
const link = `${siteUrl}/u/${item.author.username}/${item.type}/${item.slug}`;
|
|
32
32
|
const pubDate = item.publishedAt ? new Date(item.publishedAt).toUTCString() : new Date().toUTCString();
|
|
33
33
|
return ` <item>
|
|
34
34
|
<title>${escapeXml(item.title)}</title>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { shareContent, getHubBySlug, getFederatedContent, createPost, federateHubShare,
|
|
1
|
+
import { shareContent, getHubBySlug, getFederatedContent, createPost, federateHubShare, resolveContentObjectUri } from '@commonpub/server';
|
|
2
2
|
import type { HubPostItem } from '@commonpub/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
@@ -28,9 +28,8 @@ export default defineEventHandler(async (event): Promise<HubPostItem> => {
|
|
|
28
28
|
|
|
29
29
|
// Federate the shared content as Announce from the hub Group actor
|
|
30
30
|
if (config.features.federation && config.features.federateHubs) {
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
const contentUri = buildContentUri(config.instance.domain, contentSlug);
|
|
31
|
+
resolveContentObjectUri(db, input.contentId, config.instance.domain).then((contentUri) => {
|
|
32
|
+
if (contentUri) {
|
|
34
33
|
return federateHubShare(db, contentUri, hub.id, config.instance.domain);
|
|
35
34
|
}
|
|
36
35
|
}).catch((err) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { toggleLike, onContentLiked, onContentUnliked,
|
|
1
|
+
import { toggleLike, onContentLiked, onContentUnliked, resolveContentObjectUri } from '@commonpub/server';
|
|
2
2
|
import { likeTargetTypeSchema } from '@commonpub/schema';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
@@ -20,9 +20,8 @@ export default defineEventHandler(async (event): Promise<{ liked: boolean }> =>
|
|
|
20
20
|
|
|
21
21
|
// Federate likes on content items (not posts/comments)
|
|
22
22
|
if (FEDERABLE_LIKE_TYPES.has(input.targetType)) {
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
25
|
-
const contentUri = buildContentUri(config.instance.domain, slug);
|
|
23
|
+
const contentUri = await resolveContentObjectUri(db, input.targetId, config.instance.domain);
|
|
24
|
+
if (contentUri) {
|
|
26
25
|
if (result.liked) {
|
|
27
26
|
await onContentLiked(db, user.id, contentUri, config);
|
|
28
27
|
} else {
|
|
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
|
|
|
34
34
|
: new Date().toUTCString();
|
|
35
35
|
|
|
36
36
|
const rssItems = items.map((item) => {
|
|
37
|
-
const link = `${siteUrl}/${item.type}/${item.slug}`;
|
|
37
|
+
const link = `${siteUrl}/u/${item.author.username}/${item.type}/${item.slug}`;
|
|
38
38
|
const pubDate = new Date(item.publishedAt ?? item.createdAt).toUTCString();
|
|
39
39
|
return ` <item>
|
|
40
40
|
<title>${escapeXml(item.title)}</title>
|
|
@@ -50,7 +50,7 @@ export default defineEventHandler(async (event) => {
|
|
|
50
50
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
51
51
|
<channel>
|
|
52
52
|
<title>${escapeXml(displayName)} — CommonPub</title>
|
|
53
|
-
<link>${escapeXml(siteUrl)}/
|
|
53
|
+
<link>${escapeXml(siteUrl)}/u/${escapeXml(username)}</link>
|
|
54
54
|
<description>Content by ${escapeXml(displayName)}</description>
|
|
55
55
|
<language>en</language>
|
|
56
56
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
|
|
27
27
|
: new Date().toUTCString();
|
|
28
28
|
|
|
29
29
|
const rssItems = items.map((item) => {
|
|
30
|
-
const link = `${siteUrl}/${item.type}/${item.slug}`;
|
|
30
|
+
const link = `${siteUrl}/u/${item.author.username}/${item.type}/${item.slug}`;
|
|
31
31
|
const pubDate = new Date(item.publishedAt ?? item.createdAt).toUTCString();
|
|
32
32
|
return ` <item>
|
|
33
33
|
<title>${escapeXml(item.title)}</title>
|
|
@@ -63,9 +63,9 @@ export default defineEventHandler(async (event) => {
|
|
|
63
63
|
title: shared.title ?? '',
|
|
64
64
|
summary: shared.description ?? null,
|
|
65
65
|
coverImageUrl: shared.coverImageUrl ?? null,
|
|
66
|
-
originUrl: shared.slug
|
|
67
|
-
? `https://${domain}/${shared.type}/${shared.slug}`
|
|
68
|
-
: null,
|
|
66
|
+
originUrl: shared.slug && shared.authorUsername
|
|
67
|
+
? `https://${domain}/u/${shared.authorUsername}/${shared.type}/${shared.slug}`
|
|
68
|
+
: shared.slug ? `https://${domain}/${shared.type}/${shared.slug}` : null,
|
|
69
69
|
originDomain: domain,
|
|
70
70
|
};
|
|
71
71
|
const displayName = post.authorDisplayName ?? post.authorUsername;
|
|
@@ -22,8 +22,10 @@ export default defineEventHandler(async (event) => {
|
|
|
22
22
|
type: contentItems.type,
|
|
23
23
|
slug: contentItems.slug,
|
|
24
24
|
updatedAt: contentItems.updatedAt,
|
|
25
|
+
authorUsername: users.username,
|
|
25
26
|
})
|
|
26
27
|
.from(contentItems)
|
|
28
|
+
.innerJoin(users, eq(contentItems.authorId, users.id))
|
|
27
29
|
.where(eq(contentItems.status, 'published'));
|
|
28
30
|
|
|
29
31
|
// Users with public profiles
|
|
@@ -50,7 +52,7 @@ export default defineEventHandler(async (event) => {
|
|
|
50
52
|
// Content pages
|
|
51
53
|
for (const item of publishedContent) {
|
|
52
54
|
urls.push({
|
|
53
|
-
loc: `${siteUrl}/${item.type}/${item.slug}`,
|
|
55
|
+
loc: `${siteUrl}/u/${item.authorUsername}/${item.type}/${item.slug}`,
|
|
54
56
|
lastmod: new Date(item.updatedAt).toISOString(),
|
|
55
57
|
priority: '0.8',
|
|
56
58
|
changefreq: 'weekly',
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { contentToArticle } from '@commonpub/protocol';
|
|
2
|
+
import { contentItems, users } from '@commonpub/schema';
|
|
3
|
+
import { eq, and, isNull } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* New-format content AP Article endpoint.
|
|
7
|
+
* URI: /u/{username}/{type}/{slug}
|
|
8
|
+
*
|
|
9
|
+
* Serves Article JSON-LD when requested with AP Accept header.
|
|
10
|
+
* Browsers see the Nuxt page instead (this handler returns nothing for non-AP requests).
|
|
11
|
+
*/
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
const accept = getRequestHeader(event, 'accept') ?? '';
|
|
14
|
+
const isAPRequest =
|
|
15
|
+
accept.includes('application/activity+json') ||
|
|
16
|
+
accept.includes('application/ld+json');
|
|
17
|
+
|
|
18
|
+
if (!isAPRequest) return;
|
|
19
|
+
|
|
20
|
+
const config = useConfig();
|
|
21
|
+
if (!config.features.federation) return;
|
|
22
|
+
|
|
23
|
+
const username = getRouterParam(event, 'username');
|
|
24
|
+
const type = getRouterParam(event, 'type');
|
|
25
|
+
const slug = getRouterParam(event, 'slug');
|
|
26
|
+
if (!username || !type || !slug) return;
|
|
27
|
+
|
|
28
|
+
const db = useDB();
|
|
29
|
+
const domain = config.instance.domain;
|
|
30
|
+
|
|
31
|
+
const [row] = await db
|
|
32
|
+
.select({
|
|
33
|
+
content: contentItems,
|
|
34
|
+
author: {
|
|
35
|
+
username: users.username,
|
|
36
|
+
displayName: users.displayName,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.from(contentItems)
|
|
40
|
+
.innerJoin(users, eq(contentItems.authorId, users.id))
|
|
41
|
+
.where(and(
|
|
42
|
+
eq(users.username, username),
|
|
43
|
+
eq(contentItems.type, type as 'project' | 'article' | 'blog' | 'explainer'),
|
|
44
|
+
eq(contentItems.slug, slug),
|
|
45
|
+
eq(contentItems.status, 'published'),
|
|
46
|
+
isNull(contentItems.deletedAt),
|
|
47
|
+
))
|
|
48
|
+
.limit(1);
|
|
49
|
+
|
|
50
|
+
if (!row) return;
|
|
51
|
+
|
|
52
|
+
setResponseHeader(event, 'content-type', 'application/activity+json');
|
|
53
|
+
|
|
54
|
+
const article = contentToArticle(
|
|
55
|
+
{
|
|
56
|
+
id: row.content.id,
|
|
57
|
+
type: row.content.type,
|
|
58
|
+
title: row.content.title,
|
|
59
|
+
slug: row.content.slug,
|
|
60
|
+
description: row.content.description,
|
|
61
|
+
content: typeof row.content.content === 'string'
|
|
62
|
+
? row.content.content
|
|
63
|
+
: JSON.stringify(row.content.content),
|
|
64
|
+
coverImageUrl: row.content.coverImageUrl,
|
|
65
|
+
publishedAt: row.content.publishedAt,
|
|
66
|
+
updatedAt: row.content.updatedAt,
|
|
67
|
+
},
|
|
68
|
+
{ username: row.author.username, displayName: row.author.displayName ?? row.author.username },
|
|
69
|
+
domain,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return article;
|
|
73
|
+
});
|