smoodly 0.0.24 → 0.0.26
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 +214 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +100 -22
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +1 -1
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +74 -24
- package/dist/admin/ops.d.ts +31 -12
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +42 -2
- package/dist/admin/shell/EntriesList.js +17 -22
- package/dist/admin/shell/EntryForm.js +91 -13
- package/dist/admin/shell/GlobalForm.js +68 -9
- package/dist/admin/shell/GlobalList.js +10 -20
- package/dist/admin/shell/IconRail.js +14 -5
- package/dist/admin/shell/InviteModal.js +9 -5
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.js +33 -15
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +101 -74
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SettingsView.js +11 -26
- package/dist/admin/shell/live-url.d.ts +4 -3
- package/dist/admin/shell/live-url.js +12 -3
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +2 -0
- package/dist/admin/shell/members-list.js +5 -0
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +12 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +373 -204
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +17 -0
- package/dist/sql.js +21 -0
- package/dist/store.d.ts +14 -8
- package/dist/store.js +30 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +18 -5
- package/dist/supabase-store.js +83 -20
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +13 -3
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/InviteModal.tsx
|
|
4
4
|
// Invite a member (spec 2026-09-25 members management §4): an email, a
|
|
5
|
-
// role, Send. The
|
|
6
|
-
//
|
|
5
|
+
// role, Send. The role is a radio list with a line under each option
|
|
6
|
+
// saying what it may do — the one place the difference is explained in
|
|
7
|
+
// the admin (the Roles page was dropped 2026-09-25). The op's error
|
|
8
|
+
// shows inside; success closes it and the page reports what happened.
|
|
7
9
|
import { useState } from "react";
|
|
8
10
|
import { MEMBER_ROLES } from "../../permissions.js";
|
|
9
11
|
import { Modal } from "../ui/Modal.js";
|
|
10
|
-
import { Banner, Button, Field
|
|
11
|
-
import { roleLabel } from "./members-list.js";
|
|
12
|
+
import { Banner, Button, Field } from "../ui/primitives.js";
|
|
13
|
+
import { ROLE_DESCRIPTION, roleLabel } from "./members-list.js";
|
|
12
14
|
export function InviteModal({ onClose, onInvite }) {
|
|
13
15
|
const [email, setEmail] = useState("");
|
|
14
16
|
const [role, setRole] = useState("editor");
|
|
@@ -20,5 +22,7 @@ export function InviteModal({ onClose, onInvite }) {
|
|
|
20
22
|
setError(await onInvite({ email: email.trim(), role }));
|
|
21
23
|
setBusy(false);
|
|
22
24
|
};
|
|
23
|
-
|
|
25
|
+
// Editor first: the default, and the one most invites want.
|
|
26
|
+
const roles = [...MEMBER_ROLES].reverse();
|
|
27
|
+
return (_jsx(Modal, { title: "Invite a member", width: 420, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", required: true, autoFocus: true, value: email, onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Role", asDiv: true, children: _jsx("div", { className: "sm-choices", role: "radiogroup", children: roles.map((r) => (_jsxs("label", { className: "sm-choice sm-choice--desc", children: [_jsx("input", { type: "radio", name: "role", value: r, checked: role === r, onChange: () => setRole(r) }), _jsxs("span", { children: [_jsx("span", { className: "sm-choice__label", children: roleLabel(r) }), _jsx("span", { className: "sm-choice__desc", children: ROLE_DESCRIPTION[r] })] })] }, r))) }) }), 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, children: busy ? "Sending…" : "Send invite" })] })] }) }));
|
|
24
28
|
}
|
|
@@ -6,10 +6,10 @@ import { navigate } from "./router.js";
|
|
|
6
6
|
export function ListView({ title, titleTrailing, actions, notice, columns, rows, empty, sortable }) {
|
|
7
7
|
const widths = sortable ? ["24px", ...columns.map((c) => c.width)] : columns.map((c) => c.width);
|
|
8
8
|
const grid = { gridTemplateColumns: widths.join(" ") };
|
|
9
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), notice, _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
|
|
9
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), notice && _jsx("div", { className: "sm-listnotice", children: notice }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
|
|
10
10
|
const clickable = Boolean(r.href || r.onClick);
|
|
11
11
|
const href = r.href;
|
|
12
|
-
const go = r.onClick ?? (href ? () => { navigate(href); } : undefined);
|
|
12
|
+
const go = r.onClick ?? (href ? () => { void navigate(href); } : undefined);
|
|
13
13
|
return (_jsxs("div", { style: { ...grid, ...sortable?.hook.rowStyle(i) }, role: clickable ? "button" : undefined, tabIndex: clickable ? 0 : undefined, className: cx("sm-row", clickable && "sm-row--clickable", sortable?.hook.rowClass(i)), onClick: go, onKeyDown: (e) => { if (go && (e.key === "Enter" || e.key === " ")) {
|
|
14
14
|
e.preventDefault();
|
|
15
15
|
go();
|
|
@@ -9,6 +9,7 @@ import { useAssets } from "../forms/assets-context.js";
|
|
|
9
9
|
import { uploadFile } from "../forms/upload.js";
|
|
10
10
|
import { MediaGrid } from "../forms/MediaGrid.js";
|
|
11
11
|
import { Banner, Button, Field, Segmented, TopBar } from "../ui/primitives.js";
|
|
12
|
+
import { confirm } from "../ui/confirm.js";
|
|
12
13
|
import { RouteLink } from "./RouteLink.js";
|
|
13
14
|
import { KIND_FILTERS, acceptFor, formatBytes, kindOfFilter, usageLabel } from "./media-list.js";
|
|
14
15
|
const META = ["title", "alt", "caption", "credit"];
|
|
@@ -70,7 +71,16 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
|
|
|
70
71
|
load();
|
|
71
72
|
};
|
|
72
73
|
const remove = async () => {
|
|
73
|
-
if (!selected
|
|
74
|
+
if (!selected)
|
|
75
|
+
return;
|
|
76
|
+
const ok = await confirm({
|
|
77
|
+
title: "Delete this file?",
|
|
78
|
+
body: "The file is removed from the media library and from storage. This cannot be undone.",
|
|
79
|
+
confirmLabel: "Delete file",
|
|
80
|
+
destructive: true,
|
|
81
|
+
subject: { name: selected.title ?? selected.filename },
|
|
82
|
+
});
|
|
83
|
+
if (!ok)
|
|
74
84
|
return;
|
|
75
85
|
const r = await assets.ops.delete(selected.id);
|
|
76
86
|
if (!r.ok) {
|
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/MembersPage.tsx
|
|
4
4
|
// The Members page (spec 2026-09-25 §7; management §4): the admission
|
|
5
|
-
// table with Invite,
|
|
6
|
-
// the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// table with Invite, the role as a badge and a row menu with Change role
|
|
6
|
+
// (to the other role, after a confirm) and Remove member (2026-09-26; the
|
|
7
|
+
// in-place pills before) — all off when the control plane manages the
|
|
8
|
+
// space (the console URL banner stays). Own row and the last admin render
|
|
9
|
+
// their menu disabled with the guard's reason, so the rule is visible
|
|
10
|
+
// before it is hit.
|
|
9
11
|
import { useEffect, useState } from "react";
|
|
10
|
-
import { MEMBER_ROLES } from "../../permissions.js";
|
|
11
12
|
import { ActionMenu } from "../ui/ActionMenu.js";
|
|
12
|
-
import { Banner, Button,
|
|
13
|
+
import { Banner, Button, StatusBadge } from "../ui/primitives.js";
|
|
14
|
+
import { confirm } from "../ui/confirm.js";
|
|
13
15
|
import { ErrorPane } from "./ErrorPane.js";
|
|
14
16
|
import { InviteModal } from "./InviteModal.js";
|
|
15
17
|
import { ListView } from "./ListView.js";
|
|
16
|
-
import { GUARD_TITLE, memberRows, roleLabel } from "./members-list.js";
|
|
18
|
+
import { GUARD_TITLE, ROLE_DESCRIPTION, memberRows, roleLabel } from "./members-list.js";
|
|
17
19
|
export function MembersPage({ ops, userId, initial = null }) {
|
|
18
20
|
const [data, setData] = useState(initial);
|
|
19
21
|
const [error, setError] = useState(null);
|
|
@@ -38,8 +40,16 @@ export function MembersPage({ ops, userId, initial = null }) {
|
|
|
38
40
|
void load();
|
|
39
41
|
return null;
|
|
40
42
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/** Two roles: the change is to the other one, confirmed with what it may do. */
|
|
44
|
+
const changeRole = async (row) => {
|
|
45
|
+
const role = row.value === "admin" ? "editor" : "admin";
|
|
46
|
+
const ok = await confirm({
|
|
47
|
+
title: `Change role to ${roleLabel(role).toLowerCase()}?`,
|
|
48
|
+
body: `${roleLabel(role)}: ${ROLE_DESCRIPTION[role]}`,
|
|
49
|
+
confirmLabel: "Change role",
|
|
50
|
+
subject: { name: row.email },
|
|
51
|
+
});
|
|
52
|
+
if (!ok)
|
|
43
53
|
return;
|
|
44
54
|
const r = await ops.members.setRole(row.key, role);
|
|
45
55
|
if (!r.ok) {
|
|
@@ -49,7 +59,14 @@ export function MembersPage({ ops, userId, initial = null }) {
|
|
|
49
59
|
setData((d) => d && { ...d, members: d.members.map((m) => (m.userId === row.key ? r.data : m)) });
|
|
50
60
|
};
|
|
51
61
|
const remove = async (row) => {
|
|
52
|
-
|
|
62
|
+
const ok = await confirm({
|
|
63
|
+
title: "Remove this member?",
|
|
64
|
+
body: "They keep their account and lose access to this site's admin. You can invite them again later.",
|
|
65
|
+
confirmLabel: "Remove member",
|
|
66
|
+
destructive: true,
|
|
67
|
+
subject: { name: row.email },
|
|
68
|
+
});
|
|
69
|
+
if (!ok)
|
|
53
70
|
return;
|
|
54
71
|
const r = await ops.members.remove(row.key);
|
|
55
72
|
if (!r.ok) {
|
|
@@ -65,18 +82,19 @@ export function MembersPage({ ops, userId, initial = null }) {
|
|
|
65
82
|
const guardTitle = (r) => (r.own ? GUARD_TITLE.own : r.lastAdmin ? GUARD_TITLE.lastAdmin : undefined);
|
|
66
83
|
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Members", actions: !managed && data && _jsx(Button, { onClick: () => setInviting(true), children: "Invite" }), notice: _jsxs(_Fragment, { children: [data?.consoleUrl && (_jsxs(Banner, { tone: "ok", children: ["Members are managed at smoodly.io.", " ", _jsx("a", { href: data.consoleUrl, target: "_blank", rel: "noreferrer", children: "Open the console" })] })), notice && _jsx(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: notice.text })] }), columns: [
|
|
67
84
|
{ label: "Email", width: "minmax(200px,1fr)" },
|
|
68
|
-
{ label: "Role", width:
|
|
85
|
+
{ label: "Role", width: "120px" },
|
|
69
86
|
...(managed ? [] : [{ label: "", width: "48px" }]),
|
|
70
87
|
], rows: rows.map((r) => ({
|
|
71
88
|
key: r.key,
|
|
72
89
|
cells: [
|
|
73
90
|
r.fallback ? _jsx("span", { style: { color: "var(--text-muted)" }, children: r.email }) : r.email,
|
|
74
|
-
|
|
75
|
-
? _jsx(StatusBadge, { status: r.role })
|
|
76
|
-
: _jsx(Segmented, { options: MEMBER_ROLES, value: r.value, onChange: (v) => void setRole(r, v), label: roleLabel, disabled: r.own || r.lastAdmin, option: () => (guardTitle(r) ? { title: guardTitle(r) } : undefined) }),
|
|
91
|
+
_jsx(StatusBadge, { status: r.role }),
|
|
77
92
|
...(managed ? [] : [
|
|
78
93
|
// ActionMenu has no title of its own when disabled: the span carries the guard's reason.
|
|
79
|
-
_jsx("span", { title: guardTitle(r), children: _jsx(ActionMenu, { label: "Member actions", small: true, disabled: r.own || r.lastAdmin, items: [
|
|
94
|
+
_jsx("span", { title: guardTitle(r), children: _jsx(ActionMenu, { label: "Member actions", small: true, disabled: r.own || r.lastAdmin, items: [
|
|
95
|
+
{ label: "Change role", run: () => void changeRole(r) },
|
|
96
|
+
{ label: "Remove member", danger: true, run: () => void remove(r) },
|
|
97
|
+
] }) }),
|
|
80
98
|
]),
|
|
81
99
|
],
|
|
82
100
|
})), empty: "No members yet" }), inviting && _jsx(InviteModal, { onClose: () => setInviting(false), onInvite: invite })] }));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { AdminPageTemplate } from "../serialize.ts";
|
|
3
|
+
export type PageFormValues = {
|
|
4
|
+
title: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
template: string | null;
|
|
7
|
+
};
|
|
8
|
+
export declare function PageFormDialog({ title: heading, submitLabel, fields, templates, parentPath, initial, onClose, onSubmit }: {
|
|
9
|
+
/** The dialog's heading ("New page", "Rename page"). */
|
|
10
|
+
title: string;
|
|
11
|
+
submitLabel: string;
|
|
12
|
+
/** Which fields the dialog shows. */
|
|
13
|
+
fields: {
|
|
14
|
+
title?: boolean;
|
|
15
|
+
slug?: boolean;
|
|
16
|
+
template?: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** The open page registrations, for the page-type select (shown when `fields.template` and there is more than one). */
|
|
19
|
+
templates?: AdminPageTemplate[];
|
|
20
|
+
/** The parent's path, shown before the slug ("/company/"); the root's is "/". */
|
|
21
|
+
parentPath?: string;
|
|
22
|
+
initial?: Partial<PageFormValues>;
|
|
23
|
+
onClose(): void;
|
|
24
|
+
/** Resolves to an error message, or null when the op landed (the caller closes). */
|
|
25
|
+
onSubmit(values: PageFormValues): Promise<string | null>;
|
|
26
|
+
}): ReactElement;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The Pages list's form dialogs on the dialog card (ui/Dialog.tsx), in
|
|
4
|
+
// place of window.prompt (2026-09-25): New page / New path / New subpage
|
|
5
|
+
// / New subpath (a title, a slug that follows the title until edited, the
|
|
6
|
+
// page type when more than one open registration exists), Add page here
|
|
7
|
+
// (the page type alone) and Rename (title and slug; the home page's title
|
|
8
|
+
// alone, its slug being the config's). Enter submits; the op's error
|
|
9
|
+
// shows inside; success closes it from the caller.
|
|
10
|
+
import { useId, useState } from "react";
|
|
11
|
+
import { DialogCard } from "../ui/Dialog.js";
|
|
12
|
+
import { Banner, Button, Field } from "../ui/primitives.js";
|
|
13
|
+
import { followsTitle, slugify } from "./page-dialogs.js";
|
|
14
|
+
export function PageFormDialog({ title: heading, submitLabel, fields, templates = [], parentPath, initial, onClose, onSubmit }) {
|
|
15
|
+
const [values, setValues] = useState({
|
|
16
|
+
title: initial?.title ?? "",
|
|
17
|
+
slug: initial?.slug ?? "",
|
|
18
|
+
template: initial?.template ?? (templates[0]?.name ?? null),
|
|
19
|
+
});
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
const chooseTemplate = fields.template && templates.length > 1;
|
|
23
|
+
const prefix = parentPath === undefined ? undefined : parentPath === "/" ? "/" : `${parentPath}/`;
|
|
24
|
+
const setTitle = (title) => setValues((v) => ({ ...v, title, slug: fields.slug && followsTitle(v.slug, v.title) ? slugify(title) : v.slug }));
|
|
25
|
+
const submit = async (e) => {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
if (busy)
|
|
28
|
+
return;
|
|
29
|
+
setError(null);
|
|
30
|
+
setBusy(true);
|
|
31
|
+
setError(await onSubmit({ ...values, title: values.title.trim(), slug: values.slug.trim() }));
|
|
32
|
+
setBusy(false);
|
|
33
|
+
};
|
|
34
|
+
const formId = `sm-page-form${useId()}`;
|
|
35
|
+
return (_jsx(DialogCard, { title: heading, onClose: onClose, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", form: formId, className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Saving…" : submitLabel })] }), children: _jsxs("form", { id: formId, className: "sm-dialog__form", onSubmit: submit, children: [fields.title && (_jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", required: true, autoFocus: true, value: values.title, onChange: (e) => setTitle(e.target.value) }) })), fields.slug && (_jsx(Field, { label: "Slug", hint: "The URL segment: lowercase letters, digits and hyphens.", children: prefix
|
|
36
|
+
? (_jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), _jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })) })] }))
|
|
37
|
+
: (_jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })) })) })), chooseTemplate && (_jsx(Field, { label: "Page type", children: _jsx("select", { className: "sm-select", autoFocus: !fields.title && !fields.slug, value: values.template ?? "", onChange: (e) => setValues((v) => ({ ...v, template: e.target.value })), children: templates.map((t) => _jsx("option", { value: t.name, children: t.title }, t.name)) }) })), error && _jsx(Banner, { tone: "error", children: error })] }) }));
|
|
38
|
+
}
|
|
@@ -17,35 +17,26 @@ import { useSortable } from "../ui/useSortable.js";
|
|
|
17
17
|
import { useAdminHref } from "./base.js";
|
|
18
18
|
import { navigate } from "./router.js";
|
|
19
19
|
import { ErrorPane } from "./ErrorPane.js";
|
|
20
|
+
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
20
21
|
import { confirmLiveUrl } from "./live-url.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const stored = window.sessionStorage.getItem(LOCALE_KEY);
|
|
27
|
-
if (stored && registry.locales.supported.includes(stored))
|
|
28
|
-
return stored;
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
// storage unavailable (private mode, no window) — the default is fine
|
|
32
|
-
}
|
|
33
|
-
return registry.locales.default;
|
|
34
|
-
}
|
|
22
|
+
import { confirm } from "../ui/confirm.js";
|
|
23
|
+
import { PageFormDialog } from "./PageDialogs.js";
|
|
24
|
+
import { usePresenceState } from "./presence-context.js";
|
|
25
|
+
import { editorsOf } from "./presence.js";
|
|
26
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
35
27
|
export function PagesList({ ops, registry }) {
|
|
28
|
+
const { others } = usePresenceState();
|
|
36
29
|
const adminHref = useAdminHref();
|
|
37
30
|
const [pages, setPages] = useState(null);
|
|
38
31
|
const [error, setError] = useState(null);
|
|
39
32
|
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
40
33
|
const [menu, setMenu] = useState(null);
|
|
41
|
-
const [
|
|
34
|
+
const [dialog, setDialog] = useState(null);
|
|
35
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
42
36
|
const multilingual = registry.locales.supported.length > 1;
|
|
43
37
|
const pickLocale = (next) => {
|
|
44
38
|
setLocale(next);
|
|
45
|
-
|
|
46
|
-
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
47
|
-
}
|
|
48
|
-
catch { /* see rememberedLocale */ }
|
|
39
|
+
rememberLocale(next);
|
|
49
40
|
};
|
|
50
41
|
const load = useCallback(() => ops.pages.list()
|
|
51
42
|
.then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
|
|
@@ -77,7 +68,7 @@ export function PagesList({ ops, registry }) {
|
|
|
77
68
|
return;
|
|
78
69
|
// A published page under a new parent is a new live URL (members management §5).
|
|
79
70
|
const current = row.record.locales[locale];
|
|
80
|
-
if (!confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }))
|
|
71
|
+
if (!(await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path })))
|
|
81
72
|
return;
|
|
82
73
|
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index }));
|
|
83
74
|
if (!ok)
|
|
@@ -86,10 +77,10 @@ export function PagesList({ ops, registry }) {
|
|
|
86
77
|
});
|
|
87
78
|
/** The editor opens in the locale selected here. */
|
|
88
79
|
const openEditor = (id) => {
|
|
89
|
-
navigate(adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
80
|
+
void navigate(adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
90
81
|
};
|
|
91
|
-
/** Run an op; on success reload the list (and maybe navigate)
|
|
92
|
-
const
|
|
82
|
+
/** Run an op; on success reload the list (and maybe navigate). Resolves to the op's message, or null when it landed. */
|
|
83
|
+
const attempt = async (work, then) => {
|
|
93
84
|
let result;
|
|
94
85
|
try {
|
|
95
86
|
result = await work();
|
|
@@ -97,74 +88,107 @@ export function PagesList({ ops, registry }) {
|
|
|
97
88
|
catch {
|
|
98
89
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
99
90
|
}
|
|
100
|
-
if (!result.ok)
|
|
101
|
-
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
91
|
+
if (!result.ok)
|
|
92
|
+
return result.message;
|
|
104
93
|
then?.(result.data);
|
|
105
94
|
await load();
|
|
106
|
-
return
|
|
95
|
+
return null;
|
|
107
96
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
return
|
|
97
|
+
/** `attempt` for an action outside a dialog: the message goes to an alert. Resolves to whether it succeeded. */
|
|
98
|
+
const run = async (work, then) => {
|
|
99
|
+
const message = await attempt(work, then);
|
|
100
|
+
if (message)
|
|
101
|
+
window.alert(message);
|
|
102
|
+
return message === null;
|
|
114
103
|
};
|
|
115
|
-
/** New page and New path (a folder in the tree) create in the SELECTED locale (spec §6)
|
|
104
|
+
/** New page and New path (a folder in the tree) create in the SELECTED locale (spec §6),
|
|
105
|
+
* Add page here gives a folder a template, Rename edits the title and slug — all in a dialog (PageDialogs). */
|
|
116
106
|
const newNode = (parentId, kind) => {
|
|
117
107
|
if (kind === "page" && noOpen)
|
|
118
108
|
return;
|
|
119
|
-
|
|
120
|
-
if (!slug)
|
|
121
|
-
return;
|
|
122
|
-
const template = kind === "page" ? pickTemplate() : null;
|
|
123
|
-
if (kind === "page" && !template)
|
|
124
|
-
return;
|
|
125
|
-
void run(() => ops.pages.create({ parentId, locale, slug, template }), (page) => { if (template)
|
|
126
|
-
openEditor(page.id); });
|
|
109
|
+
setDialog({ kind: "new", parentId, node: kind });
|
|
127
110
|
};
|
|
128
111
|
const addPageHere = (row) => {
|
|
129
|
-
|
|
130
|
-
if (!template)
|
|
112
|
+
if (noOpen)
|
|
131
113
|
return;
|
|
132
|
-
|
|
114
|
+
if (creatable.length === 1) {
|
|
115
|
+
void run(() => ops.pages.setTemplate(row.id, creatable[0].name), (page) => openEditor(page.id));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
setDialog({ kind: "template", row });
|
|
119
|
+
};
|
|
120
|
+
const rename = (row) => { if (row.record.locales[locale])
|
|
121
|
+
setDialog({ kind: "rename", row }); };
|
|
122
|
+
const closeDialog = () => setDialog(null);
|
|
123
|
+
/** The parent's path from a row's own: "/company/about-us" → "/company", "/about" → "/". */
|
|
124
|
+
const parentPathOf = (path) => {
|
|
125
|
+
if (!path)
|
|
126
|
+
return "/";
|
|
127
|
+
const cut = path.lastIndexOf("/");
|
|
128
|
+
return cut <= 0 ? "/" : path.slice(0, cut);
|
|
129
|
+
};
|
|
130
|
+
/** What the open dialog submits. Resolves to the op's message, or null and the caller closes. */
|
|
131
|
+
const submitDialog = async (values) => {
|
|
132
|
+
if (!dialog)
|
|
133
|
+
return null;
|
|
134
|
+
if (dialog.kind === "new") {
|
|
135
|
+
const template = dialog.node === "page" ? values.template : null;
|
|
136
|
+
if (dialog.node === "page" && !template)
|
|
137
|
+
return "Choose a page type.";
|
|
138
|
+
return attempt(() => ops.pages.create({ parentId: dialog.parentId, locale, slug: values.slug, title: values.title, template }), (page) => { if (template)
|
|
139
|
+
openEditor(page.id); });
|
|
140
|
+
}
|
|
141
|
+
if (dialog.kind === "template") {
|
|
142
|
+
if (!values.template)
|
|
143
|
+
return "Choose a page type.";
|
|
144
|
+
return attempt(() => ops.pages.setTemplate(dialog.row.id, values.template), (page) => openEditor(page.id));
|
|
145
|
+
}
|
|
146
|
+
const { row } = dialog;
|
|
147
|
+
const current = row.record.locales[locale];
|
|
148
|
+
if (!current)
|
|
149
|
+
return null;
|
|
150
|
+
// The site root's slug is the config's homePageSlug — title only there.
|
|
151
|
+
if (row.isHome)
|
|
152
|
+
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title }));
|
|
153
|
+
if (!(await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path })))
|
|
154
|
+
return null;
|
|
155
|
+
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug }));
|
|
133
156
|
};
|
|
134
|
-
const addLocale = (row) => {
|
|
157
|
+
const addLocale = async (row) => {
|
|
135
158
|
const from = sourceLocaleOf(row.record, registry);
|
|
136
|
-
|
|
159
|
+
const ok = await confirm({
|
|
160
|
+
title: `Add this page to ${locale}?`,
|
|
161
|
+
body: `Its ${from} content is copied as the first ${locale} draft. Nothing is published until you publish it.`,
|
|
162
|
+
confirmLabel: `Add to ${locale}`,
|
|
163
|
+
subject: { name: row.title, path: row.record.locales[from] ? row.path : null },
|
|
164
|
+
});
|
|
165
|
+
if (!ok)
|
|
137
166
|
return;
|
|
138
167
|
void run(() => ops.pages.addLocale(row.id, locale, { from }), (page) => { if (page.template !== null)
|
|
139
168
|
openEditor(page.id); });
|
|
140
169
|
};
|
|
141
|
-
const removeLocale = (row) => {
|
|
142
|
-
|
|
170
|
+
const removeLocale = async (row) => {
|
|
171
|
+
const ok = await confirm({
|
|
172
|
+
title: `Remove this page from ${locale}?`,
|
|
173
|
+
body: `Its ${locale} content and history are deleted. Other languages are not affected.`,
|
|
174
|
+
confirmLabel: `Remove from ${locale}`,
|
|
175
|
+
destructive: true,
|
|
176
|
+
subject: { name: row.title, path: row.path },
|
|
177
|
+
});
|
|
178
|
+
if (!ok)
|
|
143
179
|
return;
|
|
144
180
|
void run(() => ops.pages.removeLocale(row.id, locale));
|
|
145
181
|
};
|
|
146
|
-
const
|
|
147
|
-
const current = row.record.locales[locale];
|
|
148
|
-
if (!current)
|
|
149
|
-
return;
|
|
150
|
-
const title = window.prompt("Title:", current.title);
|
|
151
|
-
if (title === null)
|
|
152
|
-
return;
|
|
153
|
-
// The site root's slug is the config's homePageSlug — title only here.
|
|
154
|
-
if (row.isHome) {
|
|
155
|
-
void run(() => ops.pages.rename(row.id, { locale, title }));
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
const slug = window.prompt("Slug (the URL segment):", current.slug);
|
|
159
|
-
if (slug === null)
|
|
160
|
-
return;
|
|
161
|
-
if (!confirmLiveUrl({ status: current.status, from: current.slug, to: slug }))
|
|
162
|
-
return;
|
|
163
|
-
void run(() => ops.pages.rename(row.id, { locale, title, slug }));
|
|
164
|
-
};
|
|
165
|
-
const destroy = (row) => {
|
|
182
|
+
const destroy = async (row) => {
|
|
166
183
|
const scope = row.locales.length > 1 ? ` in every language (${row.locales.join(", ")})` : "";
|
|
167
|
-
|
|
184
|
+
const ok = await confirm({
|
|
185
|
+
title: row.hasChildren ? "Delete this page and everything under it?" : "Delete this page?",
|
|
186
|
+
body: `The page${scope} and its history are removed. Links pointing to it stop working. This cannot be undone.`,
|
|
187
|
+
confirmLabel: "Delete page",
|
|
188
|
+
destructive: true,
|
|
189
|
+
subject: { name: row.title, path: row.path },
|
|
190
|
+
});
|
|
191
|
+
if (!ok)
|
|
168
192
|
return;
|
|
169
193
|
void run(() => ops.pages.delete(row.id));
|
|
170
194
|
};
|
|
@@ -200,7 +224,7 @@ export function PagesList({ ops, registry }) {
|
|
|
200
224
|
else if (row.template !== null)
|
|
201
225
|
openEditor(row.id);
|
|
202
226
|
else if (row.kind === "mount" && row.collection)
|
|
203
|
-
navigate(adminHref(`/${row.collection}`));
|
|
227
|
+
void navigate(adminHref(`/${row.collection}`));
|
|
204
228
|
else if (row.hasChildren)
|
|
205
229
|
toggle(row.id);
|
|
206
230
|
};
|
|
@@ -223,7 +247,7 @@ export function PagesList({ ops, registry }) {
|
|
|
223
247
|
key: row.id,
|
|
224
248
|
onClick: () => onRow(row),
|
|
225
249
|
cells: [
|
|
226
|
-
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, 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"),
|
|
250
|
+
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, 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 }), _jsx(LockIcon, { names: editorsOf(others, { kind: "page", id: row.id, locale }) })] }, "t"),
|
|
227
251
|
row.present
|
|
228
252
|
? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
|
|
229
253
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
|
|
@@ -238,5 +262,8 @@ export function PagesList({ ops, registry }) {
|
|
|
238
262
|
position: "absolute",
|
|
239
263
|
left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
|
|
240
264
|
top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
|
|
241
|
-
}, 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." }))] }) }))
|
|
265
|
+
}, 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." }))] }) })), dialog?.kind === "new" && (_jsx(PageFormDialog, { title: dialog.node === "page" ? (dialog.parentId ? "New subpage" : "New page") : (dialog.parentId ? "New subpath" : "New path"), submitLabel: dialog.node === "page" ? "Create page" : "Create path", fields: { title: true, slug: true, template: dialog.node === "page" }, templates: creatable, parentPath: dialog.parentId ? (rows.find((r) => r.id === dialog.parentId)?.path ?? "/") : "/", onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
|
|
266
|
+
closeDialog(); return message; }) })), dialog?.kind === "template" && (_jsx(PageFormDialog, { title: "Add page here", submitLabel: "Add page", fields: { template: true }, templates: creatable, onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
|
|
267
|
+
closeDialog(); return message; }) })), dialog?.kind === "rename" && (_jsx(PageFormDialog, { title: "Rename page", submitLabel: "Rename", fields: { title: true, slug: !dialog.row.isHome }, initial: { title: dialog.row.record.locales[locale]?.title ?? "", slug: dialog.row.record.locales[locale]?.slug ?? "" }, parentPath: dialog.row.isHome ? undefined : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
|
|
268
|
+
closeDialog(); return message; }) }))] }));
|
|
242
269
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export function SchemaNotice({ status }) {
|
|
3
|
+
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", padding: 24, color: "var(--text)" }, children: _jsxs("div", { style: { maxWidth: 520, display: "grid", gap: 12 }, children: [_jsx("h1", { className: "sm-title", style: { fontSize: 22, margin: 0 }, children: "The database needs an update" }), _jsxs("p", { style: { margin: 0 }, children: ["The database schema is at version ", status.database ?? "none", ". This version of Smoodly needs version ", status.required, "."] }), _jsx("p", { style: { margin: 0 }, children: "From the site's folder:" }), _jsx("pre", { className: "sm-code", children: _jsx("code", { children: "npx smoodly migrate" }) }), _jsx("p", { style: { margin: 0 }, children: "then apply it:" }), _jsx("pre", { className: "sm-code", children: _jsx("code", { children: "supabase db push" }) }), _jsxs("p", { style: { margin: 0, color: "var(--text-muted)" }, children: ["That is for a hosted project; for the local stack run ", _jsx("code", { children: "supabase db reset" }), ". Reload this page afterwards."] })] }) }));
|
|
4
|
+
}
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/SettingsView.tsx
|
|
4
4
|
// The Settings area (spec 2026-09-25 §5): a 200px secondary nav at the
|
|
5
|
-
// left of the main column,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// left of the main column, its card starting where the page's card starts (no heading since 2026-09-26) —
|
|
6
|
+
// then the page with its own top bar. The head segment alone replaces
|
|
7
|
+
// itself with the default page, so Back skips the empty URL. An unknown
|
|
8
|
+
// page is the shell's Not found pane. Admins only (members management
|
|
9
|
+
// §2): an editor gets the error pane, and until the role is known the
|
|
10
|
+
// area is loading. Members is the only page (the Roles table moved into
|
|
11
|
+
// the invite modal's role descriptions; API Keys never had contents).
|
|
10
12
|
import { useEffect } from "react";
|
|
11
13
|
import { can } from "../../permissions.js";
|
|
12
|
-
import { TopBar } from "../ui/primitives.js";
|
|
13
14
|
import { useAdminHref } from "./base.js";
|
|
14
15
|
import { ErrorPane } from "./ErrorPane.js";
|
|
15
16
|
import { MembersPage } from "./MembersPage.js";
|
|
16
17
|
import { RouteLink } from "./RouteLink.js";
|
|
17
18
|
import { navigate } from "./router.js";
|
|
18
|
-
import { DEFAULT_SETTINGS_PAGE, SETTINGS_PAGES, settingsPage
|
|
19
|
+
import { DEFAULT_SETTINGS_PAGE, SETTINGS_PAGES, settingsPage } from "./settings.js";
|
|
19
20
|
export function SettingsView({ ops, tail, role, userId }) {
|
|
20
21
|
const adminHref = useAdminHref();
|
|
21
22
|
const page = settingsPage(tail);
|
|
22
23
|
useEffect(() => {
|
|
23
24
|
if (tail === undefined)
|
|
24
|
-
navigate(adminHref(`/settings/${DEFAULT_SETTINGS_PAGE}`), { replace: true });
|
|
25
|
+
void navigate(adminHref(`/settings/${DEFAULT_SETTINGS_PAGE}`), { replace: true });
|
|
25
26
|
}, [tail, adminHref]);
|
|
26
27
|
if (role === null)
|
|
27
28
|
return _jsx("div", { className: "sm-empty", children: "Loading\u2026" });
|
|
@@ -29,21 +30,5 @@ export function SettingsView({ ops, tail, role, userId }) {
|
|
|
29
30
|
return _jsx(ErrorPane, { message: "Settings are for admins." });
|
|
30
31
|
if (!page)
|
|
31
32
|
return _jsx(ErrorPane, { message: "Not found." });
|
|
32
|
-
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: SETTINGS_PAGES.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children:
|
|
33
|
-
? _jsx(MembersPage, { ops: ops, userId: userId })
|
|
34
|
-
: page === "roles"
|
|
35
|
-
? _jsx(RolesPage, {})
|
|
36
|
-
: _jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: settingsTitle(page) }) }), _jsx("div", { className: "sm-listwrap" })] }) })] }));
|
|
37
|
-
}
|
|
38
|
-
// The two roles, read-only (members management §1): what each may do.
|
|
39
|
-
// A row per capability group, not per Capability — the table explains,
|
|
40
|
-
// permissions.ts decides.
|
|
41
|
-
const ROLE_ROWS = [
|
|
42
|
-
["Edit, publish, unpublish and delete content; upload and delete media", true, true],
|
|
43
|
-
["Open Settings and every page under it", true, false],
|
|
44
|
-
["Invite, re-role and remove members", true, false],
|
|
45
|
-
];
|
|
46
|
-
function RolesPage() {
|
|
47
|
-
const grid = { gridTemplateColumns: "minmax(240px,1fr) 100px 100px" };
|
|
48
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Roles" }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [_jsx("span", { children: "What a role may do" }), _jsx("span", { children: "Admin" }), _jsx("span", { children: "Editor" })] }), _jsx("div", { className: "sm-table__body", children: ROLE_ROWS.map(([what, admin, editor]) => (_jsxs("div", { className: "sm-row", style: grid, children: [_jsx("span", { className: "sm-cell", children: what }), _jsx("span", { className: "sm-cell", children: admin ? "yes" : "no" }), _jsx("span", { className: "sm-cell", children: editor ? "yes" : "no" })] }, what))) })] }) })] }));
|
|
33
|
+
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: SETTINGS_PAGES.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: _jsx(MembersPage, { ops: ops, userId: userId }) })] }));
|
|
49
34
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
import { type ConfirmOptions, type ConfirmSubject } from "../ui/confirm.ts";
|
|
2
|
+
export declare const LIVE_URL_CONFIRM: Omit<ConfirmOptions, "subject">;
|
|
2
3
|
/** `from` null = there was no URL part to change (a new row, a collection without a path). */
|
|
3
4
|
export declare function liveUrlChanges(input: {
|
|
4
5
|
status: "draft" | "published" | undefined;
|
|
5
6
|
from: string | null;
|
|
6
7
|
to: string | null;
|
|
7
8
|
}): boolean;
|
|
8
|
-
/**
|
|
9
|
-
export declare
|
|
9
|
+
/** Resolves true when the change may go ahead: not a live change, or confirmed. */
|
|
10
|
+
export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject): Promise<boolean>;
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
// The one confirm on anything that changes a live URL (spec 2026-09-25
|
|
2
2
|
// members management §5): a published page's or entry's slug, or a
|
|
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
|
+
// the record and the URL part that is about to change.
|
|
6
|
+
import { confirm } from "../ui/confirm.js";
|
|
7
|
+
export const LIVE_URL_CONFIRM = {
|
|
8
|
+
title: "Change the live URL?",
|
|
9
|
+
body: "This is published. Old links to it stop working until a redirect exists, and Smoodly has none yet.",
|
|
10
|
+
confirmLabel: "Change URL",
|
|
11
|
+
};
|
|
5
12
|
/** `from` null = there was no URL part to change (a new row, a collection without a path). */
|
|
6
13
|
export function liveUrlChanges(input) {
|
|
7
14
|
return input.status === "published" && input.from !== null && input.to !== input.from;
|
|
8
15
|
}
|
|
9
|
-
/**
|
|
10
|
-
export
|
|
16
|
+
/** Resolves true when the change may go ahead: not a live change, or confirmed. */
|
|
17
|
+
export async function confirmLiveUrl(input, subject) {
|
|
18
|
+
return !liveUrlChanges(input) || confirm({ ...LIVE_URL_CONFIRM, subject });
|
|
19
|
+
}
|