smoodly 0.0.10 → 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 (80) hide show
  1. package/README.md +73 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/forms/CropEditor.d.ts +10 -0
  4. package/dist/admin/forms/CropEditor.js +61 -0
  5. package/dist/admin/forms/FieldWidget.js +4 -4
  6. package/dist/admin/forms/MediaGrid.d.ts +11 -0
  7. package/dist/admin/forms/MediaGrid.js +16 -0
  8. package/dist/admin/forms/MediaPicker.d.ts +8 -0
  9. package/dist/admin/forms/MediaPicker.js +44 -0
  10. package/dist/admin/forms/MediaWidget.d.ts +9 -0
  11. package/dist/admin/forms/MediaWidget.js +83 -0
  12. package/dist/admin/forms/assets-context.d.ts +19 -0
  13. package/dist/admin/forms/assets-context.js +35 -0
  14. package/dist/admin/forms/crop-editor.d.ts +13 -0
  15. package/dist/admin/forms/crop-editor.js +72 -0
  16. package/dist/admin/forms/measure.d.ts +2 -0
  17. package/dist/admin/forms/measure.js +39 -0
  18. package/dist/admin/forms/upload.d.ts +17 -0
  19. package/dist/admin/forms/upload.js +33 -0
  20. package/dist/admin/index.d.ts +1 -1
  21. package/dist/admin/next/op-handler.d.ts +3 -0
  22. package/dist/admin/next/op-handler.js +1 -0
  23. package/dist/admin/ops-impl.d.ts +3 -0
  24. package/dist/admin/ops-impl.js +83 -2
  25. package/dist/admin/ops.d.ts +27 -0
  26. package/dist/admin/serialize.d.ts +5 -0
  27. package/dist/admin/serialize.js +1 -0
  28. package/dist/admin/shell/AdminApp.d.ts +0 -6
  29. package/dist/admin/shell/AdminApp.js +10 -17
  30. package/dist/admin/shell/IconRail.d.ts +7 -0
  31. package/dist/admin/shell/IconRail.js +73 -0
  32. package/dist/admin/shell/MediaScreen.d.ts +8 -0
  33. package/dist/admin/shell/MediaScreen.js +86 -0
  34. package/dist/admin/shell/media-list.d.ts +8 -0
  35. package/dist/admin/shell/media-list.js +14 -0
  36. package/dist/admin/ui/Modal.d.ts +8 -0
  37. package/dist/admin/ui/Modal.js +15 -0
  38. package/dist/admin/ui/primitives.d.ts +1 -1
  39. package/dist/admin/ui/primitives.js +3 -3
  40. package/dist/admin/ui/theme.d.ts +1 -1
  41. package/dist/admin/ui/theme.js +107 -24
  42. package/dist/admin/validate.js +6 -0
  43. package/dist/asset-store.d.ts +86 -0
  44. package/dist/asset-store.js +141 -0
  45. package/dist/collections.js +2 -0
  46. package/dist/config.d.ts +12 -0
  47. package/dist/config.js +17 -5
  48. package/dist/fields.d.ts +8 -10
  49. package/dist/fields.js +14 -1
  50. package/dist/image/SmoodlyImage.d.ts +18 -0
  51. package/dist/image/SmoodlyImage.js +16 -0
  52. package/dist/image/SmoodlyVideo.d.ts +7 -0
  53. package/dist/image/SmoodlyVideo.js +5 -0
  54. package/dist/image/crop-math.d.ts +14 -0
  55. package/dist/image/crop-math.js +38 -0
  56. package/dist/image/index.d.ts +6 -0
  57. package/dist/image/index.js +5 -0
  58. package/dist/image/loaders.d.ts +15 -0
  59. package/dist/image/loaders.js +5 -0
  60. package/dist/image/next-config.d.ts +15 -0
  61. package/dist/image/next-config.js +27 -0
  62. package/dist/image-dimensions.d.ts +4 -0
  63. package/dist/image-dimensions.js +25 -0
  64. package/dist/index.d.ts +8 -2
  65. package/dist/index.js +5 -1
  66. package/dist/media.d.ts +108 -0
  67. package/dist/media.js +129 -0
  68. package/dist/refs.d.ts +2 -0
  69. package/dist/refs.js +25 -4
  70. package/dist/resolve.d.ts +19 -4
  71. package/dist/resolve.js +55 -2
  72. package/dist/site.d.ts +2 -0
  73. package/dist/site.js +21 -3
  74. package/dist/sql-space.d.ts +8 -1
  75. package/dist/sql-space.js +28 -0
  76. package/dist/sql.js +30 -0
  77. package/dist/store.js +3 -0
  78. package/dist/supabase-asset-store.d.ts +21 -0
  79. package/dist/supabase-asset-store.js +170 -0
  80. package/package.json +5 -1
