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.
Files changed (132) hide show
  1. package/README.md +259 -38
  2. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  3. package/dist/admin/editor/CanvasOverlay.js +9 -11
  4. package/dist/admin/editor/EditorView.js +130 -25
  5. package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
  6. package/dist/admin/editor/FloatingToolbar.js +14 -0
  7. package/dist/admin/editor/InlineRichtext.d.ts +9 -0
  8. package/dist/admin/editor/InlineRichtext.js +50 -0
  9. package/dist/admin/editor/Outline.d.ts +16 -10
  10. package/dist/admin/editor/Outline.js +13 -10
  11. package/dist/admin/editor/inline-dom.d.ts +27 -0
  12. package/dist/admin/editor/inline-dom.js +43 -0
  13. package/dist/admin/editor/inline-session.d.ts +30 -0
  14. package/dist/admin/editor/inline-session.js +19 -0
  15. package/dist/admin/editor/protocol.d.ts +22 -1
  16. package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
  17. package/dist/admin/editor/useCanvasBridge.js +12 -7
  18. package/dist/admin/editor/useInlineSession.d.ts +46 -0
  19. package/dist/admin/editor/useInlineSession.js +244 -0
  20. package/dist/admin/field-paths.d.ts +13 -0
  21. package/dist/admin/field-paths.js +49 -0
  22. package/dist/admin/forms/FieldWidget.d.ts +7 -2
  23. package/dist/admin/forms/FieldWidget.js +12 -40
  24. package/dist/admin/forms/ListField.d.ts +10 -0
  25. package/dist/admin/forms/ListField.js +42 -0
  26. package/dist/admin/forms/MediaWidget.js +10 -3
  27. package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
  28. package/dist/admin/forms/RichtextElementCard.js +37 -0
  29. package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
  30. package/dist/admin/forms/RichtextImageCard.js +42 -0
  31. package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
  32. package/dist/admin/forms/RichtextInsertMenu.js +6 -0
  33. package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
  34. package/dist/admin/forms/RichtextPopovers.js +25 -0
  35. package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
  36. package/dist/admin/forms/RichtextToolbar.js +4 -2
  37. package/dist/admin/forms/RichtextWidget.d.ts +4 -1
  38. package/dist/admin/forms/RichtextWidget.js +23 -120
  39. package/dist/admin/forms/elements-context.d.ts +7 -0
  40. package/dist/admin/forms/elements-context.js +13 -0
  41. package/dist/admin/forms/reveal.d.ts +5 -0
  42. package/dist/admin/forms/reveal.js +47 -0
  43. package/dist/admin/forms/richtext-extensions.d.ts +8 -0
  44. package/dist/admin/forms/richtext-extensions.js +11 -0
  45. package/dist/admin/forms/richtext-form-context.d.ts +16 -0
  46. package/dist/admin/forms/richtext-form-context.js +18 -0
  47. package/dist/admin/forms/richtext-insert.d.ts +38 -0
  48. package/dist/admin/forms/richtext-insert.js +37 -0
  49. package/dist/admin/forms/richtext-nodes.d.ts +37 -0
  50. package/dist/admin/forms/richtext-nodes.js +85 -0
  51. package/dist/admin/forms/richtext-paste.js +5 -0
  52. package/dist/admin/forms/tabs.d.ts +3 -0
  53. package/dist/admin/forms/tabs.js +6 -0
  54. package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
  55. package/dist/admin/forms/useRichtextEditor.js +281 -0
  56. package/dist/admin/next/bridge.js +51 -20
  57. package/dist/admin/ops-impl.js +13 -8
  58. package/dist/admin/richtext-doc.d.ts +3 -0
  59. package/dist/admin/richtext-doc.js +12 -0
  60. package/dist/admin/serialize.d.ts +7 -0
  61. package/dist/admin/serialize.js +16 -1
  62. package/dist/admin/shell/AdminApp.js +2 -1
  63. package/dist/admin/shell/EntriesList.js +34 -12
  64. package/dist/admin/shell/EntryForm.js +22 -15
  65. package/dist/admin/shell/IconRail.js +6 -5
  66. package/dist/admin/shell/ListView.d.ts +9 -2
  67. package/dist/admin/shell/ListView.js +7 -6
  68. package/dist/admin/shell/LoginView.js +2 -2
  69. package/dist/admin/shell/PagesList.js +53 -24
  70. package/dist/admin/shell/SharedForm.js +17 -11
  71. package/dist/admin/shell/SharedList.js +5 -3
  72. package/dist/admin/shell/entries-list.d.ts +4 -0
  73. package/dist/admin/shell/entries-list.js +6 -0
  74. package/dist/admin/shell/pages-tree.d.ts +24 -0
  75. package/dist/admin/shell/pages-tree.js +59 -1
  76. package/dist/admin/tree-ops.d.ts +9 -1
  77. package/dist/admin/tree-ops.js +27 -0
  78. package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
  79. package/dist/admin/ui/LocaleSwitcher.js +28 -8
  80. package/dist/admin/ui/Modal.js +14 -2
  81. package/dist/admin/ui/ResizeHandle.d.ts +1 -0
  82. package/dist/admin/ui/ResizeHandle.js +127 -0
  83. package/dist/admin/ui/primitives.d.ts +29 -4
  84. package/dist/admin/ui/primitives.js +29 -10
  85. package/dist/admin/ui/resize.d.ts +16 -0
  86. package/dist/admin/ui/resize.js +23 -0
  87. package/dist/admin/ui/sortable.d.ts +23 -0
  88. package/dist/admin/ui/sortable.js +57 -0
  89. package/dist/admin/ui/theme.d.ts +3 -1
  90. package/dist/admin/ui/theme.js +512 -165
  91. package/dist/admin/ui/useSortable.d.ts +35 -0
  92. package/dist/admin/ui/useSortable.js +220 -0
  93. package/dist/admin/validate.d.ts +6 -1
  94. package/dist/admin/validate.js +28 -9
  95. package/dist/config.d.ts +1 -0
  96. package/dist/config.js +36 -0
  97. package/dist/entry-store.d.ts +5 -2
  98. package/dist/entry-store.js +4 -3
  99. package/dist/fields.d.ts +4 -0
  100. package/dist/fields.js +2 -2
  101. package/dist/image/SmoodlyImage.d.ts +4 -1
  102. package/dist/image/SmoodlyImage.js +2 -2
  103. package/dist/index.d.ts +8 -1
  104. package/dist/index.js +5 -1
  105. package/dist/marks.d.ts +53 -0
  106. package/dist/marks.js +43 -0
  107. package/dist/media.d.ts +3 -2
  108. package/dist/media.js +48 -6
  109. package/dist/refs.d.ts +2 -2
  110. package/dist/refs.js +33 -12
  111. package/dist/render.js +2 -1
  112. package/dist/resolve.d.ts +8 -2
  113. package/dist/resolve.js +8 -7
  114. package/dist/richtext/index.d.ts +12 -0
  115. package/dist/richtext/index.js +9 -0
  116. package/dist/richtext-render.d.ts +22 -2
  117. package/dist/richtext-render.js +40 -13
  118. package/dist/richtext-walk.d.ts +44 -0
  119. package/dist/richtext-walk.js +46 -0
  120. package/dist/schema.d.ts +1 -1
  121. package/dist/schema.js +13 -1
  122. package/dist/section-wrapper.js +2 -1
  123. package/dist/site.d.ts +2 -0
  124. package/dist/site.js +7 -4
  125. package/dist/store.d.ts +3 -1
  126. package/dist/store.js +4 -4
  127. package/dist/supabase-entry-store.d.ts +1 -0
  128. package/dist/supabase-entry-store.js +2 -1
  129. package/dist/supabase-store.js +1 -1
  130. package/dist/toolset.d.ts +7 -1
  131. package/dist/toolset.js +13 -4
  132. 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[];
