smoodly 0.0.27 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +32 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +4 -1
  4. package/dist/admin/editor/PageInspector.d.ts +4 -1
  5. package/dist/admin/editor/PageInspector.js +2 -2
  6. package/dist/admin/editor/PageSettings.d.ts +4 -1
  7. package/dist/admin/editor/PageSettings.js +16 -8
  8. package/dist/admin/ops-impl.js +86 -7
  9. package/dist/admin/ops.d.ts +40 -1
  10. package/dist/admin/redirect-path.d.ts +6 -0
  11. package/dist/admin/redirect-path.js +33 -0
  12. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  13. package/dist/admin/shell/AddRedirectModal.js +29 -0
  14. package/dist/admin/shell/AdminApp.js +2 -3
  15. package/dist/admin/shell/EntryForm.js +13 -5
  16. package/dist/admin/shell/IconRail.d.ts +1 -1
  17. package/dist/admin/shell/IconRail.js +3 -2
  18. package/dist/admin/shell/PagesList.js +6 -4
  19. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  20. package/dist/admin/shell/RedirectsPage.js +75 -0
  21. package/dist/admin/shell/SettingsView.d.ts +4 -2
  22. package/dist/admin/shell/SettingsView.js +18 -14
  23. package/dist/admin/shell/live-url.d.ts +10 -2
  24. package/dist/admin/shell/live-url.js +9 -4
  25. package/dist/admin/shell/members-list.js +1 -1
  26. package/dist/admin/shell/redirects-list.d.ts +12 -0
  27. package/dist/admin/shell/redirects-list.js +15 -0
  28. package/dist/admin/shell/settings.d.ts +12 -3
  29. package/dist/admin/shell/settings.js +13 -9
  30. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  31. package/dist/admin/ui/ConfirmDialog.js +4 -3
  32. package/dist/admin/ui/confirm.d.ts +13 -2
  33. package/dist/admin/ui/confirm.js +9 -5
  34. package/dist/admin/ui/theme.js +3 -0
  35. package/dist/entry-store.d.ts +18 -6
  36. package/dist/entry-store.js +13 -8
  37. package/dist/next/page-renderer.js +28 -9
  38. package/dist/path-index.d.ts +28 -4
  39. package/dist/path-index.js +37 -4
  40. package/dist/paths.d.ts +16 -0
  41. package/dist/paths.js +9 -0
  42. package/dist/schema-version.d.ts +1 -1
  43. package/dist/schema-version.js +1 -1
  44. package/dist/site.d.ts +4 -1
  45. package/dist/site.js +3 -1
  46. package/dist/sql.js +19 -1
  47. package/dist/store.d.ts +30 -5
  48. package/dist/store.js +24 -8
  49. package/dist/supabase-entry-store.d.ts +7 -2
  50. package/dist/supabase-entry-store.js +12 -8
  51. package/dist/supabase-path-index.d.ts +10 -3
  52. package/dist/supabase-path-index.js +50 -17
  53. package/dist/supabase-store.d.ts +13 -2
  54. package/dist/supabase-store.js +15 -8
  55. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  56. package/package.json +1 -1