package/README.md CHANGED
@@ -131,6 +131,19 @@ Implemented:
131
131
  store constructor options (`sections`, `entries`, shared
132
132
  `MemoryRefIndex` in memory); the cross-store contract
133
133
  (`test/page-refs-contract.ts`) runs against memory AND Supabase.
134
+ - Media (spec 2026-09-08): `f.image({ aspect? })` / `f.video()` / `f.file()`
135
+ store `{ asset, alt?, crop?, hotspot?, aspect? }`; `AssetStore` (memory +
136
+ `SupabaseAssetStore`: rows in `assets`, bytes in the public `media`
137
+ bucket, two-op signed uploads); the refs index carries `asset` edges
138
+ through lists, objects and styles, so delete is guarded and usage is
139
+ listed; `resolveTree`/`resolveFields`/the site layer resolve media to
140
+ `{ url, width, height, mime, alt, crop?, hotspot?, aspect? }` with one
141
+ fetch per page; `smoodly/image` ships `<SmoodlyImage>` (a box at the
142
+ aspect, the image framed by crop and hotspot over `next/image`),
143
+ `<SmoodlyVideo>`, `imageUrl` behind the loader seam (v1: the Next
144
+ loader only) and `smoodlyImages()` for next.config's `images`. Admin: the media
145
+ widget in every form, the library picker, the crop-and-hotspot editor,
146
+ the Media screen with metadata, usage links and guarded delete.
134
147
  - `EntryStore` contract + `MemoryEntryStore` + `SupabaseEntryStore` —
135
148
  collection-entry persistence over entries/entry_locales/entry_versions/paths
136
149
  — one node, one row per locale, each locale with its own slug, status
@@ -297,6 +310,13 @@ Implemented:
297
310
  derives every URL from it, and `pages.get(id)` hands the admin the
298
311
  finished `paths` (locale → CMS path) and `urls` (`href` applied), so the
299
312
  admin no longer derives a path of its own.
313
+ - `smoodly/image` — the site's media components: `<SmoodlyImage>`,
314
+ `<SmoodlyVideo>`, `imageUrl(image, { width, quality? }, loader?)`,
315
+ `frameFor`/`boxAspectOf` (the crop math) and `smoodlyImages()` for
316
+ `next.config`'s `images`: the Supabase host as a remote pattern, plus
317
+ `dangerouslyAllowLocalIP` when that host is loopback or private — Next
318
+ 16's optimizer refuses a private upstream otherwise, and the local stack
319
+ is one. Server-safe, no `"use client"`.
300
320
 
301
321
  - Locked zones are usable: `fillLockedZones` (a pure tree op) materializes
302
322
  the one node of every empty locked zone from the component's `sample`
@@ -398,10 +418,63 @@ site at the tagged commit.
398
418
  - Pre-generated image variants at upload time, for hosts without sharp.
399
419
  - Transform loaders beyond the Next default: Supabase Image
400
420
  Transformations, imgix, Cloudflare Images (DESIGN.md OQ 28).
