@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
@@ -1,303 +1,34 @@
1
1
  <script setup lang="ts">
2
- import type { BlockTuple } from '@commonpub/editor';
3
- import type { Serialized, ContentDetail, ContentListItem, PaginatedResponse } from '@commonpub/server';
2
+ /**
3
+ * Legacy content route redirects to /u/{username}/{type}/{slug}.
4
+ * Kept for backwards compatibility with existing links and bookmarks.
5
+ */
6
+ import type { Serialized, ContentDetail } from '@commonpub/server';
4
7
 
5
8
  const route = useRoute();
6
9
  const contentType = computed(() => route.params.type as string);
7
10
  const slug = computed(() => route.params.slug as string);
8
11
 
9
- // Pass cookies so SSR can resolve auth (needed to view own drafts)
10
12
  const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
11
- const { data: content, pending: contentPending } = useLazyFetch<Serialized<ContentDetail>>(() => `/api/content/${slug.value}`, { headers: reqHeaders });
13
+ const { data: content } = await useFetch<Serialized<ContentDetail>>(() => `/api/content/${slug.value}`, { headers: reqHeaders });
12
14
 
13
- // Explainer view uses its own topbar + full-height layout — disable the default layout
14
- // to avoid double-topbar and layout conflicts. Must also reset when navigating away
15
- // from an explainer (same page component is reused for all content types).
16
- watch(() => content.value?.type, (type) => {
17
- if (type === 'explainer') {
18
- setPageLayout('default');
19
- } else if (type) {
20
- setPageLayout('default');
21
- }
22
- }, { immediate: true });
23
-
24
- useSeoMeta({
25
- title: () => content.value?.title ? `${content.value.title} — ${useSiteName()}` : useSiteName(),
26
- description: () => content.value?.seoDescription || content.value?.description || '',
27
- ogImage: () => content.value?.coverImageUrl || '/og-default.png',
28
- ogTitle: () => content.value?.title || useSiteName(),
29
- ogDescription: () => content.value?.seoDescription || content.value?.description || '',
30
- });
31
-
32
- const { user } = useAuth();
33
- const isOwner = computed(() => user.value?.id === content.value?.author?.id);
34
-
35
- // Estimate reading time (~200 words per minute)
36
- const readTime = computed(() => {
37
- if (!content.value?.content) return undefined;
38
- const blocks = content.value.content as BlockTuple[];
39
- let words = 0;
40
- for (const [type, data] of blocks) {
41
- // Extract text from any string field in the block data
42
- for (const val of Object.values(data)) {
43
- if (typeof val === 'string' && val.trim()) {
44
- // Strip HTML tags before counting
45
- const text = val.replace(/<[^>]*>/g, '').trim();
46
- if (text) words += text.split(/\s+/).length;
47
- }
48
- }
49
- }
50
- const minutes = Math.max(1, Math.round(words / 200));
51
- return `${minutes} min read`;
52
- });
53
-
54
- // Enrich content with computed readTime
55
- const enrichedContent = computed(() => {
56
- if (!content.value) return null;
57
- return { ...content.value, readTime: readTime.value };
58
- });
59
-
60
- // Content type used for conditional view component rendering in template
61
-
62
- // Related content
63
- const { data: related } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
64
- query: { status: 'published', type: contentType.value, limit: 3, sort: 'recent' },
65
- });
66
-
67
- // Track view
68
- onMounted(() => {
69
- if (content.value?.id) {
70
- $fetch(`/api/content/${content.value.id}/view`, { method: 'POST' }).catch(() => {});
71
- }
72
- });
15
+ // Redirect to new user-scoped URL if we can resolve the author
16
+ if (content.value?.author?.username) {
17
+ await navigateTo(`/u/${content.value.author.username}/${content.value.type}/${content.value.slug}`, { redirectCode: 301, replace: true });
18
+ }
73
19
  </script>
74
20
 
75
21
  <template>
