smoodly 0.0.17 → 0.0.19

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 (73) hide show
  1. package/README.md +56 -6
  2. package/dist/admin/client-ops.js +1 -1
  3. package/dist/admin/editor/CanvasOverlay.js +4 -2
  4. package/dist/admin/editor/EditorView.js +19 -32
  5. package/dist/admin/editor/GlobalInspector.d.ts +5 -2
  6. package/dist/admin/editor/GlobalInspector.js +4 -3
  7. package/dist/admin/editor/NoticePane.js +3 -1
  8. package/dist/admin/forms/FieldWidget.d.ts +6 -6
  9. package/dist/admin/forms/FieldWidget.js +22 -10
  10. package/dist/admin/forms/RefPicker.d.ts +11 -0
  11. package/dist/admin/forms/RefPicker.js +30 -0
  12. package/dist/admin/forms/TagsField.d.ts +10 -0
  13. package/dist/admin/forms/TagsField.js +38 -0
  14. package/dist/admin/forms/ref-picker.d.ts +25 -0
  15. package/dist/admin/forms/ref-picker.js +30 -0
  16. package/dist/admin/forms/useRefOptions.d.ts +7 -0
  17. package/dist/admin/forms/useRefOptions.js +36 -0
  18. package/dist/admin/forms/useTagSuggestions.d.ts +5 -0
  19. package/dist/admin/forms/useTagSuggestions.js +22 -0
  20. package/dist/admin/ops-impl.js +23 -4
  21. package/dist/admin/ops.d.ts +4 -0
  22. package/dist/admin/serialize.d.ts +2 -0
  23. package/dist/admin/serialize.js +1 -0
  24. package/dist/admin/shell/AdminApp.js +8 -2
  25. package/dist/admin/shell/EntriesList.js +3 -2
  26. package/dist/admin/shell/EntryForm.js +14 -28
  27. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  28. package/dist/admin/shell/ErrorPane.js +6 -4
  29. package/dist/admin/shell/GlobalFields.d.ts +3 -6
  30. package/dist/admin/shell/GlobalFields.js +1 -1
  31. package/dist/admin/shell/GlobalForm.js +8 -22
  32. package/dist/admin/shell/GlobalList.js +3 -2
  33. package/dist/admin/shell/IconRail.js +3 -2
  34. package/dist/admin/shell/PagesList.js +4 -3
  35. package/dist/admin/shell/base.d.ts +4 -1
  36. package/dist/admin/shell/base.js +15 -3
  37. package/dist/admin/shell/router.d.ts +4 -3
  38. package/dist/admin/shell/router.js +17 -14
  39. package/dist/admin/ui/ActionMenu.d.ts +3 -1
  40. package/dist/admin/ui/ActionMenu.js +4 -3
  41. package/dist/admin/ui/Combobox.d.ts +30 -0
  42. package/dist/admin/ui/Combobox.js +89 -0
  43. package/dist/admin/ui/PublishControl.d.ts +3 -1
  44. package/dist/admin/ui/PublishControl.js +2 -2
  45. package/dist/admin/ui/combo-box.d.ts +16 -0
  46. package/dist/admin/ui/combo-box.js +21 -0
  47. package/dist/admin/ui/primitives.d.ts +1 -1
  48. package/dist/admin/ui/primitives.js +1 -1
  49. package/dist/admin/ui/theme.js +43 -4
  50. package/dist/admin/validate.js +39 -0
  51. package/dist/admin-path.d.ts +9 -0
  52. package/dist/admin-path.js +24 -0
  53. package/dist/config.d.ts +10 -1
  54. package/dist/config.js +4 -0
  55. package/dist/entry-page.d.ts +5 -0
  56. package/dist/entry-page.js +1 -1
  57. package/dist/entry-store.js +1 -1
  58. package/dist/fields.d.ts +3 -0
  59. package/dist/fields.js +5 -0
  60. package/dist/index.d.ts +4 -3
  61. package/dist/index.js +2 -1
  62. package/dist/next/page-renderer.js +7 -3
  63. package/dist/page.js +1 -1
  64. package/dist/paths.d.ts +21 -0
  65. package/dist/paths.js +25 -0
  66. package/dist/render.d.ts +10 -0
  67. package/dist/render.js +2 -1
  68. package/dist/site.d.ts +14 -9
  69. package/dist/site.js +31 -18
  70. package/dist/supabase-entry-store.js +1 -1
  71. package/dist/tags.d.ts +19 -0
  72. package/dist/tags.js +50 -0
  73. package/package.json +1 -1
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ // Ref candidates for a form (spec 2026-09-17 §2): every entry of each target
3
+ // collection, loaded once per locale and filtered in the browser. One hook
4
+ // for the entry form, the Globals form and the page editor — the picker
5
+ // takes options as a prop, so a move to server search changes this file only.
6
+ import { useEffect, useState } from "react";
7
+ import { entryTitle } from "../shell/entries-list.js";
8
+ import { localeStatusOf } from "../shell/pages-tree.js";
9
+ export function refOptionsOf(records, meta, registry, locale) {
10
+ return records.map((e) => {
11
+ const row = e.locales[locale];
12
+ const title = meta ? entryTitle(e, meta, registry, locale) : e.id;
13
+ return row ? { id: e.id, title, status: localeStatusOf(row) } : { id: e.id, title, status: "", absent: true };
14
+ });
15
+ }
16
+ /** Options by target collection; a target is `undefined` until its list arrives. */
17
+ export function useRefOptions(ops, registry, locale, targets) {
18
+ const [loaded, setLoaded] = useState({});
19
+ const key = [...new Set(targets)].sort().join("\n");
20
+ useEffect(() => {
21
+ let cancelled = false;
22
+ setLoaded({}); // the previous locale's options must not show under this one
23
+ for (const target of key === "" ? [] : key.split("\n")) {
24
+ const meta = registry.collections.find((c) => c.name === target);
25
+ ops.entries.list(target).then((r) => {
26
+ if (cancelled)
27
+ return;
28
+ // A failed list degrades to "nothing to pick from", not a stuck load.
29
+ setLoaded((prev) => ({ ...prev, [target]: r.ok ? refOptionsOf(r.data, meta, registry, locale) : [] }));
30
+ }).catch((e) => { console.error(e); if (!cancelled)
31
+ setLoaded((prev) => ({ ...prev, [target]: [] })); });
32
+ }
33
+ return () => { cancelled = true; };
34
+ }, [ops, registry, locale, key]);
35
+ return loaded;
36
+ }
@@ -0,0 +1,5 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminField } from "../serialize.ts";
3
+ /** The top-level tags fields — the op reads top-level keys only. */
4
+ export declare const tagFieldKeys: (fields: Record<string, AdminField>) => string[];
5
+ export declare function useTagSuggestions(ops: AdminOps, collection: string, fields: Record<string, AdminField>, locale: string): Record<string, string[] | undefined>;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ // Suggestions for a collection form's f.tags() fields (spec 2026-09-17 §6):
3
+ // one ops.entries.distinct per top-level tags field, once per locale.
4
+ import { useEffect, useState } from "react";
5
+ /** The top-level tags fields — the op reads top-level keys only. */
6
+ export const tagFieldKeys = (fields) => Object.entries(fields).filter(([, d]) => d.type === "tags").map(([key]) => key);
7
+ export function useTagSuggestions(ops, collection, fields, locale) {
8
+ const [loaded, setLoaded] = useState({});
9
+ const key = tagFieldKeys(fields).join("\n");
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ setLoaded({});
13
+ for (const field of key === "" ? [] : key.split("\n")) {
14
+ ops.entries.distinct(collection, field, locale).then((r) => {
15
+ if (!cancelled)
16
+ setLoaded((prev) => ({ ...prev, [field]: r.ok ? r.data : [] }));
17
+ }).catch(console.error);
18
+ }
19
+ return () => { cancelled = true; };
20
+ }, [ops, collection, locale, key]);
21
+ return loaded;
22
+ }
@@ -1,6 +1,7 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
+ import { distinctTags } from "../tags.js";
2
3
  import { ASSET_COLLECTION } from "../media.js";
