@pramen/cms-editor 0.0.41 → 0.0.43

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": "@pramen/cms-editor",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,17 +20,26 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@buzola/router": "^0.0.12",
23
- "@podoba/react": "^0.0.3",
24
- "@podoba/tokens": "^0.0.3",
25
- "@podoba/tailwind": "^0.0.3",
23
+ "@podoba/react": "^0.0.32",
24
+ "@podoba/tokens": "^0.0.32",
25
+ "@podoba/tailwind": "^0.0.32",
26
+ "@tiptap/react": "^3.27.4",
27
+ "@tiptap/pm": "^3.27.4",
28
+ "@tiptap/starter-kit": "^3.27.4",
29
+ "@tiptap/extension-placeholder": "^3.27.4",
30
+ "@tiptap/extension-highlight": "^3.27.4",
31
+ "@tiptap/extension-task-list": "^3.27.4",
32
+ "@tiptap/extension-task-item": "^3.27.4",
26
33
  "react": "^19.0.0",
27
34
  "react-dom": "^19.0.0"
28
35
  },
29
36
  "devDependencies": {
30
37
  "@buzola/bun-plugin": "^0.0.12",
31
38
  "@buzola/codegen": "^0.0.12",
39
+ "@tailwindcss/cli": "^4.0.0",
40
+ "@tailwindcss/typography": "^0.5.16",
32
41
  "@types/react": "^19.0.0",
33
42
  "@types/react-dom": "^19.0.0",
34
- "tailwindcss": "^3.4.19"
43
+ "tailwindcss": "^4.0.0"
35
44
  }
36
45
  }
package/src/app.css CHANGED
@@ -1,3 +1,12 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
1
+ /* Design system = podoba (Tailwind v4, CSS-first). Order matters: Tailwind first,
2
+ * then @podoba/tokens' CSS variables, then @podoba/tailwind's `@theme inline` that
3
+ * binds those vars to utilities (bg-surface, text-fg-muted, rounded-panel, …). */
4
+ @import "tailwindcss";
5
+ @import "@podoba/tokens/variables.css";
6
+ @import "@podoba/tailwind";
7
+
8
+ /* v4 auto-detects this app's sources, but node_modules is ignored by default —
9
+ * @podoba/react ships TS source with class strings the compiler must scan, so name
10
+ * it explicitly (paths are relative to this file). */
11
+ @source "./**/*.{ts,tsx}";
12
+ @source "../node_modules/@podoba/react/src/**/*.{ts,tsx}";
@@ -2,7 +2,7 @@
2
2
  // route modules under `routes/` are thin adapters: they pull `api`/`me`/`setError` from
3
3
  // the app context and wire URL params + navigation into these components.
4
4
 
5
- import { Button, Input, Textarea } from "@podoba/react";
5
+ import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textarea } from "@podoba/react";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
7
  import { Api, ApiError } from "./api";
8
8
  import { FieldForm } from "./fields";
