smoodly 0.0.27 → 0.0.29
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 +75 -3
- package/dist/admin/client-ops.js +2 -0
- package/dist/admin/drift-copy.d.ts +19 -0
- package/dist/admin/drift-copy.js +89 -0
- package/dist/admin/drift.d.ts +76 -0
- package/dist/admin/drift.js +264 -0
- package/dist/admin/editor/EditorView.js +70 -15
- package/dist/admin/editor/InlineRichtext.js +3 -1
- package/dist/admin/editor/Outline.d.ts +7 -0
- package/dist/admin/editor/Outline.js +8 -5
- package/dist/admin/editor/PageInspector.d.ts +9 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.d.ts +4 -1
- package/dist/admin/editor/PageSettings.js +33 -15
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -0
- package/dist/admin/field-paths.d.ts +3 -0
- package/dist/admin/field-paths.js +26 -0
- package/dist/admin/forms/OrphanFields.d.ts +8 -0
- package/dist/admin/forms/OrphanFields.js +11 -0
- package/dist/admin/forms/RefPicker.js +6 -2
- package/dist/admin/forms/RichtextElementCard.js +9 -3
- package/dist/admin/forms/RichtextWidget.js +3 -1
- package/dist/admin/forms/TagsField.js +1 -1
- package/dist/admin/forms/richtext-form-context.d.ts +8 -0
- package/dist/admin/forms/richtext-form-context.js +5 -0
- package/dist/admin/health.d.ts +23 -0
- package/dist/admin/health.js +42 -0
- package/dist/admin/ops-impl.js +124 -14
- package/dist/admin/ops.d.ts +53 -1
- package/dist/admin/redirect-path.d.ts +6 -0
- package/dist/admin/redirect-path.js +33 -0
- package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
- package/dist/admin/shell/AddRedirectModal.js +29 -0
- package/dist/admin/shell/AdminApp.js +2 -3
- package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
- package/dist/admin/shell/ContentHealthPage.js +37 -0
- package/dist/admin/shell/EntriesList.js +4 -4
- package/dist/admin/shell/EntryForm.js +32 -7
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +5 -4
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/IconRail.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +9 -7
- package/dist/admin/shell/RedirectsPage.d.ts +11 -0
- package/dist/admin/shell/RedirectsPage.js +75 -0
- package/dist/admin/shell/SettingsView.d.ts +4 -2
- package/dist/admin/shell/SettingsView.js +22 -14
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/live-url.d.ts +10 -2
- package/dist/admin/shell/live-url.js +9 -4
- package/dist/admin/shell/members-list.js +1 -1
- package/dist/admin/shell/redirects-list.d.ts +12 -0
- package/dist/admin/shell/redirects-list.js +15 -0
- package/dist/admin/shell/settings.d.ts +16 -3
- package/dist/admin/shell/settings.js +16 -9
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
- package/dist/admin/ui/ConfirmDialog.js +5 -4
- package/dist/admin/ui/DriftNotice.d.ts +6 -0
- package/dist/admin/ui/DriftNotice.js +10 -0
- package/dist/admin/ui/combo-box.d.ts +13 -0
- package/dist/admin/ui/combo-box.js +8 -0
- package/dist/admin/ui/confirm.d.ts +21 -2
- package/dist/admin/ui/confirm.js +26 -9
- package/dist/admin/ui/theme.js +32 -3
- package/dist/admin/validate.js +15 -47
- package/dist/entry-store.d.ts +31 -6
- package/dist/entry-store.js +39 -11
- package/dist/index.d.ts +2 -2
- package/dist/next/page-renderer.js +28 -9
- package/dist/path-index.d.ts +28 -4
- package/dist/path-index.js +37 -4
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +9 -0
- package/dist/render.js +7 -2
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +4 -1
- package/dist/site.js +3 -1
- package/dist/sql.js +19 -1
- package/dist/store.d.ts +39 -5
- package/dist/store.js +32 -8
- package/dist/supabase-entry-store.d.ts +9 -3
- package/dist/supabase-entry-store.js +39 -13
- package/dist/supabase-path-index.d.ts +10 -3
- package/dist/supabase-path-index.js +50 -17
- package/dist/supabase-store.d.ts +15 -3
- package/dist/supabase-store.js +42 -8
- package/migrations/20260926150000_smoodly_v4.sql +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Add a redirect by hand (spec 2026-09-26 redirects §5–§6): the old
|
|
4
|
+
// address, checked as typed with redirect-path.ts so the message shows
|
|
5
|
+
// before the op repeats the check, and the target — one type-ahead over
|
|
6
|
+
// every page and routed entry live in the locale, "Title · /path".
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "../redirect-path.js";
|
|
9
|
+
import { Combobox } from "../ui/Combobox.js";
|
|
10
|
+
import { Modal } from "../ui/Modal.js";
|
|
11
|
+
import { Banner, Button, Field } from "../ui/primitives.js";
|
|
12
|
+
import { parseTargetValue, targetOptions } from "./redirects-list.js";
|
|
13
|
+
export function AddRedirectModal({ locale, registry, targets, onClose, onAdd }) {
|
|
14
|
+
const [path, setPath] = useState("");
|
|
15
|
+
const [target, setTarget] = useState(null);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
const problem = redirectPathProblem(path, { adminPath: registry.adminPath, multilingual: registry.locales.supported.length > 1 });
|
|
19
|
+
const picked = target ? targets.find((t) => t.target.kind === target.kind && t.target.id === target.id) : undefined;
|
|
20
|
+
const submit = async (e) => {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
if (!target || problem)
|
|
23
|
+
return;
|
|
24
|
+
setBusy(true);
|
|
25
|
+
setError(await onAdd(path, target));
|
|
26
|
+
setBusy(false);
|
|
27
|
+
};
|
|
28
|
+
return (_jsx(Modal, { title: "Add redirect", width: 480, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Old address", error: path !== "" && problem ? REDIRECT_PATH_MESSAGE[problem] : undefined, children: _jsx("input", { className: "sm-input sm-input--mono", autoFocus: true, placeholder: "/old-address", value: path, onChange: (e) => setPath(e.target.value) }) }), _jsx(Field, { label: "Redirects to", asDiv: true, hint: `A page or entry that is live in ${locale}.`, children: _jsx(Combobox, { label: "Redirects to", options: targetOptions(targets), placeholder: "Type a title or an address", emptyText: `Nothing is live in ${locale} yet.`, selected: picked ? `${picked.title} · ${picked.path}` : undefined, onPick: (value) => setTarget(parseTargetValue(value)) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy || !target || path === "" || problem !== null, children: busy ? "Saving…" : "Save redirect" })] })] }) }));
|
|
29
|
+
}
|
|
@@ -28,7 +28,6 @@ import { ConfirmHost } from "../ui/ConfirmDialog.js";
|
|
|
28
28
|
import { useRoute } from "./router.js";
|
|
29
29
|
import { AdminPathProvider } from "./base.js";
|
|
30
30
|
import { mountMismatch } from "../../admin-path.js";
|
|
31
|
-
import { can } from "../../permissions.js";
|
|
32
31
|
import { ErrorPane } from "./ErrorPane.js";
|
|
33
32
|
import { MediaScreen } from "./MediaScreen.js";
|
|
34
33
|
import { SettingsView } from "./SettingsView.js";
|
|
@@ -139,10 +138,10 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
139
138
|
else if (head === "globals" && tail)
|
|
140
139
|
main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
141
140
|
else if (head === "settings")
|
|
142
|
-
main = _jsx(SettingsView, { ops: ops, tail: tail, role: role, userId: userId });
|
|
141
|
+
main = _jsx(SettingsView, { ops: ops, registry: registry, tail: tail, role: role, userId: userId });
|
|
143
142
|
else if (head === "media")
|
|
144
143
|
main = _jsx(MediaScreen, {});
|
|
145
144
|
else
|
|
146
145
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
147
|
-
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null
|
|
146
|
+
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
|
|
148
147
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { AdminOps } from "../ops.ts";
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
import type { HealthReport } from "../health.ts";
|
|
5
|
+
export declare function ContentHealthPage({ ops, registry, initial }: {
|
|
6
|
+
ops: AdminOps;
|
|
7
|
+
registry: AdminRegistry;
|
|
8
|
+
/** Static-markup tests: a report without a scan. */
|
|
9
|
+
initial?: HealthReport | null;
|
|
10
|
+
}): ReactElement;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Settings › Content health (spec 2026-09-27 editor safety §4): the scan on
|
|
4
|
+
// open and on Rescan; "Blocks publishing" then "Left over", each group a
|
|
5
|
+
// headline over its documents with their locales, Draft/Live tags and an
|
|
6
|
+
// Open link. It only points — the editors fix. Every member.
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { Button, TopBar } from "../ui/primitives.js";
|
|
9
|
+
import { useAdminHref } from "./base.js";
|
|
10
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
11
|
+
import { RouteLink } from "./RouteLink.js";
|
|
12
|
+
import { GROUP_PREVIEW, healthSections } from "./health-list.js";
|
|
13
|
+
export function ContentHealthPage({ ops, registry, initial = null }) {
|
|
14
|
+
const [report, setReport] = useState(initial);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const scan = () => {
|
|
17
|
+
setError(null);
|
|
18
|
+
setReport(null);
|
|
19
|
+
ops.health.scan()
|
|
20
|
+
.then((r) => (r.ok ? setReport(r.data) : setError(r.message)))
|
|
21
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
22
|
+
};
|
|
23
|
+
useEffect(() => { if (!initial)
|
|
24
|
+
scan(); }, [ops, initial]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
25
|
+
if (error)
|
|
26
|
+
return _jsx(ErrorPane, { message: error, back: false, onRetry: scan });
|
|
27
|
+
const sections = report ? healthSections(report, registry) : null;
|
|
28
|
+
const brokenCount = sections ? sections.broken.length + sections.collections.length : 0;
|
|
29
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
|
|
30
|
+
}
|
|
31
|
+
function Group({ group }) {
|
|
32
|
+
const adminHref = useAdminHref();
|
|
33
|
+
const [all, setAll] = useState(false);
|
|
34
|
+
const rows = all ? group.rows : group.rows.slice(0, GROUP_PREVIEW);
|
|
35
|
+
const n = group.rows.length;
|
|
36
|
+
return (_jsxs("div", { className: "sm-health__group", children: [_jsxs("div", { className: "sm-health__headline", children: [_jsx("span", { children: group.headline }), _jsxs("span", { className: "sm-hint", children: [n, " ", n === 1 ? "document" : "documents"] })] }), rows.map((r) => (_jsxs("div", { className: "sm-row sm-health__row", children: [_jsx("span", { className: "sm-cell", children: r.title }), _jsx("span", { className: "sm-cell sm-hint", children: r.kind }), _jsx("span", { className: "sm-cell", children: r.locales.join(" ") }), _jsxs("span", { className: "sm-cell", children: [r.draft && _jsx("span", { className: "sm-badge", style: { background: "var(--status-draft-bg)", color: "var(--status-draft-fg)" }, children: "Draft" }), r.live && _jsx("span", { className: "sm-badge", style: { background: "var(--status-published-bg)", color: "var(--status-published-fg)" }, children: "Live" })] }), _jsx("span", { className: "sm-cell", children: _jsx(RouteLink, { href: adminHref(r.href), children: "Open \u2192" }) })] }, r.key))), !all && n > GROUP_PREVIEW && _jsxs(Button, { variant: "secondary", onClick: () => setAll(true), children: ["Show all ", n] })] }));
|
|
37
|
+
}
|
|
@@ -14,7 +14,7 @@ import { relativeTime } from "../ui/format.js";
|
|
|
14
14
|
import { useSortable } from "../ui/useSortable.js";
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { navigate } from "./router.js";
|
|
17
|
-
import { confirm } from "../ui/confirm.js";
|
|
17
|
+
import { confirm, notify } from "../ui/confirm.js";
|
|
18
18
|
import { ErrorPane } from "./ErrorPane.js";
|
|
19
19
|
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
20
20
|
import { usePresenceState } from "./presence-context.js";
|
|
@@ -53,7 +53,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
53
53
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
54
54
|
}
|
|
55
55
|
if (!result.ok) {
|
|
56
|
-
|
|
56
|
+
void notify({ title: "Couldn't move the entry", message: result.message });
|
|
57
57
|
throw new Error(result.message);
|
|
58
58
|
}
|
|
59
59
|
await load();
|
|
@@ -76,7 +76,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
76
76
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
77
77
|
}
|
|
78
78
|
if (!result.ok) {
|
|
79
|
-
|
|
79
|
+
void notify({ title: "Couldn't create the entry", message: result.message });
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
82
|
open(result.data.id);
|
|
@@ -102,7 +102,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
102
102
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
103
103
|
}
|
|
104
104
|
if (!result.ok) {
|
|
105
|
-
|
|
105
|
+
void notify({ title: `Couldn't add the entry to ${locale}`, message: result.message });
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
108
|
open(row.id);
|
|
@@ -35,11 +35,16 @@ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-lis
|
|
|
35
35
|
import { useAdminHref } from "./base.js";
|
|
36
36
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
37
37
|
import { navigate, useLeaveGuard } from "./router.js";
|
|
38
|
-
import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
|
|
39
|
-
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
38
|
+
import { LIVE_URL_CONFIRM, LIVE_URL_OPTION, liveUrlChanges } from "./live-url.js";
|
|
39
|
+
import { confirm, confirmWithOption, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
40
40
|
import { draftToken } from "../../draft-token.js";
|
|
41
41
|
import { isStale, staleInfoOf } from "./stale.js";
|
|
42
42
|
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
43
|
+
import { DriftNotice } from "../ui/DriftNotice.js";
|
|
44
|
+
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
45
|
+
import { entryDrift } from "../drift.js";
|
|
46
|
+
import { misfitErrors } from "../drift-copy.js";
|
|
47
|
+
import { unsetAtPath } from "../field-paths.js";
|
|
43
48
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
44
49
|
import { editorsOf } from "./presence.js";
|
|
45
50
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
@@ -53,6 +58,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
53
58
|
const [record, setRecord] = useState(null);
|
|
54
59
|
const [fields, setFields] = useState(null);
|
|
55
60
|
const [urls, setUrls] = useState({});
|
|
61
|
+
const [redirects, setRedirects] = useState({});
|
|
56
62
|
const [parentLocales, setParentLocales] = useState(registry.locales.supported);
|
|
57
63
|
const [notice, setNotice] = useState(null);
|
|
58
64
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
@@ -68,6 +74,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
68
74
|
const [history, setHistory] = useState(null);
|
|
69
75
|
const [snapshot, setSnapshot] = useState(null);
|
|
70
76
|
const [stale, setStale] = useState(null);
|
|
77
|
+
const [liveFindings, setLiveFindings] = useState([]);
|
|
71
78
|
const formRef = useRef(null);
|
|
72
79
|
const fieldsRef = useRef(fields);
|
|
73
80
|
fieldsRef.current = fields;
|
|
@@ -75,6 +82,8 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
75
82
|
recordRef.current = record;
|
|
76
83
|
/** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
|
|
77
84
|
const confirmedSlug = useRef(null);
|
|
85
|
+
/** The confirm's "Redirect the old address" answer for the save that follows it (spec 2026-09-26 redirects §6). */
|
|
86
|
+
const redirectRef = useRef(true);
|
|
78
87
|
// The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
|
|
79
88
|
/** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
80
89
|
const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
|
|
@@ -93,15 +102,19 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
93
102
|
if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
|
|
94
103
|
const current = recordRef.current;
|
|
95
104
|
const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
|
|
96
|
-
|
|
105
|
+
const { yes, checked } = await confirmWithOption({ ...LIVE_URL_CONFIRM, option: { ...LIVE_URL_OPTION }, subject });
|
|
106
|
+
if (yes) {
|
|
97
107
|
confirmedSlug.current = next;
|
|
108
|
+
redirectRef.current = checked;
|
|
109
|
+
}
|
|
98
110
|
else {
|
|
99
111
|
value = { ...value, slug: stored };
|
|
100
112
|
setFields((f) => ({ ...(f ?? {}), slug: stored }));
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
|
-
const r = await ops.entries.save(collection, id, locale, value, token());
|
|
115
|
+
const r = await ops.entries.save(collection, id, locale, value, token(), { redirect: redirectRef.current });
|
|
104
116
|
if (r.ok) {
|
|
117
|
+
redirectRef.current = true;
|
|
105
118
|
setRecord(r.data);
|
|
106
119
|
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
107
120
|
}
|
|
@@ -140,7 +153,9 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
140
153
|
setRecord(r.data.record);
|
|
141
154
|
setFields(r.data.fields);
|
|
142
155
|
setUrls(r.data.urls);
|
|
156
|
+
setRedirects(r.data.redirects);
|
|
143
157
|
setParentLocales(r.data.parentLocales);
|
|
158
|
+
setLiveFindings(r.data.liveFindings);
|
|
144
159
|
}
|
|
145
160
|
else
|
|
146
161
|
setNotice({ tone: "error", text: r.message });
|
|
@@ -148,6 +163,11 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
148
163
|
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
149
164
|
return () => { cancelled = true; };
|
|
150
165
|
}, [ops, collection, id, locale, reloadKey]);
|
|
166
|
+
// Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
|
|
167
|
+
const findings = useMemo(() => (fields && record?.locales[locale]
|
|
168
|
+
? entryDrift(registry, collection, { fields, shared: record.fields, own: record.locales[locale].fields })
|
|
169
|
+
: []), [fields, record, locale, registry, collection]);
|
|
170
|
+
const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
|
|
151
171
|
if (!meta)
|
|
152
172
|
return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
|
|
153
173
|
const row = record?.locales[locale];
|
|
@@ -398,7 +418,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
398
418
|
return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
|
|
399
419
|
};
|
|
400
420
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
401
|
-
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors:
|
|
421
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: markerErrors, 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) => {
|
|
402
422
|
setFields((f) => ({ ...(f ?? {}), [key]: value }));
|
|
403
423
|
setDirty(true);
|
|
404
424
|
setTouched(true);
|
|
@@ -411,6 +431,11 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
411
431
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
412
432
|
},
|
|
413
433
|
{ label: heading },
|
|
414
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _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" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, 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)
|
|
415
|
-
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 ? (
|
|
434
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _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" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, 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)
|
|
435
|
+
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 ? (_jsxs(_Fragment, { children: [Object.entries(meta.fields).map(widget), _jsx(OrphanFields, { orphans: findings, onClear: (path) => {
|
|
436
|
+
setFields((f) => unsetAtPath(f ?? {}, path));
|
|
437
|
+
setDirty(true);
|
|
438
|
+
setTouched(true);
|
|
439
|
+
mark();
|
|
440
|
+
} })] })) : !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) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), 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" })] }))] })] })] }));
|
|
416
441
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
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
|
-
|
|
2
|
+
* back — none when the shell is mounted outside its own path — and a
|
|
3
|
+
* Retry when the caller can run the load again (Content health, 2026-09-27). */
|
|
4
|
+
export declare function ErrorPane({ message, back, onRetry }: {
|
|
4
5
|
message: string;
|
|
5
6
|
back?: boolean;
|
|
7
|
+
onRetry?: () => void;
|
|
6
8
|
}): import("react").JSX.Element;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// packages/smoodly/src/admin/shell/ErrorPane.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
-
import { Banner } from "../ui/primitives.js";
|
|
4
|
+
import { Banner, Button } from "../ui/primitives.js";
|
|
5
5
|
import { useAdminHref } from "./base.js";
|
|
6
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
|
-
|
|
7
|
+
* back — none when the shell is mounted outside its own path — and a
|
|
8
|
+
* Retry when the caller can run the load again (Content health, 2026-09-27). */
|
|
9
|
+
export function ErrorPane({ message, back = true, onRetry }) {
|
|
9
10
|
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" })] }) }));
|
|
11
|
+
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 }), onRetry && _jsx(Button, { variant: "secondary", style: { alignSelf: "flex-start" }, onClick: onRetry, children: "Retry" }), back && _jsx("a", { href: adminHref("/pages"), style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
|
|
11
12
|
}
|
|
@@ -34,6 +34,11 @@ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
|
34
34
|
import { draftToken } from "../../draft-token.js";
|
|
35
35
|
import { isStale, staleInfoOf } from "./stale.js";
|
|
36
36
|
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
37
|
+
import { DriftNotice } from "../ui/DriftNotice.js";
|
|
38
|
+
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
39
|
+
import { entryDrift } from "../drift.js";
|
|
40
|
+
import { misfitErrors } from "../drift-copy.js";
|
|
41
|
+
import { unsetAtPath } from "../field-paths.js";
|
|
37
42
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
38
43
|
import { editorsOf } from "./presence.js";
|
|
39
44
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
@@ -55,6 +60,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
55
60
|
const [history, setHistory] = useState(null);
|
|
56
61
|
const [snapshot, setSnapshot] = useState(null);
|
|
57
62
|
const [stale, setStale] = useState(null);
|
|
63
|
+
const [liveFindings, setLiveFindings] = useState([]);
|
|
58
64
|
const formRef = useRef(null);
|
|
59
65
|
const fieldsRef = useRef(fields);
|
|
60
66
|
fieldsRef.current = fields;
|
|
@@ -106,6 +112,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
106
112
|
setRecord(r.data.record);
|
|
107
113
|
setFields(r.data.fields);
|
|
108
114
|
setUsage(r.data.usage);
|
|
115
|
+
setLiveFindings(r.data.liveFindings);
|
|
109
116
|
}
|
|
110
117
|
else
|
|
111
118
|
setNotice({ tone: "error", text: r.message });
|
|
@@ -113,6 +120,11 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
113
120
|
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
114
121
|
return () => { cancelled = true; };
|
|
115
122
|
}, [ops, name, locale, reloadKey]);
|
|
123
|
+
// Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
|
|
124
|
+
const findings = useMemo(() => (fields && record?.locales[locale]
|
|
125
|
+
? entryDrift(registry, name, { fields, shared: record.fields, own: record.locales[locale].fields })
|
|
126
|
+
: []), [fields, record, locale, registry, name]);
|
|
127
|
+
const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
|
|
116
128
|
if (!item)
|
|
117
129
|
return _jsx("p", { style: { padding: 32 }, children: "No such global item." });
|
|
118
130
|
const row = record?.locales[locale];
|
|
@@ -292,12 +304,20 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
292
304
|
setTouched(true);
|
|
293
305
|
mark();
|
|
294
306
|
};
|
|
307
|
+
// An orphan's path names a visual item's namespaces as drift does (`settings.x`); the stored keys are `$settings` / `$styles`.
|
|
308
|
+
const storedPath = (p) => p.startsWith("settings.") ? `${SETTINGS_KEY}.${p.slice(9)}` : p.startsWith("styles.") ? `${STYLES_KEY}.${p.slice(7)}` : p;
|
|
309
|
+
const clearOrphan = (path) => {
|
|
310
|
+
setFields((f) => unsetAtPath(f ?? {}, storedPath(path)));
|
|
311
|
+
setDirty(true);
|
|
312
|
+
setTouched(true);
|
|
313
|
+
mark();
|
|
314
|
+
};
|
|
295
315
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
|
|
296
316
|
{
|
|
297
317
|
label: "Site", href: adminHref("/globals"),
|
|
298
318
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
299
319
|
},
|
|
300
320
|
{ label: item.title },
|
|
301
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _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" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, 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)
|
|
302
|
-
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:
|
|
321
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _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" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, 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)
|
|
322
|
+
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: markerErrors, 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: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !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 })] })) })] })] }));
|
|
303
323
|
}
|
|
@@ -3,7 +3,7 @@ export declare function IconRail({ registry, active, email, settings, onSignOut
|
|
|
3
3
|
registry: AdminRegistry;
|
|
4
4
|
active: string;
|
|
5
5
|
email?: string;
|
|
6
|
-
/** Whether the Settings tile shows —
|
|
6
|
+
/** Whether the Settings tile shows — once the role is known, in the shell (every member has a Settings page). */
|
|
7
7
|
settings?: boolean;
|
|
8
8
|
onSignOut: () => void;
|
|
9
9
|
}): import("react").JSX.Element;
|
|
@@ -10,8 +10,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
// Collections — one rail item whose flyout lists every collection — then
|
|
11
11
|
// Media, Globals (the global content items, when the registry has any;
|
|
12
12
|
// the route is /globals), and Settings (the Settings area, spec
|
|
13
|
-
// 2026-09-25; the route is /settings;
|
|
14
|
-
//
|
|
13
|
+
// 2026-09-25; the route is /settings; every member has a page there since
|
|
14
|
+
// Redirects, 2026-09-26, so the shell passes `settings` once the role is
|
|
15
|
+
// known). The user avatar is pinned to the bottom and opens the same
|
|
15
16
|
// flyout with the theme toggle and Sign out, which the old nav's footer
|
|
16
17
|
// held.
|
|
17
18
|
//
|
|
@@ -19,7 +19,7 @@ import { navigate } from "./router.js";
|
|
|
19
19
|
import { ErrorPane } from "./ErrorPane.js";
|
|
20
20
|
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
21
21
|
import { confirmLiveUrl } from "./live-url.js";
|
|
22
|
-
import { confirm } from "../ui/confirm.js";
|
|
22
|
+
import { confirm, notify } from "../ui/confirm.js";
|
|
23
23
|
import { PageFormDialog } from "./PageDialogs.js";
|
|
24
24
|
import { usePresenceState } from "./presence-context.js";
|
|
25
25
|
import { editorsOf } from "./presence.js";
|
|
@@ -68,9 +68,10 @@ export function PagesList({ ops, registry }) {
|
|
|
68
68
|
return;
|
|
69
69
|
// A published page under a new parent is a new live URL (members management §5).
|
|
70
70
|
const current = row.record.locales[locale];
|
|
71
|
-
|
|
71
|
+
const live = await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path });
|
|
72
|
+
if (!live.ok)
|
|
72
73
|
return;
|
|
73
|
-
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index }));
|
|
74
|
+
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index, redirect: live.redirect }));
|
|
74
75
|
if (!ok)
|
|
75
76
|
throw new Error("move refused");
|
|
76
77
|
},
|
|
@@ -94,11 +95,11 @@ export function PagesList({ ops, registry }) {
|
|
|
94
95
|
await load();
|
|
95
96
|
return null;
|
|
96
97
|
};
|
|
97
|
-
/** `attempt` for an action outside a dialog: the message goes to
|
|
98
|
+
/** `attempt` for an action outside a dialog: the message goes to a notify dialog. Resolves to whether it succeeded. */
|
|
98
99
|
const run = async (work, then) => {
|
|
99
100
|
const message = await attempt(work, then);
|
|
100
101
|
if (message)
|
|
101
|
-
|
|
102
|
+
void notify({ title: "That didn't work", message });
|
|
102
103
|
return message === null;
|
|
103
104
|
};
|
|
104
105
|
/** New page and New path (a folder in the tree) create in the SELECTED locale (spec §6),
|
|
@@ -150,9 +151,10 @@ export function PagesList({ ops, registry }) {
|
|
|
150
151
|
// The site root's slug is the config's homePageSlug — title only there.
|
|
151
152
|
if (row.isHome)
|
|
152
153
|
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title }));
|
|
153
|
-
|
|
154
|
+
const live = await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path });
|
|
155
|
+
if (!live.ok)
|
|
154
156
|
return null;
|
|
155
|
-
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug }));
|
|
157
|
+
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug, redirect: live.redirect }));
|
|
156
158
|
};
|
|
157
159
|
const addLocale = async (row) => {
|
|
158
160
|
const from = sourceLocaleOf(row.record, registry);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminOps, RedirectItem } from "../ops.ts";
|
|
2
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
+
export declare function RedirectsPage({ ops, registry, initial }: {
|
|
4
|
+
ops: AdminOps;
|
|
5
|
+
registry: AdminRegistry;
|
|
6
|
+
/** Static-markup tests: the rows without a load. */
|
|
7
|
+
initial?: {
|
|
8
|
+
locale: string;
|
|
9
|
+
items: RedirectItem[];
|
|
10
|
+
} | null;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// Settings › Redirects (spec 2026-09-26 redirects §6): the active
|
|
4
|
+
// locale's redirect rows — old address → the target's title (opens its
|
|
5
|
+
// editor) and current URL — with Add redirect and a row menu whose one
|
|
6
|
+
// item removes the row after a confirm. Every member, as renaming is.
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { ActionMenu } from "../ui/ActionMenu.js";
|
|
9
|
+
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
10
|
+
import { confirm } from "../ui/confirm.js";
|
|
11
|
+
import { Banner, Button } from "../ui/primitives.js";
|
|
12
|
+
import { AddRedirectModal } from "./AddRedirectModal.js";
|
|
13
|
+
import { useAdminHref } from "./base.js";
|
|
14
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
15
|
+
import { ListView } from "./ListView.js";
|
|
16
|
+
import { RouteLink } from "./RouteLink.js";
|
|
17
|
+
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
18
|
+
import { REMOVE_REDIRECT_CONFIRM, editorHref } from "./redirects-list.js";
|
|
19
|
+
export function RedirectsPage({ ops, registry, initial = null }) {
|
|
20
|
+
const adminHref = useAdminHref();
|
|
21
|
+
const multilingual = registry.locales.supported.length > 1;
|
|
22
|
+
const [locale, setLocale] = useState(initial?.locale ?? rememberedLocale(registry.locales));
|
|
23
|
+
const [items, setItems] = useState(initial?.items ?? null);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
const [notice, setNotice] = useState(null);
|
|
26
|
+
const [adding, setAdding] = useState(null);
|
|
27
|
+
const load = (l) => ops.redirects.list(l)
|
|
28
|
+
.then((r) => (r.ok ? setItems(r.data) : setError(r.message)))
|
|
29
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
30
|
+
useEffect(() => { if (!initial)
|
|
31
|
+
void load(locale); }, [ops, locale, initial]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
32
|
+
const pickLocale = (next) => { rememberLocale(next); setLocale(next); setItems(null); };
|
|
33
|
+
const openAdd = async () => {
|
|
34
|
+
const r = await ops.redirects.targets(locale);
|
|
35
|
+
if (!r.ok) {
|
|
36
|
+
setNotice({ tone: "error", text: r.message });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setAdding(r.data);
|
|
40
|
+
};
|
|
41
|
+
const add = async (path, target) => {
|
|
42
|
+
const r = await ops.redirects.add(locale, path, target);
|
|
43
|
+
if (!r.ok)
|
|
44
|
+
return r.message;
|
|
45
|
+
setAdding(null);
|
|
46
|
+
setNotice({ tone: "ok", text: `${path} now redirects.` });
|
|
47
|
+
void load(locale);
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
const remove = async (item) => {
|
|
51
|
+
if (!(await confirm({ ...REMOVE_REDIRECT_CONFIRM, subject: { name: item.title, path: item.path } })))
|
|
52
|
+
return;
|
|
53
|
+
const r = await ops.redirects.remove(locale, item.path);
|
|
54
|
+
if (!r.ok) {
|
|
55
|
+
setNotice({ tone: "error", text: r.message });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setNotice(null); // an earlier "now redirects" must not outlive the row
|
|
59
|
+
void load(locale);
|
|
60
|
+
};
|
|
61
|
+
if (error)
|
|
62
|
+
return _jsx(ErrorPane, { message: error });
|
|
63
|
+
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Redirects", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsx(Button, { onClick: () => void openAdd(), children: "Add redirect" }), notice: notice && _jsx(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: notice.text }), columns: [
|
|
64
|
+
{ label: "Old address", width: "minmax(200px,1fr)" },
|
|
65
|
+
{ label: "Redirects to", width: "minmax(200px,1fr)" },
|
|
66
|
+
{ label: "", width: "48px" },
|
|
67
|
+
], rows: (items ?? []).map((item) => ({
|
|
68
|
+
key: item.path,
|
|
69
|
+
cells: [
|
|
70
|
+
_jsx("code", { children: item.path }),
|
|
71
|
+
_jsxs("span", { style: { display: "flex", gap: 8, alignItems: "baseline", minWidth: 0 }, children: [_jsx(RouteLink, { href: adminHref(editorHref(item, locale)), children: item.title }), item.currentPath ? _jsx("code", { children: item.currentPath }) : _jsxs("span", { className: "sm-hint", children: ["not published in ", locale] })] }),
|
|
72
|
+
_jsx(ActionMenu, { label: "Redirect actions", small: true, items: [{ label: "Remove redirect", danger: true, run: () => void remove(item) }] }),
|
|
73
|
+
],
|
|
74
|
+
})), empty: items === null ? "Loading…" : `No redirects in ${locale}` }), adding && _jsx(AddRedirectModal, { locale: locale, registry: registry, targets: adding, onClose: () => setAdding(null), onAdd: add })] }));
|
|
75
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { MemberRole } from "../../permissions.ts";
|
|
2
2
|
import type { AdminOps } from "../ops.ts";
|
|
3
|
-
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
export declare function SettingsView({ ops, registry, tail, role, userId }: {
|
|
4
5
|
ops: AdminOps;
|
|
6
|
+
registry: AdminRegistry;
|
|
5
7
|
tail: string | undefined;
|
|
6
8
|
/** The caller's role from `members.me`; null while it loads. */
|
|
7
9
|
role: MemberRole | null;
|
|
@@ -4,31 +4,39 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// The Settings area (spec 2026-09-25 §5): a 200px secondary nav at the
|
|
5
5
|
// left of the main column, its card starting where the page's card starts (no heading since 2026-09-26) —
|
|
6
6
|
// then the page with its own top bar. The head segment alone replaces
|
|
7
|
-
// itself with the
|
|
8
|
-
// page is the shell's Not found pane.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// itself with the role's first page, so Back skips the empty URL. An
|
|
8
|
+
// unknown page is the shell's Not found pane. Each page names its
|
|
9
|
+
// capability (settings.ts): Members is for admins (members management
|
|
10
|
+
// §2) and an editor opening it gets the error pane; Redirects (spec
|
|
11
|
+
// 2026-09-26 redirects §6) and Content health (spec 2026-09-27 editor
|
|
12
|
+
// safety §4) are for every member. Until the role is known
|
|
13
|
+
// the area is loading.
|
|
12
14
|
import { useEffect } from "react";
|
|
13
|
-
import { can } from "../../permissions.js";
|
|
14
15
|
import { useAdminHref } from "./base.js";
|
|
15
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
17
|
+
import { ContentHealthPage } from "./ContentHealthPage.js";
|
|
16
18
|
import { MembersPage } from "./MembersPage.js";
|
|
19
|
+
import { RedirectsPage } from "./RedirectsPage.js";
|
|
17
20
|
import { RouteLink } from "./RouteLink.js";
|
|
18
21
|
import { navigate } from "./router.js";
|
|
19
|
-
import {
|
|
20
|
-
export function SettingsView({ ops, tail, role, userId }) {
|
|
22
|
+
import { defaultSettingsPage, settingsPage, visibleSettingsPages } from "./settings.js";
|
|
23
|
+
export function SettingsView({ ops, registry, tail, role, userId }) {
|
|
21
24
|
const adminHref = useAdminHref();
|
|
22
25
|
const page = settingsPage(tail);
|
|
23
26
|
useEffect(() => {
|
|
24
|
-
if (tail === undefined)
|
|
25
|
-
void navigate(adminHref(`/settings/${
|
|
26
|
-
}, [tail, adminHref]);
|
|
27
|
+
if (tail === undefined && role !== null)
|
|
28
|
+
void navigate(adminHref(`/settings/${defaultSettingsPage(role)}`), { replace: true });
|
|
29
|
+
}, [tail, role, adminHref]);
|
|
27
30
|
if (role === null)
|
|
28
31
|
return _jsx("div", { className: "sm-empty", children: "Loading\u2026" });
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const visible = visibleSettingsPages(role);
|
|
33
|
+
if (tail === undefined)
|
|
34
|
+
return _jsx("div", { className: "sm-empty", children: "Loading\u2026" });
|
|
31
35
|
if (!page)
|
|
32
36
|
return _jsx(ErrorPane, { message: "Not found." });
|
|
33
|
-
|
|
37
|
+
if (!visible.some((p) => p.id === page))
|
|
38
|
+
return _jsx(ErrorPane, { message: "Settings are for admins." });
|
|
39
|
+
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops, userId: userId })
|
|
40
|
+
: page === "redirects" ? _jsx(RedirectsPage, { ops: ops, registry: registry })
|
|
41
|
+
: _jsx(ContentHealthPage, { ops: ops, registry: registry }) })] }));
|
|
34
42
|
}
|