@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,814 +1,36 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { ExplainerSectionEditor } from '@commonpub/explainer/vue';
|
|
2
|
+
/**
|
|
3
|
+
* Legacy edit route — redirects to /u/{username}/{type}/{slug}/edit.
|
|
4
|
+
* For "new" content, redirects using the current user's username.
|
|
5
|
+
*/
|
|
7
6
|
definePageMeta({ layout: false, middleware: 'auth' });
|
|
8
7
|
|
|
9
8
|
const route = useRoute();
|
|
10
9
|
const contentType = computed(() => route.params.type as string);
|
|
11
10
|
const slug = computed(() => route.params.slug as string);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
visibility: 'public',
|
|
27
|
-
coverImageUrl: '',
|
|
28
|
-
...(hubFromQuery ? { hubSlug: hubFromQuery } : {}),
|
|
29
|
-
});
|
|
30
|
-
const isDirty = ref(false);
|
|
31
|
-
const { extract: extractError } = useApiError();
|
|
32
|
-
const mode = ref<'write' | 'preview' | 'code'>('write');
|
|
33
|
-
const contentId = ref<string | null>(null);
|
|
34
|
-
|
|
35
|
-
// --- Block editor (articles, blogs, projects) ---
|
|
36
|
-
const blockEditor = useBlockEditor();
|
|
37
|
-
|
|
38
|
-
// --- Explainer document (explainers only) ---
|
|
39
|
-
const isExplainer = computed(() => contentType.value === 'explainer');
|
|
40
|
-
// The prop passed to ExplainerSectionEditor — set once on load, not updated on edits
|
|
41
|
-
const explainerDocInit = ref<ExplainerDocument | null>(null);
|
|
42
|
-
// The latest version from the editor — used for saving
|
|
43
|
-
const explainerDocLatest = ref<ExplainerDocument | null>(null);
|
|
44
|
-
|
|
45
|
-
function getContentForSave(): unknown {
|
|
46
|
-
if (isExplainer.value) {
|
|
47
|
-
return explainerDocLatest.value ?? explainerDocInit.value;
|
|
48
|
-
}
|
|
49
|
-
return blockEditor.toBlockTuples();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// --- Content save composable ---
|
|
53
|
-
const {
|
|
54
|
-
saving,
|
|
55
|
-
error,
|
|
56
|
-
autoSaveStatus,
|
|
57
|
-
silentSave,
|
|
58
|
-
handlePublish: doPublish,
|
|
59
|
-
buildSaveBody,
|
|
60
|
-
cancelAutoSave,
|
|
61
|
-
initAutoSave,
|
|
62
|
-
cleanup,
|
|
63
|
-
} = useContentSave({
|
|
64
|
-
contentType,
|
|
65
|
-
title,
|
|
66
|
-
metadata,
|
|
67
|
-
isNew,
|
|
68
|
-
contentId,
|
|
69
|
-
isDirty,
|
|
70
|
-
getBlockTuples: getContentForSave as () => BlockTuple[],
|
|
71
|
-
extractError,
|
|
72
|
-
onAfterSave: syncBOM,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// --- Publish validation ---
|
|
76
|
-
const { errors: publishErrors, showErrors: showPublishErrors, validate, dismiss: dismissPublishErrors } = usePublishValidation({
|
|
77
|
-
title,
|
|
78
|
-
metadata,
|
|
79
|
-
getBlockTuples: getContentForSave as () => BlockTuple[],
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
// --- Specialized editor component map ---
|
|
83
|
-
const editorMap: Record<string, Component> = {
|
|
84
|
-
article: resolveComponent('EditorsArticleEditor') as Component,
|
|
85
|
-
blog: resolveComponent('EditorsBlogEditor') as Component,
|
|
86
|
-
explainer: resolveComponent('EditorsExplainerEditor') as Component,
|
|
87
|
-
project: resolveComponent('EditorsProjectEditor') as Component,
|
|
88
|
-
};
|
|
89
|
-
const editorComponent = computed<Component | null>(() => editorMap[contentType.value] ?? null);
|
|
90
|
-
const hasSpecializedEditor = computed(() => editorComponent.value !== null);
|
|
91
|
-
|
|
92
|
-
// --- Load existing content ---
|
|
93
|
-
const requestHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
|
|
94
|
-
if (!isNew.value) {
|
|
95
|
-
const { data } = await useFetch(() => `/api/content/${slug.value}`, { headers: requestHeaders });
|
|
11
|
+
const { user } = useAuth();
|
|
12
|
+
|
|
13
|
+
if (slug.value === 'new') {
|
|
14
|
+
// Creating new content — redirect to new URL using current user
|
|
15
|
+
if (user.value?.username) {
|
|
16
|
+
await navigateTo(
|
|
17
|
+
`/u/${user.value.username}/${contentType.value}/new/edit${route.query.hub ? `?hub=${route.query.hub}` : ''}`,
|
|
18
|
+
{ redirectCode: 301, replace: true },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
// Editing existing content — look up author
|
|
23
|
+
const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
|
|
24
|
+
const { data } = await useFetch(() => `/api/content/${slug.value}`, { headers: reqHeaders });
|
|
96
25
|
if (data.value) {
|
|
97
|
-
const d = data.value as
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (isExplainer.value && isExplainerDocument(d.content)) {
|
|
101
|
-
// Load ExplainerDocument for explainers
|
|
102
|
-
const doc = d.content as unknown as ExplainerDocument;
|
|
103
|
-
// Sync: prefer row-level title if hero title is empty
|
|
104
|
-
if (!doc.hero.title && title.value) {
|
|
105
|
-
doc.hero.title = title.value;
|
|
106
|
-
} else if (doc.hero.title) {
|
|
107
|
-
title.value = doc.hero.title;
|
|
108
|
-
}
|
|
109
|
-
explainerDocInit.value = doc;
|
|
110
|
-
explainerDocLatest.value = JSON.parse(JSON.stringify(doc));
|
|
111
|
-
} else if (Array.isArray(d.content)) {
|
|
112
|
-
blockEditor.fromBlockTuples(d.content as [string, Record<string, unknown>][]);
|
|
113
|
-
}
|
|
114
|
-
metadata.value = {
|
|
115
|
-
description: (d.description as string) || '',
|
|
116
|
-
slug: (d.slug as string) || '',
|
|
117
|
-
tags: d.tags ? (d.tags as { name: string }[]).map((t) => t.name) : [],
|
|
118
|
-
visibility: (d.visibility as string) || 'public',
|
|
119
|
-
coverImageUrl: (d.coverImageUrl as string) || '',
|
|
120
|
-
seoDescription: (d.seoDescription as string) || '',
|
|
121
|
-
difficulty: (d.difficulty as string) || '',
|
|
122
|
-
buildTime: (d.buildTime as string) || '',
|
|
123
|
-
estimatedCost: (d.estimatedCost as string) || '',
|
|
124
|
-
estimatedMinutes: (d.estimatedMinutes as number) || undefined,
|
|
125
|
-
licenseType: (d.licenseType as string) || '',
|
|
126
|
-
series: (d.series as string) || '',
|
|
127
|
-
category: (d.category as string) || '',
|
|
128
|
-
subtitle: (d.subtitle as string) || '',
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// --- Auto-generate slug from title ---
|
|
134
|
-
const slugManuallyEdited = ref(false);
|
|
135
|
-
function slugify(text: string): string {
|
|
136
|
-
return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 128);
|
|
137
|
-
}
|
|
138
|
-
watch(title, (newTitle) => {
|
|
139
|
-
if (!slugManuallyEdited.value && isNew.value) {
|
|
140
|
-
metadata.value = { ...metadata.value, slug: slugify(newTitle) };
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// --- Dirty tracking + autosave ---
|
|
145
|
-
watch(() => blockEditor.blocks.value, () => { isDirty.value = true; }, { deep: true });
|
|
146
|
-
watch(explainerDocLatest, () => { isDirty.value = true; }, { deep: true });
|
|
147
|
-
initAutoSave([() => blockEditor.blocks.value, () => explainerDocLatest.value, title, metadata]);
|
|
148
|
-
|
|
149
|
-
// --- Explainer document events ---
|
|
150
|
-
function handleExplainerUpdate(doc: ExplainerDocument): void {
|
|
151
|
-
// Store latest for saving — DON'T update explainerDocInit (would cause editor re-render loop)
|
|
152
|
-
explainerDocLatest.value = doc;
|
|
153
|
-
// Sync title from hero
|
|
154
|
-
if (doc.hero.title) title.value = doc.hero.title;
|
|
155
|
-
// Sync metadata
|
|
156
|
-
if (doc.meta.description) metadata.value = { ...metadata.value, description: doc.meta.description };
|
|
157
|
-
if (doc.meta.difficulty) metadata.value = { ...metadata.value, difficulty: doc.meta.difficulty };
|
|
158
|
-
if (doc.meta.estimatedMinutes) metadata.value = { ...metadata.value, estimatedMinutes: doc.meta.estimatedMinutes };
|
|
159
|
-
if (doc.meta.tags?.length) metadata.value = { ...metadata.value, tags: doc.meta.tags };
|
|
160
|
-
if (doc.hero.coverImageUrl) metadata.value = { ...metadata.value, coverImageUrl: doc.hero.coverImageUrl };
|
|
161
|
-
isDirty.value = true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function handleExplainerSave(doc: ExplainerDocument): void {
|
|
165
|
-
handleExplainerUpdate(doc);
|
|
166
|
-
silentSave();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// --- Init new explainer ---
|
|
170
|
-
if (isNew.value && isExplainer.value) {
|
|
171
|
-
const emptyDoc = createEmptyDocument();
|
|
172
|
-
explainerDocInit.value = emptyDoc;
|
|
173
|
-
explainerDocLatest.value = JSON.parse(JSON.stringify(emptyDoc));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Sync starter form title to explainer doc hero
|
|
177
|
-
watch(title, (newTitle) => {
|
|
178
|
-
if (isExplainer.value && explainerDocInit.value && !explainerDocLatest.value?.hero.title) {
|
|
179
|
-
explainerDocInit.value.hero.title = newTitle;
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
function handleMetadataUpdate(newMetadata: Record<string, unknown>): void {
|
|
184
|
-
if (newMetadata.title !== undefined && typeof newMetadata.title === 'string') {
|
|
185
|
-
title.value = newMetadata.title;
|
|
186
|
-
delete newMetadata.title;
|
|
187
|
-
}
|
|
188
|
-
metadata.value = newMetadata;
|
|
189
|
-
isDirty.value = true;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// --- BOM sync ---
|
|
193
|
-
async function syncBOM(id: string): Promise<void> {
|
|
194
|
-
const blocks = blockEditor.toBlockTuples();
|
|
195
|
-
const productItems: Array<{ productId: string; quantity: number; notes?: string }> = [];
|
|
196
|
-
for (const [type, content] of blocks) {
|
|
197
|
-
if (type === 'partsList' && Array.isArray(content.parts)) {
|
|
198
|
-
for (const part of content.parts as Array<{ productId?: string; qty?: number; notes?: string }>) {
|
|
199
|
-
if (part.productId) {
|
|
200
|
-
productItems.push({ productId: part.productId, quantity: part.qty ?? 1, notes: part.notes });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (productItems.length > 0 || contentType.value === 'project') {
|
|
206
|
-
await $fetch(`/api/content/${id}/products-sync`, { method: 'POST', body: { items: productItems } }).catch(() => {});
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// --- Starter form submit ---
|
|
211
|
-
async function handleStarterSubmit(): Promise<void> {
|
|
212
|
-
if (!title.value.trim()) { error.value = 'Title is required'; return; }
|
|
213
|
-
starterSaving.value = true;
|
|
214
|
-
error.value = '';
|
|
215
|
-
try {
|
|
216
|
-
const body = buildSaveBody();
|
|
217
|
-
const result = await $fetch<{ id: string; slug: string }>('/api/content', { method: 'POST', body });
|
|
218
|
-
contentId.value = result.id;
|
|
219
|
-
isNew.value = false;
|
|
220
|
-
isDirty.value = false;
|
|
221
|
-
showStarterForm.value = false;
|
|
222
|
-
history.replaceState({}, '', `/${contentType.value}/${result.slug}/edit`);
|
|
223
|
-
} catch (err: unknown) {
|
|
224
|
-
error.value = extractError(err);
|
|
225
|
-
} finally {
|
|
226
|
-
starterSaving.value = false;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// --- Publish with validation ---
|
|
231
|
-
async function handlePublish(): Promise<void> {
|
|
232
|
-
await doPublish(validate);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// --- Preview mode ---
|
|
236
|
-
function enterPreview(): void {
|
|
237
|
-
mode.value = 'preview';
|
|
238
|
-
if (isDirty.value && title.value && !saving.value && !isNew.value && contentId.value) {
|
|
239
|
-
cancelAutoSave();
|
|
240
|
-
silentSave();
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// --- Keyboard shortcuts ---
|
|
245
|
-
function onKeydown(event: KeyboardEvent): void {
|
|
246
|
-
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
|
|
247
|
-
event.preventDefault();
|
|
248
|
-
cancelAutoSave();
|
|
249
|
-
silentSave();
|
|
250
|
-
}
|
|
251
|
-
if (event.key === 'Escape' && mode.value === 'preview' && contentType.value === 'explainer') {
|
|
252
|
-
mode.value = 'write';
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
onMounted(() => { document.addEventListener('keydown', onKeydown); });
|
|
257
|
-
onUnmounted(() => { document.removeEventListener('keydown', onKeydown); cleanup(); });
|
|
258
|
-
|
|
259
|
-
// --- Warn before unload ---
|
|
260
|
-
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
|
261
|
-
if (isDirty.value) event.preventDefault();
|
|
262
|
-
}
|
|
263
|
-
if (import.meta.client) {
|
|
264
|
-
onMounted(() => { window.addEventListener('beforeunload', onBeforeUnload); });
|
|
265
|
-
onUnmounted(() => { window.removeEventListener('beforeunload', onBeforeUnload); });
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// --- Markdown import ---
|
|
269
|
-
const showImportDialog = ref(false);
|
|
270
|
-
const { importing, importMarkdown } = useMarkdownImport(blockEditor);
|
|
271
|
-
|
|
272
|
-
async function handleMarkdownImport(md: string, importMode: 'append' | 'replace'): Promise<void> {
|
|
273
|
-
await importMarkdown(md, importMode);
|
|
274
|
-
isDirty.value = true;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// --- URL import ---
|
|
278
|
-
const showUrlImport = ref(false);
|
|
279
|
-
const urlImporting = ref(false);
|
|
280
|
-
|
|
281
|
-
interface ImportedContent {
|
|
282
|
-
title: string;
|
|
283
|
-
description: string;
|
|
284
|
-
coverImageUrl: string | null;
|
|
285
|
-
content: BlockTuple[];
|
|
286
|
-
tags: string[];
|
|
287
|
-
partial: boolean;
|
|
288
|
-
meta: Record<string, unknown>;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function handleUrlImport(result: ImportedContent): Promise<void> {
|
|
292
|
-
urlImporting.value = true;
|
|
293
|
-
try {
|
|
294
|
-
// Populate title if empty
|
|
295
|
-
if (!title.value && result.title) {
|
|
296
|
-
title.value = result.title.slice(0, 255);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Populate metadata — sanitize values to match schema constraints
|
|
300
|
-
if (result.description && !metadata.value.description) {
|
|
301
|
-
metadata.value = { ...metadata.value, description: result.description.slice(0, 2000) };
|
|
302
|
-
}
|
|
303
|
-
if (result.coverImageUrl && !metadata.value.coverImageUrl) {
|
|
304
|
-
try {
|
|
305
|
-
new URL(result.coverImageUrl);
|
|
306
|
-
metadata.value = { ...metadata.value, coverImageUrl: result.coverImageUrl };
|
|
307
|
-
} catch { /* skip invalid URL */ }
|
|
26
|
+
const d = data.value as { author?: { username: string }; type: string; slug: string };
|
|
27
|
+
if (d.author?.username) {
|
|
28
|
+
await navigateTo(`/u/${d.author.username}/${d.type}/${d.slug}/edit`, { redirectCode: 301, replace: true });
|
|
308
29
|
}
|
|
309
|
-
if (result.tags.length && (!Array.isArray(metadata.value.tags) || !metadata.value.tags.length)) {
|
|
310
|
-
const safeTags = result.tags
|
|
311
|
-
.filter(t => typeof t === 'string' && t.length > 0)
|
|
312
|
-
.map(t => t.slice(0, 64))
|
|
313
|
-
.slice(0, 20);
|
|
314
|
-
metadata.value = { ...metadata.value, tags: safeTags };
|
|
315
|
-
}
|
|
316
|
-
const VALID_DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
|
|
317
|
-
if (result.meta.difficulty && !metadata.value.difficulty) {
|
|
318
|
-
const diff = String(result.meta.difficulty).toLowerCase();
|
|
319
|
-
if (VALID_DIFFICULTIES.includes(diff)) {
|
|
320
|
-
metadata.value = { ...metadata.value, difficulty: diff };
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Insert blocks
|
|
325
|
-
blockEditor.clearBlocks();
|
|
326
|
-
let insertAt = 0;
|
|
327
|
-
for (const [type, content] of result.content) {
|
|
328
|
-
blockEditor.addBlock(type, content as Record<string, unknown>, insertAt);
|
|
329
|
-
insertAt++;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
isDirty.value = true;
|
|
333
|
-
} finally {
|
|
334
|
-
urlImporting.value = false;
|
|
335
30
|
}
|
|
336
31
|
}
|
|
337
32
|
</script>
|
|
338
33
|
|
|
339
34
|
<template>
|
|
340
|
-
|
|
341
|
-
<ContentStarterForm
|
|
342
|
-
v-if="showStarterForm"
|
|
343
|
-
:content-type="contentType"
|
|
344
|
-
:title="title"
|
|
345
|
-
:metadata="metadata"
|
|
346
|
-
:saving="starterSaving"
|
|
347
|
-
:error="error"
|
|
348
|
-
@update:title="title = $event"
|
|
349
|
-
@update:metadata="metadata = $event"
|
|
350
|
-
@submit="handleStarterSubmit"
|
|
351
|
-
/>
|
|
352
|
-
|
|
353
|
-
<!-- Main editor -->
|
|
354
|
-
<div v-else class="cpub-editor-layout">
|
|
355
|
-
<PublishErrorsModal :errors="publishErrors" :show="showPublishErrors" @dismiss="dismissPublishErrors" />
|
|
356
|
-
<EditorsMarkdownImportDialog :show="showImportDialog" @close="showImportDialog = false" @import="handleMarkdownImport" />
|
|
357
|
-
<ImportUrlModal :show="showUrlImport" @close="showUrlImport = false" @imported="handleUrlImport" />
|
|
358
|
-
<!-- Top bar -->
|
|
359
|
-
<header class="cpub-editor-topbar">
|
|
360
|
-
<NuxtLink to="/" class="cpub-editor-logo" aria-label="Home">
|
|
361
|
-
<span class="cpub-logo-accent">[</span>cpub<span class="cpub-logo-accent">]</span>
|
|
362
|
-
</NuxtLink>
|
|
363
|
-
<button class="cpub-editor-back" aria-label="Go back" @click="$router.back()">
|
|
364
|
-
<i class="fa-solid fa-arrow-left"></i>
|
|
365
|
-
</button>
|
|
366
|
-
<div class="cpub-topbar-divider" aria-hidden="true" />
|
|
367
|
-
<div class="cpub-topbar-title-wrap">
|
|
368
|
-
<input
|
|
369
|
-
v-model="title"
|
|
370
|
-
type="text"
|
|
371
|
-
class="cpub-topbar-title-input"
|
|
372
|
-
:placeholder="`Untitled ${contentType}...`"
|
|
373
|
-
aria-label="Content title"
|
|
374
|
-
/>
|
|
375
|
-
<span v-if="isDirty" class="cpub-unsaved-dot" title="Unsaved changes" />
|
|
376
|
-
<span v-if="autoSaveStatus === 'saving'" class="cpub-autosave-status">
|
|
377
|
-
<i class="fa-solid fa-circle-notch fa-spin"></i> Saving...
|
|
378
|
-
</span>
|
|
379
|
-
<span v-else-if="autoSaveStatus === 'saved'" class="cpub-autosave-status cpub-autosave-status--saved">
|
|
380
|
-
<i class="fa-solid fa-check"></i> Saved
|
|
381
|
-
</span>
|
|
382
|
-
<span v-else-if="autoSaveStatus === 'error'" class="cpub-autosave-status cpub-autosave-status--error">
|
|
383
|
-
<i class="fa-solid fa-exclamation-triangle"></i> Save failed
|
|
384
|
-
</span>
|
|
385
|
-
</div>
|
|
386
|
-
<div class="cpub-mode-tabs">
|
|
387
|
-
<button :class="['cpub-mode-tab', { active: mode === 'write' }]" @click="mode = 'write'">Write</button>
|
|
388
|
-
<button :class="['cpub-mode-tab', { active: mode === 'preview' }]" @click="enterPreview">Preview</button>
|
|
389
|
-
<button v-if="!isExplainer" :class="['cpub-mode-tab', { active: mode === 'code' }]" @click="mode = 'code'">Code</button>
|
|
390
|
-
</div>
|
|
391
|
-
<div class="cpub-topbar-spacer" />
|
|
392
|
-
<div class="cpub-topbar-actions">
|
|
393
|
-
<button class="cpub-topbar-btn cpub-topbar-btn-import" :disabled="urlImporting" @click="showUrlImport = true" title="Import from URL">
|
|
394
|
-
<i class="fa-solid fa-link"></i> <span class="cpub-import-label">Import URL</span>
|
|
395
|
-
</button>
|
|
396
|
-
<button class="cpub-topbar-btn cpub-topbar-btn-import" :disabled="importing" @click="showImportDialog = true" title="Import Markdown">
|
|
397
|
-
<i class="fa-brands fa-markdown"></i> <span class="cpub-import-label">Markdown</span>
|
|
398
|
-
</button>
|
|
399
|
-
<button class="cpub-topbar-btn" :disabled="saving || !title" @click="silentSave">
|
|
400
|
-
{{ saving ? 'Saving...' : 'Save Draft' }}
|
|
401
|
-
</button>
|
|
402
|
-
<button class="cpub-topbar-btn cpub-topbar-btn-primary" :disabled="saving || !title" @click="handlePublish">
|
|
403
|
-
Publish
|
|
404
|
-
</button>
|
|
405
|
-
</div>
|
|
406
|
-
</header>
|
|
407
|
-
|
|
408
|
-
<div v-if="error" class="cpub-editor-error" role="alert">{{ error }}</div>
|
|
409
|
-
|
|
410
|
-
<!-- Explainer: section-oriented editor -->
|
|
411
|
-
<template v-if="mode === 'write' && isExplainer && explainerDocInit">
|
|
412
|
-
<ExplainerSectionEditor
|
|
413
|
-
:document="explainerDocInit"
|
|
414
|
-
@update:document="handleExplainerUpdate"
|
|
415
|
-
@save="handleExplainerSave"
|
|
416
|
-
/>
|
|
417
|
-
</template>
|
|
418
|
-
|
|
419
|
-
<!-- Write mode with specialized editor (articles, blogs, projects) -->
|
|
420
|
-
<template v-else-if="mode === 'write' && hasSpecializedEditor && !isExplainer">
|
|
421
|
-
<component
|
|
422
|
-
:is="editorComponent"
|
|
423
|
-
:block-editor="blockEditor"
|
|
424
|
-
:metadata="{ ...metadata, title: title }"
|
|
425
|
-
@update:metadata="handleMetadataUpdate"
|
|
426
|
-
/>
|
|
427
|
-
</template>
|
|
428
|
-
|
|
429
|
-
<!-- Write mode — fallback generic editor -->
|
|
430
|
-
<div v-else-if="mode === 'write'" class="cpub-editor-shell">
|
|
431
|
-
<div class="cpub-editor-canvas">
|
|
432
|
-
<EditorsBlockCanvas :block-editor="blockEditor" :block-types="[]" />
|
|
433
|
-
</div>
|
|
434
|
-
</div>
|
|
435
|
-
|
|
436
|
-
<!-- Preview mode: Generic (explainer also matches here as a hidden placeholder; actual preview is in the Teleport overlay below) -->
|
|
437
|
-
<div v-else-if="mode === 'preview'" class="cpub-editor-shell" :class="{ 'cpub-hidden': contentType === 'explainer' }">
|
|
438
|
-
<div class="cpub-preview-canvas">
|
|
439
|
-
<h1 class="cpub-preview-title">{{ title || 'Untitled' }}</h1>
|
|
440
|
-
<p v-if="metadata.description" class="cpub-preview-desc">{{ metadata.description }}</p>
|
|
441
|
-
<div class="cpub-preview-blocks">
|
|
442
|
-
<BlocksBlockContentRenderer :blocks="blockEditor.toBlockTuples()" />
|
|
443
|
-
</div>
|
|
444
|
-
</div>
|
|
445
|
-
</div>
|
|
446
|
-
|
|
447
|
-
<!-- Code mode -->
|
|
448
|
-
<div v-else class="cpub-editor-shell">
|
|
449
|
-
<div class="cpub-code-canvas">
|
|
450
|
-
<pre class="cpub-code-view">{{ JSON.stringify(blockEditor.toBlockTuples(), null, 2) }}</pre>
|
|
451
|
-
</div>
|
|
452
|
-
</div>
|
|
453
|
-
|
|
454
|
-
<!-- Explainer preview: full-screen overlay (outside v-if chain, uses Teleport) -->
|
|
455
|
-
<Teleport to="body">
|
|
456
|
-
<div v-if="mode === 'preview' && contentType === 'explainer'" class="cpub-explainer-preview-overlay">
|
|
457
|
-
<button class="cpub-preview-close-btn" @click="mode = 'write'" aria-label="Close preview">
|
|
458
|
-
<i class="fa-solid fa-xmark"></i>
|
|
459
|
-
<span>Back to Editor</span>
|
|
460
|
-
</button>
|
|
461
|
-
<ViewsExplainerView
|
|
462
|
-
:content="{
|
|
463
|
-
id: contentId || 'preview',
|
|
464
|
-
type: 'explainer',
|
|
465
|
-
title: title || 'Untitled',
|
|
466
|
-
slug: (metadata.slug as string) || 'preview',
|
|
467
|
-
subtitle: null,
|
|
468
|
-
description: (metadata.description as string) || null,
|
|
469
|
-
content: isExplainer ? (explainerDocLatest ?? explainerDocInit) : blockEditor.toBlockTuples(),
|
|
470
|
-
coverImageUrl: (metadata.coverImageUrl as string) || null,
|
|
471
|
-
category: null,
|
|
472
|
-
difficulty: (metadata.difficulty as string) || null,
|
|
473
|
-
buildTime: null,
|
|
474
|
-
estimatedCost: null,
|
|
475
|
-
status: 'draft',
|
|
476
|
-
visibility: (metadata.visibility as string) || 'public',
|
|
477
|
-
isFeatured: false,
|
|
478
|
-
seoDescription: null,
|
|
479
|
-
previewToken: null,
|
|
480
|
-
parts: null,
|
|
481
|
-
sections: null,
|
|
482
|
-
viewCount: 0,
|
|
483
|
-
likeCount: 0,
|
|
484
|
-
commentCount: 0,
|
|
485
|
-
forkCount: 0,
|
|
486
|
-
publishedAt: null,
|
|
487
|
-
createdAt: new Date().toISOString(),
|
|
488
|
-
updatedAt: new Date().toISOString(),
|
|
489
|
-
licenseType: null,
|
|
490
|
-
series: null,
|
|
491
|
-
estimatedMinutes: null,
|
|
492
|
-
tags: [],
|
|
493
|
-
author: { id: '', username: '', displayName: null, avatarUrl: null },
|
|
494
|
-
}"
|
|
495
|
-
/>
|
|
496
|
-
</div>
|
|
497
|
-
</Teleport>
|
|
498
|
-
</div>
|
|
35
|
+
<div class="cpub-loading" aria-live="polite">Redirecting...</div>
|
|
499
36
|
</template>
|
|
500
|
-
|
|
501
|
-
<style scoped>
|
|
502
|
-
.cpub-editor-layout {
|
|
503
|
-
display: flex;
|
|
504
|
-
flex-direction: column;
|
|
505
|
-
height: 100vh;
|
|
506
|
-
overflow: hidden;
|
|
507
|
-
background: var(--bg);
|
|
508
|
-
color: var(--text);
|
|
509
|
-
font-family: var(--font-sans);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
.cpub-editor-topbar {
|
|
513
|
-
height: 48px;
|
|
514
|
-
background: var(--surface);
|
|
515
|
-
border-bottom: var(--border-width-default) solid var(--border);
|
|
516
|
-
display: flex;
|
|
517
|
-
align-items: center;
|
|
518
|
-
padding: 0 16px;
|
|
519
|
-
gap: 0;
|
|
520
|
-
flex-shrink: 0;
|
|
521
|
-
z-index: 100;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
.cpub-editor-logo {
|
|
525
|
-
font-family: var(--font-mono);
|
|
526
|
-
font-size: 12px;
|
|
527
|
-
font-weight: 700;
|
|
528
|
-
color: var(--text-dim);
|
|
529
|
-
letter-spacing: 0.06em;
|
|
530
|
-
text-decoration: none;
|
|
531
|
-
flex-shrink: 0;
|
|
532
|
-
}
|
|
533
|
-
.cpub-logo-accent { color: var(--accent); }
|
|
534
|
-
|
|
535
|
-
.cpub-editor-back {
|
|
536
|
-
width: 30px; height: 30px;
|
|
537
|
-
background: none;
|
|
538
|
-
border: var(--border-width-default) solid transparent;
|
|
539
|
-
color: var(--text-dim);
|
|
540
|
-
cursor: pointer;
|
|
541
|
-
display: flex; align-items: center; justify-content: center;
|
|
542
|
-
font-size: 12px;
|
|
543
|
-
margin-left: 6px;
|
|
544
|
-
flex-shrink: 0;
|
|
545
|
-
}
|
|
546
|
-
.cpub-editor-back:hover { background: var(--surface2); border-color: var(--border2); color: var(--text); }
|
|
547
|
-
|
|
548
|
-
.cpub-topbar-divider {
|
|
549
|
-
width: 2px; height: 22px;
|
|
550
|
-
background: var(--border);
|
|
551
|
-
margin: 0 12px;
|
|
552
|
-
flex-shrink: 0;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
.cpub-topbar-title-wrap {
|
|
556
|
-
display: flex;
|
|
557
|
-
align-items: center;
|
|
558
|
-
gap: 8px;
|
|
559
|
-
flex: 1;
|
|
560
|
-
min-width: 0;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
.cpub-topbar-title-input {
|
|
564
|
-
font-size: 13px;
|
|
565
|
-
font-weight: 500;
|
|
566
|
-
color: var(--text);
|
|
567
|
-
background: none;
|
|
568
|
-
border: var(--border-width-default) solid transparent;
|
|
569
|
-
padding: 4px 8px;
|
|
570
|
-
cursor: text;
|
|
571
|
-
white-space: nowrap;
|
|
572
|
-
overflow: hidden;
|
|
573
|
-
text-overflow: ellipsis;
|
|
574
|
-
max-width: 380px;
|
|
575
|
-
outline: none;
|
|
576
|
-
font-family: var(--font-sans, system-ui);
|
|
577
|
-
}
|
|
578
|
-
.cpub-topbar-title-input:hover { border-color: var(--border2); background: var(--surface2); }
|
|
579
|
-
.cpub-topbar-title-input:focus { border-color: var(--accent); background: var(--surface2); }
|
|
580
|
-
|
|
581
|
-
.cpub-unsaved-dot {
|
|
582
|
-
width: 8px; height: 8px;
|
|
583
|
-
border-radius: 50%;
|
|
584
|
-
background: var(--yellow);
|
|
585
|
-
flex-shrink: 0;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
.cpub-autosave-status {
|
|
589
|
-
font-family: var(--font-mono);
|
|
590
|
-
font-size: 10px;
|
|
591
|
-
color: var(--text-faint);
|
|
592
|
-
display: flex;
|
|
593
|
-
align-items: center;
|
|
594
|
-
gap: 4px;
|
|
595
|
-
flex-shrink: 0;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
.cpub-autosave-status--saved {
|
|
599
|
-
color: var(--green);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
.cpub-autosave-status--error {
|
|
603
|
-
color: var(--red);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
.cpub-mode-tabs {
|
|
607
|
-
display: flex;
|
|
608
|
-
background: var(--surface2);
|
|
609
|
-
border: var(--border-width-default) solid var(--border);
|
|
610
|
-
padding: 2px;
|
|
611
|
-
flex-shrink: 0;
|
|
612
|
-
margin: 0 10px;
|
|
613
|
-
}
|
|
614
|
-
.cpub-mode-tab {
|
|
615
|
-
font-family: var(--font-mono);
|
|
616
|
-
font-size: 10px;
|
|
617
|
-
font-weight: 600;
|
|
618
|
-
letter-spacing: 0.06em;
|
|
619
|
-
text-transform: uppercase;
|
|
620
|
-
padding: 5px 14px;
|
|
621
|
-
border: none;
|
|
622
|
-
background: none;
|
|
623
|
-
color: var(--text-dim);
|
|
624
|
-
cursor: pointer;
|
|
625
|
-
}
|
|
626
|
-
.cpub-mode-tab.active {
|
|
627
|
-
background: var(--surface);
|
|
628
|
-
color: var(--text);
|
|
629
|
-
box-shadow: var(--shadow-sm);
|
|
630
|
-
}
|
|
631
|
-
.cpub-mode-tab:hover:not(.active) { color: var(--text); }
|
|
632
|
-
|
|
633
|
-
.cpub-topbar-spacer { flex: 1; }
|
|
634
|
-
|
|
635
|
-
.cpub-topbar-actions {
|
|
636
|
-
display: flex;
|
|
637
|
-
align-items: center;
|
|
638
|
-
gap: 8px;
|
|
639
|
-
flex-shrink: 0;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
.cpub-topbar-btn {
|
|
643
|
-
font-family: var(--font-sans, system-ui);
|
|
644
|
-
font-size: 12px;
|
|
645
|
-
padding: 6px 14px;
|
|
646
|
-
border: var(--border-width-default) solid var(--border);
|
|
647
|
-
background: var(--surface);
|
|
648
|
-
color: var(--text);
|
|
649
|
-
cursor: pointer;
|
|
650
|
-
}
|
|
651
|
-
.cpub-topbar-btn:hover { background: var(--surface2); }
|
|
652
|
-
.cpub-topbar-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
653
|
-
.cpub-topbar-btn-primary {
|
|
654
|
-
background: var(--accent);
|
|
655
|
-
color: var(--color-text-inverse);
|
|
656
|
-
font-weight: 600;
|
|
657
|
-
box-shadow: var(--shadow-md);
|
|
658
|
-
}
|
|
659
|
-
.cpub-topbar-btn-primary:hover { box-shadow: var(--shadow-sm); }
|
|
660
|
-
.cpub-topbar-btn-import { border-color: var(--teal); color: var(--teal); }
|
|
661
|
-
.cpub-topbar-btn-import:hover { background: var(--teal-bg, var(--surface2)); }
|
|
662
|
-
|
|
663
|
-
.cpub-editor-error {
|
|
664
|
-
padding: 10px 16px;
|
|
665
|
-
background: var(--red-bg);
|
|
666
|
-
color: var(--red);
|
|
667
|
-
border-bottom: var(--border-width-default) solid var(--red);
|
|
668
|
-
font-size: 12px;
|
|
669
|
-
font-family: var(--font-mono);
|
|
670
|
-
display: flex;
|
|
671
|
-
align-items: center;
|
|
672
|
-
gap: 8px;
|
|
673
|
-
z-index: 99;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
.cpub-editor-shell {
|
|
677
|
-
display: flex;
|
|
678
|
-
flex: 1;
|
|
679
|
-
overflow: hidden;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
.cpub-editor-canvas {
|
|
683
|
-
flex: 1;
|
|
684
|
-
overflow-y: auto;
|
|
685
|
-
padding: 24px;
|
|
686
|
-
background: var(--bg);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
.cpub-preview-canvas {
|
|
690
|
-
flex: 1;
|
|
691
|
-
overflow-y: auto;
|
|
692
|
-
padding: 48px;
|
|
693
|
-
max-width: 740px;
|
|
694
|
-
margin: 0 auto;
|
|
695
|
-
}
|
|
696
|
-
.cpub-preview-title {
|
|
697
|
-
font-size: 28px;
|
|
698
|
-
font-weight: 700;
|
|
699
|
-
margin-bottom: 12px;
|
|
700
|
-
line-height: 1.25;
|
|
701
|
-
}
|
|
702
|
-
.cpub-preview-desc {
|
|
703
|
-
font-size: 15px;
|
|
704
|
-
color: var(--text-dim);
|
|
705
|
-
margin-bottom: 32px;
|
|
706
|
-
}
|
|
707
|
-
.cpub-preview-blocks {
|
|
708
|
-
display: flex;
|
|
709
|
-
flex-direction: column;
|
|
710
|
-
gap: 16px;
|
|
711
|
-
}
|
|
712
|
-
.cpub-preview-block {
|
|
713
|
-
font-size: 15px;
|
|
714
|
-
line-height: 1.75;
|
|
715
|
-
}
|
|
716
|
-
.cpub-preview-code {
|
|
717
|
-
background: var(--text);
|
|
718
|
-
color: var(--surface);
|
|
719
|
-
padding: 16px;
|
|
720
|
-
font-family: var(--font-mono);
|
|
721
|
-
font-size: 13px;
|
|
722
|
-
overflow-x: auto;
|
|
723
|
-
margin: 0;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
.cpub-code-canvas {
|
|
727
|
-
flex: 1;
|
|
728
|
-
overflow: auto;
|
|
729
|
-
background: var(--text);
|
|
730
|
-
padding: 16px;
|
|
731
|
-
}
|
|
732
|
-
.cpub-code-view {
|
|
733
|
-
color: var(--border2);
|
|
734
|
-
font-family: var(--font-mono);
|
|
735
|
-
font-size: 12px;
|
|
736
|
-
white-space: pre-wrap;
|
|
737
|
-
margin: 0;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
.cpub-hidden {
|
|
741
|
-
display: none;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/* ── RESPONSIVE ── */
|
|
745
|
-
@media (max-width: 768px) {
|
|
746
|
-
.cpub-editor-topbar { padding: 0 10px; gap: 0; }
|
|
747
|
-
.cpub-editor-logo { display: none; }
|
|
748
|
-
.cpub-topbar-divider { display: none; }
|
|
749
|
-
.cpub-editor-back { margin-left: 0; }
|
|
750
|
-
.cpub-topbar-title-input { max-width: none; font-size: 12px; padding: 3px 6px; }
|
|
751
|
-
.cpub-autosave-status { display: none; }
|
|
752
|
-
.cpub-mode-tabs { margin: 0 6px; padding: 1px; }
|
|
753
|
-
.cpub-mode-tab { padding: 4px 10px; font-size: 10px; }
|
|
754
|
-
.cpub-topbar-spacer { display: none; }
|
|
755
|
-
.cpub-topbar-actions { gap: 4px; }
|
|
756
|
-
.cpub-topbar-btn { font-size: 11px; padding: 8px 10px; min-height: 36px; }
|
|
757
|
-
.cpub-import-label { display: none; }
|
|
758
|
-
.cpub-editor-canvas { padding: 12px; }
|
|
759
|
-
.cpub-preview-canvas { padding: 16px; }
|
|
760
|
-
.cpub-preview-title { font-size: 22px; }
|
|
761
|
-
.cpub-code-canvas { padding: 10px; }
|
|
762
|
-
.cpub-code-view { font-size: 11px; }
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
@media (max-width: 480px) {
|
|
766
|
-
.cpub-mode-tabs { margin: 0 4px; }
|
|
767
|
-
.cpub-mode-tab { padding: 4px 8px; font-size: 9px; }
|
|
768
|
-
.cpub-topbar-btn { padding: 6px 8px; font-size: 10px; min-height: 34px; }
|
|
769
|
-
.cpub-editor-back { width: 34px; height: 34px; }
|
|
770
|
-
.cpub-preview-title { font-size: 18px; }
|
|
771
|
-
}
|
|
772
|
-
</style>
|
|
773
|
-
|
|
774
|
-
<style>
|
|
775
|
-
/* Unscoped so Teleport overlay works */
|
|
776
|
-
.cpub-explainer-preview-overlay {
|
|
777
|
-
position: fixed;
|
|
778
|
-
inset: 0;
|
|
779
|
-
z-index: 9999;
|
|
780
|
-
background: var(--bg);
|
|
781
|
-
overflow-y: auto;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
.cpub-preview-close-btn {
|
|
785
|
-
position: fixed;
|
|
786
|
-
top: 10px;
|
|
787
|
-
right: 16px;
|
|
788
|
-
z-index: 10001;
|
|
789
|
-
display: flex;
|
|
790
|
-
align-items: center;
|
|
791
|
-
gap: 6px;
|
|
792
|
-
padding: 6px 14px;
|
|
793
|
-
background: var(--surface);
|
|
794
|
-
border: var(--border-width-default) solid var(--border);
|
|
795
|
-
color: var(--text);
|
|
796
|
-
font-family: var(--font-mono);
|
|
797
|
-
font-size: 11px;
|
|
798
|
-
font-weight: 600;
|
|
799
|
-
letter-spacing: 0.04em;
|
|
800
|
-
cursor: pointer;
|
|
801
|
-
box-shadow: var(--shadow-md);
|
|
802
|
-
transition: box-shadow 0.1s, transform 0.1s;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
.cpub-preview-close-btn:hover {
|
|
806
|
-
box-shadow: var(--shadow-sm);
|
|
807
|
-
transform: translate(1px, 1px);
|
|
808
|
-
background: var(--surface2);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
.cpub-preview-close-btn i {
|
|
812
|
-
font-size: 12px;
|
|
813
|
-
}
|
|
814
|
-
</style>
|