@commonpub/layer 0.5.4 → 0.5.5

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.
@@ -165,7 +165,18 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
165
165
 
166
166
  <template>
167
167
  <!-- Scroll viewer for ExplainerDocument format -->
168
- <ScrollViewer v-if="isDocumentFormat && explainerDoc" :document="explainerDoc" />
168
+ <div v-if="isDocumentFormat && explainerDoc" class="cpub-scroll-viewer-wrap">
169
+ <ScrollViewer :document="explainerDoc" />
170
+ <NuxtLink
171
+ v-if="isOwner"
172
+ :to="`/${content.type}/${content.slug}/edit`"
173
+ class="cpub-scroll-edit-btn"
174
+ title="Edit explainer"
175
+ aria-label="Edit explainer"
176
+ >
177
+ <i class="fa-solid fa-pen"></i>
178
+ </NuxtLink>
179
+ </div>
169
180
 
170
181
  <!-- Block-based viewer fallback -->
171
182
  <div v-else class="cpub-explainer-view">
@@ -332,6 +343,30 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
332
343
  </template>
333
344
 
334
345
  <style scoped>
346
+ /* ── SCROLL VIEWER WRAPPER + EDIT BUTTON ── */
347
+ .cpub-scroll-viewer-wrap { position: relative; }
348
+ .cpub-scroll-edit-btn {
349
+ position: fixed;
350
+ bottom: 20px;
351
+ right: 20px;
352
+ width: 40px;
353
+ height: 40px;
354
+ display: flex;
355
+ align-items: center;
356
+ justify-content: center;
357
+ background: var(--surface, #1a1a1a);
358
+ border: 1px solid var(--border, #333);
359
+ color: var(--text-dim, #999);
360
+ font-size: 14px;
361
+ text-decoration: none;
362
+ z-index: 100;
363
+ transition: background 0.15s, color 0.15s;
364
+ }
365
+ .cpub-scroll-edit-btn:hover {
366
+ background: var(--accent-bg, #1a2a4a);
367
+ color: var(--accent, #5b9cf6);
368
+ }
369
+
335
370
  /* ── PROGRESS BAR ── */
336
371
  .cpub-progress-line {
337
372
  position: fixed;
package/nuxt.config.ts CHANGED
@@ -47,6 +47,7 @@ export default defineNuxtConfig({
47
47
  uiTheme('layouts.css'),
48
48
  uiTheme('forms.css'),
49
49
  uiTheme('editor-panels.css'),
50
+ '@commonpub/explainer/vue/theme/explainer-themes.css',
50
51
  ],
51
52
  runtimeConfig: {
52
53
  databaseUrl: '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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/auth": "0.5.0",
54
53
  "@commonpub/config": "0.8.0",
55
- "@commonpub/docs": "0.6.0",
56
54
  "@commonpub/editor": "0.5.0",
57
- "@commonpub/explainer": "0.6.4",
55
+ "@commonpub/explainer": "0.7.0",
56
+ "@commonpub/docs": "0.6.0",
57
+ "@commonpub/auth": "0.5.0",
58
58
  "@commonpub/learning": "0.5.0",
59
- "@commonpub/schema": "0.8.17",
60
59
  "@commonpub/protocol": "0.9.6",
61
- "@commonpub/server": "2.24.1",
62
- "@commonpub/ui": "0.8.4"
60
+ "@commonpub/server": "2.25.0",
61
+ "@commonpub/ui": "0.8.4",
62
+ "@commonpub/schema": "0.8.17"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -386,7 +386,7 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
386
386
  <div class="cpub-mode-tabs">
387
387
  <button :class="['cpub-mode-tab', { active: mode === 'write' }]" @click="mode = 'write'">Write</button>
388
388
  <button :class="['cpub-mode-tab', { active: mode === 'preview' }]" @click="enterPreview">Preview</button>
389
- <button :class="['cpub-mode-tab', { active: mode === 'code' }]" @click="mode = 'code'">Code</button>
389
+ <button v-if="!isExplainer" :class="['cpub-mode-tab', { active: mode === 'code' }]" @click="mode = 'code'">Code</button>
390
390
  </div>
391
391
  <div class="cpub-topbar-spacer" />
392
392
  <div class="cpub-topbar-actions">
@@ -466,7 +466,7 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
466
466
  slug: (metadata.slug as string) || 'preview',
467
467
  subtitle: null,
468
468
  description: (metadata.description as string) || null,
469
- content: blockEditor.toBlockTuples(),
469
+ content: isExplainer ? (explainerDocLatest ?? explainerDocInit) : blockEditor.toBlockTuples(),
470
470
  coverImageUrl: (metadata.coverImageUrl as string) || null,
471
471
  category: null,
472
472
  difficulty: (metadata.difficulty as string) || null,
@@ -9,9 +9,17 @@ const pagePath = computed(() => {
9
9
  return Array.isArray(p) ? p[p.length - 1] : p;
10
10
  });
11
11
 
12
+ const selectedVersion = ref('');
13
+
12
14
  const { data: site } = useLazyFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions: Array<{ id: string; label: string; slug: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
13
- const { data: nav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => `/api/docs/${siteSlug.value}/nav`);
14
- const { data: pages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => `/api/docs/${siteSlug.value}/pages`);
15
+ const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
16
+ const base = `/api/docs/${siteSlug.value}/nav`;
17
+ return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
18
+ });
19
+ const { data: pages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
20
+ const base = `/api/docs/${siteSlug.value}/pages`;
21
+ return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
22
+ });
15
23
 
16
24
  // Fetch the rendered page (server-side markdown rendering or block content)
17
25
  interface RenderedPage {
@@ -37,7 +45,10 @@ const blockContent = computed<BlockTuple[]>(() => {
37
45
  });
38
46
 
39
47
  const { data: renderedPage, pending: pagePending, error: pageError, refresh: refreshPage } = useLazyFetch<RenderedPage>(
40
- () => `/api/docs/${siteSlug.value}/pages/${pagePath.value}`,
48
+ () => {
49
+ const base = `/api/docs/${siteSlug.value}/pages/${pagePath.value}`;
50
+ return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
51
+ },
41
52
  { key: `doc-page-${siteSlug.value}-${pagePath.value}` },
42
53
  );
43
54
 
@@ -178,15 +189,20 @@ watch(searchQuery, (q) => {
178
189
  searchTimeout = setTimeout(handleSearch, 300);
179
190
  });
180
191
 
181
- // Version switching
182
- const selectedVersion = ref('');
192
+ // Version switching — initialize from site data
183
193
  watch(site, (s) => {
184
194
  if (s?.versions?.length) {
185
195
  const def = s.versions.find((v: { isDefault: boolean }) => v.isDefault) ?? s.versions[0];
186
- if (def) selectedVersion.value = def.version;
196
+ if (def && !selectedVersion.value) selectedVersion.value = def.version;
187
197
  }
188
198
  }, { immediate: true });
189
199
 
200
+ // Reload page content and nav when version changes
201
+ watch(selectedVersion, () => {
202
+ refreshNav();
203
+ refreshPage();
204
+ });
205
+
190
206
  // Mobile sidebar
191
207
  const sidebarOpen = ref(false);
192
208
 
@@ -10,8 +10,26 @@ const siteSlug = computed(() => route.params.siteSlug as string);
10
10
  const { show: toast } = useToast();
11
11
 
12
12
  // ═══ DATA FETCHING ═══
13
- const { data: site, refresh: refreshSite } = await useFetch<{ id: string; name: string; slug: string; description: string; ownerId: string }>(() => `/api/docs/${siteSlug.value}`);
14
- const { data: rawPages, refresh: refreshPages } = await useFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null; content: string | BlockTuple[] | null; format?: string }>>(() => `/api/docs/${siteSlug.value}/pages`);
13
+ const { data: site, refresh: refreshSite } = await useFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions?: Array<{ id: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
14
+
15
+ // Version selector
16
+ const selectedVersion = ref('');
17
+ watch(site, (s) => {
18
+ if (s?.versions?.length && !selectedVersion.value) {
19
+ const def = s.versions.find((v) => v.isDefault) ?? s.versions[0];
20
+ if (def) selectedVersion.value = def.version;
21
+ }
22
+ }, { immediate: true });
23
+
24
+ const { data: rawPages, refresh: refreshPages } = await useFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null; content: string | BlockTuple[] | null; format?: string }>>(() => {
25
+ const base = `/api/docs/${siteSlug.value}/pages`;
26
+ return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
27
+ });
28
+
29
+ watch(selectedVersion, () => {
30
+ selectedPageId.value = null;
31
+ refreshPages();
32
+ });
15
33
 
16
34
  useSeoMeta({ title: () => `Edit ${site.value?.name ?? 'Docs'} — ${useSiteName()}` });
17
35
 
@@ -478,6 +496,13 @@ async function createVersion(): Promise<void> {
478
496
  <span class="cpub-docs-left-label">Pages</span>
479
497
  <span class="cpub-docs-page-count">{{ pages.length }}</span>
480
498
  </div>
499
+ <div v-if="site?.versions && site.versions.length > 1" class="cpub-docs-version-select">
500
+ <select v-model="selectedVersion" class="cpub-docs-version-dropdown" aria-label="Select version">
501
+ <option v-for="v in site.versions" :key="v.id" :value="v.version">
502
+ {{ v.version }}{{ v.isDefault ? ' (latest)' : '' }}
503
+ </option>
504
+ </select>
505
+ </div>
481
506
  <EditorsDocsPageTree
482
507
  :pages="treePages"
483
508
  :selected-page-id="selectedPageId"
@@ -794,6 +819,20 @@ async function createVersion(): Promise<void> {
794
819
  color: var(--text-faint);
795
820
  }
796
821
 
822
+ .cpub-docs-version-select {
823
+ padding: 4px 0 6px;
824
+ }
825
+ .cpub-docs-version-dropdown {
826
+ width: 100%;
827
+ font-family: var(--font-mono);
828
+ font-size: 11px;
829
+ padding: 4px 6px;
830
+ background: var(--surface);
831
+ border: var(--border-width-default) solid var(--border2);
832
+ color: var(--text);
833
+ cursor: pointer;
834
+ }
835
+
797
836
  .cpub-docs-page-count {
798
837
  font-family: var(--font-mono);
799
838
  font-size: 9px;
@@ -2,9 +2,17 @@
2
2
  const route = useRoute();
3
3
  const siteSlug = computed(() => route.params.siteSlug as string);
4
4
 
5
+ const selectedVersion = ref('');
6
+
5
7
  const { data: site, pending: sitePending, error: siteError, refresh: refreshSite } = useLazyFetch<{ id: string; name: string; slug: string; description: string; ownerId: string; versions: Array<{ id: string; label: string; slug: string; version: string; isDefault: boolean }> }>(() => `/api/docs/${siteSlug.value}`);
6
- const { data: nav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => `/api/docs/${siteSlug.value}/nav`);
7
- const { data: pages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => `/api/docs/${siteSlug.value}/pages`);
8
+ const { data: nav, refresh: refreshNav } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
9
+ const base = `/api/docs/${siteSlug.value}/nav`;
10
+ return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
11
+ });
12
+ const { data: pages, refresh: refreshPages } = useLazyFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null }>>(() => {
13
+ const base = `/api/docs/${siteSlug.value}/pages`;
14
+ return selectedVersion.value ? `${base}?version=${encodeURIComponent(selectedVersion.value)}` : base;
15
+ });
8
16
 
9
17
  const { user } = useAuth();
10
18
  const isOwner = computed(() => site.value && user.value && site.value.ownerId === user.value.id);
@@ -44,15 +52,19 @@ function toggleSection(id: string): void {
44
52
  else expandedSections.value.add(id);
45
53
  }
46
54
 
47
- // Version selector
48
- const selectedVersion = ref('');
55
+ // Version selector — initialize from site data, refresh nav on change
49
56
  watch(site, (s) => {
50
57
  if (s?.versions?.length) {
51
58
  const def = s.versions.find((v: { isDefault: boolean }) => v.isDefault) ?? s.versions[0];
52
- if (def) selectedVersion.value = def.version;
59
+ if (def && !selectedVersion.value) selectedVersion.value = def.version;
53
60
  }
54
61
  }, { immediate: true });
55
62
 
63
+ watch(selectedVersion, () => {
64
+ refreshNav();
65
+ refreshPages();
66
+ });
67
+
56
68
  // Search
57
69
  const searchQuery = ref('');
58
70
  const searchOpen = ref(false);
@@ -1,11 +1,22 @@
1
- import { createComment } from '@commonpub/server';
1
+ import { createComment, onContentCommented } from '@commonpub/server';
2
2
  import type { CommentItem } from '@commonpub/server';
3
3
  import { createCommentSchema } from '@commonpub/schema';
4
4
 
5
+ /** Content types that should federate comments */
6
+ const FEDERABLE_COMMENT_TYPES = new Set(['project', 'article', 'blog', 'explainer']);
7
+
5
8
  export default defineEventHandler(async (event): Promise<CommentItem> => {
6
9
  const user = requireAuth(event);
7
10
  const db = useDB();
11
+ const config = useConfig();
8
12
  const input = await parseBody(event, createCommentSchema);
9
13
 
10
- return createComment(db, user.id, input);
14
+ const comment = await createComment(db, user.id, input);
15
+
16
+ // Federate comment on content items (non-blocking)
17
+ if (FEDERABLE_COMMENT_TYPES.has(input.targetType)) {
18
+ onContentCommented(db, comment.id, user.id, input.targetType, input.targetId, config).catch(() => {});
19
+ }
20
+
21
+ return comment;
11
22
  });