@commonpub/layer 0.7.1 → 0.7.3

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 (43) hide show
  1. package/components/editors/ArticleEditor.vue +11 -12
  2. package/components/editors/BlogEditor.vue +17 -18
  3. package/components/editors/ExplainerEditor.vue +13 -14
  4. package/components/editors/ProjectEditor.vue +17 -18
  5. package/composables/useMarkdownImport.ts +1 -1
  6. package/package.json +8 -8
  7. package/pages/docs/[siteSlug]/edit.vue +4 -4
  8. package/pages/federated-hubs/[id]/posts/[postId].vue +91 -13
  9. package/pages/hubs/[slug]/index.vue +1 -1
  10. package/pages/hubs/[slug]/posts/[postId].vue +12 -0
  11. package/pages/u/[username]/[type]/[slug]/edit.vue +2 -1
  12. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +13 -0
  13. package/server/api/federation/hub-post-reply.post.ts +16 -10
  14. package/components/editors/BlockCanvas.vue +0 -487
  15. package/components/editors/BlockInsertZone.vue +0 -84
  16. package/components/editors/BlockPicker.vue +0 -285
  17. package/components/editors/BlockWrapper.vue +0 -192
  18. package/components/editors/EditorBlocks.vue +0 -248
  19. package/components/editors/EditorSection.vue +0 -81
  20. package/components/editors/EditorShell.vue +0 -196
  21. package/components/editors/EditorTagInput.vue +0 -114
  22. package/components/editors/EditorVisibility.vue +0 -110
  23. package/components/editors/blocks/BuildStepBlock.vue +0 -102
  24. package/components/editors/blocks/CalloutBlock.vue +0 -122
  25. package/components/editors/blocks/CheckpointBlock.vue +0 -27
  26. package/components/editors/blocks/CodeBlock.vue +0 -177
  27. package/components/editors/blocks/DividerBlock.vue +0 -22
  28. package/components/editors/blocks/DownloadsBlock.vue +0 -41
  29. package/components/editors/blocks/EmbedBlock.vue +0 -20
  30. package/components/editors/blocks/GalleryBlock.vue +0 -236
  31. package/components/editors/blocks/HeadingBlock.vue +0 -96
  32. package/components/editors/blocks/ImageBlock.vue +0 -271
  33. package/components/editors/blocks/MarkdownBlock.vue +0 -258
  34. package/components/editors/blocks/MathBlock.vue +0 -37
  35. package/components/editors/blocks/PartsListBlock.vue +0 -358
  36. package/components/editors/blocks/QuizBlock.vue +0 -47
  37. package/components/editors/blocks/QuoteBlock.vue +0 -101
  38. package/components/editors/blocks/SectionHeaderBlock.vue +0 -130
  39. package/components/editors/blocks/SliderBlock.vue +0 -318
  40. package/components/editors/blocks/TextBlock.vue +0 -201
  41. package/components/editors/blocks/ToolListBlock.vue +0 -70
  42. package/components/editors/blocks/VideoBlock.vue +0 -22
  43. package/composables/useBlockEditor.ts +0 -187
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { BlockEditor } from '../../composables/useBlockEditor';
3
- import type { BlockTypeGroup } from './BlockPicker.vue';
2
+ import { BlockCanvas, EditorBlocks, EditorSection, EditorTagInput, EditorVisibility, type BlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
4
3
 
5
4
  const props = defineProps<{
6
5
  blockEditor: BlockEditor;
@@ -201,7 +200,7 @@ const canvasMaxWidth = computed(() => {
201
200
 
202
201
  <!-- Modules tab -->
203
202
  <div v-if="activeLeftTab === 'modules'" class="cpub-ae-left-body">
204
- <EditorsEditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
203
+ <EditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
205
204
  </div>
206
205
 
207
206
  <!-- Structure tab -->
@@ -267,7 +266,7 @@ const canvasMaxWidth = computed(() => {
267
266
  <!-- Scrollable canvas -->
268
267
  <div class="cpub-ae-canvas">
269
268
  <div class="cpub-ae-canvas-inner" :style="{ maxWidth: canvasMaxWidth }">
270
- <EditorsBlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
269
+ <BlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
271
270
  </div>
272
271
  </div>
273
272
 
@@ -297,7 +296,7 @@ const canvasMaxWidth = computed(() => {
297
296
  </div>
298
297
  <div class="cpub-ae-right-body">
299
298
  <!-- Content / Metadata -->
300
- <EditorsEditorSection title="Content" icon="fa-pen-nib" :open="openSections.content" @toggle="toggleSection('content')">
299
+ <EditorSection title="Content" icon="fa-pen-nib" :open="openSections.content" @toggle="toggleSection('content')">
301
300
  <div class="cpub-ep-field">
302
301
  <label class="cpub-ep-flabel">Slug</label>
303
302
  <input class="cpub-ep-input" type="text" :value="metadata.slug" placeholder="auto-generated" @input="updateMeta('slug', ($event.target as HTMLInputElement).value)">
@@ -331,22 +330,22 @@ const canvasMaxWidth = computed(() => {
331
330
  </div>
332
331
  </template>
333
332
  </div>
334
- </EditorsEditorSection>
333
+ </EditorSection>
335
334
 
336
335
  <!-- SEO -->
337
- <EditorsEditorSection title="SEO" icon="fa-magnifying-glass" :open="openSections.seo" @toggle="toggleSection('seo')">
336
+ <EditorSection title="SEO" icon="fa-magnifying-glass" :open="openSections.seo" @toggle="toggleSection('seo')">
338
337
  <div class="cpub-ep-field">
339
338
  <label class="cpub-ep-flabel">Meta Description</label>
340
339
  <textarea class="cpub-ep-textarea" rows="3" :value="metadata.seoDescription as string" placeholder="Search engine description..." @input="updateMeta('seoDescription', ($event.target as HTMLTextAreaElement).value)" />
341
340
  <span class="cpub-ep-hint">{{ ((metadata.seoDescription as string) || '').length }}/160</span>
342
341
  </div>
343
- </EditorsEditorSection>
342
+ </EditorSection>
344
343
 
345
344
  <!-- Publishing -->
346
- <EditorsEditorSection title="Publishing" icon="fa-rocket" :open="openSections.publishing" @toggle="toggleSection('publishing')">
345
+ <EditorSection title="Publishing" icon="fa-rocket" :open="openSections.publishing" @toggle="toggleSection('publishing')">
347
346
  <div class="cpub-ep-field">
348
347
  <label class="cpub-ep-flabel">Visibility</label>
349
- <EditorsEditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
348
+ <EditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
350
349
  </div>
351
350
  <div class="cpub-ep-field">
352
351
  <label class="cpub-ep-flabel">Category</label>
@@ -362,9 +361,9 @@ const canvasMaxWidth = computed(() => {
362
361
  </div>
363
362
  <div class="cpub-ep-field">
364
363
  <label class="cpub-ep-flabel">Tags</label>
365
- <EditorsEditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
364
+ <EditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
366
365
  </div>
367
- </EditorsEditorSection>
366
+ </EditorSection>
368
367
  </div>
369
368
  </aside>
370
369
  </div>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { BlockEditor } from '../../composables/useBlockEditor';
3
- import type { BlockTypeGroup } from './BlockPicker.vue';
2
+ import { BlockCanvas, EditorBlocks, EditorSection, EditorTagInput, EditorVisibility, type BlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
4
3
 
5
4
  const props = defineProps<{
6
5
  blockEditor: BlockEditor;
@@ -149,7 +148,7 @@ const canvasMaxWidth = computed(() => {
149
148
 
150
149
  <!-- LEFT: Block Library -->
151
150
  <aside class="cpub-be-library" :class="{ 'cpub-be-sidebar-open': mobileLeftOpen }" aria-label="Block library">
152
- <EditorsEditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
151
+ <EditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
153
152
  </aside>
154
153
 
155
154
  <!-- CENTER: Canvas with toolbar, cover, title, subtitle, byline, blocks -->
@@ -231,7 +230,7 @@ const canvasMaxWidth = computed(() => {
231
230
  </div>
232
231
 
233
232
  <!-- Block editor canvas -->
234
- <EditorsBlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
233
+ <BlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
235
234
  </div>
236
235
 
237
236
  <!-- Word count bar -->
@@ -247,7 +246,7 @@ const canvasMaxWidth = computed(() => {
247
246
  <aside class="cpub-be-right" :class="{ 'cpub-be-sidebar-open': mobileRightOpen }" aria-label="Blog properties">
248
247
  <div class="cpub-be-right-body">
249
248
  <!-- Meta -->
250
- <EditorsEditorSection title="Meta" icon="fa-tag" :open="openSections.meta" @toggle="toggleSection('meta')">
249
+ <EditorSection title="Meta" icon="fa-tag" :open="openSections.meta" @toggle="toggleSection('meta')">
251
250
  <div class="cpub-ep-field">
252
251
  <label class="cpub-ep-flabel">Slug</label>
253
252
  <input class="cpub-ep-input cpub-ep-input-mono" type="text" :value="metadata.slug" placeholder="auto-generated" @input="updateMeta('slug', ($event.target as HTMLInputElement).value)">
@@ -266,21 +265,21 @@ const canvasMaxWidth = computed(() => {
266
265
  </div>
267
266
  <div class="cpub-ep-field">
268
267
  <label class="cpub-ep-flabel">Tags</label>
269
- <EditorsEditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
268
+ <EditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
270
269
  </div>
271
- </EditorsEditorSection>
270
+ </EditorSection>
272
271
 
273
272
  <!-- Excerpt / Description -->
274
- <EditorsEditorSection title="Excerpt" icon="fa-align-left" :open="openSections.excerpt" @toggle="toggleSection('excerpt')">
273
+ <EditorSection title="Excerpt" icon="fa-align-left" :open="openSections.excerpt" @toggle="toggleSection('excerpt')">
275
274
  <div class="cpub-ep-field">
276
275
  <label class="cpub-ep-flabel">Custom Excerpt</label>
277
276
  <textarea class="cpub-ep-textarea" rows="3" :value="(metadata.description as string) || ''" placeholder="Short description shown in feed previews..." @input="updateMeta('description', ($event.target as HTMLTextAreaElement).value)" />
278
277
  <span class="cpub-ep-hint cpub-ep-hint-right">{{ ((metadata.description as string) || '').length }} / 300</span>
279
278
  </div>
280
- </EditorsEditorSection>
279
+ </EditorSection>
281
280
 
282
281
  <!-- SEO Preview -->
283
- <EditorsEditorSection title="SEO Preview" icon="fa-brands fa-google" :open="openSections.seo" @toggle="toggleSection('seo')">
282
+ <EditorSection title="SEO Preview" icon="fa-brands fa-google" :open="openSections.seo" @toggle="toggleSection('seo')">
284
283
  <div class="cpub-be-seo-card">
285
284
  <div class="cpub-be-seo-url">
286
285
  <span class="cpub-be-seo-favicon">C</span>
@@ -294,13 +293,13 @@ const canvasMaxWidth = computed(() => {
294
293
  <textarea class="cpub-ep-textarea" rows="3" :value="(metadata.seoDescription as string) || ''" placeholder="Search engine description..." @input="updateMeta('seoDescription', ($event.target as HTMLTextAreaElement).value)" />
295
294
  <span class="cpub-ep-hint cpub-ep-hint-right">{{ ((metadata.seoDescription as string) || '').length }}/160</span>
296
295
  </div>
297
- </EditorsEditorSection>
296
+ </EditorSection>
298
297
 
299
298
  <!-- Publishing -->
300
- <EditorsEditorSection title="Publishing" icon="fa-globe" :open="openSections.publishing" @toggle="toggleSection('publishing')">
299
+ <EditorSection title="Publishing" icon="fa-globe" :open="openSections.publishing" @toggle="toggleSection('publishing')">
301
300
  <div class="cpub-ep-field">
302
301
  <label class="cpub-ep-flabel">Visibility</label>
303
- <EditorsEditorVisibility :model-value="(metadata.visibility as string) || 'public'" @update:model-value="(v: string) => updateMeta('visibility', v)" />
302
+ <EditorVisibility :model-value="(metadata.visibility as string) || 'public'" @update:model-value="(v: string) => updateMeta('visibility', v)" />
304
303
  </div>
305
304
  <div class="cpub-ep-field">
306
305
  <label class="cpub-be-schedule-row">
@@ -319,10 +318,10 @@ const canvasMaxWidth = computed(() => {
319
318
  <label class="cpub-ep-flabel">Series <span class="cpub-ep-optional">(optional)</span></label>
320
319
  <input class="cpub-ep-input" type="text" :value="metadata.series" placeholder="e.g. Home Lab Chronicles" @input="updateMeta('series', ($event.target as HTMLInputElement).value)">
321
320
  </div>
322
- </EditorsEditorSection>
321
+ </EditorSection>
323
322
 
324
323
  <!-- Author -->
325
- <EditorsEditorSection title="Author" icon="fa-user" :open="openSections.author" @toggle="toggleSection('author')">
324
+ <EditorSection title="Author" icon="fa-user" :open="openSections.author" @toggle="toggleSection('author')">
326
325
  <div class="cpub-be-author-row">
327
326
  <div class="cpub-be-author-av">{{ authorInitials }}</div>
328
327
  <div class="cpub-be-author-info">
@@ -331,10 +330,10 @@ const canvasMaxWidth = computed(() => {
331
330
  </div>
332
331
  <span class="cpub-be-author-badge">You</span>
333
332
  </div>
334
- </EditorsEditorSection>
333
+ </EditorSection>
335
334
 
336
335
  <!-- Social -->
337
- <EditorsEditorSection title="Social" icon="fa-share-nodes" :open="openSections.social" @toggle="toggleSection('social')">
336
+ <EditorSection title="Social" icon="fa-share-nodes" :open="openSections.social" @toggle="toggleSection('social')">
338
337
  <div class="cpub-ep-field">
339
338
  <label class="cpub-ep-flabel">Open Graph Image</label>
340
339
  <div class="cpub-be-og-thumb">
@@ -344,7 +343,7 @@ const canvasMaxWidth = computed(() => {
344
343
  </div>
345
344
  </div>
346
345
  </div>
347
- </EditorsEditorSection>
346
+ </EditorSection>
348
347
  </div>
349
348
  </aside>
350
349
  </div>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { BlockEditor } from '../../composables/useBlockEditor';
3
- import type { BlockTypeGroup } from './BlockPicker.vue';
2
+ import { BlockCanvas, EditorBlocks, EditorSection, EditorTagInput, EditorVisibility, type BlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
4
3
 
5
4
  const props = defineProps<{
6
5
  blockEditor: BlockEditor;
@@ -203,7 +202,7 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
203
202
  </div>
204
203
 
205
204
  <div v-if="activeLeftTab === 'modules'" class="cpub-ee-left-body">
206
- <EditorsEditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
205
+ <EditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
207
206
  </div>
208
207
 
209
208
  <div v-else-if="activeLeftTab === 'structure'" class="cpub-ee-left-body" style="padding: 10px;">
@@ -299,7 +298,7 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
299
298
 
300
299
  <div class="cpub-ee-canvas">
301
300
  <div class="cpub-ee-canvas-inner" :style="{ maxWidth: canvasMaxWidth }">
302
- <EditorsBlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
301
+ <BlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
303
302
  </div>
304
303
  </div>
305
304
 
@@ -318,7 +317,7 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
318
317
  <!-- RIGHT: Properties -->
319
318
  <aside class="cpub-ee-right" :class="{ 'cpub-ee-sidebar-open': mobileRightOpen }" aria-label="Explainer properties">
320
319
  <div class="cpub-ee-right-body">
321
- <EditorsEditorSection title="Content" icon="fa-sliders" :open="openSections.section" @toggle="toggleSection('section')">
320
+ <EditorSection title="Content" icon="fa-sliders" :open="openSections.section" @toggle="toggleSection('section')">
322
321
  <div class="cpub-ep-field">
323
322
  <label class="cpub-ep-flabel">Slug</label>
324
323
  <input class="cpub-ep-input" type="text" :value="metadata.slug" placeholder="auto-generated" @input="updateMeta('slug', ($event.target as HTMLInputElement).value)">
@@ -333,27 +332,27 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
333
332
  </div>
334
333
  <div class="cpub-ep-field">
335
334
  <label class="cpub-ep-flabel">Tags</label>
336
- <EditorsEditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
335
+ <EditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
337
336
  </div>
338
- </EditorsEditorSection>
337
+ </EditorSection>
339
338
 
340
- <EditorsEditorSection title="Difficulty" icon="fa-gauge-high" :open="openSections.difficulty" @toggle="toggleSection('difficulty')">
339
+ <EditorSection title="Difficulty" icon="fa-gauge-high" :open="openSections.difficulty" @toggle="toggleSection('difficulty')">
341
340
  <select class="cpub-ep-select" :value="metadata.difficulty || 'beginner'" @change="updateMeta('difficulty', ($event.target as HTMLSelectElement).value)">
342
341
  <option value="beginner">Beginner</option>
343
342
  <option value="intermediate">Intermediate</option>
344
343
  <option value="advanced">Advanced</option>
345
344
  </select>
346
- </EditorsEditorSection>
345
+ </EditorSection>
347
346
 
348
- <EditorsEditorSection title="Visibility" icon="fa-eye" :open="openSections.visibility" @toggle="toggleSection('visibility')">
349
- <EditorsEditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
350
- </EditorsEditorSection>
347
+ <EditorSection title="Visibility" icon="fa-eye" :open="openSections.visibility" @toggle="toggleSection('visibility')">
348
+ <EditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
349
+ </EditorSection>
351
350
 
352
- <EditorsEditorSection title="Cover Image" icon="fa-image" :open="openSections.cover" @toggle="toggleSection('cover')">
351
+ <EditorSection title="Cover Image" icon="fa-image" :open="openSections.cover" @toggle="toggleSection('cover')">
353
352
  <div class="cpub-ep-field">
354
353
  <input class="cpub-ep-input" type="url" :value="metadata.coverImageUrl" placeholder="https://..." @input="updateMeta('coverImageUrl', ($event.target as HTMLInputElement).value)">
355
354
  </div>
356
- </EditorsEditorSection>
355
+ </EditorSection>
357
356
  </div>
358
357
  </aside>
359
358
  </div>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { BlockEditor } from '../../composables/useBlockEditor';
3
- import type { BlockTypeGroup } from './BlockPicker.vue';
2
+ import { BlockCanvas, EditorBlocks, EditorSection, EditorTagInput, EditorVisibility, type BlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
4
3
 
5
4
  const props = defineProps<{
6
5
  blockEditor: BlockEditor;
@@ -151,7 +150,7 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
151
150
 
152
151
  <!-- LEFT: Block Library -->
153
152
  <aside class="cpub-pe-library" :class="{ 'cpub-pe-sidebar-open': mobileLeftOpen }" aria-label="Block library">
154
- <EditorsEditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
153
+ <EditorBlocks :groups="blockTypes" :block-editor="blockEditor" />
155
154
  </aside>
156
155
 
157
156
  <!-- CENTER: Canvas with toolbar -->
@@ -202,7 +201,7 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
202
201
  placeholder="Project title..."
203
202
  />
204
203
 
205
- <EditorsBlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
204
+ <BlockCanvas :block-editor="blockEditor" :block-types="blockTypes" />
206
205
  </div>
207
206
  </div>
208
207
 
@@ -217,7 +216,7 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
217
216
  <!-- RIGHT: Settings Panel -->
218
217
  <aside class="cpub-pe-settings" :class="{ 'cpub-pe-sidebar-open': mobileRightOpen }" aria-label="Project settings">
219
218
  <div class="cpub-pe-settings-body">
220
- <EditorsEditorSection title="Project Meta" icon="fa-sliders" :open="openSections.meta" @toggle="toggleSection('meta')">
219
+ <EditorSection title="Project Meta" icon="fa-sliders" :open="openSections.meta" @toggle="toggleSection('meta')">
221
220
  <div class="cpub-ep-field">
222
221
  <label class="cpub-ep-flabel">Slug</label>
223
222
  <input class="cpub-ep-input" type="text" :value="metadata.slug" placeholder="project-url-slug" @input="updateMeta('slug', ($event.target as HTMLInputElement).value)">
@@ -246,17 +245,17 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
246
245
  <label class="cpub-ep-flabel">Description</label>
247
246
  <textarea class="cpub-ep-textarea" rows="3" :value="metadata.description as string" placeholder="Brief project description..." @input="updateMeta('description', ($event.target as HTMLTextAreaElement).value)" />
248
247
  </div>
249
- </EditorsEditorSection>
248
+ </EditorSection>
250
249
 
251
- <EditorsEditorSection title="Tags" icon="fa-tag" :open="openSections.tags" @toggle="toggleSection('tags')">
252
- <EditorsEditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
253
- </EditorsEditorSection>
250
+ <EditorSection title="Tags" icon="fa-tag" :open="openSections.tags" @toggle="toggleSection('tags')">
251
+ <EditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
252
+ </EditorSection>
254
253
 
255
- <EditorsEditorSection title="Visibility" icon="fa-eye" :open="openSections.visibility" @toggle="toggleSection('visibility')">
256
- <EditorsEditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
257
- </EditorsEditorSection>
254
+ <EditorSection title="Visibility" icon="fa-eye" :open="openSections.visibility" @toggle="toggleSection('visibility')">
255
+ <EditorVisibility :model-value="visibility" @update:model-value="onVisibilityUpdate" />
256
+ </EditorSection>
258
257
 
259
- <EditorsEditorSection title="Cover Image" icon="fa-image" :open="openSections.cover" @toggle="toggleSection('cover')">
258
+ <EditorSection title="Cover Image" icon="fa-image" :open="openSections.cover" @toggle="toggleSection('cover')">
260
259
  <div class="cpub-pe-cover" :class="{ 'has-image': !!coverImageUrl }">
261
260
  <template v-if="coverImageUrl">
262
261
  <img :src="coverImageUrl" alt="Cover image" class="cpub-pe-cover-img" />
@@ -282,17 +281,17 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
282
281
  </div>
283
282
  </template>
284
283
  </div>
285
- </EditorsEditorSection>
284
+ </EditorSection>
286
285
 
287
- <EditorsEditorSection title="SEO" icon="fa-magnifying-glass" :open="openSections.seo" @toggle="toggleSection('seo')">
286
+ <EditorSection title="SEO" icon="fa-magnifying-glass" :open="openSections.seo" @toggle="toggleSection('seo')">
288
287
  <div class="cpub-pe-field">
289
288
  <label class="cpub-pe-flabel">Meta Description</label>
290
289
  <textarea class="cpub-pe-textarea" rows="3" :value="metadata.seoDescription as string" placeholder="Search engine description (recommended 50-160 chars)" @input="updateMeta('seoDescription', ($event.target as HTMLTextAreaElement).value)" />
291
290
  <span class="cpub-pe-hint">{{ ((metadata.seoDescription as string) || '').length }}/160</span>
292
291
  </div>
293
- </EditorsEditorSection>
292
+ </EditorSection>
294
293
 
295
- <EditorsEditorSection title="Checklist" icon="fa-circle-check" :open="openSections.checklist" @toggle="toggleSection('checklist')">
294
+ <EditorSection title="Checklist" icon="fa-circle-check" :open="openSections.checklist" @toggle="toggleSection('checklist')">
296
295
  <div class="cpub-pe-checklist">
297
296
  <div v-for="item in checklist" :key="item.label" class="cpub-pe-check-item" :class="{ pass: item.pass }">
298
297
  <i :class="item.pass ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" :style="{ color: item.pass ? 'var(--green)' : 'var(--text-faint)' }"></i>
@@ -302,7 +301,7 @@ const blockCount = computed(() => props.blockEditor.blocks.value.length);
302
301
  <div class="cpub-pe-checklist-summary">
303
302
  {{ checklistDone }}/{{ checklist.length }} complete
304
303
  </div>
305
- </EditorsEditorSection>
304
+ </EditorSection>
306
305
  </div>
307
306
  </aside>
308
307
  </div>
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { markdownToBlockTuples } from '@commonpub/editor';
5
5
  import type { BlockTuple } from '@commonpub/editor';
6
- import type { BlockEditor } from './useBlockEditor';
6
+ import type { BlockEditor } from '@commonpub/editor/vue';
7
7
 
8
8
  export function useMarkdownImport(blockEditor: BlockEditor) {
9
9
  const importing = ref(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -52,14 +52,14 @@
52
52
  "zod": "^4.3.6",
53
53
  "@commonpub/auth": "0.5.0",
54
54
  "@commonpub/config": "0.8.0",
55
- "@commonpub/editor": "0.5.0",
56
- "@commonpub/learning": "0.5.0",
57
55
  "@commonpub/docs": "0.6.1",
58
- "@commonpub/schema": "0.9.1",
59
- "@commonpub/ui": "0.8.4",
60
- "@commonpub/explainer": "0.7.2",
61
- "@commonpub/server": "2.27.1",
62
- "@commonpub/protocol": "0.9.7"
56
+ "@commonpub/explainer": "0.7.3",
57
+ "@commonpub/editor": "0.6.0",
58
+ "@commonpub/learning": "0.5.0",
59
+ "@commonpub/protocol": "0.9.7",
60
+ "@commonpub/schema": "0.9.2",
61
+ "@commonpub/server": "2.27.2",
62
+ "@commonpub/ui": "0.8.4"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { BlockTuple } from '@commonpub/editor';
3
- import type { BlockTypeGroup } from '../../../components/editors/BlockPicker.vue';
3
+ import { BlockCanvas, EditorShell, useBlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
4
4
  import type { PageTreeItem } from '../../../components/editors/DocsPageTree.vue';
5
5
 
6
6
  definePageMeta({ layout: false, middleware: 'auth' });
@@ -489,7 +489,7 @@ async function createVersion(): Promise<void> {
489
489
  </div>
490
490
 
491
491
  <!-- 3-panel editor -->
492
- <EditorsEditorShell :show-left-sidebar="true" :show-right-sidebar="!!selectedPageId">
492
+ <EditorShell :show-left-sidebar="true" :show-right-sidebar="!!selectedPageId">
493
493
  <!-- LEFT: Page tree -->
494
494
  <template #left>
495
495
  <div class="cpub-docs-left-header">
@@ -543,7 +543,7 @@ async function createVersion(): Promise<void> {
543
543
  </div>
544
544
 
545
545
  <!-- Block canvas -->
546
- <EditorsBlockCanvas
546
+ <BlockCanvas
547
547
  :block-editor="blockEditor"
548
548
  :block-types="blockTypes"
549
549
  />
@@ -649,7 +649,7 @@ async function createVersion(): Promise<void> {
649
649
  </span>
650
650
  </span>
651
651
  </template>
652
- </EditorsEditorShell>
652
+ </EditorShell>
653
653
 
654
654
  <!-- Markdown import dialog -->
655
655
  <EditorsMarkdownImportDialog
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { FederatedHubListItem, FederatedHubPostItem } from '@commonpub/server';
2
+ import type { FederatedHubListItem, FederatedHubPostItem, FederatedHubPostReplyItem } from '@commonpub/server';
3
3
 
4
4
  const route = useRoute();
5
5
  const id = route.params.id as string;
@@ -10,6 +10,9 @@ const toast = useToast();
10
10
 
11
11
  const { data: hub } = useLazyFetch<FederatedHubListItem>(`/api/federated-hubs/${id}`);
12
12
  const { data: post, refresh: refreshPost } = useLazyFetch<FederatedHubPostItem>(`/api/federated-hubs/${id}/posts/${postId}`);
13
+ const { data: repliesData, refresh: refreshReplies } = useLazyFetch<{ items: FederatedHubPostReplyItem[]; total: number }>(`/api/federated-hubs/${id}/posts/${postId}/replies`, { default: () => ({ items: [], total: 0 }) });
14
+
15
+ const replies = computed(() => repliesData.value?.items ?? []);
13
16
 
14
17
  function formatDate(d: string | Date | null): string {
15
18
  if (!d) return '';
@@ -68,6 +71,7 @@ async function handleLike(): Promise<void> {
68
71
  // Reply
69
72
  const replyContent = ref('');
70
73
  const replying = ref(false);
74
+ const replyingTo = ref<string | null>(null);
71
75
 
72
76
  async function handleReply(): Promise<void> {
73
77
  if (!replyContent.value.trim()) return;
@@ -75,11 +79,12 @@ async function handleReply(): Promise<void> {
75
79
  try {
76
80
  await $fetch('/api/federation/hub-post-reply' as string, {
77
81
  method: 'POST',
78
- body: { federatedHubPostId: postId, content: replyContent.value },
82
+ body: { federatedHubPostId: postId, content: replyContent.value, parentId: replyingTo.value || undefined },
79
83
  });
80
84
  replyContent.value = '';
81
- toast.success('Reply sent via federation');
82
- await refreshPost();
85
+ replyingTo.value = null;
86
+ toast.success('Reply posted');
87
+ await Promise.all([refreshReplies(), refreshPost()]);
83
88
  } catch {
84
89
  toast.error('Failed to send reply');
85
90
  } finally {
@@ -162,12 +167,15 @@ useHead({
162
167
 
163
168
  <!-- Reply form -->
164
169
  <div v-if="isAuthenticated" class="cpub-reply-form">
170
+ <div v-if="replyingTo" class="cpub-replying-to">
171
+ Replying to a comment <button class="cpub-cancel-reply" @click="replyingTo = null"><i class="fa-solid fa-xmark"></i></button>
172
+ </div>
165
173
  <div class="cpub-reply-row">
166
174
  <input
167
175
  v-model="replyContent"
168
176
  class="cpub-reply-input"
169
177
  type="text"
170
- placeholder="Write a reply (sent via federation)..."
178
+ placeholder="Write a reply..."
171
179
  aria-label="Write a reply"
172
180
  @keydown.enter="handleReply"
173
181
  />
@@ -176,18 +184,53 @@ useHead({
176
184
  </button>
177
185
  </div>
178
186
  <p class="cpub-fed-reply-hint">
179
- <i class="fa-solid fa-globe"></i> Your reply will be sent to {{ hub?.originDomain }} via ActivityPub
187
+ <i class="fa-solid fa-globe"></i> Your reply will also be sent to {{ hub?.originDomain }} via ActivityPub
180
188
  </p>
181
189
  </div>
182
190
 
183
- <!-- Reply thread info -->
191
+ <!-- Replies -->
184
192
  <div class="cpub-replies-section">
185
- <div class="cpub-empty-state" style="padding: 32px 16px">
186
- <p class="cpub-empty-state-title"><i class="fa-solid fa-globe"></i> Federated thread</p>
193
+ <h3 v-if="replies.length" class="cpub-replies-title">{{ repliesData?.total ?? 0 }} Local Replies</h3>
194
+ <div v-for="reply in replies" :key="reply.id" class="cpub-reply">
195
+ <div class="cpub-reply-author">
196
+ <div class="cpub-reply-avatar">
197
+ <img v-if="reply.author?.avatarUrl" :src="reply.author.avatarUrl" :alt="reply.author?.displayName || reply.author?.username" class="cpub-reply-avatar-img" />
198
+ <span v-else>{{ (reply.author?.displayName || reply.author?.username || 'U').charAt(0).toUpperCase() }}</span>
199
+ </div>
200
+ <NuxtLink v-if="reply.author" :to="`/u/${reply.author.username}`" class="cpub-reply-author-name">{{ reply.author.displayName || reply.author.username }}</NuxtLink>
201
+ <span class="cpub-post-sep">&middot;</span>
202
+ <time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
203
+ </div>
204
+ <div class="cpub-reply-content"><MentionText :text="reply.content" /></div>
205
+ <div class="cpub-reply-actions">
206
+ <button v-if="isAuthenticated" class="cpub-reply-btn" @click="replyingTo = reply.id; replyContent = `@${reply.author?.username ?? ''} `">
207
+ <i class="fa-solid fa-reply"></i> Reply
208
+ </button>
209
+ </div>
210
+
211
+ <!-- Nested replies -->
212
+ <div v-if="reply.replies?.length" class="cpub-nested-replies">
213
+ <div v-for="child in reply.replies" :key="child.id" class="cpub-reply cpub-reply-nested">
214
+ <div class="cpub-reply-author">
215
+ <div class="cpub-reply-avatar">
216
+ <img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-reply-avatar-img" />
217
+ <span v-else>{{ (child.author?.displayName || child.author?.username || 'U').charAt(0).toUpperCase() }}</span>
218
+ </div>
219
+ <NuxtLink v-if="child.author" :to="`/u/${child.author.username}`" class="cpub-reply-author-name">{{ child.author.displayName || child.author.username }}</NuxtLink>
220
+ <span class="cpub-post-sep">&middot;</span>
221
+ <time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
222
+ </div>
223
+ <div class="cpub-reply-content"><MentionText :text="child.content" /></div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Federation thread info -->
229
+ <div class="cpub-fed-thread-info" style="padding: 16px">
187
230
  <p class="cpub-empty-state-desc">
188
- Replies sent here are delivered to <strong>{{ hub?.originDomain }}</strong> via ActivityPub.
231
+ <i class="fa-solid fa-globe"></i>
189
232
  <a v-if="post.objectUri" :href="post.objectUri" target="_blank" rel="noopener noreferrer" class="cpub-inline-link">
190
- View full thread on origin <i class="fa-solid fa-arrow-up-right-from-square"></i>
233
+ View full thread on {{ hub?.originDomain }} <i class="fa-solid fa-arrow-up-right-from-square"></i>
191
234
  </a>
192
235
  </p>
193
236
  </div>
@@ -278,6 +321,11 @@ useHead({
278
321
 
279
322
  /* Reply form */
280
323
  .cpub-reply-form { margin-bottom: 16px; }
324
+ .cpub-replying-to {
325
+ font-size: 11px; color: var(--text-dim); margin-bottom: 6px;
326
+ display: flex; align-items: center; gap: 6px;
327
+ }
328
+ .cpub-cancel-reply { background: none; border: none; cursor: pointer; color: var(--text-faint); font-size: 12px; }
281
329
  .cpub-reply-row { display: flex; gap: 8px; }
282
330
  .cpub-reply-input {
283
331
  flex: 1; padding: 8px 12px; background: var(--surface); border: var(--border-width-default) solid var(--border);
@@ -293,8 +341,38 @@ useHead({
293
341
 
294
342
  /* Replies section */
295
343
  .cpub-replies-section {}
296
- .cpub-empty-state { text-align: center; }
297
- .cpub-empty-state-title { font-size: 14px; font-weight: 600; color: var(--text-dim); margin-bottom: 4px; }
344
+ .cpub-replies-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
345
+
346
+ .cpub-reply {
347
+ padding: 12px 16px; background: var(--surface); border: var(--border-width-default) solid var(--border);
348
+ margin-bottom: 8px;
349
+ }
350
+ .cpub-reply-nested { margin-left: 24px; border-color: var(--border2); }
351
+ .cpub-nested-replies { margin-top: 8px; }
352
+
353
+ .cpub-reply-author { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-faint); margin-bottom: 6px; }
354
+
355
+ .cpub-reply-avatar {
356
+ width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
357
+ background: var(--surface2); border: 1px solid var(--border);
358
+ font-family: var(--font-mono); font-size: 9px; font-weight: 700; color: var(--text-dim);
359
+ overflow: hidden;
360
+ }
361
+ .cpub-reply-avatar-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
362
+
363
+ .cpub-reply-author-name { font-weight: 500; color: var(--text-dim); text-decoration: none; }
364
+ .cpub-reply-author-name:hover { color: var(--accent); }
365
+
366
+ .cpub-reply-content { font-size: 13px; line-height: 1.6; color: var(--text); }
367
+
368
+ .cpub-reply-actions { margin-top: 6px; }
369
+ .cpub-reply-btn {
370
+ background: none; border: none; cursor: pointer; font-size: 11px;
371
+ color: var(--text-faint); padding: 2px 0;
372
+ }
373
+ .cpub-reply-btn:hover { color: var(--accent); }
374
+
375
+ .cpub-fed-thread-info { text-align: center; margin-top: 8px; }
298
376
  .cpub-empty-state-desc { font-size: 12px; color: var(--text-faint); line-height: 1.5; }
299
377
  .cpub-inline-link { color: var(--accent); text-decoration: none; white-space: nowrap; }
300
378
  .cpub-inline-link:hover { text-decoration: underline; }
@@ -63,7 +63,7 @@ const postsVM = computed<HubPostViewModel[]>(() => {
63
63
  author: {
64
64
  name: p.author?.displayName || p.author?.username || p.remoteActorName || 'Unknown',
65
65
  handle: p.author ? null : remoteDomain(p.remoteActorUri ?? undefined),
66
- avatarUrl: p.author?.avatarUrl ?? null,
66
+ avatarUrl: p.author?.avatarUrl ?? p.remoteActorAvatarUrl ?? null,
67
67
  },
68
68
  createdAt: p.createdAt,
69
69
  likeCount: p.likeCount ?? 0,