@@ -35,8 +35,8 @@ 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";
@@ -53,6 +53,7 @@ export function EntryForm({ ops, registry, collection, id }) {
53
53
  const [record, setRecord] = useState(null);
54
54
  const [fields, setFields] = useState(null);
55
55
  const [urls, setUrls] = useState({});
56
+ const [redirects, setRedirects] = useState({});
56
57
  const [parentLocales, setParentLocales] = useState(registry.locales.supported);
57
58
  const [notice, setNotice] = useState(null);
58
59
  const [fieldErrors, setFieldErrors] = useState({});
@@ -75,6 +76,8 @@ export function EntryForm({ ops, registry, collection, id }) {
75
76
  recordRef.current = record;
76
77
  /** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
77
78
  const confirmedSlug = useRef(null);
79
+ /** The confirm's "Redirect the old address" answer for the save that follows it (spec 2026-09-26 redirects §6). */
80
+ const redirectRef = useRef(true);
78
81
  // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
79
82
  /** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
80
83
  const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
@@ -93,15 +96,19 @@ export function EntryForm({ ops, registry, collection, id }) {
93
96
  if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
94
97
  const current = recordRef.current;
95
98
  const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
96
- if (await confirm({ ...LIVE_URL_CONFIRM, subject }))
99
+ const { yes, checked } = await confirmWithOption({ ...LIVE_URL_CONFIRM, option: { ...LIVE_URL_OPTION }, subject });
100
+ if (yes) {
97
101
  confirmedSlug.current = next;
102
+ redirectRef.current = checked;
103
+ }
98
104
  else {
99
105
  value = { ...value, slug: stored };
100
106
  setFields((f) => ({ ...(f ?? {}), slug: stored }));
101
107
  }
102
108
  }
103
- const r = await ops.entries.save(collection, id, locale, value, token());
109
+ const r = await ops.entries.save(collection, id, locale, value, token(), { redirect: redirectRef.current });
104
110
  if (r.ok) {
111
+ redirectRef.current = true;
105
112
  setRecord(r.data);
106
113
  presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
107
114
  }
@@ -140,6 +147,7 @@ export function EntryForm({ ops, registry, collection, id }) {
140
147
  setRecord(r.data.record);
141
148
  setFields(r.data.fields);
142
149
  setUrls(r.data.urls);
150
+ setRedirects(r.data.redirects);
143
151
  setParentLocales(r.data.parentLocales);
144
152
  }
145
153
  else
@@ -412,5 +420,5 @@ export function EntryForm({ ops, registry, collection, id }) {
412
420
  },
413
421
  { label: heading },
414
422
  ] }) }), 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 ? (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" })] }))] })] })] }));
423
+ 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) }), (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
424
  }
@@ -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 — `can(role, "settings")` in the shell. */
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; admins only, so the shell passes
14
- // `settings` false for an editor). The user avatar is pinned to the bottom and opens the same
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
  //
@@ -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
- if (!(await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path })))
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
  },
@@ -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
- if (!(await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path })))
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 { type MemberRole } from "../../permissions.ts";
1
+ import type { MemberRole } from "../../permissions.ts";
2
2
  import type { AdminOps } from "../ops.ts";
3
- export declare function SettingsView({ ops, tail, role, userId }: {
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,35 @@ 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 default page, so Back skips the empty URL. An unknown
8
- // page is the shell's Not found pane. Admins only (members management
9
- // §2): an editor gets the error pane, and until the role is known the
10
- // area is loading. Members is the only page (the Roles table moved into
11
- // the invite modal's role descriptions; API Keys never had contents).
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) is for every member. Until the role is known
12
+ // the area is loading.
12
13
  import { useEffect } from "react";
13
- import { can } from "../../permissions.js";
14
14
  import { useAdminHref } from "./base.js";
15
15
  import { ErrorPane } from "./ErrorPane.js";
16
16
  import { MembersPage } from "./MembersPage.js";
17
+ import { RedirectsPage } from "./RedirectsPage.js";
17
18
  import { RouteLink } from "./RouteLink.js";
18
19
  import { navigate } from "./router.js";
19
- import { DEFAULT_SETTINGS_PAGE, SETTINGS_PAGES, settingsPage } from "./settings.js";
20
- export function SettingsView({ ops, tail, role, userId }) {
20
+ import { defaultSettingsPage, settingsPage, visibleSettingsPages } from "./settings.js";
21
+ export function SettingsView({ ops, registry, tail, role, userId }) {
21
22
  const adminHref = useAdminHref();
22
23
  const page = settingsPage(tail);
23
24
  useEffect(() => {
24
- if (tail === undefined)
25
- void navigate(adminHref(`/settings/${DEFAULT_SETTINGS_PAGE}`), { replace: true });
26
- }, [tail, adminHref]);
25
+ if (tail === undefined && role !== null)
26
+ void navigate(adminHref(`/settings/${defaultSettingsPage(role)}`), { replace: true });
27
+ }, [tail, role, adminHref]);
27
28
  if (role === null)
28
29
  return _jsx("div", { className: "sm-empty", children: "Loading\u2026" });
29
- if (!can(role, "settings"))
30
- return _jsx(ErrorPane, { message: "Settings are for admins." });
30
+ const visible = visibleSettingsPages(role);
31
+ if (tail === undefined)
32
+ return _jsx("div", { className: "sm-empty", children: "Loading\u2026" });
31
33
  if (!page)
32
34
  return _jsx(ErrorPane, { message: "Not found." });
33
- return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: SETTINGS_PAGES.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: _jsx(MembersPage, { ops: ops, userId: userId }) })] }));
35
+ if (!visible.some((p) => p.id === page))
36
+ return _jsx(ErrorPane, { message: "Settings are for admins." });
37
+ 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 }) : _jsx(RedirectsPage, { ops: ops, registry: registry }) })] }));
34
38
  }