76
- <div v-if="contentPending" class="cpub-loading" aria-live="polite">Loading content...</div>
77
- <div v-else-if="enrichedContent">
78
- <!-- Edit button overlay -->
79
- <div v-if="isOwner && enrichedContent.type !== 'explainer'" class="cpub-view-edit-bar">
80
- <NuxtLink :to="`/${enrichedContent.type}/${enrichedContent.slug}/edit`" class="cpub-edit-btn" aria-label="Edit">
81
- <i class="fa-solid fa-pen"></i> Edit
82
- </NuxtLink>
83
- </div>
84
-
85
- <!-- Specialized view by content type -->
86
- <ViewsProjectView v-if="contentType === 'project'" :content="(enrichedContent as any)" />
87
- <ViewsArticleView v-else-if="contentType === 'article'" :content="(enrichedContent as any)" />
88
- <ViewsBlogView v-else-if="contentType === 'blog'" :content="(enrichedContent as any)" />
89
- <ViewsExplainerView v-else-if="contentType === 'explainer'" :content="(enrichedContent as any)" />
90
-
91
- <!-- Fallback: generic view for unknown types -->
92
- <article v-else class="cpub-view">
93
- <div class="cpub-cover" v-if="enrichedContent.coverImageUrl">
94
- <img :src="enrichedContent.coverImageUrl" :alt="enrichedContent.title" />
95
- </div>
96
- <div class="cpub-cover cpub-cover-placeholder" v-else>
97
- <span class="cpub-cover-label">{{ contentType }}</span>
98
- </div>
99
-
100
- <div class="cpub-view-container">
101
- <header class="cpub-view-header">
102
- <ContentTypeBadge :type="enrichedContent.type" />
103
- <h1 class="cpub-view-title">{{ enrichedContent.title }}</h1>
104
- <p class="cpub-view-subtitle" v-if="enrichedContent.subtitle || enrichedContent.description">
105
- {{ enrichedContent.subtitle || enrichedContent.description }}
106
- </p>
107
- <AuthorRow
108
- :author="enrichedContent.author"
109
- :date="enrichedContent.publishedAt || enrichedContent.createdAt"
110
- :read-time="readTime"
111
- />
112
- <EngagementBar
113
- :target-type="enrichedContent.type"
114
- :target-id="enrichedContent.id"
115
- :like-count="enrichedContent.likeCount ?? 0"
116
- :comment-count="enrichedContent.commentCount ?? 0"
117
- />
118
- </header>
119
-
120
- <div class="cpub-view-body">
121
- <template v-if="enrichedContent.content && Array.isArray(enrichedContent.content) && (enrichedContent.content as unknown[]).length > 0">
122
- <BlocksBlockContentRenderer :blocks="(enrichedContent.content as [string, Record<string, unknown>][])" />
123
- </template>
124
- <p v-else class="cpub-view-empty">No content body yet.</p>
125
- </div>
126
-
127
- <div class="cpub-view-tags" v-if="enrichedContent.tags?.length">
128
- <span class="cpub-view-tag" v-for="tag in enrichedContent.tags" :key="tag.id || tag.name">{{ tag.name || tag }}</span>
129
- </div>
130
-
131
- <AuthorCard :author="enrichedContent.author" />
132
- <CommentSection :target-type="enrichedContent.type" :target-id="enrichedContent.id" />
133
- </div>
134
- </article>
135
-
136
- <!-- Related content (shown for all types) -->
137
- <section class="cpub-view-related" v-if="related?.items?.length">
138
- <div class="cpub-view-related-inner">
139
- <h2 class="cpub-view-related-title">Related {{ contentType }}s</h2>
140
- <div class="cpub-view-related-grid">
141
- <ContentCard
142
- v-for="item in related.items.filter((i: Record<string, unknown>) => i.id !== enrichedContent?.id).slice(0, 3)"
143
- :key="item.id"
144
- :item="item"
145
- />
146
- </div>
147
- </div>
148
- </section>
149
- </div>
150
- <div v-else class="cpub-not-found">
22
+ <div v-if="!content" class="cpub-not-found">
151
23
  <h1>Content not found</h1>
152
24
  <p>The requested content could not be found.</p>
153
25
  </div>
154
26
  </template>
155
27
 
156
28
  <style scoped>
