smoodly 0.0.9 → 0.0.11

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 (125) hide show
  1. package/README.md +167 -18
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  4. package/dist/admin/editor/CanvasOverlay.js +39 -0
  5. package/dist/admin/editor/EditorView.js +56 -237
  6. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  7. package/dist/admin/editor/InsertPicker.js +18 -0
  8. package/dist/admin/editor/NoticePane.d.ts +4 -0
  9. package/dist/admin/editor/NoticePane.js +4 -0
  10. package/dist/admin/editor/Outline.d.ts +18 -0
  11. package/dist/admin/editor/Outline.js +12 -0
  12. package/dist/admin/editor/locale-url.d.ts +12 -0
  13. package/dist/admin/editor/locale-url.js +32 -0
  14. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  15. package/dist/admin/editor/overlay-geometry.js +27 -0
  16. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  17. package/dist/admin/editor/useCanvasBridge.js +53 -0
  18. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  19. package/dist/admin/editor/usePageLocale.js +88 -0
  20. package/dist/admin/forms/CropEditor.d.ts +10 -0
  21. package/dist/admin/forms/CropEditor.js +61 -0
  22. package/dist/admin/forms/FieldWidget.js +8 -26
  23. package/dist/admin/forms/MediaGrid.d.ts +11 -0
  24. package/dist/admin/forms/MediaGrid.js +16 -0
  25. package/dist/admin/forms/MediaPicker.d.ts +8 -0
  26. package/dist/admin/forms/MediaPicker.js +44 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +9 -0
  28. package/dist/admin/forms/MediaWidget.js +83 -0
  29. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  30. package/dist/admin/forms/RichtextToolbar.js +37 -0
  31. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  32. package/dist/admin/forms/RichtextWidget.js +123 -0
  33. package/dist/admin/forms/assets-context.d.ts +19 -0
  34. package/dist/admin/forms/assets-context.js +35 -0
  35. package/dist/admin/forms/crop-editor.d.ts +13 -0
  36. package/dist/admin/forms/crop-editor.js +72 -0
  37. package/dist/admin/forms/measure.d.ts +2 -0
  38. package/dist/admin/forms/measure.js +39 -0
  39. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  40. package/dist/admin/forms/richtext-extensions.js +74 -0
  41. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  42. package/dist/admin/forms/richtext-paste.js +59 -0
  43. package/dist/admin/forms/upload.d.ts +17 -0
  44. package/dist/admin/forms/upload.js +33 -0
  45. package/dist/admin/index.d.ts +1 -2
  46. package/dist/admin/index.js +0 -1
  47. package/dist/admin/next/create-admin.d.ts +3 -7
  48. package/dist/admin/next/create-admin.js +14 -5
  49. package/dist/admin/next/op-handler.d.ts +3 -0
  50. package/dist/admin/next/op-handler.js +1 -0
  51. package/dist/admin/ops-impl.d.ts +3 -0
  52. package/dist/admin/ops-impl.js +83 -2
  53. package/dist/admin/ops.d.ts +27 -0
  54. package/dist/admin/richtext-doc.d.ts +7 -0
  55. package/dist/admin/richtext-doc.js +18 -0
  56. package/dist/admin/serialize.d.ts +5 -0
  57. package/dist/admin/serialize.js +1 -0
  58. package/dist/admin/shell/AdminApp.d.ts +1 -2
  59. package/dist/admin/shell/AdminApp.js +18 -19
  60. package/dist/admin/shell/EntriesList.js +3 -2
  61. package/dist/admin/shell/EntryForm.js +8 -24
  62. package/dist/admin/shell/IconRail.d.ts +7 -0
  63. package/dist/admin/shell/IconRail.js +73 -0
  64. package/dist/admin/shell/ListView.js +3 -1
  65. package/dist/admin/shell/MediaScreen.d.ts +8 -0
  66. package/dist/admin/shell/MediaScreen.js +86 -0
  67. package/dist/admin/shell/PagesList.js +3 -2
  68. package/dist/admin/shell/RouteLink.d.ts +4 -0
  69. package/dist/admin/shell/RouteLink.js +12 -0
  70. package/dist/admin/shell/SharedForm.js +3 -21
  71. package/dist/admin/shell/media-list.d.ts +8 -0
  72. package/dist/admin/shell/media-list.js +14 -0
  73. package/dist/admin/shell/router.d.ts +37 -0
  74. package/dist/admin/shell/router.js +84 -0
  75. package/dist/admin/shell/session.js +3 -1
  76. package/dist/admin/ui/Modal.d.ts +8 -0
  77. package/dist/admin/ui/Modal.js +15 -0
  78. package/dist/admin/ui/primitives.d.ts +3 -2
  79. package/dist/admin/ui/primitives.js +8 -6
  80. package/dist/admin/ui/theme.d.ts +1 -1
  81. package/dist/admin/ui/theme.js +133 -24
  82. package/dist/admin/validate.js +16 -0
  83. package/dist/asset-store.d.ts +86 -0
  84. package/dist/asset-store.js +141 -0
  85. package/dist/collections.d.ts +0 -14
  86. package/dist/collections.js +2 -10
  87. package/dist/config.d.ts +12 -0
  88. package/dist/config.js +17 -5
  89. package/dist/fields.d.ts +10 -11
  90. package/dist/fields.js +16 -2
  91. package/dist/image/SmoodlyImage.d.ts +18 -0
  92. package/dist/image/SmoodlyImage.js +16 -0
  93. package/dist/image/SmoodlyVideo.d.ts +7 -0
  94. package/dist/image/SmoodlyVideo.js +5 -0
  95. package/dist/image/crop-math.d.ts +14 -0
  96. package/dist/image/crop-math.js +38 -0
  97. package/dist/image/index.d.ts +6 -0
  98. package/dist/image/index.js +5 -0
  99. package/dist/image/loaders.d.ts +15 -0
  100. package/dist/image/loaders.js +5 -0
  101. package/dist/image/next-config.d.ts +15 -0
  102. package/dist/image/next-config.js +27 -0
  103. package/dist/image-dimensions.d.ts +4 -0
  104. package/dist/image-dimensions.js +25 -0
  105. package/dist/index.d.ts +12 -4
  106. package/dist/index.js +8 -2
  107. package/dist/media.d.ts +108 -0
  108. package/dist/media.js +129 -0
  109. package/dist/refs.d.ts +2 -0
  110. package/dist/refs.js +25 -4
  111. package/dist/resolve.d.ts +19 -4
  112. package/dist/resolve.js +55 -2
  113. package/dist/site.d.ts +2 -0
  114. package/dist/site.js +21 -3
  115. package/dist/sql-space.d.ts +8 -1
  116. package/dist/sql-space.js +28 -0
  117. package/dist/sql.js +30 -0
  118. package/dist/store.js +3 -0
  119. package/dist/supabase-asset-store.d.ts +21 -0
  120. package/dist/supabase-asset-store.js +170 -0
  121. package/dist/toolset.d.ts +30 -0
  122. package/dist/toolset.js +49 -0
  123. package/package.json +15 -1
  124. package/dist/admin/richtext.d.ts +0 -16
  125. package/dist/admin/richtext.js +0 -98