3
- import { BASE } from "./shell/base.js";
4
+ import { adminHref } from "../admin-path.js";
4
5
  import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
5
6
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
6
7
  import { ensureGlobalItems } from "./global-items.js";
@@ -207,16 +208,16 @@ export function createAdminOps(deps) {
207
208
  const record = await pages.getPage(sourceId);
208
209
  if (!record)
209
210
  return { title: sourceId, href: null };
210
- return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
211
+ return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: adminHref(config.admin.path, `/pages/${sourceId}`) };
211
212
  }
212
213
  for (const item of config.registry.globals ?? []) {
213
214
  if (await entries.get(item.name, sourceId))
214
- return { title: item.title, href: `${BASE}/globals/${encodeURIComponent(item.name)}` };
215
+ return { title: item.title, href: adminHref(config.admin.path, `/globals/${encodeURIComponent(item.name)}`) };
215
216
  }
216
217
  for (const c of config.registry.collections ?? []) {
217
218
  const record = await entries.get(c.name, sourceId);
218
219
  if (record)
219
- return { title: entryTitleIn(record, def, c), href: `${BASE}/${c.name}/${sourceId}` };
220
+ return { title: entryTitleIn(record, def, c), href: adminHref(config.admin.path, `/${c.name}/${sourceId}`) };
220
221
  }
