smoodly 0.0.8 → 0.0.10
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 +228 -18
- package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
- package/dist/admin/editor/CanvasOverlay.js +39 -0
- package/dist/admin/editor/EditorView.js +56 -237
- package/dist/admin/editor/InsertPicker.d.ts +18 -0
- package/dist/admin/editor/InsertPicker.js +18 -0
- package/dist/admin/editor/NoticePane.d.ts +4 -0
- package/dist/admin/editor/NoticePane.js +4 -0
- package/dist/admin/editor/Outline.d.ts +18 -0
- package/dist/admin/editor/Outline.js +12 -0
- package/dist/admin/editor/locale-url.d.ts +12 -0
- package/dist/admin/editor/locale-url.js +32 -0
- package/dist/admin/editor/overlay-geometry.d.ts +14 -0
- package/dist/admin/editor/overlay-geometry.js +27 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
- package/dist/admin/editor/useCanvasBridge.js +53 -0
- package/dist/admin/editor/usePageLocale.d.ts +23 -0
- package/dist/admin/editor/usePageLocale.js +88 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +68 -8
- package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
- package/dist/admin/forms/RichtextToolbar.js +37 -0
- package/dist/admin/forms/RichtextWidget.d.ts +5 -0
- package/dist/admin/forms/RichtextWidget.js +123 -0
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- package/dist/admin/forms/richtext-extensions.d.ts +5 -0
- package/dist/admin/forms/richtext-extensions.js +74 -0
- package/dist/admin/forms/richtext-paste.d.ts +22 -0
- package/dist/admin/forms/richtext-paste.js +59 -0
- package/dist/admin/index.d.ts +0 -1
- package/dist/admin/index.js +0 -1
- package/dist/admin/next/create-admin.d.ts +3 -7
- package/dist/admin/next/create-admin.js +14 -5
- package/dist/admin/richtext-doc.d.ts +7 -0
- package/dist/admin/richtext-doc.js +18 -0
- package/dist/admin/serialize.d.ts +4 -0
- package/dist/admin/serialize.js +3 -0
- package/dist/admin/shell/AdminApp.d.ts +7 -2
- package/dist/admin/shell/AdminApp.js +12 -6
- package/dist/admin/shell/EntriesList.js +3 -2
- package/dist/admin/shell/EntryForm.js +9 -25
- package/dist/admin/shell/ListView.js +3 -1
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/RouteLink.d.ts +4 -0
- package/dist/admin/shell/RouteLink.js +12 -0
- package/dist/admin/shell/SharedForm.js +4 -22
- package/dist/admin/shell/router.d.ts +37 -0
- package/dist/admin/shell/router.js +84 -0
- package/dist/admin/shell/session.js +3 -1
- package/dist/admin/ui/primitives.d.ts +2 -1
- package/dist/admin/ui/primitives.js +5 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +40 -1
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +53 -13
- package/dist/collections.d.ts +0 -14
- package/dist/collections.js +0 -10
- package/dist/entry-store.js +7 -1
- package/dist/fields.d.ts +18 -2
- package/dist/fields.js +51 -5
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/refs.js +5 -0
- package/dist/resolve.js +3 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -0
- package/dist/sql-space.d.ts +4 -0
- package/dist/sql-space.js +5 -1
- package/dist/sql.js +1 -1
- package/dist/supabase-entry-store.d.ts +8 -0
- package/dist/supabase-entry-store.js +37 -2
- package/dist/toolset.d.ts +30 -0
- package/dist/toolset.js +49 -0
- package/package.json +11 -1
- package/dist/admin/richtext.d.ts +0 -14
- package/dist/admin/richtext.js +0 -17
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The editor's extension set (spec 2026-09-08 §2): always the FULL schema
|
|
2
|
+
// of what `RichText` draws, so content a toolset forbids survives the
|
|
3
|
+
// round trip; the toolset gates only creation — input rules, paste rules
|
|
4
|
+
// and keyboard shortcuts here, the toolbar in RichtextToolbar, paste in
|
|
5
|
+
// richtext-paste. StarterKit supplies document, paragraph, text, hard
|
|
6
|
+
// break, list item, list keymap and undo/redo; every gated node or mark
|
|
7
|
+
// is switched off there and added back extended.
|
|
8
|
+
import { Extension, textblockTypeInputRule } from "@tiptap/core";
|
|
9
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
10
|
+
import Heading from "@tiptap/extension-heading";
|
|
11
|
+
import Bold from "@tiptap/extension-bold";
|
|
12
|
+
import Italic from "@tiptap/extension-italic";
|
|
13
|
+
import Link from "@tiptap/extension-link";
|
|
14
|
+
import Blockquote from "@tiptap/extension-blockquote";
|
|
15
|
+
import { BulletList, OrderedList } from "@tiptap/extension-list";
|
|
16
|
+
/** Keep the mark in the schema; add no way to create it when `on` is false. */
|
|
17
|
+
function gateMarkConfig(on) {
|
|
18
|
+
return {
|
|
19
|
+
addInputRules() { return on ? this.parent?.() ?? [] : []; },
|
|
20
|
+
addPasteRules() { return on ? this.parent?.() ?? [] : []; },
|
|
21
|
+
addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function gateNodeConfig(on) {
|
|
25
|
+
return {
|
|
26
|
+
addInputRules() { return on ? this.parent?.() ?? [] : []; },
|
|
27
|
+
addPasteRules() { return on ? this.parent?.() ?? [] : []; },
|
|
28
|
+
addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const ALL_LEVELS = [1, 2, 3, 4, 5, 6];
|
|
32
|
+
export function richtextExtensions(allowed, hooks) {
|
|
33
|
+
const link = allowed.marks.has("link");
|
|
34
|
+
const levels = allowed.headingLevels;
|
|
35
|
+
return [
|
|
36
|
+
StarterKit.configure({
|
|
37
|
+
heading: false, bold: false, italic: false, link: false, blockquote: false,
|
|
38
|
+
bulletList: false, orderedList: false,
|
|
39
|
+
code: false, codeBlock: false, strike: false, underline: false, horizontalRule: false,
|
|
40
|
+
dropcursor: false, gapcursor: false, trailingNode: false,
|
|
41
|
+
}),
|
|
42
|
+
Heading.extend({
|
|
43
|
+
// Exact `#` counts, and only the allowed levels; `# ` does nothing when h1 is forbidden.
|
|
44
|
+
addInputRules() {
|
|
45
|
+
return levels.map((level) => textblockTypeInputRule({ find: new RegExp(`^(#{${level}})\\s$`), type: this.type, getAttributes: { level } }));
|
|
46
|
+
},
|
|
47
|
+
addKeyboardShortcuts() {
|
|
48
|
+
return Object.fromEntries(levels.map((level) => [`Mod-Alt-${level}`, () => this.editor.commands.toggleHeading({ level })]));
|
|
49
|
+
},
|
|
50
|
+
}).configure({ levels: ALL_LEVELS }),
|
|
51
|
+
Bold.extend(gateMarkConfig(allowed.marks.has("bold"))),
|
|
52
|
+
Italic.extend(gateMarkConfig(allowed.marks.has("italic"))),
|
|
53
|
+
Link.extend({
|
|
54
|
+
...gateMarkConfig(link),
|
|
55
|
+
// href only (spec §3): no target, rel or class in the stored JSON.
|
|
56
|
+
addAttributes() { return { href: { default: null } }; },
|
|
57
|
+
}).configure({
|
|
58
|
+
openOnClick: false,
|
|
59
|
+
autolink: link,
|
|
60
|
+
linkOnPaste: link,
|
|
61
|
+
// Site-relative targets are links too; the site renderer's scheme filter is the safety net.
|
|
62
|
+
isAllowedUri: (url, ctx) => /^[/#?.]/.test(url) || ctx.defaultValidate(url),
|
|
63
|
+
}),
|
|
64
|
+
Blockquote.extend(gateNodeConfig(allowed.nodes.has("blockquote"))),
|
|
65
|
+
BulletList.extend(gateNodeConfig(allowed.nodes.has("bulletList"))),
|
|
66
|
+
OrderedList.extend(gateNodeConfig(allowed.nodes.has("orderedList"))),
|
|
67
|
+
Extension.create({
|
|
68
|
+
name: "smoodlyLinkShortcut",
|
|
69
|
+
addKeyboardShortcuts() {
|
|
70
|
+
return link ? { "Mod-k": () => { hooks.onLink(); return true; } } : {};
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Allowed } from "../../toolset.ts";
|
|
2
|
+
export type PmJSON = {
|
|
3
|
+
type: string;
|
|
4
|
+
attrs?: Record<string, unknown>;
|
|
5
|
+
content?: PmJSON[];
|
|
6
|
+
text?: string;
|
|
7
|
+
marks?: {
|
|
8
|
+
type: string;
|
|
9
|
+
attrs?: Record<string, unknown>;
|
|
10
|
+
}[];
|
|
11
|
+
};
|
|
12
|
+
/** ProseMirror's Slice.toJSON() shape. */
|
|
13
|
+
export type SliceJSON = {
|
|
14
|
+
content?: PmJSON[] | null;
|
|
15
|
+
openStart?: number;
|
|
16
|
+
openEnd?: number;
|
|
17
|
+
};
|
|
18
|
+
/** The allowed level closest to `level`; the smaller number on a tie;
|
|
19
|
+
* null when no heading is allowed. */
|
|
20
|
+
export declare function clampLevel(level: number, levels: number[]): number | null;
|
|
21
|
+
export declare function gateNodes(nodes: PmJSON[], allowed: Allowed): PmJSON[];
|
|
22
|
+
export declare function gatePastedSlice(slice: SliceJSON, allowed: Allowed): SliceJSON;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** The allowed level closest to `level`; the smaller number on a tie;
|
|
2
|
+
* null when no heading is allowed. */
|
|
3
|
+
export function clampLevel(level, levels) {
|
|
4
|
+
if (levels.length === 0)
|
|
5
|
+
return null;
|
|
6
|
+
let best = levels[0];
|
|
7
|
+
for (const l of levels) {
|
|
8
|
+
const d = Math.abs(l - level);
|
|
9
|
+
const bd = Math.abs(best - level);
|
|
10
|
+
if (d < bd || (d === bd && l < best))
|
|
11
|
+
best = l;
|
|
12
|
+
}
|
|
13
|
+
return best;
|
|
14
|
+
}
|
|
15
|
+
export function gateNodes(nodes, allowed) {
|
|
16
|
+
return nodes.flatMap((n) => gateNode(n, allowed));
|
|
17
|
+
}
|
|
18
|
+
function gateNode(n, allowed) {
|
|
19
|
+
if (n.type === "text") {
|
|
20
|
+
const marks = (n.marks ?? []).filter((m) => allowed.marks.has(m.type));
|
|
21
|
+
const { marks: _drop, ...rest } = n;
|
|
22
|
+
return [marks.length > 0 ? { ...rest, marks } : rest];
|
|
23
|
+
}
|
|
24
|
+
const content = n.content ? gateNodes(n.content, allowed) : undefined;
|
|
25
|
+
const withContent = (node) => (content ? { ...node, content } : node);
|
|
26
|
+
if (n.type === "heading") {
|
|
27
|
+
const level = clampLevel(Number(n.attrs?.level ?? 1), allowed.headingLevels);
|
|
28
|
+
if (level === null)
|
|
29
|
+
return [withContent({ type: "paragraph" })];
|
|
30
|
+
return [withContent({ ...n, attrs: { ...n.attrs, level } })];
|
|
31
|
+
}
|
|
32
|
+
if (n.type === "blockquote" && !allowed.nodes.has("blockquote"))
|
|
33
|
+
return content ?? [];
|
|
34
|
+
if ((n.type === "bulletList" && !allowed.nodes.has("bulletList")) || (n.type === "orderedList" && !allowed.nodes.has("orderedList"))) {
|
|
35
|
+
return (content ?? []).flatMap((item) => item.content ?? []);
|
|
36
|
+
}
|
|
37
|
+
return [withContent(n)];
|
|
38
|
+
}
|
|
39
|
+
/** Depth of the first (or last) child chain, counting every non-text node. */
|
|
40
|
+
function edgeDepth(nodes, side) {
|
|
41
|
+
let depth = 0;
|
|
42
|
+
let list = nodes;
|
|
43
|
+
while (list.length > 0) {
|
|
44
|
+
const n = side === "first" ? list[0] : list[list.length - 1];
|
|
45
|
+
if (n.type === "text")
|
|
46
|
+
break;
|
|
47
|
+
depth++;
|
|
48
|
+
list = n.content ?? [];
|
|
49
|
+
}
|
|
50
|
+
return depth;
|
|
51
|
+
}
|
|
52
|
+
export function gatePastedSlice(slice, allowed) {
|
|
53
|
+
const content = gateNodes(slice.content ?? [], allowed);
|
|
54
|
+
return {
|
|
55
|
+
content,
|
|
56
|
+
openStart: Math.min(slice.openStart ?? 0, edgeDepth(content, "first")),
|
|
57
|
+
openEnd: Math.min(slice.openEnd ?? 0, edgeDepth(content, "last")),
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -9,4 +9,3 @@ export { serializeAdminConfig } from "./serialize.ts";
|
|
|
9
9
|
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
|
|
10
10
|
export * from "./tree-ops.ts";
|
|
11
11
|
export { validateFields, type FieldError } from "./validate.ts";
|
|
12
|
-
export { plainToRichtext, richtextToPlain } from "./richtext.ts";
|
package/dist/admin/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import type { ResolvedConfig } from "../../config.ts";
|
|
3
3
|
import type { AdminOpAction } from "../ops.ts";
|
|
4
4
|
import type { AuthConfig } from "../shell/session.ts";
|
|
@@ -7,12 +7,8 @@ export declare function createSmoodlyAdmin(input: {
|
|
|
7
7
|
op: AdminOpAction;
|
|
8
8
|
auth: () => AuthConfig;
|
|
9
9
|
}): {
|
|
10
|
-
AdminPage: (
|
|
11
|
-
params: Promise<{
|
|
12
|
-
segments?: string[];
|
|
13
|
-
}>;
|
|
14
|
-
}) => Promise<ReactElement>;
|
|
10
|
+
AdminPage: () => null;
|
|
15
11
|
AdminLayout: ({ children }: {
|
|
16
12
|
children: ReactNode;
|
|
17
|
-
}) =>
|
|
13
|
+
}) => import("react").JSX.Element;
|
|
18
14
|
};
|
|
@@ -4,16 +4,25 @@ import { AdminApp } from "../shell/AdminApp.js";
|
|
|
4
4
|
import { AdminStyles } from "../ui/theme.js";
|
|
5
5
|
export function createSmoodlyAdmin(input) {
|
|
6
6
|
const registry = serializeAdminConfig(input.config);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/** The catch-all page only makes every /admin URL exist. The shell is
|
|
8
|
+
* mounted by the layout, not here: a server action that revalidates
|
|
9
|
+
* (publish, delete, a save of published content) answers with a
|
|
10
|
+
* re-rendered tree for the URL it was posted to, and Next remounts a
|
|
11
|
+
* page whose dynamic segment changed since the document load, while a
|
|
12
|
+
* layout persists (spec 2026-09-08 client routing, §1 amendment). */
|
|
13
|
+
function AdminPage() {
|
|
14
|
+
return null;
|
|
10
15
|
}
|
|
11
16
|
/** The admin owns the viewport: the whole UI is a fixed three-column
|
|
12
17
|
* shell that scrolls internally, never the host document. The design
|
|
13
18
|
* system's tokens ride along in one <style> — the host app imports
|
|
14
|
-
* nothing (see ui/theme.tsx).
|
|
19
|
+
* nothing (see ui/theme.tsx). The shell reads its route from the URL
|
|
20
|
+
* on mount (shell/router.ts); `children` is the page's nothing.
|
|
21
|
+
* suppressHydrationWarning: the theme boot script sets `data-theme`
|
|
22
|
+
* on this element before React hydrates, on purpose (no white flash
|
|
23
|
+
* for a dark-mode editor), so the attribute differs from the server's. */
|
|
15
24
|
function AdminLayout({ children }) {
|
|
16
|
-
return (_jsxs("div", { className: "sm-admin", children: [_jsx(AdminStyles, {}), children] }));
|
|
25
|
+
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
|
|
17
26
|
}
|
|
18
27
|
return { AdminPage, AdminLayout };
|
|
19
28
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** True when any text node in the tree has non-whitespace text. */
|
|
2
|
+
export declare function hasText(doc: unknown): boolean;
|
|
3
|
+
/** The document as plain JSON. ProseMirror builds node attrs with a null
|
|
4
|
+
* prototype, which React's server-action encoding refuses to serialize
|
|
5
|
+
* (it becomes a temporary reference and the server's structuredClone
|
|
6
|
+
* throws); a JSON round trip gives every object the plain prototype. */
|
|
7
|
+
export declare function plainDoc<T>(doc: T): T;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Shape helpers over a TipTap document, used by the validator (an empty
|
|
2
|
+
// document is missing, spec 2026-09-08 §3). Pure, no TipTap import.
|
|
3
|
+
/** True when any text node in the tree has non-whitespace text. */
|
|
4
|
+
export function hasText(doc) {
|
|
5
|
+
if (!doc || typeof doc !== "object")
|
|
6
|
+
return false;
|
|
7
|
+
const n = doc;
|
|
8
|
+
if (typeof n.text === "string" && n.text.trim() !== "")
|
|
9
|
+
return true;
|
|
10
|
+
return Array.isArray(n.content) && n.content.some(hasText);
|
|
11
|
+
}
|
|
12
|
+
/** The document as plain JSON. ProseMirror builds node attrs with a null
|
|
13
|
+
* prototype, which React's server-action encoding refuses to serialize
|
|
14
|
+
* (it becomes a temporary reference and the server's structuredClone
|
|
15
|
+
* throws); a JSON round trip gives every object the plain prototype. */
|
|
16
|
+
export function plainDoc(doc) {
|
|
17
|
+
return JSON.parse(JSON.stringify(doc));
|
|
18
|
+
}
|
|
@@ -3,6 +3,10 @@ import type { ZonePolicy } from "../zones.ts";
|
|
|
3
3
|
export type AdminField = Record<string, unknown> & {
|
|
4
4
|
type: string;
|
|
5
5
|
target?: string;
|
|
6
|
+
/** An object's subfields, serialized. */
|
|
7
|
+
fields?: Record<string, AdminField>;
|
|
8
|
+
/** A list's item descriptor, serialized. */
|
|
9
|
+
item?: AdminField;
|
|
6
10
|
};
|
|
7
11
|
export type AdminSection = {
|
|
8
12
|
name: string;
|
package/dist/admin/serialize.js
CHANGED
|
@@ -10,6 +10,9 @@ function serializeDescriptor(d) {
|
|
|
10
10
|
else if (key === "fields" && d.type === "object") {
|
|
11
11
|
out.fields = serializeMap(value);
|
|
12
12
|
}
|
|
13
|
+
else if (key === "item" && d.type === "list") {
|
|
14
|
+
out.item = serializeDescriptor(value);
|
|
15
|
+
}
|
|
13
16
|
else if (typeof value !== "function") {
|
|
14
17
|
out[key] = value;
|
|
15
18
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { AdminOpAction } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
3
|
import { type AuthConfig } from "./session.ts";
|
|
4
|
-
export declare function
|
|
4
|
+
export declare function Nav({ registry, active, email, onSignOut }: {
|
|
5
|
+
registry: AdminRegistry;
|
|
6
|
+
active: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
onSignOut: () => void;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
10
|
+
export declare function AdminApp({ registry, op, auth }: {
|
|
5
11
|
registry: AdminRegistry;
|
|
6
|
-
segments: string[];
|
|
7
12
|
op: AdminOpAction;
|
|
8
13
|
auth: AuthConfig;
|
|
9
14
|
}): import("react").JSX.Element;
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
4
|
// The admin shell: registry-derived nav (never separately configured),
|
|
5
5
|
// list views in the main area, editor/entry views mounted by segment.
|
|
6
|
-
// Internal routing
|
|
6
|
+
// Internal routing is the shell's own (shell/router.ts): the route is read
|
|
7
|
+
// from the URL on mount, then pushState moves between views and the shell
|
|
8
|
+
// never reloads (spec 2026-09-08 client routing). The shell mounts from the
|
|
9
|
+
// admin LAYOUT (next/create-admin.tsx), never from the catch-all page.
|
|
7
10
|
//
|
|
8
11
|
// Layout is the design system's three columns: a 200px nav, then the main
|
|
9
12
|
// column whose views own their own 48px top bar (the editor adds the third,
|
|
@@ -21,20 +24,23 @@ import { LoginView } from "./LoginView.js";
|
|
|
21
24
|
import { useAdminSession } from "./session.js";
|
|
22
25
|
import { Logo, ThemeToggle } from "../ui/primitives.js";
|
|
23
26
|
import { BASE } from "./base.js";
|
|
27
|
+
import { RouteLink } from "./RouteLink.js";
|
|
28
|
+
import { useRoute } from "./router.js";
|
|
24
29
|
import { ErrorPane } from "./ErrorPane.js";
|
|
25
30
|
/** Nav items the registry can't produce yet: shown, disabled, so the shape
|
|
26
31
|
* of the product is visible — the same set the design system's SideNav has. */
|
|
27
32
|
const PLANNED = ["Media", "Settings"];
|
|
28
|
-
function Nav({ registry, active, email, onSignOut }) {
|
|
29
|
-
const item = (href, label, key) => (_jsx(
|
|
33
|
+
export function Nav({ registry, active, email, onSignOut }) {
|
|
34
|
+
const item = (href, label, key) => href ? (_jsx(RouteLink, { className: "sm-nav-item", href: href, "aria-current": key === active ? "page" : undefined, children: label }, key)) : (_jsx("a", { className: "sm-nav-item", "aria-disabled": true, children: label }, key));
|
|
30
35
|
const name = email?.split("@")[0] ?? "Editor";
|
|
31
36
|
const initials = name.slice(0, 2).toUpperCase();
|
|
32
37
|
return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), registry.shared.length > 0 && item(`${BASE}/shared`, "Shared content", "shared"), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
|
|
33
38
|
}
|
|
34
|
-
export function AdminApp({ registry,
|
|
39
|
+
export function AdminApp({ registry, op, auth }) {
|
|
35
40
|
const session = useAdminSession(auth);
|
|
36
41
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
37
|
-
const
|
|
42
|
+
const route = useRoute();
|
|
43
|
+
const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
|
|
38
44
|
const isCollection = registry.collections.some((c) => c.name === head);
|
|
39
45
|
const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
|
|
40
46
|
if (session.state.status === "loading") {
|
|
@@ -57,5 +63,5 @@ export function AdminApp({ registry, segments, op, auth }) {
|
|
|
57
63
|
main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
58
64
|
else
|
|
59
65
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
60
|
-
return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));
|
|
66
|
+
return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }));
|
|
61
67
|
}
|
|
@@ -10,6 +10,7 @@ import { entryRows, entrySourceLocale } from "./entries-list.js";
|
|
|
10
10
|
import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
|
|
11
11
|
import { relativeTime } from "../ui/format.js";
|
|
12
12
|
import { BASE } from "./base.js";
|
|
13
|
+
import { navigate } from "./router.js";
|
|
13
14
|
import { ErrorPane } from "./ErrorPane.js";
|
|
14
15
|
const LOCALE_KEY = "smoodly.entries.locale";
|
|
15
16
|
/** The selected locale survives a reload within the session; a stored
|
|
@@ -49,7 +50,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
49
50
|
if (error)
|
|
50
51
|
return _jsx(ErrorPane, { message: error });
|
|
51
52
|
const open = (id) => {
|
|
52
|
-
|
|
53
|
+
navigate(`${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
|
|
53
54
|
};
|
|
54
55
|
const addLocale = async (row) => {
|
|
55
56
|
const record = records?.find((r) => r.id === row.id);
|
|
@@ -72,7 +73,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
72
73
|
open(row.id);
|
|
73
74
|
};
|
|
74
75
|
const rows = records ? entryRows(records, meta, registry, locale) : [];
|
|
75
|
-
return (_jsx(ListView, { title: meta.title, count: records?.length, actions: _jsxs(_Fragment, { children: [multilingual && _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }), _jsx(Button, { onClick: () => {
|
|
76
|
+
return (_jsx(ListView, { title: meta.title, count: records?.length, actions: _jsxs(_Fragment, { children: [multilingual && _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }), _jsx(Button, { onClick: () => { navigate(`${BASE}/${collection}/new?locale=${encodeURIComponent(locale)}`); }, children: "+ New entry" })] }), columns: [
|
|
76
77
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
77
78
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
78
79
|
{ label: "Status", width: "120px" },
|
|
@@ -15,29 +15,9 @@ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
|
15
15
|
import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
|
|
16
16
|
import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
|
|
17
17
|
import { BASE } from "./base.js";
|
|
18
|
+
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
19
|
+
import { navigate, useLeaveGuard } from "./router.js";
|
|
18
20
|
const STATUSES = ["Draft", "Published"];
|
|
19
|
-
/** The locale to edit: from the URL (the list links here with ?locale=), else the default. */
|
|
20
|
-
function localeFromUrl(registry) {
|
|
21
|
-
try {
|
|
22
|
-
const l = new URLSearchParams(window.location.search).get("locale");
|
|
23
|
-
if (l && registry.locales.supported.includes(l))
|
|
24
|
-
return l;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// no window — the default is fine
|
|
28
|
-
}
|
|
29
|
-
return registry.locales.default;
|
|
30
|
-
}
|
|
31
|
-
function writeLocaleToUrl(next) {
|
|
32
|
-
try {
|
|
33
|
-
const u = new URL(window.location.href);
|
|
34
|
-
u.searchParams.set("locale", next);
|
|
35
|
-
window.history.replaceState(null, "", u);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// the URL is cosmetic here
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
21
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
42
22
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
43
23
|
const isNew = id === "new";
|
|
@@ -52,6 +32,9 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
52
32
|
const [adding, setAdding] = useState(false);
|
|
53
33
|
const [reloadKey, setReloadKey] = useState(0);
|
|
54
34
|
const [dirty, setDirty] = useState(false);
|
|
35
|
+
// A nav click, the Back arrow, a list row: the router asks before
|
|
36
|
+
// leaving unsaved changes; reload and close ask through beforeunload.
|
|
37
|
+
useLeaveGuard(dirty);
|
|
55
38
|
const refTargets = useMemo(() => {
|
|
56
39
|
const out = {};
|
|
57
40
|
for (const [key, d] of Object.entries(meta?.fields ?? {})) {
|
|
@@ -123,7 +106,8 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
123
106
|
const r = await ops.entries.create(collection, { locale, fields });
|
|
124
107
|
if (applyResult(r, "Created.") && r.ok) {
|
|
125
108
|
setDirty(false);
|
|
126
|
-
|
|
109
|
+
// force: the create just landed, but React has not flushed setDirty(false) yet.
|
|
110
|
+
navigate(`${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`, { force: true });
|
|
127
111
|
}
|
|
128
112
|
}
|
|
129
113
|
else {
|
|
@@ -212,7 +196,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
212
196
|
return;
|
|
213
197
|
const r = await ops.entries.delete(collection, id);
|
|
214
198
|
if (r.ok)
|
|
215
|
-
|
|
199
|
+
navigate(`${BASE}/${collection}`, { force: true });
|
|
216
200
|
else
|
|
217
201
|
setBanner({ tone: "error", text: r.message });
|
|
218
202
|
};
|
|
@@ -223,7 +207,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
223
207
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
224
208
|
const shared = Object.entries(meta.fields).filter(([key]) => !localeKeys.includes(key));
|
|
225
209
|
const own = Object.entries(meta.fields).filter(([key]) => localeKeys.includes(key));
|
|
226
|
-
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key],
|
|
210
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
|
|
227
211
|
const absent = !isNew && record !== null && fields === null && !record.locales[locale];
|
|
228
212
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [{ label: meta.title, href: `${BASE}/${collection}` }, { label: heading }] }), multilingual && !isNew && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })), multilingual && isNew && _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), 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: () => switchLocale(entrySourceLocale(record, registry)), children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
|
|
229
213
|
display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px",
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
4
|
import { TopBar } from "../ui/primitives.js";
|
|
5
|
+
import { navigate } from "./router.js";
|
|
5
6
|
export function ListView({ title, count, actions, columns, rows, empty }) {
|
|
6
7
|
const grid = { gridTemplateColumns: columns.map((c) => c.width).join(" ") };
|
|
7
8
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), count !== undefined && _jsx("span", { className: "sm-count", children: count })] }), right: actions }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: "auto" }, children: _jsxs("div", { className: "sm-table", children: [_jsx("div", { className: "sm-table__head", style: grid, children: columns.map((c) => _jsx("span", { children: c.label }, c.label)) }), _jsxs("div", { className: "sm-table__body", children: [rows.map((r) => {
|
|
8
9
|
const clickable = Boolean(r.href || r.onClick);
|
|
9
|
-
const
|
|
10
|
+
const href = r.href;
|
|
11
|
+
const go = r.onClick ?? (href ? () => { navigate(href); } : undefined);
|
|
10
12
|
return (_jsx("div", { style: grid, role: clickable ? "button" : undefined, tabIndex: clickable ? 0 : undefined, className: clickable ? "sm-row sm-row--clickable" : "sm-row", onClick: go, onKeyDown: (e) => { if (go && (e.key === "Enter" || e.key === " ")) {
|
|
11
13
|
e.preventDefault();
|
|
12
14
|
go();
|
|
@@ -12,6 +12,7 @@ import { ListView } from "./ListView.js";
|
|
|
12
12
|
import { openTemplates, pagesTreeRows, sourceLocaleOf } from "./pages-tree.js";
|
|
13
13
|
import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
|
|
14
14
|
import { BASE } from "./base.js";
|
|
15
|
+
import { navigate } from "./router.js";
|
|
15
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
16
17
|
const LOCALE_KEY = "smoodly.pages.locale";
|
|
17
18
|
/** The selected locale survives a reload within the session; a stored
|
|
@@ -56,7 +57,7 @@ export function PagesList({ ops, registry }) {
|
|
|
56
57
|
next.add(id); return next; });
|
|
57
58
|
/** The editor opens in the locale selected here. */
|
|
58
59
|
const openEditor = (id) => {
|
|
59
|
-
|
|
60
|
+
navigate(`${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
|
|
60
61
|
};
|
|
61
62
|
/** Run an op; on success reload the list (and maybe navigate), else show the op's message. */
|
|
62
63
|
const run = async (work, then) => {
|
|
@@ -167,7 +168,7 @@ export function PagesList({ ops, registry }) {
|
|
|
167
168
|
if (!row.present)
|
|
168
169
|
addLocale(row);
|
|
169
170
|
else if (row.kind === "mount" && row.collection)
|
|
170
|
-
|
|
171
|
+
navigate(`${BASE}/${row.collection}`);
|
|
171
172
|
else if (row.template !== null)
|
|
172
173
|
openEditor(row.id);
|
|
173
174
|
else if (row.hasChildren)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { isPlainLeftClick, navigate } from "./router.js";
|
|
4
|
+
export function RouteLink({ href, onClick, ...rest }) {
|
|
5
|
+
return (_jsx("a", { href: href, ...rest, onClick: (e) => {
|
|
6
|
+
onClick?.(e);
|
|
7
|
+
if (isPlainLeftClick(e, rest.target)) {
|
|
8
|
+
e.preventDefault();
|
|
9
|
+
navigate(href);
|
|
10
|
+
}
|
|
11
|
+
} }));
|
|
12
|
+
}
|
|
@@ -15,28 +15,9 @@ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
|
15
15
|
import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
|
|
16
16
|
import { entryTitle } from "./entries-list.js";
|
|
17
17
|
import { BASE } from "./base.js";
|
|
18
|
+
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
19
|
+
import { useLeaveGuard } from "./router.js";
|
|
18
20
|
const STATUSES = ["Draft", "Published"];
|
|
19
|
-
function localeFromUrl(registry) {
|
|
20
|
-
try {
|
|
21
|
-
const l = new URLSearchParams(window.location.search).get("locale");
|
|
22
|
-
if (l && registry.locales.supported.includes(l))
|
|
23
|
-
return l;
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
// no window — the default is fine
|
|
27
|
-
}
|
|
28
|
-
return registry.locales.default;
|
|
29
|
-
}
|
|
30
|
-
function writeLocaleToUrl(next) {
|
|
31
|
-
try {
|
|
32
|
-
const u = new URL(window.location.href);
|
|
33
|
-
u.searchParams.set("locale", next);
|
|
34
|
-
window.history.replaceState(null, "", u);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// the URL is cosmetic here
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
21
|
export function SharedForm({ ops, registry, name }) {
|
|
41
22
|
const item = registry.shared.find((s) => s.name === name);
|
|
42
23
|
const multilingual = registry.locales.supported.length > 1;
|
|
@@ -48,6 +29,7 @@ export function SharedForm({ ops, registry, name }) {
|
|
|
48
29
|
const [banner, setBanner] = useState(null);
|
|
49
30
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
50
31
|
const [dirty, setDirty] = useState(false);
|
|
32
|
+
useLeaveGuard(dirty);
|
|
51
33
|
const refTargets = useMemo(() => {
|
|
52
34
|
const out = {};
|
|
53
35
|
for (const [key, d] of Object.entries(item?.fields ?? {})) {
|
|
@@ -135,7 +117,7 @@ export function SharedForm({ ops, registry, name }) {
|
|
|
135
117
|
const own = Object.entries(item.fields).filter(([key]) => localeKeys.includes(key));
|
|
136
118
|
const styles = (fields?.[STYLES_KEY] ?? {});
|
|
137
119
|
const pages = new Set(usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId));
|
|
138
|
-
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key],
|
|
120
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
|
|
139
121
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [{ label: "Shared content", href: `${BASE}/shared` }, { label: item.title }] }), multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined }))] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), 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: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] })), item.section !== undefined && Object.keys(item.styles).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), Object.entries(item.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], onChange: (value) => {
|
|
140
122
|
setFields((f) => ({ ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } }));
|
|
141
123
|
setDirty(true);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type Route = {
|
|
2
|
+
segments: string[];
|
|
3
|
+
search: string;
|
|
4
|
+
};
|
|
5
|
+
/** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
|
|
6
|
+
* the mount itself, or a pathname outside it, → []. Segments stay as
|
|
7
|
+
* written; the shell decodes the ones it uses, as it does for the
|
|
8
|
+
* catch-all's params. */
|
|
9
|
+
export declare function segmentsOf(pathname: string, base?: string): string[];
|
|
10
|
+
/** A click the browser would turn into a same-tab navigation: left
|
|
11
|
+
* button, no modifier, no other target, not already handled. */
|
|
12
|
+
export declare function isPlainLeftClick(e: {
|
|
13
|
+
button: number;
|
|
14
|
+
metaKey: boolean;
|
|
15
|
+
ctrlKey: boolean;
|
|
16
|
+
shiftKey: boolean;
|
|
17
|
+
altKey: boolean;
|
|
18
|
+
defaultPrevented: boolean;
|
|
19
|
+
}, target?: string | null): boolean;
|
|
20
|
+
export declare const LEAVE_MESSAGE = "Discard unsaved changes?";
|
|
21
|
+
/** Leaving is allowed when no guard is dirty, else when the editor
|
|
22
|
+
* confirms — asked once, however many guards are dirty. */
|
|
23
|
+
export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (message: string) => boolean): boolean;
|
|
24
|
+
/** Move to `href` in place; true when the move happened. A push asks the
|
|
25
|
+
* leave guard; a replace never leaves the view, so it does not ask.
|
|
26
|
+
* `force` skips the guard for a view that has just saved or deleted its
|
|
27
|
+
* record — React has not flushed its setDirty(false) yet. */
|
|
28
|
+
export declare function navigate(href: string, opts?: {
|
|
29
|
+
replace?: boolean;
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}): boolean;
|
|
32
|
+
/** The current route: empty until mount, then the URL, re-read on
|
|
33
|
+
* popstate and after navigate(). */
|
|
34
|
+
export declare function useRoute(): Route;
|
|
35
|
+
/** Register the calling view as dirty: navigate() asks before leaving,
|
|
36
|
+
* and beforeunload covers reload, closing the tab and a real link out. */
|
|
37
|
+
export declare function useLeaveGuard(dirty: boolean): void;
|