@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.
- 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 +8 -8
- 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
|
@@ -1,303 +1,34 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
13
|
+
const { data: content } = await useFetch<Serialized<ContentDetail>>(() => `/api/content/${slug.value}`, { headers: reqHeaders });
|
|
12
14
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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="
|
|
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>
|
package/pages/[type]/index.vue
CHANGED
|
@@ -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="
|
|
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>
|
package/pages/admin/content.vue
CHANGED
|
@@ -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="
|
|
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="
|
|
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="
|
|
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="
|
|
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 }">
|
package/pages/dashboard.vue
CHANGED
|
@@ -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="
|
|
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="
|
|
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="
|
|
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="
|
|
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}` :
|
|
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 =
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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>
|