@@ -1,10 +1,18 @@
1
1
  import { type ConfirmOptions, type ConfirmSubject } from "../ui/confirm.ts";
2
2
  export declare const LIVE_URL_CONFIRM: Omit<ConfirmOptions, "subject">;
3
+ /** The rider (spec 2026-09-26 redirects §1): on by default, the editor unticks to let the old address go dark. */
4
+ export declare const LIVE_URL_OPTION: {
5
+ readonly label: "Redirect the old address";
6
+ readonly checked: true;
7
+ };
3
8
  /** `from` null = there was no URL part to change (a new row, a collection without a path). */
4
9
  export declare function liveUrlChanges(input: {
5
10
  status: "draft" | "published" | undefined;
6
11
  from: string | null;
7
12
  to: string | null;
8
13
  }): boolean;
9
- /** Resolves true when the change may go ahead: not a live change, or confirmed. */
10
- export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject): Promise<boolean>;
14
+ /** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
15
+ export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject): Promise<{
16
+ ok: boolean;
17
+ redirect: boolean;
18
+ }>;
@@ -3,17 +3,22 @@
3
3
  // published page's parent. Whoever the role — the mistake is the same.
4
4
  // Asked in the admin's own dialog (ui/confirm.ts); the subject chip names
5
5
  // the record and the URL part that is about to change.
6
- import { confirm } from "../ui/confirm.js";
6
+ import { confirmWithOption } from "../ui/confirm.js";
7
7
  export const LIVE_URL_CONFIRM = {
8
8
  title: "Change the live URL?",
9
- body: "This is published. Old links to it stop working until a redirect exists, and Smoodly has none yet.",
9
+ body: "This is published. With a redirect, visitors and search engines who open the old address are sent to the new one.",
10
10
  confirmLabel: "Change URL",
11
11
  };
12
+ /** The rider (spec 2026-09-26 redirects §1): on by default, the editor unticks to let the old address go dark. */
13
+ export const LIVE_URL_OPTION = { label: "Redirect the old address", checked: true };
12
14
  /** `from` null = there was no URL part to change (a new row, a collection without a path). */
13
15
  export function liveUrlChanges(input) {
14
16
  return input.status === "published" && input.from !== null && input.to !== input.from;
15
17
  }
