smoodly 0.0.10 → 0.0.12

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 +108 -8
  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 +9 -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,16 @@ 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). Server-safe, no `"use client"`.
316
+ - `smoodly/image/config` — `smoodlyImages()` for `next.config`'s `images`:
317
+ the Supabase host as a remote pattern, plus `dangerouslyAllowLocalIP`
318
+ when that host is loopback or private — Next 16's optimizer refuses a
319
+ private upstream otherwise, and the local stack is one. Its own entry
320
+ because Next loads `next.config.ts` in plain Node, where anything that
321
+ reaches `next/image` fails to resolve (next@16 has no `exports` map);
322
+ `smoodly/image` still re-exports the helper for code the bundler sees.
300
323
 
301
324
  - Locked zones are usable: `fillLockedZones` (a pure tree op) materializes
302
325
  the one node of every empty locked zone from the component's `sample`
@@ -350,17 +373,41 @@ is open is hiding a section by a locale-dependent condition).
350
373
 
351
374
  ## Releasing
352
375
 
353
- Both packages move together. From the repo root:
376
+ Both packages move together. Bump before build (the CLI's templates are
377
+ regenerated from `examples/site` in the build and pin `smoodly@<version>`),
378
+ the user runs the two publishes (npm 2FA is a browser passkey), tag only
379
+ after both are on the registry. From the repo root:
354
380
 
355
- npm version 0.0.4 -w smoodly -w create-smoodly-app --no-git-tag-version
381
+ npm version 0.0.12 -w smoodly -w create-smoodly-app --no-git-tag-version
356
382
  npm run build
357
- npm publish -w smoodly --access public
358
- npm publish -w create-smoodly-app --access public
359
- git commit -am "release: 0.0.4" && git tag v0.0.4 && git push --tags origin main
383
+ npm publish -w smoodly --dry-run && npm publish -w create-smoodly-app --dry-run
384
+ npm publish -w smoodly --access public # user; first, the scaffold pins it
385
+ npm publish -w create-smoodly-app --access public # user
386
+ npm view smoodly@0.0.12 version # 404s for a minute or two after a good publish; poll
387
+ npm view create-smoodly-app@0.0.12 version
388
+ git commit -am "release: 0.0.12 — <changes since 0.0.11>" && git tag v0.0.12 && git push --tags origin main
360
389
 
361
- `prepublishOnly` rebuilds each package; the CLI's template is regenerated from
362
- `examples/site` in that build, so the template always matches the example
363
- site at the tagged commit.
390
+ `prepublishOnly` rebuilds each package. The Claude Code skill
391
+ `.claude/skills/releasing-smoodly` carries the same order with its checks.
392
+
393
+ ## Follow-ups (logged 2026-09-08, tooling session)
394
+
395
+ - **CI live job red since e09a632, 0.0.11 fresh install broken — fixed
396
+ 2026-09-08, released as 0.0.12: `smoodly/image/config` is the entry
397
+ `next.config.ts` imports; `test/image-config-entry.test.ts` loads the
398
+ built file under bare node.** The
399
+ scaffolded `next.config.ts` imports `smoodlyImages` from `smoodly/image`,
400
+ whose index re-exports `SmoodlyImage.tsx` and its `import Image from
401
+ "next/image"`. Next loads the config in plain Node ESM and next@16.3.4
402
+ has no `exports` map, so the extensionless specifier fails
403
+ (`ERR_MODULE_NOT_FOUND ... Did you mean "next/image.js"?`) before
404
+ `next build` starts; the bundler resolves it, which is why nothing else
405
+ caught it. Reproduce from `examples/site`:
406
+ `node --input-type=module -e "await import('smoodly/image')"`. Fix: a
407
+ component-free entry for the config helper (the template and the
408
+ example comment then import from it), a plain-Node import test, then
409
+ release 0.0.12. Appending `.js` alone is weaker: Node hands back the
410
+ CommonJS `module.exports` object as the default, not the component.
364
411
 
365
412
  ## Follow-ups (logged 2026-09-08, client routing spec)
366
413
 
@@ -398,10 +445,63 @@ site at the tagged commit.
398
445
  - Pre-generated image variants at upload time, for hosts without sharp.
399
446
  - Transform loaders beyond the Next default: Supabase Image
