@acms-subir/core 0.1.0 → 0.2.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 (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli.mjs +63 -9
  3. package/integration.mjs +11 -0
  4. package/package.json +10 -9
  5. package/src/actions/index.ts +184 -6
  6. package/src/components/DesignManager.tsx +53 -17
  7. package/src/components/LinksManager.tsx +110 -48
  8. package/src/components/NavManager.tsx +83 -37
  9. package/src/components/editor/PostEditor.tsx +228 -55
  10. package/src/components/editor/extensions/cards.tsx +17 -1
  11. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  12. package/src/components/editor/extensions/infoCards.tsx +16 -1
  13. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  14. package/src/components/editor/extensions/slash.tsx +70 -8
  15. package/src/components/editor/extensions/toc.tsx +89 -0
  16. package/src/components/editor/extensions/toggle.tsx +70 -0
  17. package/src/layouts/Site.astro +10 -1
  18. package/src/lib/analytics.ts +121 -0
  19. package/src/lib/designPreview.ts +14 -0
  20. package/src/lib/drafts.ts +77 -0
  21. package/src/lib/dynamicBlocks.ts +43 -3
  22. package/src/lib/repo/migrations.ts +37 -0
  23. package/src/lib/repo/sql.ts +127 -10
  24. package/src/lib/repo/types.ts +21 -1
  25. package/src/lib/schema.ts +53 -0
  26. package/src/pages/[...slug].astro +3 -1
  27. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  28. package/src/pages/admin/c/[collection]/index.astro +1 -0
  29. package/src/pages/admin/design.astro +6 -1
  30. package/src/pages/admin/index.astro +5 -2
  31. package/src/pages/admin/pages/[id].astro +10 -2
  32. package/src/pages/admin/pages/index.astro +1 -0
  33. package/src/pages/admin/settings.astro +148 -0
  34. package/src/pages/blog/[slug].astro +4 -1
  35. package/src/pages/index.astro +4 -1
  36. package/src/pages/links.astro +10 -4
  37. package/src/pages/preview/[type]/[id].astro +9 -4
  38. package/src/styles/admin.css +4 -0
  39. package/src/styles/cards.css +23 -0
  40. package/src/styles/editor.css +53 -1
  41. package/src/styles/site.css +3 -0
@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react';
3
3
  import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
4
4
  import type { Range } from '@tiptap/react';
5
5
  import StarterKit from '@tiptap/starter-kit';
6
+ import Highlight from '@tiptap/extension-highlight';
6
7
  import { TableKit } from '@tiptap/extension-table';
7
8
  import { Placeholder, CharacterCount } from '@tiptap/extensions';
8
9
  import { actions } from 'astro:actions';
@@ -11,20 +12,26 @@ import { ImageCard, EmbedCard, ButtonCard, Callout, HtmlCard, detectEmbed } from
11
12
  import { FileCard, GalleryCard, BookmarkCard } from './extensions/mediaCards';
12
13
  import { CodeBlock } from './extensions/codeBlock';
13
14
  import { PostListCard } from './extensions/postList';
15
+ import { ToggleCard } from './extensions/toggle';
16
+ import { TocCard } from './extensions/toc';
14
17
  import { CalendarCard } from './extensions/calendar';
15
18
  import { InfoCards } from './extensions/infoCards';
16
- import { SlashMenu } from './extensions/slash';
19
+ import { SlashMenu, type SlashSnippet } from './extensions/slash';
17
20
  import { TableTyping } from './extensions/tableTyping';
18
21
  import { ImagePicker, type PickedImage } from './ImagePicker';
19
22
  import { PreviewOverlay } from './PreviewOverlay';
20
23
  import { renderMarkdown } from '../../lib/markdown';
21
24
  import { CONTENT, collectionUrl } from '../../lib/content';
22
25
  import { I18N, localeHomeUrl } from '../../lib/i18n';
26
+ import { mergePageDraft, mergePostDraft } from '../../lib/drafts';
23
27
 
24
28
  // The Ghost-style writing surface, shared by posts AND pages: distraction-free
25
29
  // column, cards via "/", autosave, a collapsible settings sidebar, and an
26
30
  // optional side-by-side live preview. `kind` switches the save action and
27
31
  // which settings fields exist — everything else is identical.
32
+ //
33
+ // Live (published/scheduled) documents autosave into a private pending draft;
34
+ // the live version only changes when the user hits Update (applyUpdate).
28
35
 
29
36
  type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
30
37
 
@@ -36,6 +43,9 @@ interface Props {
36
43
  unsplashEnabled: boolean;
37
44
  previewPath: string;
38
45
  sharePath: string;
46
+ // public URL of the LIVE version — may differ from the staged slug while
47
+ // edits are pending; falls back to the editor state's URL when omitted
48
+ livePath?: string;
39
49
  // theme-provided template names for this document kind; empty = no picker
40
50
  templates?: string[];
41
51
  }
@@ -112,9 +122,12 @@ function joinBrokenTableRows(text: string): string {
112
122
  return out.join('\n');
113
123
  }
114
124
 
115
- export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, templates = [] }: Props) {
125
+ export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [] }: Props) {
116
126
  const doc = (kind === 'page' ? page : post)!;
117
127
  const docId = doc.id;
128
+ // pending = staged edits exist that the live site doesn't show yet
129
+ const [pending, setPending] = useState(() => Boolean(doc.draft));
130
+ const [livePath, setLivePath] = useState(livePathProp ?? '');
118
131
 
119
132
  const [meta, setMetaState] = useState<Meta>(() =>
120
133
  kind === 'page'
@@ -154,8 +167,13 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
154
167
  const [scheduleDraft, setScheduleDraft] = useState('');
155
168
  const [linkEdit, setLinkEdit] = useState(false);
156
169
  const [linkDraft, setLinkDraft] = useState('');
170
+ const [linkResults, setLinkResults] = useState<{ kind: string; title: string; url: string }[]>([]);
171
+ const [linkSel, setLinkSel] = useState(-1);
157
172
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
158
173
  const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
174
+ // saved snippets for the "/" menu; a ref so the (non-React) suggestion
175
+ // plugin always reads the current list
176
+ const snippetsRef = useRef<SlashSnippet[]>([]);
159
177
 
160
178
  const setSave = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
161
179
  const toggleSplit = () => {
@@ -168,11 +186,12 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
168
186
  const editor = useEditor({
169
187
  extensions: [
170
188
  StarterKit.configure({
171
- heading: { levels: [2, 3] },
189
+ heading: { levels: [1, 2, 3] },
172
190
  link: { openOnClick: false },
173
191
  codeBlock: false,
174
192
  }),
175
193
  CodeBlock,
194
+ Highlight,
176
195
  Placeholder.configure({
177
196
  // Only the empty document gets the invitation; empty paragraphs in an
178
197
  // existing doc get the floating quick-insert menu instead (the two
@@ -189,6 +208,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
189
208
  Callout,
190
209
  HtmlCard,
191
210
  PostListCard,
211
+ ToggleCard,
212
+ TocCard,
192
213
  CalendarCard,
193
214
  InfoCards,
194
215
  FileCard,
@@ -200,6 +221,11 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
200
221
  pickerRangeRef.current = range;
201
222
  setPicker('editor');
202
223
  },
224
+ getSnippets: () => snippetsRef.current,
225
+ deleteSnippet: (id) => {
226
+ snippetsRef.current = snippetsRef.current.filter((s) => s.id !== id);
227
+ actions.snippets.delete({ id });
228
+ },
203
229
  },
204
230
  }),
205
231
  ],
@@ -220,8 +246,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
220
246
  return true;
221
247
  }
222
248
  }
223
- // plain-text markdown → rich content; headings clamp to the editor's
224
- // h2/h3 schema. Rich clipboards keep ProseMirror's own HTML handling,
249
+ // plain-text markdown → rich content; h4–h6 clamp to h3, the editor's
250
+ // deepest level. Rich clipboards keep ProseMirror's own HTML handling,
225
251
  // EXCEPT when the plain text carries a markdown table — copied
226
252
  // markdown source usually has an HTML flavor alongside it (code
227
253
  // editors, chat apps, this editor's own output) that would otherwise
@@ -231,7 +257,6 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
231
257
  const hasMarkdownTable = /^\s*\|.+\|\s*\n\s*\|(\s*:?-+:?\s*\|)+\s*$/m.test(joined);
232
258
  if (text && looksLikeMarkdown(text) && (!html || hasMarkdownTable)) {
233
259
  const rich = renderMarkdown(joined)
234
- .replace(/<(\/?)h1\b[^>]*>/g, '<$1h2>')
235
260
  .replace(/<(\/?)h[4-6]\b[^>]*>/g, '<$1h3>');
236
261
  editorRef.current?.chain().insertContent(rich).run();
237
262
  return true;
@@ -271,8 +296,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
271
296
  setSave('error');
272
297
  return;
273
298
  }
274
- // server normalizes the locale (default stores as '')
275
- const p = r.data.page;
299
+ // server normalizes the locale (default stores as ''); a staged save
300
+ // returns the live row + pending draft, so sync from the merged view
301
+ const p = mergePageDraft(r.data.page);
302
+ setPending(Boolean(r.data.page.draft));
276
303
  setMetaState((prev) => {
277
304
  const next = { ...prev, slug: p.slug, isHome: p.isHome, locale: p.locale };
278
305
  metaRef.current = next;
@@ -303,8 +330,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
303
330
  setSave('error');
304
331
  return;
305
332
  }
306
- // server may have re-slugified / auto-set publish date
307
- const p = r.data.post;
333
+ // server may have re-slugified / auto-set publish date; a staged save
334
+ // returns the live row + pending draft, so sync from the merged view
335
+ const p = mergePostDraft(r.data.post);
336
+ setPending(Boolean(r.data.post.draft));
308
337
  setMetaState((prev) => {
309
338
  const next = { ...prev, slug: p.slug, publishedAt: p.publishedAt, collection: p.collection };
310
339
  metaRef.current = next;
@@ -330,6 +359,26 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
330
359
  queueSave();
331
360
  }, [queueSave]);
332
361
 
362
+ // Saved snippets for the "/" menu, loaded once per mount.
363
+ useEffect(() => {
364
+ actions.snippets.list({}).then((r) => {
365
+ if (!r.error) snippetsRef.current = r.data.snippets;
366
+ });
367
+ }, []);
368
+
369
+ const saveSnippet = useCallback(async () => {
370
+ const ed = editorRef.current;
371
+ if (!ed || ed.state.selection.empty) return;
372
+ const name = window.prompt('Snippet name — it will appear in the “/” menu:');
373
+ if (!name?.trim()) return;
374
+ const nodes = ed.state.selection.content().content.toJSON() as unknown[] | null;
375
+ if (!nodes?.length) return;
376
+ const r = await actions.snippets.create({ name: name.trim(), contentJson: JSON.stringify(nodes) });
377
+ if (r.error) return;
378
+ snippetsRef.current = [...snippetsRef.current, r.data.snippet].sort((a, b) => a.name.localeCompare(b.name));
379
+ showToast({ text: `Snippet “${r.data.snippet.name}” saved — insert it with /` });
380
+ }, []);
381
+
333
382
  // Author + tag-registry options for the settings sidebar, loaded on first open.
334
383
  const sidebarLoaded = useRef(false);
335
384
  useEffect(() => {
@@ -353,6 +402,29 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
353
402
  setLinkEdit(true);
354
403
  }, []);
355
404
 
405
+ const applyLink = useCallback((raw: string) => {
406
+ const ed = editorRef.current;
407
+ if (!ed) return;
408
+ const url = raw.trim();
409
+ if (url === '' || url === 'https://') ed.chain().focus().extendMarkRange('link').unsetLink().run();
410
+ else ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
411
+ setLinkEdit(false);
412
+ }, []);
413
+
414
+ // Internal link search: typing a title into the ⌘K box surfaces published
415
+ // posts/pages to link to; URL-ish input (or the seeded https://) skips it.
416
+ useEffect(() => {
417
+ if (!linkEdit) { setLinkResults([]); setLinkSel(-1); return; }
418
+ const q = linkDraft.trim();
419
+ if (q.length < 2 || /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(q)) { setLinkResults([]); setLinkSel(-1); return; }
420
+ const t = setTimeout(() => {
421
+ actions.linkTargets.search({ query: q }).then((r) => {
422
+ if (!r.error) { setLinkResults(r.data.targets); setLinkSel(r.data.targets.length ? 0 : -1); }
423
+ });
424
+ }, 200);
425
+ return () => clearTimeout(t);
426
+ }, [linkDraft, linkEdit]);
427
+
356
428
  // Cmd+S flush, Cmd+K link, Cmd+/ shortcut sheet + unsaved-changes guard
357
429
  useEffect(() => {
358
430
  const onKey = (e: KeyboardEvent) => {
@@ -389,13 +461,22 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
389
461
  return kind === 'post' ? collectionUrl(m.collection, m.slug) : m.isHome ? localeHomeUrl(m.locale) : `/${m.slug}`;
390
462
  }
391
463
 
464
+ function showToast(msg: { text: string; href?: string }) {
465
+ setToast(msg);
466
+ if (toastTimer.current) clearTimeout(toastTimer.current);
467
+ toastTimer.current = setTimeout(() => setToast(null), 4500);
468
+ }
469
+
392
470
  async function transition(patch: Partial<Meta>) {
393
471
  setPublishOpen(false);
394
472
  const next = { ...metaRef.current, ...patch };
395
473
  metaRef.current = next;
396
474
  setMetaState(next);
475
+ // a status change writes the full editor state live (any staged edits are
476
+ // folded in — the payload IS the staged content) and clears the draft
397
477
  await save(patch);
398
478
  if (saveStateRef.current === 'error') return;
479
+ if (patch.status === 'published') setLivePath(publicPath(next));
399
480
  const messages: Partial<Record<Status, { text: string; href?: string }>> = {
400
481
  published: { text: `${kind === 'page' ? 'Page' : (CONTENT.collections[next.collection] ?? CONTENT.posts).singular} published`, href: publicPath(next) },
401
482
  scheduled: { text: `Scheduled for ${next.scheduledAt ? new Date(next.scheduledAt).toLocaleString() : 'later'}` },
@@ -403,20 +484,54 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
403
484
  archived: { text: 'Archived — hidden from the site' },
404
485
  };
405
486
  const msg = patch.status ? messages[patch.status] : undefined;
406
- if (msg) {
407
- setToast(msg);
408
- if (toastTimer.current) clearTimeout(toastTimer.current);
409
- toastTimer.current = setTimeout(() => setToast(null), 4500);
487
+ if (msg) showToast(msg);
488
+ }
489
+
490
+ // Update button: flush the current edits into the pending draft, then ask
491
+ // the server to copy the draft onto the live version.
492
+ async function applyUpdate() {
493
+ setPublishOpen(false);
494
+ await save();
495
+ if (saveStateRef.current === 'error') return;
496
+ const r = kind === 'page'
497
+ ? await actions.pages.applyUpdate({ id: docId })
498
+ : await actions.posts.applyUpdate({ id: docId });
499
+ if (r.error) {
500
+ setSave('error');
501
+ return;
502
+ }
503
+ setPending(false);
504
+ const href = publicPath(metaRef.current);
505
+ setLivePath(href);
506
+ setPreviewTick((t) => t + 1);
507
+ showToast({ text: 'Changes are live', href });
508
+ }
509
+
510
+ async function discardChanges() {
511
+ if (!window.confirm(`Discard the pending changes and go back to the live ${noun}? This cannot be undone.`)) return;
512
+ setPublishOpen(false);
513
+ // cancel any queued autosave so it can't re-stage the edits after the
514
+ // discard, and clear the dirty flag so the reload isn't blocked
515
+ if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
516
+ setSave('saved');
517
+ const r = kind === 'page'
518
+ ? await actions.pages.discardChanges({ id: docId })
519
+ : await actions.posts.discardChanges({ id: docId });
520
+ if (r.error) {
521
+ setSave('error');
522
+ return;
410
523
  }
524
+ location.reload(); // rehydrate the editor from the live version
411
525
  }
412
526
 
413
527
  const words = editor?.storage.characterCount?.words() ?? 0;
414
528
  const saveLabel =
415
- saveState === 'saved' ? 'Saved' :
529
+ saveState === 'saved' ? (pending ? 'Saved — not live yet' : 'Saved') :
416
530
  saveState === 'saving' ? 'Saving…' :
417
531
  saveState === 'dirty' ? 'Unsaved changes' : 'Save failed — retrying on next change';
418
532
 
419
533
  const publishLabel = meta.status === 'published' ? 'Update' : 'Publish';
534
+ const liveHref = livePath || publicPath(meta);
420
535
 
421
536
  return (
422
537
  <div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
@@ -431,7 +546,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
431
546
  <div className="pe-topbar-left">
432
547
  <a className="pe-btn ghost sm" href={listHref}>{kind === 'page' ? '‹ Pages' : '‹ Posts'}</a>
433
548
  <span className={`pe-savestate ${saveState}`}>
434
- {STATUS_LABEL[meta.status]} — {saveLabel}
549
+ {STATUS_LABEL[meta.status]}{pending && <em className="pe-edited-dot"> · edited</em>} — {saveLabel}
435
550
  </span>
436
551
  </div>
437
552
  <div className="pe-topbar-right">
@@ -492,16 +607,29 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
492
607
  {meta.status === 'scheduled' && (
493
608
  <>
494
609
  <div className="pe-pop-head">
495
- <b>Scheduled</b>
496
- <span>Goes live {meta.scheduledAt ? new Date(meta.scheduledAt).toLocaleString() : ''}.</span>
610
+ <b>Scheduled{pending ? ' · unpublished edits' : ''}</b>
611
+ <span>
612
+ Goes live {meta.scheduledAt ? new Date(meta.scheduledAt).toLocaleString() : ''}.
613
+ {pending && ' Edits are saved privately — press Update to include them.'}
614
+ </span>
497
615
  </div>
616
+ {pending && (
617
+ <button type="button" className="pe-btn primary pe-pop-cta" onClick={applyUpdate}>
618
+ Update scheduled version
619
+ </button>
620
+ )}
498
621
  <button
499
622
  type="button"
500
- className="pe-btn primary pe-pop-cta"
623
+ className={`pe-btn ${pending ? '' : 'primary '}pe-pop-cta`}
501
624
  onClick={() => transition({ status: 'published', scheduledAt: null })}
502
625
  >
503
626
  Publish now instead
504
627
  </button>
628
+ {pending && (
629
+ <button type="button" className="pe-pop-item" onClick={discardChanges}>
630
+ <b>Discard pending changes</b><span>Back to the scheduled version</span>
631
+ </button>
632
+ )}
505
633
  <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft', scheduledAt: null })}>
506
634
  <b>Unschedule</b><span>Back to a private draft</span>
507
635
  </button>
@@ -510,12 +638,26 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
510
638
  {meta.status === 'published' && (
511
639
  <>
512
640
  <div className="pe-pop-head">
513
- <b>Published</b>
514
- <span>Live on the site — edits save and go live automatically.</span>
641
+ <b>{pending ? 'Published · unpublished edits' : 'Published'}</b>
642
+ <span>
643
+ {pending
644
+ ? `Changes are saved privately — the live ${noun} hasn't changed yet.`
645
+ : 'Live on the site — edits stay private until you press Update.'}
646
+ </span>
515
647
  </div>
516
- <a className="pe-btn pe-pop-cta" href={publicPath(meta)} target="_blank" rel="noopener">
517
- View on site ↗
648
+ {pending && (
649
+ <button type="button" className="pe-btn primary pe-pop-cta" onClick={applyUpdate}>
650
+ Update — push changes live
651
+ </button>
652
+ )}
653
+ <a className="pe-btn pe-pop-cta" href={liveHref} target="_blank" rel="noopener">
654
+ {pending ? 'View live version ↗' : 'View on site ↗'}
518
655
  </a>
656
+ {pending && (
657
+ <button type="button" className="pe-pop-item" onClick={discardChanges}>
658
+ <b>Discard pending changes</b><span>Back to the live version</span>
659
+ </button>
660
+ )}
519
661
  <div className="pe-pop-sep" />
520
662
  <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft' })}>
521
663
  <b>Unpublish</b><span>Revert to a private draft</span>
@@ -604,10 +746,22 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
604
746
  <button type="button" className={editor.isActive('italic') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleItalic().run()}><i>I</i></button>
605
747
  <button type="button" className={editor.isActive('strike') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleStrike().run()}><s>S</s></button>
606
748
  <button type="button" className={editor.isActive('code') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleCode().run()}>{'<>'}</button>
749
+ <button type="button" className={editor.isActive('highlight') ? 'is-active' : ''} title="Highlight (==text==)" onClick={() => editor.chain().focus().toggleHighlight().run()}><mark>H</mark></button>
607
750
  <span className="pe-bubble-sep" />
751
+ <button type="button" className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</button>
608
752
  <button type="button" className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
609
753
  <button type="button" className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
610
754
  <button type="button" className={editor.isActive('blockquote') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBlockquote().run()}>❝</button>
755
+ <button
756
+ type="button"
757
+ title="Regular text"
758
+ onClick={() => {
759
+ const c = editor.chain().focus().setParagraph();
760
+ (editor.isActive('blockquote') ? c.toggleBlockquote() : c).run();
761
+ }}
762
+ >
763
+
764
+ </button>
611
765
  <span className="pe-bubble-sep" />
612
766
  <button
613
767
  type="button"
@@ -617,6 +771,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
617
771
  >
618
772
  Link
619
773
  </button>
774
+ <span className="pe-bubble-sep" />
775
+ <button type="button" title="Save selection as a snippet — reusable from the “/” menu" onClick={saveSnippet}>
776
+ + Snippet
777
+ </button>
620
778
  </BubbleMenu>
621
779
  )}
622
780
 
@@ -661,6 +819,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
661
819
  ['File', () => editor.chain().focus().insertContent({ type: 'fileCard' }).run()],
662
820
  ['Button', () => editor.chain().focus().insertContent({ type: 'buttonCard' }).run()],
663
821
  ['Callout', () => editor.chain().focus().insertContent({ type: 'callout' }).run()],
822
+ ['Toggle', () => editor.chain().focus().insertContent({ type: 'toggleCard', content: [{ type: 'paragraph' }] }).run()],
664
823
  ['Calendar', () => editor.chain().focus().insertContent({ type: 'calendarCard' }).run()],
665
824
  ['Info cards', () => editor.chain().focus().insertContent({ type: 'infoCards' }).run()],
666
825
  ['Table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()],
@@ -675,42 +834,55 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
675
834
 
676
835
  {linkEdit && editor && (
677
836
  <div className="pe-linkedit" role="dialog" aria-label="Edit link">
678
- <input
679
- autoFocus
680
- type="url"
681
- placeholder="https://… (empty removes the link)"
682
- value={linkDraft}
683
- onChange={(e) => setLinkDraft(e.target.value)}
684
- onKeyDown={(e) => {
685
- if (e.key === 'Enter') {
686
- e.preventDefault();
687
- const url = linkDraft.trim();
688
- if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
689
- else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
690
- setLinkEdit(false);
691
- }
692
- if (e.key === 'Escape') { e.preventDefault(); setLinkEdit(false); editor.commands.focus(); }
693
- }}
694
- />
695
- <button
696
- type="button"
697
- className="pe-btn primary sm"
698
- onClick={() => {
699
- const url = linkDraft.trim();
700
- if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
701
- else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
702
- setLinkEdit(false);
703
- }}
704
- >
705
- Apply
706
- </button>
707
- <button type="button" className="pe-btn ghost sm" onClick={() => { setLinkEdit(false); editor.commands.focus(); }}>✕</button>
837
+ <div className="pe-linkedit-row">
838
+ <input
839
+ autoFocus
840
+ type="text"
841
+ placeholder="Paste a URL, or type to search your content… (empty removes the link)"
842
+ value={linkDraft}
843
+ onChange={(e) => setLinkDraft(e.target.value)}
844
+ onKeyDown={(e) => {
845
+ if (e.key === 'ArrowDown' && linkResults.length) { e.preventDefault(); setLinkSel((s) => (s + 1) % linkResults.length); }
846
+ if (e.key === 'ArrowUp' && linkResults.length) { e.preventDefault(); setLinkSel((s) => (s - 1 + linkResults.length) % linkResults.length); }
847
+ if (e.key === 'Enter') {
848
+ e.preventDefault();
849
+ applyLink(linkSel >= 0 && linkResults[linkSel] ? linkResults[linkSel].url : linkDraft);
850
+ }
851
+ if (e.key === 'Escape') { e.preventDefault(); setLinkEdit(false); editor.commands.focus(); }
852
+ }}
853
+ />
854
+ <button
855
+ type="button"
856
+ className="pe-btn primary sm"
857
+ onClick={() => applyLink(linkSel >= 0 && linkResults[linkSel] ? linkResults[linkSel].url : linkDraft)}
858
+ >
859
+ Apply
860
+ </button>
861
+ <button type="button" className="pe-btn ghost sm" onClick={() => { setLinkEdit(false); editor.commands.focus(); }}>✕</button>
862
+ </div>
863
+ {linkResults.length > 0 && (
864
+ <div className="pe-link-results">
865
+ {linkResults.map((t, i) => (
866
+ <button
867
+ key={t.url}
868
+ type="button"
869
+ className={i === linkSel ? 'is-selected' : ''}
870
+ onMouseEnter={() => setLinkSel(i)}
871
+ onMouseDown={(e) => { e.preventDefault(); applyLink(t.url); }}
872
+ >
873
+ <span className="pe-link-title">{t.title}</span>
874
+ <span className="pe-link-url">{t.url}</span>
875
+ <span className="pe-link-kind">{t.kind}</span>
876
+ </button>
877
+ ))}
878
+ </div>
879
+ )}
708
880
  </div>
709
881
  )}
710
882
 
711
883
  <EditorContent editor={editor} />
712
884
  <div className="pe-wordcount">
713
- {words.toLocaleString()} words
885
+ {words.toLocaleString()} words{words > 0 && ` · ${Math.max(1, Math.round(words / 220))} min read`}
714
886
  <button type="button" className="pe-shortcuts-btn" title="Keyboard shortcuts (⌘/)" onClick={() => setShortcutsOpen(true)}>⌘/</button>
715
887
  </div>
716
888
 
@@ -726,7 +898,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
726
898
  ['⌘B / ⌘I', 'Bold / italic'],
727
899
  ['⌘⇧X', 'Strikethrough'],
728
900
  ['⌘E', 'Inline code'],
729
- ['⌘K', 'Add or edit a link'],
901
+ ['==text==', 'Highlight'],
902
+ ['⌘K', 'Link — paste a URL or search your own content'],
730
903
  ['/', 'Insert a card (image, gallery, embed…)'],
731
904
  ]],
732
905
  ['Markdown as you type', [
@@ -2,6 +2,7 @@ import { Node } from '@tiptap/react';
2
2
  import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
4
  import { useState } from 'react';
5
+ import { cleanEventName } from '../../../lib/analytics';
5
6
 
6
7
  // Card nodes shared by the post editor. renderHTML emits the public markup
7
8
  // (`.pc-*` classes, styled in src/styles/cards.css); parseHTML round-trips it
@@ -292,6 +293,8 @@ export const ButtonCard = Node.create({
292
293
  label: { default: '' },
293
294
  href: { default: '' },
294
295
  align: { default: 'center' },
296
+ // optional analytics event name → data-umami-event on the public <a>
297
+ event: { default: '' },
295
298
  };
296
299
  },
297
300
 
@@ -306,6 +309,7 @@ export const ButtonCard = Node.create({
306
309
  label: link?.textContent ?? '',
307
310
  href: link?.getAttribute('href') ?? '',
308
311
  align: e.getAttribute('data-align') ?? 'center',
312
+ event: link?.getAttribute('data-umami-event') ?? '',
309
313
  };
310
314
  },
311
315
  },
@@ -314,10 +318,15 @@ export const ButtonCard = Node.create({
314
318
 
315
319
  renderHTML({ node }) {
316
320
  const a = node.attrs;
321
+ const event = cleanEventName(a.event ?? '');
317
322
  return [
318
323
  'div',
319
324
  { class: 'pc-buttoncard', 'data-align': a.align, style: `text-align:${a.align}` },
320
- ['a', { class: 'pc-btncard', href: a.href }, a.label || 'Button'],
325
+ [
326
+ 'a',
327
+ { class: 'pc-btncard', href: a.href, ...(event ? { 'data-umami-event': event } : {}) },
328
+ a.label || 'Button',
329
+ ],
321
330
  ];
322
331
  },
323
332
 
@@ -353,6 +362,13 @@ function ButtonCardView({ node, updateAttributes, deleteNode, selected }: NodeVi
353
362
  value={a.href}
354
363
  onChange={(e) => updateAttributes({ href: e.target.value })}
355
364
  />
365
+ <input
366
+ type="text"
367
+ placeholder="Analytics event (optional)"
368
+ title="Clicks are counted under this name in Umami (and forwarded to enabled ad pixels)"
369
+ value={a.event}
370
+ onChange={(e) => updateAttributes({ event: e.target.value })}
371
+ />
356
372
  </div>
357
373
  <div className="pc-buttoncard" style={{ textAlign: a.align as 'center' | 'left' }}>
358
374
  <span className="pc-btncard">{a.label || 'Button'}</span>
@@ -54,7 +54,7 @@ function CodeBlockView({ node, updateAttributes }: NodeViewProps) {
54
54
  ))}
55
55
  </select>
56
56
  <pre>
57
- <NodeViewContent as="code" />
57
+ <NodeViewContent<'code'> as="code" />
58
58
  </pre>
59
59
  </NodeViewWrapper>
60
60
  );
@@ -1,6 +1,7 @@
1
1
  import { Node } from '@tiptap/core';
2
2
  import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
+ import { cleanEventName } from '../../../lib/analytics';
4
5
 
5
6
  // Info cards: a *static* grid of title / badge / description / detail-line
6
7
  // cards — services, opening hours, team members, feature grids. Unlike the
@@ -25,6 +26,7 @@ export interface InfoItem {
25
26
  badgeStyle: 'filled' | 'outline';
26
27
  description: string;
27
28
  href: string; // empty = plain card; set = whole card is a link
29
+ event: string; // optional analytics event name (linked cards only)
28
30
  lines: InfoLine[];
29
31
  }
30
32
 
@@ -67,6 +69,7 @@ const sanitizeItem = (raw: unknown): InfoItem | null => {
67
69
  badgeStyle: it.badgeStyle === 'outline' ? 'outline' : 'filled',
68
70
  description: str(it.description),
69
71
  href: str(it.href),
72
+ event: str(it.event),
70
73
  lines,
71
74
  };
72
75
  };
@@ -90,6 +93,7 @@ const newItem = (): InfoItem => ({
90
93
  badgeStyle: 'filled',
91
94
  description: '',
92
95
  href: '',
96
+ event: '',
93
97
  lines: [
94
98
  { icon: 'calendar', text: '' },
95
99
  { icon: 'clock', text: '' },
@@ -140,8 +144,9 @@ export const InfoCards = Node.create({
140
144
  ]);
141
145
  }
142
146
  const href = it.href.trim();
147
+ const event = cleanEventName(it.event ?? '');
143
148
  return href
144
- ? ['a', { class: 'pc-info-card', href }, ...children]
149
+ ? ['a', { class: 'pc-info-card', href, ...(event ? { 'data-umami-event': event } : {}) }, ...children]
145
150
  : ['article', { class: 'pc-info-card' }, ...children];
146
151
  });
147
152
  return ['div', { class: 'pc-infocards', 'data-items': JSON.stringify(items) }, ...cards];
@@ -256,6 +261,16 @@ function InfoCardsView({ node, updateAttributes, deleteNode, selected }: NodeVie
256
261
  value={it.href}
257
262
  onChange={(e) => patch(i, { href: e.target.value })}
258
263
  />
264
+ {it.href.trim() !== '' && (
265
+ <input
266
+ type="text"
267
+ className="pe-infocard-href"
268
+ placeholder="Analytics event (optional)"
269
+ title="Clicks are counted under this name in Umami (and forwarded to enabled ad pixels)"
270
+ value={it.event}
271
+ onChange={(e) => patch(i, { event: e.target.value })}
272
+ />
273
+ )}
259
274
  </div>
260
275
  </div>
261
276
  ))}