@@ -0,0 +1,15 @@
1
+ import { type RefObject } from "react";
2
+ import type { BridgeRect, ToBridge, ZoneRect } from "./protocol.ts";
3
+ export type Geometry = "waiting" | "ok" | "missing";
4
+ export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }: {
5
+ iframeRef: RefObject<HTMLIFrameElement | null>;
6
+ iframeSrc: string | null;
7
+ onSelect(id: string | null): void;
8
+ onHover(id: string | null): void;
9
+ }): {
10
+ rects: BridgeRect[];
11
+ emptyZones: ZoneRect[];
12
+ docH: number;
13
+ geometry: Geometry;
14
+ postToCanvas: (m: ToBridge) => void;
15
+ };
@@ -0,0 +1,53 @@
1
+ // The admin side of the editor⇄canvas protocol: the geometry the bridge
2
+ // reports (node rects, empty zones, document height), select/hover
3
+ // forwarded from the iframe, and `postToCanvas` for refresh/scroll.
4
+ // Geometry silence is survivable — after GEOMETRY_TIMEOUT_MS the overlay
5
+ // goes dark, the iframe and the outline keep working.
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ const GEOMETRY_TIMEOUT_MS = 4000;
8
+ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }) {
9
+ const [rects, setRects] = useState([]);
10
+ const [emptyZones, setEmptyZones] = useState([]);
11
+ const [docH, setDocH] = useState(900);
12
+ const [geometry, setGeometry] = useState("waiting");
13
+ // The listener registers once; the handlers are read through refs.
14
+ const handlers = useRef({ onSelect, onHover });
15
+ handlers.current = { onSelect, onHover };
16
+ // A new canvas URL (a locale switch, a rename) means new geometry — the
17
+ // previous page's rects are stale the instant the iframe starts loading
18
+ // a different route, so clear them alongside resetting geometry.
19
+ useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); }, [iframeSrc]);
20
+ const postToCanvas = useCallback((m) => {
21
+ iframeRef.current?.contentWindow?.postMessage(m, "*");
22
+ }, [iframeRef]);
23
+ useEffect(() => {
24
+ const onMessage = (e) => {
25
+ const m = e.data;
26
+ if (m?.source !== "smoodly")
27
+ return;
28
+ if (e.source !== iframeRef.current?.contentWindow)
29
+ return;
30
+ if (m.kind === "rects") {
31
+ setRects(m.nodes);
32
+ setEmptyZones(m.emptyZones);
33
+ setDocH(m.docH);
34
+ setGeometry("ok");
35
+ }
36
+ else if (m.kind === "select") {
37
+ handlers.current.onSelect(m.id);
38
+ }
39
+ else if (m.kind === "hover") {
40
+ handlers.current.onHover(m.id);
41
+ }
42
+ };
43
+ window.addEventListener("message", onMessage);
44
+ return () => window.removeEventListener("message", onMessage);
45
+ }, [iframeRef]);
46
+ useEffect(() => {
47
+ if (!iframeSrc || geometry !== "waiting")
48
+ return;
49
+ const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
50
+ return () => clearTimeout(t);
51
+ }, [iframeSrc, geometry]);
52
+ return { rects, emptyZones, docH, geometry, postToCanvas };
53
+ }
@@ -0,0 +1,23 @@
1
+ import type { PageRecord } from "../../store.ts";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ export declare function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }: {
5
+ registry: AdminRegistry;
6
+ ops: AdminOps;
7
+ id: string;
8
+ record: PageRecord | null;
9
+ setRecord(record: PageRecord): void;
10
+ /** Flush the pending autosave; false when it could not land. */
11
+ flush(): Promise<boolean>;
12
+ /** Show an error banner. */
13
+ fail(text: string): void;
14
+ clearBanner(): void;
15
+ /** Reload the editor for the current locale. */
16
+ reload(): void;
17
+ }): {
18
+ locale: string;
19
+ adding: boolean;
20
+ switchLocale: (next: string) => Promise<void>;
21
+ doAddLocale: (target: string) => Promise<void>;
22
+ doRemoveLocale: () => Promise<void>;
23
+ };
@@ -0,0 +1,88 @@
1
+ // The editor's locale lifecycle: which locale is being edited (from the
2
+ // URL), switching, adding this page to a locale, removing it from one.
3
+ // Every transition flushes the pending autosave first — a save landing
4
+ // after the switch would persist THIS tree under the new locale.
5
+ import { useState } from "react";
6
+ import { sourceLocaleOf } from "../shell/pages-tree.js";
7
+ import { localeFromUrl, writeLocaleToUrl } from "./locale-url.js";
8
+ export function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }) {
9
+ const [locale, setLocale] = useState(() => localeFromUrl(registry));
10
+ const [adding, setAdding] = useState(false);
11
+ const switchLocale = async (next) => {
12
+ if (next === locale)
13
+ return;
14
+ const flushed = await flush();
15
+ if (!flushed) {
16
+ fail("Couldn't save your latest changes — switch cancelled.");
17
+ return;
18
+ }
19
+ writeLocaleToUrl(next);
20
+ clearBanner();
21
+ setLocale(next);
22
+ };
23
+ /** Add a locale, copying the tree on screen (or the record's source locale when this one is absent). */
24
+ const doAddLocale = async (target) => {
25
+ if (!record || adding)
26
+ return;
27
+ const from = record.locales[locale] ? locale : sourceLocaleOf(record, registry);
28
+ if (from === locale) {
29
+ const flushed = await flush();
30
+ if (!flushed) {
31
+ fail("Couldn't save your latest changes — add cancelled.");
32
+ return;
33
+ }
34
+ }
35
+ setAdding(true);
36
+ let result;
37
+ try {
38
+ result = await ops.pages.addLocale(id, target, { from });
39
+ }
40
+ catch {
41
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
42
+ }
43
+ setAdding(false);
44
+ if (!result.ok) {
45
+ fail(result.message);
46
+ return;
47
+ }
48
+ // The record now HAS the locale; keep it, or the pane below would go
49
+ // on offering "doesn't exist yet" until the reload lands.
50
+ setRecord(result.data);
51
+ if (target === locale)
52
+ reload();
53
+ else
54
+ void switchLocale(target);
55
+ };
56
+ const doRemoveLocale = async () => {
57
+ if (!record)
58
+ return;
59
+ const remaining = Object.keys(record.locales).filter((l) => l !== locale);
60
+ if (remaining.length === 0)
61
+ return;
62
+ if (!window.confirm(`Remove this page from ${locale}? Its ${locale} content and history are deleted.`))
63
+ return;
64
+ // Let a pending save land before the row goes, so no save races the
65
+ // delete — and if it cannot land, stop: a retry mid-flight would
66
+ // recreate the very draft the removal is deleting.
67
+ const flushed = await flush();
68
+ if (!flushed) {
69
+ fail("Couldn't save your latest changes — removal cancelled.");
70
+ return;
71
+ }
72
+ let result;
73
+ try {
74
+ result = await ops.pages.removeLocale(id, locale);
75
+ }
76
+ catch {
77
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
78
+ }
79
+ if (!result.ok) {
80
+ fail(result.message);
81
+ return;
82
+ }
83
+ const next = remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0];
84
+ writeLocaleToUrl(next);
85
+ setLocale(next);
86
+ };
87
+ return { locale, adding, switchLocale, doAddLocale, doRemoveLocale };
88
+ }
@@ -0,0 +1,10 @@
1
+ import { type ReactElement } from "react";
2
+ import type { AssetRecord, ImageValue } from "../../media.ts";
3
+ import type { AdminField } from "../serialize.ts";
4
+ export declare function CropEditor({ asset, descriptor, value, onSave, onClose }: {
5
+ asset: AssetRecord;
6
+ descriptor: AdminField;
7
+ value: ImageValue;
8
+ onSave(next: ImageValue): void;
9
+ onClose(): void;
10
+ }): ReactElement;
@@ -0,0 +1,61 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The crop and hotspot modal (spec 2026-09-08 media §10): the full image,
4
+ // a draggable rectangle (locked to the declared aspect, a segmented
5
+ // switch when several), a draggable hotspot, the per-use alt override
6
+ // and a live preview at the chosen aspect. Pointer deltas become image
7
+ // fractions through the stage's width; the math is in crop-editor.ts.
8
+ import { useRef, useState } from "react";
9
+ import { parseAspect } from "../../media.js";
10
+ import { frameFor } from "../../image/crop-math.js";
11
+ import { Button, Field, Segmented } from "../ui/primitives.js";
12
+ import { Modal } from "../ui/Modal.js";
13
+ import { initialState, moveCrop, placeHotspot, relock, resizeCrop } from "./crop-editor.js";
14
+ const pct = (n) => `${n * 100}%`;
15
+ export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
16
+ const declared = descriptor.aspect;
17
+ const options = Array.isArray(declared) ? declared : typeof declared === "string" ? [declared] : [];
18
+ const width = asset.width ?? 1, height = asset.height ?? 1;
19
+ const imageAspect = width / height;
20
+ const [chosen, setChosen] = useState(options.includes(value.aspect ?? "") ? value.aspect : options[0]);
21
+ const aspect = chosen ? parseAspect(chosen) : undefined;
22
+ const [state, setState] = useState(() => initialState(value, aspect, imageAspect));
23
+ const [alt, setAlt] = useState(value.alt ?? "");
24
+ const stage = useRef(null);
25
+ const drag = useRef(null);
26
+ const pick = (next) => { setChosen(next); setState((s) => relock(s, parseAspect(next), imageAspect)); };
27
+ const begin = (kind) => (e) => {
28
+ e.preventDefault();
29
+ e.stopPropagation();
30
+ e.currentTarget.setPointerCapture(e.pointerId);
31
+ drag.current = { kind, x: e.clientX, y: e.clientY, start: state };
32
+ };
33
+ const move = (e) => {
34
+ const d = drag.current, el = stage.current;
35
+ if (!d || !el)
36
+ return;
37
+ const dx = (e.clientX - d.x) / el.clientWidth, dy = (e.clientY - d.y) / el.clientHeight;
38
+ const lock = aspect === undefined ? undefined : aspect / imageAspect;
39
+ if (d.kind === "move") {
40
+ const crop = moveCrop(d.start.crop, dx, dy);
41
+ setState({ crop, hotspot: placeHotspot(d.start.hotspot, crop) });
42
+ }
43
+ else if (d.kind === "hotspot") {
44
+ setState({ crop: d.start.crop, hotspot: placeHotspot({ x: d.start.hotspot.x + dx, y: d.start.hotspot.y + dy }, d.start.crop) });
45
+ }
46
+ else {
47
+ const crop = resizeCrop(d.start.crop, d.kind, dx, dy, lock);
48
+ setState({ crop, hotspot: placeHotspot(d.start.hotspot, crop) });
49
+ }
50
+ };
51
+ const end = () => { drag.current = null; };
52
+ const { crop, hotspot } = state;
53
+ const previewAspect = aspect ?? (crop.width * imageAspect) / crop.height;
54
+ const frame = frameFor({ width, height, crop, hotspot }, previewAspect);
55
+ const save = () => onSave({
56
+ ...value, crop, hotspot,
57
+ ...(alt.trim() ? { alt: alt.trim() } : { alt: undefined }),
58
+ ...(options.length > 1 && chosen ? { aspect: chosen } : { aspect: undefined }),
59
+ });
60
+ return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: asset.url, alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top)}, 0 ${pct(crop.top)})` } }), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [options.length > 1 && (_jsx(Field, { label: "Aspect", asDiv: true, children: _jsx(Segmented, { options: options, value: chosen ?? options[0], onChange: pick }) })), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(previewAspect) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${frame.left}%`, top: `${frame.top}%`, width: `${frame.width}%`, height: `${frame.height}%` } }) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) })] })] }) }));
61
+ }
@@ -6,7 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  // Objects and lists recurse through this same component; the list's
7
7
  // logic lives in ./list.ts as pure functions.
