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