@commonpub/layer 0.4.12 → 0.5.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@commonpub/docs": "0.5.2",
|
|
54
|
-
"@commonpub/config": "0.8.0",
|
|
55
|
-
"@commonpub/explainer": "0.5.3",
|
|
56
53
|
"@commonpub/auth": "0.5.0",
|
|
54
|
+
"@commonpub/config": "0.8.0",
|
|
55
|
+
"@commonpub/docs": "0.6.0",
|
|
57
56
|
"@commonpub/editor": "0.5.0",
|
|
57
|
+
"@commonpub/explainer": "0.6.0",
|
|
58
58
|
"@commonpub/learning": "0.5.0",
|
|
59
|
-
"@commonpub/
|
|
60
|
-
"@commonpub/
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
59
|
+
"@commonpub/protocol": "0.9.6",
|
|
60
|
+
"@commonpub/schema": "0.8.15",
|
|
61
|
+
"@commonpub/server": "2.23.1",
|
|
62
|
+
"@commonpub/ui": "0.8.4"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Component } from 'vue';
|
|
3
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';
|
|
4
7
|
definePageMeta({ layout: false, middleware: 'auth' });
|
|
5
8
|
|
|
6
9
|
const route = useRoute();
|
|
@@ -29,9 +32,23 @@ const { extract: extractError } = useApiError();
|
|
|
29
32
|
const mode = ref<'write' | 'preview' | 'code'>('write');
|
|
30
33
|
const contentId = ref<string | null>(null);
|
|
31
34
|
|
|
32
|
-
// --- Block editor ---
|
|
35
|
+
// --- Block editor (articles, blogs, projects) ---
|
|
33
36
|
const blockEditor = useBlockEditor();
|
|
34
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
|
+
|
|
35
52
|
// --- Content save composable ---
|
|
36
53
|
const {
|
|
37
54
|
saving,
|
|
@@ -50,7 +67,7 @@ const {
|
|
|
50
67
|
isNew,
|
|
51
68
|
contentId,
|
|
52
69
|
isDirty,
|
|
53
|
-
getBlockTuples: () =>
|
|
70
|
+
getBlockTuples: getContentForSave as () => BlockTuple[],
|
|
54
71
|
extractError,
|
|
55
72
|
onAfterSave: syncBOM,
|
|
56
73
|
});
|
|
@@ -59,7 +76,7 @@ const {
|
|
|
59
76
|
const { errors: publishErrors, showErrors: showPublishErrors, validate, dismiss: dismissPublishErrors } = usePublishValidation({
|
|
60
77
|
title,
|
|
61
78
|
metadata,
|
|
62
|
-
getBlockTuples: () =>
|
|
79
|
+
getBlockTuples: getContentForSave as () => BlockTuple[],
|
|
63
80
|
});
|
|
64
81
|
|
|
65
82
|
// --- Specialized editor component map ---
|
|
@@ -80,7 +97,18 @@ if (!isNew.value) {
|
|
|
80
97
|
const d = data.value as Record<string, unknown>;
|
|
81
98
|
contentId.value = d.id as string;
|
|
82
99
|
title.value = d.title as string;
|
|
83
|
-
if (
|
|
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)) {
|
|
84
112
|
blockEditor.fromBlockTuples(d.content as [string, Record<string, unknown>][]);
|
|
85
113
|
}
|
|
86
114
|
metadata.value = {
|
|
@@ -115,7 +143,42 @@ watch(title, (newTitle) => {
|
|
|
115
143
|
|
|
116
144
|
// --- Dirty tracking + autosave ---
|
|
117
145
|
watch(() => blockEditor.blocks.value, () => { isDirty.value = true; }, { deep: true });
|
|
118
|
-
|
|
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
|
+
});
|
|
119
182
|
|
|
120
183
|
function handleMetadataUpdate(newMetadata: Record<string, unknown>): void {
|
|
121
184
|
if (newMetadata.title !== undefined && typeof newMetadata.title === 'string') {
|
|
@@ -344,8 +407,17 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
|
|
|
344
407
|
|
|
345
408
|
<div v-if="error" class="cpub-editor-error" role="alert">{{ error }}</div>
|
|
346
409
|
|
|
347
|
-
<!--
|
|
348
|
-
<template v-if="mode === 'write' &&
|
|
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">
|
|
349
421
|
<component
|
|
350
422
|
:is="editorComponent"
|
|
351
423
|
:block-editor="blockEditor"
|
|
@@ -35,6 +35,10 @@ const socialLinks = ref({
|
|
|
35
35
|
github: '',
|
|
36
36
|
twitter: '',
|
|
37
37
|
linkedin: '',
|
|
38
|
+
youtube: '',
|
|
39
|
+
instagram: '',
|
|
40
|
+
mastodon: '',
|
|
41
|
+
discord: '',
|
|
38
42
|
});
|
|
39
43
|
const pronouns = ref('');
|
|
40
44
|
const experience = ref<Array<{ title: string; company: string; startDate: string; endDate: string; description: string }>>([]);
|
|
@@ -79,9 +83,14 @@ if (profile.value) {
|
|
|
79
83
|
}
|
|
80
84
|
pronouns.value = p.pronouns || '';
|
|
81
85
|
if (p.socialLinks) {
|
|
82
|
-
|
|
83
|
-
socialLinks.value.
|
|
84
|
-
socialLinks.value.
|
|
86
|
+
const sl = p.socialLinks as Record<string, string | undefined>;
|
|
87
|
+
socialLinks.value.github = sl.github || '';
|
|
88
|
+
socialLinks.value.twitter = sl.twitter || '';
|
|
89
|
+
socialLinks.value.linkedin = sl.linkedin || '';
|
|
90
|
+
socialLinks.value.youtube = sl.youtube || '';
|
|
91
|
+
socialLinks.value.instagram = sl.instagram || '';
|
|
92
|
+
socialLinks.value.mastodon = sl.mastodon || '';
|
|
93
|
+
socialLinks.value.discord = sl.discord || '';
|
|
85
94
|
}
|
|
86
95
|
const profileRecord = p as Record<string, unknown>;
|
|
87
96
|
if (Array.isArray(profileRecord.experience)) {
|
|
@@ -419,6 +428,26 @@ async function handleSave(): Promise<void> {
|
|
|
419
428
|
/>
|
|
420
429
|
</div>
|
|
421
430
|
|
|
431
|
+
<div class="cpub-form-group">
|
|
432
|
+
<label for="social-youtube" class="cpub-form-label">YouTube</label>
|
|
433
|
+
<input id="social-youtube" v-model="socialLinks.youtube" type="url" class="cpub-input" placeholder="https://youtube.com/@channel" />
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<div class="cpub-form-group">
|
|
437
|
+
<label for="social-instagram" class="cpub-form-label">Instagram</label>
|
|
438
|
+
<input id="social-instagram" v-model="socialLinks.instagram" type="url" class="cpub-input" placeholder="https://instagram.com/username" />
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<div class="cpub-form-group">
|
|
442
|
+
<label for="social-mastodon" class="cpub-form-label">Mastodon</label>
|
|
443
|
+
<input id="social-mastodon" v-model="socialLinks.mastodon" type="url" class="cpub-input" placeholder="https://mastodon.social/@username" />
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<div class="cpub-form-group">
|
|
447
|
+
<label for="social-discord" class="cpub-form-label">Discord</label>
|
|
448
|
+
<input id="social-discord" v-model="socialLinks.discord" type="url" class="cpub-input" placeholder="https://discord.gg/invite" />
|
|
449
|
+
</div>
|
|
450
|
+
|
|
422
451
|
</div>
|
|
423
452
|
|
|
424
453
|
<!-- Experience -->
|
|
@@ -742,58 +771,6 @@ async function handleSave(): Promise<void> {
|
|
|
742
771
|
margin-bottom: var(--space-3);
|
|
743
772
|
}
|
|
744
773
|
|
|
745
|
-
.cpub-skill-name {
|
|
746
|
-
flex: 1;
|
|
747
|
-
min-width: 0;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
.cpub-skill-slider {
|
|
751
|
-
display: flex;
|
|
752
|
-
align-items: center;
|
|
753
|
-
gap: var(--space-2);
|
|
754
|
-
width: 180px;
|
|
755
|
-
flex-shrink: 0;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
.cpub-range {
|
|
759
|
-
flex: 1;
|
|
760
|
-
appearance: none;
|
|
761
|
-
height: 4px;
|
|
762
|
-
background: var(--border2);
|
|
763
|
-
outline: none;
|
|
764
|
-
cursor: pointer;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
.cpub-range::-webkit-slider-thumb {
|
|
768
|
-
appearance: none;
|
|
769
|
-
width: 14px;
|
|
770
|
-
height: 14px;
|
|
771
|
-
background: var(--accent);
|
|
772
|
-
border: var(--border-width-default) solid var(--accent);
|
|
773
|
-
cursor: pointer;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
.cpub-range::-moz-range-thumb {
|
|
777
|
-
width: 14px;
|
|
778
|
-
height: 14px;
|
|
779
|
-
background: var(--accent);
|
|
780
|
-
border: var(--border-width-default) solid var(--accent);
|
|
781
|
-
cursor: pointer;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
.cpub-range:focus-visible::-webkit-slider-thumb {
|
|
785
|
-
outline: 2px solid var(--accent);
|
|
786
|
-
outline-offset: 2px;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
.cpub-skill-value {
|
|
790
|
-
font-family: var(--font-mono);
|
|
791
|
-
font-size: var(--text-xs);
|
|
792
|
-
color: var(--text-dim);
|
|
793
|
-
min-width: 36px;
|
|
794
|
-
text-align: right;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
774
|
/* ─── Buttons ─── */
|
|
798
775
|
.cpub-btn-icon {
|
|
799
776
|
width: 32px;
|
|
@@ -972,12 +949,8 @@ async function handleSave(): Promise<void> {
|
|
|
972
949
|
|
|
973
950
|
@media (max-width: 768px) {
|
|
974
951
|
.cpub-settings-form { padding: 0 var(--space-1); }
|
|
975
|
-
.cpub-skill-slider { width: 120px; }
|
|
976
952
|
.cpub-experience-dates { grid-template-columns: 1fr; }
|
|
977
953
|
.cpub-banner-upload { height: 100px; }
|
|
978
954
|
}
|
|
979
955
|
|
|
980
|
-
@media (max-width: 480px) {
|
|
981
|
-
.cpub-skill-slider { width: 80px; }
|
|
982
|
-
}
|
|
983
956
|
</style>
|
|
@@ -6,6 +6,8 @@ useSeoMeta({
|
|
|
6
6
|
title: `${username} — ${useSiteName()}`,
|
|
7
7
|
ogTitle: `${username} — ${useSiteName()}`,
|
|
8
8
|
ogImage: '/og-default.png',
|
|
9
|
+
ogType: 'profile',
|
|
10
|
+
twitterCard: 'summary',
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
const { explainers: explainersEnabled, learning: learningEnabled, federation: federationEnabled } = useFeatures();
|
|
@@ -372,12 +374,12 @@ async function handleReport(): Promise<void> {
|
|
|
372
374
|
</div>
|
|
373
375
|
|
|
374
376
|
<!-- Experience -->
|
|
375
|
-
<div v-if="
|
|
377
|
+
<div v-if="p.experience?.length" class="cpub-experience-section">
|
|
376
378
|
<div class="cpub-sec-head">
|
|
377
379
|
<h2><i class="fa-solid fa-briefcase" style="color: var(--purple); margin-right: 6px"></i>Experience</h2>
|
|
378
380
|
</div>
|
|
379
381
|
<div class="cpub-experience-list">
|
|
380
|
-
<div v-for="(exp, idx) in
|
|
382
|
+
<div v-for="(exp, idx) in p.experience" :key="idx" class="cpub-experience-item">
|
|
381
383
|
<div class="cpub-exp-header">
|
|
382
384
|
<strong>{{ exp.title }}</strong>
|
|
383
385
|
<span v-if="exp.company" class="cpub-exp-company">{{ exp.company }}</span>
|