@commonpub/layer 0.7.27 → 0.8.1
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/ContentTypeBadge.vue +1 -2
- package/components/FederatedContentCard.vue +1 -1
- package/components/editors/ArticleEditor.vue +68 -16
- package/components/views/ArticleView.vue +190 -16
- package/composables/useContentTypes.ts +4 -5
- package/composables/useMirrorContent.ts +1 -1
- package/layouts/default.vue +0 -2
- package/nuxt.config.ts +1 -1
- package/package.json +7 -7
- package/pages/create.vue +7 -16
- package/pages/explore.vue +1 -1
- package/pages/index.vue +2 -2
- package/pages/learn/[slug]/edit.vue +1 -1
- package/pages/mirror/[id].vue +1 -2
- package/pages/privacy.vue +2 -2
- package/pages/u/[username]/[type]/[slug]/edit.vue +2 -2
- package/pages/u/[username]/[type]/[slug]/index.vue +1 -2
- package/pages/u/[username]/index.vue +2 -2
- package/server/api/hubs/[slug]/share.post.ts +1 -1
- package/server/middleware/blog-redirect.ts +26 -0
- package/server/middleware/content-ap.ts +10 -4
- package/server/plugins/migrate-article-to-blog.ts +67 -0
- package/server/routes/hubs/[slug]/posts/[postId].ts +1 -1
|
@@ -5,9 +5,8 @@ defineProps<{
|
|
|
5
5
|
|
|
6
6
|
const iconMap: Record<string, string> = {
|
|
7
7
|
project: 'fa-solid fa-microchip',
|
|
8
|
-
article: 'fa-solid fa-file-lines',
|
|
9
|
-
guide: 'fa-solid fa-book',
|
|
10
8
|
blog: 'fa-solid fa-pen-nib',
|
|
9
|
+
article: 'fa-solid fa-file-lines',
|
|
11
10
|
explainer: 'fa-solid fa-lightbulb',
|
|
12
11
|
};
|
|
13
12
|
</script>
|
|
@@ -28,7 +28,7 @@ const actorName = computed(() =>
|
|
|
28
28
|
|
|
29
29
|
const typeLabel = computed(() => {
|
|
30
30
|
if (props.content.cpubType) return props.content.cpubType;
|
|
31
|
-
return props.content.apType === 'Note' ? 'post' : '
|
|
31
|
+
return props.content.apType === 'Note' ? 'post' : 'blog';
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
const timeAgo = computed(() => {
|
|
@@ -154,6 +154,9 @@ const seoDomain = computed(() => {
|
|
|
154
154
|
});
|
|
155
155
|
const seoPreviewDesc = computed(() => (props.metadata.seoDescription as string) || (props.metadata.description as string) || '');
|
|
156
156
|
|
|
157
|
+
// --- Schedule ---
|
|
158
|
+
const scheduleEnabled = ref(false);
|
|
159
|
+
|
|
157
160
|
// --- Right panel ---
|
|
158
161
|
const openSections = ref<Record<string, boolean>>({
|
|
159
162
|
content: true, seo: false, publishing: true, cover: false, banner: false,
|
|
@@ -174,7 +177,8 @@ const wordCount = computed(() => {
|
|
|
174
177
|
const html = (block.content.html as string) || '';
|
|
175
178
|
const text = (block.content.text as string) || '';
|
|
176
179
|
const code = (block.content.code as string) || '';
|
|
177
|
-
|
|
180
|
+
const instructions = (block.content.instructions as string) || '';
|
|
181
|
+
let combined = html.replace(/<[^>]*>/g, ' ') + ' ' + text + ' ' + code + ' ' + instructions;
|
|
178
182
|
const children = block.content.children;
|
|
179
183
|
if (Array.isArray(children)) {
|
|
180
184
|
for (const child of children) {
|
|
@@ -345,9 +349,29 @@ const canvasMaxWidth = computed(() => {
|
|
|
345
349
|
<label class="cpub-ep-flabel">Slug</label>
|
|
346
350
|
<input class="cpub-ep-input" type="text" :value="metadata.slug" placeholder="auto-generated" @input="updateMeta('slug', ($event.target as HTMLInputElement).value)">
|
|
347
351
|
</div>
|
|
352
|
+
<div class="cpub-ep-field">
|
|
353
|
+
<label class="cpub-ep-flabel">Subtitle <span class="cpub-ep-optional">(optional)</span></label>
|
|
354
|
+
<input class="cpub-ep-input" type="text" :value="metadata.subtitle as string" placeholder="Add a subtitle..." @input="updateMeta('subtitle', ($event.target as HTMLInputElement).value)">
|
|
355
|
+
</div>
|
|
348
356
|
<div class="cpub-ep-field">
|
|
349
357
|
<label class="cpub-ep-flabel">Description</label>
|
|
350
|
-
<textarea class="cpub-ep-textarea" rows="3" :value="metadata.description as string" placeholder="Brief description..." @input="updateMeta('description', ($event.target as HTMLTextAreaElement).value)" />
|
|
358
|
+
<textarea class="cpub-ep-textarea" rows="3" :value="metadata.description as string" placeholder="Brief description shown in feed previews..." @input="updateMeta('description', ($event.target as HTMLTextAreaElement).value)" />
|
|
359
|
+
<span class="cpub-ep-hint cpub-ep-hint-right">{{ ((metadata.description as string) || '').length }} / 300</span>
|
|
360
|
+
</div>
|
|
361
|
+
<div class="cpub-ep-field">
|
|
362
|
+
<label class="cpub-ep-flabel">Category</label>
|
|
363
|
+
<select class="cpub-ep-select" :value="metadata.category || ''" @change="updateMeta('category', ($event.target as HTMLSelectElement).value)">
|
|
364
|
+
<option value="">Select category</option>
|
|
365
|
+
<option value="article">Article</option>
|
|
366
|
+
<option value="blog">Blog Post</option>
|
|
367
|
+
<option value="tutorial">Tutorial</option>
|
|
368
|
+
<option value="deep-dive">Deep Dive</option>
|
|
369
|
+
<option value="opinion">Opinion</option>
|
|
370
|
+
<option value="hardware">Hardware & Makers</option>
|
|
371
|
+
<option value="software">Software</option>
|
|
372
|
+
<option value="ai-ml">AI & Machine Learning</option>
|
|
373
|
+
<option value="homelab">Home Lab</option>
|
|
374
|
+
</select>
|
|
351
375
|
</div>
|
|
352
376
|
<div class="cpub-ae-cover" :class="{ 'has-image': !!coverImageUrl }">
|
|
353
377
|
<template v-if="coverImageUrl">
|
|
@@ -402,9 +426,9 @@ const canvasMaxWidth = computed(() => {
|
|
|
402
426
|
<div class="cpub-seo-card">
|
|
403
427
|
<div class="cpub-seo-url">
|
|
404
428
|
<span class="cpub-seo-favicon">C</span>
|
|
405
|
-
{{ seoDomain }} ›
|
|
429
|
+
{{ seoDomain }} › blog
|
|
406
430
|
</div>
|
|
407
|
-
<div class="cpub-seo-title">{{ (metadata.title as string) || '
|
|
431
|
+
<div class="cpub-seo-title">{{ (metadata.title as string) || 'Post title' }}</div>
|
|
408
432
|
<div class="cpub-seo-desc">{{ seoPreviewDesc || 'Post description will appear here...' }}</div>
|
|
409
433
|
</div>
|
|
410
434
|
<div class="cpub-ep-field" style="margin-top: 10px;">
|
|
@@ -420,22 +444,27 @@ const canvasMaxWidth = computed(() => {
|
|
|
420
444
|
<label class="cpub-ep-flabel">Visibility</label>
|
|
421
445
|
<EditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
|
|
422
446
|
</div>
|
|
423
|
-
<div class="cpub-ep-field">
|
|
424
|
-
<label class="cpub-ep-flabel">Category</label>
|
|
425
|
-
<select class="cpub-ep-select" :value="metadata.category || ''" @change="updateMeta('category', ($event.target as HTMLSelectElement).value)">
|
|
426
|
-
<option value="">Select category</option>
|
|
427
|
-
<option value="technology">Technology</option>
|
|
428
|
-
<option value="hardware">Hardware</option>
|
|
429
|
-
<option value="ai-ml">AI & Machine Learning</option>
|
|
430
|
-
<option value="tutorial">Tutorial</option>
|
|
431
|
-
<option value="deep-dive">Deep Dive</option>
|
|
432
|
-
<option value="opinion">Opinion</option>
|
|
433
|
-
</select>
|
|
434
|
-
</div>
|
|
435
447
|
<div class="cpub-ep-field">
|
|
436
448
|
<label class="cpub-ep-flabel">Tags</label>
|
|
437
449
|
<EditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
|
|
438
450
|
</div>
|
|
451
|
+
<div class="cpub-ep-field">
|
|
452
|
+
<label class="cpub-ep-flabel">Series <span class="cpub-ep-optional">(optional)</span></label>
|
|
453
|
+
<input class="cpub-ep-input" type="text" :value="metadata.series as string" placeholder="e.g. Home Lab Chronicles" @input="updateMeta('series', ($event.target as HTMLInputElement).value)">
|
|
454
|
+
</div>
|
|
455
|
+
<div class="cpub-ep-field">
|
|
456
|
+
<label class="cpub-ae-schedule-row">
|
|
457
|
+
<span class="cpub-ae-toggle-switch">
|
|
458
|
+
<input v-model="scheduleEnabled" type="checkbox" />
|
|
459
|
+
<span class="cpub-ae-toggle-track" />
|
|
460
|
+
</span>
|
|
461
|
+
<span class="cpub-ae-toggle-label">Schedule for later</span>
|
|
462
|
+
</label>
|
|
463
|
+
</div>
|
|
464
|
+
<div v-if="scheduleEnabled" class="cpub-ep-field">
|
|
465
|
+
<label class="cpub-ep-flabel">Publish Date</label>
|
|
466
|
+
<input class="cpub-ep-input cpub-ep-input-mono" type="datetime-local" :value="metadata.scheduledAt as string" @input="updateMeta('scheduledAt', ($event.target as HTMLInputElement).value)">
|
|
467
|
+
</div>
|
|
439
468
|
</EditorSection>
|
|
440
469
|
</div>
|
|
441
470
|
</aside>
|
|
@@ -638,4 +667,27 @@ const canvasMaxWidth = computed(() => {
|
|
|
638
667
|
.cpub-ae-banner-preview { position: relative; margin-bottom: 8px; }
|
|
639
668
|
.cpub-ae-banner-img { width: 100%; height: 80px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); }
|
|
640
669
|
.cpub-ae-banner-actions { display: flex; gap: 6px; margin-top: 6px; }
|
|
670
|
+
|
|
671
|
+
/* Schedule toggle */
|
|
672
|
+
.cpub-ae-schedule-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
|
673
|
+
.cpub-ae-toggle-switch {
|
|
674
|
+
position: relative; width: 30px; height: 16px; flex-shrink: 0;
|
|
675
|
+
}
|
|
676
|
+
.cpub-ae-toggle-switch input { display: none; }
|
|
677
|
+
.cpub-ae-toggle-track {
|
|
678
|
+
display: block; width: 100%; height: 100%; background: var(--surface3);
|
|
679
|
+
border: var(--border-width-default) solid var(--border); cursor: pointer; transition: background 0.15s; position: relative;
|
|
680
|
+
}
|
|
681
|
+
.cpub-ae-toggle-track::after {
|
|
682
|
+
content: ''; position: absolute; width: 8px; height: 8px;
|
|
683
|
+
background: var(--text-faint); top: 2px; left: 2px; transition: transform 0.15s, background 0.15s;
|
|
684
|
+
}
|
|
685
|
+
.cpub-ae-toggle-switch input:checked + .cpub-ae-toggle-track { background: var(--accent-bg); border-color: var(--accent); }
|
|
686
|
+
.cpub-ae-toggle-switch input:checked + .cpub-ae-toggle-track::after { transform: translateX(14px); background: var(--accent); }
|
|
687
|
+
.cpub-ae-toggle-label { font-size: 11px; color: var(--text-dim); }
|
|
688
|
+
|
|
689
|
+
/* Optional hint / mono input */
|
|
690
|
+
.cpub-ep-optional { font-size: 9px; font-weight: 400; color: var(--text-faint); }
|
|
691
|
+
.cpub-ep-input-mono { font-family: var(--font-mono); font-size: 11px; }
|
|
692
|
+
.cpub-ep-hint-right { text-align: right; display: block; }
|
|
641
693
|
</style>
|
|
@@ -7,7 +7,7 @@ const props = defineProps<{
|
|
|
7
7
|
}>();
|
|
8
8
|
|
|
9
9
|
const contentId = computed(() => props.content?.id);
|
|
10
|
-
const contentType = computed(() => props.content?.type ?? '
|
|
10
|
+
const contentType = computed(() => props.content?.type ?? 'blog');
|
|
11
11
|
const fedId = computed(() => props.federatedId);
|
|
12
12
|
const { liked, bookmarked, likeCount, isFederated, toggleLike, toggleBookmark, share, fetchInitialState } = useEngagement({ contentId, contentType, federatedContentId: fedId });
|
|
13
13
|
|
|
@@ -30,11 +30,14 @@ const tocHeadings = computed(() => {
|
|
|
30
30
|
const block = b as [string, Record<string, unknown>];
|
|
31
31
|
return block[0] === 'heading';
|
|
32
32
|
})
|
|
33
|
-
.map((b: unknown
|
|
33
|
+
.map((b: unknown) => {
|
|
34
34
|
const block = b as [string, Record<string, unknown>];
|
|
35
|
+
const text = (block[1].text as string) || 'Untitled';
|
|
36
|
+
// Must match BlockHeadingView's slug generation
|
|
37
|
+
const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
35
38
|
return {
|
|
36
|
-
id:
|
|
37
|
-
text
|
|
39
|
+
id: slug,
|
|
40
|
+
text,
|
|
38
41
|
level: (block[1].level as number) || 2,
|
|
39
42
|
};
|
|
40
43
|
});
|
|
@@ -65,6 +68,12 @@ async function handleFollowAuthor(): Promise<void> {
|
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
// Series data — only available when content has series metadata
|
|
72
|
+
const seriesPart = computed(() => props.content?.seriesPart as number | undefined);
|
|
73
|
+
const seriesTitle = computed(() => props.content?.seriesTitle as string | undefined);
|
|
74
|
+
const seriesTotalParts = computed(() => (props.content?.seriesTotalParts as number) || 0);
|
|
75
|
+
const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value > 0);
|
|
76
|
+
|
|
68
77
|
const config = useRuntimeConfig();
|
|
69
78
|
useJsonLd({
|
|
70
79
|
type: 'article',
|
|
@@ -97,7 +106,7 @@ useJsonLd({
|
|
|
97
106
|
<div class="cpub-article-wrap">
|
|
98
107
|
|
|
99
108
|
<!-- TYPE BADGE -->
|
|
100
|
-
<div class="cpub-content-type-badge"><i class="fa-solid fa-
|
|
109
|
+
<div class="cpub-content-type-badge"><i class="fa-solid fa-pen-nib"></i> {{ content.category || 'Blog Post' }}</div>
|
|
101
110
|
|
|
102
111
|
<!-- TITLE -->
|
|
103
112
|
<h1 class="cpub-article-title">{{ content.title }}</h1>
|
|
@@ -122,6 +131,10 @@ useJsonLd({
|
|
|
122
131
|
<span>{{ new Date(content.publishedAt || content.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}</span>
|
|
123
132
|
<span class="cpub-sep">·</span>
|
|
124
133
|
<span><i class="fa-regular fa-clock"></i> {{ content.readTime || '5 min read' }}</span>
|
|
134
|
+
<template v-if="hasSeries">
|
|
135
|
+
<span class="cpub-sep">·</span>
|
|
136
|
+
<span class="cpub-tag cpub-tag-accent">{{ seriesTitle }} · Part {{ seriesPart || 1 }} of {{ seriesTotalParts }}</span>
|
|
137
|
+
</template>
|
|
125
138
|
<template v-if="content.tags?.length">
|
|
126
139
|
<span class="cpub-sep">·</span>
|
|
127
140
|
<NuxtLink :to="`/tags/${content.tags[0]?.slug || (content.tags[0]?.name || String(content.tags[0])).toLowerCase().replace(/\s+/g, '-')}`" class="cpub-tag cpub-tag-teal">{{ content.tags[0]?.name || content.tags[0] }}</NuxtLink>
|
|
@@ -167,6 +180,49 @@ useJsonLd({
|
|
|
167
180
|
</template>
|
|
168
181
|
</div>
|
|
169
182
|
|
|
183
|
+
<!-- SERIES NAVIGATION -->
|
|
184
|
+
<div v-if="hasSeries" class="cpub-series-nav">
|
|
185
|
+
<div class="cpub-series-header">
|
|
186
|
+
<div class="cpub-series-icon"><i class="fa-solid fa-layer-group"></i></div>
|
|
187
|
+
<div>
|
|
188
|
+
<div class="cpub-series-label">Series</div>
|
|
189
|
+
<div class="cpub-series-title">{{ seriesTitle }}</div>
|
|
190
|
+
</div>
|
|
191
|
+
<div style="margin-left:auto;">
|
|
192
|
+
<span class="cpub-tag cpub-tag-accent">Part {{ seriesPart || 1 }} of {{ seriesTotalParts }}</span>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="cpub-series-progress">
|
|
196
|
+
<div class="cpub-series-progress-label">
|
|
197
|
+
<span>Progress</span>
|
|
198
|
+
<span>{{ seriesPart || 1 }} / {{ seriesTotalParts }} published</span>
|
|
199
|
+
</div>
|
|
200
|
+
<div class="cpub-series-progress-track">
|
|
201
|
+
<div class="cpub-series-progress-fill" :style="{ width: ((seriesPart || 1) / seriesTotalParts * 100) + '%' }"></div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="cpub-series-nav-btns">
|
|
205
|
+
<NuxtLink v-if="content.seriesPrev" :to="content.seriesPrev.url || '#'" class="cpub-series-nav-btn cpub-prev">
|
|
206
|
+
<div class="cpub-series-nav-dir"><i class="fa-solid fa-chevron-left"></i> Previous</div>
|
|
207
|
+
<div class="cpub-series-nav-ep">Part {{ (seriesPart || 2) - 1 }}</div>
|
|
208
|
+
<div class="cpub-series-nav-post-title">{{ content.seriesPrev.title }}</div>
|
|
209
|
+
</NuxtLink>
|
|
210
|
+
<div v-else class="cpub-series-nav-btn cpub-prev cpub-disabled">
|
|
211
|
+
<div class="cpub-series-nav-dir"><i class="fa-solid fa-chevron-left"></i> Previous</div>
|
|
212
|
+
<div class="cpub-series-nav-ep">—</div>
|
|
213
|
+
</div>
|
|
214
|
+
<NuxtLink v-if="content.seriesNext" :to="content.seriesNext.url || '#'" class="cpub-series-nav-btn cpub-next">
|
|
215
|
+
<div class="cpub-series-nav-dir">Next <i class="fa-solid fa-chevron-right"></i></div>
|
|
216
|
+
<div class="cpub-series-nav-ep">Part {{ (seriesPart || 1) + 1 }}</div>
|
|
217
|
+
<div class="cpub-series-nav-post-title">{{ content.seriesNext.title }}</div>
|
|
218
|
+
</NuxtLink>
|
|
219
|
+
<div v-else class="cpub-series-nav-btn cpub-next cpub-disabled">
|
|
220
|
+
<div class="cpub-series-nav-dir">Next <i class="fa-solid fa-chevron-right"></i></div>
|
|
221
|
+
<div class="cpub-series-nav-ep">Coming soon</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
170
226
|
<!-- TAGS -->
|
|
171
227
|
<div v-if="content.tags?.length" class="cpub-tags-row">
|
|
172
228
|
<div class="cpub-tags-label">Filed under</div>
|
|
@@ -183,7 +239,8 @@ useJsonLd({
|
|
|
183
239
|
|
|
184
240
|
<!-- AUTHOR CARD -->
|
|
185
241
|
<div v-if="content.author" class="cpub-author-card">
|
|
186
|
-
<
|
|
242
|
+
<img v-if="content.author.avatarUrl" :src="content.author.avatarUrl" :alt="content.author.displayName ?? content.author.username ?? ''" class="cpub-av cpub-av-xl" style="object-fit:cover;border:2px solid var(--border);" />
|
|
243
|
+
<div v-else class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
|
|
187
244
|
<div class="cpub-author-card-info">
|
|
188
245
|
<div class="cpub-author-card-label">Written by</div>
|
|
189
246
|
<div class="cpub-author-card-name">
|
|
@@ -194,7 +251,7 @@ useJsonLd({
|
|
|
194
251
|
<div v-if="content.author.bio" class="cpub-author-card-bio">{{ content.author.bio }}</div>
|
|
195
252
|
<div class="cpub-author-card-footer">
|
|
196
253
|
<div class="cpub-author-card-stats">
|
|
197
|
-
<div class="cpub-author-card-stat"><span class="n">{{ content.author.articleCount ?? 0 }}</span><span class="l">
|
|
254
|
+
<div class="cpub-author-card-stat"><span class="n">{{ content.author.articleCount ?? 0 }}</span><span class="l">posts</span></div>
|
|
198
255
|
<div class="cpub-author-card-stat"><span class="n">{{ content.author.followerCount ?? 0 }}</span><span class="l">followers</span></div>
|
|
199
256
|
<div class="cpub-author-card-stat"><span class="n">{{ content.author.totalViews ?? 0 }}</span><span class="l">total views</span></div>
|
|
200
257
|
</div>
|
|
@@ -209,7 +266,7 @@ useJsonLd({
|
|
|
209
266
|
</div>
|
|
210
267
|
|
|
211
268
|
<!-- RELATED ARTICLES -->
|
|
212
|
-
<div v-if="content.related?.length" class="cpub-section-head">Related
|
|
269
|
+
<div v-if="content.related?.length" class="cpub-section-head">Related Posts</div>
|
|
213
270
|
<div v-if="content.related?.length" class="cpub-related-grid">
|
|
214
271
|
<NuxtLink
|
|
215
272
|
v-for="item in content.related.slice(0, 3)"
|
|
@@ -244,21 +301,18 @@ useJsonLd({
|
|
|
244
301
|
|
|
245
302
|
<style scoped>
|
|
246
303
|
/* ── TOC SIDEBAR ── */
|
|
247
|
-
.cpub-article-
|
|
304
|
+
.cpub-article-body-layout {
|
|
248
305
|
display: none;
|
|
249
306
|
}
|
|
250
307
|
@media (min-width: 1200px) {
|
|
251
308
|
.cpub-article-body-layout {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
309
|
+
display: block;
|
|
310
|
+
position: absolute;
|
|
311
|
+
left: calc(100% + 32px);
|
|
312
|
+
top: 0;
|
|
255
313
|
width: 200px;
|
|
256
|
-
z-index: 10;
|
|
257
|
-
pointer-events: none;
|
|
258
314
|
}
|
|
259
315
|
.cpub-article-toc-sidebar {
|
|
260
|
-
display: block;
|
|
261
|
-
pointer-events: auto;
|
|
262
316
|
position: sticky;
|
|
263
317
|
top: 80px;
|
|
264
318
|
}
|
|
@@ -325,6 +379,7 @@ useJsonLd({
|
|
|
325
379
|
max-width: 720px;
|
|
326
380
|
margin: 0 auto;
|
|
327
381
|
padding: 40px clamp(12px, 4vw, 24px) 80px;
|
|
382
|
+
position: relative;
|
|
328
383
|
}
|
|
329
384
|
|
|
330
385
|
/* ── TYPE BADGE ── */
|
|
@@ -576,6 +631,123 @@ useJsonLd({
|
|
|
576
631
|
margin: 36px 0;
|
|
577
632
|
}
|
|
578
633
|
|
|
634
|
+
/* ── SERIES NAV ── */
|
|
635
|
+
.cpub-series-nav {
|
|
636
|
+
background: var(--surface);
|
|
637
|
+
border: var(--border-width-default) solid var(--border);
|
|
638
|
+
padding: 20px;
|
|
639
|
+
margin: 40px 0;
|
|
640
|
+
box-shadow: var(--shadow-sm);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.cpub-series-header {
|
|
644
|
+
display: flex;
|
|
645
|
+
align-items: center;
|
|
646
|
+
gap: 8px;
|
|
647
|
+
margin-bottom: 14px;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.cpub-series-icon {
|
|
651
|
+
width: 28px;
|
|
652
|
+
height: 28px;
|
|
653
|
+
background: var(--accent-bg);
|
|
654
|
+
border: var(--border-width-default) solid var(--accent);
|
|
655
|
+
display: flex;
|
|
656
|
+
align-items: center;
|
|
657
|
+
justify-content: center;
|
|
658
|
+
font-size: 12px;
|
|
659
|
+
color: var(--accent);
|
|
660
|
+
flex-shrink: 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.cpub-series-label {
|
|
664
|
+
font-size: 10px;
|
|
665
|
+
font-family: var(--font-mono);
|
|
666
|
+
color: var(--text-faint);
|
|
667
|
+
letter-spacing: 0.1em;
|
|
668
|
+
text-transform: uppercase;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.cpub-series-title {
|
|
672
|
+
font-size: 13px;
|
|
673
|
+
font-weight: 600;
|
|
674
|
+
color: var(--text);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.cpub-series-progress {
|
|
678
|
+
margin-bottom: 16px;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.cpub-series-progress-label {
|
|
682
|
+
font-size: 11px;
|
|
683
|
+
font-family: var(--font-mono);
|
|
684
|
+
color: var(--text-faint);
|
|
685
|
+
margin-bottom: 6px;
|
|
686
|
+
display: flex;
|
|
687
|
+
justify-content: space-between;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.cpub-series-progress-track {
|
|
691
|
+
height: 4px;
|
|
692
|
+
background: var(--surface3);
|
|
693
|
+
overflow: hidden;
|
|
694
|
+
border: var(--border-width-default) solid var(--border2);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.cpub-series-progress-fill {
|
|
698
|
+
height: 100%;
|
|
699
|
+
background: var(--accent);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.cpub-series-nav-btns {
|
|
703
|
+
display: grid;
|
|
704
|
+
grid-template-columns: 1fr 1fr;
|
|
705
|
+
gap: 8px;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.cpub-series-nav-btn {
|
|
709
|
+
background: var(--surface);
|
|
710
|
+
border: var(--border-width-default) solid var(--border);
|
|
711
|
+
padding: 12px 14px;
|
|
712
|
+
cursor: pointer;
|
|
713
|
+
text-decoration: none;
|
|
714
|
+
display: flex;
|
|
715
|
+
flex-direction: column;
|
|
716
|
+
gap: 4px;
|
|
717
|
+
color: inherit;
|
|
718
|
+
transition: background var(--transition-fast);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.cpub-series-nav-btn:hover { background: var(--surface2); }
|
|
722
|
+
.cpub-series-nav-btn.cpub-next { text-align: right; }
|
|
723
|
+
.cpub-series-nav-btn.cpub-disabled { opacity: 0.5; pointer-events: none; }
|
|
724
|
+
|
|
725
|
+
.cpub-series-nav-dir {
|
|
726
|
+
font-size: 10px;
|
|
727
|
+
font-family: var(--font-mono);
|
|
728
|
+
color: var(--text-faint);
|
|
729
|
+
letter-spacing: 0.08em;
|
|
730
|
+
text-transform: uppercase;
|
|
731
|
+
display: flex;
|
|
732
|
+
align-items: center;
|
|
733
|
+
gap: 4px;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.cpub-series-nav-btn.cpub-next .cpub-series-nav-dir { justify-content: flex-end; }
|
|
737
|
+
|
|
738
|
+
.cpub-series-nav-ep {
|
|
739
|
+
font-size: 10px;
|
|
740
|
+
font-family: var(--font-mono);
|
|
741
|
+
color: var(--accent);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.cpub-series-nav-post-title {
|
|
745
|
+
font-size: 12px;
|
|
746
|
+
font-weight: 600;
|
|
747
|
+
color: var(--text);
|
|
748
|
+
line-height: 1.35;
|
|
749
|
+
}
|
|
750
|
+
|
|
579
751
|
/* ── TAGS ROW ── */
|
|
580
752
|
.cpub-tags-row {
|
|
581
753
|
display: flex;
|
|
@@ -803,6 +975,8 @@ useJsonLd({
|
|
|
803
975
|
.cpub-engage-btn { padding: 8px 12px; min-height: 36px; }
|
|
804
976
|
.cpub-engage-sep { display: none; }
|
|
805
977
|
.cpub-tag-link { padding: 4px 10px; font-size: 11px; min-height: 28px; display: inline-flex; align-items: center; }
|
|
978
|
+
.cpub-series-nav-btns { grid-template-columns: 1fr; }
|
|
979
|
+
.cpub-series-nav-btn { padding: 12px; min-height: 44px; }
|
|
806
980
|
}
|
|
807
981
|
|
|
808
982
|
@media (max-width: 480px) {
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
export type ContentType = 'project' | 'article' | 'blog' | 'explainer';
|
|
4
4
|
|
|
5
|
-
const CONTENT_TYPE_META: Record<
|
|
5
|
+
const CONTENT_TYPE_META: Record<string, { label: string; plural: string; icon: string; route: string }> = {
|
|
6
6
|
project: { label: 'Project', plural: 'Projects', icon: 'fa-solid fa-microchip', route: '/project' },
|
|
7
|
-
|
|
8
|
-
blog: { label: 'Blog', plural: 'Blogs', icon: 'fa-solid fa-pen-nib', route: '/blog' },
|
|
7
|
+
blog: { label: 'Blog', plural: 'Blog', icon: 'fa-solid fa-pen-nib', route: '/blog' },
|
|
9
8
|
explainer: { label: 'Explainer', plural: 'Explainers', icon: 'fa-solid fa-lightbulb', route: '/explainer' },
|
|
10
9
|
};
|
|
11
10
|
|
|
@@ -14,8 +13,8 @@ export function useContentTypes() {
|
|
|
14
13
|
|
|
15
14
|
const enabledTypes = computed<ContentType[]>(() => {
|
|
16
15
|
const raw = config.public.contentTypes as string;
|
|
17
|
-
if (!raw) return ['project', '
|
|
18
|
-
return raw.split(',').map(s => s.trim()).filter(Boolean) as ContentType[];
|
|
16
|
+
if (!raw) return ['project', 'blog', 'explainer'];
|
|
17
|
+
return raw.split(',').map(s => s.trim()).filter(s => s !== 'article').filter(Boolean) as ContentType[];
|
|
19
18
|
});
|
|
20
19
|
|
|
21
20
|
const isTypeEnabled = (type: ContentType): boolean => {
|
|
@@ -6,7 +6,7 @@ import type { ContentViewData } from './useEngagement';
|
|
|
6
6
|
*/
|
|
7
7
|
export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>) {
|
|
8
8
|
const contentType = computed(() => {
|
|
9
|
-
const t = (fedContent.value?.cpubType as string) || (fedContent.value?.apType as string)?.toLowerCase() || '
|
|
9
|
+
const t = (fedContent.value?.cpubType as string) || (fedContent.value?.apType as string)?.toLowerCase() || 'blog';
|
|
10
10
|
return t;
|
|
11
11
|
});
|
|
12
12
|
|
package/layouts/default.vue
CHANGED
|
@@ -112,7 +112,6 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
112
112
|
<i class="fa-solid fa-newspaper"></i> Read <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
113
113
|
</button>
|
|
114
114
|
<div v-if="openDropdown === 'read'" class="cpub-nav-panel">
|
|
115
|
-
<NuxtLink to="/article" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-file-lines"></i> Articles</NuxtLink>
|
|
116
115
|
<NuxtLink to="/blog" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
|
|
117
116
|
</div>
|
|
118
117
|
</div>
|
|
@@ -202,7 +201,6 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
202
201
|
|
|
203
202
|
<!-- Read -->
|
|
204
203
|
<div class="cpub-mobile-section-label">Read</div>
|
|
205
|
-
<NuxtLink to="/article" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-file-lines"></i> Articles</NuxtLink>
|
|
206
204
|
<NuxtLink to="/blog" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
|
|
207
205
|
|
|
208
206
|
<!-- Watch -->
|
package/nuxt.config.ts
CHANGED
|
@@ -90,7 +90,7 @@ export default defineNuxtConfig({
|
|
|
90
90
|
admin: false,
|
|
91
91
|
emailNotifications: false,
|
|
92
92
|
},
|
|
93
|
-
contentTypes: 'project,
|
|
93
|
+
contentTypes: 'project,blog,explainer',
|
|
94
94
|
contestCreation: 'admin',
|
|
95
95
|
instanceCookies: [] as Array<{ name: string; category: string; description: string; duration: string; provider?: string }>,
|
|
96
96
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -51,15 +51,15 @@
|
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
53
|
"@commonpub/auth": "0.5.0",
|
|
54
|
-
"@commonpub/config": "0.9.0",
|
|
55
54
|
"@commonpub/docs": "0.6.2",
|
|
56
|
-
"@commonpub/explainer": "0.7.6",
|
|
57
|
-
"@commonpub/schema": "0.9.5",
|
|
58
|
-
"@commonpub/protocol": "0.9.7",
|
|
59
|
-
"@commonpub/learning": "0.5.0",
|
|
60
55
|
"@commonpub/editor": "0.7.9",
|
|
56
|
+
"@commonpub/schema": "0.9.6",
|
|
57
|
+
"@commonpub/config": "0.9.1",
|
|
58
|
+
"@commonpub/protocol": "0.9.8",
|
|
59
|
+
"@commonpub/learning": "0.5.0",
|
|
61
60
|
"@commonpub/ui": "0.8.5",
|
|
62
|
-
"@commonpub/server": "2.
|
|
61
|
+
"@commonpub/server": "2.28.0",
|
|
62
|
+
"@commonpub/explainer": "0.7.10"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/create.vue
CHANGED
|
@@ -21,13 +21,13 @@ const allTypes = [
|
|
|
21
21
|
badge: 'Popular',
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
|
-
type: '
|
|
25
|
-
icon: 'fa-solid fa-
|
|
26
|
-
color: 'var(--
|
|
27
|
-
bg: 'var(--
|
|
28
|
-
border: 'var(--
|
|
29
|
-
name: '
|
|
30
|
-
desc: 'Write
|
|
24
|
+
type: 'blog',
|
|
25
|
+
icon: 'fa-solid fa-pen-nib',
|
|
26
|
+
color: 'var(--pink)',
|
|
27
|
+
bg: 'var(--pink-bg)',
|
|
28
|
+
border: 'var(--pink-border)',
|
|
29
|
+
name: 'Blog',
|
|
30
|
+
desc: 'Write long-form content — articles, tutorials, deep dives, opinion pieces, or personal updates with rich formatting.',
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
type: 'explainer',
|
|
@@ -38,15 +38,6 @@ const allTypes = [
|
|
|
38
38
|
name: 'Explainer',
|
|
39
39
|
desc: 'Create an interactive explorable explanation with sliders, quizzes, and section-by-section progression.',
|
|
40
40
|
},
|
|
41
|
-
{
|
|
42
|
-
type: 'blog',
|
|
43
|
-
icon: 'fa-solid fa-pen-nib',
|
|
44
|
-
color: 'var(--pink)',
|
|
45
|
-
bg: 'var(--pink-bg)',
|
|
46
|
-
border: 'var(--pink-border)',
|
|
47
|
-
name: 'Blog Post',
|
|
48
|
-
desc: 'Share thoughts, tutorials, or updates with a clean writing experience and inline media.',
|
|
49
|
-
},
|
|
50
41
|
];
|
|
51
42
|
|
|
52
43
|
const types = computed(() => allTypes.filter(t => isTypeEnabled(t.type as ContentType)));
|
package/pages/explore.vue
CHANGED
|
@@ -168,7 +168,7 @@ const sortOptions = [
|
|
|
168
168
|
<div v-if="statsData" class="cpub-explore-stats">
|
|
169
169
|
<div class="cpub-explore-stat">
|
|
170
170
|
<span class="cpub-explore-stat-n">{{ statsData?.content?.total ?? 0 }}</span>
|
|
171
|
-
<span class="cpub-explore-stat-l">Projects &
|
|
171
|
+
<span class="cpub-explore-stat-l">Projects & Posts</span>
|
|
172
172
|
</div>
|
|
173
173
|
<div v-if="hubsEnabled" class="cpub-explore-stat">
|
|
174
174
|
<span class="cpub-explore-stat-n">{{ statsData?.hubs?.total ?? 0 }}</span>
|
package/pages/index.vue
CHANGED
|
@@ -294,8 +294,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
294
294
|
<span class="cpub-stat-lbl">Projects</span>
|
|
295
295
|
</div>
|
|
296
296
|
<div class="cpub-stat-block">
|
|
297
|
-
<span class="cpub-stat-num">{{ stats?.content?.byType?.article ?? 0 }}</span>
|
|
298
|
-
<span class="cpub-stat-lbl">
|
|
297
|
+
<span class="cpub-stat-num">{{ (stats?.content?.byType?.blog ?? 0) + (stats?.content?.byType?.article ?? 0) }}</span>
|
|
298
|
+
<span class="cpub-stat-lbl">Posts</span>
|
|
299
299
|
</div>
|
|
300
300
|
<div class="cpub-stat-block">
|
|
301
301
|
<span class="cpub-stat-num">{{ stats?.users?.total ?? 0 }}</span>
|
|
@@ -343,7 +343,7 @@ async function handlePublish(): Promise<void> {
|
|
|
343
343
|
|
|
344
344
|
<ContentPicker
|
|
345
345
|
:open="showContentPicker"
|
|
346
|
-
:types="['
|
|
346
|
+
:types="['blog', 'project', 'explainer']"
|
|
347
347
|
@update:open="showContentPicker = $event"
|
|
348
348
|
@select="linkContent"
|
|
349
349
|
/>
|
package/pages/mirror/[id].vue
CHANGED
|
@@ -98,8 +98,7 @@ useSeoMeta({
|
|
|
98
98
|
|
|
99
99
|
<!-- Reuse existing content view components by type -->
|
|
100
100
|
<ViewsProjectView v-if="contentType === 'project'" :content="transformedContent" :federated-id="id" />
|
|
101
|
-
<ViewsArticleView v-else-if="contentType === 'article'" :content="transformedContent" :federated-id="id" />
|
|
102
|
-
<ViewsBlogView v-else-if="contentType === 'blog'" :content="transformedContent" :federated-id="id" />
|
|
101
|
+
<ViewsArticleView v-else-if="contentType === 'article' || contentType === 'blog'" :content="transformedContent" :federated-id="id" />
|
|
103
102
|
<ViewsExplainerView v-else-if="contentType === 'explainer'" :content="transformedContent" :federated-id="id" />
|
|
104
103
|
|
|
105
104
|
<!-- Fallback for non-CommonPub content (Mastodon notes, Lemmy posts, etc.) -->
|
package/pages/privacy.vue
CHANGED
|
@@ -31,7 +31,7 @@ const { federation: federationEnabled } = useFeatures();
|
|
|
31
31
|
<ul>
|
|
32
32
|
<li><strong>Account data:</strong> email address, username, password (stored as a secure hash)</li>
|
|
33
33
|
<li><strong>Profile data:</strong> display name, bio, headline, location, website, avatar, banner image, social links, skills, pronouns, timezone (all optional)</li>
|
|
34
|
-
<li><strong>Content:</strong> projects, articles,
|
|
34
|
+
<li><strong>Content:</strong> projects, articles, comments, and other content you create</li>
|
|
35
35
|
<li><strong>Activity data:</strong> likes, follows, bookmarks, hub memberships, learning path enrollments</li>
|
|
36
36
|
<li><strong>Messages:</strong> direct messages you send to other users on this instance</li>
|
|
37
37
|
</ul>
|
|
@@ -77,7 +77,7 @@ const { federation: federationEnabled } = useFeatures();
|
|
|
77
77
|
<p>This instance participates in the <a href="https://activitypub.rocks" target="_blank" rel="noopener">ActivityPub</a> federation protocol. When you publish content or interact publicly, the following data may be shared with remote instances:</p>
|
|
78
78
|
<ul>
|
|
79
79
|
<li>Your username, display name, avatar, and bio</li>
|
|
80
|
-
<li>Your published content (projects, articles,
|
|
80
|
+
<li>Your published content (projects, articles, explainers)</li>
|
|
81
81
|
<li>Your public interactions (likes, follows, comments on federated content)</li>
|
|
82
82
|
</ul>
|
|
83
83
|
<p>Your email address, location, social links, timezone, and other private profile fields are <strong>never</strong> shared via federation.</p>
|
|
@@ -130,8 +130,8 @@ provide(SEARCH_PRODUCTS_KEY, async (query: string) => {
|
|
|
130
130
|
|
|
131
131
|
// --- Specialized editor component map ---
|
|
132
132
|
const editorMap: Record<string, Component> = {
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
blog: resolveComponent('EditorsArticleEditor') as Component,
|
|
134
|
+
article: resolveComponent('EditorsArticleEditor') as Component, // article merged into blog
|
|
135
135
|
explainer: resolveComponent('EditorsExplainerEditor') as Component,
|
|
136
136
|
project: resolveComponent('EditorsProjectEditor') as Component,
|
|
137
137
|
};
|
|
@@ -90,8 +90,7 @@ onMounted(() => {
|
|
|
90
90
|
|
|
91
91
|
<!-- Specialized view by content type -->
|
|
92
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)" />
|
|
93
|
+
<ViewsArticleView v-else-if="contentType === 'article' || contentType === 'blog'" :content="(enrichedContent as any)" />
|
|
95
94
|
<ViewsExplainerView v-else-if="contentType === 'explainer'" :content="(enrichedContent as any)" />
|
|
96
95
|
|
|
97
96
|
<!-- Fallback: generic view for unknown types -->
|
|
@@ -33,7 +33,7 @@ const activeTab = ref('projects');
|
|
|
33
33
|
const tabDefs = computed(() => {
|
|
34
34
|
const tabs = [
|
|
35
35
|
{ value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open' },
|
|
36
|
-
{ value: 'articles', label: '
|
|
36
|
+
{ value: 'articles', label: 'Blog', icon: 'fa-solid fa-pen-nib' },
|
|
37
37
|
];
|
|
38
38
|
if (explainersEnabled.value) {
|
|
39
39
|
tabs.push({ value: 'explainers', label: 'Explainers', icon: 'fa-solid fa-book-open' });
|
|
@@ -52,7 +52,7 @@ const profileStats = computed(() => {
|
|
|
52
52
|
{ value: p.stats?.projects ?? 0, label: 'Projects' },
|
|
53
53
|
{ value: p.followerCount ?? p.stats?.followers ?? 0, label: 'Followers' },
|
|
54
54
|
{ value: p.followingCount ?? p.stats?.following ?? 0, label: 'Following' },
|
|
55
|
-
{ value: p.stats?.articles ?? 0, label: '
|
|
55
|
+
{ value: p.stats?.articles ?? 0, label: 'Posts' },
|
|
56
56
|
{ value: p.viewCount ?? 0, label: 'Total Views' },
|
|
57
57
|
{ value: p.likeCount ?? 0, label: 'Likes' },
|
|
58
58
|
];
|
|
@@ -49,7 +49,7 @@ export default defineEventHandler(async (event): Promise<HubPostItem> => {
|
|
|
49
49
|
const sharePayload = JSON.stringify({
|
|
50
50
|
federatedContentId: fedContent.id,
|
|
51
51
|
title: fedContent.title,
|
|
52
|
-
type: fedContent.cpubType ?? fedContent.apType ?? '
|
|
52
|
+
type: fedContent.cpubType ?? fedContent.apType ?? 'blog',
|
|
53
53
|
coverImageUrl: fedContent.coverImageUrl ?? null,
|
|
54
54
|
description: fedContent.summary ?? null,
|
|
55
55
|
originUrl: fedContent.url ?? fedContent.objectUri,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server middleware: redirect article URLs to blog URLs.
|
|
3
|
+
* - /u/{username}/article/{slug} → /u/{username}/blog/{slug}
|
|
4
|
+
* - /u/{username}/article/{slug}/edit → /u/{username}/blog/{slug}/edit
|
|
5
|
+
* - /article → /blog (listing page)
|
|
6
|
+
*
|
|
7
|
+
* Article content type has been merged into blog. These 301 redirects
|
|
8
|
+
* ensure old URLs and bookmarks continue working.
|
|
9
|
+
*/
|
|
10
|
+
export default defineEventHandler((event) => {
|
|
11
|
+
const path = getRequestURL(event).pathname;
|
|
12
|
+
|
|
13
|
+
// Redirect /article listing to /blog
|
|
14
|
+
if (path === '/article' || path === '/article/') {
|
|
15
|
+
const query = getRequestURL(event).search;
|
|
16
|
+
return sendRedirect(event, `/blog${query}`, 301);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Redirect /u/{username}/article/{slug}[/edit] to /u/{username}/blog/{slug}[/edit]
|
|
20
|
+
const match = path.match(/^\/u\/([^/]+)\/article\/(.+)$/);
|
|
21
|
+
if (!match) return;
|
|
22
|
+
|
|
23
|
+
const newPath = `/u/${match[1]}/blog/${match[2]}`;
|
|
24
|
+
const query = getRequestURL(event).search;
|
|
25
|
+
return sendRedirect(event, `${newPath}${query}`, 301);
|
|
26
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { contentToArticle } from '@commonpub/protocol';
|
|
2
2
|
import { contentItems, users } from '@commonpub/schema';
|
|
3
|
-
import { eq, and, isNull } from 'drizzle-orm';
|
|
3
|
+
import { eq, and, isNull, sql } from 'drizzle-orm';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Middleware: serve ActivityPub Article JSON-LD for content URIs.
|
|
@@ -26,12 +26,18 @@ export default defineEventHandler(async (event) => {
|
|
|
26
26
|
const config = useConfig();
|
|
27
27
|
if (!config.features.federation) return;
|
|
28
28
|
|
|
29
|
-
const [, username,
|
|
30
|
-
if (!username || !
|
|
29
|
+
const [, username, rawType, slug] = match;
|
|
30
|
+
if (!username || !rawType || !slug) return;
|
|
31
|
+
const type = rawType === 'article' ? 'blog' : rawType; // normalize article→blog
|
|
31
32
|
|
|
32
33
|
const db = useDB();
|
|
33
34
|
const domain = config.instance.domain;
|
|
34
35
|
|
|
36
|
+
// For blog type, also match 'article' in DB (transition: pre-migration rows still have type='article')
|
|
37
|
+
const typeFilter = type === 'blog'
|
|
38
|
+
? sql`${contentItems.type} IN ('blog', 'article')`
|
|
39
|
+
: eq(contentItems.type, type as 'project' | 'explainer');
|
|
40
|
+
|
|
35
41
|
const [row] = await db
|
|
36
42
|
.select({
|
|
37
43
|
content: contentItems,
|
|
@@ -44,7 +50,7 @@ export default defineEventHandler(async (event) => {
|
|
|
44
50
|
.innerJoin(users, eq(contentItems.authorId, users.id))
|
|
45
51
|
.where(and(
|
|
46
52
|
eq(users.username, username),
|
|
47
|
-
|
|
53
|
+
typeFilter,
|
|
48
54
|
eq(contentItems.slug, slug),
|
|
49
55
|
eq(contentItems.status, 'published'),
|
|
50
56
|
isNull(contentItems.deletedAt),
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time data migration: article → blog type merge.
|
|
3
|
+
*
|
|
4
|
+
* Converts content_items with type='article' to type='blog', preserving the
|
|
5
|
+
* original intent by setting category='article' (only when category is empty).
|
|
6
|
+
*
|
|
7
|
+
* Handles the edge case where the same author has both type='article' and
|
|
8
|
+
* type='blog' with the same slug — appends '-2' to the article's slug before
|
|
9
|
+
* converting to avoid unique constraint violation on (authorId, type, slug).
|
|
10
|
+
*
|
|
11
|
+
* Idempotent — safe to run on every startup. Once no article rows remain,
|
|
12
|
+
* this is a no-op SELECT that returns 0.
|
|
13
|
+
*
|
|
14
|
+
* Can be removed after confirming both instances have been migrated.
|
|
15
|
+
*/
|
|
16
|
+
import { contentItems } from '@commonpub/schema';
|
|
17
|
+
import { eq, and, sql, isNull } from 'drizzle-orm';
|
|
18
|
+
|
|
19
|
+
export default defineNitroPlugin((nitro) => {
|
|
20
|
+
setTimeout(async () => {
|
|
21
|
+
try {
|
|
22
|
+
const db = useDB();
|
|
23
|
+
|
|
24
|
+
const [{ count: articleCount }] = await db
|
|
25
|
+
.select({ count: sql<number>`count(*)::int` })
|
|
26
|
+
.from(contentItems)
|
|
27
|
+
.where(eq(contentItems.type, 'article'));
|
|
28
|
+
|
|
29
|
+
if (articleCount === 0) return;
|
|
30
|
+
|
|
31
|
+
// Handle slug collisions: if author has both article/slug and blog/slug,
|
|
32
|
+
// rename the article's slug before converting type
|
|
33
|
+
await db.execute(sql`
|
|
34
|
+
UPDATE content_items a
|
|
35
|
+
SET slug = a.slug || '-2'
|
|
36
|
+
WHERE a.type = 'article'
|
|
37
|
+
AND EXISTS (
|
|
38
|
+
SELECT 1 FROM content_items b
|
|
39
|
+
WHERE b.type = 'blog'
|
|
40
|
+
AND b.author_id = a.author_id
|
|
41
|
+
AND b.slug = a.slug
|
|
42
|
+
)
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
// Set category='article' for rows that don't already have a category,
|
|
46
|
+
// so the original "article" intent is preserved as metadata
|
|
47
|
+
await db
|
|
48
|
+
.update(contentItems)
|
|
49
|
+
.set({ category: 'article' })
|
|
50
|
+
.where(and(
|
|
51
|
+
eq(contentItems.type, 'article'),
|
|
52
|
+
isNull(contentItems.category),
|
|
53
|
+
));
|
|
54
|
+
|
|
55
|
+
// Convert the type
|
|
56
|
+
await db
|
|
57
|
+
.update(contentItems)
|
|
58
|
+
.set({ type: 'blog' })
|
|
59
|
+
.where(eq(contentItems.type, 'article'));
|
|
60
|
+
|
|
61
|
+
console.log(`[migrate-article-to-blog] Converted ${articleCount} article(s) to blog type (category preserved)`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Log but don't crash the app — migration can retry on next restart
|
|
64
|
+
console.warn('[migrate-article-to-blog] Migration failed, will retry on next restart:', (err as Error).message);
|
|
65
|
+
}
|
|
66
|
+
}, 3000);
|
|
67
|
+
});
|
|
@@ -59,7 +59,7 @@ export default defineEventHandler(async (event) => {
|
|
|
59
59
|
try {
|
|
60
60
|
const shared = JSON.parse(post.content) as Record<string, unknown>;
|
|
61
61
|
ext['cpub:sharedContent'] = {
|
|
62
|
-
type: shared.type ?? '
|
|
62
|
+
type: shared.type ?? 'blog',
|
|
63
63
|
title: shared.title ?? '',
|
|
64
64
|
summary: shared.description ?? null,
|
|
65
65
|
coverImageUrl: shared.coverImageUrl ?? null,
|