@commonpub/layer 0.5.6 → 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 +8 -8
  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
@@ -47,12 +47,8 @@ const diffDots = computed(() => {
47
47
 
48
48
  const isFederated = computed(() => props.item.source === 'federated');
49
49
 
50
- const cardLink = computed(() => {
51
- if (isFederated.value && props.item.federatedContentId) {
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
- await navigateTo(`/auth/login?redirect=/article/${props.content.slug}`);
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}/article/${props.content.slug}`,
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}/blog/${props.content.slug}`,
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}/explainer/${props.content.slug}`,
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="`/${content.type}/${content.slug}/edit`"
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="`/${content.type}/${content.slug}/edit`"
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}/project/${props.content.slug}`,
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(`/${result.type}/${result.slug}/edit`);
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({}, '', `/${opts.contentType.value}/${result.slug}/edit`);
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({}, '', `/${opts.contentType.value}/${updated.slug}/edit`);
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(`/${opts.contentType.value}/${result.slug}`);
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(`/${opts.contentType.value}/${currentSlug}`);
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(`/${opts.contentType.value}/${resultSlug}`);
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.5.6",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -50,16 +50,16 @@
50
50
  "vue": "^3.4.0",
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
- "@commonpub/explainer": "0.7.0",
54
- "@commonpub/docs": "0.6.0",
55
- "@commonpub/config": "0.8.0",
56
53
  "@commonpub/auth": "0.5.0",
54
+ "@commonpub/config": "0.8.0",
55
+ "@commonpub/docs": "0.6.0",
57
56
  "@commonpub/editor": "0.5.0",
58
- "@commonpub/protocol": "0.9.6",
59
- "@commonpub/schema": "0.8.17",
60
57
  "@commonpub/learning": "0.5.0",
61
- "@commonpub/ui": "0.8.4",
62
- "@commonpub/server": "2.25.0"
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",