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.
- package/README.md +32 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +4 -1
- package/dist/admin/editor/PageInspector.d.ts +4 -1
- package/dist/admin/editor/PageInspector.js +2 -2
- package/dist/admin/editor/PageSettings.d.ts +4 -1
- package/dist/admin/editor/PageSettings.js +16 -8
- package/dist/admin/ops-impl.js +86 -7
- package/dist/admin/ops.d.ts +40 -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/EntryForm.js +13 -5
- package/dist/admin/shell/IconRail.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +6 -4
- 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 +18 -14
- 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 +12 -3
- package/dist/admin/shell/settings.js +13 -9
- package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
- package/dist/admin/ui/ConfirmDialog.js +4 -3
- package/dist/admin/ui/confirm.d.ts +13 -2
- package/dist/admin/ui/confirm.js +9 -5
- package/dist/admin/ui/theme.js +3 -0
- package/dist/entry-store.d.ts +18 -6
- package/dist/entry-store.js +13 -8
- 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/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 +30 -5
- package/dist/store.js +24 -8
- package/dist/supabase-entry-store.d.ts +7 -2
- package/dist/supabase-entry-store.js +12 -8
- package/dist/supabase-path-index.d.ts +10 -3
- package/dist/supabase-path-index.js +50 -17
- package/dist/supabase-store.d.ts +13 -2
- package/dist/supabase-store.js +15 -8
- package/migrations/20260926150000_smoodly_v4.sql +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -490,6 +490,17 @@ Implemented:
|
|
|
490
490
|
the public keys"); admin ops — `test/admin-auth.test.ts`,
|
|
491
491
|
`test/admin-auth.supabase.test.ts`, `test/admin-ops.test.ts` ("members
|
|
492
492
|
ops").
|
|
493
|
+
- Redirects (spec 2026-09-26 redirects): a `paths` row with `redirect`
|
|
494
|
+
set is an old address of its target (schema version 4). A published
|
|
495
|
+
rename or move keeps the old addresses as redirects unless the editor
|
|
496
|
+
unticks "Redirect the old address" on the live-URL confirm; the live
|
|
497
|
+
URL wins an address; nothing chains; `renderSmoodlyPath` answers a
|
|
498
|
+
redirect row with `permanentRedirect` through `href`. Settings ›
|
|
499
|
+
Redirects (every member) lists, adds by hand (validated old address,
|
|
500
|
+
target type-ahead) and removes. Tests: `test/path-index-contract.ts`
|
|
501
|
+
(the rule table, both adapters), the store contracts, `admin-ops`
|
|
502
|
+
("redirects on rename and move", "redirects ops"),
|
|
503
|
+
`next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
|
|
493
504
|
|
|
494
505
|
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
495
506
|
collections and the pages tree drag since 2026-09-09 — spec
|
|
@@ -517,6 +528,27 @@ after both are on the registry. From the repo root:
|
|
|
517
528
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
518
529
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
519
530
|
|
|
531
|
+
## Follow-ups (logged 2026-09-26, redirects spec)
|
|
532
|
+
|
|
533
|
+
- Destinations outside Smoodly, extension and query-string addresses,
|
|
534
|
+
code-owned renames (a collection `path`, a fixed slug): the guide's
|
|
535
|
+
`next.config` recipe; a `redirects` table is OQ 34.
|
|
536
|
+
- The content API's `getPage("/old")` returns null for a redirect row
|
|
537
|
+
(spec §7); a `follow` option is a one-line addition if a site asks.
|
|
538
|
+
- A redirect row has no audit columns (who, when); add them with a
|
|
539
|
+
need. The Redirects page is not paged.
|
|
540
|
+
- The browser suite covers rename, the unticked box, no chains, the
|
|
541
|
+
Settings tab's list and the manual add/remove; a parent's MOVE
|
|
542
|
+
redirecting a child's URL is proven at the store contract and ops
|
|
543
|
+
level only (no tree drag in the suite yet).
|
|
544
|
+
- `remove` on the Supabase index deletes by target, one statement per
|
|
545
|
+
target (the RPC now spares redirect rows); fine at subtree sizes.
|
|
546
|
+
- Pressing Enter in the page Settings tab's slug input commits once,
|
|
547
|
+
then the confirm's autofocus blurs the input and `commit` runs again,
|
|
548
|
+
so a second identical question queues behind the first and its Cancel
|
|
549
|
+
restores the input from a stale row closure (pre-existing, seen on the
|
|
550
|
+
redirects walk). A re-entrancy guard on `commit` fixes it.
|
|
551
|
+
|
|
520
552
|
## Follow-ups (logged 2026-09-26, security pass)
|
|
521
553
|
|
|
522
554
|
- The bridge posts with `"*"` on both sides. Safe today: the admin
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -4,6 +4,7 @@ const GROUPS = {
|
|
|
4
4
|
globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
|
|
5
5
|
assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
|
|
6
6
|
members: ["list", "me", "invite", "setRole", "remove"],
|
|
7
|
+
redirects: ["list", "targets", "add", "remove"],
|
|
7
8
|
schema: ["status"],
|
|
8
9
|
preview: ["enable", "disable"],
|
|
9
10
|
};
|
|
@@ -83,6 +83,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
83
83
|
const [folder, setFolder] = useState(false);
|
|
84
84
|
const [reloadKey, setReloadKey] = useState(0);
|
|
85
85
|
const [paths, setPaths] = useState({});
|
|
86
|
+
const [redirects, setRedirects] = useState({});
|
|
86
87
|
const [urls, setUrls] = useState({});
|
|
87
88
|
// The locales the parent exists in — "Add locale" offers only these.
|
|
88
89
|
const [parentLocales, setParentLocales] = useState([]);
|
|
@@ -249,6 +250,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
249
250
|
}
|
|
250
251
|
setRecord(result.data.record);
|
|
251
252
|
setPaths(result.data.paths);
|
|
253
|
+
setRedirects(result.data.redirects);
|
|
252
254
|
setUrls(result.data.urls);
|
|
253
255
|
setParentLocales(result.data.parentLocales);
|
|
254
256
|
// Absent in this locale: the "Add <locale>" pane, derived from the record.
|
|
@@ -287,6 +289,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
287
289
|
return;
|
|
288
290
|
setRecord(result.data.record);
|
|
289
291
|
setPaths(result.data.paths);
|
|
292
|
+
setRedirects(result.data.redirects);
|
|
290
293
|
setUrls(result.data.urls);
|
|
291
294
|
setParentLocales(result.data.parentLocales);
|
|
292
295
|
}, [ops, id, locale]);
|
|
@@ -845,7 +848,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
845
848
|
else
|
|
846
849
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
847
850
|
}, onEscape: inline.end }));
|
|
848
|
-
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
851
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
849
852
|
!isFixedNode(record, registry) &&
|
|
850
853
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
851
854
|
? () => { void doRemoveLocale(); }
|
|
@@ -3,7 +3,7 @@ import type { PageRecord } from "../../store.ts";
|
|
|
3
3
|
import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
4
4
|
export type PageTab = "SEO & Social" | "Settings";
|
|
5
5
|
export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
|
|
6
|
-
export declare function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }: {
|
|
6
|
+
export declare function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }: {
|
|
7
7
|
record: PageRecord;
|
|
8
8
|
locale: string;
|
|
9
9
|
registry: AdminRegistry;
|
|
@@ -11,9 +11,12 @@ export declare function PageInspector({ record, locale, registry, tree, path, fi
|
|
|
11
11
|
/** The page's path in this locale (the `paths` index). */
|
|
12
12
|
path: string | undefined;
|
|
13
13
|
fixed: boolean;
|
|
14
|
+
/** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
|
|
15
|
+
redirects?: string[];
|
|
14
16
|
onRename(patch: {
|
|
15
17
|
title?: string;
|
|
16
18
|
slug?: string;
|
|
19
|
+
redirect?: boolean;
|
|
17
20
|
}): Promise<string | null>;
|
|
18
21
|
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
19
22
|
onRemoveLocale?: () => void;
|
|
@@ -13,12 +13,12 @@ import { SeoSettings } from "./SeoSettings.js";
|
|
|
13
13
|
export function pageTabsFor(template) {
|
|
14
14
|
return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
|
|
15
15
|
}
|
|
16
|
-
export function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }) {
|
|
16
|
+
export function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }) {
|
|
17
17
|
const template = registry.pages.find((p) => p.name === record.template);
|
|
18
18
|
const tabs = pageTabsFor(template);
|
|
19
19
|
const [tab, setTab] = useState(tabs[0]);
|
|
20
20
|
useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
21
21
|
const active = tabs.includes(tab) ? tab : tabs[0];
|
|
22
22
|
const title = record.locales[locale]?.title ?? "";
|
|
23
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
|
|
23
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
|
|
24
24
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageRecord } from "../../store.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
|
|
3
|
+
export declare function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }: {
|
|
4
4
|
record: PageRecord;
|
|
5
5
|
locale: string;
|
|
6
6
|
registry: AdminRegistry;
|
|
@@ -8,8 +8,11 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
|
|
|
8
8
|
path: string | undefined;
|
|
9
9
|
fixed: boolean;
|
|
10
10
|
/** Resolves to an error message, or null when the rename landed. */
|
|
11
|
+
/** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
|
|
12
|
+
redirects?: string[];
|
|
11
13
|
onRename(patch: {
|
|
12
14
|
title?: string;
|
|
13
15
|
slug?: string;
|
|
16
|
+
redirect?: boolean;
|
|
14
17
|
}): Promise<string | null>;
|
|
15
18
|
}): import("react").JSX.Element | null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
|
|
4
4
|
// §6): the ACTIVE locale's title and slug, saved through pages.rename,
|
|
5
5
|
// and "Remove from this locale". The switcher in the canvas meta row
|
|
@@ -11,10 +11,13 @@ import { useEffect, useState } from "react";
|
|
|
11
11
|
import { updatedLine } from "../ui/format.js";
|
|
12
12
|
import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
|
|
13
13
|
import { confirmLiveUrl } from "../shell/live-url.js";
|
|
14
|
-
|
|
14
|
+
import { RouteLink } from "../shell/RouteLink.js";
|
|
15
|
+
import { useAdminHref } from "../shell/base.js";
|
|
16
|
+
export function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }) {
|
|
15
17
|
const row = record.locales[locale];
|
|
16
18
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
17
19
|
const [error, setError] = useState(null);
|
|
20
|
+
const adminHref = useAdminHref();
|
|
18
21
|
const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
19
22
|
// The record changes identity on every rename; re-seed the inputs from it.
|
|
20
23
|
useEffect(() => {
|
|
@@ -31,12 +34,17 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
31
34
|
setError(`A ${key} is required — nothing falls back to another locale.`);
|
|
32
35
|
return;
|
|
33
36
|
}
|
|
34
|
-
// A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
|
|
38
|
+
let redirect = true;
|
|
39
|
+
if (key === "slug") {
|
|
40
|
+
const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
|
|
41
|
+
if (!live.ok) {
|
|
42
|
+
setDraft((d) => ({ ...d, slug: row.slug }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
redirect = live.redirect;
|
|
38
46
|
}
|
|
39
|
-
setError(await onRename({
|
|
47
|
+
setError(await onRename(key === "slug" ? { slug: next, redirect } : { title: next }));
|
|
40
48
|
};
|
|
41
49
|
const onKey = (key) => (e) => {
|
|
42
50
|
if (e.key === "Enter") {
|
|
@@ -44,5 +52,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
44
52
|
void commit(key);
|
|
45
53
|
}
|
|
46
54
|
};
|
|
47
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
|
|
55
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), redirects && redirects.length > 0 && (_jsx(Field, { label: "Redirects from", asDiv: true, hint: _jsxs(_Fragment, { children: ["Old addresses that send visitors here. Manage them in ", _jsx(RouteLink, { href: adminHref("/settings/redirects"), children: "Settings \u203A Redirects" }), "."] }), children: _jsx("div", { className: "sm-redirects-from", children: redirects.map((p) => _jsx("code", { children: p }, p)) }) })), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
|
|
48
56
|
}
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -11,6 +11,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
|
|
|
11
11
|
import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
12
12
|
import { withDefaults } from "../field-defaults.js";
|
|
13
13
|
import { elementSchemas } from "../richtext-walk.js";
|
|
14
|
+
import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "./redirect-path.js";
|
|
14
15
|
import { SCHEMA_VERSION } from "../schema-version.js";
|
|
15
16
|
import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
|
|
16
17
|
const ok = (data) => ({ ok: true, data });
|
|
@@ -65,6 +66,8 @@ const classify = (e) => {
|
|
|
65
66
|
return err("not_found", message);
|
|
66
67
|
if (message.includes("cannot take a slug"))
|
|
67
68
|
return err("validation", message);
|
|
69
|
+
if (message.includes("is a live address"))
|
|
70
|
+
return err("validation", message);
|
|
68
71
|
// Ahead of the generic "already exists" check below: the singleton
|
|
69
72
|
// refusal's message ("...already exists — it is a singleton.") contains
|
|
70
73
|
// "already exists" as a substring, so it must classify here first or it
|
|
@@ -208,6 +211,22 @@ export function createAdminOps(deps) {
|
|
|
208
211
|
const def = config.locales.default;
|
|
209
212
|
/** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
|
|
210
213
|
const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
|
|
214
|
+
/** "kind:id" → title in the locale (+ collection for an entry): every page with a template, every entry of a routed collection. */
|
|
215
|
+
const targetTitles = async (locale) => {
|
|
216
|
+
const out = new Map();
|
|
217
|
+
for (const p of await pages.listPages()) {
|
|
218
|
+
if (p.template === null)
|
|
219
|
+
continue;
|
|
220
|
+
out.set(`page:${p.id}`, { title: p.locales[locale]?.title ?? nameOf(p), collection: null });
|
|
221
|
+
}
|
|
222
|
+
for (const c of config.registry.collections ?? []) {
|
|
223
|
+
if (!c.path)
|
|
224
|
+
continue;
|
|
225
|
+
for (const e of await entries.list(c.name))
|
|
226
|
+
out.set(`entry:${e.id}`, { title: entryTitleIn(e, locale, c), collection: c.name });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
};
|
|
211
230
|
const assets = deps.assets;
|
|
212
231
|
const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
|
|
213
232
|
const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to createAdminOpHandler.");
|
|
@@ -292,12 +311,13 @@ export function createAdminOps(deps) {
|
|
|
292
311
|
const draft = record.template === null || !record.locales[locale] ? null : await pages.getDraftTree(id, locale);
|
|
293
312
|
const paths = await pages.pathsOf(id);
|
|
294
313
|
const urls = Object.fromEntries(Object.entries(paths).map(([l, path]) => [l, href(path, l)]));
|
|
314
|
+
const redirects = await pages.redirectsOf(id);
|
|
295
315
|
// A locale can only be added where the parent already has it; a
|
|
296
316
|
// root has no parent, so every supported locale is open.
|
|
297
317
|
const parentLocales = record.parentId === null
|
|
298
318
|
? config.locales.supported
|
|
299
319
|
: Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
|
|
300
|
-
return ok({ record, locale, draft, paths, urls, parentLocales });
|
|
320
|
+
return ok({ record, locale, draft, paths, urls, redirects, parentLocales });
|
|
301
321
|
}),
|
|
302
322
|
create: (input) => guard(async () => {
|
|
303
323
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -494,7 +514,8 @@ export function createAdminOps(deps) {
|
|
|
494
514
|
return unsupported;
|
|
495
515
|
if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
|
|
496
516
|
return slugTaken();
|
|
497
|
-
const
|
|
517
|
+
const { redirect, ...rest } = patch;
|
|
518
|
+
const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
|
|
498
519
|
// Every descendant's path changed with this segment; the parent
|
|
499
520
|
// lists this node's title and path.
|
|
500
521
|
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
@@ -521,7 +542,8 @@ export function createAdminOps(deps) {
|
|
|
521
542
|
if (blocked)
|
|
522
543
|
return blocked;
|
|
523
544
|
}
|
|
524
|
-
const
|
|
545
|
+
const { redirect, ...dest } = to;
|
|
546
|
+
const moved = await pages.move(id, dest, { redirect: redirect ?? true });
|
|
525
547
|
expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
|
|
526
548
|
expireTree();
|
|
527
549
|
return ok(moved);
|
|
@@ -607,7 +629,8 @@ export function createAdminOps(deps) {
|
|
|
607
629
|
: Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
|
|
608
630
|
const paths = await entries.pathsOf(collection, id);
|
|
609
631
|
const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
|
|
610
|
-
|
|
632
|
+
const redirects = await entries.redirectsOf(collection, id);
|
|
633
|
+
return ok({ record, locale, fields, parentLocales, urls, redirects });
|
|
611
634
|
}),
|
|
612
635
|
create: (collection, input) => guard(async () => {
|
|
613
636
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -620,7 +643,7 @@ export function createAdminOps(deps) {
|
|
|
620
643
|
expireCollection(collection);
|
|
621
644
|
return ok(created);
|
|
622
645
|
}),
|
|
623
|
-
save: (collection, id, locale, fields, expected) => guard(async () => {
|
|
646
|
+
save: (collection, id, locale, fields, expected, options) => guard(async () => {
|
|
624
647
|
const unsupported = supportedLocale(locale);
|
|
625
648
|
if (unsupported)
|
|
626
649
|
return unsupported;
|
|
@@ -630,7 +653,7 @@ export function createAdminOps(deps) {
|
|
|
630
653
|
const descriptors = collectionFields(collection);
|
|
631
654
|
if (!descriptors)
|
|
632
655
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
633
|
-
const entry = await entries.update(collection, id, locale, fields, by, token);
|
|
656
|
+
const entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
|
|
634
657
|
await fanOut(id);
|
|
635
658
|
expireCollection(collection);
|
|
636
659
|
return ok(entry);
|
|
@@ -707,7 +730,8 @@ export function createAdminOps(deps) {
|
|
|
707
730
|
// The tag expires only after the delete succeeded; a blocked delete expires nothing.
|
|
708
731
|
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
|
|
709
732
|
move: (collection, id, to) => guard(async () => {
|
|
710
|
-
const
|
|
733
|
+
const { redirect, ...dest } = to;
|
|
734
|
+
const moved = await entries.move(collection, id, dest, { redirect: redirect ?? true });
|
|
711
735
|
if (effects?.revalidate) {
|
|
712
736
|
for (const entryId of await entrySubtreeIds(collection, id))
|
|
713
737
|
effects.revalidate(entryTag(entryId));
|
|
@@ -973,6 +997,61 @@ export function createAdminOps(deps) {
|
|
|
973
997
|
return ok(null);
|
|
974
998
|
}),
|
|
975
999
|
},
|
|
1000
|
+
redirects: {
|
|
1001
|
+
list: (locale) => guard(async () => {
|
|
1002
|
+
const unsupported = supportedLocale(locale);
|
|
1003
|
+
if (unsupported)
|
|
1004
|
+
return unsupported;
|
|
1005
|
+
const rows = await pages.listRedirects(locale);
|
|
1006
|
+
const titles = await targetTitles(locale);
|
|
1007
|
+
const items = [];
|
|
1008
|
+
for (const r of rows) {
|
|
1009
|
+
const t = titles.get(`${r.kind}:${r.id}`);
|
|
1010
|
+
const currentPath = (await pages.targetPaths({ kind: r.kind, id: r.id }))[locale] ?? null;
|
|
1011
|
+
items.push({ locale, path: r.path, target: { kind: r.kind, id: r.id }, collection: t?.collection ?? null, title: t?.title ?? r.id, currentPath, url: currentPath ? href(currentPath, locale) : null });
|
|
1012
|
+
}
|
|
1013
|
+
return ok(items.sort((a, b) => a.path.localeCompare(b.path)));
|
|
1014
|
+
}),
|
|
1015
|
+
targets: (locale) => guard(async () => {
|
|
1016
|
+
const unsupported = supportedLocale(locale);
|
|
1017
|
+
if (unsupported)
|
|
1018
|
+
return unsupported;
|
|
1019
|
+
const titles = await targetTitles(locale);
|
|
1020
|
+
const out = [];
|
|
1021
|
+
for (const r of await pages.listLive(locale)) {
|
|
1022
|
+
const t = titles.get(`${r.kind}:${r.id}`);
|
|
1023
|
+
if (!t)
|
|
1024
|
+
continue; // a folder, or an entry outside a routed collection
|
|
1025
|
+
out.push({ target: { kind: r.kind, id: r.id }, collection: t.collection, title: t.title, path: r.path });
|
|
1026
|
+
}
|
|
1027
|
+
return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
|
|
1028
|
+
}),
|
|
1029
|
+
add: (locale, path, target) => guard(async () => {
|
|
1030
|
+
const unsupported = supportedLocale(locale);
|
|
1031
|
+
if (unsupported)
|
|
1032
|
+
return unsupported;
|
|
1033
|
+
const problem = redirectPathProblem(path, { adminPath: config.admin.path, multilingual: config.locales.supported.length > 1 });
|
|
1034
|
+
if (problem)
|
|
1035
|
+
return err("validation", REDIRECT_PATH_MESSAGE[problem]);
|
|
1036
|
+
const titles = await targetTitles(locale);
|
|
1037
|
+
if (!titles.has(`${target.kind}:${target.id}`))
|
|
1038
|
+
return err("not_found", `smoodly: no ${target.kind} "${target.id}" to redirect to.`);
|
|
1039
|
+
const holder = await pages.resolvePath(locale, path);
|
|
1040
|
+
if (holder && !holder.redirect) {
|
|
1041
|
+
const owner = titles.get(`${holder.kind}:${holder.id}`)?.title ?? holder.id;
|
|
1042
|
+
return err("validation", `smoodly: "${path}" is the address of "${owner}".`);
|
|
1043
|
+
}
|
|
1044
|
+
await pages.addRedirect({ kind: target.kind, id: target.id, locale, path });
|
|
1045
|
+
return ok(null);
|
|
1046
|
+
}),
|
|
1047
|
+
remove: (locale, path) => guard(async () => {
|
|
1048
|
+
const unsupported = supportedLocale(locale);
|
|
1049
|
+
if (unsupported)
|
|
1050
|
+
return unsupported;
|
|
1051
|
+
await pages.removeRedirect(locale, path);
|
|
1052
|
+
return ok(null);
|
|
1053
|
+
}),
|
|
1054
|
+
},
|
|
976
1055
|
schema: {
|
|
977
1056
|
status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
|
|
978
1057
|
},
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts
|
|
|
7
7
|
import type { MemberRole } from "../permissions.ts";
|
|
8
8
|
import type { FieldError } from "./validate.ts";
|
|
9
9
|
import type { DraftToken } from "../draft-token.ts";
|
|
10
|
+
import type { PathTarget } from "../paths.ts";
|
|
10
11
|
export type OpError = {
|
|
11
12
|
ok: false;
|
|
12
13
|
code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
|
|
@@ -44,6 +45,8 @@ export type PageGetResult = {
|
|
|
44
45
|
paths: Record<string, string>;
|
|
45
46
|
/** locale → site URL, `href` applied server-side (the canvas and "View live" use these). */
|
|
46
47
|
urls: Record<string, string>;
|
|
48
|
+
/** locale → the page's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
|
|
49
|
+
redirects: Record<string, string[]>;
|
|
47
50
|
/** The locales the PARENT exists in — a locale can only be added here
|
|
48
51
|
* once the parent has it (DESIGN.md, Localization), so the editor
|
|
49
52
|
* disables the rest with a reason. Every supported locale for a root
|
|
@@ -65,6 +68,8 @@ export type EntryGetResult = {
|
|
|
65
68
|
fields: Record<string, unknown> | null;
|
|
66
69
|
/** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
|
|
67
70
|
urls: Record<string, string>;
|
|
71
|
+
/** locale → the entry's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
|
|
72
|
+
redirects: Record<string, string[]>;
|
|
68
73
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
69
74
|
parentLocales: string[];
|
|
70
75
|
};
|
|
@@ -109,6 +114,23 @@ export type SchemaStatus = {
|
|
|
109
114
|
database: number | null;
|
|
110
115
|
required: number;
|
|
111
116
|
};
|
|
117
|
+
/** A redirect row with its target joined (spec 2026-09-26 redirects §6): `currentPath`/`url` null when the target has no live path in the locale. */
|
|
118
|
+
export type RedirectItem = {
|
|
119
|
+
locale: string;
|
|
120
|
+
path: string;
|
|
121
|
+
target: PathTarget;
|
|
122
|
+
collection: string | null;
|
|
123
|
+
title: string;
|
|
124
|
+
currentPath: string | null;
|
|
125
|
+
url: string | null;
|
|
126
|
+
};
|
|
127
|
+
/** A page or entry live in the locale, for the Add dialog's type-ahead. */
|
|
128
|
+
export type RedirectTargetItem = {
|
|
129
|
+
target: PathTarget;
|
|
130
|
+
collection: string | null;
|
|
131
|
+
title: string;
|
|
132
|
+
path: string;
|
|
133
|
+
};
|
|
112
134
|
export type AdminOps = {
|
|
113
135
|
pages: {
|
|
114
136
|
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
@@ -127,14 +149,17 @@ export type AdminOps = {
|
|
|
127
149
|
/** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
|
|
128
150
|
discardDraft(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
129
151
|
delete(id: string): Promise<OpResult<null>>;
|
|
152
|
+
/** `redirect` (default true) keeps a published row's old address as a redirect (spec 2026-09-26 redirects §1). */
|
|
130
153
|
rename(id: string, patch: {
|
|
131
154
|
locale: string;
|
|
132
155
|
slug?: string;
|
|
133
156
|
title?: string;
|
|
157
|
+
redirect?: boolean;
|
|
134
158
|
}): Promise<OpResult<PageRecord>>;
|
|
135
159
|
move(id: string, to: {
|
|
136
160
|
parentId: string | null;
|
|
137
161
|
index?: number;
|
|
162
|
+
redirect?: boolean;
|
|
138
163
|
}): Promise<OpResult<PageRecord>>;
|
|
139
164
|
/** "Add page here": a folder becomes a page. */
|
|
140
165
|
setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
|
|
@@ -159,7 +184,9 @@ export type AdminOps = {
|
|
|
159
184
|
get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
|
|
160
185
|
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
161
186
|
/** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
|
|
162
|
-
save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null
|
|
187
|
+
save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null, options?: {
|
|
188
|
+
redirect?: boolean;
|
|
189
|
+
}): Promise<OpResult<EntryRecord>>;
|
|
163
190
|
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
164
191
|
/** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
|
|
165
192
|
discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
@@ -167,6 +194,7 @@ export type AdminOps = {
|
|
|
167
194
|
move(collection: string, id: string, to: {
|
|
168
195
|
parentId: string | null;
|
|
169
196
|
index?: number;
|
|
197
|
+
redirect?: boolean;
|
|
170
198
|
}): Promise<OpResult<EntryRecord>>;
|
|
171
199
|
/** Copies `from`'s localized fields and slug as the new locale's draft (spec 2026-09-06 §7). Expires nothing. */
|
|
172
200
|
addLocale(collection: string, id: string, locale: string, options: {
|
|
@@ -225,6 +253,17 @@ export type AdminOps = {
|
|
|
225
253
|
/** Admins only, self-host only; the same two guards. The Auth account stays. */
|
|
226
254
|
remove(userId: string): Promise<OpResult<null>>;
|
|
227
255
|
};
|
|
256
|
+
/** Redirects by hand (spec 2026-09-26 redirects §5): open to every member, as renaming is; nothing here expires a cache. */
|
|
257
|
+
redirects: {
|
|
258
|
+
/** The locale's redirect rows with their targets' titles and current URLs, sorted by old address. */
|
|
259
|
+
list(locale: string): Promise<OpResult<RedirectItem[]>>;
|
|
260
|
+
/** Every page with a template and every routed entry live in the locale, for the Add dialog's type-ahead. */
|
|
261
|
+
targets(locale: string): Promise<OpResult<RedirectTargetItem[]>>;
|
|
262
|
+
/** Validates the old address (redirect-path.ts), refuses a live address naming its owner, `not_found` for an unknown target. */
|
|
263
|
+
add(locale: string, path: string, target: PathTarget): Promise<OpResult<null>>;
|
|
264
|
+
/** Deletes a redirect row; a live row is never touched. */
|
|
265
|
+
remove(locale: string, path: string): Promise<OpResult<null>>;
|
|
266
|
+
};
|
|
228
267
|
schema: {
|
|
229
268
|
/** Every member. The shell calls it once on mount and blocks when behind. */
|
|
230
269
|
status(): Promise<OpResult<SchemaStatus>>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type RedirectPathProblem = "empty" | "leading-slash" | "root" | "trailing-slash" | "double-slash" | "whitespace" | "query-or-hash" | "admin" | "extension";
|
|
2
|
+
export declare const REDIRECT_PATH_MESSAGE: Record<RedirectPathProblem, string>;
|
|
3
|
+
export declare function redirectPathProblem(path: string, opts: {
|
|
4
|
+
adminPath: string;
|
|
5
|
+
multilingual: boolean;
|
|
6
|
+
}): RedirectPathProblem | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const REDIRECT_PATH_MESSAGE = {
|
|
2
|
+
empty: "Enter the old address.",
|
|
3
|
+
"leading-slash": "Start with a slash: /old-address.",
|
|
4
|
+
root: "The site root cannot redirect.",
|
|
5
|
+
"trailing-slash": "Leave out the trailing slash.",
|
|
6
|
+
"double-slash": "An address cannot have an empty part.",
|
|
7
|
+
whitespace: "An address cannot contain spaces.",
|
|
8
|
+
"query-or-hash": "Leave out the query string and the hash: they are not part of the address.",
|
|
9
|
+
admin: "That is the admin's address.",
|
|
10
|
+
extension: "An address with a file extension never reaches Smoodly on a multilingual site — redirect it in next.config instead.",
|
|
11
|
+
};
|
|
12
|
+
export function redirectPathProblem(path, opts) {
|
|
13
|
+
if (path === "")
|
|
14
|
+
return "empty";
|
|
15
|
+
if (/\s/.test(path))
|
|
16
|
+
return "whitespace";
|
|
17
|
+
if (/[?#]/.test(path))
|
|
18
|
+
return "query-or-hash";
|
|
19
|
+
if (!path.startsWith("/"))
|
|
20
|
+
return "leading-slash";
|
|
21
|
+
if (path === "/")
|
|
22
|
+
return "root";
|
|
23
|
+
if (path.endsWith("/"))
|
|
24
|
+
return "trailing-slash";
|
|
25
|
+
if (path.includes("//"))
|
|
26
|
+
return "double-slash";
|
|
27
|
+
if (path === opts.adminPath || path.startsWith(`${opts.adminPath}/`))
|
|
28
|
+
return "admin";
|
|
29
|
+
const last = path.slice(path.lastIndexOf("/") + 1);
|
|
30
|
+
if (opts.multilingual && /\.[A-Za-z0-9]+$/.test(last))
|
|
31
|
+
return "extension";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PathTarget } from "../../paths.ts";
|
|
2
|
+
import type { RedirectTargetItem } from "../ops.ts";
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
export declare function AddRedirectModal({ locale, registry, targets, onClose, onAdd }: {
|
|
5
|
+
locale: string;
|
|
6
|
+
registry: AdminRegistry;
|
|
7
|
+
targets: RedirectTargetItem[];
|
|
8
|
+
onClose(): void;
|
|
9
|
+
/** Resolves to an error message, or null when the redirect landed (the caller closes). */
|
|
10
|
+
onAdd(path: string, target: PathTarget): Promise<string | null>;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -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: picked ? `${picked.title} · ${picked.path}` : `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.`, 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
|
}
|