221
222
  return { title: sourceId, href: null };
222
223
  };
@@ -519,6 +520,24 @@ export function createAdminOps(deps) {
519
520
  },
520
521
  entries: {
521
522
  list: (collection) => guard(async () => ok(await entries.list(collection))),
523
+ // In JS over the list the admin already reads: PostgREST has no DISTINCT over
524
+ // jsonb_array_elements without an RPC (spec 2026-09-17 §6; the RPC is in the register).
525
+ distinct: (collection, field, locale) => guard(async () => {
526
+ const unsupported = supportedLocale(locale);
527
+ if (unsupported)
528
+ return unsupported;
529
+ const descriptors = collectionFields(collection);
530
+ if (!descriptors)
531
+ return err("not_found", `smoodly: no collection named "${collection}".`);
532
+ const d = descriptors[field];
533
+ if (d?.type !== "tags")
534
+ return err("validation", `smoodly: "${field}" is not a tags field of "${collection}".`);
535
+ const records = await entries.list(collection);
536
+ const values = d.localized === true
537
+ ? records.flatMap((r) => (r.locales[locale] ? [r.locales[locale].fields[field]] : []))
538
+ : records.map((r) => r.fields[field]);
539
+ return ok(distinctTags(values));
540
+ }),
522
541
  get: (collection, id, locale) => guard(async () => {
523
542
  const unsupported = supportedLocale(locale);
524
543
  if (unsupported)
@@ -131,6 +131,10 @@ export type AdminOps = {
131
131
  };
132
132
  entries: {
133
133
  list(collection: string): Promise<OpResult<EntryRecord[]>>;
134
+ /** The distinct tags one top-level `f.tags()` field holds across the collection's working copies
135
+ * (drafts included), for the chip widget's suggestions (spec 2026-09-17 §6). A localized field
136
+ * reads `locale`'s rows; a shared one reads every entry. `validation` for any other field. */
137
+ distinct(collection: string, field: string, locale: string): Promise<OpResult<string[]>>;
134
138
  /** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
135
139
  get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
136
140
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
@@ -85,6 +85,8 @@ export type AdminRegistry = {
85
85
  maxSize: number;
86
86
  accept: string[];
87
87
  };
88
+ /** The admin's mount path: every link and the client router sit under it (spec 2026-09-17 §3). */
89
+ adminPath: string;
88
90
  };
89
91
  /** A registered section's settings descriptors — what a visual global's form shows (spec 2026-09-14 §3). */
90
92
  export declare function sectionSettings(config: ResolvedConfig, name: string): Record<string, Descriptor>;
@@ -107,5 +107,6 @@ export function serializeAdminConfig(config) {
107
107
  homePageSlug: config.homePageSlug,
108
108
  pageDepth: config.pageDepth,
109
109
  media: { maxSize: config.media.maxSize, accept: [...config.media.accept] },
110
+ adminPath: config.admin.path,
110
111
  };
111
112
  }
@@ -24,6 +24,8 @@ import { LoginView } from "./LoginView.js";
24
24
  import { useAdminSession } from "./session.js";
25
25
  import { IconRail } from "./IconRail.js";
26
26
  import { useRoute } from "./router.js";
27
+ import { AdminPathProvider } from "./base.js";
28
+ import { mountMismatch } from "../../admin-path.js";
27
29
  import { ErrorPane } from "./ErrorPane.js";
28
30
  import { MediaScreen } from "./MediaScreen.js";
29
31
  import { AssetsProvider } from "../forms/assets-context.js";
@@ -31,13 +33,17 @@ import { ElementsProvider } from "../forms/elements-context.js";
31
33
  export function AdminApp({ registry, op, auth }) {
32
34
  const session = useAdminSession(auth);
33
35
  const [ops] = useState(() => makeClientOps(op, session.token));
34
- const route = useRoute();
36
+ const route = useRoute(registry.adminPath);
35
37
  const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
36
38
  const isCollection = registry.collections.some((c) => c.name === head);
37
39
  const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
38
40
  if (session.state.status === "loading") {
39
41
  return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
40
42
  }
43
+ const mismatch = mountMismatch(route.pathname, registry.adminPath);
44
+ if (mismatch) {
45
+ return _jsx("div", { style: { height: "100vh", display: "flex" }, children: _jsx(ErrorPane, { message: mismatch, back: false }) });
46
+ }
41
47
  if (session.state.status === "signed_out")
42
48
  return _jsx(LoginView, { client: session.client, providers: auth.providers });
43
49
  let main;
@@ -57,5 +63,5 @@ export function AdminApp({ registry, op, auth }) {
57
63
  main = _jsx(MediaScreen, {});
58
64
  else
59
65
  main = _jsx(ErrorPane, { message: "Not found." });
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 ?? ""}`)] }) }) }));
66
+ return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _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 ?? ""}`)] }) }) }) }));
61
67
  }