@@ -33,7 +33,7 @@ function Hero({ lead, em, children }: { lead: string; em: string; children?: Rea
33
33
  function Cta({ text, em, children }: { text: string; em: string; children: ReactNode }) {
34
34
  return (
35
35
  <div className="flex min-w-[360px] items-center gap-4 rounded-full bg-brand-green py-3 pl-7 pr-3 max-[820px]:min-w-0">
36
- <span className="text-lg text-[#0f3a25]">
36
+ <span className="text-callout text-fg-on-brand">
37
37
  {text} <span className="font-semibold">{em}</span>
38
38
  </span>
39
39
  {children}
@@ -46,32 +46,38 @@ function Section({ children }: { children: ReactNode }) {
46
46
  }
47
47
 
48
48
  function Pill({ status, children }: { status?: string; children: ReactNode }) {
49
+ // Token-only colors (no hardcoded hex) so status pills flip correctly under the
50
+ // podoba dark theme. `fg-on-brand` is the AA-safe ink on the fixed brand surfaces.
49
51
  const tone =
50
52
  status === "published" || status === "active"
51
- ? "bg-brand-green text-[#0f3a25] border-transparent"
53
+ ? "bg-brand-green text-fg-on-brand border-transparent"
52
54
  : status === "in_review" || status === "review"
53
- ? "bg-accent-yellow text-[#4a3512] border-transparent"
55
+ ? "bg-accent-yellow text-fg-on-brand border-transparent"
54
56
  : status === "rejected" || status === "archived" || status === "inactive"
55
57
  ? "border-danger text-danger bg-surface-card"
56
58
  : "border-border text-fg-muted bg-surface-card";
57
- return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${tone}`}>{children}</span>;
59
+ return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-caption font-medium ${tone}`}>{children}</span>;
58
60
  }
59
61
 
62
+ // Modal chrome on podoba's shared overlay: focus trap, Esc-to-close, backdrop
63
+ // dismiss, the token blur-scrim and enter/exit animation — all for free. The caller
64
+ // API (conditionally mounted + `onClose`) is preserved, so call sites don't change.
60
65
  function Modal({ onClose, wide, children }: { onClose: () => void; wide?: boolean; children: ReactNode }) {
61
66
  return (
62
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(20,15,5,0.28)] p-6" onClick={onClose}>
63
- <div
64
- className={`max-h-[86vh] w-full overflow-auto rounded-panel border border-border bg-surface-card px-9 py-8 shadow-[0_24px_60px_rgba(30,20,10,0.12)] ${wide ? "max-w-[680px]" : "max-w-[520px]"}`}
65
- onClick={(e) => e.stopPropagation()}
66
- >
67
- {children}
68
- </div>
69
- </div>
67
+ <ModalOverlay isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
68
+ <ModalSurface className={`w-full px-9 py-8 ${wide ? "max-w-[680px]" : "max-w-[520px]"}`}>
69
+ <ModalDialog className="max-h-[86vh] overflow-auto outline-none">{children}</ModalDialog>
70
+ </ModalSurface>
71
+ </ModalOverlay>
70
72
  );
71
73
  }
72
74
 
73
75
  function ModalTitle({ children }: { children: ReactNode }) {
74
- return <h2 className="mb-5 text-[28px] font-normal leading-[1.15] text-fg">{children}</h2>;
76
+ return (
77
+ <Heading level="1" className="mb-5 font-normal">
78
+ {children}
79
+ </Heading>
80
+ );
75
81
  }
76
82
 
77
83
  function KV({ children, className }: { children: ReactNode; className?: string }) {
@@ -79,12 +85,14 @@ function KV({ children, className }: { children: ReactNode; className?: string }
79
85
  }
80
86
 
81
87
  function Card({ children }: { children: ReactNode }) {
82
- return <div className="overflow-auto rounded-panel border border-border bg-surface-card p-6">{children}</div>;
88
+ // rounded-lg is the unified podoba card radius (the 1.1rem soft-panel radius was retired).
89
+ return <div className="overflow-auto rounded-lg border border-border bg-surface-card p-6">{children}</div>;
83
90
  }
84
91
 
85
92
  function Banner({ ok, children }: { ok?: boolean; children: ReactNode }) {
93
+ // Token-only colors so the banner reads correctly in dark mode too.
86
94
  return (
87
- <div className={`my-2 rounded-lg border px-3.5 py-2.5 text-[13px] ${ok ? "border-brand-green bg-brand-green/20 text-[#0f3a25]" : "border-danger bg-surface-card text-danger"}`}>{children}</div>
95
+ <div className={`my-2 rounded-lg border px-3.5 py-2.5 text-small ${ok ? "border-brand-green bg-brand-green/20 text-fg" : "border-danger bg-surface-card text-danger"}`}>{children}</div>
88
96
  );
89
97
  }
90
98
 
@@ -386,11 +394,20 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
386
394
  const patchRegion = (region: string, fn: (list: RenderedBlock[]) => RenderedBlock[]) =>
387
395
  setAssembled((prev) => (prev ? { ...prev, regions: { ...prev.regions, [region]: fn(prev.regions[region] ?? []) } } : prev));
388
396
 
389
- const addBlock = async (region: string, slug: string) => {
397
+ // Add a block, optionally AT an index (`at`) rather than appended — the Notion-style
398
+ // insert-between. addBlock always appends server-side, so when `at` lands mid-list we
399
+ // follow up with a reorderRegion to move the new placement into place.
400
+ const addBlock = async (region: string, slug: string, at?: number) => {
390
401
  // Optimistic: show a placeholder immediately (no wait for the round trip), then
391
402
  // reconcile with the persisted row addBlock echoes — or roll it back on failure.
392
403
  const tempId = `tmp:${crypto.randomUUID()}`;
393
- patchRegion(region, (list) => [...list, { id: tempId, block_id: tempId, block_type: slug, title: null, fields: {}, is_shared: false, pending: true }]);
404
+ const existingIds = (assembled?.regions[region] ?? []).map((b) => b.id);
405
+ const index = at != null && at >= 0 && at < existingIds.length ? at : existingIds.length;
406
+ patchRegion(region, (list) => {
407
+ const next = [...list];
408
+ next.splice(index, 0, { id: tempId, block_id: tempId, block_type: slug, title: null, fields: {}, is_shared: false, pending: true });
409
+ return next;
410
+ });
394
411
  try {
395
412
  const { block, placement } = await api.call<{
396
413
  block: { id: string; title?: string | null; fields?: Record<string, unknown> | null };
@@ -405,6 +422,13 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
405
422
  is_shared: Boolean(placement.isShared),
406
423
  };
407
424
  patchRegion(region, (list) => list.map((b) => (b.id === tempId ? rb : b)));
425
+ // Inserted mid-list: the server appended, so persist the intended order (this
426
+ // reloads, like a drag-reorder does).
427
+ if (index < existingIds.length) {
428
+ const order = [...existingIds];
429
+ order.splice(index, 0, rb.id);
430
+ await reorder(region, order);
431
+ }
408
432
  } catch (e) {
409
433
  patchRegion(region, (list) => list.filter((b) => b.id !== tempId));
410
434
  setErr(errMsg(e));
@@ -473,37 +497,44 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
473
497
  </div>
474
498
  </div>
475
499
 
476
- <div className="overflow-auto py-1.5">
500
+ {/* The canvas: one inline document. `pl-8` reserves the left gutter that each
501
+ block's drag handle occupies on hover. Regions are titled sections. */}
502
+ <div className="overflow-auto py-1.5 pl-8 pr-2">
477
503
  {err ? <Banner>{err}</Banner> : null}
478
504
  {regions.map((r) => {
479
505
  const blocks = assembled?.regions[r.name] ?? [];
480
506
  const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
481
507
  return (
482
- <div className="mb-8" key={r.name}>
483
- <h3 className="m-0 mb-3 text-[22px] font-normal text-fg">{r.label ?? r.name}</h3>
484
- {blocks.length === 0 ? <p className="mb-3 text-sm text-fg-subtle">No blocks yet — add one below.</p> : null}
508
+ <div className="mb-10" key={r.name}>
509
+ <div className="mb-1 text-caption font-medium uppercase tracking-wide text-fg-subtle">{r.label ?? r.name}</div>
485
510
  {blocks.map((b, i) => (
486
- <BlockCard
487
- key={b.id}
488
- api={api}
489
- block={b}
490
- blockType={btBySlug.get(b.block_type)}
491
- isFirst={i === 0}
492
- isLast={i === blocks.length - 1}
493
- onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
494
- onRemove={() => removeBlock(b)}
495
- onPatch={patchBlockFields}
496
- onDirtyChange={reportDirty}
497
- onError={setErr}
498
- dragging={drag?.region === r.name && drag.from === i}
499
- isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
500
- onDragStartBlock={() => beginDrag(r.name, i)}
501
- onDragOverBlock={() => hoverDrag(r.name, i)}
502
- onDropBlock={() => dropDrag(r.name)}
503
- onDragEndBlock={cancelDrag}
504
- />
511
+ <div key={b.id}>
512
+ {/* Between-blocks insert point — a hover "+" that adds AT index i. */}
513
+ <Inserter compact allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug, i)} />
514
+ <BlockCard
515
+ api={api}
516
+ block={b}
517
+ blockType={btBySlug.get(b.block_type)}
518
+ isFirst={i === 0}
519
+ isLast={i === blocks.length - 1}
520
+ onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
521
+ onRemove={() => removeBlock(b)}
522
+ onPatch={patchBlockFields}
523
+ onDirtyChange={reportDirty}
524
+ onError={setErr}
525
+ dragging={drag?.region === r.name && drag.from === i}
526
+ isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
527
+ onDragStartBlock={() => beginDrag(r.name, i)}
528
+ onDragOverBlock={() => hoverDrag(r.name, i)}
529
+ onDropBlock={() => dropDrag(r.name)}
530
+ onDragEndBlock={cancelDrag}
531
+ />
532
+ </div>
505
533
  ))}
506
- <AddBlock allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
534
+ {/* End inserter appends. */}
535
+ <div className="mt-1">
536
+ <Inserter allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
537
+ </div>
507
538
  </div>
508
539
  );
509
540
  })}
@@ -573,7 +604,9 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
573
604
 
574
605
  const dirty = fields != null && JSON.stringify(fields) !== saved.current;
575
606
 
576
- // Explicit save — the ONLY thing that writes. No autosave, no save-on-blur, no save-on-unmount.
607
+ // Writes the block. Fired by the debounced autosave below AND by the manual "Save
608
+ // now" button (which also serves as the retry path if an autosave fails). Idempotent:
609
+ // no-ops when nothing changed since the last persist.
577
610
  const save = useCallback(async () => {
578
611
  if (block.pending || fields == null) return;
579
612
  const snapshot = JSON.stringify(fields);
@@ -591,6 +624,20 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
591
624
  }
592
625
  }, [api, blockId, placementId, fields, onPatch, onError, block.pending]);
593
626
 
627
+ // Debounced autosave — persist ~800ms after the last edit (Notion-style). Keyed on
628
+ // `fields`, so it fires only on an actual edit: a failed save leaves `fields`
629
+ // unchanged and does NOT auto-retry (no hot loop) — the next edit, or the manual
630
+ // Save button, retries. The editor's unsaved guard still covers the debounce window
631
+ // if you navigate away mid-edit.
632
+ const saveRef = useRef(save);
633
+ saveRef.current = save;
634
+ useEffect(() => {
635
+ if (fields == null || block.pending) return;
636
+ if (JSON.stringify(fields) === saved.current) return; // not dirty
637
+ const t = setTimeout(() => void saveRef.current(), 800);
638
+ return () => clearTimeout(t);
639
+ }, [fields, block.pending]);
640
+
594
641
  const change = (next: Record<string, unknown>) => setFields(next);
595
642
 
596
643
  // Report dirty state up (for the editor's leave/unload guard); clear it on unmount so a
@@ -602,60 +649,62 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
602
649
  const name = blockType?.name ?? block.block_type;
603
650
 
604
651
  return (
605
- // The card is the drop target; the handle is the only draggable element, so
606
- // dragging never fights the inline contentEditable text selection. The handle's drag
607
- // image is set to the whole card so you drag a preview of the block, not just the grip.
652
+ // One inline document row (no card chrome). The row is the drop target; the
653
+ // handle in the hover gutter is the only draggable element, so dragging never
654
+ // fights the inline text selection. Its drag image is the whole row.
608
655
  <div
609
656
  ref={cardRef}
610
- className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""} ${block.pending ? "pointer-events-none opacity-60" : ""}`}
657
+ className={`group relative rounded-lg px-2 py-1 transition-colors ${isOver ? "ring-2 ring-brand-green" : ""} ${dragging ? "opacity-40" : ""} ${block.pending ? "pointer-events-none opacity-60" : ""}`}
611
658
  onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
612
659
  onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
613
660
  >
614
- <div className="flex items-center gap-2.5 px-3.5 py-2.5">
615
- <span
616
- draggable
617
- onDragStart={(e) => {
618
- if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
619
- e.dataTransfer.effectAllowed = "move";
620
- e.dataTransfer.setData("text/plain", "");
621
- onDragStartBlock();
622
- }}
623
- onDragEnd={onDragEndBlock}
624
- className="shrink-0 cursor-grab select-none px-0.5 text-fg-subtle hover:text-fg active:cursor-grabbing"
625
- title="Drag to reorder"
626
- >⠿</span>
661
+ {/* Left gutter drag handle, revealed on hover, sitting in the canvas's pl-8. */}
662
+ <span
663
+ draggable
664
+ onDragStart={(e) => {
665
+ if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
666
+ e.dataTransfer.effectAllowed = "move";
667
+ e.dataTransfer.setData("text/plain", "");
668
+ onDragStartBlock();
669
+ }}
670
+ onDragEnd={onDragEndBlock}
671
+ className="absolute -left-6 top-1.5 cursor-grab select-none px-1 text-fg-subtle opacity-0 transition-opacity hover:text-fg group-hover:opacity-100 active:cursor-grabbing"
672
+ title="Drag to reorder"
673
+ >⠿</span>
674
+
675
+ {/* Header — subtle type label + state + actions, mostly revealed on hover. */}
676
+ <div className="flex items-center gap-2 opacity-60 transition-opacity group-hover:opacity-100">
627
677
  <button type="button" className="w-4 shrink-0 text-fg-subtle hover:text-fg" title={collapsed ? "Expand" : "Collapse"} onClick={() => setCollapsed((c) => !c)}>{collapsed ? "▸" : "▾"}</button>
628
- <span className="font-medium text-fg">{name}</span>
629
- {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
630
- <span className={`text-[11px] ${dirty && saveState !== "saving" ? "text-accent-strong" : "text-fg-subtle"}`}>{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : dirty ? "● unsaved" : ""}</span>
678
+ <span className="text-caption font-medium uppercase tracking-wide text-fg-subtle">{name}</span>
679
+ {block.is_shared ? <span className="text-caption text-accent-strong">shared</span> : null}
680
+ <span className={`text-caption ${dirty && saveState !== "saving" ? "text-accent-strong" : "text-fg-subtle"}`}>{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : dirty ? "● unsaved" : ""}</span>
631
681
  <span className="flex-1" />
632
682
  <Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
633
683
  <Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
634
684
  <Button variant="ghost" size="sm" className="text-danger" onPress={onRemove}>✕</Button>
635
685
  </div>
686
+
636
687
  {collapsed ? (
637
- <div className="cursor-pointer truncate px-3.5 pb-3 text-sm text-fg-subtle" onClick={() => setCollapsed(false)}>
688
+ <div className="cursor-pointer truncate pb-1 pl-6 text-small text-fg-subtle" onClick={() => setCollapsed(false)}>
638
689
  {fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
639
690
  </div>
640
691
  ) : (
641
- <div className="border-t border-border px-3.5 py-3.5">
692
+ <div className="pb-1 pl-6">
642
693
  {fields == null ? (
643
- <p className="text-sm text-fg-subtle">loading…</p>
694
+ <p className="text-small text-fg-subtle">loading…</p>
644
695
  ) : schema.length === 0 ? (
645
- <p className="text-sm text-fg-subtle">This block has no editable fields.</p>
696
+ <p className="text-small text-fg-subtle">This block has no editable fields.</p>
646
697
  ) : (
647
698
  <>
648
699
  <FieldForm schema={schema} value={fields} onChange={change} api={api} />
649
- <div className="mt-3.5 flex items-center gap-2 border-t border-border pt-3">
650
- <Button size="sm" onPress={() => void save()} isDisabled={!dirty || saveState === "saving" || block.pending}>
651
- {saveState === "saving" ? "Saving…" : "Save"}
652
- </Button>
653
- {dirty ? (
654
- <span className="text-[11px] text-accent-strong">Unsaved changes</span>
655
- ) : saveState === "saved" ? (
656
- <span className="text-[11px] text-fg-subtle">Saved ✓</span>
657
- ) : null}
658
- </div>
700
+ {dirty || saveState === "saving" ? (
701
+ <div className="mt-2 flex items-center gap-2">
702
+ <Button variant="secondary" size="sm" onPress={() => void save()} isDisabled={!dirty || saveState === "saving" || block.pending}>
703
+ {saveState === "saving" ? "Saving…" : "Save now"}
704
+ </Button>
705
+ <span className="text-caption text-fg-subtle">{saveState === "saving" ? "" : "Autosaving…"}</span>
706
+ </div>
707
+ ) : null}
659
708
  </>
660
709
  )}
661
710
  </div>
@@ -664,21 +713,91 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
664
713
  );
665
714
  }
666
715
 
667
- // "+ Add block" reveals a compact picker of the region's allowed types (by friendly name),
668
- // replacing the always-on row of every type as a wall of buttons.
669
- function AddBlock({ allowed, btBySlug, onAdd }: { allowed: string[]; btBySlug: Map<string, BlockType>; onAdd: (slug: string) => void }) {
716
+ // Notion-style block inserter the page-canvas analogue of the BlockEditor's `/`
717
+ // palette. A slim "+ Add block" affordance expands into a searchable list of the
718
+ // region's allowed block types (type to filter; ↑/↓ + Enter to pick, Esc/blur to
719
+ // close). Insertion appends to the region; reorder via drag to position.
720
+ function Inserter({ allowed, btBySlug, onAdd, compact }: { allowed: string[]; btBySlug: Map<string, BlockType>; onAdd: (slug: string) => void; compact?: boolean }) {
670
721
  const [open, setOpen] = useState(false);
671
- if (!open) return <Button variant="secondary" size="sm" onPress={() => setOpen(true)}>+ Add block</Button>;
722
+ const [q, setQ] = useState("");
723
+ const [idx, setIdx] = useState(0);
724
+ const inputRef = useRef<HTMLInputElement>(null);
725
+
726
+ const items = useMemo(() => {
727
+ const list = allowed.map((slug) => ({ slug, name: btBySlug.get(slug)?.name ?? slug }));
728
+ const s = q.trim().toLowerCase();
729
+ return s ? list.filter((x) => x.name.toLowerCase().includes(s) || x.slug.toLowerCase().includes(s)) : list;
730
+ }, [allowed, btBySlug, q]);
731
+
732
+ useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
733
+ useEffect(() => { setIdx(0); }, [q]);
734
+
735
+ const close = () => { setOpen(false); setQ(""); };
736
+ const pick = (slug?: string) => { if (slug) onAdd(slug); close(); };
737
+
738
+ if (!open) {
739
+ // Compact: a thin between-blocks divider that reveals a centered "+" on hover.
740
+ if (compact) {
741
+ return (
742
+ <button
743
+ type="button"
744
+ onClick={() => setOpen(true)}
745
+ aria-label="Insert block here"
746
+ className="group/ins flex h-3 w-full items-center justify-center opacity-0 transition-opacity hover:opacity-100"
747
+ >
748
+ <span className="flex h-full w-full items-center">
749
+ <span className="h-px flex-1 bg-brand-green/40" />
750
+ <span className="mx-1 rounded bg-brand-green/15 px-1 text-caption leading-none text-brand-green">+</span>
751
+ <span className="h-px flex-1 bg-brand-green/40" />
752
+ </span>
753
+ </button>
754
+ );
755
+ }
756
+ return (
757
+ <button
758
+ type="button"
759
+ onClick={() => setOpen(true)}
760
+ className="flex w-full items-center gap-2 rounded-lg px-1 py-1.5 text-left text-small text-fg-subtle opacity-70 transition-colors hover:bg-surface-muted hover:text-fg-muted hover:opacity-100"
761
+ >
762
+ <span className="text-base leading-none">+</span>
763
+ <span>Add block</span>
764
+ </button>
765
+ );
766
+ }
672
767
  return (
673
- <div className="rounded-panel border border-border bg-surface-card p-2.5">
674
- <div className="mb-1.5 px-1 text-[11px] text-fg-subtle">Add a block</div>
675
- <div className="flex flex-wrap gap-1.5">
676
- {allowed.map((slug) => (
677
- <Button key={slug} variant="ghost" size="sm" onPress={() => { onAdd(slug); setOpen(false); }}>{btBySlug.get(slug)?.name ?? slug}</Button>
678
- ))}
679
- </div>
680
- <div className="mt-1.5 text-right">
681
- <Button variant="ghost" size="sm" className="text-fg-subtle" onPress={() => setOpen(false)}>cancel</Button>
768
+ <div className="rounded-lg border border-border bg-surface-card p-1 shadow-md">
769
+ <input
770
+ ref={inputRef}
771
+ value={q}
772
+ placeholder="Filter blocks…"
773
+ spellCheck={false}
774
+ className="mb-1 w-full rounded-md bg-surface px-2.5 py-1.5 text-small text-fg outline-none placeholder:text-fg-subtle"
775
+ onChange={(e) => setQ(e.target.value)}
776
+ onBlur={() => setTimeout(close, 120)}
777
+ onKeyDown={(e) => {
778
+ if (e.key === "Escape") close();
779
+ else if (e.key === "ArrowDown") { e.preventDefault(); setIdx((n) => (n + 1) % Math.max(items.length, 1)); }
780
+ else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((n) => (n - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1)); }
781
+ else if (e.key === "Enter") { e.preventDefault(); pick(items[idx]?.slug); }
782
+ }}
783
+ />
784
+ <div className="max-h-64 overflow-auto">
785
+ {items.length === 0 ? (
786
+ <div className="px-2.5 py-2 text-small text-fg-subtle">No matching block types</div>
787
+ ) : (
788
+ items.map((x, n) => (
789
+ <button
790
+ key={x.slug}
791
+ type="button"
792
+ onMouseEnter={() => setIdx(n)}
793
+ onMouseDown={(e) => e.preventDefault()}
794
+ onClick={() => pick(x.slug)}
795
+ className={`flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-small ${n === idx ? "bg-surface-muted text-fg" : "text-fg-muted hover:bg-surface-muted"}`}
796
+ >
797
+ {x.name}
798
+ </button>
799
+ ))
800
+ )}
682
801
  </div>
683
802
  </div>
684
803
  );