157
- .cpub-view-edit-bar {
158
- display: flex;
159
- justify-content: flex-end;
160
- padding: var(--space-2) var(--space-6);
161
- border-bottom: var(--border-width-default) solid var(--border2);
162
- }
163
-
164
- .cpub-edit-btn {
165
- padding: var(--space-1) var(--space-3);
166
- border: var(--border-width-default) solid var(--border);
167
- font-size: var(--text-xs);
168
- color: var(--text);
169
- text-decoration: none;
170
- }
171
-
172
- .cpub-edit-btn:hover {
173
- background: var(--surface2);
174
- }
175
-
176
- .cpub-view {
177
- max-width: 100%;
178
- }
179
-
180
- .cpub-cover {
181
- width: 100%;
182
- height: 300px;
183
- position: relative;
184
- overflow: hidden;
185
- border-bottom: var(--border-width-default) solid var(--border);
186
- }
187
-
188
- .cpub-cover img {
189
- width: 100%;
190
- height: 100%;
191
- object-fit: cover;
192
- }
193
-
194
- .cpub-cover-placeholder {
195
- background: var(--surface2);
196
- display: flex;
197
- align-items: center;
198
- justify-content: center;
199
- background-image:
200
- linear-gradient(var(--border2) 1px, transparent 1px),
201
- linear-gradient(90deg, var(--border2) 1px, transparent 1px);
202
- background-size: 40px 40px;
203
- }
204
-
205
- .cpub-cover-label {
206
- font-family: var(--font-mono);
207
- font-size: var(--text-sm);
208
- text-transform: uppercase;
209
- letter-spacing: var(--tracking-widest);
210
- color: var(--text-faint);
211
- background: var(--surface);
212
- padding: var(--space-2) var(--space-4);
213
- border: var(--border-width-default) solid var(--border);
214
- }
215
-
216
- .cpub-view-container {
217
- max-width: var(--content-max-width);
218
- margin: 0 auto;
219
- padding: var(--space-8) var(--space-6);
220
- }
221
-
222
- .cpub-view-header {
223
- margin-bottom: var(--space-6);
224
- }
225
-
226
- .cpub-view-title {
227
- font-size: var(--text-2xl);
228
- font-weight: var(--font-weight-bold);
229
- margin-top: var(--space-3);
230
- margin-bottom: var(--space-2);
231
- line-height: var(--leading-tight);
232
- }
233
-
234
- .cpub-view-subtitle {
235
- font-size: var(--text-md);
236
- color: var(--text-dim);
237
- margin-bottom: var(--space-4);
238
- line-height: var(--leading-relaxed);
239
- }
240
-
241
- .cpub-view-body {
242
- margin-bottom: var(--space-8);
243
- }
244
-
245
- .cpub-view-empty {
246
- color: var(--text-faint);
247
- font-style: italic;
248
- padding: var(--space-10) 0;
249
- text-align: center;
250
- }
251
-
252
- .cpub-view-tags {
253
- display: flex;
254
- gap: var(--space-2);
255
- flex-wrap: wrap;
256
- margin-bottom: var(--space-8);
257
- }
258
-
259
- .cpub-view-tag {
260
- padding: var(--space-1) var(--space-3);
261
- background: var(--surface2);
262
- font-family: var(--font-mono);
263
- font-size: 10px;
264
- font-weight: var(--font-weight-medium);
265
- color: var(--text-dim);
266
- text-transform: lowercase;
267
- }
268
-
269
- .cpub-view-related {
270
- border-top: var(--border-width-default) solid var(--border2);
271
- }
272
-
273
- .cpub-view-related-inner {
274
- max-width: var(--content-max-width);
275
- margin: 0 auto;
276
- padding: var(--space-8) var(--space-6);
277
- }
278
-
279
- .cpub-view-related-title {
280
- font-size: var(--text-lg);
281
- font-weight: var(--font-weight-bold);
282
- margin-bottom: var(--space-5);
283
- text-transform: capitalize;
284
- }
285
-
286
- .cpub-view-related-grid {
287
- display: grid;
288
- grid-template-columns: repeat(3, 1fr);
289
- gap: var(--space-4);
290
- }
291
-
292
29
  .cpub-not-found {
293
30
  text-align: center;
294
31
  padding: var(--space-16) 0;
295
32
  color: var(--text-dim);
296
33
  }
297
-
298
- @media (max-width: 768px) {
299
- .cpub-view-related-grid {
300
- grid-template-columns: 1fr;
301
- }
302
- }
303
34
  </style>
@@ -4,6 +4,7 @@ import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/
4
4
  const route = useRoute();
5
5
  const contentType = computed(() => route.params.type as string);
6
6
  const siteName = useSiteName();
7
+ const { user } = useAuth();
7
8
 