8
8
  import { useState } from "react";
9
- import { plainToRichtext, richtextToPlain } from "../richtext.js";
9
+ import { RichtextWidget } from "./RichtextWidget.js";
10
+ import { MediaWidget } from "./MediaWidget.js";
10
11
  import { Button, Field, IconButton } from "../ui/primitives.js";
11
12
  import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
12
13
  const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
@@ -41,15 +42,16 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
41
42
  break;
42
43
  }
43
44
  case "richtext":
44
- control = _jsx(RichtextArea, { value: value, onChange: onChange });
45
+ // The toolbar holds buttons, so this control can never sit in a <label>.
46
+ asDiv = true;
47
+ control = _jsx(RichtextWidget, { value: value, toolset: d.toolset, onChange: onChange });
45
48
  break;
46
49
  case "image":
47
50
  case "video":
48
- case "file": {
49
- const url = value?.url ?? "";
50
- control = (_jsx("input", { className: "sm-input", placeholder: "https://\u2026", value: url, onChange: (e) => onChange(e.target.value ? { url: e.target.value } : undefined) }));
51
+ case "file":
52
+ asDiv = true; // buttons inside
53
+ control = _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange });
51
54
  break;
52
- }
53
55
  case "ref":
54
56
  control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: "\u2014" }), refOptions.map((o) => _jsx("option", { value: o.id, children: optionTitle(o) }, o.id))] }));
