smoodly 0.0.18 → 0.0.19
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 +36 -1
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/editor/EditorView.js +17 -30
- package/dist/admin/editor/GlobalInspector.d.ts +1 -1
- package/dist/admin/editor/GlobalInspector.js +3 -2
- package/dist/admin/editor/NoticePane.js +3 -1
- package/dist/admin/forms/FieldWidget.d.ts +6 -6
- package/dist/admin/forms/FieldWidget.js +22 -10
- package/dist/admin/forms/RefPicker.d.ts +11 -0
- package/dist/admin/forms/RefPicker.js +30 -0
- package/dist/admin/forms/TagsField.d.ts +10 -0
- package/dist/admin/forms/TagsField.js +38 -0
- package/dist/admin/forms/ref-picker.d.ts +25 -0
- package/dist/admin/forms/ref-picker.js +30 -0
- package/dist/admin/forms/useRefOptions.d.ts +7 -0
- package/dist/admin/forms/useRefOptions.js +36 -0
- package/dist/admin/forms/useTagSuggestions.d.ts +5 -0
- package/dist/admin/forms/useTagSuggestions.js +22 -0
- package/dist/admin/ops-impl.js +23 -4
- package/dist/admin/ops.d.ts +4 -0
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +8 -2
- package/dist/admin/shell/EntriesList.js +3 -2
- package/dist/admin/shell/EntryForm.js +14 -28
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +6 -4
- package/dist/admin/shell/GlobalFields.d.ts +3 -6
- package/dist/admin/shell/GlobalFields.js +1 -1
- package/dist/admin/shell/GlobalForm.js +8 -22
- package/dist/admin/shell/GlobalList.js +3 -2
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +4 -3
- package/dist/admin/shell/base.d.ts +4 -1
- package/dist/admin/shell/base.js +15 -3
- package/dist/admin/shell/router.d.ts +4 -3
- package/dist/admin/shell/router.js +17 -14
- package/dist/admin/ui/Combobox.d.ts +30 -0
- package/dist/admin/ui/Combobox.js +89 -0
- package/dist/admin/ui/combo-box.d.ts +16 -0
- package/dist/admin/ui/combo-box.js +21 -0
- package/dist/admin/ui/theme.js +36 -0
- package/dist/admin/validate.js +39 -0
- package/dist/admin-path.d.ts +9 -0
- package/dist/admin-path.js +24 -0
- package/dist/config.d.ts +10 -1
- package/dist/config.js +4 -0
- package/dist/entry-store.js +1 -1
- package/dist/fields.d.ts +3 -0
- package/dist/fields.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/page.js +1 -1
- package/dist/supabase-entry-store.js +1 -1
- package/dist/tags.d.ts +19 -0
- package/dist/tags.js +50 -0
- package/package.json +1 -1
package/dist/admin/serialize.js
CHANGED
|
@@ -24,6 +24,8 @@ import { LoginView } from "./LoginView.js";
|
|
|
24
24
|
import { useAdminSession } from "./session.js";
|
|
25
25
|
import { IconRail } from "./IconRail.js";
|
|
26
26
|
import { useRoute } from "./router.js";
|
|
27
|
+
import { AdminPathProvider } from "./base.js";
|
|
28
|
+
import { mountMismatch } from "../../admin-path.js";
|
|
27
29
|
import { ErrorPane } from "./ErrorPane.js";
|
|
28
30
|
import { MediaScreen } from "./MediaScreen.js";
|
|
29
31
|
import { AssetsProvider } from "../forms/assets-context.js";
|
|
@@ -31,13 +33,17 @@ import { ElementsProvider } from "../forms/elements-context.js";
|
|
|
31
33
|
export function AdminApp({ registry, op, auth }) {
|
|
32
34
|
const session = useAdminSession(auth);
|
|
33
35
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
34
|
-
const route = useRoute();
|
|
36
|
+
const route = useRoute(registry.adminPath);
|
|
35
37
|
const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
|
|
36
38
|
const isCollection = registry.collections.some((c) => c.name === head);
|
|
37
39
|
const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
|
|
38
40
|
if (session.state.status === "loading") {
|
|
39
41
|
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
40
42
|
}
|
|
43
|
+
const mismatch = mountMismatch(route.pathname, registry.adminPath);
|
|
44
|
+
if (mismatch) {
|
|
45
|
+
return _jsx("div", { style: { height: "100vh", display: "flex" }, children: _jsx(ErrorPane, { message: mismatch, back: false }) });
|
|
46
|
+
}
|
|
41
47
|
if (session.state.status === "signed_out")
|
|
42
48
|
return _jsx(LoginView, { client: session.client, providers: auth.providers });
|
|
43
49
|
let main;
|
|
@@ -57,5 +63,5 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
57
63
|
main = _jsx(MediaScreen, {});
|
|
58
64
|
else
|
|
59
65
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
60
|
-
return (_jsx(ElementsProvider, { elements: registry.elements, children: _jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }) }));
|
|
66
|
+
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }) }) }));
|
|
61
67
|
}
|
|
@@ -12,7 +12,7 @@ import { statusLabel } from "../ui/status-label.js";
|
|
|
12
12
|
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
13
13
|
import { relativeTime } from "../ui/format.js";
|
|
14
14
|
import { useSortable } from "../ui/useSortable.js";
|
|
15
|
-
import {
|
|
15
|
+
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { navigate } from "./router.js";
|
|
17
17
|
import { ErrorPane } from "./ErrorPane.js";
|
|
18
18
|
const LOCALE_KEY = "smoodly.entries.locale";
|
|
@@ -30,6 +30,7 @@ function rememberedLocale(registry) {
|
|
|
30
30
|
return registry.locales.default;
|
|
31
31
|
}
|
|
32
32
|
export function EntriesList({ ops, registry, collection }) {
|
|
33
|
+
const adminHref = useAdminHref();
|
|
33
34
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
34
35
|
const [records, setRecords] = useState(null);
|
|
35
36
|
const [error, setError] = useState(null);
|
|
@@ -74,7 +75,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
74
75
|
if (error)
|
|
75
76
|
return _jsx(ErrorPane, { message: error });
|
|
76
77
|
const open = (id) => {
|
|
77
|
-
navigate(
|
|
78
|
+
navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
78
79
|
};
|
|
79
80
|
/** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
|
|
80
81
|
const createEntry = async () => {
|
|
@@ -18,6 +18,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
18
18
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
19
19
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
20
20
|
import { revealField } from "../forms/reveal.js";
|
|
21
|
+
import { useRefOptions } from "../forms/useRefOptions.js";
|
|
22
|
+
import { useTagSuggestions } from "../forms/useTagSuggestions.js";
|
|
21
23
|
import { useDraftAutosave } from "../editor/autosave.js";
|
|
22
24
|
import { relativeTime, updatedLine } from "../ui/format.js";
|
|
23
25
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
@@ -27,10 +29,13 @@ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
|
27
29
|
import { localeStatusOf } from "./pages-tree.js";
|
|
28
30
|
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
29
31
|
import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
|
|
30
|
-
import {
|
|
32
|
+
import { useAdminHref } from "./base.js";
|
|
31
33
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
32
34
|
import { navigate, useLeaveGuard } from "./router.js";
|
|
35
|
+
/** A stable empty map for the hooks that run before the "no such collection" return. */
|
|
36
|
+
const NO_FIELDS = {};
|
|
33
37
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
38
|
+
const adminHref = useAdminHref();
|
|
34
39
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
35
40
|
const multilingual = registry.locales.supported.length > 1;
|
|
36
41
|
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
@@ -38,7 +43,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
38
43
|
const [fields, setFields] = useState(null);
|
|
39
44
|
const [urls, setUrls] = useState({});
|
|
40
45
|
const [parentLocales, setParentLocales] = useState(registry.locales.supported);
|
|
41
|
-
const [refOptions, setRefOptions] = useState({});
|
|
42
46
|
const [notice, setNotice] = useState(null);
|
|
43
47
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
44
48
|
const [adding, setAdding] = useState(false);
|
|
@@ -76,6 +80,9 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
76
80
|
}
|
|
77
81
|
return out;
|
|
78
82
|
}, [meta]);
|
|
83
|
+
const refTargetList = useMemo(() => Object.values(refTargets), [refTargets]);
|
|
84
|
+
const refOptions = useRefOptions(ops, registry, locale, refTargetList);
|
|
85
|
+
const tagSuggestions = useTagSuggestions(ops, collection, meta?.fields ?? NO_FIELDS, locale);
|
|
79
86
|
const localeKeys = useMemo(() => (meta ? localizedFieldKeys(meta) : []), [meta]);
|
|
80
87
|
// Load the locale's merged fields; an absent locale comes back with fields: null.
|
|
81
88
|
useEffect(() => {
|
|
@@ -99,27 +106,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
99
106
|
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
100
107
|
return () => { cancelled = true; };
|
|
101
108
|
}, [ops, collection, id, locale, reloadKey]);
|
|
102
|
-
// Ref options in the locale being edited: a target absent there is not offered.
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
let cancelled = false;
|
|
105
|
-
for (const target of new Set(Object.values(refTargets))) {
|
|
106
|
-
const targetMeta = registry.collections.find((c) => c.name === target);
|
|
107
|
-
ops.entries.list(target).then((r) => {
|
|
108
|
-
// A list issued for the previous locale must not overwrite this one's.
|
|
109
|
-
if (cancelled || !r.ok)
|
|
110
|
-
return;
|
|
111
|
-
const options = r.data
|
|
112
|
-
.filter((e) => e.locales[locale])
|
|
113
|
-
.map((e) => ({
|
|
114
|
-
id: e.id,
|
|
115
|
-
title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
|
|
116
|
-
status: e.locales[locale].status,
|
|
117
|
-
}));
|
|
118
|
-
setRefOptions((prev) => ({ ...prev, [target]: options }));
|
|
119
|
-
}).catch(console.error);
|
|
120
|
-
}
|
|
121
|
-
return () => { cancelled = true; };
|
|
122
|
-
}, [ops, refTargets, registry, locale]);
|
|
123
109
|
if (!meta)
|
|
124
110
|
return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
|
|
125
111
|
const row = record?.locales[locale];
|
|
@@ -308,7 +294,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
308
294
|
return;
|
|
309
295
|
const r = await ops.entries.delete(collection, id);
|
|
310
296
|
if (r.ok)
|
|
311
|
-
navigate(
|
|
297
|
+
navigate(adminHref(`/${collection}`), { force: true });
|
|
312
298
|
else
|
|
313
299
|
fail(r);
|
|
314
300
|
};
|
|
@@ -317,19 +303,19 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
317
303
|
const base = meta.path?.[locale];
|
|
318
304
|
const slugPrefix = base ? `/${base}/` : undefined;
|
|
319
305
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
320
|
-
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]]
|
|
306
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
|
|
321
307
|
setFields((f) => ({ ...(f ?? {}), [key]: value }));
|
|
322
308
|
setDirty(true);
|
|
323
309
|
setTouched(true);
|
|
324
310
|
mark();
|
|
325
311
|
} }, key));
|
|
326
312
|
const absent = record !== null && fields === null && !record.locales[locale];
|
|
327
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref:
|
|
313
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref(`/${collection}`), items: [
|
|
328
314
|
{
|
|
329
|
-
label: meta.title, href:
|
|
315
|
+
label: meta.title, href: adminHref(`/${collection}`),
|
|
330
316
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
331
317
|
},
|
|
332
318
|
{ label: heading },
|
|
333
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]]
|
|
319
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
334
320
|
revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
335
321
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
/** A whole-column failure: nothing to show but the message and a way
|
|
2
|
-
|
|
1
|
+
/** A whole-column failure: nothing to show but the message and a way
|
|
2
|
+
* back — none when the shell is mounted outside its own path. */
|
|
3
|
+
export declare function ErrorPane({ message, back }: {
|
|
3
4
|
message: string;
|
|
5
|
+
back?: boolean;
|
|
4
6
|
}): import("react").JSX.Element;
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
4
|
import { Banner } from "../ui/primitives.js";
|
|
5
|
-
import {
|
|
6
|
-
/** A whole-column failure: nothing to show but the message and a way
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { useAdminHref } from "./base.js";
|
|
6
|
+
/** A whole-column failure: nothing to show but the message and a way
|
|
7
|
+
* back — none when the shell is mounted outside its own path. */
|
|
8
|
+
export function ErrorPane({ message, back = true }) {
|
|
9
|
+
const adminHref = useAdminHref();
|
|
10
|
+
return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), back && _jsx("a", { href: adminHref("/pages"), style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
|
|
9
11
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import type { AdminGlobal } from "../serialize.ts";
|
|
2
2
|
import type { Group } from "../forms/tabs.ts";
|
|
3
3
|
import { type NodeValues } from "../editor/InspectorFields.tsx";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
title: string;
|
|
7
|
-
status: string;
|
|
8
|
-
};
|
|
4
|
+
import type { RefOption } from "../forms/ref-picker.ts";
|
|
5
|
+
export type { RefOption };
|
|
9
6
|
/** The item's value map as the inspector reads it: the reserved keys pulled out into their namespaces. */
|
|
10
7
|
export declare function globalValues(fields: Record<string, unknown>): NodeValues;
|
|
11
8
|
export declare function GlobalFields({ item, groups, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }: {
|
|
@@ -15,7 +12,7 @@ export declare function GlobalFields({ item, groups, fields, errors, refOptions,
|
|
|
15
12
|
fields: Record<string, unknown>;
|
|
16
13
|
errors?: Record<string, string>;
|
|
17
14
|
/** Loaded ref options by target collection. */
|
|
18
|
-
refOptions: Record<string, RefOption[]>;
|
|
15
|
+
refOptions: Record<string, RefOption[] | undefined>;
|
|
19
16
|
multilingual: boolean;
|
|
20
17
|
/** The content field with an active inline session: its richtext widget yields to a note. */
|
|
21
18
|
inlinePath?: string;
|
|
@@ -14,6 +14,6 @@ export function GlobalFields({ item, groups, fields, errors, refOptions, multili
|
|
|
14
14
|
return (_jsx(InspectorFields, { groups: groups, values: values, render: ({ key, ns, descriptor }, { emptyLabel, scope }) => {
|
|
15
15
|
const target = (descriptor.type === "ref" || descriptor.type === "refList") && typeof descriptor.target === "string" ? descriptor.target : null;
|
|
16
16
|
const localized = ns === "fields" && item.fields[key]?.localized === true;
|
|
17
|
-
return (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(values, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, errors: errors, refOptions: target ? refOptions[target]
|
|
17
|
+
return (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(values, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, errors: errors, refOptions: target ? refOptions[target] : undefined, mark: ns === "fields" && multilingual && !localized ? _jsx(UnlocalizedMark, {}) : undefined, inlinePath: ns === "fields" ? inlinePath : undefined, onEditHere: onEditHere, onChange: (value) => onChange(ns, key, value) }, `${ns}.${key}`));
|
|
18
18
|
} }));
|
|
19
19
|
}
|
|
@@ -15,6 +15,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
15
15
|
import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
|
|
16
16
|
import { tabsFor } from "../forms/tabs.js";
|
|
17
17
|
import { revealField } from "../forms/reveal.js";
|
|
18
|
+
import { useRefOptions } from "../forms/useRefOptions.js";
|
|
18
19
|
import { useDraftAutosave } from "../editor/autosave.js";
|
|
19
20
|
import { updatedLine } from "../ui/format.js";
|
|
20
21
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
@@ -23,19 +24,18 @@ import { PublishControl } from "../ui/PublishControl.js";
|
|
|
23
24
|
import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
24
25
|
import { localeStatusOf } from "./pages-tree.js";
|
|
25
26
|
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
26
|
-
import {
|
|
27
|
-
import { BASE } from "./base.js";
|
|
27
|
+
import { useAdminHref } from "./base.js";
|
|
28
28
|
import { GlobalFields } from "./GlobalFields.js";
|
|
29
29
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
30
30
|
import { useLeaveGuard } from "./router.js";
|
|
31
31
|
export function GlobalForm({ ops, registry, name }) {
|
|
32
|
+
const adminHref = useAdminHref();
|
|
32
33
|
const item = registry.globals.find((s) => s.name === name);
|
|
33
34
|
const multilingual = registry.locales.supported.length > 1;
|
|
34
35
|
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
35
36
|
const [record, setRecord] = useState(null);
|
|
36
37
|
const [fields, setFields] = useState(null);
|
|
37
38
|
const [usage, setUsage] = useState([]);
|
|
38
|
-
const [refOptions, setRefOptions] = useState({});
|
|
39
39
|
const [notice, setNotice] = useState(null);
|
|
40
40
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
41
41
|
const [dirty, setDirty] = useState(false);
|
|
@@ -67,6 +67,8 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
67
67
|
}
|
|
68
68
|
return out;
|
|
69
69
|
}, [item]);
|
|
70
|
+
const refTargetList = useMemo(() => Object.values(refTargets), [refTargets]);
|
|
71
|
+
const refOptions = useRefOptions(ops, registry, locale, refTargetList);
|
|
70
72
|
useEffect(() => {
|
|
71
73
|
let cancelled = false;
|
|
72
74
|
setFields(null);
|
|
@@ -87,22 +89,6 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
87
89
|
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
88
90
|
return () => { cancelled = true; };
|
|
89
91
|
}, [ops, name, locale, reloadKey]);
|
|
90
|
-
// Ref options in the locale being edited — the entry form's rule.
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
let cancelled = false;
|
|
93
|
-
for (const target of new Set(Object.values(refTargets))) {
|
|
94
|
-
const targetMeta = registry.collections.find((c) => c.name === target);
|
|
95
|
-
ops.entries.list(target).then((r) => {
|
|
96
|
-
if (cancelled || !r.ok)
|
|
97
|
-
return;
|
|
98
|
-
const options = r.data
|
|
99
|
-
.filter((e) => e.locales[locale])
|
|
100
|
-
.map((e) => ({ id: e.id, title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id, status: e.locales[locale].status }));
|
|
101
|
-
setRefOptions((prev) => ({ ...prev, [target]: options }));
|
|
102
|
-
}).catch(console.error);
|
|
103
|
-
}
|
|
104
|
-
return () => { cancelled = true; };
|
|
105
|
-
}, [ops, refTargets, registry, locale]);
|
|
106
92
|
if (!item)
|
|
107
93
|
return _jsx("p", { style: { padding: 32 }, children: "No such global item." });
|
|
108
94
|
const row = record?.locales[locale];
|
|
@@ -247,12 +233,12 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
247
233
|
setTouched(true);
|
|
248
234
|
mark();
|
|
249
235
|
};
|
|
250
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref:
|
|
236
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
|
|
251
237
|
{
|
|
252
|
-
label: "Globals", href:
|
|
238
|
+
label: "Globals", href: adminHref("/globals"),
|
|
253
239
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
254
240
|
},
|
|
255
241
|
{ label: item.title },
|
|
256
242
|
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
257
|
-
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href:
|
|
243
|
+
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
|
|
258
244
|
}
|
|
@@ -12,7 +12,7 @@ import { statusLabel } from "../ui/status-label.js";
|
|
|
12
12
|
import { StatusBadge } from "../ui/primitives.js";
|
|
13
13
|
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
14
14
|
import { relativeTime } from "../ui/format.js";
|
|
15
|
-
import {
|
|
15
|
+
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
17
17
|
const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
|
|
18
18
|
function rememberedLocale(registry) {
|
|
@@ -27,6 +27,7 @@ function rememberedLocale(registry) {
|
|
|
27
27
|
return registry.locales.default;
|
|
28
28
|
}
|
|
29
29
|
export function GlobalList({ ops, registry }) {
|
|
30
|
+
const adminHref = useAdminHref();
|
|
30
31
|
const [items, setItems] = useState(null);
|
|
31
32
|
const [error, setError] = useState(null);
|
|
32
33
|
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
@@ -54,7 +55,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
54
55
|
{ label: "Status", width: "170px" },
|
|
55
56
|
], empty: items ? "No globals are registered." : "Loading…", rows: rows.map((row) => ({
|
|
56
57
|
key: row.name,
|
|
57
|
-
href:
|
|
58
|
+
href: adminHref(`/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`),
|
|
58
59
|
cells: [
|
|
59
60
|
_jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
|
|
60
61
|
_jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
|
|
@@ -20,7 +20,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
20
20
|
import { useEffect, useId, useRef, useState } from "react";
|
|
21
21
|
import { Globe, Image, LibraryBig, PanelTop, Settings } from "lucide-react";
|
|
22
22
|
import { ThemeToggle } from "../ui/primitives.js";
|
|
23
|
-
import {
|
|
23
|
+
import { useAdminHref } from "./base.js";
|
|
24
24
|
import { RouteLink } from "./RouteLink.js";
|
|
25
25
|
/** Lucide at the rail's size and stroke (the tile's CSS restates both). */
|
|
26
26
|
const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
|
|
@@ -30,6 +30,7 @@ function AppIcon() {
|
|
|
30
30
|
return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 89 86", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { fill: "#0038B1", d: "M0 51.5552C0 24.3141 25.0505 0 52.5493 0H52.7761C74.4707 0 89 14.6336 89 34.445C89 61.6862 64.4034 86 36.0066 86C14.0946 86 0 70.9165 0 51.5552Z" }), _jsx("path", { fill: "#FFFFFF", d: "M23.25 46.1188C23.25 33.0027 34.8806 21.2959 47.6479 21.2959H47.7532C57.8257 21.2959 64.5714 28.3417 64.5714 37.8805C64.5714 50.9966 53.1516 62.7033 39.9674 62.7033C29.7939 62.7033 23.25 55.4409 23.25 46.1188Z" })] }));
|
|
31
31
|
}
|
|
32
32
|
export function IconRail({ registry, active, email, onSignOut }) {
|
|
33
|
+
const adminHref = useAdminHref();
|
|
33
34
|
const root = useRef(null);
|
|
34
35
|
const uid = useId();
|
|
35
36
|
const [open, setOpen] = useState(null);
|
|
@@ -77,5 +78,5 @@ export function IconRail({ registry, active, email, onSignOut }) {
|
|
|
77
78
|
const collectionsOpen = open === "collections" || (hovered && open === null);
|
|
78
79
|
const name = email?.split("@")[0] ?? "Editor";
|
|
79
80
|
const initials = name.slice(0, 2).toUpperCase();
|
|
80
|
-
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }),
|
|
81
|
+
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), adminHref("/globals")), link("settings", "Settings", _jsx(Settings, { ...ICON }), null)] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
81
82
|
}
|
|
@@ -14,7 +14,7 @@ import { Button, StatusBadge } from "../ui/primitives.js";
|
|
|
14
14
|
import { statusLabel } from "../ui/status-label.js";
|
|
15
15
|
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
16
16
|
import { useSortable } from "../ui/useSortable.js";
|
|
17
|
-
import {
|
|
17
|
+
import { useAdminHref } from "./base.js";
|
|
18
18
|
import { navigate } from "./router.js";
|
|
19
19
|
import { ErrorPane } from "./ErrorPane.js";
|
|
20
20
|
const LOCALE_KEY = "smoodly.pages.locale";
|
|
@@ -32,6 +32,7 @@ function rememberedLocale(registry) {
|
|
|
32
32
|
return registry.locales.default;
|
|
33
33
|
}
|
|
34
34
|
export function PagesList({ ops, registry }) {
|
|
35
|
+
const adminHref = useAdminHref();
|
|
35
36
|
const [pages, setPages] = useState(null);
|
|
36
37
|
const [error, setError] = useState(null);
|
|
37
38
|
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
@@ -80,7 +81,7 @@ export function PagesList({ ops, registry }) {
|
|
|
80
81
|
});
|
|
81
82
|
/** The editor opens in the locale selected here. */
|
|
82
83
|
const openEditor = (id) => {
|
|
83
|
-
navigate(
|
|
84
|
+
navigate(adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
84
85
|
};
|
|
85
86
|
/** Run an op; on success reload the list (and maybe navigate), else show the op's message. Resolves to whether it succeeded. */
|
|
86
87
|
const run = async (work, then) => {
|
|
@@ -192,7 +193,7 @@ export function PagesList({ ops, registry }) {
|
|
|
192
193
|
else if (row.template !== null)
|
|
193
194
|
openEditor(row.id);
|
|
194
195
|
else if (row.kind === "mount" && row.collection)
|
|
195
|
-
navigate(
|
|
196
|
+
navigate(adminHref(`/${row.collection}`));
|
|
196
197
|
else if (row.hasChildren)
|
|
197
198
|
toggle(row.id);
|
|
198
199
|
};
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
export declare const
|
|
1
|
+
export declare const AdminPathProvider: import("react").Provider<string>;
|
|
2
|
+
export declare const useAdminPath: () => string;
|
|
3
|
+
/** `adminHref("/pages")` → "/admin/pages" — the path starts with "/". */
|
|
4
|
+
export declare function useAdminHref(): (path: string) => string;
|
package/dist/admin/shell/base.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
// packages/smoodly/src/admin/shell/base.ts
|
|
2
|
-
// The admin's mount path
|
|
3
|
-
//
|
|
4
|
-
|
|
3
|
+
// The admin's mount path inside the shell (spec 2026-09-17 §3): AdminApp
|
|
4
|
+
// provides registry.adminPath, every view builds its links through
|
|
5
|
+
// useAdminHref(). Without a provider the path is the default, so a
|
|
6
|
+
// component rendered alone (a test, a story) links under /admin.
|
|
7
|
+
import { createContext, useCallback, useContext } from "react";
|
|
8
|
+
import { DEFAULT_ADMIN_PATH, adminHref } from "../../admin-path.js";
|
|
9
|
+
const AdminPathContext = createContext(DEFAULT_ADMIN_PATH);
|
|
10
|
+
export const AdminPathProvider = AdminPathContext.Provider;
|
|
11
|
+
export const useAdminPath = () => useContext(AdminPathContext);
|
|
12
|
+
/** `adminHref("/pages")` → "/admin/pages" — the path starts with "/". */
|
|
13
|
+
export function useAdminHref() {
|
|
14
|
+
const base = useContext(AdminPathContext);
|
|
15
|
+
return useCallback((path) => adminHref(base, path), [base]);
|
|
16
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type Route = {
|
|
2
|
+
pathname: string;
|
|
2
3
|
segments: string[];
|
|
3
4
|
search: string;
|
|
4
5
|
};
|
|
@@ -29,9 +30,9 @@ export declare function navigate(href: string, opts?: {
|
|
|
29
30
|
replace?: boolean;
|
|
30
31
|
force?: boolean;
|
|
31
32
|
}): boolean;
|
|
32
|
-
/** The current route
|
|
33
|
-
* popstate and after navigate(). */
|
|
34
|
-
export declare function useRoute(): Route;
|
|
33
|
+
/** The current route under `base`: empty until mount, then the URL,
|
|
34
|
+
* re-read on popstate and after navigate(). */
|
|
35
|
+
export declare function useRoute(base: string): Route;
|
|
35
36
|
/** Register the calling view as dirty: navigate() asks before leaving,
|
|
36
37
|
* and beforeunload covers reload, closing the tab and a real link out. */
|
|
37
38
|
export declare function useLeaveGuard(dirty: boolean): void;
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
// packages/smoodly/src/admin/shell/router.ts
|
|
3
3
|
// The admin's own router (spec 2026-09-08 client routing): the route is
|
|
4
|
-
// the URL under
|
|
5
|
-
// the shell stays mounted and a click is a state change,
|
|
6
|
-
// load. The URL is the only source: the shell's first render is the
|
|
4
|
+
// the URL under the admin path, held in React state and moved with
|
|
5
|
+
// pushState, so the shell stays mounted and a click is a state change,
|
|
6
|
+
// not a document load. The URL is the only source: the shell's first render is the
|
|
7
7
|
// session gate, so no view exists before the mount effect has read it.
|
|
8
8
|
// Next's router is not involved — native pushState updates its
|
|
9
9
|
// usePathname without a refetch — and the admin core stays
|
|
10
10
|
// framework-agnostic.
|
|
11
11
|
import { useEffect, useState } from "react";
|
|
12
|
-
import {
|
|
12
|
+
import { DEFAULT_ADMIN_PATH } from "../../admin-path.js";
|
|
13
13
|
/** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
|
|
14
14
|
* the mount itself, or a pathname outside it, → []. Segments stay as
|
|
15
15
|
* written; the shell decodes the ones it uses, as it does for the
|
|
16
16
|
* catch-all's params. */
|
|
17
|
-
export function segmentsOf(pathname, base =
|
|
17
|
+
export function segmentsOf(pathname, base = DEFAULT_ADMIN_PATH) {
|
|
18
18
|
if (pathname !== base && !pathname.startsWith(`${base}/`))
|
|
19
19
|
return [];
|
|
20
20
|
return pathname.slice(base.length).split("/").filter((s) => s !== "");
|
|
@@ -40,10 +40,13 @@ export function mayLeave(guards, confirm) {
|
|
|
40
40
|
// ── the live router ──────────────────────────────────────────────────
|
|
41
41
|
const guards = new Set();
|
|
42
42
|
const listeners = new Set();
|
|
43
|
-
function currentRoute() {
|
|
44
|
-
|
|
43
|
+
function currentRoute(base) {
|
|
44
|
+
const { pathname, search } = window.location;
|
|
45
|
+
return { pathname, segments: segmentsOf(pathname, base), search };
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
// The segments derive from the pathname and a base that never changes
|
|
48
|
+
// under a mounted shell, so two strings decide sameness.
|
|
49
|
+
const sameRoute = (a, b) => a.pathname === b.pathname && a.search === b.search;
|
|
47
50
|
/** Move to `href` in place; true when the move happened. A push asks the
|
|
48
51
|
* leave guard; a replace never leaves the view, so it does not ask.
|
|
49
52
|
* `force` skips the guard for a view that has just saved or deleted its
|
|
@@ -56,17 +59,17 @@ export function navigate(href, opts = {}) {
|
|
|
56
59
|
listener();
|
|
57
60
|
return true;
|
|
58
61
|
}
|
|
59
|
-
/** The current route
|
|
60
|
-
* popstate and after navigate(). */
|
|
61
|
-
export function useRoute() {
|
|
62
|
-
const [route, setRoute] = useState({ segments: [], search: "" });
|
|
62
|
+
/** The current route under `base`: empty until mount, then the URL,
|
|
63
|
+
* re-read on popstate and after navigate(). */
|
|
64
|
+
export function useRoute(base) {
|
|
65
|
+
const [route, setRoute] = useState({ pathname: "", segments: [], search: "" });
|
|
63
66
|
useEffect(() => {
|
|
64
|
-
const sync = () => setRoute((prev) => { const next = currentRoute(); return sameRoute(prev, next) ? prev : next; });
|
|
67
|
+
const sync = () => setRoute((prev) => { const next = currentRoute(base); return sameRoute(prev, next) ? prev : next; });
|
|
65
68
|
sync();
|
|
66
69
|
listeners.add(sync);
|
|
67
70
|
window.addEventListener("popstate", sync);
|
|
68
71
|
return () => { listeners.delete(sync); window.removeEventListener("popstate", sync); };
|
|
69
|
-
}, []);
|
|
72
|
+
}, [base]);
|
|
70
73
|
return route;
|
|
71
74
|
}
|
|
72
75
|
/** Register the calling view as dirty: navigate() asks before leaving,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type KeyboardEvent, type ReactNode } from "react";
|
|
2
|
+
import { type ComboOption } from "./combo-box.ts";
|
|
3
|
+
export type ComboboxProps = {
|
|
4
|
+
options: ComboOption[];
|
|
5
|
+
onPick(value: string): void;
|
|
6
|
+
/** Accessible name of the input. */
|
|
7
|
+
label: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Stay open with the query cleared after a pick (multi-select). */
|
|
11
|
+
keepOpen?: boolean;
|
|
12
|
+
/** Shown when there are no options at all; "No matches" covers a filter that leaves nothing. */
|
|
13
|
+
emptyText?: string;
|
|
14
|
+
renderOption?(o: ComboOption): ReactNode;
|
|
15
|
+
/** Free text: Enter with no active row submits the query, and no row starts active. */
|
|
16
|
+
onSubmit?(query: string): void;
|
|
17
|
+
/** With `onSubmit`: a last row, `Create "query"`, unless an option's label equals the query. */
|
|
18
|
+
createRow?: boolean;
|
|
19
|
+
/** Every query change passes through; the return value is the query kept. */
|
|
20
|
+
onQueryChange?(next: string): string;
|
|
21
|
+
/** Keys the owner wants first; return true when handled. */
|
|
22
|
+
onInputKey?(e: KeyboardEvent<HTMLInputElement>, query: string): boolean;
|
|
23
|
+
/** Escape, or focus leaving the control; `query` is what was typed and not picked. */
|
|
24
|
+
onClose?(query: string, reason: "escape" | "blur"): void;
|
|
25
|
+
autoFocus?: boolean;
|
|
26
|
+
/** Start open: the tags input, and static-markup tests. */
|
|
27
|
+
initialOpen?: boolean;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, }: ComboboxProps): import("react").JSX.Element;
|