smoodly 0.0.23 → 0.0.25
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 +192 -43
- package/bin/smoodly.js +6 -0
- package/dist/admin/auth.d.ts +10 -5
- package/dist/admin/auth.js +14 -13
- package/dist/admin/client-ops.js +2 -1
- package/dist/admin/editor/EditorView.js +20 -6
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +6 -0
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/next/index.d.ts +2 -0
- package/dist/admin/ops-impl.js +73 -1
- package/dist/admin/ops.d.ts +22 -2
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +48 -3
- package/dist/admin/shell/EntriesList.js +12 -21
- package/dist/admin/shell/EntryForm.js +63 -5
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/GlobalList.js +4 -18
- package/dist/admin/shell/IconRail.d.ts +3 -1
- package/dist/admin/shell/IconRail.js +17 -7
- package/dist/admin/shell/InviteModal.d.ts +6 -0
- package/dist/admin/shell/InviteModal.js +28 -0
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/LoginView.js +34 -5
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.d.ts +2 -1
- package/dist/admin/shell/MembersPage.js +90 -25
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +100 -70
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SetPasswordView.d.ts +7 -0
- package/dist/admin/shell/SetPasswordView.js +41 -0
- package/dist/admin/shell/SettingsView.d.ts +5 -1
- package/dist/admin/shell/SettingsView.js +17 -11
- package/dist/admin/shell/live-url.d.ts +10 -0
- package/dist/admin/shell/live-url.js +19 -0
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +15 -1
- package/dist/admin/shell/members-list.js +24 -7
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/session.d.ts +6 -0
- package/dist/admin/shell/session.js +17 -2
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/supabase-auth.js +6 -5
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +11 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +368 -203
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/member-store.d.ts +30 -1
- package/dist/member-store.js +37 -6
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +19 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +6 -0
- package/dist/sql.js +10 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +4 -0
- package/dist/supabase-member-store.d.ts +8 -1
- package/dist/supabase-member-store.js +62 -2
- package/dist/supabase-store.d.ts +3 -0
- package/dist/supabase-store.js +23 -0
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/package.json +13 -3
|
@@ -10,6 +10,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
10
10
|
import { useEffect, useState } from "react";
|
|
11
11
|
import { updatedLine } from "../ui/format.js";
|
|
12
12
|
import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
|
|
13
|
+
import { confirmLiveUrl } from "../shell/live-url.js";
|
|
13
14
|
export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
|
|
14
15
|
const row = record.locales[locale];
|
|
15
16
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
@@ -30,6 +31,11 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
30
31
|
setError(`A ${key} is required — nothing falls back to another locale.`);
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
34
|
+
// A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
|
|
35
|
+
if (key === "slug" && !(await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }))) {
|
|
36
|
+
setDraft((d) => ({ ...d, slug: row.slug }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
33
39
|
setError(await onRename({ [key]: next }));
|
|
34
40
|
};
|
|
35
41
|
const onKey = (key) => (e) => {
|
|
@@ -11,5 +11,5 @@ function Counter({ n, max }) {
|
|
|
11
11
|
export function SeoSettings({ meta, onPatch }) {
|
|
12
12
|
const title = str(meta.title);
|
|
13
13
|
const description = str(meta.description);
|
|
14
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(
|
|
14
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v), label: "Social image", hint: "1200\u00D7630 recommended" }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
|
|
15
15
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { AdminRegistry } from "../serialize.ts";
|
|
2
2
|
type Locales = AdminRegistry["locales"];
|
|
3
|
-
/** A supported ?locale from the query string, else the default. */
|
|
4
|
-
export declare function localeFromSearch(search: string, locales: Locales): string;
|
|
3
|
+
/** A supported ?locale from the query string, else the fallback (the default). */
|
|
4
|
+
export declare function localeFromSearch(search: string, locales: Locales, fallback?: string): string;
|
|
5
5
|
/** The same URL pointed at a locale, keeping the rest of the query
|
|
6
6
|
* string — a bare `?locale=…` would drop every other param. */
|
|
7
7
|
export declare function withLocale(href: string, next: string): string;
|
|
8
|
+
/** The URL's locale, else the remembered one, else the default — and
|
|
9
|
+
* remembered from here on. Read once per screen, not per render. */
|
|
8
10
|
export declare function localeFromUrl(registry: AdminRegistry): string;
|
|
9
|
-
/** Point the current URL at a locale in place. A replace,
|
|
10
|
-
* never asks the leave guard — the form's own confirm
|
|
11
|
+
/** Point the current URL at a locale in place, remembering it. A replace,
|
|
12
|
+
* so the router never asks the leave guard — the form's own confirm
|
|
13
|
+
* covers a switch. */
|
|
11
14
|
export declare function writeLocaleToUrl(next: string): void;
|
|
12
15
|
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { navigate } from "../shell/router.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { rememberLocale, rememberedLocale } from "../shell/locale-memory.js";
|
|
3
|
+
/** A supported ?locale from the query string, else the fallback (the default). */
|
|
4
|
+
export function localeFromSearch(search, locales, fallback = locales.default) {
|
|
4
5
|
const l = new URLSearchParams(search).get("locale");
|
|
5
|
-
return l && locales.supported.includes(l) ? l :
|
|
6
|
+
return l && locales.supported.includes(l) ? l : fallback;
|
|
6
7
|
}
|
|
7
8
|
/** The same URL pointed at a locale, keeping the rest of the query
|
|
8
9
|
* string — a bare `?locale=…` would drop every other param. */
|
|
@@ -11,20 +12,26 @@ export function withLocale(href, next) {
|
|
|
11
12
|
u.searchParams.set("locale", next);
|
|
12
13
|
return u.toString();
|
|
13
14
|
}
|
|
15
|
+
/** The URL's locale, else the remembered one, else the default — and
|
|
16
|
+
* remembered from here on. Read once per screen, not per render. */
|
|
14
17
|
export function localeFromUrl(registry) {
|
|
15
18
|
try {
|
|
16
|
-
|
|
19
|
+
const locale = localeFromSearch(window.location.search, registry.locales, rememberedLocale(registry.locales));
|
|
20
|
+
rememberLocale(locale);
|
|
21
|
+
return locale;
|
|
17
22
|
}
|
|
18
23
|
catch {
|
|
19
24
|
// no window — the default is fine
|
|
20
25
|
return registry.locales.default;
|
|
21
26
|
}
|
|
22
27
|
}
|
|
23
|
-
/** Point the current URL at a locale in place. A replace,
|
|
24
|
-
* never asks the leave guard — the form's own confirm
|
|
28
|
+
/** Point the current URL at a locale in place, remembering it. A replace,
|
|
29
|
+
* so the router never asks the leave guard — the form's own confirm
|
|
30
|
+
* covers a switch. */
|
|
25
31
|
export function writeLocaleToUrl(next) {
|
|
32
|
+
rememberLocale(next);
|
|
26
33
|
try {
|
|
27
|
-
navigate(withLocale(window.location.href, next), { replace: true });
|
|
34
|
+
void navigate(withLocale(window.location.href, next), { replace: true });
|
|
28
35
|
}
|
|
29
36
|
catch {
|
|
30
37
|
// no window (or no history) — the URL is cosmetic here
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { useState } from "react";
|
|
6
6
|
import { sourceLocaleOf } from "../shell/pages-tree.js";
|
|
7
7
|
import { localeFromUrl, writeLocaleToUrl } from "./locale-url.js";
|
|
8
|
+
import { confirm } from "../ui/confirm.js";
|
|
8
9
|
export function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }) {
|
|
9
10
|
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
10
11
|
const [adding, setAdding] = useState(false);
|
|
@@ -59,7 +60,14 @@ export function usePageLocale({ registry, ops, id, record, setRecord, flush, fai
|
|
|
59
60
|
const remaining = Object.keys(record.locales).filter((l) => l !== locale);
|
|
60
61
|
if (remaining.length === 0)
|
|
61
62
|
return;
|
|
62
|
-
|
|
63
|
+
const ok = await confirm({
|
|
64
|
+
title: `Remove this page from ${locale}?`,
|
|
65
|
+
body: `Its ${locale} content and history are deleted. Other languages are not affected.`,
|
|
66
|
+
confirmLabel: `Remove from ${locale}`,
|
|
67
|
+
destructive: true,
|
|
68
|
+
subject: { name: record.locales[locale]?.title ?? id },
|
|
69
|
+
});
|
|
70
|
+
if (!ok)
|
|
63
71
|
return;
|
|
64
72
|
// Let a pending save land before the row goes, so no save races the
|
|
65
73
|
// delete — and if it cannot land, stop: a retry mid-flight would
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
2
|
import type { AssetRecord, ImageValue } from "../../media.ts";
|
|
3
3
|
import type { AdminField } from "../serialize.ts";
|
|
4
|
-
export declare function CropEditor({ asset, descriptor, value, onSave, onClose }: {
|
|
4
|
+
export declare function CropEditor({ asset, descriptor, value, caption, onSave, onClose }: {
|
|
5
5
|
asset: AssetRecord;
|
|
6
6
|
descriptor: AdminField;
|
|
7
7
|
value: ImageValue;
|
|
8
|
-
|
|
8
|
+
/** Given, a Caption field is shown and its text handed back beside the value. */
|
|
9
|
+
caption?: string;
|
|
10
|
+
onSave(next: ImageValue, caption?: string): void;
|
|
9
11
|
onClose(): void;
|
|
10
12
|
}): ReactElement;
|
|
@@ -5,6 +5,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
5
5
|
// switch when several), a draggable hotspot, the per-use alt override
|
|
6
6
|
// and a live preview at the chosen aspect. Pointer deltas become image
|
|
7
7
|
// fractions through the stage's width; the math is in crop-editor.ts.
|
|
8
|
+
// A richtext image card passes its `caption` too (2026-09-25): the card
|
|
9
|
+
// shows no inputs, so the modal is where both texts are edited.
|
|
8
10
|
import { useRef, useState } from "react";
|
|
9
11
|
import { parseAspect } from "../../media.js";
|
|
10
12
|
import { frameFor } from "../../image/crop-math.js";
|
|
@@ -12,7 +14,7 @@ import { Button, Field, Segmented } from "../ui/primitives.js";
|
|
|
12
14
|
import { Modal } from "../ui/Modal.js";
|
|
13
15
|
import { initialState, moveCrop, placeHotspot, relock, resizeCrop } from "./crop-editor.js";
|
|
14
16
|
const pct = (n) => `${n * 100}%`;
|
|
15
|
-
export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
|
|
17
|
+
export function CropEditor({ asset, descriptor, value, caption, onSave, onClose }) {
|
|
16
18
|
const declared = descriptor.aspect;
|
|
17
19
|
const options = Array.isArray(declared) ? declared : typeof declared === "string" ? [declared] : [];
|
|
18
20
|
const width = asset.width ?? 1, height = asset.height ?? 1;
|
|
@@ -21,6 +23,7 @@ export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
|
|
|
21
23
|
const aspect = chosen ? parseAspect(chosen) : undefined;
|
|
22
24
|
const [state, setState] = useState(() => initialState(value, aspect, imageAspect));
|
|
23
25
|
const [alt, setAlt] = useState(value.alt ?? "");
|
|
26
|
+
const [cap, setCap] = useState(caption ?? "");
|
|
24
27
|
const stage = useRef(null);
|
|
25
28
|
const drag = useRef(null);
|
|
26
29
|
const pick = (next) => { setChosen(next); setState((s) => relock(s, parseAspect(next), imageAspect)); };
|
|
@@ -56,6 +59,6 @@ export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
|
|
|
56
59
|
...value, crop, hotspot,
|
|
57
60
|
...(alt.trim() ? { alt: alt.trim() } : { alt: undefined }),
|
|
58
61
|
...(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) }) })] })] }) }));
|
|
62
|
+
}, caption === undefined ? undefined : cap);
|
|
63
|
+
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) }) }), caption !== undefined && (_jsx(Field, { label: "Caption", hint: "Shown under the image.", children: _jsx("input", { className: "sm-input", value: cap, onChange: (e) => setCap(e.target.value) }) }))] })] }) }));
|
|
61
64
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function DatePicker({ kind, value, onChange, label, initialOpen }: {
|
|
2
|
+
kind: "date" | "datetime";
|
|
3
|
+
value: unknown;
|
|
4
|
+
onChange(value: unknown): void;
|
|
5
|
+
/** Accessible name of the text box; "Date" when absent. */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** Start open: static-markup tests. */
|
|
8
|
+
initialOpen?: boolean;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The date and datetime widget (spec 2026-09-25 date pickers §4): a text
|
|
4
|
+
// box the editor may type into, a calendar button opening a month grid,
|
|
5
|
+
// and for datetime an hour and a minute select plus a hint naming the
|
|
6
|
+
// zone. The stored value is the field's string (a calendar day, or a UTC
|
|
7
|
+
// instant); the picker edits a datetime in the site's zone from the
|
|
8
|
+
// context, else the browser's. The input is the ONE focus owner: grid and
|
|
9
|
+
// head buttons swallow mousedown, so focus leaving the control is the
|
|
10
|
+
// close signal besides Escape (as the Combobox does). Math in
|
|
11
|
+
// ./date-picker.ts.
|
|
12
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
13
|
+
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
|
14
|
+
import { browserZone } from "../../time-zone.js";
|
|
15
|
+
import { cx } from "../ui/primitives.js";
|
|
16
|
+
import { clipBand } from "../ui/Combobox.js";
|
|
17
|
+
import { opensUp } from "../ui/combo-box.js";
|
|
18
|
+
import { useTimeZone } from "./time-zone-context.js";
|
|
19
|
+
import { HOURS, formatDay, fromLocalParts, isSameDay, minuteOptions, monthGrid, monthLabel, parseDay, stepDay, toLocalParts, todayIn, weekdayLabels, } from "./date-picker.js";
|
|
20
|
+
const DEFAULT_HOUR = 9;
|
|
21
|
+
const keepFocus = (e) => e.preventDefault();
|
|
22
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
23
|
+
export function DatePicker({ kind, value, onChange, label, initialOpen = false }) {
|
|
24
|
+
const configured = useTimeZone();
|
|
25
|
+
const zone = configured ?? browserZone();
|
|
26
|
+
const stored = typeof value === "string" ? value : "";
|
|
27
|
+
// What the value means on screen: the day (and the zone's clock, for a datetime).
|
|
28
|
+
const local = kind === "datetime" ? toLocalParts(stored, zone) : null;
|
|
29
|
+
const day = kind === "date" ? parseDay(stored) : local?.day ?? null;
|
|
30
|
+
const dayText = kind === "date" ? stored : local ? formatDay(local.day) : "";
|
|
31
|
+
const hour = local?.hour ?? DEFAULT_HOUR;
|
|
32
|
+
const minute = local?.minute ?? 0;
|
|
33
|
+
// A datetime's text box holds a draft until it parses (the value cannot carry a half-typed day).
|
|
34
|
+
const [draft, setDraft] = useState(null);
|
|
35
|
+
const [open, setOpen] = useState(initialOpen);
|
|
36
|
+
const [month, setMonth] = useState(() => { const d = day ?? todayIn(zone); return { y: d.y, m: d.m }; });
|
|
37
|
+
const [focus, setFocus] = useState(null);
|
|
38
|
+
const [up, setUp] = useState(false);
|
|
39
|
+
const root = useRef(null);
|
|
40
|
+
const pop = useRef(null);
|
|
41
|
+
const btn = useRef(null);
|
|
42
|
+
/** The popover's left inside the field: its right edge under the calendar button's (2026-09-26). */
|
|
43
|
+
const [x, setX] = useState(0);
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
if (!open || !root.current || !pop.current)
|
|
46
|
+
return;
|
|
47
|
+
const box = root.current.getBoundingClientRect();
|
|
48
|
+
const band = clipBand(root.current);
|
|
49
|
+
setUp(opensUp({ below: band.bottom - box.bottom - 4, above: box.top - band.top - 4, height: pop.current.offsetHeight }));
|
|
50
|
+
const b = btn.current?.getBoundingClientRect();
|
|
51
|
+
if (b)
|
|
52
|
+
setX(Math.max(0, Math.round(b.right - box.left - pop.current.offsetWidth)));
|
|
53
|
+
}, [open, month]);
|
|
54
|
+
const emitDay = (next) => {
|
|
55
|
+
if (next === null) {
|
|
56
|
+
onChange(undefined);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
onChange(kind === "date" ? formatDay(next) : fromLocalParts(next, hour, minute, zone));
|
|
60
|
+
};
|
|
61
|
+
const showMonth = (d) => setMonth({ y: d.y, m: d.m });
|
|
62
|
+
const openAt = (d) => {
|
|
63
|
+
const at = d ?? todayIn(zone);
|
|
64
|
+
showMonth(at);
|
|
65
|
+
setFocus(at);
|
|
66
|
+
setOpen(true);
|
|
67
|
+
};
|
|
68
|
+
const close = () => { setOpen(false); setFocus(null); };
|
|
69
|
+
const pick = (d) => { emitDay(d); setDraft(null); close(); };
|
|
70
|
+
const moveFocus = (d) => { setFocus(d); showMonth(d); };
|
|
71
|
+
const stepMonth = (n) => setMonth((c) => { const next = stepDay({ ...c, d: 1 }, "month", n); return { y: next.y, m: next.m }; });
|
|
72
|
+
const onText = (text) => {
|
|
73
|
+
if (kind === "date") {
|
|
74
|
+
onChange(text === "" ? undefined : text);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (text === "") {
|
|
78
|
+
setDraft(null);
|
|
79
|
+
onChange(undefined);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const parsed = parseDay(text);
|
|
83
|
+
if (parsed) {
|
|
84
|
+
setDraft(null);
|
|
85
|
+
emitDay(parsed);
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
setDraft(text);
|
|
89
|
+
};
|
|
90
|
+
const onKey = (e) => {
|
|
91
|
+
if (!open) {
|
|
92
|
+
if (e.key === "ArrowDown") {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
openAt(day);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const at = focus ?? day ?? todayIn(zone);
|
|
99
|
+
const steps = {
|
|
100
|
+
ArrowLeft: ["day", -1], ArrowRight: ["day", 1], ArrowUp: ["week", -1], ArrowDown: ["week", 1], PageUp: ["month", -1], PageDown: ["month", 1],
|
|
101
|
+
};
|
|
102
|
+
if (e.key in steps) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
const [by, n] = steps[e.key];
|
|
105
|
+
moveFocus(stepDay(at, by, n));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (e.key === "Enter") {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
pick(at);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (e.key === "Escape") {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
close();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const onBlur = (e) => {
|
|
119
|
+
if (root.current && e.relatedTarget instanceof Node && root.current.contains(e.relatedTarget))
|
|
120
|
+
return;
|
|
121
|
+
setDraft(null);
|
|
122
|
+
close();
|
|
123
|
+
};
|
|
124
|
+
const onTime = (h, m) => { if (day)
|
|
125
|
+
onChange(fromLocalParts(day, h, m, zone)); };
|
|
126
|
+
const today = todayIn(zone);
|
|
127
|
+
const hint = kind === "datetime"
|
|
128
|
+
? configured
|
|
129
|
+
? `${zone} time · stored as UTC`
|
|
130
|
+
: `Your browser's time (${zone}) · stored as UTC — set timeZone in smoodly.config`
|
|
131
|
+
: null;
|
|
132
|
+
return (_jsxs("div", { ref: root, className: cx("sm-date", kind === "datetime" && "sm-date--time"), onBlur: onBlur, children: [_jsxs("div", { className: "sm-date__row", children: [_jsxs("span", { className: "sm-inputwrap sm-date__wrap", children: [_jsx("input", { className: "sm-input", "aria-label": label ?? "Date", placeholder: "YYYY-MM-DD", value: draft ?? dayText, onChange: (e) => onText(e.target.value), onKeyDown: onKey }), _jsx("button", { ref: btn, type: "button", className: "sm-date__btn", "aria-label": "Open calendar", "aria-expanded": open, onMouseDown: keepFocus, onClick: () => (open ? close() : openAt(day)), children: _jsx(CalendarDays, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), kind === "datetime" && (_jsxs(_Fragment, { children: [_jsx("select", { className: "sm-select sm-date__time", "aria-label": "Hour", value: hour, disabled: !day, onChange: (e) => onTime(Number(e.target.value), minute), children: HOURS.map((h) => _jsx("option", { value: h, children: pad(h) }, h)) }), _jsx("span", { className: "sm-date__colon", "aria-hidden": "true", children: ":" }), _jsx("select", { className: "sm-select sm-date__time", "aria-label": "Minute", value: minute, disabled: !day, onChange: (e) => onTime(hour, Number(e.target.value)), children: minuteOptions(local ? local.minute : null).map((m) => _jsx("option", { value: m, children: pad(m) }, m)) })] }))] }), hint && _jsx("span", { className: "sm-hint", children: hint }), open && (_jsxs("div", { ref: pop, className: cx("sm-cal", up && "sm-cal--up"), style: { left: x }, role: "dialog", "aria-label": "Calendar", onMouseDown: keepFocus, children: [_jsxs("div", { className: "sm-cal__head", children: [_jsx("button", { type: "button", className: "sm-cal__nav", "aria-label": "Previous month", onClick: () => stepMonth(-1), children: _jsx(ChevronLeft, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("span", { className: "sm-cal__month", children: monthLabel(month.y, month.m) }), _jsx("button", { type: "button", className: "sm-cal__nav", "aria-label": "Next month", onClick: () => stepMonth(1), children: _jsx(ChevronRight, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "sm-cal__grid", role: "grid", children: [weekdayLabels().map((w) => _jsx("span", { className: "sm-cal__wd", role: "columnheader", children: w }, w)), monthGrid(month.y, month.m).flat().map((d) => (_jsx("button", { type: "button", role: "gridcell", className: cx("sm-cal__day", d.m !== month.m && "sm-cal__day--out", isSameDay(d, today) && "sm-cal__day--today", focus !== null && isSameDay(d, focus) && "sm-cal__day--focus"), "aria-pressed": day !== null && isSameDay(d, day), tabIndex: -1, onClick: () => pick(d), children: d.d }, formatDay(d))))] }), _jsx("div", { className: "sm-cal__foot", children: _jsx("button", { type: "button", className: "sm-field__action", onClick: () => pick(today), children: "Today" }) })] }))] }));
|
|
133
|
+
}
|
|
@@ -6,6 +6,7 @@ import { ListField } from "./ListField.js";
|
|
|
6
6
|
import { RefPicker } from "./RefPicker.js";
|
|
7
7
|
import { TagsField } from "./TagsField.js";
|
|
8
8
|
import { RowsWidget } from "./RowsWidget.js";
|
|
9
|
+
import { DatePicker } from "./DatePicker.js";
|
|
9
10
|
import { optionsOf } from "../../fields.js";
|
|
10
11
|
import { isHidden } from "../../conditions.js";
|
|
11
12
|
import { cssProblem } from "../../css.js";
|
|
@@ -31,14 +32,18 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
|
|
|
31
32
|
switch (d.type) {
|
|
32
33
|
case "text":
|
|
33
34
|
case "link":
|
|
34
|
-
case "slug":
|
|
35
|
-
case "date": {
|
|
35
|
+
case "slug": {
|
|
36
36
|
const input = (_jsx("input", { className: d.type === "slug" ? "sm-input sm-input--mono" : "sm-input", value: String(value ?? ""), onChange: (e) => onChange(e.target.value) }));
|
|
37
37
|
control = d.type === "slug" && prefix
|
|
38
38
|
? _jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), input] })
|
|
39
39
|
: input;
|
|
40
40
|
break;
|
|
41
41
|
}
|
|
42
|
+
case "date":
|
|
43
|
+
case "datetime":
|
|
44
|
+
asDiv = true; // the calendar button and the time selects: never inside one implicit label
|
|
45
|
+
control = _jsx(DatePicker, { kind: d.type, value: value, onChange: onChange, label: label(fieldKey) });
|
|
46
|
+
break;
|
|
42
47
|
case "number":
|
|
43
48
|
control = (_jsx("input", { className: "sm-input", type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
|
|
44
49
|
break;
|
|
@@ -97,9 +102,10 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
|
|
|
97
102
|
case "image":
|
|
98
103
|
case "video":
|
|
99
104
|
case "file":
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
// The widget renders its own Field: its Edit / Replace / Remove sit in the head.
|
|
106
|
+
return bare
|
|
107
|
+
? _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange, error: message, path: path })
|
|
108
|
+
: _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange, label: label(fieldKey), mark: mark, error: message, path: path });
|
|
103
109
|
case "ref":
|
|
104
110
|
case "refList": {
|
|
105
111
|
asDiv = true; // rows of buttons: never inside one implicit label
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { AssetRecord, ImageValue } from "../../media.ts";
|
|
3
|
+
export declare function ImagePreview({ asset, value, aspect, height }: {
|
|
4
|
+
asset: AssetRecord;
|
|
5
|
+
value: ImageValue;
|
|
6
|
+
/** The field's declared aspect (one string); the value's own wins. */
|
|
7
|
+
aspect?: string;
|
|
8
|
+
height: number;
|
|
9
|
+
}): ReactElement | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { boxAspectOf, frameFor } from "../../image/crop-math.js";
|
|
4
|
+
export function ImagePreview({ asset, value, aspect, height }) {
|
|
5
|
+
if (!asset.width || !asset.height)
|
|
6
|
+
return null;
|
|
7
|
+
const image = { url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: "", crop: value.crop, hotspot: value.hotspot, aspect: value.aspect ?? aspect };
|
|
8
|
+
const box = boxAspectOf(image);
|
|
9
|
+
const f = frameFor(image, box);
|
|
10
|
+
return (_jsx("div", { className: "sm-media__canvas", style: { maxHeight: height }, children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box), width: `calc(${height}px * ${box})`, maxHeight: height }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }) }));
|
|
11
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { type ReactElement } from "react";
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import type { AssetRecord } from "../../media.ts";
|
|
3
3
|
import type { AdminField } from "../serialize.ts";
|
|
4
|
-
export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded }: {
|
|
4
|
+
export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded, label, mark, hint, error: fieldError, path }: {
|
|
5
5
|
descriptor: AdminField;
|
|
6
6
|
value: unknown;
|
|
7
7
|
onChange(value: unknown): void;
|
|
8
8
|
asset?: AssetRecord | null;
|
|
9
|
+
/** The field head's label and mark, its hint and error, its reveal path — the widget owns the Field. */
|
|
10
|
+
label?: ReactNode;
|
|
11
|
+
mark?: ReactNode;
|
|
12
|
+
hint?: ReactNode;
|
|
13
|
+
error?: ReactNode;
|
|
14
|
+
path?: string;
|
|
9
15
|
}): ReactElement;
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The media field widget (spec 2026-09-08 media §10): empty is a drop
|
|
4
4
|
// zone (upload straight away, or open the library); filled is the
|
|
5
5
|
// thumbnail with the crop applied — contained on a canvas at most
|
|
6
6
|
// CANVAS_H tall, the frame sized min(CANVAS_H tall, full width) so it
|
|
7
|
-
// keeps its aspect — the alt, and
|
|
7
|
+
// keeps its aspect — the alt, and Edit / Replace / Remove as small text
|
|
8
|
+
// actions in the field's head, right of the label (2026-09-25; the same
|
|
9
|
+
// row a richtext image card shows). The widget renders its own Field so
|
|
10
|
+
// the actions can sit in the head.
|
|
8
11
|
// The record comes from the assets context (cached per id); `asset`
|
|
9
12
|
// preloads it for static renders.
|
|
10
13
|
import { useEffect, useState } from "react";
|
|
11
|
-
import {
|
|
12
|
-
import { Button } from "../ui/primitives.js";
|
|
14
|
+
import { Button, Field } from "../ui/primitives.js";
|
|
13
15
|
import { acceptFor, formatBytes } from "../shell/media-list.js";
|
|
14
16
|
import { useAssets } from "./assets-context.js";
|
|
15
17
|
import { uploadFile } from "./upload.js";
|
|
16
18
|
import { MediaPicker } from "./MediaPicker.js";
|
|
17
19
|
import { CropEditor } from "./CropEditor.js";
|
|
18
20
|
import { AssetThumb } from "./MediaGrid.js";
|
|
21
|
+
import { ImagePreview } from "./ImagePreview.js";
|
|
19
22
|
/** The filled preview's canvas height cap (the stylesheet repeats it). */
|
|
20
23
|
const CANVAS_H = 300;
|
|
21
|
-
export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
|
|
24
|
+
export function MediaWidget({ descriptor, value, onChange, asset: preloaded, label, mark, hint, error: fieldError, path }) {
|
|
22
25
|
const assets = useAssets();
|
|
23
26
|
const kind = descriptor.type;
|
|
24
27
|
const id = typeof value?.asset === "string" ? value.asset : null;
|
|
@@ -75,16 +78,13 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
|
|
|
75
78
|
}
|
|
76
79
|
else {
|
|
77
80
|
const v = value;
|
|
78
|
-
const preview = asset.kind === "image" && asset.width && asset.height ? (() => {
|
|
79
|
-
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) };
|
|
80
|
-
const box = boxAspectOf(image);
|
|
81
|
-
const f = frameFor(image, box);
|
|
82
|
-
return (_jsx("div", { className: "sm-media__canvas", children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box), width: `calc(${CANVAS_H}px * ${box})` }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }) }));
|
|
83
|
-
})() : asset.kind === "video" ? (() => {
|
|
81
|
+
const preview = asset.kind === "image" && asset.width && asset.height ? (_jsx(ImagePreview, { asset: asset, value: v, aspect: typeof descriptor.aspect === "string" ? descriptor.aspect : undefined, height: CANVAS_H })) : asset.kind === "video" ? (() => {
|
|
84
82
|
const ratio = asset.width && asset.height ? asset.width / asset.height : 16 / 9;
|
|
85
83
|
return (_jsx("div", { className: "sm-media__canvas", children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(ratio), width: `calc(${CANVAS_H}px * ${ratio})` }, children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) }) }));
|
|
86
84
|
})() : (_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) })] }));
|
|
87
|
-
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" })
|
|
85
|
+
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" })] }));
|
|
88
86
|
}
|
|
89
|
-
|
|
87
|
+
// Edit only for an image that has its dimensions (the crop editor's frame needs them).
|
|
88
|
+
const actions = id && asset ? (_jsxs("span", { className: "sm-field__actions", children: [asset.kind === "image" && asset.width && asset.height && (_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" })), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : undefined;
|
|
89
|
+
return (_jsxs(Field, { label: label, mark: mark, hint: hint, error: fieldError, path: path, action: actions, asDiv: true, 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) }))] }));
|
|
90
90
|
}
|
|
@@ -39,5 +39,5 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
|
|
|
39
39
|
const scope = { fields, settings };
|
|
40
40
|
const visibleFields = Object.entries(element?.fields ?? {}).filter(([, d]) => !isHidden(d, { self: fields, ...scope }));
|
|
41
41
|
const visibleSettings = Object.entries(element?.settings ?? {}).filter(([, d]) => !isHidden(d, { self: settings, ...scope }));
|
|
42
|
-
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, {
|
|
42
|
+
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
|
|
43
43
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// The image node view (spec 2026-09-08 block nodes §3):
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// The image node view (spec 2026-09-08 block nodes §3): the cropped
|
|
4
|
+
// frame, the caption as centred text, and in the head Edit /
|
|
5
|
+
// Replace / Remove — the media field's three, as the same small text
|
|
6
|
+
// actions (2026-09-25). Edit is the crop editor, which also carries the
|
|
7
|
+
// alt and the caption: the card itself holds no inputs. The node's
|
|
8
|
+
// `image` attr is an ImageValue like the field's, so a crop and hotspot
|
|
9
|
+
// store there; the bound renderer applies them. Attribute edits go
|
|
10
|
+
// through updateAttributes so the outer history sees them.
|
|
9
11
|
import { useEffect, useState } from "react";
|
|
10
12
|
import { NodeViewWrapper } from "@tiptap/react";
|
|
11
|
-
import { IconButton } from "../ui/primitives.js";
|
|
12
13
|
import { useAssets } from "./assets-context.js";
|
|
13
14
|
import { MediaPicker } from "./MediaPicker.js";
|
|
15
|
+
import { CropEditor } from "./CropEditor.js";
|
|
16
|
+
import { ImagePreview } from "./ImagePreview.js";
|
|
14
17
|
import { cardClass, useRichtextForm } from "./richtext-form-context.js";
|
|
15
18
|
export function RichtextImageCard({ node, editor, getPos, updateAttributes, deleteNode, selected }) {
|
|
16
19
|
const assets = useAssets();
|
|
@@ -20,6 +23,7 @@ export function RichtextImageCard({ node, editor, getPos, updateAttributes, dele
|
|
|
20
23
|
const id = typeof value.asset === "string" ? value.asset : null;
|
|
21
24
|
const [asset, setAsset] = useState(null);
|
|
22
25
|
const [picker, setPicker] = useState(false);
|
|
26
|
+
const [crop, setCrop] = useState(false);
|
|
23
27
|
useEffect(() => {
|
|
24
28
|
if (!id) {
|
|
25
29
|
setAsset(null);
|
|
@@ -32,11 +36,12 @@ export function RichtextImageCard({ node, editor, getPos, updateAttributes, dele
|
|
|
32
36
|
}, [id, assets]);
|
|
33
37
|
const select = () => { const pos = getPos(); if (typeof pos === "number")
|
|
34
38
|
editor.commands.setNodeSelection(pos); };
|
|
35
|
-
|
|
39
|
+
// A new asset keeps the alt and drops the crop: the frame was drawn on the old picture.
|
|
36
40
|
const replace = (record) => {
|
|
37
41
|
assets.remember(record);
|
|
38
42
|
setPicker(false);
|
|
39
43
|
updateAttributes({ image: value.alt ? { asset: record.id, alt: value.alt } : { asset: record.id } });
|
|
40
44
|
};
|
|
41
|
-
|
|
45
|
+
const editable = asset !== null && asset.kind === "image" && !!asset.width && !!asset.height;
|
|
46
|
+
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, "data-drag-handle": true, children: [_jsxs("div", { className: "sm-rtcard__head", onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: "\u25A3" }), _jsx("span", { className: "sm-rtcard__title", children: "Image" }), _jsxs("span", { className: "sm-field__actions", children: [editable && _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => deleteNode(), children: "Remove" })] })] }), _jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [asset && id ? _jsx(ImagePreview, { asset: asset, value: { ...value, asset: id }, height: 160 }) : _jsx("div", { className: "sm-rtcard__thumb", style: { height: 80 } }), caption !== "" && _jsx("span", { className: "sm-rtcard__caption", children: caption })] }), picker && _jsx(MediaPicker, { kinds: ["image"], onPick: replace, onClose: () => setPicker(false) }), crop && asset && id && (_jsx(CropEditor, { asset: asset, descriptor: { type: "image" }, value: { ...value, asset: id }, caption: caption, onSave: (next, cap) => { updateAttributes({ image: next, caption: cap ?? "" }); setCrop(false); }, onClose: () => setCrop(false) }))] }));
|
|
42
47
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A calendar day; `m` is 1–12. */
|
|
2
|
+
export type Day = {
|
|
3
|
+
y: number;
|
|
4
|
+
m: number;
|
|
5
|
+
d: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function daysInMonth(y: number, m: number): number;
|
|
8
|
+
/** Strict `YYYY-MM-DD`, and a day the calendar has (no 2026-02-30). */
|
|
9
|
+
export declare function parseDay(s: string): Day | null;
|
|
10
|
+
export declare const formatDay: (day: Day) => string;
|
|
11
|
+
export declare const isSameDay: (a: Day, b: Day) => boolean;
|
|
12
|
+
export declare const isCalendarDay: (s: string) => boolean;
|
|
13
|
+
/** A day `n` days, weeks or months on (negative: back). A month step keeps the day, clamped to the target month's length. */
|
|
14
|
+
export declare function stepDay(day: Day, by: "day" | "week" | "month", n: number): Day;
|
|
15
|
+
/** Six rows of seven, Monday first, the month's days padded with the neighbours'. */
|
|
16
|
+
export declare function monthGrid(y: number, m: number): Day[][];
|
|
17
|
+
/** "September 2026" in a language; English when none is given (static renders). */
|
|
18
|
+
export declare function monthLabel(y: number, m: number, locale?: string): string;
|
|
19
|
+
/** Short weekday names, Monday first (2024-01-01 was a Monday). */
|
|
20
|
+
export declare function weekdayLabels(locale?: string): string[];
|
|
21
|
+
/** Strict ISO to the minute, seconds 00, Z. */
|
|
22
|
+
export declare function parseInstant(s: string): Date | null;
|
|
23
|
+
export declare const formatInstant: (date: Date) => string;
|
|
24
|
+
export declare const isInstant: (s: string) => boolean;
|
|
25
|
+
/** The clock of a zone at an instant, through Intl; `hourCycle: "h23"` so midnight is 0, never 24. */
|
|
26
|
+
export declare function zoneParts(date: Date, zone: string): {
|
|
27
|
+
day: Day;
|
|
28
|
+
hour: number;
|
|
29
|
+
minute: number;
|
|
30
|
+
};
|
|
31
|
+
export declare function toLocalParts(iso: string, zone: string): {
|
|
32
|
+
day: Day;
|
|
33
|
+
hour: number;
|
|
34
|
+
minute: number;
|
|
35
|
+
} | null;
|
|
36
|
+
/** The instant at which the zone's clock reads the given wall time. A
|
|
37
|
+
* fixed point: read the zone's clock at a guess, shift by the difference,
|
|
38
|
+
* twice for a daylight-saving edge. A wall time that does not exist (the
|
|
39
|
+
* spring-forward gap) lands on the instant an hour on. */
|
|
40
|
+
export declare function fromLocalParts(day: Day, hour: number, minute: number, zone: string): string;
|
|
41
|
+
export declare function todayIn(zone: string, now?: Date): Day;
|
|
42
|
+
export declare const HOURS: number[];
|
|
43
|
+
/** The five-minute grid, plus a stored minute that is not on it. */
|
|
44
|
+
export declare function minuteOptions(current: number | null): number[];
|