@@ -12,7 +12,7 @@ import { statusLabel } from "../ui/status-label.js";
12
12
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
13
13
  import { relativeTime } from "../ui/format.js";
14
14
  import { useSortable } from "../ui/useSortable.js";
15
- import { BASE } from "./base.js";
15
+ import { useAdminHref } from "./base.js";
16
16
  import { navigate } from "./router.js";
17
17
  import { ErrorPane } from "./ErrorPane.js";
18
18
  const LOCALE_KEY = "smoodly.entries.locale";
@@ -30,6 +30,7 @@ function rememberedLocale(registry) {
30
30
  return registry.locales.default;
31
31
  }
32
32
  export function EntriesList({ ops, registry, collection }) {
33
+ const adminHref = useAdminHref();
33
34
  const meta = registry.collections.find((c) => c.name === collection);
34
35
  const [records, setRecords] = useState(null);
35
36
  const [error, setError] = useState(null);
@@ -74,7 +75,7 @@ export function EntriesList({ ops, registry, collection }) {
74
75
  if (error)
75
76
  return _jsx(ErrorPane, { message: error });
76
77
  const open = (id) => {
77
- navigate(`${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
78
+ navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
78
79
  };
79
80
  /** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
80
81
  const createEntry = async () => {
@@ -18,6 +18,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
18
18
  import { useEffect, useMemo, useRef, useState } from "react";
19
19
  import { FieldWidget } from "../forms/FieldWidget.js";
20
20
  import { revealField } from "../forms/reveal.js";
21
+ import { useRefOptions } from "../forms/useRefOptions.js";
22
+ import { useTagSuggestions } from "../forms/useTagSuggestions.js";
21
23
  import { useDraftAutosave } from "../editor/autosave.js";
22
24
  import { relativeTime, updatedLine } from "../ui/format.js";
23
25
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
@@ -27,10 +29,13 @@ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
27
29
  import { localeStatusOf } from "./pages-tree.js";
28
30
  import { ResizeHandle } from "../ui/ResizeHandle.js";
29
31
  import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
30
- import { BASE } from "./base.js";
32
+ import { useAdminHref } from "./base.js";
31
33
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
32
34
  import { navigate, useLeaveGuard } from "./router.js";
35
+ /** A stable empty map for the hooks that run before the "no such collection" return. */
36
+ const NO_FIELDS = {};
33
37
  export function EntryForm({ ops, registry, collection, id }) {
38
+ const adminHref = useAdminHref();
34
39
  const meta = registry.collections.find((c) => c.name === collection);
35
40
  const multilingual = registry.locales.supported.length > 1;
36
41
  const [locale, setLocale] = useState(() => localeFromUrl(registry));
@@ -38,7 +43,6 @@ export function EntryForm({ ops, registry, collection, id }) {
38
43
  const [fields, setFields] = useState(null);
39
44
  const [urls, setUrls] = useState({});
40
45
  const [parentLocales, setParentLocales] = useState(registry.locales.supported);
41
- const [refOptions, setRefOptions] = useState({});
42
46
  const [notice, setNotice] = useState(null);
43
47
  const [fieldErrors, setFieldErrors] = useState({});
44
48
  const [adding, setAdding] = useState(false);
@@ -76,6 +80,9 @@ export function EntryForm({ ops, registry, collection, id }) {
76
80
  }
77
81
  return out;
78
82
  }, [meta]);
83
+ const refTargetList = useMemo(() => Object.values(refTargets), [refTargets]);
84
+ const refOptions = useRefOptions(ops, registry, locale, refTargetList);
85
+ const tagSuggestions = useTagSuggestions(ops, collection, meta?.fields ?? NO_FIELDS, locale);
79
86
  const localeKeys = useMemo(() => (meta ? localizedFieldKeys(meta) : []), [meta]);
80
87
  // Load the locale's merged fields; an absent locale comes back with fields: null.
81
88
  useEffect(() => {
@@ -99,27 +106,6 @@ export function EntryForm({ ops, registry, collection, id }) {
99
106
  setNotice({ tone: "error", text: "Couldn't reach the server." }); });
100
107
  return () => { cancelled = true; };
101
108
  }, [ops, collection, id, locale, reloadKey]);
102
- // Ref options in the locale being edited: a target absent there is not offered.
103
- useEffect(() => {
104
- let cancelled = false;
105
- for (const target of new Set(Object.values(refTargets))) {
106
- const targetMeta = registry.collections.find((c) => c.name === target);
107
- ops.entries.list(target).then((r) => {
108
- // A list issued for the previous locale must not overwrite this one's.
109
- if (cancelled || !r.ok)
110
- return;
111
- const options = r.data
112
- .filter((e) => e.locales[locale])
113
- .map((e) => ({
114
- id: e.id,
115
- title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
116
- status: e.locales[locale].status,
117
- }));
118
- setRefOptions((prev) => ({ ...prev, [target]: options }));
119
- }).catch(console.error);
120
- }
121
- return () => { cancelled = true; };
122
- }, [ops, refTargets, registry, locale]);
123
109
  if (!meta)
124
110
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
125
111
  const row = record?.locales[locale];
@@ -308,7 +294,7 @@ export function EntryForm({ ops, registry, collection, id }) {
308
294
  return;
309
295
  const r = await ops.entries.delete(collection, id);
310
296
  if (r.ok)
311
- navigate(`${BASE}/${collection}`, { force: true });
297
+ navigate(adminHref(`/${collection}`), { force: true });
312
298
  else
313
299
  fail(r);
314
300
  };
@@ -317,19 +303,19 @@ export function EntryForm({ ops, registry, collection, id }) {
317
303
  const base = meta.path?.[locale];
318
304
  const slugPrefix = base ? `/${base}/` : undefined;
319
305
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
320
- 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) => {
306
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
321
307
  setFields((f) => ({ ...(f ?? {}), [key]: value }));
322
308
  setDirty(true);
323
309
  setTouched(true);
324
310
  mark();
325
311
  } }, key));
326
312
  const absent = record !== null && fields === null && !record.locales[locale];
327
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [
313
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref(`/${collection}`), items: [
328
314
  {
329
- label: meta.title, href: `${BASE}/${collection}`,
315
+ label: meta.title, href: adminHref(`/${collection}`),
330
316
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
331
317
  },
332
318
  { label: heading },
333
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
319
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
334
320
  revealField(formRef.current, path); } })] })), 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: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_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" })] }))] })] })] }));
335
321
  }
@@ -1,4 +1,6 @@
1
- /** A whole-column failure: nothing to show but the message and a way back. */
2
- export declare function ErrorPane({ message }: {
1
+ /** A whole-column failure: nothing to show but the message and a way
2
+ * back — none when the shell is mounted outside its own path. */
3
+ export declare function ErrorPane({ message, back }: {
3
4
  message: string;
5
+ back?: boolean;
4
6
  }): import("react").JSX.Element;
@@ -2,8 +2,10 @@
2
2
  "use client";
3
3
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { Banner } from "../ui/primitives.js";
5
- import { BASE } from "./base.js";
6
- /** A whole-column failure: nothing to show but the message and a way back. */
7
- export function ErrorPane({ message }) {
8
- return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), _jsx("a", { href: `${BASE}/pages`, style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
5
+ import { useAdminHref } from "./base.js";
6
+ /** A whole-column failure: nothing to show but the message and a way
7
+ * back — none when the shell is mounted outside its own path. */
8
+ export function ErrorPane({ message, back = true }) {
9
+ const adminHref = useAdminHref();
10
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), back && _jsx("a", { href: adminHref("/pages"), style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
9
11
  }
@@ -1,11 +1,8 @@
1
1
  import type { AdminGlobal } from "../serialize.ts";
2
2
  import type { Group } from "../forms/tabs.ts";
3
3
  import { type NodeValues } from "../editor/InspectorFields.tsx";
4
- export type RefOption = {
5
- id: string;
6
- title: string;
7
- status: string;
8
- };
4
+ import type { RefOption } from "../forms/ref-picker.ts";
5
+ export type { RefOption };
9
6
  /** The item's value map as the inspector reads it: the reserved keys pulled out into their namespaces. */
10
7
  export declare function globalValues(fields: Record<string, unknown>): NodeValues;
11
8
  export declare function GlobalFields({ item, groups, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }: {
@@ -15,7 +12,7 @@ export declare function GlobalFields({ item, groups, fields, errors, refOptions,
15
12
  fields: Record<string, unknown>;
16
13
  errors?: Record<string, string>;
17
14
  /** Loaded ref options by target collection. */
18
- refOptions: Record<string, RefOption[]>;
15
+ refOptions: Record<string, RefOption[] | undefined>;
19
16
  multilingual: boolean;
20
17
  /** The content field with an active inline session: its richtext widget yields to a note. */
21
18
  inlinePath?: string;
@@ -14,6 +14,6 @@ export function GlobalFields({ item, groups, fields, errors, refOptions, multili
14
14
  return (_jsx(InspectorFields, { groups: groups, values: values, render: ({ key, ns, descriptor }, { emptyLabel, scope }) => {
15
15
  const target = (descriptor.type === "ref" || descriptor.type === "refList") && typeof descriptor.target === "string" ? descriptor.target : null;
16
16
  const localized = ns === "fields" && item.fields[key]?.localized === true;
17
- return (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(values, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, errors: errors, refOptions: target ? refOptions[target] ?? [] : undefined, mark: ns === "fields" && multilingual && !localized ? _jsx(UnlocalizedMark, {}) : undefined, inlinePath: ns === "fields" ? inlinePath : undefined, onEditHere: onEditHere, onChange: (value) => onChange(ns, key, value) }, `${ns}.${key}`));
17
+ return (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(values, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, errors: errors, refOptions: target ? refOptions[target] : undefined, mark: ns === "fields" && multilingual && !localized ? _jsx(UnlocalizedMark, {}) : undefined, inlinePath: ns === "fields" ? inlinePath : undefined, onEditHere: onEditHere, onChange: (value) => onChange(ns, key, value) }, `${ns}.${key}`));
18
18
  } }));
19
19
  }
@@ -15,6 +15,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
15
15
  import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
16
16
  import { tabsFor } from "../forms/tabs.js";
17
17
  import { revealField } from "../forms/reveal.js";
18
+ import { useRefOptions } from "../forms/useRefOptions.js";
18
19
  import { useDraftAutosave } from "../editor/autosave.js";
19
20
  import { updatedLine } from "../ui/format.js";
20
21
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
@@ -23,19 +24,18 @@ import { PublishControl } from "../ui/PublishControl.js";
23
24
  import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
24
25
  import { localeStatusOf } from "./pages-tree.js";
25
26
  import { ResizeHandle } from "../ui/ResizeHandle.js";
26
- import { entryTitle } from "./entries-list.js";
27
- import { BASE } from "./base.js";
27
+ import { useAdminHref } from "./base.js";
28
28
  import { GlobalFields } from "./GlobalFields.js";
29
29
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
30
30
  import { useLeaveGuard } from "./router.js";
31
31
  export function GlobalForm({ ops, registry, name }) {
32
+ const adminHref = useAdminHref();
32
33
  const item = registry.globals.find((s) => s.name === name);
33
34
  const multilingual = registry.locales.supported.length > 1;
34
35
  const [locale, setLocale] = useState(() => localeFromUrl(registry));
35
36
  const [record, setRecord] = useState(null);
36
37
  const [fields, setFields] = useState(null);
37
38
  const [usage, setUsage] = useState([]);
38
- const [refOptions, setRefOptions] = useState({});
39
39
  const [notice, setNotice] = useState(null);
40
40
  const [fieldErrors, setFieldErrors] = useState({});
41
41
  const [dirty, setDirty] = useState(false);
@@ -67,6 +67,8 @@ export function GlobalForm({ ops, registry, name }) {
67
67
  }
68
68
  return out;
69
69
  }, [item]);
70
+ const refTargetList = useMemo(() => Object.values(refTargets), [refTargets]);
71
+ const refOptions = useRefOptions(ops, registry, locale, refTargetList);
70
72
  useEffect(() => {
71
73
  let cancelled = false;
72
74
  setFields(null);
@@ -87,22 +89,6 @@ export function GlobalForm({ ops, registry, name }) {
87
89
  setNotice({ tone: "error", text: "Couldn't reach the server." }); });
88
90
  return () => { cancelled = true; };
89
91
  }, [ops, name, locale, reloadKey]);
90
- // Ref options in the locale being edited — the entry form's rule.
91
- useEffect(() => {
92
- let cancelled = false;
93
- for (const target of new Set(Object.values(refTargets))) {
94
- const targetMeta = registry.collections.find((c) => c.name === target);
95
- ops.entries.list(target).then((r) => {
96
- if (cancelled || !r.ok)
97
- return;
98
- const options = r.data
99
- .filter((e) => e.locales[locale])
100
- .map((e) => ({ id: e.id, title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id, status: e.locales[locale].status }));
101
- setRefOptions((prev) => ({ ...prev, [target]: options }));
102
- }).catch(console.error);
103
- }
104
- return () => { cancelled = true; };
105
- }, [ops, refTargets, registry, locale]);
106
92
  if (!item)
107
93
  return _jsx("p", { style: { padding: 32 }, children: "No such global item." });
108
94
  const row = record?.locales[locale];
@@ -247,12 +233,12 @@ export function GlobalForm({ ops, registry, name }) {
247
233
  setTouched(true);
248
234
  mark();
249
235
  };
250
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/globals`, items: [
236
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
251
237
  {
252
- label: "Globals", href: `${BASE}/globals`,
238
+ label: "Globals", href: adminHref("/globals"),
253
239
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
254
240
  },
255
241
  { label: item.title },
256
242
  ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
257
- revealField(formRef.current, path); } })] })), 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: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_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: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
243
+ revealField(formRef.current, path); } })] })), 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: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_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: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
258
244
  }
