smoodly 0.0.4 → 0.0.6
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 +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- package/dist/admin/shell/pages-list.js +0 -31
|
@@ -4,75 +4,31 @@ 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
6
|
// Internal routing = the catch-all's segments; plain <a> navigation.
|
|
7
|
+
//
|
|
8
|
+
// Layout is the design system's three columns: a 200px nav, then the main
|
|
9
|
+
// column whose views own their own 48px top bar (the editor adds the third,
|
|
10
|
+
// its 300px inspector). The shell is viewport-height; every column scrolls
|
|
11
|
+
// inside itself.
|
|
7
12
|
import { useEffect, useState } from "react";
|
|
8
13
|
import { makeClientOps } from "../client-ops.js";
|
|
9
|
-
import { ListView
|
|
10
|
-
import {
|
|
11
|
-
import { pagePath } from "../page-path.js";
|
|
14
|
+
import { ListView } from "./ListView.js";
|
|
15
|
+
import { PagesList } from "./PagesList.js";
|
|
12
16
|
import { EntryForm } from "./EntryForm.js";
|
|
13
17
|
import { EditorView } from "../editor/EditorView.js";
|
|
14
18
|
import { LoginView } from "./LoginView.js";
|
|
15
19
|
import { useAdminSession } from "./session.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
ops.pages.list()
|
|
30
|
-
.then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
|
|
31
|
-
.catch(() => setError("Couldn't reach the server."));
|
|
32
|
-
}, [ops]);
|
|
33
|
-
const rows = pages ? pagesRows(pages, registry.pages) : [];
|
|
34
|
-
const creatable = openTemplates(registry.pages);
|
|
35
|
-
const noOpen = creatable.length === 0;
|
|
36
|
-
/** Create a page, then open it in the editor — or surface the op's message. */
|
|
37
|
-
const createAndOpen = async (slug, template) => {
|
|
38
|
-
const result = await ops.pages.create({ slug, template });
|
|
39
|
-
if (result.ok)
|
|
40
|
-
window.location.href = `${BASE}/pages/${encodeURIComponent(slug)}`;
|
|
41
|
-
else
|
|
42
|
-
window.alert(result.message);
|
|
43
|
-
};
|
|
44
|
-
const newPage = async () => {
|
|
45
|
-
if (noOpen)
|
|
46
|
-
return;
|
|
47
|
-
const slug = window.prompt("Slug for the new page (e.g. about):");
|
|
48
|
-
if (!slug)
|
|
49
|
-
return;
|
|
50
|
-
const template = creatable.length === 1
|
|
51
|
-
? creatable[0].name
|
|
52
|
-
: window.prompt(`Template? (${creatable.map((p) => p.name).join(", ")})`) ?? "";
|
|
53
|
-
await createAndOpen(slug, template);
|
|
54
|
-
};
|
|
55
|
-
if (error)
|
|
56
|
-
return _jsx("p", { style: { padding: 32, color: "#b91c1c" }, children: error });
|
|
57
|
-
return (_jsx(ListView, { title: "Pages", actions: _jsx("button", { onClick: newPage, disabled: noOpen, title: noOpen ? "Every page registration is fixed to its own route file — add an open one (a page schema with no `slug`) to create pages here." : undefined, style: { background: "#6d4aff", color: "#fff", border: 0, borderRadius: 9, padding: "9px 16px", fontWeight: 600, cursor: noOpen ? "not-allowed" : "pointer", opacity: noOpen ? 0.5 : 1 }, children: "New page" }), columns: ["Slug", "Template", "Status"], rows: rows.map((row) => row.kind === "missing"
|
|
58
|
-
? {
|
|
59
|
-
key: `missing:${row.slug}`,
|
|
60
|
-
onClick: () => createAndOpen(row.slug, row.template),
|
|
61
|
-
cells: [
|
|
62
|
-
_jsx("strong", { children: pagePath(row.slug, registry.homePageSlug) }, "s"),
|
|
63
|
-
row.templateTitle,
|
|
64
|
-
_jsx("em", { style: { color: "#6b7280" }, children: "not created" }, "c"),
|
|
65
|
-
],
|
|
66
|
-
}
|
|
67
|
-
: {
|
|
68
|
-
key: row.record.id,
|
|
69
|
-
href: `${BASE}/pages/${encodeURIComponent(row.record.slug)}`,
|
|
70
|
-
cells: [
|
|
71
|
-
_jsx("strong", { children: pagePath(row.record.slug, registry.homePageSlug) }, "s"),
|
|
72
|
-
row.templateTitle,
|
|
73
|
-
_jsx(StatusChip, { status: row.record.publishedVersionId ? (row.record.publishedVersionId === row.record.draftVersionId ? "published" : "draft edits") : "draft" }, "c"),
|
|
74
|
-
],
|
|
75
|
-
}) }));
|
|
20
|
+
import { Button, Logo, StatusBadge, ThemeToggle } from "../ui/primitives.js";
|
|
21
|
+
import { relativeTime } from "../ui/format.js";
|
|
22
|
+
import { BASE } from "./base.js";
|
|
23
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
24
|
+
/** Nav items the registry can't produce yet: shown, disabled, so the shape
|
|
25
|
+
* of the product is visible — the same set the design system's SideNav has. */
|
|
26
|
+
const PLANNED = ["Shared Content", "Media", "Settings"];
|
|
27
|
+
function Nav({ registry, active, email, onSignOut }) {
|
|
28
|
+
const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
|
|
29
|
+
const name = email?.split("@")[0] ?? "Editor";
|
|
30
|
+
const initials = name.slice(0, 2).toUpperCase();
|
|
31
|
+
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)), 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" })] })] }));
|
|
76
32
|
}
|
|
77
33
|
function EntriesList({ ops, registry, collection }) {
|
|
78
34
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
@@ -84,17 +40,21 @@ function EntriesList({ ops, registry, collection }) {
|
|
|
84
40
|
.catch(() => setError("Couldn't reach the server."));
|
|
85
41
|
}, [ops, collection]);
|
|
86
42
|
if (!meta)
|
|
87
|
-
return _jsx(
|
|
43
|
+
return _jsx(ErrorPane, { message: "No such collection." });
|
|
88
44
|
if (error)
|
|
89
|
-
return _jsx(
|
|
45
|
+
return _jsx(ErrorPane, { message: error });
|
|
90
46
|
const title = (e) => String((meta.titleField && e.fields[meta.titleField]) ?? Object.values(e.fields).find((v) => typeof v === "string") ?? e.id);
|
|
91
|
-
return (_jsx(ListView, { title: meta.title, actions: _jsx(
|
|
47
|
+
return (_jsx(ListView, { title: meta.title, count: rows?.length, actions: _jsx(Button, { onClick: () => { window.location.href = `${BASE}/${collection}/new`; }, children: "+ New entry" }), columns: [
|
|
48
|
+
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
49
|
+
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
50
|
+
{ label: "Status", width: "120px" },
|
|
51
|
+
], empty: rows ? "Nothing here yet." : "Loading…", rows: (rows ?? []).map((e) => ({
|
|
92
52
|
key: e.id,
|
|
93
53
|
href: `${BASE}/${collection}/${encodeURIComponent(e.id)}`,
|
|
94
54
|
cells: [
|
|
95
|
-
_jsx("
|
|
96
|
-
_jsx(
|
|
97
|
-
|
|
55
|
+
_jsx("span", { className: "sm-cell sm-cell--title", children: title(e) }, "t"),
|
|
56
|
+
_jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(e.updatedAt) }, "u"),
|
|
57
|
+
_jsx(StatusBadge, { status: e.status }, "c"),
|
|
98
58
|
],
|
|
99
59
|
})) }));
|
|
100
60
|
}
|
|
@@ -103,20 +63,22 @@ export function AdminApp({ registry, segments, op, auth }) {
|
|
|
103
63
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
104
64
|
const [head = "pages", tail] = segments.length ? segments : ["pages"];
|
|
105
65
|
const isCollection = registry.collections.some((c) => c.name === head);
|
|
106
|
-
|
|
107
|
-
|
|
66
|
+
const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
|
|
67
|
+
if (session.state.status === "loading") {
|
|
68
|
+
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
69
|
+
}
|
|
108
70
|
if (session.state.status === "signed_out")
|
|
109
71
|
return _jsx(LoginView, { client: session.client, providers: auth.providers });
|
|
110
72
|
let main;
|
|
111
73
|
if (head === "pages" && !tail)
|
|
112
74
|
main = _jsx(PagesList, { ops: ops, registry: registry });
|
|
113
75
|
else if (head === "pages" && tail)
|
|
114
|
-
main = _jsx(EditorView, { ops: ops, registry: registry,
|
|
76
|
+
main = _jsx(EditorView, { ops: ops, registry: registry, id: decodeURIComponent(tail) });
|
|
115
77
|
else if (isCollection && !tail)
|
|
116
78
|
main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
|
|
117
79
|
else if (isCollection && tail)
|
|
118
80
|
main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
|
|
119
81
|
else
|
|
120
|
-
main =
|
|
121
|
-
return (_jsxs("div", {
|
|
82
|
+
main = _jsx(ErrorPane, { message: "Not found." });
|
|
83
|
+
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 })] }));
|
|
122
84
|
}
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
// packages/smoodly/src/admin/shell/EntryForm.tsx
|
|
2
2
|
"use client";
|
|
3
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
4
4
|
// The classic field view: same FieldWidget renderer as the editor sidebar,
|
|
5
5
|
// no tabs (collections are fields-only by design). The safe-delete guard's
|
|
6
6
|
// message renders verbatim — it is product surface, not an error detail.
|
|
7
|
+
//
|
|
8
|
+
// Layout follows the design system's article-edit screen: top bar with the
|
|
9
|
+
// breadcrumb and the save action, a roomy field column, and a 300px sidebar
|
|
10
|
+
// holding status and document info.
|
|
7
11
|
import { useEffect, useMemo, useState } from "react";
|
|
8
12
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
9
|
-
import {
|
|
13
|
+
import { relativeTime } from "../ui/format.js";
|
|
14
|
+
import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar, } from "../ui/primitives.js";
|
|
15
|
+
const STATUSES = ["Draft", "Published"];
|
|
10
16
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
11
17
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
12
18
|
const isNew = id === "new";
|
|
13
19
|
const [fields, setFields] = useState({});
|
|
14
20
|
const [status, setStatus] = useState("draft");
|
|
21
|
+
const [stamps, setStamps] = useState(null);
|
|
15
22
|
const [refOptions, setRefOptions] = useState({});
|
|
16
23
|
const [banner, setBanner] = useState(null);
|
|
17
24
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
@@ -31,6 +38,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
31
38
|
setFields(r.data.fields);
|
|
32
39
|
setStatus(r.data.status);
|
|
33
40
|
setLoaded(true);
|
|
41
|
+
setStamps({ createdAt: r.data.createdAt, updatedAt: r.data.updatedAt });
|
|
34
42
|
}
|
|
35
43
|
else
|
|
36
44
|
setBanner({ tone: "error", text: r.message });
|
|
@@ -70,11 +78,14 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
70
78
|
}
|
|
71
79
|
}
|
|
72
80
|
else {
|
|
73
|
-
|
|
81
|
+
const r = await ops.entries.save(collection, id, fields);
|
|
82
|
+
if (applyResult(r, "Saved.") && r.ok)
|
|
83
|
+
setStamps({ createdAt: r.data.createdAt, updatedAt: r.data.updatedAt });
|
|
74
84
|
}
|
|
75
85
|
};
|
|
76
|
-
const
|
|
77
|
-
|
|
86
|
+
const setPublished = async (next) => {
|
|
87
|
+
if (next === status)
|
|
88
|
+
return;
|
|
78
89
|
const r = await ops.entries.setStatus(collection, id, next);
|
|
79
90
|
if (applyResult(r, next === "published" ? "Published." : "Unpublished."))
|
|
80
91
|
setStatus(next);
|
|
@@ -88,9 +99,22 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
88
99
|
else
|
|
89
100
|
setBanner({ tone: "error", text: r.message });
|
|
90
101
|
};
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
const titleValue = meta.titleField ? fields[meta.titleField] : undefined;
|
|
103
|
+
const heading = isNew ? "New entry" : (typeof titleValue === "string" && titleValue) || id;
|
|
104
|
+
// The mount's path in the default locale prefixes the slug (spec §7);
|
|
105
|
+
// a collection without a path has no URLs and shows no prefix.
|
|
106
|
+
const base = meta.path?.[registry.locales.default];
|
|
107
|
+
const slugPrefix = base ? `/${base}/` : undefined;
|
|
108
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: `/admin/${collection}`, items: [{ label: meta.title, href: `/admin/${collection}` }, { label: heading }] }), right: _jsx(Button, { onClick: save, disabled: !loaded, 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 }), loaded
|
|
109
|
+
? Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => setFields((f) => ({ ...f, [key]: value })) }, key)))
|
|
110
|
+
: !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
|
|
111
|
+
display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px",
|
|
112
|
+
minHeight: 0, overflow: "auto", borderLeft: "var(--border)",
|
|
113
|
+
}, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Status" }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: isNew || !loaded, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } }), isNew && _jsx("span", { className: "sm-hint", children: "Create the entry first \u2014 it starts as a draft." })] }), !isNew && stamps && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(stamps.createdAt) }), _jsx(InfoRow, { label: "Updated", value: relativeTime(stamps.updatedAt) }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })] })), !isNew && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsx(Button, { variant: "danger", disabled: !loaded, onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
114
|
+
}
|
|
115
|
+
function InfoRow({ label, value, mono }) {
|
|
116
|
+
return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: {
|
|
117
|
+
color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined,
|
|
118
|
+
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
119
|
+
}, children: value })] }));
|
|
96
120
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// packages/smoodly/src/admin/shell/ErrorPane.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { Banner } from "../ui/primitives.js";
|
|
5
|
+
import { BASE } from "./base.js";
|
|
6
|
+
/** A whole-column failure: nothing to show but the message and a way back. */
|
|
7
|
+
export function ErrorPane({ message }) {
|
|
8
|
+
return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), _jsx("a", { href: `${BASE}/pages`, style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
|
|
9
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
export
|
|
2
|
+
export type ListColumn = {
|
|
3
|
+
label: string;
|
|
4
|
+
width: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function ListView({ title, count, actions, columns, rows, empty }: {
|
|
3
7
|
title: string;
|
|
8
|
+
count?: number;
|
|
4
9
|
actions?: ReactNode;
|
|
5
|
-
columns:
|
|
10
|
+
columns: ListColumn[];
|
|
6
11
|
rows: {
|
|
7
12
|
key: string;
|
|
8
13
|
href?: string;
|
|
9
14
|
onClick?: () => void;
|
|
10
15
|
cells: ReactNode[];
|
|
11
16
|
}[];
|
|
12
|
-
|
|
13
|
-
export declare function StatusChip({ status }: {
|
|
14
|
-
status: string;
|
|
17
|
+
empty?: ReactNode;
|
|
15
18
|
}): import("react").JSX.Element;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// packages/smoodly/src/admin/shell/ListView.tsx
|
|
2
2
|
"use client";
|
|
3
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { TopBar } from "../ui/primitives.js";
|
|
5
|
+
export function ListView({ title, count, actions, columns, rows, empty }) {
|
|
6
|
+
const grid = { gridTemplateColumns: columns.map((c) => c.width).join(" ") };
|
|
7
|
+
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
|
+
const clickable = Boolean(r.href || r.onClick);
|
|
9
|
+
const go = r.onClick ?? (r.href ? () => { window.location.href = r.href; } : undefined);
|
|
10
|
+
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
|
+
e.preventDefault();
|
|
12
|
+
go();
|
|
13
|
+
} }, children: r.cells.map((cell, i) => _jsx("span", { className: "sm-cell", children: cell }, i)) }, r.key));
|
|
14
|
+
}), rows.length === 0 && _jsx("div", { className: "sm-empty", children: empty ?? "Nothing here yet." })] })] }) })] }));
|
|
14
15
|
}
|
|
@@ -5,6 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
// Supabase Auth verbatim. Being an editor is checked by the server on
|
|
6
6
|
// the first op — a signed-in non-editor sees that message in the shell.
|
|
7
7
|
import { useState } from "react";
|
|
8
|
+
import { Banner, Button, Field, Logo } from "../ui/primitives.js";
|
|
8
9
|
const LABELS = { google: "Continue with Google", github: "Continue with GitHub" };
|
|
9
10
|
export function LoginView({ client, providers = [] }) {
|
|
10
11
|
const [email, setEmail] = useState("");
|
|
@@ -42,6 +43,9 @@ export function LoginView({ client, providers = [] }) {
|
|
|
42
43
|
setBusy(false);
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: _jsxs("form", { onSubmit: submit, style: {
|
|
47
|
+
width: 320, display: "flex", flexDirection: "column", gap: 14,
|
|
48
|
+
background: "var(--surface)", padding: 24, borderRadius: "var(--radius-md)",
|
|
49
|
+
border: "var(--border)", boxShadow: "var(--shadow-page)",
|
|
50
|
+
}, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", value: password, required: true, autoComplete: "current-password", onChange: (e) => setPassword(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => oauth(p), children: LABELS[p] }, p)))] }) }));
|
|
47
51
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// packages/smoodly/src/admin/shell/PagesList.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
+
// The Pages list: the tree of the design system's TreeTable, plus the
|
|
5
|
+
// row menu (New subpage/subfolder, Add page here, Rename, Move, Delete)
|
|
6
|
+
// as a floating popover positioned from the triggering click.
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { ListView } from "./ListView.js";
|
|
9
|
+
import { openTemplates, pagesTreeRows } from "./pages-tree.js";
|
|
10
|
+
import { Button, StatusBadge } from "../ui/primitives.js";
|
|
11
|
+
import { BASE } from "./base.js";
|
|
12
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
13
|
+
export function PagesList({ ops, registry }) {
|
|
14
|
+
const [pages, setPages] = useState(null);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
17
|
+
const [menu, setMenu] = useState(null);
|
|
18
|
+
const locale = registry.locales.default;
|
|
19
|
+
const load = useCallback(() => {
|
|
20
|
+
ops.pages.list()
|
|
21
|
+
.then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
|
|
22
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
23
|
+
}, [ops]);
|
|
24
|
+
useEffect(load, [load]);
|
|
25
|
+
const rows = pages ? pagesTreeRows(pages, registry, locale, collapsed) : [];
|
|
26
|
+
const creatable = openTemplates(registry.pages);
|
|
27
|
+
const noOpen = creatable.length === 0;
|
|
28
|
+
const toggle = (id) => setCollapsed((c) => { const next = new Set(c); if (next.has(id))
|
|
29
|
+
next.delete(id);
|
|
30
|
+
else
|
|
31
|
+
next.add(id); return next; });
|
|
32
|
+
const openEditor = (id) => { window.location.href = `${BASE}/pages/${encodeURIComponent(id)}`; };
|
|
33
|
+
/** Run an op; on success reload the list (and maybe navigate), else show the op's message. */
|
|
34
|
+
const run = async (work, then) => {
|
|
35
|
+
let result;
|
|
36
|
+
try {
|
|
37
|
+
result = await work();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
41
|
+
}
|
|
42
|
+
if (!result.ok) {
|
|
43
|
+
window.alert(result.message);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
then?.(result.data);
|
|
47
|
+
load();
|
|
48
|
+
};
|
|
49
|
+
const pickTemplate = () => {
|
|
50
|
+
if (noOpen)
|
|
51
|
+
return null;
|
|
52
|
+
if (creatable.length === 1)
|
|
53
|
+
return creatable[0].name;
|
|
54
|
+
return window.prompt(`Template? (${creatable.map((p) => p.name).join(", ")})`) || null;
|
|
55
|
+
};
|
|
56
|
+
const newNode = (parentId, kind) => {
|
|
57
|
+
if (kind === "page" && noOpen)
|
|
58
|
+
return;
|
|
59
|
+
const slug = window.prompt(`Slug for the new ${kind} (e.g. about):`);
|
|
60
|
+
if (!slug)
|
|
61
|
+
return;
|
|
62
|
+
const template = kind === "page" ? pickTemplate() : null;
|
|
63
|
+
if (kind === "page" && !template)
|
|
64
|
+
return;
|
|
65
|
+
void run(() => ops.pages.create({ parentId, slug, template }), (page) => { if (template)
|
|
66
|
+
openEditor(page.id); });
|
|
67
|
+
};
|
|
68
|
+
const addPageHere = (row) => {
|
|
69
|
+
const template = pickTemplate();
|
|
70
|
+
if (!template)
|
|
71
|
+
return;
|
|
72
|
+
void run(() => ops.pages.setTemplate(row.id, template), (page) => openEditor(page.id));
|
|
73
|
+
};
|
|
74
|
+
const rename = (row) => {
|
|
75
|
+
const title = window.prompt("Title:", row.record.title);
|
|
76
|
+
if (title === null)
|
|
77
|
+
return;
|
|
78
|
+
// The site root's slug is the config's homePageSlug — title only here.
|
|
79
|
+
if (row.isHome) {
|
|
80
|
+
void run(() => ops.pages.rename(row.id, { locale, title }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const slug = window.prompt("Slug (the URL segment):", row.record.slug);
|
|
84
|
+
if (slug === null)
|
|
85
|
+
return;
|
|
86
|
+
void run(() => ops.pages.rename(row.id, { locale, title, slug }));
|
|
87
|
+
};
|
|
88
|
+
const move = (row, dir) => void run(() => ops.pages.move(row.id, { parentId: row.parentId, index: row.index + dir }));
|
|
89
|
+
const destroy = (row) => {
|
|
90
|
+
if (!window.confirm(`Delete "${row.title}"?`))
|
|
91
|
+
return;
|
|
92
|
+
void run(() => ops.pages.delete(row.id));
|
|
93
|
+
};
|
|
94
|
+
const menuItems = (row) => {
|
|
95
|
+
const items = [];
|
|
96
|
+
if (row.kind === "mount" && row.template !== null)
|
|
97
|
+
items.push({ label: "Edit index page", run: () => openEditor(row.id) });
|
|
98
|
+
if (row.canHaveChildren && !noOpen)
|
|
99
|
+
items.push({ label: "New subpage", run: () => newNode(row.id, "page") });
|
|
100
|
+
if (row.canHaveChildren)
|
|
101
|
+
items.push({ label: "New subfolder", run: () => newNode(row.id, "folder") });
|
|
102
|
+
if (row.kind === "folder" && !noOpen)
|
|
103
|
+
items.push({ label: "Add page here", run: () => addPageHere(row) });
|
|
104
|
+
if (!row.fixed)
|
|
105
|
+
items.push({ label: "Rename…", run: () => rename(row) });
|
|
106
|
+
if (!row.fixed && !row.isHome && row.index > 0)
|
|
107
|
+
items.push({ label: "Move up", run: () => move(row, -1) });
|
|
108
|
+
if (!row.fixed && !row.isHome && row.index < row.siblingCount - 1)
|
|
109
|
+
items.push({ label: "Move down", run: () => move(row, 1) });
|
|
110
|
+
if (!row.fixed && !row.isHome && !row.hasChildren)
|
|
111
|
+
items.push({ label: "Delete", run: () => destroy(row) });
|
|
112
|
+
return items;
|
|
113
|
+
};
|
|
114
|
+
const onRow = (row) => {
|
|
115
|
+
if (row.kind === "mount" && row.collection)
|
|
116
|
+
window.location.href = `${BASE}/${row.collection}`;
|
|
117
|
+
else if (row.template !== null)
|
|
118
|
+
openEditor(row.id);
|
|
119
|
+
else if (row.hasChildren)
|
|
120
|
+
toggle(row.id);
|
|
121
|
+
};
|
|
122
|
+
if (error)
|
|
123
|
+
return _jsx(ErrorPane, { message: error });
|
|
124
|
+
const items = menu ? menuItems(menu.row) : [];
|
|
125
|
+
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Pages", count: pages?.length, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: () => newNode(null, "folder"), children: "+ New folder" }), _jsx(Button, { onClick: () => newNode(null, "page"), disabled: noOpen, title: noOpen ? "Every page registration is fixed — add an open one (a page schema with no `slug`) to create pages here." : undefined, children: "+ New page" })] }), columns: [
|
|
126
|
+
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
127
|
+
{ label: "Path", width: "minmax(140px,260px)" },
|
|
128
|
+
{ label: "Template", width: "minmax(100px,160px)" },
|
|
129
|
+
{ label: "Status", width: "110px" },
|
|
130
|
+
{ label: "", width: "32px" },
|
|
131
|
+
], empty: pages ? "No pages yet." : "Loading…", rows: rows.map((row) => ({
|
|
132
|
+
key: row.id,
|
|
133
|
+
onClick: () => onRow(row),
|
|
134
|
+
cells: [
|
|
135
|
+
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: row.depth * 20 }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title })] }, "t"),
|
|
136
|
+
_jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p"),
|
|
137
|
+
_jsx("span", { className: "sm-cell sm-cell--muted", children: row.templateTitle }, "k"),
|
|
138
|
+
row.status
|
|
139
|
+
? _jsx(StatusBadge, { status: row.status }, "s")
|
|
140
|
+
: _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
|
|
141
|
+
_jsx("button", { type: "button", className: "sm-rowmenu", "aria-label": "Row menu", onClick: (e) => { e.stopPropagation(); setMenu({ row, x: e.clientX, y: e.clientY }); }, onKeyDown: (e) => e.stopPropagation(), children: "\u00B7\u00B7\u00B7" }, "m"),
|
|
142
|
+
],
|
|
143
|
+
})) }), menu && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setMenu(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
|
|
144
|
+
position: "absolute",
|
|
145
|
+
left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
|
|
146
|
+
top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
|
|
147
|
+
}, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "Owned by code \u2014 nothing to change here." }))] }) }))] }));
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const BASE = "/admin";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PageRecord } from "../../store.ts";
|
|
2
|
+
import type { AdminCollection, AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
3
|
+
/** Registrations editors may create pages against: fixed ones are
|
|
4
|
+
* materialized by Smoodly, never offered. */
|
|
5
|
+
export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
|
|
6
|
+
/** The collection a ROOT record is the mount of, if any — known from the
|
|
7
|
+
* registry, never from a column (spec 2026-09-05 §4). */
|
|
8
|
+
export declare function mountOf(record: {
|
|
9
|
+
parentId: string | null;
|
|
10
|
+
slug: string;
|
|
11
|
+
}, registry: AdminRegistry): AdminCollection | null;
|
|
12
|
+
/** Fixed pages and mounts: never renamed, moved or deleted by editors. */
|
|
13
|
+
export declare function isFixedNode(record: {
|
|
14
|
+
parentId: string | null;
|
|
15
|
+
slug: string;
|
|
16
|
+
}, registry: AdminRegistry): boolean;
|
|
17
|
+
export type PagesTreeRow = {
|
|
18
|
+
id: string;
|
|
19
|
+
parentId: string | null;
|
|
20
|
+
depth: number;
|
|
21
|
+
/** Position among the siblings, and how many there are — for Move up/down. */
|
|
22
|
+
index: number;
|
|
23
|
+
siblingCount: number;
|
|
24
|
+
hasChildren: boolean;
|
|
25
|
+
expanded: boolean;
|
|
26
|
+
title: string;
|
|
27
|
+
path: string;
|
|
28
|
+
kind: "page" | "folder" | "mount";
|
|
29
|
+
collection: string | null;
|
|
30
|
+
fixed: boolean;
|
|
31
|
+
/** The record serving "/" — never moved or deleted, and its default-locale slug is the config's. */
|
|
32
|
+
isHome: boolean;
|
|
33
|
+
template: string | null;
|
|
34
|
+
templateTitle: string;
|
|
35
|
+
status: "published" | "draft edits" | "draft" | null;
|
|
36
|
+
canHaveChildren: boolean;
|
|
37
|
+
record: PageRecord;
|
|
38
|
+
};
|
|
39
|
+
export declare function pagesTreeRows(records: PageRecord[], registry: AdminRegistry, locale: string, collapsed: ReadonlySet<string>): PagesTreeRow[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure view-model for the Pages list — the tree of the design system's
|
|
2
|
+
// TreeTable (indent per depth, chevron on parents, the derived path as
|
|
3
|
+
// the slug column). The shell is not DOM-tested; every decision that
|
|
4
|
+
// matters lives here.
|
|
5
|
+
import { titleFor } from "../../localize.js";
|
|
6
|
+
import { buildPageTree } from "../../paths.js";
|
|
7
|
+
/** Registrations editors may create pages against: fixed ones are
|
|
8
|
+
* materialized by Smoodly, never offered. */
|
|
9
|
+
export function openTemplates(templates) {
|
|
10
|
+
return templates.filter((t) => t.slug === undefined);
|
|
11
|
+
}
|
|
12
|
+
/** The collection a ROOT record is the mount of, if any — known from the
|
|
13
|
+
* registry, never from a column (spec 2026-09-05 §4). */
|
|
14
|
+
export function mountOf(record, registry) {
|
|
15
|
+
if (record.parentId !== null)
|
|
16
|
+
return null;
|
|
17
|
+
return registry.collections.find((c) => c.path?.[registry.locales.default] === record.slug) ?? null;
|
|
18
|
+
}
|
|
19
|
+
/** Fixed pages and mounts: never renamed, moved or deleted by editors. */
|
|
20
|
+
export function isFixedNode(record, registry) {
|
|
21
|
+
return record.parentId === null && (registry.pages.some((t) => t.slug === record.slug) || mountOf(record, registry) !== null);
|
|
22
|
+
}
|
|
23
|
+
export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
24
|
+
const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
|
|
25
|
+
const templateTitle = (name) => registry.pages.find((t) => t.name === name)?.title ?? name;
|
|
26
|
+
const rows = [];
|
|
27
|
+
const walk = (nodes, depth, parentId) => {
|
|
28
|
+
nodes.forEach((node, index) => {
|
|
29
|
+
const r = node.record;
|
|
30
|
+
const mount = mountOf(r, registry);
|
|
31
|
+
const isHome = r.parentId === null && r.slug === registry.homePageSlug;
|
|
32
|
+
const expanded = !collapsed.has(r.id);
|
|
33
|
+
rows.push({
|
|
34
|
+
id: r.id,
|
|
35
|
+
parentId,
|
|
36
|
+
depth,
|
|
37
|
+
index,
|
|
38
|
+
siblingCount: nodes.length,
|
|
39
|
+
hasChildren: node.children.length > 0,
|
|
40
|
+
expanded,
|
|
41
|
+
title: titleFor(r, locale, registry.locales),
|
|
42
|
+
path: node.path,
|
|
43
|
+
kind: mount ? "mount" : r.template === null ? "folder" : "page",
|
|
44
|
+
collection: mount?.name ?? null,
|
|
45
|
+
fixed: isFixedNode(r, registry),
|
|
46
|
+
isHome,
|
|
47
|
+
template: r.template,
|
|
48
|
+
templateTitle: mount
|
|
49
|
+
? r.template === null ? "Collection" : `Collection · ${templateTitle(r.template)}`
|
|
50
|
+
: r.template === null ? "Folder" : templateTitle(r.template),
|
|
51
|
+
status: r.template === null
|
|
52
|
+
? null
|
|
53
|
+
: r.publishedVersionId
|
|
54
|
+
? r.publishedVersionId === r.draftVersionId ? "published" : "draft edits"
|
|
55
|
+
: "draft",
|
|
56
|
+
canHaveChildren: !mount && !isHome && depth + 1 < registry.pageDepth,
|
|
57
|
+
record: r,
|
|
58
|
+
});
|
|
59
|
+
if (expanded)
|
|
60
|
+
walk(node.children, depth + 1, r.id);
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
walk(buildPageTree(records, locale, opts), 0, null);
|
|
64
|
+
return rows;
|
|
65
|
+
}
|