16
- /** Resolves true when the change may go ahead: not a live change, or confirmed. */
18
+ /** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
17
19
  export async function confirmLiveUrl(input, subject) {
18
- return !liveUrlChanges(input) || confirm({ ...LIVE_URL_CONFIRM, subject });
20
+ if (!liveUrlChanges(input))
21
+ return { ok: true, redirect: true };
22
+ const { yes, checked } = await confirmWithOption({ ...LIVE_URL_CONFIRM, option: { ...LIVE_URL_OPTION }, subject });
23
+ return { ok: yes, redirect: checked };
19
24
  }
@@ -2,7 +2,7 @@ import { normalizeRole } from "../../permissions.js";
2
2
  export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
3
3
  /** What each role may do, in one line — the invite modal's radio descriptions (permissions.ts decides). */
4
4
  export const ROLE_DESCRIPTION = {
5
- editor: "Edits, publishes and deletes content and media.",
5
+ editor: "Edits, publishes and deletes content and media, and manages redirects.",
6
6
  admin: "Everything an editor can, and Settings: invites, re-roles and removes members.",
7
7
  };
8
8
  export function memberRows(members, opts) {
@@ -0,0 +1,12 @@
1
+ import type { PathTarget } from "../../paths.ts";
2
+ import type { RedirectTargetItem } from "../ops.ts";
3
+ import type { ComboOption } from "../ui/combo-box.ts";
4
+ import type { ConfirmOptions } from "../ui/confirm.ts";
5
+ export declare const targetOptions: (targets: RedirectTargetItem[]) => ComboOption[];
6
+ export declare function parseTargetValue(value: string): PathTarget | null;
7
+ /** Admin-relative (the caller applies adminHref). */
8
+ export declare const editorHref: (item: {
9
+ target: PathTarget;
10
+ collection: string | null;
11
+ }, locale: string) => string;
12
+ export declare const REMOVE_REDIRECT_CONFIRM: Omit<ConfirmOptions, "subject">;
@@ -0,0 +1,15 @@
1
+ export const targetOptions = (targets) => targets.map((t) => ({ value: `${t.target.kind}:${t.target.id}`, label: `${t.title} · ${t.path}` }));
2
+ export function parseTargetValue(value) {
3
+ const m = /^(page|entry):(.+)$/.exec(value);
4
+ return m ? { kind: m[1], id: m[2] } : null;
5
+ }
6
+ /** Admin-relative (the caller applies adminHref). */
7
+ export const editorHref = (item, locale) => item.target.kind === "page"
8
+ ? `/pages/${encodeURIComponent(item.target.id)}?locale=${encodeURIComponent(locale)}`
9
+ : `/${item.collection}/${encodeURIComponent(item.target.id)}?locale=${encodeURIComponent(locale)}`;
10
+ export const REMOVE_REDIRECT_CONFIRM = {
11
+ title: "Remove this redirect?",
12
+ body: "Visitors who open the old address get a Not found page. Browsers that already followed the redirect may keep going to the new address for a while.",
13
+ confirmLabel: "Remove redirect",
14
+ destructive: true,
15
+ };
@@ -1,9 +1,18 @@
1
+ import { type MemberRole } from "../../permissions.ts";
1
2
  export declare const SETTINGS_PAGES: readonly [{
2
3
  readonly id: "members";
3
4
  readonly title: "Members";
5
+ readonly capability: "settings";
6
+ }, {
7
+ readonly id: "redirects";
8
+ readonly title: "Redirects";
9
+ readonly capability: "content.write";
4
10
  }];
5
- export type SettingsPageId = (typeof SETTINGS_PAGES)[number]["id"];
6
- export declare const DEFAULT_SETTINGS_PAGE: SettingsPageId;
7
- /** The page for the segment after /settings: the default for none, null for an unknown one. */
11
+ export type SettingsPage = (typeof SETTINGS_PAGES)[number];
12
+ export type SettingsPageId = SettingsPage["id"];
13
+ /** The pages this role may open, in nav order. Every member has content.write, so the area is never empty. */
14
+ export declare const visibleSettingsPages: (role: MemberRole) => readonly SettingsPage[];
15
+ export declare const defaultSettingsPage: (role: MemberRole) => SettingsPageId;
16
+ /** The page for the segment after /settings; null for none or an unknown one. */
8
17
  export declare function settingsPage(tail: string | undefined): SettingsPageId | null;
9
18
  export declare function settingsTitle(page: SettingsPageId): string;
@@ -1,15 +1,19 @@
1
- // The Settings area's pages (spec 2026-09-25 §5; narrowed 2026-09-25 to
2
- // Members alone — Roles and API Keys dropped, see DESIGN.md §2): ids are
3
- // URL segments under /settings, titles are what the secondary nav and
4
- // each top bar show. Pure, so routing is testable without React.
1
+ // The Settings area's pages (spec 2026-09-25 §5; Roles and API Keys
2
+ // dropped 2026-09-25, Redirects added 2026-09-26 §6): ids are URL
3
+ // segments under /settings, titles are what the secondary nav and each
4
+ // top bar show, and each page names the capability that opens it —
5
+ // Members for admins, Redirects for every member, as renaming is. Pure,
6
+ // so routing is testable without React.
7
+ import { can } from "../../permissions.js";
5
8
  export const SETTINGS_PAGES = [
6
- { id: "members", title: "Members" },
9
+ { id: "members", title: "Members", capability: "settings" },
10
+ { id: "redirects", title: "Redirects", capability: "content.write" },
7
11
  ];
8
- export const DEFAULT_SETTINGS_PAGE = "members";
9
- /** The page for the segment after /settings: the default for none, null for an unknown one. */
12
+ /** The pages this role may open, in nav order. Every member has content.write, so the area is never empty. */
13
+ export const visibleSettingsPages = (role) => SETTINGS_PAGES.filter((p) => can(role, p.capability));
14
+ export const defaultSettingsPage = (role) => visibleSettingsPages(role)[0].id;
15
+ /** The page for the segment after /settings; null for none or an unknown one. */
10
16
  export function settingsPage(tail) {
11
- if (tail === undefined)
12
- return DEFAULT_SETTINGS_PAGE;
13
17
  return SETTINGS_PAGES.some((p) => p.id === tail) ? tail : null;
14
18
  }
15
19
  export function settingsTitle(page) {
@@ -2,7 +2,7 @@ import { type ReactElement } from "react";
2
2
  import { type ConfirmOptions } from "./confirm.ts";
3
3
  export declare function ConfirmDialog({ options, onAnswer }: {
4
4
  options: ConfirmOptions;
5
- onAnswer(yes: boolean): void;
5
+ onAnswer(yes: boolean, checked?: boolean): void;
6
6
  }): ReactElement;
7
7
  /** Renders the pending question, once, from the shell. */
8
8
  export declare function ConfirmHost(): ReactElement | null;
@@ -10,16 +10,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  // <ConfirmHost/> renders the pending question from ui/confirm.ts, once,
11
11
  // from the shell; the dialog itself is a plain component so a test can
12
12
  // render it statically.
13
- import { useId, useSyncExternalStore } from "react";
13
+ import { useId, useState, useSyncExternalStore } from "react";
14
14
  import { DialogCard } from "./Dialog.js";
15
15
  import { Button } from "./primitives.js";
16
16
  import { pendingConfirm, subscribeConfirm } from "./confirm.js";
17
17
  export function ConfirmDialog({ options, onAnswer }) {
18
- const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject } = options;
18
+ const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject, option } = options;
19
+ const [checked, setChecked] = useState(option?.checked ?? false);
19
20
  const uid = useId();
20
21
  const bodyId = `sm-confirm-body${uid}`;
21
22
  const cancel = () => onAnswer(false);
22
- return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
23
+ return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true, checked), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), option && (_jsxs("label", { className: "sm-choice sm-confirm__option", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: (e) => setChecked(e.target.checked) }), _jsx("span", { children: option.label })] })), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
23
24
  }
