smoodly 0.0.26 → 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 +70 -6
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +5 -2
- 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 +24 -10
- package/dist/admin/shell/EntryForm.js +14 -6
- package/dist/admin/shell/GlobalForm.js +1 -1
- 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/presence-realtime.d.ts +1 -1
- package/dist/admin/shell/presence-realtime.js +19 -9
- package/dist/admin/shell/presence.d.ts +18 -0
- package/dist/admin/shell/presence.js +13 -0
- 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/primitives.d.ts +4 -1
- package/dist/admin/ui/primitives.js +4 -2
- 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-space.d.ts +6 -0
- package/dist/sql-space.js +37 -10
- package/dist/sql.js +24 -5
- 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/20260926140000_smoodly_v3.sql +38 -0
- package/migrations/20260926150000_smoodly_v4.sql +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Add a redirect by hand (spec 2026-09-26 redirects §5–§6): the old
|
|
4
|
+
// address, checked as typed with redirect-path.ts so the message shows
|
|
5
|
+
// before the op repeats the check, and the target — one type-ahead over
|
|
6
|
+
// every page and routed entry live in the locale, "Title · /path".
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "../redirect-path.js";
|
|
9
|
+
import { Combobox } from "../ui/Combobox.js";
|
|
10
|
+
import { Modal } from "../ui/Modal.js";
|
|
11
|
+
import { Banner, Button, Field } from "../ui/primitives.js";
|
|
12
|
+
import { parseTargetValue, targetOptions } from "./redirects-list.js";
|
|
13
|
+
export function AddRedirectModal({ locale, registry, targets, onClose, onAdd }) {
|
|
14
|
+
const [path, setPath] = useState("");
|
|
15
|
+
const [target, setTarget] = useState(null);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
const problem = redirectPathProblem(path, { adminPath: registry.adminPath, multilingual: registry.locales.supported.length > 1 });
|
|
19
|
+
const picked = target ? targets.find((t) => t.target.kind === target.kind && t.target.id === target.id) : undefined;
|
|
20
|
+
const submit = async (e) => {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
if (!target || problem)
|
|
23
|
+
return;
|
|
24
|
+
setBusy(true);
|
|
25
|
+
setError(await onAdd(path, target));
|
|
26
|
+
setBusy(false);
|
|
27
|
+
};
|
|
28
|
+
return (_jsx(Modal, { title: "Add redirect", width: 480, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Old address", error: path !== "" && problem ? REDIRECT_PATH_MESSAGE[problem] : undefined, children: _jsx("input", { className: "sm-input sm-input--mono", autoFocus: true, placeholder: "/old-address", value: path, onChange: (e) => setPath(e.target.value) }) }), _jsx(Field, { label: "Redirects to", asDiv: true, hint: 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
|
+
}
|
|
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
12
|
// (shell/IconRail.tsx), then the main column whose views own their own
|
|
13
13
|
// 48px top bar (the editor adds the third, its inspector). The shell is
|
|
14
14
|
// viewport-height; every column scrolls inside itself.
|
|
15
|
-
import { useEffect, useState } from "react";
|
|
15
|
+
import { useEffect, useRef, useState } from "react";
|
|
16
16
|
import { makeClientOps } from "../client-ops.js";
|
|
17
17
|
import { PagesList } from "./PagesList.js";
|
|
18
18
|
import { EntriesList } from "./EntriesList.js";
|
|
@@ -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";
|
|
@@ -37,7 +36,7 @@ import { ElementsProvider } from "../forms/elements-context.js";
|
|
|
37
36
|
import { TimeZoneProvider } from "../forms/time-zone-context.js";
|
|
38
37
|
import { SchemaNotice } from "./SchemaNotice.js";
|
|
39
38
|
import { schemaBehind } from "../../schema-version.js";
|
|
40
|
-
import { createPresence } from "./presence.js";
|
|
39
|
+
import { createPresence, followTokenRefresh } from "./presence.js";
|
|
41
40
|
import { connectRealtime } from "./presence-realtime.js";
|
|
42
41
|
import { PresenceProvider } from "./presence-context.js";
|
|
43
42
|
export function AdminApp({ registry, op, auth, content }) {
|
|
@@ -81,9 +80,15 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
81
80
|
return () => { live = false; };
|
|
82
81
|
}, [signedIn, ops]);
|
|
83
82
|
const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
|
|
84
|
-
// Presence (spec 2026-09-26 concurrency §5
|
|
85
|
-
//
|
|
83
|
+
// Presence (spec 2026-09-26 concurrency §5; gated 2026-09-26 security pass §1):
|
|
84
|
+
// one channel per signed-in session, the content project's public config as the
|
|
85
|
+
// gateway and the SESSION token on the socket; absent config = presence off.
|
|
86
|
+
// The token getter is a new closure every render — read it through a ref so
|
|
87
|
+
// the effect keys on the session, not on the render.
|
|
86
88
|
const [presence, setPresence] = useState(null);
|
|
89
|
+
const tokenRef = useRef(session.token);
|
|
90
|
+
tokenRef.current = session.token;
|
|
91
|
+
const authClient = session.client;
|
|
87
92
|
const contentUrl = content?.url, contentKey = content?.key, contentSpace = content?.spaceId;
|
|
88
93
|
useEffect(() => {
|
|
89
94
|
if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
|
|
@@ -91,11 +96,20 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
91
96
|
return;
|
|
92
97
|
}
|
|
93
98
|
const config = { url: contentUrl, key: contentKey, spaceId: contentSpace };
|
|
94
|
-
const p = createPresence({
|
|
99
|
+
const p = createPresence({
|
|
100
|
+
email,
|
|
101
|
+
connect: () => {
|
|
102
|
+
const token = tokenRef.current();
|
|
103
|
+
if (!token)
|
|
104
|
+
throw new Error("smoodly: no session token for presence");
|
|
105
|
+
return connectRealtime(config, email, token);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
95
108
|
p.join();
|
|
96
109
|
setPresence(p);
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
const stop = followTokenRefresh(authClient.auth, p);
|
|
111
|
+
return () => { stop(); p.dispose(); setPresence(null); };
|
|
112
|
+
}, [signedIn, contentUrl, contentKey, contentSpace, email, authClient]);
|
|
99
113
|
if (session.state.status === "loading") {
|
|
100
114
|
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
101
115
|
}
|
|
@@ -124,10 +138,10 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
124
138
|
else if (head === "globals" && tail)
|
|
125
139
|
main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
126
140
|
else if (head === "settings")
|
|
127
|
-
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 });
|
|
128
142
|
else if (head === "media")
|
|
129
143
|
main = _jsx(MediaScreen, {});
|
|
130
144
|
else
|
|
131
145
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
132
|
-
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, {})] }) }) }) }) }));
|
|
133
147
|
}
|
|
@@ -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
|
-
|
|
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
|
|
@@ -411,6 +419,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
411
419
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
412
420
|
},
|
|
413
421
|
{ label: heading },
|
|
414
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), 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" })] }))] })] })] }));
|
|
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)
|
|
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
|
}
|
|
@@ -298,6 +298,6 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
298
298
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
299
299
|
},
|
|
300
300
|
{ label: item.title },
|
|
301
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
301
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
302
302
|
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
|
|
303
303
|
}
|
|
@@ -3,7 +3,7 @@ export declare function IconRail({ registry, active, email, settings, onSignOut
|
|
|
3
3
|
registry: AdminRegistry;
|
|
4
4
|
active: string;
|
|
5
5
|
email?: string;
|
|
6
|
-
/** Whether the Settings tile shows —
|
|
6
|
+
/** Whether the Settings tile shows — once the role is known, in the shell (every member has a Settings page). */
|
|
7
7
|
settings?: boolean;
|
|
8
8
|
onSignOut: () => void;
|
|
9
9
|
}): import("react").JSX.Element;
|
|
@@ -10,8 +10,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
// Collections — one rail item whose flyout lists every collection — then
|
|
11
11
|
// Media, Globals (the global content items, when the registry has any;
|
|
12
12
|
// the route is /globals), and Settings (the Settings area, spec
|
|
13
|
-
// 2026-09-25; the route is /settings;
|
|
14
|
-
//
|
|
13
|
+
// 2026-09-25; the route is /settings; every member has a page there since
|
|
14
|
+
// Redirects, 2026-09-26, so the shell passes `settings` once the role is
|
|
15
|
+
// known). The user avatar is pinned to the bottom and opens the same
|
|
15
16
|
// flyout with the theme toggle and Sign out, which the old nav's footer
|
|
16
17
|
// held.
|
|
17
18
|
//
|
|
@@ -68,9 +68,10 @@ export function PagesList({ ops, registry }) {
|
|
|
68
68
|
return;
|
|
69
69
|
// A published page under a new parent is a new live URL (members management §5).
|
|
70
70
|
const current = row.record.locales[locale];
|
|
71
|
-
|
|
71
|
+
const live = await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path });
|
|
72
|
+
if (!live.ok)
|
|
72
73
|
return;
|
|
73
|
-
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index }));
|
|
74
|
+
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index, redirect: live.redirect }));
|
|
74
75
|
if (!ok)
|
|
75
76
|
throw new Error("move refused");
|
|
76
77
|
},
|
|
@@ -150,9 +151,10 @@ export function PagesList({ ops, registry }) {
|
|
|
150
151
|
// The site root's slug is the config's homePageSlug — title only there.
|
|
151
152
|
if (row.isHome)
|
|
152
153
|
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title }));
|
|
153
|
-
|
|
154
|
+
const live = await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path });
|
|
155
|
+
if (!live.ok)
|
|
154
156
|
return null;
|
|
155
|
-
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug }));
|
|
157
|
+
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug, redirect: live.redirect }));
|
|
156
158
|
};
|
|
157
159
|
const addLocale = async (row) => {
|
|
158
160
|
const from = sourceLocaleOf(row.record, registry);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminOps, RedirectItem } from "../ops.ts";
|
|
2
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
+
export declare function RedirectsPage({ ops, registry, initial }: {
|
|
4
|
+
ops: AdminOps;
|
|
5
|
+
registry: AdminRegistry;
|
|
6
|
+
/** Static-markup tests: the rows without a load. */
|
|
7
|
+
initial?: {
|
|
8
|
+
locale: string;
|
|
9
|
+
items: RedirectItem[];
|
|
10
|
+
} | null;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// Settings › Redirects (spec 2026-09-26 redirects §6): the active
|
|
4
|
+
// locale's redirect rows — old address → the target's title (opens its
|
|
5
|
+
// editor) and current URL — with Add redirect and a row menu whose one
|
|
6
|
+
// item removes the row after a confirm. Every member, as renaming is.
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { ActionMenu } from "../ui/ActionMenu.js";
|
|
9
|
+
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
10
|
+
import { confirm } from "../ui/confirm.js";
|
|
11
|
+
import { Banner, Button } from "../ui/primitives.js";
|
|
12
|
+
import { AddRedirectModal } from "./AddRedirectModal.js";
|
|
13
|
+
import { useAdminHref } from "./base.js";
|
|
14
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
15
|
+
import { ListView } from "./ListView.js";
|
|
16
|
+
import { RouteLink } from "./RouteLink.js";
|
|
17
|
+
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
18
|
+
import { REMOVE_REDIRECT_CONFIRM, editorHref } from "./redirects-list.js";
|
|
19
|
+
export function RedirectsPage({ ops, registry, initial = null }) {
|
|
20
|
+
const adminHref = useAdminHref();
|
|
21
|
+
const multilingual = registry.locales.supported.length > 1;
|
|
22
|
+
const [locale, setLocale] = useState(initial?.locale ?? rememberedLocale(registry.locales));
|
|
23
|
+
const [items, setItems] = useState(initial?.items ?? null);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
const [notice, setNotice] = useState(null);
|
|
26
|
+
const [adding, setAdding] = useState(null);
|
|
27
|
+
const load = (l) => ops.redirects.list(l)
|
|
28
|
+
.then((r) => (r.ok ? setItems(r.data) : setError(r.message)))
|
|
29
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
30
|
+
useEffect(() => { if (!initial)
|
|
31
|
+
void load(locale); }, [ops, locale, initial]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
32
|
+
const pickLocale = (next) => { rememberLocale(next); setLocale(next); setItems(null); };
|
|
33
|
+
const openAdd = async () => {
|
|
34
|
+
const r = await ops.redirects.targets(locale);
|
|
35
|
+
if (!r.ok) {
|
|
36
|
+
setNotice({ tone: "error", text: r.message });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setAdding(r.data);
|
|
40
|
+
};
|
|
41
|
+
const add = async (path, target) => {
|
|
42
|
+
const r = await ops.redirects.add(locale, path, target);
|
|
43
|
+
if (!r.ok)
|
|
44
|
+
return r.message;
|
|
45
|
+
setAdding(null);
|
|
46
|
+
setNotice({ tone: "ok", text: `${path} now redirects.` });
|
|
47
|
+
void load(locale);
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
const remove = async (item) => {
|
|
51
|
+
if (!(await confirm({ ...REMOVE_REDIRECT_CONFIRM, subject: { name: item.title, path: item.path } })))
|
|
52
|
+
return;
|
|
53
|
+
const r = await ops.redirects.remove(locale, item.path);
|
|
54
|
+
if (!r.ok) {
|
|
55
|
+
setNotice({ tone: "error", text: r.message });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setNotice(null); // an earlier "now redirects" must not outlive the row
|
|
59
|
+
void load(locale);
|
|
60
|
+
};
|
|
61
|
+
if (error)
|
|
62
|
+
return _jsx(ErrorPane, { message: error });
|
|
63
|
+
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Redirects", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsx(Button, { onClick: () => void openAdd(), children: "Add redirect" }), notice: notice && _jsx(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: notice.text }), columns: [
|
|
64
|
+
{ label: "Old address", width: "minmax(200px,1fr)" },
|
|
65
|
+
{ label: "Redirects to", width: "minmax(200px,1fr)" },
|
|
66
|
+
{ label: "", width: "48px" },
|
|
67
|
+
], rows: (items ?? []).map((item) => ({
|
|
68
|
+
key: item.path,
|
|
69
|
+
cells: [
|
|
70
|
+
_jsx("code", { children: item.path }),
|
|
71
|
+
_jsxs("span", { style: { display: "flex", gap: 8, alignItems: "baseline", minWidth: 0 }, children: [_jsx(RouteLink, { href: adminHref(editorHref(item, locale)), children: item.title }), item.currentPath ? _jsx("code", { children: item.currentPath }) : _jsxs("span", { className: "sm-hint", children: ["not published in ", locale] })] }),
|
|
72
|
+
_jsx(ActionMenu, { label: "Redirect actions", small: true, items: [{ label: "Remove redirect", danger: true, run: () => void remove(item) }] }),
|
|
73
|
+
],
|
|
74
|
+
})), empty: items === null ? "Loading…" : `No redirects in ${locale}` }), adding && _jsx(AddRedirectModal, { locale: locale, registry: registry, targets: adding, onClose: () => setAdding(null), onAdd: add })] }));
|
|
75
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { MemberRole } from "../../permissions.ts";
|
|
2
2
|
import type { AdminOps } from "../ops.ts";
|
|
3
|
-
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
export declare function SettingsView({ ops, registry, tail, role, userId }: {
|
|
4
5
|
ops: AdminOps;
|
|
6
|
+
registry: AdminRegistry;
|
|
5
7
|
tail: string | undefined;
|
|
6
8
|
/** The caller's role from `members.me`; null while it loads. */
|
|
7
9
|
role: MemberRole | null;
|
|
@@ -4,31 +4,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
|
|
8
|
-
// page is the shell's Not found pane.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// itself with the role's first page, so Back skips the empty URL. An
|
|
8
|
+
// unknown page is the shell's Not found pane. Each page names its
|
|
9
|
+
// capability (settings.ts): Members is for admins (members management
|
|
10
|
+
// §2) and an editor opening it gets the error pane; Redirects (spec
|
|
11
|
+
// 2026-09-26 redirects §6) 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 {
|
|
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/${
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
10
|
-
export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject): Promise<
|
|
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 {
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { ContentConfig } from "../../env.ts";
|
|
2
2
|
import type { PresenceChannel } from "./presence.ts";
|
|
3
|
-
export declare function connectRealtime(config: ContentConfig, email: string): PresenceChannel;
|
|
3
|
+
export declare function connectRealtime(config: ContentConfig, email: string, token: string): PresenceChannel;
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
// The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6
|
|
3
|
-
// supabase-js client on the CONTENT
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6;
|
|
3
|
+
// gated 2026-09-26 security pass §1): one supabase-js client on the CONTENT
|
|
4
|
+
// project whose public key (the anon key in self-host, the read key in the
|
|
5
|
+
// cloud) is only the gateway apikey — the socket authenticates with the
|
|
6
|
+
// editor's SESSION token, which the policies read auth.uid() from. A
|
|
7
|
+
// private channel named after the space, presence keyed by the email.
|
|
8
|
+
//
|
|
9
|
+
// The token rides the client's `accessToken` callback, not a bare
|
|
10
|
+
// `realtime.setAuth(token)`: realtime-js treats the callback as the source
|
|
11
|
+
// of truth and re-asks it after every join and reconnect — with the
|
|
12
|
+
// default callback that answer is the public key, which the server then
|
|
13
|
+
// refuses ("no permissions to read"). The join itself waits for the first
|
|
14
|
+
// setAuth, so it never goes out with the apikey alone.
|
|
6
15
|
import { createClient } from "@supabase/supabase-js";
|
|
7
|
-
export function connectRealtime(config, email) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
export function connectRealtime(config, email, token) {
|
|
17
|
+
let current = token;
|
|
18
|
+
const client = createClient(config.url, config.key, { accessToken: async () => current });
|
|
19
|
+
const ready = client.realtime.setAuth(token).catch(() => { });
|
|
11
20
|
const channel = client.channel(`space:${config.spaceId}`, { config: { private: true, presence: { key: email } } });
|
|
12
21
|
return {
|
|
13
|
-
subscribe: (onStatus) => { channel.subscribe((status) => onStatus(status)); },
|
|
22
|
+
subscribe: (onStatus) => { void ready.then(() => { channel.subscribe((status) => onStatus(status)); }); },
|
|
23
|
+
setAuth: (fresh) => { current = fresh; return client.realtime.setAuth(fresh); },
|
|
14
24
|
track: (state) => channel.track(state),
|
|
15
25
|
untrack: () => channel.untrack(),
|
|
16
26
|
send: (message) => channel.send(message),
|
|
@@ -18,6 +18,8 @@ export type PresenceState = {
|
|
|
18
18
|
/** The slice of a Realtime channel presence uses. */
|
|
19
19
|
export type PresenceChannel = {
|
|
20
20
|
subscribe(onStatus: (status: string) => void): void;
|
|
21
|
+
/** A session token for the socket (spec 2026-09-26 security pass §1): the channel policies admit by auth.uid(). */
|
|
22
|
+
setAuth(token: string): Promise<unknown>;
|
|
21
23
|
track(state: Record<string, unknown>): Promise<unknown>;
|
|
22
24
|
untrack(): Promise<unknown>;
|
|
23
25
|
send(message: {
|
|
@@ -37,6 +39,8 @@ export type Presence = {
|
|
|
37
39
|
track(target: PresenceTarget | null): void;
|
|
38
40
|
/** After a guarded op acked: tell the others. */
|
|
39
41
|
announce(target: PresenceTarget, savedAt: string | null): void;
|
|
42
|
+
/** The session token was refreshed: re-authenticate the socket so the channel outlives the old token. No-op unless joined. */
|
|
43
|
+
setAuth(token: string): void;
|
|
40
44
|
onSaved(listener: (event: SavedEvent) => void): () => void;
|
|
41
45
|
getState(): PresenceState;
|
|
42
46
|
subscribe(listener: () => void): () => void;
|
|
@@ -49,3 +53,17 @@ export declare function createPresence(opts: {
|
|
|
49
53
|
email: string;
|
|
50
54
|
connect: () => PresenceChannel;
|
|
51
55
|
}): Presence;
|
|
56
|
+
/** What the shell needs from the auth client to follow refreshes — supabase-js's `auth` satisfies it. */
|
|
57
|
+
export type RefreshSource = {
|
|
58
|
+
onAuthStateChange(cb: (event: string, session: {
|
|
59
|
+
access_token: string;
|
|
60
|
+
} | null) => void): {
|
|
61
|
+
data: {
|
|
62
|
+
subscription: {
|
|
63
|
+
unsubscribe(): void;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
69
|
+
export declare function followTokenRefresh(auth: RefreshSource, presence: Pick<Presence, "setAuth">): () => void;
|
|
@@ -76,6 +76,11 @@ export function createPresence(opts) {
|
|
|
76
76
|
return;
|
|
77
77
|
void channel.send({ type: "broadcast", event: "saved", payload: { ...target, email: opts.email, savedAt } }).catch(() => { });
|
|
78
78
|
},
|
|
79
|
+
setAuth(token) {
|
|
80
|
+
if (!channel)
|
|
81
|
+
return;
|
|
82
|
+
void channel.setAuth(token).catch(() => { });
|
|
83
|
+
},
|
|
79
84
|
onSaved(listener) { savedListeners.add(listener); return () => { savedListeners.delete(listener); }; },
|
|
80
85
|
getState: () => state,
|
|
81
86
|
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
@@ -88,3 +93,11 @@ export function createPresence(opts) {
|
|
|
88
93
|
},
|
|
89
94
|
};
|
|
90
95
|
}
|
|
96
|
+
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
97
|
+
export function followTokenRefresh(auth, presence) {
|
|
98
|
+
const { data } = auth.onAuthStateChange((event, session) => {
|
|
99
|
+
if (event === "TOKEN_REFRESHED" && session)
|
|
100
|
+
presence.setAuth(session.access_token);
|
|
101
|
+
});
|
|
102
|
+
return () => data.subscription.unsubscribe();
|
|
103
|
+
}
|
|
@@ -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">;
|