421
+ - Delivery caching (checked 2026-09-08): Supabase Storage's basic CDN
422
+ fronts every public-bucket object on a hosted project with no code on
423
+ our side, and the Smart CDN (Pro plan, automatic) only adds
424
+ invalidation on overwrite, which never happens — objects are
425
+ immutable, no upsert on either path, Replace picks a new id. So both
426
+ upload paths now set a year-long `cache-control`
427
+ (`MEDIA_CACHE_SECONDS`): the `cacheControl` option on the server-side
428
+ upload, the header on the browser's signed PUT (without it a signed
429
+ upload lands as `no-cache`; Storage's default is one hour). The live
430
+ suite reads the header back from the local stack. Not yet verified
431
+ against a hosted project's CDN edge; check `cf-cache-status` and
432
+ `cache-control` on a real upload there once. Remaining: in v1 the
433
+ site's `<SmoodlyImage>` goes through `next/image`, so the visitor hits
434
+ the app server's image cache and only its origin fetch touches the
435
+ CDN; video, files and SVGs hit the CDN directly. A Supabase Image
436
+ Transformations loader (`/storage/v1/render/image/public/...`, Pro
437
+ only, must stay optional) would move resizing to the CDN — the loader
438
+ bullet above.
401
439
  - The asset refs inherit the index's scope: it reflects the latest saved
402
440
  version of a source, so a published version still using an asset the
403
441
  draft dropped is not protected from delete — the same gap entry refs
404
442
  have today.
443
+ - Deviations from the spec, decided while building: the starter's Hero
444
+ declares `aspect: "4:5"` (the design's own ratio), not `16:9`; `assets.list`
445
+ pages by a `created_at` cursor, 60 per page; the loader seam is the
446
+ `loader` prop of `<SmoodlyImage>` and `imageUrl`'s third argument — a
447
+ config-level `media.loader` waits for a second loader to exist.
448
+ - Image dimensions server-side cover JPEG and PNG (`imageDimensions`); a
449
+ WebP or AVIF seeded through `AssetStore.create` lands without a size
450
+ and resolves to nothing until the row is edited. Browser uploads
451
+ measure every type.
452
+ - A section's `styles` are walked for media, the config's standard style
453
+ set is not (it holds selects). A standard-set image would need the
454
+ walk to read `resolvedStyles`.
455
+ - `<SmoodlyImage>` sizes the image by the crop factor, so `sizes` picks a
456
+ candidate for the box, not for the framed image; a CDN loader with
457
+ `handlesCrop` removes the overfetch.
458
+ - `smoodlyImages()` returns the whole `images` config, not just the
459
+ patterns (decided 2026-09-08 in the browser walk): Next 16's optimizer
460
+ answers 400 for an upstream that resolves to a private IP unless
461
+ `images.dangerouslyAllowLocalIP` is set, and the local stack is
462
+ `127.0.0.1`. The helper sets the flag only for `localhost`, loopback
463
+ and RFC 1918 hosts, so a hosted project stays strict; a Docker alias
464
+ that resolves privately needs the flag by hand. The spec's "a helper
465
+ on `smoodly/next`" lives on `smoodly/image` instead, beside the
466
+ components it serves.
467
+ - Build deviations from the plan (2026-09-08, sessions A–C):
468
+ `test/admin-pages-tree.test.ts` builds its `AdminRegistry` literal by
469
+ hand and carries `media: { maxSize: 1, accept: [] }`; `MediaPicker`'s
470
+ `filters` is `readonly KindFilter[]` and the `Segmented` calls pin
471
+ `<KindFilter>`; `MediaScreen` seeds its metadata state from
472
+ `initialSelected` because the plan's effect never runs under
473
+ `renderToStaticMarkup`; `forms/measure.ts` is browser-only and
474
+ untested by design (`putSigned` is unit-tested over a stubbed fetch
475
+ and exercised live since 2026-09-08); the installer's templates are
476
+ generated (gitignored), so "templates rebuilt" is a build step, not a
477
+ diff.
405
478
 
406
479
  ## Follow-ups (logged 2026-09-08, richtext widget spec)
407
480
 
@@ -2,6 +2,7 @@ const GROUPS = {
2
2
  pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale"],
3
3
  entries: ["list", "get", "create", "save", "setStatus", "delete", "move", "addLocale", "removeLocale"],
4
4
  shared: ["list", "get", "save", "setStatus"],
5
+ assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
5
6
  preview: ["enable", "disable"],
6
7
  };
7
8
  export function makeClientOps(action, token) {
@@ -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
+ }
@@ -7,6 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
7
7
  // logic lives in ./list.ts as pure functions.
8
8
  import { useState } from "react";
9
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());
@@ -47,11 +48,10 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
47
48
  break;
