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.
- package/README.md +73 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/forms/CropEditor.d.ts +10 -0
- package/dist/admin/forms/CropEditor.js +61 -0
- package/dist/admin/forms/FieldWidget.js +4 -4
- package/dist/admin/forms/MediaGrid.d.ts +11 -0
- package/dist/admin/forms/MediaGrid.js +16 -0
- package/dist/admin/forms/MediaPicker.d.ts +8 -0
- package/dist/admin/forms/MediaPicker.js +44 -0
- package/dist/admin/forms/MediaWidget.d.ts +9 -0
- package/dist/admin/forms/MediaWidget.js +83 -0
- package/dist/admin/forms/assets-context.d.ts +19 -0
- package/dist/admin/forms/assets-context.js +35 -0
- package/dist/admin/forms/crop-editor.d.ts +13 -0
- package/dist/admin/forms/crop-editor.js +72 -0
- package/dist/admin/forms/measure.d.ts +2 -0
- package/dist/admin/forms/measure.js +39 -0
- package/dist/admin/forms/upload.d.ts +17 -0
- package/dist/admin/forms/upload.js +33 -0
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/next/op-handler.d.ts +3 -0
- package/dist/admin/next/op-handler.js +1 -0
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +83 -2
- package/dist/admin/ops.d.ts +27 -0
- package/dist/admin/serialize.d.ts +5 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +0 -6
- package/dist/admin/shell/AdminApp.js +10 -17
- package/dist/admin/shell/IconRail.d.ts +7 -0
- package/dist/admin/shell/IconRail.js +73 -0
- package/dist/admin/shell/MediaScreen.d.ts +8 -0
- package/dist/admin/shell/MediaScreen.js +86 -0
- package/dist/admin/shell/media-list.d.ts +8 -0
- package/dist/admin/shell/media-list.js +14 -0
- package/dist/admin/ui/Modal.d.ts +8 -0
- package/dist/admin/ui/Modal.js +15 -0
- package/dist/admin/ui/primitives.d.ts +1 -1
- package/dist/admin/ui/primitives.js +3 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +107 -24
- package/dist/admin/validate.js +6 -0
- package/dist/asset-store.d.ts +86 -0
- package/dist/asset-store.js +141 -0
- package/dist/collections.js +2 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +17 -5
- package/dist/fields.d.ts +8 -10
- package/dist/fields.js +14 -1
- package/dist/image/SmoodlyImage.d.ts +18 -0
- package/dist/image/SmoodlyImage.js +16 -0
- package/dist/image/SmoodlyVideo.d.ts +7 -0
- package/dist/image/SmoodlyVideo.js +5 -0
- package/dist/image/crop-math.d.ts +14 -0
- package/dist/image/crop-math.js +38 -0
- package/dist/image/index.d.ts +6 -0
- package/dist/image/index.js +5 -0
- package/dist/image/loaders.d.ts +15 -0
- package/dist/image/loaders.js +5 -0
- package/dist/image/next-config.d.ts +15 -0
- package/dist/image/next-config.js +27 -0
- package/dist/image-dimensions.d.ts +4 -0
- package/dist/image-dimensions.js +25 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/media.d.ts +108 -0
- package/dist/media.js +129 -0
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +25 -4
- package/dist/resolve.d.ts +19 -4
- package/dist/resolve.js +55 -2
- package/dist/site.d.ts +2 -0
- package/dist/site.js +21 -3
- package/dist/sql-space.d.ts +8 -1
- package/dist/sql-space.js +28 -0
- package/dist/sql.js +30 -0
- package/dist/store.js +3 -0
- package/dist/supabase-asset-store.d.ts +21 -0
- package/dist/supabase-asset-store.js +170 -0
- 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
|
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -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
|
-
|
|
52
|
-
control =
|
|
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,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
|
+
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -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";
|