8
9
  useSeoMeta({
9
10
  title: () => `${contentType.value} — ${siteName}`,
@@ -31,7 +32,7 @@ const { data } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>(
31
32
  <select v-model="sortBy" class="cpub-listing-sort" aria-label="Sort by">
32
33
  <option v-for="opt in sortOptions" :key="opt" :value="opt">{{ opt }}</option>
33
34
  </select>
34
- <NuxtLink :to="`/${contentType}/new/edit`" class="cpub-listing-create">
35
+ <NuxtLink :to="user?.username ? `/u/${user.username}/${contentType}/new/edit` : `/auth/login?redirect=/${contentType}/new/edit`" class="cpub-listing-create">
35
36
  + New {{ contentType }}
36
37
  </NuxtLink>
37
38
  </div>
@@ -52,7 +52,7 @@ async function toggleFeatured(id: string, current: boolean): Promise<void> {
52
52
  <tbody>
53
53
  <tr v-for="item in data.items" :key="item.id">
54
54
  <td>
55
- <NuxtLink :to="`/${item.type}/${item.slug}`" class="cpub-admin-link">{{ item.title }}</NuxtLink>
55
+ <NuxtLink :to="`/u/${item.author?.username}/${item.type}/${item.slug}`" class="cpub-admin-link">{{ item.title }}</NuxtLink>
56
56
  </td>
57
57
  <td><ContentTypeBadge :type="item.type" /></td>
58
58
  <td class="cpub-admin-author">{{ item.author?.displayName || item.author?.username || 'Unknown' }}</td>
@@ -289,7 +289,7 @@ async function submitEntry(): Promise<void> {
289
289
  <span v-if="entry.rank" class="cpub-entry-rank" :class="`cpub-rank-${entry.rank}`">#{{ entry.rank }}</span>
290
290
  </div>
291
291
  <div class="cpub-entry-body">
292
- <NuxtLink :to="`/${entry.contentType}/${entry.contentSlug}`" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
292
+ <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
293
293
  <div class="cpub-entry-author">
294
294
  <div class="cpub-entry-av">
295
295
  <img v-if="entry.authorAvatarUrl" :src="entry.authorAvatarUrl" :alt="entry.authorName || entry.authorUsername" class="cpub-entry-av-img" />
@@ -20,6 +20,7 @@ const entryList = computed(() => {
20
20
  contentType: entry.contentType,
21
21
  contentTitle: entry.contentTitle,
22
22
  authorName: entry.authorName,
23
+ authorUsername: entry.authorUsername,
23
24
  score: entry.score ?? null,
24
25
  rank: entry.rank ?? null,
25
26
  }));
@@ -82,7 +83,7 @@ async function submitScore(entryId: string): Promise<void> {
82
83
  <div class="cpub-judge-entry-info">
83
84
  <div class="cpub-judge-entry-title">{{ entry.contentTitle }}</div>
84
85
  <div class="cpub-judge-entry-author">by {{ entry.authorName }}</div>
85
- <NuxtLink :to="`/${entry.contentType}/${entry.contentSlug}`" class="cpub-judge-entry-link" target="_blank">
86
+ <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-judge-entry-link" target="_blank">
86
87
  <i class="fa-solid fa-arrow-up-right-from-square"></i> View entry
87
88
  </NuxtLink>
88
89
  </div>
package/pages/create.vue CHANGED
@@ -5,6 +5,7 @@ useSeoMeta({ title: `Create — ${useSiteName()}` });
5
5
  definePageMeta({ middleware: 'auth' });
6
6
 
7
7
  const { isTypeEnabled } = useContentTypes();
8
+ const { user } = useAuth();
8
9
  const route = useRoute();
9
10
  const hubParam = computed(() => (route.query.hub as string) || '');
10
11
 
@@ -63,7 +64,7 @@ const types = computed(() => allTypes.filter(t => isTypeEnabled(t.type as Conten
63
64
  <NuxtLink
64
65
  v-for="t in types"
65
66
  :key="t.type"
66
- :to="`/${t.type}/new/edit${hubParam ? `?hub=${hubParam}` : ''}`"
67
+ :to="`/u/${user?.username}/${t.type}/new/edit${hubParam ? `?hub=${hubParam}` : ''}`"
67
68
  class="cpub-create-card"
68
69
  >
69
70
  <div class="cpub-create-card-icon" :style="{ color: t.color, background: t.bg, borderColor: t.border }">
@@ -202,7 +202,7 @@ async function deleteItem(id: string, title: string): Promise<void> {
202
202
  <h2 class="cpub-dash-section-title">Drafts</h2>
203
203
  <div class="cpub-dash-list">
204
204
  <div v-for="item in drafts" :key="item.id" class="cpub-dash-row">
205
- <NuxtLink :to="`/${item.type}/${item.slug}/edit`" class="cpub-dash-row-title">
205
+ <NuxtLink :to="`/u/${user?.username}/${item.type}/${item.slug}/edit`" class="cpub-dash-row-title">
206
206
  {{ item.title }}
207
207
  </NuxtLink>
208
208
  <span class="cpub-dash-row-meta">
@@ -210,7 +210,7 @@ async function deleteItem(id: string, title: string): Promise<void> {
210
210
  <time>{{ new Date(item.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}</time>
211
211
  </span>
212
212
  <div class="cpub-dash-row-actions">
213
- <NuxtLink :to="`/${item.type}/${item.slug}/edit`" class="cpub-dash-action-btn" aria-label="Edit" title="Edit">
213
+ <NuxtLink :to="`/u/${user?.username}/${item.type}/${item.slug}/edit`" class="cpub-dash-action-btn" aria-label="Edit" title="Edit">
214
214
  <i class="fa-solid fa-pen"></i> Edit
215
215
  </NuxtLink>
216
216
  <button class="cpub-dash-action-btn cpub-dash-action-btn--danger" aria-label="Delete" title="Delete" :disabled="actionLoading === item.id" @click="deleteItem(item.id, item.title)">
@@ -226,7 +226,7 @@ async function deleteItem(id: string, title: string): Promise<void> {
226
226
  <h2 class="cpub-dash-section-title">Published</h2>
227
227
  <div class="cpub-dash-list">
228
228
  <div v-for="item in published" :key="item.id" class="cpub-dash-row">
229
- <NuxtLink :to="`/${item.type}/${item.slug}`" class="cpub-dash-row-title">
229
+ <NuxtLink :to="`/u/${user?.username}/${item.type}/${item.slug}`" class="cpub-dash-row-title">
230
230
  {{ item.title }}
231
231
  </NuxtLink>
232
232
  <span class="cpub-dash-row-meta">
@@ -235,7 +235,7 @@ async function deleteItem(id: string, title: string): Promise<void> {
235
235
  <span><i class="fa-regular fa-heart"></i> {{ item.likeCount ?? 0 }}</span>
236
236
  </span>
237
237
  <div class="cpub-dash-row-actions">
238
- <NuxtLink :to="`/${item.type}/${item.slug}/edit`" class="cpub-dash-action-btn" aria-label="Edit" title="Edit">
238
+ <NuxtLink :to="`/u/${user?.username}/${item.type}/${item.slug}/edit`" class="cpub-dash-action-btn" aria-label="Edit" title="Edit">
239
239
  <i class="fa-solid fa-pen"></i> Edit
240
240
  </NuxtLink>
241
241
  <button class="cpub-dash-action-btn cpub-dash-action-btn--warn" aria-label="Unpublish" title="Unpublish" :disabled="actionLoading === item.id" @click="unpublishItem(item.id)">
@@ -256,7 +256,7 @@ async function deleteItem(id: string, title: string): Promise<void> {
256
256
  <div class="cpub-dash-list">
257
257
  <div v-for="bm in bookmarkData?.items ?? []" :key="bm.id" class="cpub-dash-row">
258
258
  <template v-if="bm.content">
259
- <NuxtLink :to="bm.isFederated ? `/mirror/${bm.targetId}` : `/${bm.content.type}/${bm.content.slug}`" class="cpub-dash-row-title">
259
+ <NuxtLink :to="bm.isFederated ? `/mirror/${bm.targetId}` : `/u/${bm.content.author?.username}/${bm.content.type}/${bm.content.slug}`" class="cpub-dash-row-title">
260
260
  {{ bm.content.title }}
261
261
  </NuxtLink>
262
262
  <span class="cpub-dash-row-meta">
@@ -207,7 +207,7 @@ const mirrorStatus = computed(() => hub.value?.followStatus ?? 'pending');
207
207
 
208
208
  const remoteFollowRef = ref<{ show: () => void } | null>(null);
209
209
  const hubFollowing = ref(false);
210
- const hubFollowStatus = ref('');
210
+ const hubFollowStatus = computed(() => hub.value?.followStatus ?? '');
211
211
 
212
212
  /** Follow the hub — if logged in, call API directly; otherwise show the remote follow modal */
213
213
  async function handleJoinHub(): Promise<void> {
@@ -219,8 +219,7 @@ async function handleJoinHub(): Promise<void> {
219
219
  method: 'POST',
220
220
  body: { federatedHubId: hub.value.id },
221
221
  });
222
- hubFollowStatus.value = result.status;
223
- toast.success(result.status === 'accepted' ? 'Now following this hub' : 'Follow request sent');
222
+ toast.success(result.status === 'accepted' ? 'Now following this hub' : 'Follow request sent — it may take a moment to be accepted');
224
223
  await refreshHub();
225
224
  } catch (err: unknown) {
226
225
  const msg = err instanceof Error ? err.message : 'Failed to follow hub';
@@ -299,9 +298,12 @@ async function handlePostVote(postId: string): Promise<void> {
299
298
  <span v-if="mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-mirrored">
300
299
  <i class="fa-solid fa-globe"></i> Mirrored
301
300
  </span>
302
- <button v-if="hub?.actorUri" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="hubFollowing" @click="handleJoinHub">
303
- <i class="fa-solid fa-user-plus"></i> {{ hubFollowing ? 'Following...' : 'Join from your instance' }}
301
+ <button v-if="hub?.actorUri && mirrorStatus !== 'accepted'" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="hubFollowing || hubFollowStatus === 'pending'" @click="handleJoinHub">
302
+ <i class="fa-solid fa-user-plus"></i> {{ hubFollowing ? 'Following...' : hubFollowStatus === 'pending' ? 'Follow pending...' : 'Join from your instance' }}
304
303
  </button>
304
+ <span v-else-if="hub?.actorUri && mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-joined">
305
+ <i class="fa-solid fa-check"></i> Joined
306
+ </span>
305
307
  <a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm">
306
308
  <i class="fa-solid fa-arrow-up-right-from-square"></i> Visit original
307
309
  </a>
@@ -72,6 +72,7 @@ const postsVM = computed<HubPostViewModel[]>(() => {
72
72
  title: (p.sharedContent as Record<string, unknown>).title as string,
73
73
  description: ((p.sharedContent as Record<string, unknown>).description as string | null) ?? null,
74
74
  coverImageUrl: ((p.sharedContent as Record<string, unknown>).coverImageUrl as string | null) ?? null,
75
+ authorUsername: ((p.sharedContent as Record<string, unknown>).authorUsername as string | undefined) ?? undefined,
75
76
  },
76
77
  } : {}),
77
78
  }));
package/pages/index.vue CHANGED
@@ -227,7 +227,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
227
227
  </div>
228
228
  <div class="cpub-featured-body">
229
229
  <h2 class="cpub-featured-title">
230
- <NuxtLink :to="`/${featured.items[0].type}/${featured.items[0].slug}`">
230
+ <NuxtLink :to="`/u/${featured.items[0].author?.username}/${featured.items[0].type}/${featured.items[0].slug}`">
231
231
  {{ featured.items[0].title }}
232
232
  </NuxtLink>
233
233
  </h2>
@@ -222,7 +222,7 @@ const videoEmbedUrl = computed(() => {
222
222
  <span class="linked-content-title">{{ lesson.title }}</span>
223
223
  </div>
224
224
  <div class="linked-content-actions">
225
- <NuxtLink :to="`/${lesson.type}/${lessonData?.linkedContent?.slug || ''}/edit`" class="cpub-btn cpub-btn-sm">
225
+ <NuxtLink :to="`/u/${(lessonData?.linkedContent as any)?.author?.username ?? ''}/${lesson.type}/${lessonData?.linkedContent?.slug || ''}/edit`" class="cpub-btn cpub-btn-sm">
226
226
  <i class="fa-solid fa-pen"></i> Edit Content
227
227
  </NuxtLink>
228
228
  <button class="cpub-btn cpub-btn-sm" style="color: var(--red); border-color: var(--red-border);" @click="unlinkContent">
@@ -226,7 +226,7 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
226
226
 
227
227
  <!-- View original link for linked content -->
228
228
  <div v-if="lessonData?.linkedContent" class="lesson-linked-source">
229
- <NuxtLink :to="`/${lessonData.linkedContent.type}/${lessonData.linkedContent.slug}`" class="lesson-linked-source-link">
229
+ <NuxtLink :to="`/u/${(lessonData.linkedContent as any).author?.username ?? ''}/${lessonData.linkedContent.type}/${lessonData.linkedContent.slug}`" class="lesson-linked-source-link">
230
230
  <i class="fa-solid fa-arrow-up-right-from-square"></i> View original {{ lessonData.linkedContent.type }}
231
231
  </NuxtLink>
232
232
  </div>