@commonpub/layer 0.7.27 → 0.8.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/ContentTypeBadge.vue +1 -2
- package/components/FederatedContentCard.vue +1 -1
- package/components/editors/ArticleEditor.vue +59 -7
- package/components/views/ArticleView.vue +178 -5
- 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 +8 -8
- 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,14 @@ 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>
|
|
351
360
|
</div>
|
|
352
361
|
<div class="cpub-ae-cover" :class="{ 'has-image': !!coverImageUrl }">
|
|
353
362
|
<template v-if="coverImageUrl">
|
|
@@ -402,9 +411,9 @@ const canvasMaxWidth = computed(() => {
|
|
|
402
411
|
<div class="cpub-seo-card">
|
|
403
412
|
<div class="cpub-seo-url">
|
|
404
413
|
<span class="cpub-seo-favicon">C</span>
|
|
405
|
-
{{ seoDomain }} ›
|
|
414
|
+
{{ seoDomain }} › blog
|
|
406
415
|
</div>
|
|
407
|
-
<div class="cpub-seo-title">{{ (metadata.title as string) || '
|
|
416
|
+
<div class="cpub-seo-title">{{ (metadata.title as string) || 'Post title' }}</div>
|
|
408
417
|
<div class="cpub-seo-desc">{{ seoPreviewDesc || 'Post description will appear here...' }}</div>
|
|
409
418
|
</div>
|
|
410
419
|
<div class="cpub-ep-field" style="margin-top: 10px;">
|
|
@@ -424,18 +433,38 @@ const canvasMaxWidth = computed(() => {
|
|
|
424
433
|
<label class="cpub-ep-flabel">Category</label>
|
|
425
434
|
<select class="cpub-ep-select" :value="metadata.category || ''" @change="updateMeta('category', ($event.target as HTMLSelectElement).value)">
|
|
426
435
|
<option value="">Select category</option>
|
|
427
|
-
<option value="
|
|
428
|
-
<option value="
|
|
429
|
-
<option value="ai-ml">AI & Machine Learning</option>
|
|
436
|
+
<option value="article">Article</option>
|
|
437
|
+
<option value="blog">Blog Post</option>
|
|
430
438
|
<option value="tutorial">Tutorial</option>
|
|
431
439
|
<option value="deep-dive">Deep Dive</option>
|
|
432
440
|
<option value="opinion">Opinion</option>
|
|
441
|
+
<option value="hardware">Hardware & Makers</option>
|
|
442
|
+
<option value="software">Software</option>
|
|
443
|
+
<option value="ai-ml">AI & Machine Learning</option>
|
|
444
|
+
<option value="homelab">Home Lab</option>
|
|
433
445
|
</select>
|
|
434
446
|
</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
|
|
|
@@ -65,6 +65,12 @@ async function handleFollowAuthor(): Promise<void> {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Series data — only available when content has series metadata
|
|
69
|
+
const seriesPart = computed(() => props.content?.seriesPart as number | undefined);
|
|
70
|
+
const seriesTitle = computed(() => props.content?.seriesTitle as string | undefined);
|
|
71
|
+
const seriesTotalParts = computed(() => (props.content?.seriesTotalParts as number) || 0);
|
|
72
|
+
const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value > 0);
|
|
73
|
+
|
|
68
74
|
const config = useRuntimeConfig();
|
|
69
75
|
useJsonLd({
|
|
70
76
|
type: 'article',
|
|
@@ -97,7 +103,7 @@ useJsonLd({
|
|
|
97
103
|
<div class="cpub-article-wrap">
|
|
98
104
|
|
|
99
105
|
<!-- TYPE BADGE -->
|
|
100
|
-
<div class="cpub-content-type-badge"><i class="fa-solid fa-
|
|
106
|
+
<div class="cpub-content-type-badge"><i class="fa-solid fa-pen-nib"></i> {{ content.category || 'Blog Post' }}</div>
|
|
101
107
|
|
|
102
108
|
<!-- TITLE -->
|
|
103
109
|
<h1 class="cpub-article-title">{{ content.title }}</h1>
|
|
@@ -122,6 +128,10 @@ useJsonLd({
|
|
|
122
128
|
<span>{{ new Date(content.publishedAt || content.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}</span>
|
|
123
129
|
<span class="cpub-sep">·</span>
|
|
124
130
|
<span><i class="fa-regular fa-clock"></i> {{ content.readTime || '5 min read' }}</span>
|
|
131
|
+
<template v-if="hasSeries">
|
|
132
|
+
<span class="cpub-sep">·</span>
|
|
133
|
+
<span class="cpub-tag cpub-tag-accent">{{ seriesTitle }} · Part {{ seriesPart || 1 }} of {{ seriesTotalParts }}</span>
|
|
134
|
+
</template>
|
|
125
135
|
<template v-if="content.tags?.length">
|
|
126
136
|
<span class="cpub-sep">·</span>
|
|
127
137
|
<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 +177,49 @@ useJsonLd({
|
|
|
167
177
|
</template>
|
|
168
178
|
</div>
|
|
169
179
|
|
|
180
|
+
<!-- SERIES NAVIGATION -->
|
|
181
|
+
<div v-if="hasSeries" class="cpub-series-nav">
|
|
182
|
+
<div class="cpub-series-header">
|
|
183
|
+
<div class="cpub-series-icon"><i class="fa-solid fa-layer-group"></i></div>
|
|
184
|
+
<div>
|
|
185
|
+
<div class="cpub-series-label">Series</div>
|
|
186
|
+
<div class="cpub-series-title">{{ seriesTitle }}</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div style="margin-left:auto;">
|
|
189
|
+
<span class="cpub-tag cpub-tag-accent">Part {{ seriesPart || 1 }} of {{ seriesTotalParts }}</span>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="cpub-series-progress">
|
|
193
|
+
<div class="cpub-series-progress-label">
|
|
194
|
+
<span>Progress</span>
|
|
195
|
+
<span>{{ seriesPart || 1 }} / {{ seriesTotalParts }} published</span>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="cpub-series-progress-track">
|
|
198
|
+
<div class="cpub-series-progress-fill" :style="{ width: ((seriesPart || 1) / seriesTotalParts * 100) + '%' }"></div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="cpub-series-nav-btns">
|
|
202
|
+
<NuxtLink v-if="content.seriesPrev" :to="content.seriesPrev.url || '#'" class="cpub-series-nav-btn cpub-prev">
|
|
203
|
+
<div class="cpub-series-nav-dir"><i class="fa-solid fa-chevron-left"></i> Previous</div>
|
|
204
|
+
<div class="cpub-series-nav-ep">Part {{ (seriesPart || 2) - 1 }}</div>
|
|
205
|
+
<div class="cpub-series-nav-post-title">{{ content.seriesPrev.title }}</div>
|
|
206
|
+
</NuxtLink>
|
|
207
|
+
<div v-else class="cpub-series-nav-btn cpub-prev cpub-disabled">
|
|
208
|
+
<div class="cpub-series-nav-dir"><i class="fa-solid fa-chevron-left"></i> Previous</div>
|
|
209
|
+
<div class="cpub-series-nav-ep">—</div>
|
|
210
|
+
</div>
|
|
211
|
+
<NuxtLink v-if="content.seriesNext" :to="content.seriesNext.url || '#'" class="cpub-series-nav-btn cpub-next">
|
|
212
|
+
<div class="cpub-series-nav-dir">Next <i class="fa-solid fa-chevron-right"></i></div>
|
|
213
|
+
<div class="cpub-series-nav-ep">Part {{ (seriesPart || 1) + 1 }}</div>
|
|
214
|
+
<div class="cpub-series-nav-post-title">{{ content.seriesNext.title }}</div>
|
|
215
|
+
</NuxtLink>
|
|
216
|
+
<div v-else class="cpub-series-nav-btn cpub-next cpub-disabled">
|
|
217
|
+
<div class="cpub-series-nav-dir">Next <i class="fa-solid fa-chevron-right"></i></div>
|
|
218
|
+
<div class="cpub-series-nav-ep">Coming soon</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
170
223
|
<!-- TAGS -->
|
|
171
224
|
<div v-if="content.tags?.length" class="cpub-tags-row">
|
|
172
225
|
<div class="cpub-tags-label">Filed under</div>
|
|
@@ -183,7 +236,8 @@ useJsonLd({
|
|
|
183
236
|
|
|
184
237
|
<!-- AUTHOR CARD -->
|
|
185
238
|
<div v-if="content.author" class="cpub-author-card">
|
|
186
|
-
<
|
|
239
|
+
<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);" />
|
|
240
|
+
<div v-else class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
|
|
187
241
|
<div class="cpub-author-card-info">
|
|
188
242
|
<div class="cpub-author-card-label">Written by</div>
|
|
189
243
|
<div class="cpub-author-card-name">
|
|
@@ -194,7 +248,7 @@ useJsonLd({
|
|
|
194
248
|
<div v-if="content.author.bio" class="cpub-author-card-bio">{{ content.author.bio }}</div>
|
|
195
249
|
<div class="cpub-author-card-footer">
|
|
196
250
|
<div class="cpub-author-card-stats">
|
|
197
|
-
<div class="cpub-author-card-stat"><span class="n">{{ content.author.articleCount ?? 0 }}</span><span class="l">
|
|
251
|
+
<div class="cpub-author-card-stat"><span class="n">{{ content.author.articleCount ?? 0 }}</span><span class="l">posts</span></div>
|
|
198
252
|
<div class="cpub-author-card-stat"><span class="n">{{ content.author.followerCount ?? 0 }}</span><span class="l">followers</span></div>
|
|
199
253
|
<div class="cpub-author-card-stat"><span class="n">{{ content.author.totalViews ?? 0 }}</span><span class="l">total views</span></div>
|
|
200
254
|
</div>
|
|
@@ -209,7 +263,7 @@ useJsonLd({
|
|
|
209
263
|
</div>
|
|
210
264
|
|
|
211
265
|
<!-- RELATED ARTICLES -->
|
|
212
|
-
<div v-if="content.related?.length" class="cpub-section-head">Related
|
|
266
|
+
<div v-if="content.related?.length" class="cpub-section-head">Related Posts</div>
|
|
213
267
|
<div v-if="content.related?.length" class="cpub-related-grid">
|
|
214
268
|
<NuxtLink
|
|
215
269
|
v-for="item in content.related.slice(0, 3)"
|
|
@@ -576,6 +630,123 @@ useJsonLd({
|
|
|
576
630
|
margin: 36px 0;
|
|
577
631
|
}
|
|
578
632
|
|
|
633
|
+
/* ── SERIES NAV ── */
|
|
634
|
+
.cpub-series-nav {
|
|
635
|
+
background: var(--surface);
|
|
636
|
+
border: var(--border-width-default) solid var(--border);
|
|
637
|
+
padding: 20px;
|
|
638
|
+
margin: 40px 0;
|
|
639
|
+
box-shadow: var(--shadow-sm);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.cpub-series-header {
|
|
643
|
+
display: flex;
|
|
644
|
+
align-items: center;
|
|
645
|
+
gap: 8px;
|
|
646
|
+
margin-bottom: 14px;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.cpub-series-icon {
|
|
650
|
+
width: 28px;
|
|
651
|
+
height: 28px;
|
|
652
|
+
background: var(--accent-bg);
|
|
653
|
+
border: var(--border-width-default) solid var(--accent);
|
|
654
|
+
display: flex;
|
|
655
|
+
align-items: center;
|
|
656
|
+
justify-content: center;
|
|
657
|
+
font-size: 12px;
|
|
658
|
+
color: var(--accent);
|
|
659
|
+
flex-shrink: 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.cpub-series-label {
|
|
663
|
+
font-size: 10px;
|
|
664
|
+
font-family: var(--font-mono);
|
|
665
|
+
color: var(--text-faint);
|
|
666
|
+
letter-spacing: 0.1em;
|
|
667
|
+
text-transform: uppercase;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.cpub-series-title {
|
|
671
|
+
font-size: 13px;
|
|
672
|
+
font-weight: 600;
|
|
673
|
+
color: var(--text);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.cpub-series-progress {
|
|
677
|
+
margin-bottom: 16px;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.cpub-series-progress-label {
|
|
681
|
+
font-size: 11px;
|
|
682
|
+
font-family: var(--font-mono);
|
|
683
|
+
color: var(--text-faint);
|
|
684
|
+
margin-bottom: 6px;
|
|
685
|
+
display: flex;
|
|
686
|
+
justify-content: space-between;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.cpub-series-progress-track {
|
|
690
|
+
height: 4px;
|
|
691
|
+
background: var(--surface3);
|
|
692
|
+
overflow: hidden;
|
|
693
|
+
border: var(--border-width-default) solid var(--border2);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.cpub-series-progress-fill {
|
|
697
|
+
height: 100%;
|
|
698
|
+
background: var(--accent);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.cpub-series-nav-btns {
|
|
702
|
+
display: grid;
|
|
703
|
+
grid-template-columns: 1fr 1fr;
|
|
704
|
+
gap: 8px;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.cpub-series-nav-btn {
|
|
708
|
+
background: var(--surface);
|
|
709
|
+
border: var(--border-width-default) solid var(--border);
|
|
710
|
+
padding: 12px 14px;
|
|
711
|
+
cursor: pointer;
|
|
712
|
+
text-decoration: none;
|
|
713
|
+
display: flex;
|
|
714
|
+
flex-direction: column;
|
|
715
|
+
gap: 4px;
|
|
716
|
+
color: inherit;
|
|
717
|
+
transition: background var(--transition-fast);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.cpub-series-nav-btn:hover { background: var(--surface2); }
|
|
721
|
+
.cpub-series-nav-btn.cpub-next { text-align: right; }
|
|
722
|
+
.cpub-series-nav-btn.cpub-disabled { opacity: 0.5; pointer-events: none; }
|
|
723
|
+
|
|
724
|
+
.cpub-series-nav-dir {
|
|
725
|
+
font-size: 10px;
|
|
726
|
+
font-family: var(--font-mono);
|
|
727
|
+
color: var(--text-faint);
|
|
728
|
+
letter-spacing: 0.08em;
|
|
729
|
+
text-transform: uppercase;
|
|
730
|
+
display: flex;
|
|
731
|
+
align-items: center;
|
|
732
|
+
gap: 4px;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.cpub-series-nav-btn.cpub-next .cpub-series-nav-dir { justify-content: flex-end; }
|
|
736
|
+
|
|
737
|
+
.cpub-series-nav-ep {
|
|
738
|
+
font-size: 10px;
|
|
739
|
+
font-family: var(--font-mono);
|
|
740
|
+
color: var(--accent);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.cpub-series-nav-post-title {
|
|
744
|
+
font-size: 12px;
|
|
745
|
+
font-weight: 600;
|
|
746
|
+
color: var(--text);
|
|
747
|
+
line-height: 1.35;
|
|
748
|
+
}
|
|
749
|
+
|
|
579
750
|
/* ── TAGS ROW ── */
|
|
580
751
|
.cpub-tags-row {
|
|
581
752
|
display: flex;
|
|
@@ -803,6 +974,8 @@ useJsonLd({
|
|
|
803
974
|
.cpub-engage-btn { padding: 8px 12px; min-height: 36px; }
|
|
804
975
|
.cpub-engage-sep { display: none; }
|
|
805
976
|
.cpub-tag-link { padding: 4px 10px; font-size: 11px; min-height: 28px; display: inline-flex; align-items: center; }
|
|
977
|
+
.cpub-series-nav-btns { grid-template-columns: 1fr; }
|
|
978
|
+
.cpub-series-nav-btn { padding: 12px; min-height: 44px; }
|
|
806
979
|
}
|
|
807
980
|
|
|
808
981
|
@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.0",
|
|
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
|
-
"@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
54
|
"@commonpub/editor": "0.7.9",
|
|
55
|
+
"@commonpub/learning": "0.5.0",
|
|
56
|
+
"@commonpub/config": "0.9.1",
|
|
57
|
+
"@commonpub/docs": "0.6.2",
|
|
58
|
+
"@commonpub/protocol": "0.9.8",
|
|
59
|
+
"@commonpub/schema": "0.9.6",
|
|
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,
|