@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
|
@@ -47,12 +47,8 @@ const diffDots = computed(() => {
|
|
|
47
47
|
|
|
48
48
|
const isFederated = computed(() => props.item.source === 'federated');
|
|
49
49
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
return `/mirror/${props.item.federatedContentId}`;
|
|
53
|
-
}
|
|
54
|
-
return `/${props.item.type}/${props.item.slug}`;
|
|
55
|
-
});
|
|
50
|
+
const { contentLink } = useContentUrl();
|
|
51
|
+
const cardLink = computed(() => contentLink(props.item));
|
|
56
52
|
|
|
57
53
|
const authorInitial = computed(() => {
|
|
58
54
|
const name = props.item.author?.displayName || props.item.author?.username || '?';
|
|
@@ -61,7 +61,7 @@ const filteredPosts = computed(() => {
|
|
|
61
61
|
<div v-if="filteredPosts.length" class="cpub-feed-list">
|
|
62
62
|
<template v-for="post in filteredPosts" :key="post.id">
|
|
63
63
|
<!-- Share posts -->
|
|
64
|
-
<NuxtLink v-if="post.sharedContent?.slug && !post.sharedContent?.url" :to="`/${post.sharedContent.type}/${post.sharedContent.slug}`" class="cpub-share-card">
|
|
64
|
+
<NuxtLink v-if="post.sharedContent?.slug && !post.sharedContent?.url" :to="(post.sharedContent as any).authorUsername ? `/u/${(post.sharedContent as any).authorUsername}/${post.sharedContent.type}/${post.sharedContent.slug}` : `/${post.sharedContent.type}/${post.sharedContent.slug}`" class="cpub-share-card">
|
|
65
65
|
<div class="cpub-share-card-context">
|
|
66
66
|
<i class="fa-solid fa-share-nodes"></i>
|
|
67
67
|
{{ post.author.name }} shared a {{ post.sharedContent.type }}
|
|
@@ -46,7 +46,8 @@ const followingAuthor = ref((props.content as unknown as Record<string, unknown>
|
|
|
46
46
|
|
|
47
47
|
async function handleFollowAuthor(): Promise<void> {
|
|
48
48
|
if (!isAuthenticated.value) {
|
|
49
|
-
|
|
49
|
+
const { contentPath } = useContentUrl();
|
|
50
|
+
await navigateTo(`/auth/login?redirect=${contentPath(props.content.author?.username ?? '', props.content.type, props.content.slug)}`);
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
53
|
const username = props.content.author?.username;
|
|
@@ -69,7 +70,7 @@ useJsonLd({
|
|
|
69
70
|
type: 'article',
|
|
70
71
|
title: props.content.title,
|
|
71
72
|
description: props.content.seoDescription ?? props.content.description ?? '',
|
|
72
|
-
url: `${config.public.siteUrl}/
|
|
73
|
+
url: `${config.public.siteUrl}/u/${props.content.author?.username}/${props.content.type}/${props.content.slug}`,
|
|
73
74
|
imageUrl: props.content.coverImageUrl ?? undefined,
|
|
74
75
|
authorName: props.content.author?.displayName ?? props.content.author?.username ?? '',
|
|
75
76
|
authorUrl: `${config.public.siteUrl}/u/${props.content.author?.username}`,
|
|
@@ -207,7 +208,7 @@ useJsonLd({
|
|
|
207
208
|
<NuxtLink
|
|
208
209
|
v-for="item in content.related.slice(0, 3)"
|
|
209
210
|
:key="item.id"
|
|
210
|
-
:to="`/${item.type}/${item.slug}`"
|
|
211
|
+
:to="(item as any).author?.username ? `/u/${(item as any).author.username}/${item.type}/${item.slug}` : `/${item.type}/${item.slug}`"
|
|
211
212
|
class="cpub-related-card"
|
|
212
213
|
>
|
|
213
214
|
<div class="cpub-related-card-thumb">
|
|
@@ -26,7 +26,7 @@ useJsonLd({
|
|
|
26
26
|
type: 'article',
|
|
27
27
|
title: props.content.title,
|
|
28
28
|
description: props.content.seoDescription ?? props.content.description ?? '',
|
|
29
|
-
url: `${config.public.siteUrl}/
|
|
29
|
+
url: `${config.public.siteUrl}/u/${props.content.author?.username}/${props.content.type}/${props.content.slug}`,
|
|
30
30
|
imageUrl: props.content.coverImageUrl ?? undefined,
|
|
31
31
|
authorName: props.content.author?.displayName ?? props.content.author?.username ?? '',
|
|
32
32
|
authorUrl: `${config.public.siteUrl}/u/${props.content.author?.username}`,
|
|
@@ -110,7 +110,7 @@ useJsonLd({
|
|
|
110
110
|
type: 'article',
|
|
111
111
|
title: props.content.title,
|
|
112
112
|
description: props.content.seoDescription ?? props.content.description ?? '',
|
|
113
|
-
url: `${runtimeConfig.public.siteUrl}/
|
|
113
|
+
url: `${runtimeConfig.public.siteUrl}/u/${props.content.author?.username}/${props.content.type}/${props.content.slug}`,
|
|
114
114
|
imageUrl: props.content.coverImageUrl ?? undefined,
|
|
115
115
|
authorName: props.content.author?.displayName ?? props.content.author?.username ?? '',
|
|
116
116
|
authorUrl: `${runtimeConfig.public.siteUrl}/u/${props.content.author?.username}`,
|
|
@@ -169,7 +169,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
169
169
|
<ScrollViewer :document="explainerDoc" />
|
|
170
170
|
<NuxtLink
|
|
171
171
|
v-if="isOwner"
|
|
172
|
-
:to="
|
|
172
|
+
:to="`/u/${content.author?.username}/${content.type}/${content.slug}/edit`"
|
|
173
173
|
class="cpub-scroll-edit-btn"
|
|
174
174
|
title="Edit explainer"
|
|
175
175
|
aria-label="Edit explainer"
|
|
@@ -212,7 +212,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
212
212
|
</button>
|
|
213
213
|
<NuxtLink
|
|
214
214
|
v-if="isOwner"
|
|
215
|
-
:to="
|
|
215
|
+
:to="`/u/${content.author?.username}/${content.type}/${content.slug}/edit`"
|
|
216
216
|
class="cpub-icon-btn cpub-edit-link"
|
|
217
217
|
title="Edit explainer"
|
|
218
218
|
aria-label="Edit explainer"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { ContentViewData } from '../../composables/useEngagement';
|
|
3
3
|
|
|
4
4
|
const { hubs: hubsEnabled } = useFeatures();
|
|
5
|
+
const { user: authUser } = useAuth();
|
|
5
6
|
|
|
6
7
|
const props = defineProps<{
|
|
7
8
|
content: ContentViewData;
|
|
@@ -49,7 +50,7 @@ useJsonLd({
|
|
|
49
50
|
type: 'howto',
|
|
50
51
|
title: props.content.title,
|
|
51
52
|
description: props.content.seoDescription ?? props.content.description ?? '',
|
|
52
|
-
url: `${config.public.siteUrl}/
|
|
53
|
+
url: `${config.public.siteUrl}/u/${props.content.author?.username}/${props.content.type}/${props.content.slug}`,
|
|
53
54
|
imageUrl: props.content.coverImageUrl ?? undefined,
|
|
54
55
|
authorName: props.content.author?.displayName ?? props.content.author?.username ?? '',
|
|
55
56
|
authorUrl: `${config.public.siteUrl}/u/${props.content.author?.username}`,
|
|
@@ -268,7 +269,7 @@ async function handleFork(): Promise<void> {
|
|
|
268
269
|
? `/api/federation/content/${props.federatedId}/fork`
|
|
269
270
|
: `/api/content/${props.content.id}/fork`;
|
|
270
271
|
const result = await $fetch<{ slug: string; type: string }>(url, { method: 'POST' });
|
|
271
|
-
await navigateTo(
|
|
272
|
+
await navigateTo(`/u/${authUser.value?.username ?? ''}/${result.type}/${result.slug}/edit`);
|
|
272
273
|
} catch {
|
|
273
274
|
// fork failed silently
|
|
274
275
|
} finally {
|
|
@@ -16,6 +16,8 @@ export interface ContentSaveOptions {
|
|
|
16
16
|
extractError: (err: unknown) => string;
|
|
17
17
|
/** Called after every save to sync product links from partsList blocks */
|
|
18
18
|
onAfterSave?: (id: string) => Promise<void>;
|
|
19
|
+
/** Author username for user-scoped URLs (/u/{username}/{type}/{slug}) */
|
|
20
|
+
username?: Ref<string>;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface ContentSaveReturn {
|
|
@@ -53,6 +55,16 @@ function slugify(text: string): string {
|
|
|
53
55
|
const AUTO_SAVE_DELAY = 30_000;
|
|
54
56
|
|
|
55
57
|
export function useContentSave(opts: ContentSaveOptions): ContentSaveReturn {
|
|
58
|
+
// URL helpers — use user-scoped paths when username is available
|
|
59
|
+
function viewPath(type: string, slug: string): string {
|
|
60
|
+
if (opts.username?.value) return `/u/${opts.username.value}/${type}/${slug}`;
|
|
61
|
+
return `/${type}/${slug}`;
|
|
62
|
+
}
|
|
63
|
+
function editPath(type: string, slug: string): string {
|
|
64
|
+
if (opts.username?.value) return `/u/${opts.username.value}/${type}/${slug}/edit`;
|
|
65
|
+
return `/${type}/${slug}/edit`;
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
const saving = ref(false);
|
|
57
69
|
const error = ref('');
|
|
58
70
|
const autoSaveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
@@ -108,14 +120,14 @@ export function useContentSave(opts: ContentSaveOptions): ContentSaveReturn {
|
|
|
108
120
|
opts.isDirty.value = false;
|
|
109
121
|
autoSaveStatus.value = 'saved';
|
|
110
122
|
if (opts.onAfterSave) await opts.onAfterSave(result.id);
|
|
111
|
-
history.replaceState({}, '',
|
|
123
|
+
history.replaceState({}, '', editPath(opts.contentType.value, result.slug));
|
|
112
124
|
} else {
|
|
113
125
|
const updated = await $fetch<{ slug: string }>(`/api/content/${opts.contentId.value}`, { method: 'PUT', body });
|
|
114
126
|
opts.isDirty.value = false;
|
|
115
127
|
autoSaveStatus.value = 'saved';
|
|
116
128
|
if (opts.onAfterSave) await opts.onAfterSave(opts.contentId.value!);
|
|
117
129
|
if (updated?.slug) {
|
|
118
|
-
history.replaceState({}, '',
|
|
130
|
+
history.replaceState({}, '', editPath(opts.contentType.value, updated.slug));
|
|
119
131
|
}
|
|
120
132
|
}
|
|
121
133
|
|
|
@@ -145,13 +157,13 @@ export function useContentSave(opts: ContentSaveOptions): ContentSaveReturn {
|
|
|
145
157
|
opts.isNew.value = false;
|
|
146
158
|
opts.isDirty.value = false;
|
|
147
159
|
if (opts.onAfterSave) await opts.onAfterSave(result.id);
|
|
148
|
-
await navigateTo(
|
|
160
|
+
await navigateTo(viewPath(opts.contentType.value, result.slug));
|
|
149
161
|
} else {
|
|
150
162
|
const updated = await $fetch<{ slug?: string }>(`/api/content/${opts.contentId.value}`, { method: 'PUT', body });
|
|
151
163
|
opts.isDirty.value = false;
|
|
152
164
|
if (opts.onAfterSave) await opts.onAfterSave(opts.contentId.value!);
|
|
153
165
|
const currentSlug = updated?.slug || useRoute().params.slug as string;
|
|
154
|
-
await navigateTo(
|
|
166
|
+
await navigateTo(viewPath(opts.contentType.value, currentSlug));
|
|
155
167
|
}
|
|
156
168
|
} catch (err: unknown) {
|
|
157
169
|
error.value = opts.extractError(err);
|
|
@@ -203,7 +215,7 @@ export function useContentSave(opts: ContentSaveOptions): ContentSaveReturn {
|
|
|
203
215
|
}
|
|
204
216
|
|
|
205
217
|
opts.isDirty.value = false;
|
|
206
|
-
await navigateTo(
|
|
218
|
+
await navigateTo(viewPath(opts.contentType.value, resultSlug));
|
|
207
219
|
return [];
|
|
208
220
|
} catch (err: unknown) {
|
|
209
221
|
error.value = opts.extractError(err);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Content URL composable — single source of truth for content path construction
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns helpers for constructing content URLs in the new `/u/{username}/{type}/{slug}` format.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { contentPath, contentEditPath, contentNewPath } = useContentUrl();
|
|
8
|
+
* const path = contentPath('alice', 'project', 'robot-arm'); // '/u/alice/project/robot-arm'
|
|
9
|
+
*/
|
|
10
|
+
export function useContentUrl() {
|
|
11
|
+
const config = useRuntimeConfig();
|
|
12
|
+
const siteUrl = computed(() => config.public.siteUrl as string);
|
|
13
|
+
|
|
14
|
+
/** Build relative path: /u/{username}/{type}/{slug} */
|
|
15
|
+
function contentPath(username: string, type: string, slug: string): string {
|
|
16
|
+
return `/u/${username}/${type}/${slug}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build relative edit path: /u/{username}/{type}/{slug}/edit */
|
|
20
|
+
function contentEditPath(username: string, type: string, slug: string): string {
|
|
21
|
+
return `/u/${username}/${type}/${slug}/edit`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build relative create path: /u/{username}/{type}/new/edit */
|
|
25
|
+
function contentNewPath(username: string, type: string): string {
|
|
26
|
+
return `/u/${username}/${type}/new/edit`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build absolute URL for SEO/feeds: https://domain/u/{username}/{type}/{slug} */
|
|
30
|
+
function contentUrl(username: string, type: string, slug: string): string {
|
|
31
|
+
return `${siteUrl.value}/u/${username}/${type}/${slug}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a content link from an item object (the common case in templates).
|
|
36
|
+
* Handles federated content by returning the mirror path instead.
|
|
37
|
+
*/
|
|
38
|
+
function contentLink(item: {
|
|
39
|
+
type: string;
|
|
40
|
+
slug: string;
|
|
41
|
+
author?: { username: string } | null;
|
|
42
|
+
source?: string;
|
|
43
|
+
federatedContentId?: string;
|
|
44
|
+
}): string {
|
|
45
|
+
if (item.source === 'federated' && item.federatedContentId) {
|
|
46
|
+
return `/mirror/${item.federatedContentId}`;
|
|
47
|
+
}
|
|
48
|
+
if (!item.author?.username) {
|
|
49
|
+
// Fallback for items missing author — should not happen in practice
|
|
50
|
+
return `/${item.type}/${item.slug}`;
|
|
51
|
+
}
|
|
52
|
+
return `/u/${item.author.username}/${item.type}/${item.slug}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
contentPath,
|
|
57
|
+
contentEditPath,
|
|
58
|
+
contentNewPath,
|
|
59
|
+
contentUrl,
|
|
60
|
+
contentLink,
|
|
61
|
+
};
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -54,12 +54,12 @@
|
|
|
54
54
|
"@commonpub/config": "0.8.0",
|
|
55
55
|
"@commonpub/docs": "0.6.0",
|
|
56
56
|
"@commonpub/editor": "0.5.0",
|
|
57
|
-
"@commonpub/explainer": "0.7.1",
|
|
58
57
|
"@commonpub/learning": "0.5.0",
|
|
59
|
-
"@commonpub/
|
|
60
|
-
"@commonpub/schema": "0.8.
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
58
|
+
"@commonpub/explainer": "0.7.1",
|
|
59
|
+
"@commonpub/schema": "0.8.18",
|
|
60
|
+
"@commonpub/server": "2.26.0",
|
|
61
|
+
"@commonpub/protocol": "0.9.7",
|
|
62
|
+
"@commonpub/ui": "0.8.4"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|