400
447
  Transformations, imgix, Cloudflare Images (DESIGN.md OQ 28).
448
+ - Delivery caching (checked 2026-09-08): Supabase Storage's basic CDN
449
+ fronts every public-bucket object on a hosted project with no code on
450
+ our side, and the Smart CDN (Pro plan, automatic) only adds
451
+ invalidation on overwrite, which never happens — objects are
452
+ immutable, no upsert on either path, Replace picks a new id. So both
453
+ upload paths now set a year-long `cache-control`
454
+ (`MEDIA_CACHE_SECONDS`): the `cacheControl` option on the server-side
455
+ upload, the header on the browser's signed PUT (without it a signed
456
+ upload lands as `no-cache`; Storage's default is one hour). The live
457
+ suite reads the header back from the local stack. Not yet verified
458
+ against a hosted project's CDN edge; check `cf-cache-status` and
459
+ `cache-control` on a real upload there once. Remaining: in v1 the
460
+ site's `<SmoodlyImage>` goes through `next/image`, so the visitor hits
461
+ the app server's image cache and only its origin fetch touches the
462
+ CDN; video, files and SVGs hit the CDN directly. A Supabase Image
463
+ Transformations loader (`/storage/v1/render/image/public/...`, Pro
464
+ only, must stay optional) would move resizing to the CDN — the loader
465
+ bullet above.
401
466
  - The asset refs inherit the index's scope: it reflects the latest saved
402
467
  version of a source, so a published version still using an asset the
403
468
  draft dropped is not protected from delete — the same gap entry refs
404
469
  have today.
470
+ - Deviations from the spec, decided while building: the starter's Hero
471
+ declares `aspect: "4:5"` (the design's own ratio), not `16:9`; `assets.list`
472
+ pages by a `created_at` cursor, 60 per page; the loader seam is the
473
+ `loader` prop of `<SmoodlyImage>` and `imageUrl`'s third argument — a
474
+ config-level `media.loader` waits for a second loader to exist.
475
+ - Image dimensions server-side cover JPEG and PNG (`imageDimensions`); a
476
+ WebP or AVIF seeded through `AssetStore.create` lands without a size
477
+ and resolves to nothing until the row is edited. Browser uploads
478
+ measure every type.
479
+ - A section's `styles` are walked for media, the config's standard style
480
+ set is not (it holds selects). A standard-set image would need the
481
+ walk to read `resolvedStyles`.
482
+ - `<SmoodlyImage>` sizes the image by the crop factor, so `sizes` picks a
483
+ candidate for the box, not for the framed image; a CDN loader with
484
+ `handlesCrop` removes the overfetch.
485
+ - `smoodlyImages()` returns the whole `images` config, not just the
486
+ patterns (decided 2026-09-08 in the browser walk): Next 16's optimizer
487
+ answers 400 for an upstream that resolves to a private IP unless
488
+ `images.dangerouslyAllowLocalIP` is set, and the local stack is
489
+ `127.0.0.1`. The helper sets the flag only for `localhost`, loopback
490
+ and RFC 1918 hosts, so a hosted project stays strict; a Docker alias
491
+ that resolves privately needs the flag by hand. The spec's "a helper
492
+ on `smoodly/next`" lives on `smoodly/image` instead, beside the
493
+ components it serves.
494
+ - Build deviations from the plan (2026-09-08, sessions A–C):
495
+ `test/admin-pages-tree.test.ts` builds its `AdminRegistry` literal by
496
+ hand and carries `media: { maxSize: 1, accept: [] }`; `MediaPicker`'s
497
+ `filters` is `readonly KindFilter[]` and the `Segmented` calls pin
498
+ `<KindFilter>`; `MediaScreen` seeds its metadata state from
499
+ `initialSelected` because the plan's effect never runs under
500
+ `renderToStaticMarkup`; `forms/measure.ts` is browser-only and
501
+ untested by design (`putSigned` is unit-tested over a stubbed fetch
502
+ and exercised live since 2026-09-08); the installer's templates are
503
+ generated (gitignored), so "templates rebuilt" is a build step, not a
504
+ diff.
405
505
 
406
506
  ## Follow-ups (logged 2026-09-08, richtext widget spec)
407
507
 
@@ -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
+ }