24
25
  /** Renders the pending question, once, from the shell. */
25
26
  export function ConfirmHost() {
@@ -17,16 +17,27 @@ export type ConfirmOptions = {
17
17
  destructive?: boolean;
18
18
  /** The context chip repeating the subject; omit when there is no single subject. */
19
19
  subject?: ConfirmSubject;
20
+ /** A yes/no rider under the body — "Redirect the old address" (spec 2026-09-26 redirects §6); `checked` is its initial state. */
21
+ option?: {
22
+ label: string;
23
+ checked: boolean;
24
+ };
25
+ };
26
+ export type ConfirmAnswer = {
27
+ yes: boolean;
28
+ checked: boolean;
20
29
  };
21
30
  export type ConfirmRequest = {
22
31
  id: number;
23
32
  options: ConfirmOptions;
24
- answer(yes: boolean): void;
33
+ answer(yes: boolean, checked?: boolean): void;
25
34
  };
26
35
  /** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
27
36
  export declare const DISCARD_CONFIRM: Omit<ConfirmOptions, "subject">;
37
+ /** Ask; resolves with the answer and the option's final state once the dialog closes. */
38
+ export declare function confirmWithOption(options: ConfirmOptions): Promise<ConfirmAnswer>;
28
39
  /** Ask; resolves with the answer once the dialog closes. */
29
- export declare function confirm(options: ConfirmOptions): Promise<boolean>;
40
+ export declare const confirm: (options: ConfirmOptions) => Promise<boolean>;
30
41
  /** The question being asked, if any — the head of the queue. */
31
42
  export declare const pendingConfirm: () => ConfirmRequest | null;
32
43
  /** For useSyncExternalStore: fires on every queue change. */
@@ -20,29 +20,33 @@ let queue = [];
20
20
  let seq = 0;
21
21
  const notify = () => { for (const fn of listeners)
22
22
  fn(); };
23
- /** Ask; resolves with the answer once the dialog closes. */
24
- export function confirm(options) {
23
+ /** Ask; resolves with the answer and the option's final state once the dialog closes. */
24
+ export function confirmWithOption(options) {
25
+ const initial = options.option?.checked ?? false;
25
26
  if (listeners.size === 0) {
26
27
  const text = [options.title, options.body].filter(Boolean).join("\n\n");
27
- return Promise.resolve(typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false);
28
+ const yes = typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false;
29
+ return Promise.resolve({ yes, checked: initial });
28
30
  }
29
31
  return new Promise((resolve) => {
30
32
  const id = ++seq;
31
33
  const request = {
32
34
  id,
33
35
  options,
34
- answer(yes) {
36
+ answer(yes, checked = initial) {
35
37
  if (!queue.some((r) => r.id === id))
36
38
  return; // answered already
37
39
  queue = queue.filter((r) => r.id !== id);
38
40
  notify();
39
- resolve(yes);
41
+ resolve({ yes, checked });
40
42
  },
41
43
  };
42
44
  queue = [...queue, request];
43
45
  notify();
44
46
  });
45
47
  }
48
+ /** Ask; resolves with the answer once the dialog closes. */
49
+ export const confirm = (options) => confirmWithOption(options).then((a) => a.yes);
46
50
  /** The question being asked, if any — the head of the queue. */
47
51
  export const pendingConfirm = () => queue[0] ?? null;
48
52
  /** For useSyncExternalStore: fires on every queue change. */
@@ -1152,6 +1152,9 @@ body:has(.sm-admin){margin:0}
1152
1152
  }
1153
1153
  .sm-dialog__context strong{font-weight:500;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:var(--text-md)}
1154
1154
  .sm-dialog__context code{font-family:var(--font-mono);flex:none}
1155
+ .sm-confirm__option{margin-top:4px;font-size:var(--text-md);color:var(--text)}
1156
+ .sm-redirects-from{display:flex;flex-direction:column;gap:4px}
1157
+ .sm-redirects-from code{font-size:var(--text-sm)}
1155
1158
  /* A form dialog's fields: white fields on the white card get the tile tone instead. */
1156
1159
  .sm-dialog__form{display:flex;flex-direction:column;gap:14px}
1157
1160
  .sm-dialog__form .sm-input,.sm-dialog__form .sm-select,.sm-dialog__form .sm-inputwrap{background:var(--surface)}
@@ -2,9 +2,9 @@ import type { CollectionSchema, EntryOrder } from "./collections.ts";
2
2
  import type { GlobalSchema } from "./global.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import { MemoryRefIndex } from "./ref-index.ts";
5
- import { type PathIndex } from "./path-index.ts";
5
+ import { type PathHit, type PathIndex } from "./path-index.ts";
6
6
  import { type DraftToken } from "./draft-token.ts";
7
- import { type LocaleSet, type PathTarget } from "./paths.ts";
7
+ import { type LocaleSet } from "./paths.ts";
8
8
  export type EntryLocale = {
9
9
  /** The entry's segment in this locale; null claims no URL. */
10
10
  slug: string | null;
@@ -107,7 +107,10 @@ export interface EntryStore {
107
107
  }): Promise<Record<string, Record<string, unknown>>>;
108
108
  /** Replaces the merged fields for `locale`: shared keys on the node
109
109
  * (every locale sees them), localized keys and the slug on the row. */
110
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
110
+ /** `redirect` (default true) keeps a published row's old address as a redirect when the slug changes (spec 2026-09-26 redirects §1). */
111
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null, options?: {
112
+ redirect?: boolean;
113
+ }): Promise<EntryRecord>;
111
114
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
112
115
  /** The live row goes back to what is published: the published snapshot's fields
113
116
  * (shared and localized, split as on save — the slug is never versioned) written over
@@ -143,6 +146,8 @@ export interface EntryStore {
143
146
  move(collection: string, id: string, to: {
144
147
  parentId: string | null;
145
148
  index?: number;
149
+ }, options?: {
150
+ redirect?: boolean;
146
151
  }): Promise<EntryRecord>;
147
152
  /** Rewrite manual order to 1..N for the given ids, one batched pass. */
148
153
  reorder(collection: string, orderedIds: string[]): Promise<void>;
@@ -153,8 +158,10 @@ export interface EntryStore {
153
158
  sourceKind: string;
154
159
  sourceId: string;
155
160
  }[]>;
156
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
161
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
157
162
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
163
+ /** locale → this entry's old addresses. */
164
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
158
165
  }