55
57
  break;
@@ -110,23 +112,3 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
110
112
  function ListRow({ index, count, summary, open, onToggle, onMove, onRemove, children }) {
111
113
  return (_jsxs("div", { className: "sm-listrow", children: [_jsxs("div", { className: "sm-listrow__head", children: [_jsxs("button", { type: "button", className: "sm-listrow__toggle", "aria-expanded": open, onClick: onToggle, children: [_jsx("span", { "aria-hidden": "true", children: open ? "▾" : "▸" }), _jsx("span", { className: "sm-listrow__summary", children: summary })] }), _jsx(IconButton, { label: "Move up", disabled: index === 0, onClick: () => onMove(index - 1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", disabled: index === count - 1, onClick: () => onMove(index + 1), children: "\u2193" }), _jsx(IconButton, { label: "Remove", onClick: onRemove, children: "\u00D7" })] }), open && _jsx("div", { className: "sm-listrow__body", children: children })] }));
112
114
  }
113
- /** The interim richtext widget owns its text: the line syntax drops a trailing
114
- * newline on the round trip, so deriving the value from the document each
115
- * render would snap the caret back after Enter at the end of the text. Re-seeds
116
- * when the document changes underneath it (load, locale switch). The parent must
117
- * store the emitted document by reference — a clone or a server copy handed back
118
- * would re-seed the text and bring the caret snap back. */
119
- function RichtextArea({ value, onChange }) {
120
- const [text, setText] = useState(() => richtextToPlain(value));
121
- const [seen, setSeen] = useState(value);
122
- if (value !== seen) {
123
- setSeen(value);
124
- setText(richtextToPlain(value));
125
- }
126
- return (_jsx("textarea", { className: "sm-textarea", rows: 5, value: text, onChange: (e) => {
127
- const doc = plainToRichtext(e.target.value);
128
- setText(e.target.value);
129
- setSeen(doc);
130
- onChange(doc);
131
- } }));
132
- }
@@ -0,0 +1,11 @@
1
+ import type { ReactElement } from "react";
2
+ import type { AssetRecord } from "../../media.ts";
3
+ export declare function AssetThumb({ asset, className }: {
4
+ asset: AssetRecord;
5
+ className?: string;
6
+ }): ReactElement;
7
+ export declare function MediaGrid({ items, selectedId, onSelect }: {
8
+ items: AssetRecord[];
9
+ selectedId?: string | null;
10
+ onSelect(asset: AssetRecord): void;
11
+ }): ReactElement;
@@ -0,0 +1,16 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { formatBytes } from "../shell/media-list.js";
4
+ export function AssetThumb({ asset, className }) {
5
+ if (asset.kind === "image")
6
+ return _jsx("img", { className: className, src: asset.url, alt: "", loading: "lazy" });
7
+ if (asset.kind === "video") {
8
+ return asset.posterId
9
+ ? _jsx("video", { className: className, src: asset.url, muted: true, playsInline: true, preload: "metadata" })
10
+ : _jsx("span", { className: `${className ?? ""} sm-thumb--chip`, children: "Video" });
11
+ }
12
+ return _jsx("span", { className: `${className ?? ""} sm-thumb--chip`, children: asset.filename.split(".").pop()?.toUpperCase() ?? "File" });
13
+ }
14
+ export function MediaGrid({ items, selectedId, onSelect }) {
15
+ return (_jsxs("div", { className: "sm-mediagrid", children: [items.map((asset) => (_jsxs("button", { type: "button", className: "sm-mediacard", "aria-pressed": asset.id === selectedId, onClick: () => onSelect(asset), children: [_jsx("span", { className: "sm-mediacard__thumb", children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) }), _jsx("span", { className: "sm-mediacard__name", children: asset.title ?? asset.filename }), _jsxs("span", { className: "sm-mediacard__meta", children: [asset.width && asset.height ? `${asset.width}×${asset.height} · ` : "", formatBytes(asset.size)] })] }, asset.id))), items.length === 0 && _jsx("div", { className: "sm-empty", children: "No media yet." })] }));
16
+ }
@@ -0,0 +1,8 @@
1
+ import { type ReactElement } from "react";
2
+ import type { AssetKind, AssetRecord } from "../../media.ts";
3
+ export declare function MediaPicker({ kinds, onPick, onClose, initialItems }: {
4
+ kinds: AssetKind[];
5
+ onPick(asset: AssetRecord): void;
6
+ onClose(): void;
7
+ initialItems?: AssetRecord[];
8
+ }): ReactElement;
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The library modal (spec 2026-09-08 media §10): grid first, an upload
4
+ // zone above it that uploads and selects in one motion, search and a
5
+ // kind filter. `initialItems` seeds the grid (tests and the widget's
6
+ // optimistic reopen); the effect loads the real list.
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { Banner, Segmented } from "../ui/primitives.js";
9
+ import { Modal } from "../ui/Modal.js";
10
+ import { useAssets } from "./assets-context.js";
11
+ import { uploadFile } from "./upload.js";
12
+ import { MediaGrid } from "./MediaGrid.js";
13
+ import { KIND_FILTERS, acceptFor, kindOfFilter } from "../shell/media-list.js";
14
+ export function MediaPicker({ kinds, onPick, onClose, initialItems = [] }) {
15
+ const assets = useAssets();
16
+ const [items, setItems] = useState(initialItems);
17
+ const [search, setSearch] = useState("");
18
+ const [filter, setFilter] = useState(kinds.length === 1 ? (kinds[0] === "image" ? "Images" : kinds[0] === "video" ? "Video" : "Files") : "All");
19
+ const [busy, setBusy] = useState(false);
20
+ const [error, setError] = useState(null);
21
+ const filters = kinds.length === 1 ? [filter] : KIND_FILTERS;
22
+ const load = useCallback(() => {
23
+ assets.ops.list({ kind: kindOfFilter(filter), search: search || undefined })
24
+ .then((r) => (r.ok ? setItems(r.data.items.filter((a) => kinds.includes(a.kind))) : setError(r.message)))
25
+ .catch(() => setError("Couldn't reach the server."));
26
+ }, [assets, filter, search, kinds]);
27
+ useEffect(load, [load]);
28
+ const onFiles = async (files) => {
29
+ const file = files?.[0];
30
+ if (!file)
31
+ return;
32
+ setBusy(true);
33
+ setError(null);
34
+ const r = await uploadFile(assets.ops, file, assets.upload);
35
+ setBusy(false);
36
+ if (!r.ok) {
37
+ setError(r.message);
38
+ return;
39
+ }
40
+ assets.remember(r.data);
41
+ onPick(r.data);
42
+ };
43
+ return (_jsxs(Modal, { title: "Media", onClose: onClose, children: [_jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { maxWidth: 260 } }), filters.length > 1 && _jsx(Segmented, { options: filters, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", style: { marginLeft: "auto" }, children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(kinds, assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsx("div", { className: "sm-dropzone sm-dropzone--bar", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: "Drop a file here to upload it" }), _jsx(MediaGrid, { items: items, onSelect: onPick })] }));
44
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReactElement } from "react";
2
+ import type { AssetRecord } from "../../media.ts";
3
+ import type { AdminField } from "../serialize.ts";
4
+ export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded }: {
5
+ descriptor: AdminField;
6
+ value: unknown;
7
+ onChange(value: unknown): void;
8
+ asset?: AssetRecord | null;
9
+ }): ReactElement;
@@ -0,0 +1,83 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The media field widget (spec 2026-09-08 media §10): empty is a drop
4
+ // zone (upload straight away, or open the library); filled is the
5
+ // thumbnail with the crop applied, the alt, and Replace / Edit / Remove.
6
+ // The record comes from the assets context (cached per id); `asset`
7
+ // preloads it for static renders.
8
+ import { useEffect, useState } from "react";
9
+ import { frameFor, boxAspectOf } from "../../image/crop-math.js";
10
+ import { Button } from "../ui/primitives.js";
11
+ import { acceptFor, formatBytes } from "../shell/media-list.js";
12
+ import { useAssets } from "./assets-context.js";
13
+ import { uploadFile } from "./upload.js";
14
+ import { MediaPicker } from "./MediaPicker.js";
15
+ import { CropEditor } from "./CropEditor.js";
16
+ import { AssetThumb } from "./MediaGrid.js";
17
+ export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
18
+ const assets = useAssets();
19
+ const kind = descriptor.type;
20
+ const id = typeof value?.asset === "string" ? value.asset : null;
21
+ const [asset, setAsset] = useState(preloaded ?? null);
22
+ const [busy, setBusy] = useState(false);
23
+ const [error, setError] = useState(null);
24
+ const [picker, setPicker] = useState(false);
25
+ const [crop, setCrop] = useState(false);
26
+ useEffect(() => {
27
+ if (!id) {
28
+ setAsset(null);
29
+ return;
30
+ }
31
+ if (preloaded?.id === id) {
32
+ setAsset(preloaded);
33
+ return;
34
+ }
35
+ let live = true;
36
+ assets.get(id).then((r) => { if (live)
37
+ setAsset(r); });
38
+ return () => { live = false; };
39
+ }, [id, assets, preloaded]);
40
+ const select = (record) => {
41
+ assets.remember(record);
42
+ setAsset(record);
43
+ setPicker(false);
44
+ onChange({ asset: record.id });
45
+ };
46
+ const onFiles = async (files) => {
47
+ const file = files?.[0];
48
+ if (!file)
49
+ return;
50
+ setBusy(true);
51
+ setError(null);
52
+ const r = await uploadFile(assets.ops, file, assets.upload);
53
+ setBusy(false);
54
+ if (!r.ok) {
55
+ setError(r.message);
56
+ return;
57
+ }
58
+ select(r.data);
59
+ };
60
+ const accept = acceptFor([kind], assets.media.accept);
61
+ const fileInput = (label, variant = "secondary") => (_jsxs("label", { className: `sm-btn sm-btn--${variant}`, children: [busy ? "Uploading…" : label, _jsx("input", { type: "file", accept: accept, hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] }));
62
+ let body;
63
+ if (!id) {
64
+ body = (_jsxs("div", { className: "sm-dropzone", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: [_jsx("span", { children: "Drop a file here" }), _jsxs("div", { className: "sm-media__actions", children: [fileInput("Upload", "primary"), _jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Choose from library" })] })] }));
65
+ }
66
+ else if (!asset) {
67
+ body = _jsx("div", { className: "sm-media__chip", children: "Loading\u2026" });
68
+ }
69
+ else if (asset.status === "pending") {
70
+ body = _jsx("div", { className: "sm-media__chip", children: "Uploading\u2026" });
71
+ }
72
+ else {
73
+ const v = value;
74
+ const preview = asset.kind === "image" && asset.width && asset.height ? (() => {
75
+ const image = { url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: "", crop: v.crop, hotspot: v.hotspot, aspect: v.aspect ?? (typeof descriptor.aspect === "string" ? descriptor.aspect : undefined) };
76
+ const box = boxAspectOf(image);
77
+ const f = frameFor(image, box);
78
+ return (_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }));
79
+ })() : asset.kind === "video" ? (_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: asset.width && asset.height ? `${asset.width} / ${asset.height}` : "16 / 9" }, children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) })) : (_jsxs("div", { className: "sm-media__chip", children: [_jsx("span", { style: { fontWeight: 500 }, children: asset.filename }), _jsx("span", { className: "sm-media__alt", children: formatBytes(asset.size) })] }));
80
+ body = (_jsxs("div", { className: "sm-media", children: [preview, asset.kind === "image" && _jsx("span", { className: "sm-media__alt", children: v.alt ?? asset.alt ?? "No alt text" }), _jsxs("div", { className: "sm-media__actions", children: [_jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Replace" }), asset.kind === "image" && _jsx(Button, { variant: "secondary", onClick: () => setCrop(true), children: "Edit" }), _jsx(Button, { variant: "secondary", onClick: () => onChange(undefined), children: "Remove" })] })] }));
81
+ }
82
+ return (_jsxs(_Fragment, { children: [body, error && _jsx("span", { className: "sm-error", children: error }), picker && _jsx(MediaPicker, { kinds: [kind], onPick: select, onClose: () => setPicker(false) }), crop && asset && (_jsx(CropEditor, { asset: asset, descriptor: descriptor, value: value, onSave: (next) => { onChange(next); setCrop(false); }, onClose: () => setCrop(false) }))] }));
83
+ }
@@ -0,0 +1,20 @@
1
+ import type { Allowed } from "../../toolset.ts";
2
+ export type ToolbarCommand = "bold" | "italic" | "link" | "bulletList" | "orderedList" | "blockquote" | {
3
+ heading: number;
4
+ };
5
+ export type ActiveState = {
6
+ bold: boolean;
7
+ italic: boolean;
8
+ link: boolean;
9
+ bulletList: boolean;
10
+ orderedList: boolean;
11
+ blockquote: boolean;
12
+ /** The active heading level, or null in a paragraph. */
13
+ heading: number | null;
14
+ };
15
+ export declare const INACTIVE: ActiveState;
16
+ export declare function RichtextToolbar({ allowed, active, onCommand }: {
17
+ allowed: Allowed;
18
+ active: ActiveState;
19
+ onCommand(command: ToolbarCommand): void;
20
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Bold, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, Link, List, ListOrdered, Quote } from "lucide-react";
4
+ import { IconButton } from "../ui/primitives.js";
5
+ export const INACTIVE = {
6
+ bold: false, italic: false, link: false, bulletList: false, orderedList: false, blockquote: false, heading: null,
7
+ };
8
+ const HEADING_ICONS = { 1: Heading1, 2: Heading2, 3: Heading3, 4: Heading4, 5: Heading5, 6: Heading6 };
9
+ const ICON = 14;
10
+ /** Keep the editor's selection: a toolbar press must not blur the content. */
11
+ const keepFocus = (e) => e.preventDefault();
12
+ export function RichtextToolbar({ allowed, active, onCommand }) {
13
+ const button = (label, pressed, command, Icon) => (_jsx(IconButton, { label: label, "aria-pressed": pressed, onMouseDown: keepFocus, onClick: () => onCommand(command), children: _jsx(Icon, { size: ICON, "aria-hidden": "true" }) }, label));
14
+ const groups = [];
15
+ const marks = [
16
+ allowed.marks.has("bold") && button("Bold", active.bold, "bold", Bold),
17
+ allowed.marks.has("italic") && button("Italic", active.italic, "italic", Italic),
18
+ allowed.marks.has("link") && button("Link", active.link, "link", Link),
19
+ ].filter(Boolean);
20
+ if (marks.length > 0)
21
+ groups.push(marks);
22
+ const headings = allowed.headingLevels
23
+ .filter((l) => l >= 1 && l <= 6)
24
+ .map((l) => button(`Heading ${l}`, active.heading === l, { heading: l }, HEADING_ICONS[l]));
25
+ if (headings.length > 0)
26
+ groups.push(headings);
27
+ const blocks = [
28
+ allowed.nodes.has("bulletList") && button("Bullet list", active.bulletList, "bulletList", List),
29
+ allowed.nodes.has("orderedList") && button("Numbered list", active.orderedList, "orderedList", ListOrdered),
30
+ allowed.nodes.has("blockquote") && button("Quote", active.blockquote, "blockquote", Quote),
31
+ ].filter(Boolean);
32
+ if (blocks.length > 0)
33
+ groups.push(blocks);
34
+ if (groups.length === 0)
35
+ return null;
36
+ return (_jsx("div", { className: "sm-richtext__bar", role: "toolbar", "aria-label": "Formatting", children: groups.map((group, i) => (_jsxs("span", { style: { display: "contents" }, children: [i > 0 && _jsx("span", { className: "sm-richtext__sep", "aria-hidden": "true" }), group] }, i))) }));
37
+ }
@@ -0,0 +1,5 @@
1
+ export declare function RichtextWidget({ value, toolset, onChange }: {
2
+ value: unknown;
3
+ toolset: unknown;
4
+ onChange(doc: unknown): void;
5
+ }): import("react").JSX.Element;