@@ -12,7 +12,7 @@ import { statusLabel } from "../ui/status-label.js";
12
12
  import { StatusBadge } from "../ui/primitives.js";
13
13
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
14
14
  import { relativeTime } from "../ui/format.js";
15
- import { BASE } from "./base.js";
15
+ import { useAdminHref } from "./base.js";
16
16
  import { ErrorPane } from "./ErrorPane.js";
17
17
  const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
18
18
  function rememberedLocale(registry) {
@@ -27,6 +27,7 @@ function rememberedLocale(registry) {
27
27
  return registry.locales.default;
28
28
  }
29
29
  export function GlobalList({ ops, registry }) {
30
+ const adminHref = useAdminHref();
30
31
  const [items, setItems] = useState(null);
31
32
  const [error, setError] = useState(null);
32
33
  const [locale, setLocale] = useState(() => rememberedLocale(registry));
@@ -54,7 +55,7 @@ export function GlobalList({ ops, registry }) {
54
55
  { label: "Status", width: "170px" },
55
56
  ], empty: items ? "No globals are registered." : "Loading…", rows: rows.map((row) => ({
56
57
  key: row.name,
57
- href: `${BASE}/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`,
58
+ href: adminHref(`/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`),
58
59
  cells: [
59
60
  _jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
60
61
  _jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
@@ -20,7 +20,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
20
  import { useEffect, useId, useRef, useState } from "react";
21
21
  import { Globe, Image, LibraryBig, PanelTop, Settings } from "lucide-react";
22
22
  import { ThemeToggle } from "../ui/primitives.js";
23
- import { BASE } from "./base.js";
23
+ import { useAdminHref } from "./base.js";
24
24
  import { RouteLink } from "./RouteLink.js";
25
25
  /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
26
26
  const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
@@ -30,6 +30,7 @@ function AppIcon() {
30
30
  return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 89 86", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { fill: "#0038B1", d: "M0 51.5552C0 24.3141 25.0505 0 52.5493 0H52.7761C74.4707 0 89 14.6336 89 34.445C89 61.6862 64.4034 86 36.0066 86C14.0946 86 0 70.9165 0 51.5552Z" }), _jsx("path", { fill: "#FFFFFF", d: "M23.25 46.1188C23.25 33.0027 34.8806 21.2959 47.6479 21.2959H47.7532C57.8257 21.2959 64.5714 28.3417 64.5714 37.8805C64.5714 50.9966 53.1516 62.7033 39.9674 62.7033C29.7939 62.7033 23.25 55.4409 23.25 46.1188Z" })] }));
31
31
  }
32
32
  export function IconRail({ registry, active, email, onSignOut }) {
33
+ const adminHref = useAdminHref();
33
34
  const root = useRef(null);
34
35
  const uid = useId();
35
36
  const [open, setOpen] = useState(null);
@@ -77,5 +78,5 @@ export function IconRail({ registry, active, email, onSignOut }) {
77
78
  const collectionsOpen = open === "collections" || (hovered && open === null);
78
79
  const name = email?.split("@")[0] ?? "Editor";
79
80
  const initials = name.slice(0, 2).toUpperCase();
80
- 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(PanelTop, { ...ICON }), `${BASE}/pages`), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: 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(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), `${BASE}/media`), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), `${BASE}/globals`), link("settings", "Settings", _jsx(Settings, { ...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" }) })] })] })] }));
81
+ 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(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), adminHref("/globals")), link("settings", "Settings", _jsx(Settings, { ...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" }) })] })] })] }));
81
82
  }
@@ -14,7 +14,7 @@ import { Button, StatusBadge } from "../ui/primitives.js";
14
14
  import { statusLabel } from "../ui/status-label.js";
15
15
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
16
16
  import { useSortable } from "../ui/useSortable.js";
17
- import { BASE } from "./base.js";
17
+ import { useAdminHref } from "./base.js";
18
18
  import { navigate } from "./router.js";
19
19
  import { ErrorPane } from "./ErrorPane.js";
20
20
  const LOCALE_KEY = "smoodly.pages.locale";
@@ -32,6 +32,7 @@ function rememberedLocale(registry) {
32
32
  return registry.locales.default;
33
33
  }
34
34
  export function PagesList({ ops, registry }) {
35
+ const adminHref = useAdminHref();
35
36
  const [pages, setPages] = useState(null);
36
37
  const [error, setError] = useState(null);
37
38
  const [collapsed, setCollapsed] = useState(() => new Set());
@@ -80,7 +81,7 @@ export function PagesList({ ops, registry }) {
80
81
  });
81
82
  /** The editor opens in the locale selected here. */
82
83
  const openEditor = (id) => {
83
- navigate(`${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
84
+ navigate(adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
84
85
  };
85
86
  /** Run an op; on success reload the list (and maybe navigate), else show the op's message. Resolves to whether it succeeded. */
86
87
  const run = async (work, then) => {
@@ -192,7 +193,7 @@ export function PagesList({ ops, registry }) {
192
193
  else if (row.template !== null)
193
194
  openEditor(row.id);
194
195
  else if (row.kind === "mount" && row.collection)
195
- navigate(`${BASE}/${row.collection}`);
196
+ navigate(adminHref(`/${row.collection}`));
196
197
  else if (row.hasChildren)
197
198
  toggle(row.id);
198
199
  };
@@ -1 +1,4 @@
1
- export declare const BASE = "/admin";
1
+ export declare const AdminPathProvider: import("react").Provider<string>;
2
+ export declare const useAdminPath: () => string;
3
+ /** `adminHref("/pages")` → "/admin/pages" — the path starts with "/". */
4
+ export declare function useAdminHref(): (path: string) => string;
@@ -1,4 +1,16 @@
1
+ "use client";
1
2
  // packages/smoodly/src/admin/shell/base.ts
2
- // The admin's mount path — shared by every shell view and route link so
3
- // there is exactly one place that knows it.
4
- export const BASE = "/admin";
3
+ // The admin's mount path inside the shell (spec 2026-09-17 §3): AdminApp
4
+ // provides registry.adminPath, every view builds its links through
5
+ // useAdminHref(). Without a provider the path is the default, so a
6
+ // component rendered alone (a test, a story) links under /admin.
7
+ import { createContext, useCallback, useContext } from "react";
8
+ import { DEFAULT_ADMIN_PATH, adminHref } from "../../admin-path.js";
9
+ const AdminPathContext = createContext(DEFAULT_ADMIN_PATH);
10
+ export const AdminPathProvider = AdminPathContext.Provider;
11
+ export const useAdminPath = () => useContext(AdminPathContext);
12
+ /** `adminHref("/pages")` → "/admin/pages" — the path starts with "/". */
13
+ export function useAdminHref() {
14
+ const base = useContext(AdminPathContext);
15
+ return useCallback((path) => adminHref(base, path), [base]);
16
+ }