159
166
  export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
160
167
  /** A global item is a collection with exactly one entry (spec 2026-09-07). */
@@ -271,7 +278,9 @@ export declare class MemoryEntryStore implements EntryStore {
271
278
  getMany(collection: string, ids: string[], locale: string, options?: {
272
279
  status?: "published";
273
280
  }): Promise<Record<string, Record<string, unknown>>>;
274
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
281
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null, options?: {
282
+ redirect?: boolean;
283
+ }): Promise<EntryRecord>;
275
284
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
276
285
  discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
277
286
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
@@ -282,6 +291,8 @@ export declare class MemoryEntryStore implements EntryStore {
282
291
  move(collection: string, id: string, to: {
283
292
  parentId: string | null;
284
293
  index?: number;
294
+ }, options?: {
295
+ redirect?: boolean;
285
296
  }): Promise<EntryRecord>;
286
297
  delete(collection: string, id: string): Promise<void>;
287
298
  reorder(collection: string, orderedIds: string[]): Promise<void>;
@@ -290,6 +301,7 @@ export declare class MemoryEntryStore implements EntryStore {
290
301
  sourceKind: string;
291
302
  sourceId: string;
292
303
  }[]>;
293
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
304
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
294
305
  pathsOf(collection: string, id: string): Promise<{}>;
306
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
295
307
  }