smoodly 0.0.11 → 0.0.13
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 +259 -38
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +130 -25
- package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
- package/dist/admin/editor/FloatingToolbar.js +14 -0
- package/dist/admin/editor/InlineRichtext.d.ts +9 -0
- package/dist/admin/editor/InlineRichtext.js +50 -0
- package/dist/admin/editor/Outline.d.ts +16 -10
- package/dist/admin/editor/Outline.js +13 -10
- package/dist/admin/editor/inline-dom.d.ts +27 -0
- package/dist/admin/editor/inline-dom.js +43 -0
- package/dist/admin/editor/inline-session.d.ts +30 -0
- package/dist/admin/editor/inline-session.js +19 -0
- package/dist/admin/editor/protocol.d.ts +22 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
- package/dist/admin/editor/useCanvasBridge.js +12 -7
- package/dist/admin/editor/useInlineSession.d.ts +46 -0
- package/dist/admin/editor/useInlineSession.js +244 -0
- package/dist/admin/field-paths.d.ts +13 -0
- package/dist/admin/field-paths.js +49 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -2
- package/dist/admin/forms/FieldWidget.js +12 -40
- package/dist/admin/forms/ListField.d.ts +10 -0
- package/dist/admin/forms/ListField.js +42 -0
- package/dist/admin/forms/MediaWidget.js +10 -3
- package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
- package/dist/admin/forms/RichtextElementCard.js +37 -0
- package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
- package/dist/admin/forms/RichtextImageCard.js +42 -0
- package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
- package/dist/admin/forms/RichtextInsertMenu.js +6 -0
- package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
- package/dist/admin/forms/RichtextPopovers.js +25 -0
- package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
- package/dist/admin/forms/RichtextToolbar.js +4 -2
- package/dist/admin/forms/RichtextWidget.d.ts +4 -1
- package/dist/admin/forms/RichtextWidget.js +23 -120
- package/dist/admin/forms/elements-context.d.ts +7 -0
- package/dist/admin/forms/elements-context.js +13 -0
- package/dist/admin/forms/reveal.d.ts +5 -0
- package/dist/admin/forms/reveal.js +47 -0
- package/dist/admin/forms/richtext-extensions.d.ts +8 -0
- package/dist/admin/forms/richtext-extensions.js +11 -0
- package/dist/admin/forms/richtext-form-context.d.ts +16 -0
- package/dist/admin/forms/richtext-form-context.js +18 -0
- package/dist/admin/forms/richtext-insert.d.ts +38 -0
- package/dist/admin/forms/richtext-insert.js +37 -0
- package/dist/admin/forms/richtext-nodes.d.ts +37 -0
- package/dist/admin/forms/richtext-nodes.js +85 -0
- package/dist/admin/forms/richtext-paste.js +5 -0
- package/dist/admin/forms/tabs.d.ts +3 -0
- package/dist/admin/forms/tabs.js +6 -0
- package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
- package/dist/admin/forms/useRichtextEditor.js +281 -0
- package/dist/admin/next/bridge.js +51 -20
- package/dist/admin/ops-impl.js +13 -8
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +7 -0
- package/dist/admin/serialize.js +16 -1
- package/dist/admin/shell/AdminApp.js +2 -1
- package/dist/admin/shell/EntriesList.js +34 -12
- package/dist/admin/shell/EntryForm.js +22 -15
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/ListView.d.ts +9 -2
- package/dist/admin/shell/ListView.js +7 -6
- package/dist/admin/shell/LoginView.js +2 -2
- package/dist/admin/shell/PagesList.js +53 -24
- package/dist/admin/shell/SharedForm.js +17 -11
- package/dist/admin/shell/SharedList.js +5 -3
- package/dist/admin/shell/entries-list.d.ts +4 -0
- package/dist/admin/shell/entries-list.js +6 -0
- package/dist/admin/shell/pages-tree.d.ts +24 -0
- package/dist/admin/shell/pages-tree.js +59 -1
- package/dist/admin/tree-ops.d.ts +9 -1
- package/dist/admin/tree-ops.js +27 -0
- package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
- package/dist/admin/ui/LocaleSwitcher.js +28 -8
- package/dist/admin/ui/Modal.js +14 -2
- package/dist/admin/ui/ResizeHandle.d.ts +1 -0
- package/dist/admin/ui/ResizeHandle.js +127 -0
- package/dist/admin/ui/primitives.d.ts +29 -4
- package/dist/admin/ui/primitives.js +29 -10
- package/dist/admin/ui/resize.d.ts +16 -0
- package/dist/admin/ui/resize.js +23 -0
- package/dist/admin/ui/sortable.d.ts +23 -0
- package/dist/admin/ui/sortable.js +57 -0
- package/dist/admin/ui/theme.d.ts +3 -1
- package/dist/admin/ui/theme.js +512 -165
- package/dist/admin/ui/useSortable.d.ts +35 -0
- package/dist/admin/ui/useSortable.js +220 -0
- package/dist/admin/validate.d.ts +6 -1
- package/dist/admin/validate.js +28 -9
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +5 -2
- package/dist/entry-store.js +4 -3
- package/dist/fields.d.ts +4 -0
- package/dist/fields.js +2 -2
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -1
- package/dist/marks.d.ts +53 -0
- package/dist/marks.js +43 -0
- package/dist/media.d.ts +3 -2
- package/dist/media.js +48 -6
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.js +2 -1
- package/dist/resolve.d.ts +8 -2
- package/dist/resolve.js +8 -7
- package/dist/richtext/index.d.ts +12 -0
- package/dist/richtext/index.js +9 -0
- package/dist/richtext-render.d.ts +22 -2
- package/dist/richtext-render.js +40 -13
- package/dist/richtext-walk.d.ts +44 -0
- package/dist/richtext-walk.js +46 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -1
- package/dist/section-wrapper.js +2 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.js +7 -4
- package/dist/store.d.ts +3 -1
- package/dist/store.js +4 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +2 -1
- package/dist/supabase-store.js +1 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- package/package.json +10 -1
|
@@ -27,6 +27,8 @@ export type AdminCollection = {
|
|
|
27
27
|
path?: Record<string, string>;
|
|
28
28
|
/** How deep entries may nest; 1 = flat. */
|
|
29
29
|
depth: number;
|
|
30
|
+
/** `order: "manual"` — the list drags into the `sort` order (spec 2026-09-09 §5). */
|
|
31
|
+
manual: boolean;
|
|
30
32
|
};
|
|
31
33
|
export type AdminPageTemplate = {
|
|
32
34
|
name: string;
|
|
@@ -47,8 +49,13 @@ export type AdminShared = {
|
|
|
47
49
|
/** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
|
|
48
50
|
styles: Record<string, AdminField>;
|
|
49
51
|
};
|
|
52
|
+
/** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts. */
|
|
53
|
+
export type AdminElement = AdminSection & {
|
|
54
|
+
contexts: string[];
|
|
55
|
+
};
|
|
50
56
|
export type AdminRegistry = {
|
|
51
57
|
sections: AdminSection[];
|
|
58
|
+
elements: AdminElement[];
|
|
52
59
|
collections: AdminCollection[];
|
|
53
60
|
pages: AdminPageTemplate[];
|
|
54
61
|
shared: AdminShared[];
|
package/dist/admin/serialize.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fixedSlug } from "../page.js";
|
|
2
|
-
import { collectionDepth } from "../collections.js";
|
|
2
|
+
import { collectionDepth, collectionOrder } from "../collections.js";
|
|
3
3
|
import { perLocaleSegments } from "../paths.js";
|
|
4
4
|
function serializeDescriptor(d) {
|
|
5
5
|
const out = { type: d.type };
|
|
@@ -38,10 +38,25 @@ export function serializeAdminConfig(config) {
|
|
|
38
38
|
sample: s.sample,
|
|
39
39
|
};
|
|
40
40
|
}),
|
|
41
|
+
elements: (config.registry.elements ?? []).map(({ schema }) => {
|
|
42
|
+
const e = schema;
|
|
43
|
+
return {
|
|
44
|
+
name: e.name,
|
|
45
|
+
title: e.title ?? e.name,
|
|
46
|
+
icon: e.icon,
|
|
47
|
+
description: e.description,
|
|
48
|
+
contexts: [...(e.contexts ?? [])],
|
|
49
|
+
fields: serializeMap(e.fields),
|
|
50
|
+
styles: serializeMap(e.styles),
|
|
51
|
+
meta: serializeMap(e.meta),
|
|
52
|
+
sample: e.sample,
|
|
53
|
+
};
|
|
54
|
+
}),
|
|
41
55
|
collections: (config.registry.collections ?? []).map((c) => ({
|
|
42
56
|
name: c.name, title: c.title, titleField: c.titleField, fields: serializeMap(c.fields),
|
|
43
57
|
...(c.path !== undefined ? { path: perLocale(c.path) } : {}),
|
|
44
58
|
depth: collectionDepth(c),
|
|
59
|
+
manual: collectionOrder(c) === "manual",
|
|
45
60
|
})),
|
|
46
61
|
pages: (config.registry.pages ?? [])
|
|
47
62
|
.filter(({ schema }) => schema.kind === "page")
|
|
@@ -27,6 +27,7 @@ import { useRoute } from "./router.js";
|
|
|
27
27
|
import { ErrorPane } from "./ErrorPane.js";
|
|
28
28
|
import { MediaScreen } from "./MediaScreen.js";
|
|
29
29
|
import { AssetsProvider } from "../forms/assets-context.js";
|
|
30
|
+
import { ElementsProvider } from "../forms/elements-context.js";
|
|
30
31
|
export function AdminApp({ registry, op, auth }) {
|
|
31
32
|
const session = useAdminSession(auth);
|
|
32
33
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
@@ -56,5 +57,5 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
56
57
|
main = _jsx(MediaScreen, {});
|
|
57
58
|
else
|
|
58
59
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
59
|
-
return (_jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }));
|
|
60
|
+
return (_jsx(ElementsProvider, { elements: registry.elements, children: _jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }) }));
|
|
60
61
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// A collection's list for ONE selected locale: every record is a row;
|
|
4
4
|
// a record absent from the selected locale is dimmed and its click is
|
|
5
5
|
// "Add <locale>" (a confirm, then addLocale copying the source locale).
|
|
6
6
|
// Opening a present row opens the form in the selected locale.
|
|
7
7
|
import { useCallback, useEffect, useState } from "react";
|
|
8
8
|
import { ListView } from "./ListView.js";
|
|
9
|
-
import { entryRows, entrySourceLocale } from "./entries-list.js";
|
|
10
|
-
import { Button,
|
|
9
|
+
import { canReorder, entryRows, entrySourceLocale } from "./entries-list.js";
|
|
10
|
+
import { Button, StatusBadge } from "../ui/primitives.js";
|
|
11
|
+
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
11
12
|
import { relativeTime } from "../ui/format.js";
|
|
13
|
+
import { useSortable } from "../ui/useSortable.js";
|
|
12
14
|
import { BASE } from "./base.js";
|
|
13
15
|
import { navigate } from "./router.js";
|
|
14
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
@@ -39,12 +41,33 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
39
41
|
}
|
|
40
42
|
catch { /* see rememberedLocale */ }
|
|
41
43
|
};
|
|
42
|
-
const load = useCallback(() =>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const load = useCallback(() => ops.entries.list(collection)
|
|
45
|
+
.then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
|
|
46
|
+
.catch(() => setError("Couldn't reach the server.")), [ops, collection]);
|
|
47
|
+
useEffect(() => { void load(); }, [load]);
|
|
48
|
+
// Drag (spec 2026-09-09 §5): a manual-order flat collection reorders
|
|
49
|
+
// through entries.move; the rows are held in place until the reload lands.
|
|
50
|
+
const rows = records && meta ? entryRows(records, meta, registry, locale) : [];
|
|
51
|
+
const sortable = useSortable({
|
|
52
|
+
count: rows.length,
|
|
53
|
+
onDrop: async (from, to) => {
|
|
54
|
+
const record = records?.find((r) => r.id === rows[from].id);
|
|
55
|
+
if (!record || from === to)
|
|
56
|
+
return;
|
|
57
|
+
let result;
|
|
58
|
+
try {
|
|
59
|
+
result = await ops.entries.move(collection, record.id, { parentId: record.parentId, index: to });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
63
|
+
}
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
window.alert(result.message);
|
|
66
|
+
throw new Error(result.message);
|
|
67
|
+
}
|
|
68
|
+
await load();
|
|
69
|
+
},
|
|
70
|
+
});
|
|
48
71
|
if (!meta)
|
|
49
72
|
return _jsx(ErrorPane, { message: "No such collection." });
|
|
50
73
|
if (error)
|
|
@@ -72,12 +95,11 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
72
95
|
}
|
|
73
96
|
open(row.id);
|
|
74
97
|
};
|
|
75
|
-
|
|
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: [
|
|
98
|
+
return (_jsx(ListView, { title: meta.title, titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsx(Button, { onClick: () => { navigate(`${BASE}/${collection}/new?locale=${encodeURIComponent(locale)}`); }, children: "+ New entry" }), columns: [
|
|
77
99
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
78
100
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
79
101
|
{ label: "Status", width: "120px" },
|
|
80
|
-
], empty: records ? "Nothing here yet." : "Loading…", rows: rows.map((row) => {
|
|
102
|
+
], empty: records ? "Nothing here yet." : "Loading…", sortable: canReorder(meta) ? { hook: sortable, canDrag: () => true } : undefined, rows: rows.map((row) => {
|
|
81
103
|
const dim = row.present ? undefined : { opacity: 0.45 };
|
|
82
104
|
return {
|
|
83
105
|
key: row.id,
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// The classic field view for ONE locale of an entry (spec 2026-09-06
|
|
4
|
-
// §7):
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// §7): the fields in schema order, the same FieldWidget renderer as the
|
|
5
|
+
// editor sidebar, no tabs. In a multilingual project a field that is
|
|
6
|
+
// NOT .localized() (nor the slug) carries a globe-off mark after its
|
|
7
|
+
// name — every locale sees it — instead of the two "Shared across
|
|
8
|
+
// languages" / "In <locale>" groups (dropped 2026-09-09). The locale
|
|
9
|
+
// switcher loads that locale's merged fields; switching to a locale the
|
|
10
|
+
// entry lacks shows the "Add <locale>" pane, which copies the locale on
|
|
11
|
+
// screen; "Remove from <locale>" sits in the sidebar behind a confirm.
|
|
12
|
+
// Status is per locale and immediate. The safe-delete guard's message
|
|
13
|
+
// renders verbatim — it is product surface.
|
|
11
14
|
import { useEffect, useMemo, useState } from "react";
|
|
12
15
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
13
16
|
import { relativeTime } from "../ui/format.js";
|
|
14
17
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
15
|
-
import { Banner, Breadcrumb, Button,
|
|
18
|
+
import { Banner, Breadcrumb, Button, SectionLabel, Segmented, TopBar, UnlocalizedMark } from "../ui/primitives.js";
|
|
19
|
+
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
16
20
|
import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
|
|
17
21
|
import { BASE } from "./base.js";
|
|
18
22
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
@@ -205,14 +209,17 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
205
209
|
const base = meta.path?.[locale];
|
|
206
210
|
const slugPrefix = base ? `/${base}/` : undefined;
|
|
207
211
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
208
|
-
const
|
|
209
|
-
const own = Object.entries(meta.fields).filter(([key]) => localeKeys.includes(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));
|
|
212
|
+
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, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
|
|
211
213
|
const absent = !isNew && record !== null && fields === null && !record.locales[locale];
|
|
212
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [
|
|
215
|
+
{
|
|
216
|
+
label: meta.title, href: `${BASE}/${collection}`,
|
|
217
|
+
trailing: multilingual && (isNew
|
|
218
|
+
? _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })
|
|
219
|
+
: _jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })),
|
|
220
|
+
},
|
|
221
|
+
{ label: heading },
|
|
222
|
+
] }) }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", 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 ? (Object.entries(meta.fields).map(widget)) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [_jsxs("div", { className: "sm-card", children: [_jsxs(SectionLabel, { children: ["Status", multilingual ? ` · ${locale}` : ""] }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: isNew || !row, 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 && record && row && (_jsx(_Fragment, { children: _jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: relativeTime(row.updatedAt) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] }) })), !isNew && record && (_jsx(_Fragment, { children: _jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }) }))] })] })] }));
|
|
216
223
|
}
|
|
217
224
|
function InfoRow({ label, value, mono }) {
|
|
218
225
|
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: {
|
|
@@ -8,10 +8,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
//
|
|
9
9
|
// Items are registry-derived, never separately configured: Pages, then
|
|
10
10
|
// Collections — one rail item whose flyout lists every collection — then
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// old nav's footer
|
|
11
|
+
// Media, Globals (the shared content items, when the registry has any;
|
|
12
|
+
// the route stays /shared), and Settings, shown but inert until it
|
|
13
|
+
// exists. The user avatar is pinned to the bottom and opens the same
|
|
14
|
+
// flyout with the theme toggle and Sign out, which the old nav's footer
|
|
15
|
+
// held.
|
|
15
16
|
//
|
|
16
17
|
// Flyouts: click toggles, Esc closes and returns focus to the trigger,
|
|
17
18
|
// ↑/↓ move between entries, a pointer down outside closes. A child being
|
|
@@ -69,5 +70,5 @@ export function IconRail({ registry, active, email, onSignOut }) {
|
|
|
69
70
|
const collectionActive = collections.some((c) => c.name === active);
|
|
70
71
|
const name = email?.split("@")[0] ?? "Editor";
|
|
71
72
|
const initials = name.slice(0, 2).toUpperCase();
|
|
72
|
-
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(Square, { ...ICON }), `${BASE}/pages`), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "collections", "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsxs("span", { className: "sm-rail__tile", children: [_jsx(Library, { ...ICON }), _jsx("span", { className: "sm-rail__chev", children: _jsx(ChevronDown, { ...ICON, size: 8, strokeWidth: 3 }) })] }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsxs("ul", { className: "sm-rail__menu", role: "menu", id: menuId("collections"), "data-open": open === "collections", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: "Collections" }), collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: `${BASE}/${c.name}`, "aria-current": c.name === active ? "page" : undefined, onClick: close, children: _jsx("span", { children: c.title }) }) }, c.name)))] })] })),
|
|
73
|
+
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(Square, { ...ICON }), `${BASE}/pages`), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "collections", "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsxs("span", { className: "sm-rail__tile", children: [_jsx(Library, { ...ICON }), _jsx("span", { className: "sm-rail__chev", children: _jsx(ChevronDown, { ...ICON, size: 8, strokeWidth: 3 }) })] }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsxs("ul", { className: "sm-rail__menu", role: "menu", id: menuId("collections"), "data-open": open === "collections", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: "Collections" }), collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: `${BASE}/${c.name}`, "aria-current": c.name === active ? "page" : undefined, onClick: close, children: _jsx("span", { children: c.title }) }) }, c.name)))] })] })), link("media", "Media", _jsx(Image, { ...ICON }), `${BASE}/media`), registry.shared.length > 0 && link("shared", "Globals", _jsx(LayoutPanelTop, { ...ICON }), `${BASE}/shared`), link("settings", "Settings", _jsx(Settings2, { ...ICON }), null)] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
73
74
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import type { Sortable } from "../ui/useSortable.ts";
|
|
2
3
|
export type ListColumn = {
|
|
3
4
|
label: string;
|
|
4
5
|
width: string;
|
|
5
6
|
};
|
|
6
|
-
export declare function ListView({ title,
|
|
7
|
+
export declare function ListView({ title, titleTrailing, actions, columns, rows, empty, sortable }: {
|
|
7
8
|
title: string;
|
|
8
|
-
|
|
9
|
+
/** Rides right after the title — the locale picker, in the place every form's switch has. */
|
|
10
|
+
titleTrailing?: ReactNode;
|
|
9
11
|
actions?: ReactNode;
|
|
10
12
|
columns: ListColumn[];
|
|
11
13
|
rows: {
|
|
@@ -15,4 +17,9 @@ export declare function ListView({ title, count, actions, columns, rows, empty }
|
|
|
15
17
|
cells: ReactNode[];
|
|
16
18
|
}[];
|
|
17
19
|
empty?: ReactNode;
|
|
20
|
+
/** Rows drag (spec 2026-09-09 §6): a 24px grip column first; `canDrag` false leaves a row's cell empty. */
|
|
21
|
+
sortable?: {
|
|
22
|
+
hook: Sortable;
|
|
23
|
+
canDrag(rowIndex: number): boolean;
|
|
24
|
+
};
|
|
18
25
|
}): import("react").JSX.Element;
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// packages/smoodly/src/admin/shell/ListView.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
-
import { TopBar } from "../ui/primitives.js";
|
|
4
|
+
import { Grip, TopBar, cx } from "../ui/primitives.js";
|
|
5
5
|
import { navigate } from "./router.js";
|
|
6
|
-
export function ListView({ title,
|
|
7
|
-
const
|
|
8
|
-
|
|
6
|
+
export function ListView({ title, titleTrailing, actions, columns, rows, empty, sortable }) {
|
|
7
|
+
const widths = sortable ? ["24px", ...columns.map((c) => c.width)] : columns.map((c) => c.width);
|
|
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 }), _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
10
|
const clickable = Boolean(r.href || r.onClick);
|
|
10
11
|
const href = r.href;
|
|
11
12
|
const go = r.onClick ?? (href ? () => { navigate(href); } : undefined);
|
|
12
|
-
return (
|
|
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 === " ")) {
|
|
13
14
|
e.preventDefault();
|
|
14
15
|
go();
|
|
15
|
-
} }, children: r.cells.map((cell, i) => _jsx("span", { className: "sm-cell", children: cell }, i)) }, r.key));
|
|
16
|
+
} }, children: [sortable && (_jsx("span", { className: "sm-cell sm-cell--grip", children: sortable.canDrag(i) && _jsx(Grip, { ...sortable.hook.handleProps(i) }) })), r.cells.map((cell, i) => _jsx("span", { className: "sm-cell", children: cell }, i))] }, r.key));
|
|
16
17
|
}), rows.length === 0 && _jsx("div", { className: "sm-empty", children: empty ?? "Nothing here yet." })] })] }) })] }));
|
|
17
18
|
}
|
|
@@ -45,7 +45,7 @@ export function LoginView({ client, providers = [] }) {
|
|
|
45
45
|
};
|
|
46
46
|
return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: _jsxs("form", { onSubmit: submit, style: {
|
|
47
47
|
width: 320, display: "flex", flexDirection: "column", gap: 14,
|
|
48
|
-
background: "var(--surface)", padding:
|
|
49
|
-
border: "var(--border)", boxShadow: "var(--shadow-page)",
|
|
48
|
+
background: "var(--surface)", padding: 28, borderRadius: "var(--radius-card-lg)",
|
|
49
|
+
border: "var(--menu-border)", boxShadow: "var(--shadow-page)",
|
|
50
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)))] }) }));
|
|
51
51
|
}
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
4
|
// The Pages list: the tree of the design system's TreeTable for ONE
|
|
5
5
|
// selected locale (spec 2026-09-06 §6), plus the row menu (New
|
|
6
|
-
// subpage/
|
|
6
|
+
// subpage/subpath, Add page here, Add <locale>, Rename, Move, Remove
|
|
7
7
|
// from <locale>, Delete) as a floating popover positioned from the
|
|
8
8
|
// triggering click. Structure is shared, so every node has a row; a node
|
|
9
9
|
// absent from the selected locale is dimmed and offers "Add <locale>".
|
|
10
10
|
import { useCallback, useEffect, useState } from "react";
|
|
11
11
|
import { ListView } from "./ListView.js";
|
|
12
|
-
import { openTemplates, pagesTreeRows, sourceLocaleOf } from "./pages-tree.js";
|
|
13
|
-
import { Button,
|
|
12
|
+
import { canDragRow, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
|
|
13
|
+
import { Button, StatusBadge } from "../ui/primitives.js";
|
|
14
|
+
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
15
|
+
import { useSortable } from "../ui/useSortable.js";
|
|
14
16
|
import { BASE } from "./base.js";
|
|
15
17
|
import { navigate } from "./router.js";
|
|
16
18
|
import { ErrorPane } from "./ErrorPane.js";
|
|
@@ -42,12 +44,10 @@ export function PagesList({ ops, registry }) {
|
|
|
42
44
|
}
|
|
43
45
|
catch { /* see rememberedLocale */ }
|
|
44
46
|
};
|
|
45
|
-
const load = useCallback(() =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}, [ops]);
|
|
50
|
-
useEffect(load, [load]);
|
|
47
|
+
const load = useCallback(() => ops.pages.list()
|
|
48
|
+
.then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
|
|
49
|
+
.catch(() => setError("Couldn't reach the server.")), [ops]);
|
|
50
|
+
useEffect(() => { void load(); }, [load]);
|
|
51
51
|
const rows = pages ? pagesTreeRows(pages, registry, locale, collapsed) : [];
|
|
52
52
|
const creatable = openTemplates(registry.pages);
|
|
53
53
|
const noOpen = creatable.length === 0;
|
|
@@ -55,11 +55,33 @@ export function PagesList({ ops, registry }) {
|
|
|
55
55
|
next.delete(id);
|
|
56
56
|
else
|
|
57
57
|
next.add(id); return next; });
|
|
58
|
+
// Drag (spec 2026-09-09 §4–§5): the dragged subtree collapses at drag
|
|
59
|
+
// start so it travels whole; the slot is read as a tree position by
|
|
60
|
+
// projectDrop; the drop is one pages.move, rows held until the reload.
|
|
61
|
+
const sortable = useSortable({
|
|
62
|
+
count: rows.length,
|
|
63
|
+
onDragStart: (from) => {
|
|
64
|
+
const row = rows[from];
|
|
65
|
+
if (row.hasChildren && row.expanded)
|
|
66
|
+
toggle(row.id);
|
|
67
|
+
},
|
|
68
|
+
project: (slot, from, xDrift) => projectDrop(rows, from, slot, xDrift, subtreeHeight(pages ?? [], rows[from].id), registry.pageDepth),
|
|
69
|
+
onDrop: async (from, _to, projection) => {
|
|
70
|
+
const row = rows[from];
|
|
71
|
+
if (!projection)
|
|
72
|
+
return;
|
|
73
|
+
if (projection.parentId === row.parentId && projection.index === row.index)
|
|
74
|
+
return;
|
|
75
|
+
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index }));
|
|
76
|
+
if (!ok)
|
|
77
|
+
throw new Error("move refused");
|
|
78
|
+
},
|
|
79
|
+
});
|
|
58
80
|
/** The editor opens in the locale selected here. */
|
|
59
81
|
const openEditor = (id) => {
|
|
60
82
|
navigate(`${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
|
|
61
83
|
};
|
|
62
|
-
/** Run an op; on success reload the list (and maybe navigate), else show the op's message. */
|
|
84
|
+
/** Run an op; on success reload the list (and maybe navigate), else show the op's message. Resolves to whether it succeeded. */
|
|
63
85
|
const run = async (work, then) => {
|
|
64
86
|
let result;
|
|
65
87
|
try {
|
|
@@ -70,10 +92,11 @@ export function PagesList({ ops, registry }) {
|
|
|
70
92
|
}
|
|
71
93
|
if (!result.ok) {
|
|
72
94
|
window.alert(result.message);
|
|
73
|
-
return;
|
|
95
|
+
return false;
|
|
74
96
|
}
|
|
75
97
|
then?.(result.data);
|
|
76
|
-
load();
|
|
98
|
+
await load();
|
|
99
|
+
return true;
|
|
77
100
|
};
|
|
78
101
|
const pickTemplate = () => {
|
|
79
102
|
if (noOpen)
|
|
@@ -82,11 +105,11 @@ export function PagesList({ ops, registry }) {
|
|
|
82
105
|
return creatable[0].name;
|
|
83
106
|
return window.prompt(`Template? (${creatable.map((p) => p.name).join(", ")})`) || null;
|
|
84
107
|
};
|
|
85
|
-
/** New page and New folder create in the SELECTED locale (spec §6). */
|
|
108
|
+
/** New page and New path (a folder in the tree) create in the SELECTED locale (spec §6). */
|
|
86
109
|
const newNode = (parentId, kind) => {
|
|
87
110
|
if (kind === "page" && noOpen)
|
|
88
111
|
return;
|
|
89
|
-
const slug = window.prompt(`Slug for the new ${kind} (e.g. about):`);
|
|
112
|
+
const slug = window.prompt(`Slug for the new ${kind === "folder" ? "path" : "page"} (e.g. about):`);
|
|
90
113
|
if (!slug)
|
|
91
114
|
return;
|
|
92
115
|
const template = kind === "page" ? pickTemplate() : null;
|
|
@@ -143,19 +166,18 @@ export function PagesList({ ops, registry }) {
|
|
|
143
166
|
items.push({ label: `Add ${locale}`, run: () => addLocale(row) });
|
|
144
167
|
return items;
|
|
145
168
|
}
|
|
146
|
-
if (row.kind === "mount" && row.template !== null)
|
|
147
|
-
items.push({ label: "Edit index page", run: () => openEditor(row.id) });
|
|
148
169
|
if (row.canHaveChildren && !noOpen)
|
|
149
170
|
items.push({ label: "New subpage", run: () => newNode(row.id, "page") });
|
|
150
171
|
if (row.canHaveChildren)
|
|
151
|
-
items.push({ label: "New
|
|
172
|
+
items.push({ label: "New subpath", run: () => newNode(row.id, "folder") });
|
|
152
173
|
if (row.kind === "folder" && !noOpen)
|
|
153
174
|
items.push({ label: "Add page here", run: () => addPageHere(row) });
|
|
154
175
|
if (!row.fixed)
|
|
155
176
|
items.push({ label: "Rename…", run: () => rename(row) });
|
|
156
|
-
|
|
177
|
+
// Every row reorders among its siblings, fixed nodes and the home page included (root order is nothing code owns).
|
|
178
|
+
if (row.index > 0)
|
|
157
179
|
items.push({ label: "Move up", run: () => move(row, -1) });
|
|
158
|
-
if (
|
|
180
|
+
if (row.index < row.siblingCount - 1)
|
|
159
181
|
items.push({ label: "Move down", run: () => move(row, 1) });
|
|
160
182
|
if (multilingual && !row.fixed && !row.isHome && row.locales.length > 1) {
|
|
161
183
|
items.push({ label: `Remove from ${locale}`, run: () => removeLocale(row) });
|
|
@@ -164,33 +186,40 @@ export function PagesList({ ops, registry }) {
|
|
|
164
186
|
items.push({ label: "Delete", run: () => destroy(row) });
|
|
165
187
|
return items;
|
|
166
188
|
};
|
|
189
|
+
// A mount row is a page like any other when a fixed page claims the
|
|
190
|
+
// collection's segments (its index page): the click opens that editor.
|
|
191
|
+
// Only a mount with no index page has nowhere else to go, so it opens
|
|
192
|
+
// the collection list. The collection itself lives on the rail.
|
|
167
193
|
const onRow = (row) => {
|
|
168
194
|
if (!row.present)
|
|
169
195
|
addLocale(row);
|
|
170
|
-
else if (row.kind === "mount" && row.collection)
|
|
171
|
-
navigate(`${BASE}/${row.collection}`);
|
|
172
196
|
else if (row.template !== null)
|
|
173
197
|
openEditor(row.id);
|
|
198
|
+
else if (row.kind === "mount" && row.collection)
|
|
199
|
+
navigate(`${BASE}/${row.collection}`);
|
|
174
200
|
else if (row.hasChildren)
|
|
175
201
|
toggle(row.id);
|
|
176
202
|
};
|
|
177
203
|
if (error)
|
|
178
204
|
return _jsx(ErrorPane, { message: error });
|
|
179
205
|
const items = menu ? menuItems(menu.row) : [];
|
|
180
|
-
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Pages",
|
|
206
|
+
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Pages", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: () => newNode(null, "folder"), children: "+ New path" }), _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: [
|
|
181
207
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
182
208
|
{ label: "Path", width: "minmax(140px,260px)" },
|
|
183
209
|
{ label: "Template", width: "minmax(100px,160px)" },
|
|
184
210
|
{ label: "Status", width: "110px" },
|
|
185
211
|
{ label: "", width: "32px" },
|
|
186
|
-
], empty: pages ? "No pages yet." : "Loading…", rows: rows.map((row) => {
|
|
212
|
+
], empty: pages ? "No pages yet." : "Loading…", sortable: { hook: sortable, canDrag: (i) => canDragRow(rows[i]) }, rows: rows.map((row, i) => {
|
|
187
213
|
// An absent node is still a row (structure is shared) but reads as dimmed.
|
|
188
214
|
const dim = row.present ? undefined : { opacity: 0.45 };
|
|
215
|
+
// The lifted row's indent follows the projected depth, so the pointer's drift reads as a level.
|
|
216
|
+
const lifted = sortable.active?.from === i ? sortable.active : null;
|
|
217
|
+
const depth = lifted?.projection ? lifted.projection.depth : row.depth;
|
|
189
218
|
return {
|
|
190
219
|
key: row.id,
|
|
191
220
|
onClick: () => onRow(row),
|
|
192
221
|
cells: [
|
|
193
|
-
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft:
|
|
222
|
+
_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"),
|
|
194
223
|
row.present
|
|
195
224
|
? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
|
|
196
225
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// One
|
|
4
|
-
// shape without what a singleton never has — no slug, no
|
|
5
|
-
// delete, no add/remove locale (every supported locale exists
|
|
6
|
-
// construction).
|
|
7
|
-
//
|
|
3
|
+
// One Globals item (shared content, spec 2026-09-07 §6), one locale: the
|
|
4
|
+
// entry form's shape without what a singleton never has — no slug, no
|
|
5
|
+
// "new", no delete, no add/remove locale (every supported locale exists
|
|
6
|
+
// by construction). The fields in schema order, a globe-off mark after
|
|
7
|
+
// the name of each that is not .localized(), and for a visual item a
|
|
8
|
+
// Styles group edited under the reserved `$styles` key.
|
|
8
9
|
// Status is per locale and immediate. "Used on N pages" is the refs
|
|
9
10
|
// index's answer.
|
|
10
11
|
import { useEffect, useMemo, useState } from "react";
|
|
@@ -12,7 +13,8 @@ import { STYLES_KEY } from "../../shared.js";
|
|
|
12
13
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
13
14
|
import { relativeTime } from "../ui/format.js";
|
|
14
15
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
15
|
-
import { Banner, Breadcrumb, Button,
|
|
16
|
+
import { Banner, Breadcrumb, Button, SectionLabel, Segmented, TopBar, UnlocalizedMark } from "../ui/primitives.js";
|
|
17
|
+
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
16
18
|
import { entryTitle } from "./entries-list.js";
|
|
17
19
|
import { BASE } from "./base.js";
|
|
18
20
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
@@ -113,15 +115,19 @@ export function SharedForm({ ops, registry, name }) {
|
|
|
113
115
|
setBanner(null);
|
|
114
116
|
setLocale(next);
|
|
115
117
|
};
|
|
116
|
-
const shared = Object.entries(item.fields).filter(([key]) => !localeKeys.includes(key));
|
|
117
|
-
const own = Object.entries(item.fields).filter(([key]) => localeKeys.includes(key));
|
|
118
118
|
const styles = (fields?.[STYLES_KEY] ?? {});
|
|
119
119
|
const pages = new Set(usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId));
|
|
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));
|
|
121
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left:
|
|
120
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
|
|
121
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [
|
|
122
|
+
{
|
|
123
|
+
label: "Globals", href: `${BASE}/shared`,
|
|
124
|
+
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined })),
|
|
125
|
+
},
|
|
126
|
+
{ label: item.title },
|
|
127
|
+
] }) }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "Save" }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", 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: [Object.entries(item.fields).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) => {
|
|
122
128
|
setFields((f) => ({ ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } }));
|
|
123
129
|
setDirty(true);
|
|
124
|
-
} }, key)))] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }),
|
|
130
|
+
} }, key)))] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [_jsxs("div", { className: "sm-card", children: [_jsxs(SectionLabel, { children: ["Status", multilingual ? ` · ${locale}` : ""] }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: !row, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } })] }), record && row && (_jsx(_Fragment, { children: _jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: relativeTime(row.updatedAt) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }) }))] })] })] }));
|
|
125
131
|
}
|
|
126
132
|
function InfoRow({ label, value, mono }) {
|
|
127
133
|
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: { color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: value })] }));
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
// The
|
|
3
|
+
// The Globals list (shared content, spec 2026-09-07 §6; renamed in the
|
|
4
|
+
// UI 2026-09-09, the route stays /shared): every registered item is
|
|
4
5
|
// a row, edited on its own page. Nothing here is created or deleted by
|
|
5
6
|
// editors — the registry declares the items and ops.shared.list
|
|
6
7
|
// materializes them — so there is no "+ New".
|
|
7
8
|
import { useCallback, useEffect, useState } from "react";
|
|
8
9
|
import { ListView } from "./ListView.js";
|
|
9
10
|
import { sharedRows } from "./shared-list.js";
|
|
10
|
-
import {
|
|
11
|
+
import { StatusBadge } from "../ui/primitives.js";
|
|
12
|
+
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
11
13
|
import { relativeTime } from "../ui/format.js";
|
|
12
14
|
import { BASE } from "./base.js";
|
|
13
15
|
import { ErrorPane } from "./ErrorPane.js";
|
|
@@ -44,7 +46,7 @@ export function SharedList({ ops, registry }) {
|
|
|
44
46
|
if (error)
|
|
45
47
|
return _jsx(ErrorPane, { message: error });
|
|
46
48
|
const rows = items ? sharedRows(items, registry, locale) : [];
|
|
47
|
-
return (_jsx(ListView, { title: "
|
|
49
|
+
return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
|
|
48
50
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
49
51
|
{ label: "Kind", width: "90px" },
|
|
50
52
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
@@ -19,3 +19,7 @@ export declare function entrySourceLocale(record: EntryRecord, registry: AdminRe
|
|
|
19
19
|
* (the same split entry-store.ts's splitFields makes). */
|
|
20
20
|
export declare function localizedFieldKeys(meta: AdminCollection): string[];
|
|
21
21
|
export declare function entryRows(records: EntryRecord[], meta: AdminCollection, registry: AdminRegistry, locale: string): EntryRow[];
|
|
22
|
+
/** The list drags only when the order IS the `sort` column and the rows
|
|
23
|
+
* are siblings: a nested collection's flat index is not a sibling index
|
|
24
|
+
* (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */
|
|
25
|
+
export declare function canReorder(meta: AdminCollection): boolean;
|