48
49
  case "image":
49
50
  case "video":
50
- case "file": {
51
- const url = value?.url ?? "";
52
- 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 });
53
54
  break;
54
- }
55
55
  case "ref":
56
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))] }));
57
57
  break;
@@ -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,19 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ import type { AssetRecord } from "../../media.ts";
5
+ import { type UploadDeps } from "./upload.ts";
6
+ export type AssetsApi = {
7
+ ops: AdminOps["assets"];
8
+ get(id: string): Promise<AssetRecord | null>;
9
+ remember(record: AssetRecord): void;
10
+ upload: UploadDeps;
11
+ media: AdminRegistry["media"];
12
+ };
13
+ export declare function AssetsProvider({ ops, media, upload, children }: {
14
+ ops: AdminOps;
15
+ media: AdminRegistry["media"];
16
+ upload?: UploadDeps;
17
+ children: ReactNode;
18
+ }): ReactElement;
19
+ export declare function useAssets(): AssetsApi;
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The one place widgets reach the assets ops: a context with a small
4
+ // record cache (an id fetched once per shell lifetime), the field
5
+ // limits from the registry, and the injectable upload deps.
6
+ import { createContext, useContext, useMemo, useRef } from "react";
7
+ import { putSigned } from "./upload.js";
8
+ import { measureFile } from "./measure.js";
9
+ const Ctx = createContext(null);
10
+ export function AssetsProvider({ ops, media, upload, children }) {
11
+ const cache = useRef(new Map());
12
+ const api = useMemo(() => ({
13
+ ops: ops.assets,
14
+ media,
15
+ upload: upload ?? { put: putSigned, measure: measureFile },
16
+ remember: (record) => { cache.current.set(record.id, record); },
17
+ get: async (id) => {
18
+ const hit = cache.current.get(id);
19
+ if (hit)
20
+ return hit;
21
+ const r = await ops.assets.get(id);
22
+ if (!r.ok)
23
+ return null;
24
+ cache.current.set(id, r.data);
25
+ return r.data;
26
+ },
27
+ }), [ops, media, upload]);
28
+ return _jsx(Ctx.Provider, { value: api, children: children });
29
+ }
30
+ export function useAssets() {
31
+ const api = useContext(Ctx);
32
+ if (!api)
33
+ throw new Error("smoodly: useAssets() needs an <AssetsProvider> above it.");
34
+ return api;
35
+ }
@@ -0,0 +1,13 @@
1
+ import { type Crop, type Hotspot, type ImageValue } from "../../media.ts";
2
+ export type Handle = "nw" | "ne" | "sw" | "se";
3
+ export type CropState = {
4
+ crop: Crop;
5
+ hotspot: Hotspot;
6
+ };
7
+ export declare const MIN_SIDE = 0.05;
8
+ export declare function fitAspect(aspect: number, imageAspect: number): Crop;
9
+ export declare function moveCrop(crop: Crop, dx: number, dy: number): Crop;
10
+ export declare function resizeCrop(crop: Crop, handle: Handle, dx: number, dy: number, lock?: number, min?: number): Crop;
11
+ export declare function placeHotspot(h: Hotspot, crop: Crop): Hotspot;
12
+ export declare function relock(state: CropState, aspect: number | undefined, imageAspect: number): CropState;
13
+ export declare function initialState(value: ImageValue | undefined, aspect: number | undefined, imageAspect: number): CropState;
@@ -0,0 +1,72 @@
1
+ // The crop editor's math, pure (spec 2026-09-08 media §10): everything in
2
+ // fractions of the original image. A "lock" is the wanted pixel ratio
3
+ // divided by the image's own, i.e. the ratio crop.width / crop.height
4
+ // must keep.
5
+ import { CENTER_HOTSPOT, FULL_CROP, clamp01 } from "../../media.js";
6
+ export const MIN_SIDE = 0.05;
7
+ export function fitAspect(aspect, imageAspect) {
8
+ const fa = aspect / imageAspect;
9
+ const width = fa <= 1 ? fa : 1;
10
+ const height = fa <= 1 ? 1 : 1 / fa;
11
+ return { left: (1 - width) / 2, top: (1 - height) / 2, width, height };
12
+ }
13
+ export function moveCrop(crop, dx, dy) {
14
+ return {
15
+ ...crop,
16
+ left: Math.min(Math.max(crop.left + dx, 0), 1 - crop.width),
17
+ top: Math.min(Math.max(crop.top + dy, 0), 1 - crop.height),
18
+ };
19
+ }
20
+ export function resizeCrop(crop, handle, dx, dy, lock, min = MIN_SIDE) {
21
+ const right = crop.left + crop.width, bottom = crop.top + crop.height;
22
+ const ax = handle.includes("w") ? right : crop.left; // anchor: the opposite corner
23
+ const ay = handle.includes("n") ? bottom : crop.top;
24
+ const mx = clamp01((handle.includes("w") ? crop.left : right) + dx);
25
+ const my = clamp01((handle.includes("n") ? crop.top : bottom) + dy);
26
+ // Signed: a corner dragged past its anchor collapses to `min`, never flips.
27
+ let width = Math.max(handle.includes("w") ? ax - mx : mx - ax, min);
28
+ let height = Math.max(handle.includes("n") ? ay - my : my - ay, min);
29
+ const roomX = handle.includes("w") ? ax : 1 - ax; // how far the moving corner may go
30
+ const roomY = handle.includes("n") ? ay : 1 - ay;
31
+ if (lock !== undefined) {
32
+ height = width / lock;
33
+ if (height > roomY) {
34
+ height = roomY;
35
+ width = height * lock;
36
+ }
37
+ if (width > roomX) {
38
+ width = roomX;
39
+ height = width / lock;
40
+ }
41
+ if (height < min) {
42
+ height = min;
43
+ width = height * lock;
44
+ }
45
+ }
46
+ else {
47
+ width = Math.min(width, roomX);
48
+ height = Math.min(height, roomY);
49
+ }
50
+ return {
51
+ left: handle.includes("w") ? ax - width : ax,
52
+ top: handle.includes("n") ? ay - height : ay,
53
+ width, height,
54
+ };
55
+ }
56
+ export function placeHotspot(h, crop) {
57
+ return {
58
+ x: Math.min(Math.max(h.x, crop.left), crop.left + crop.width),
59
+ y: Math.min(Math.max(h.y, crop.top), crop.top + crop.height),
60
+ };
61
+ }
62
+ const atRatio = (crop, lock) => Math.abs(crop.width / crop.height - lock) < 1e-3;
63
+ export function relock(state, aspect, imageAspect) {
64
+ if (aspect === undefined)
65
+ return { crop: state.crop, hotspot: placeHotspot(state.hotspot, state.crop) };
66
+ const lock = aspect / imageAspect;
67
+ const crop = atRatio(state.crop, lock) ? state.crop : fitAspect(aspect, imageAspect);
68
+ return { crop, hotspot: placeHotspot(state.hotspot, crop) };
69
+ }
70
+ export function initialState(value, aspect, imageAspect) {
71
+ return relock({ crop: value?.crop ?? FULL_CROP, hotspot: value?.hotspot ?? CENTER_HOTSPOT }, aspect, imageAspect);
72
+ }
@@ -0,0 +1,2 @@
1
+ import type { Measured } from "./upload.ts";
2
+ export declare function measureFile(file: Blob, mime: string): Promise<Measured>;
@@ -0,0 +1,39 @@
1
+ const objectUrl = (file) => URL.createObjectURL(file);
2
+ function measureImage(file) {
3
+ return new Promise((resolve) => {
4
+ const url = objectUrl(file);
5
+ const img = new Image();
6
+ img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }); URL.revokeObjectURL(url); };
7
+ img.onerror = () => { resolve({}); URL.revokeObjectURL(url); };
8
+ img.src = url;
9
+ });
10
+ }
11
+ function measureVideo(file) {
12
+ return new Promise((resolve) => {
13
+ const url = objectUrl(file);
14
+ const video = document.createElement("video");
15
+ video.preload = "metadata";
16
+ video.muted = true;
17
+ const done = (m) => { resolve(m); URL.revokeObjectURL(url); };
18
+ video.onerror = () => done({});
19
+ video.onloadedmetadata = () => {
20
+ const base = { width: video.videoWidth, height: video.videoHeight, duration: video.duration };
21
+ video.onseeked = () => {
22
+ const canvas = document.createElement("canvas");
23
+ canvas.width = video.videoWidth;
24
+ canvas.height = video.videoHeight;
25
+ canvas.getContext("2d")?.drawImage(video, 0, 0);
26
+ canvas.toBlob((blob) => done(blob ? { ...base, poster: blob } : base), "image/jpeg", 0.85);
27
+ };
28
+ video.currentTime = Math.min(1, video.duration / 2);
29
+ };
30
+ video.src = url;
31
+ });
32
+ }
33
+ export function measureFile(file, mime) {
34
+ if (mime.startsWith("image/"))
35
+ return measureImage(file);
36
+ if (mime.startsWith("video/"))
37
+ return measureVideo(file);
38
+ return Promise.resolve({});
39
+ }
@@ -0,0 +1,17 @@
1
+ import type { AdminOps, OpResult } from "../ops.ts";
2
+ import { type AssetRecord } from "../../media.ts";
3
+ export type Measured = {
4
+ width?: number;
5
+ height?: number;
6
+ duration?: number;
7
+ poster?: Blob;
8
+ };
9
+ export type UploadDeps = {
10
+ put(url: string, file: Blob, mime: string): Promise<void>;
11
+ measure(file: Blob, mime: string): Promise<Measured>;
12
+ };
13
+ export declare function uploadFile(ops: AdminOps["assets"], file: File, deps: UploadDeps): Promise<OpResult<AssetRecord>>;
14
+ /** A raw PUT to the signed URL: the body is the file, the type its mime,
15
+ * and the year-long cache-control Storage serves back (without the
16
+ * header a signed upload lands as `no-cache`). */
17
+ export declare function putSigned(url: string, file: Blob, mime: string): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { MEDIA_CACHE_SECONDS } from "../../media.js";
2
+ const stem = (name) => name.replace(/\.[^.]+$/, "");
3
+ export async function uploadFile(ops, file, deps) {
4
+ const begun = await ops.beginUpload({ name: file.name, mime: file.type, size: file.size });
5
+ if (!begun.ok)
6
+ return begun;
7
+ try {
8
+ await deps.put(begun.data.uploadUrl, file, file.type);
9
+ }
10
+ catch (e) {
11
+ return { ok: false, code: "internal", message: `smoodly: upload failed — ${e instanceof Error ? e.message : String(e)}` };
12
+ }
13
+ const m = await deps.measure(file, file.type);
14
+ let posterId;
15
+ if (m.poster) {
16
+ const poster = await uploadFile(ops, new File([m.poster], `${stem(file.name)}-poster.jpg`, { type: "image/jpeg" }), deps);
17
+ if (poster.ok)
18
+ posterId = poster.data.id;
19
+ }
20
+ return ops.finishUpload(begun.data.id, { width: m.width, height: m.height, duration: m.duration, posterId });
21
+ }
22
+ /** A raw PUT to the signed URL: the body is the file, the type its mime,
23
+ * and the year-long cache-control Storage serves back (without the
24
+ * header a signed upload lands as `no-cache`). */
25
+ export async function putSigned(url, file, mime) {
26
+ const res = await fetch(url, {
27
+ method: "PUT",
28
+ headers: { "content-type": mime, "x-upsert": "false", "cache-control": `max-age=${MEDIA_CACHE_SECONDS}` },
29
+ body: file,
30
+ });
31
+ if (!res.ok)
32
+ throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);
33
+ }
@@ -1,4 +1,4 @@
1
- export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult } from "./ops.ts";
1
+ export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult, AssetUsageItem } from "./ops.ts";
2
2
  export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
3
3
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
4
4
  export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";