@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.
Files changed (33) hide show
  1. package/components/ContentCard.vue +2 -6
  2. package/components/hub/HubFeed.vue +1 -1
  3. package/components/views/ArticleView.vue +4 -3
  4. package/components/views/BlogView.vue +1 -1
  5. package/components/views/ExplainerView.vue +3 -3
  6. package/components/views/ProjectView.vue +3 -2
  7. package/composables/useContentSave.ts +17 -5
  8. package/composables/useContentUrl.ts +62 -0
  9. package/package.json +6 -6
  10. package/pages/[type]/[slug]/edit.vue +22 -800
  11. package/pages/[type]/[slug]/index.vue +11 -280
  12. package/pages/[type]/index.vue +2 -1
  13. package/pages/admin/content.vue +1 -1
  14. package/pages/contests/[slug]/index.vue +1 -1
  15. package/pages/contests/[slug]/judge.vue +2 -1
  16. package/pages/create.vue +2 -1
  17. package/pages/dashboard.vue +5 -5
  18. package/pages/federated-hubs/[id]/index.vue +7 -5
  19. package/pages/hubs/[slug]/index.vue +1 -0
  20. package/pages/index.vue +1 -1
  21. package/pages/learn/[slug]/[lessonSlug]/edit.vue +1 -1
  22. package/pages/learn/[slug]/[lessonSlug]/index.vue +1 -1
  23. package/pages/u/[username]/[type]/[slug]/edit.vue +783 -0
  24. package/pages/u/[username]/[type]/[slug]/index.vue +309 -0
  25. package/server/api/content/[id]/index.get.ts +4 -1
  26. package/server/api/hubs/[slug]/feed.xml.get.ts +1 -1
  27. package/server/api/hubs/[slug]/share.post.ts +3 -4
  28. package/server/api/social/like.post.ts +3 -4
  29. package/server/api/users/[username]/feed.xml.get.ts +2 -2
  30. package/server/routes/feed.xml.ts +1 -1
  31. package/server/routes/hubs/[slug]/posts/[postId].ts +3 -3
  32. package/server/routes/sitemap.xml.ts +3 -1
  33. 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, getContentSlugById, buildContentUri } from '@commonpub/server';
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
- getContentSlugById(db, input.contentId).then((contentSlug) => {
32
- if (contentSlug) {
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, getContentSlugById, buildContentUri } from '@commonpub/server';
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 slug = await getContentSlugById(db, input.targetId);
24
- if (slug) {
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)}/profile/${escapeXml(username)}</link>
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
+ });