@@ -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, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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, Segmented, StatusBadge } from "../ui/primitives.js";
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
- ops.entries.list(collection)
44
- .then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
45
- .catch(() => setError("Couldn't reach the server."));
46
- }, [ops, collection]);
47
- useEffect(load, [load]);
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
- const rows = records ? entryRows(records, meta, registry, locale) : [];
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): shared fields (every locale sees them) above, the locale's own
5
- // fields the .localized() ones and the slug below, the same
6
- // FieldWidget renderer as the editor sidebar, no tabs. The locale
7
- // switcher loads that locale's merged fields; "Add locale" copies the
8
- // locale on screen; "Remove from <locale>" sits in the sidebar behind a
9
- // confirm. Status is per locale and immediate. The safe-delete guard's
10
- // message renders verbatim it is product surface.
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, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
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 shared = Object.entries(meta.fields).filter(([key]) => !localeKeys.includes(key));
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: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [{ label: meta.title, href: `${BASE}/${collection}` }, { label: heading }] }), multilingual && !isNew && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })), multilingual && isNew && _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => switchLocale(entrySourceLocale(record, registry)), children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
213
- display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px",
214
- minHeight: 0, overflow: "auto", borderLeft: "var(--border)",
215
- }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, 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 && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(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 && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, 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" })] })] }))] })] })] }));
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
- // Shared when the registry has shared items, Media, and Settings, shown
12
- // but inert until it exists. The user avatar is pinned to the bottom and
13
- // opens the same flyout with the theme toggle and Sign out, which the
14
- // old nav's footer held.
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)))] })] })), registry.shared.length > 0 && link("shared", "Shared", _jsx(LayoutPanelTop, { ...ICON }), `${BASE}/shared`), link("media", "Media", _jsx(Image, { ...ICON }), `${BASE}/media`), 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
+ 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, count, actions, columns, rows, empty }: {
7
+ export declare function ListView({ title, titleTrailing, actions, columns, rows, empty, sortable }: {
7
8
  title: string;
8
- count?: number;
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, count, actions, columns, rows, empty }) {
7
- const grid = { gridTemplateColumns: columns.map((c) => c.width).join(" ") };
8
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), count !== undefined && _jsx("span", { className: "sm-count", children: count })] }), right: actions }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: "auto" }, children: _jsxs("div", { className: "sm-table", children: [_jsx("div", { className: "sm-table__head", style: grid, children: columns.map((c) => _jsx("span", { children: c.label }, c.label)) }), _jsxs("div", { className: "sm-table__body", children: [rows.map((r) => {
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 (_jsx("div", { style: grid, role: clickable ? "button" : undefined, tabIndex: clickable ? 0 : undefined, className: clickable ? "sm-row sm-row--clickable" : "sm-row", onClick: go, onKeyDown: (e) => { if (go && (e.key === "Enter" || e.key === " ")) {
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: 24, borderRadius: "var(--radius-md)",
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/subfolder, Add page here, Add <locale>, Rename, Move, Remove
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, Segmented, StatusBadge } from "../ui/primitives.js";
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
- ops.pages.list()
47
- .then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
48
- .catch(() => setError("Couldn't reach the server."));
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 subfolder", run: () => newNode(row.id, "folder") });
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
- if (!row.fixed && !row.isHome && row.index > 0)
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 (!row.fixed && !row.isHome && row.index < row.siblingCount - 1)
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", count: pages?.length, actions: _jsxs(_Fragment, { children: [multilingual && _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }), _jsx(Button, { variant: "secondary", onClick: () => newNode(null, "folder"), children: "+ New folder" }), _jsx(Button, { onClick: () => newNode(null, "page"), disabled: noOpen, title: noOpen ? "Every page registration is fixed — add an open one (a page schema with no `slug`) to create pages here." : undefined, children: "+ New page" })] }), columns: [
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: row.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"),
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 shared item, one locale (spec 2026-09-07 §6): the entry form's
4
- // shape without what a singleton never has — no slug, no "new", no
5
- // delete, no add/remove locale (every supported locale exists by
6
- // construction). Shared fields above, the locale's own below, and for a
7
- // visual item a Styles group edited under the reserved `$styles` key.
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, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
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: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [{ label: "Shared content", href: `${BASE}/shared` }, { label: item.title }] }), multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined }))] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] })), item.section !== undefined && Object.keys(item.styles).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), Object.entries(item.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], onChange: (value) => {
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" })] }), _jsxs("aside", { style: { display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px", minHeight: 0, overflow: "auto", borderLeft: "var(--border)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, 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 && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "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 })] })] }))] })] })] }));
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 Shared content list (spec 2026-09-07 §6): every registered item is
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 { Segmented, StatusBadge } from "../ui/primitives.js";
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: "Shared content", count: items ? rows.length : undefined, actions: multilingual ? _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }) : undefined, columns: [
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;