smoodly 0.0.9 → 0.0.11

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 (125) hide show
  1. package/README.md +167 -18
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  4. package/dist/admin/editor/CanvasOverlay.js +39 -0
  5. package/dist/admin/editor/EditorView.js +56 -237
  6. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  7. package/dist/admin/editor/InsertPicker.js +18 -0
  8. package/dist/admin/editor/NoticePane.d.ts +4 -0
  9. package/dist/admin/editor/NoticePane.js +4 -0
  10. package/dist/admin/editor/Outline.d.ts +18 -0
  11. package/dist/admin/editor/Outline.js +12 -0
  12. package/dist/admin/editor/locale-url.d.ts +12 -0
  13. package/dist/admin/editor/locale-url.js +32 -0
  14. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  15. package/dist/admin/editor/overlay-geometry.js +27 -0
  16. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  17. package/dist/admin/editor/useCanvasBridge.js +53 -0
  18. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  19. package/dist/admin/editor/usePageLocale.js +88 -0
  20. package/dist/admin/forms/CropEditor.d.ts +10 -0
  21. package/dist/admin/forms/CropEditor.js +61 -0
  22. package/dist/admin/forms/FieldWidget.js +8 -26
  23. package/dist/admin/forms/MediaGrid.d.ts +11 -0
  24. package/dist/admin/forms/MediaGrid.js +16 -0
  25. package/dist/admin/forms/MediaPicker.d.ts +8 -0
  26. package/dist/admin/forms/MediaPicker.js +44 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +9 -0
  28. package/dist/admin/forms/MediaWidget.js +83 -0
  29. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  30. package/dist/admin/forms/RichtextToolbar.js +37 -0
  31. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  32. package/dist/admin/forms/RichtextWidget.js +123 -0
  33. package/dist/admin/forms/assets-context.d.ts +19 -0
  34. package/dist/admin/forms/assets-context.js +35 -0
  35. package/dist/admin/forms/crop-editor.d.ts +13 -0
  36. package/dist/admin/forms/crop-editor.js +72 -0
  37. package/dist/admin/forms/measure.d.ts +2 -0
  38. package/dist/admin/forms/measure.js +39 -0
  39. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  40. package/dist/admin/forms/richtext-extensions.js +74 -0
  41. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  42. package/dist/admin/forms/richtext-paste.js +59 -0
  43. package/dist/admin/forms/upload.d.ts +17 -0
  44. package/dist/admin/forms/upload.js +33 -0
  45. package/dist/admin/index.d.ts +1 -2
  46. package/dist/admin/index.js +0 -1
  47. package/dist/admin/next/create-admin.d.ts +3 -7
  48. package/dist/admin/next/create-admin.js +14 -5
  49. package/dist/admin/next/op-handler.d.ts +3 -0
  50. package/dist/admin/next/op-handler.js +1 -0
  51. package/dist/admin/ops-impl.d.ts +3 -0
  52. package/dist/admin/ops-impl.js +83 -2
  53. package/dist/admin/ops.d.ts +27 -0
  54. package/dist/admin/richtext-doc.d.ts +7 -0
  55. package/dist/admin/richtext-doc.js +18 -0
  56. package/dist/admin/serialize.d.ts +5 -0
  57. package/dist/admin/serialize.js +1 -0
  58. package/dist/admin/shell/AdminApp.d.ts +1 -2
  59. package/dist/admin/shell/AdminApp.js +18 -19
  60. package/dist/admin/shell/EntriesList.js +3 -2
  61. package/dist/admin/shell/EntryForm.js +8 -24
  62. package/dist/admin/shell/IconRail.d.ts +7 -0
  63. package/dist/admin/shell/IconRail.js +73 -0
  64. package/dist/admin/shell/ListView.js +3 -1
  65. package/dist/admin/shell/MediaScreen.d.ts +8 -0
  66. package/dist/admin/shell/MediaScreen.js +86 -0
  67. package/dist/admin/shell/PagesList.js +3 -2
  68. package/dist/admin/shell/RouteLink.d.ts +4 -0
  69. package/dist/admin/shell/RouteLink.js +12 -0
  70. package/dist/admin/shell/SharedForm.js +3 -21
  71. package/dist/admin/shell/media-list.d.ts +8 -0
  72. package/dist/admin/shell/media-list.js +14 -0
  73. package/dist/admin/shell/router.d.ts +37 -0
  74. package/dist/admin/shell/router.js +84 -0
  75. package/dist/admin/shell/session.js +3 -1
  76. package/dist/admin/ui/Modal.d.ts +8 -0
  77. package/dist/admin/ui/Modal.js +15 -0
  78. package/dist/admin/ui/primitives.d.ts +3 -2
  79. package/dist/admin/ui/primitives.js +8 -6
  80. package/dist/admin/ui/theme.d.ts +1 -1
  81. package/dist/admin/ui/theme.js +133 -24
  82. package/dist/admin/validate.js +16 -0
  83. package/dist/asset-store.d.ts +86 -0
  84. package/dist/asset-store.js +141 -0
  85. package/dist/collections.d.ts +0 -14
  86. package/dist/collections.js +2 -10
  87. package/dist/config.d.ts +12 -0
  88. package/dist/config.js +17 -5
  89. package/dist/fields.d.ts +10 -11
  90. package/dist/fields.js +16 -2
  91. package/dist/image/SmoodlyImage.d.ts +18 -0
  92. package/dist/image/SmoodlyImage.js +16 -0
  93. package/dist/image/SmoodlyVideo.d.ts +7 -0
  94. package/dist/image/SmoodlyVideo.js +5 -0
  95. package/dist/image/crop-math.d.ts +14 -0
  96. package/dist/image/crop-math.js +38 -0
  97. package/dist/image/index.d.ts +6 -0
  98. package/dist/image/index.js +5 -0
  99. package/dist/image/loaders.d.ts +15 -0
  100. package/dist/image/loaders.js +5 -0
  101. package/dist/image/next-config.d.ts +15 -0
  102. package/dist/image/next-config.js +27 -0
  103. package/dist/image-dimensions.d.ts +4 -0
  104. package/dist/image-dimensions.js +25 -0
  105. package/dist/index.d.ts +12 -4
  106. package/dist/index.js +8 -2
  107. package/dist/media.d.ts +108 -0
  108. package/dist/media.js +129 -0
  109. package/dist/refs.d.ts +2 -0
  110. package/dist/refs.js +25 -4
  111. package/dist/resolve.d.ts +19 -4
  112. package/dist/resolve.js +55 -2
  113. package/dist/site.d.ts +2 -0
  114. package/dist/site.js +21 -3
  115. package/dist/sql-space.d.ts +8 -1
  116. package/dist/sql-space.js +28 -0
  117. package/dist/sql.js +30 -0
  118. package/dist/store.js +3 -0
  119. package/dist/supabase-asset-store.d.ts +21 -0
  120. package/dist/supabase-asset-store.js +170 -0
  121. package/dist/toolset.d.ts +30 -0
  122. package/dist/toolset.js +49 -0
  123. package/package.json +15 -1
  124. package/dist/admin/richtext.d.ts +0 -16
  125. package/dist/admin/richtext.js +0 -98
@@ -1,4 +1,6 @@
1
- import { entryFieldsIn } from "../entry-store.js";
1
+ import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
+ import { ASSET_COLLECTION } from "../media.js";
3
+ import { BASE } from "./shell/base.js";
2
4
  import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
3
5
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
4
6
  import { ensureSharedItems } from "./shared-items.js";
@@ -76,8 +78,10 @@ const classify = (e) => {
76
78
  return err("validation", message);
77
79
  if (message.includes("not a valid slug"))
78
80
  return err("validation", message);
79
- if (message.includes("no page") || message.includes("no entry") || message.includes("no collection"))
81
+ if (message.includes("no page") || message.includes("no entry") || message.includes("no collection") || message.includes("no asset"))
80
82
  return err("not_found", message);
83
+ if (message.includes("not pending"))
84
+ return err("conflict", message);
81
85
  return err("internal", message);
82
86
  };
83
87
  export function createAdminOps(deps) {
@@ -147,6 +151,29 @@ export function createAdminOps(deps) {
147
151
  const def = config.locales.default;
148
152
  /** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
149
153
  const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
154
+ const assets = deps.assets;
155
+ const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
156
+ const mb = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
157
+ const isDim = (v) => v === undefined || (typeof v === "number" && Number.isFinite(v) && v >= 0);
158
+ /** What an asset's usage row points at, labelled for the Media screen. */
159
+ const describeSource = async (sourceKind, sourceId) => {
160
+ if (sourceKind === "page") {
161
+ const record = await pages.getPage(sourceId);
162
+ if (!record)
163
+ return { title: sourceId, href: null };
164
+ return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
165
+ }
166
+ for (const item of config.registry.shared ?? []) {
167
+ if (await entries.get(item.name, sourceId))
168
+ return { title: item.title, href: `${BASE}/shared/${encodeURIComponent(item.name)}` };
169
+ }
170
+ for (const c of config.registry.collections ?? []) {
171
+ const record = await entries.get(c.name, sourceId);
172
+ if (record)
173
+ return { title: entryTitleIn(record, def, c), href: `${BASE}/${c.name}/${sourceId}` };
174
+ }
175
+ return { title: sourceId, href: null };
176
+ };
150
177
  const owned = (record, verb) => err("blocked", `smoodly: "${nameOf(record)}" is owned by code — ${verb}.`);
151
178
  /** The record serving "/" — guarded whether or not a registration claims
152
179
  * its slug: the config's homePageSlug is what routes it. Judged by the
@@ -487,6 +514,60 @@ export function createAdminOps(deps) {
487
514
  return ok(changed);
488
515
  }),
489
516
  },
517
+ assets: {
518
+ list: (query) => guard(async () => (assets ? ok(await assets.list(query)) : noMedia())),
519
+ get: (id) => guard(async () => {
520
+ if (!assets)
521
+ return noMedia();
522
+ const record = await assets.get(id);
523
+ return record ? ok(record) : err("not_found", `smoodly: no asset "${id}".`);
524
+ }),
525
+ beginUpload: (input) => guard(async () => {
526
+ if (!assets)
527
+ return noMedia();
528
+ if (typeof input?.name !== "string" || typeof input.mime !== "string" || typeof input.size !== "number") {
529
+ return err("validation", "smoodly: an upload needs a name, a mime type and a size.");
530
+ }
531
+ if (!config.media.accept.includes(input.mime))
532
+ return err("validation", `smoodly: "${input.mime}" is not an accepted file type.`);
533
+ if (input.size > config.media.maxSize)
534
+ return err("validation", `smoodly: the file is larger than ${mb(config.media.maxSize)}.`);
535
+ return ok(await assets.beginUpload({ filename: input.name, mime: input.mime, size: input.size }));
536
+ }),
537
+ finishUpload: (id, dims) => guard(async () => {
538
+ if (!assets)
539
+ return noMedia();
540
+ const d = dims ?? {};
541
+ if (!isDim(d.width) || !isDim(d.height) || !isDim(d.duration) || (d.posterId !== undefined && typeof d.posterId !== "string")) {
542
+ return err("validation", "smoodly: malformed dimensions.");
543
+ }
544
+ return ok(await assets.finishUpload(id, d));
545
+ }),
546
+ update: (id, meta) => guard(async () => {
547
+ if (!assets)
548
+ return noMedia();
549
+ const patch = {};
550
+ for (const key of ["title", "alt", "caption", "credit"]) {
551
+ const v = (meta ?? {})[key];
552
+ if (v === undefined)
553
+ continue;
554
+ if (v !== null && typeof v !== "string")
555
+ return err("validation", `smoodly: ${key} must be text.`);
556
+ patch[key] = v;
557
+ }
558
+ return ok(await assets.update(id, patch));
559
+ }),
560
+ delete: (id) => guard(async () => (assets ? (await assets.delete(id), ok(null)) : noMedia())),
561
+ usage: (id) => guard(async () => {
562
+ if (!assets)
563
+ return noMedia();
564
+ const rows = await entries.referencesTo(ASSET_COLLECTION, id);
565
+ const out = [];
566
+ for (const r of rows)
567
+ out.push({ ...r, ...(await describeSource(r.sourceKind, r.sourceId)) });
568
+ return ok(out);
569
+ }),
570
+ },
490
571
  preview: {
491
572
  enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
492
573
  disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
@@ -1,6 +1,8 @@
1
1
  import type { PageRecord } from "../store.ts";
2
2
  import type { PageTree } from "../render.tsx";
3
3
  import type { EntryRecord, EntryUsage } from "../entry-store.ts";
4
+ import type { AssetRecord, AssetMeta } from "../media.ts";
5
+ import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-store.ts";
4
6
  import type { FieldError } from "./validate.ts";
5
7
  export type OpError = {
6
8
  ok: false;
@@ -70,6 +72,14 @@ export type SharedGetResult = {
70
72
  /** Who links it: "used on N pages". */
71
73
  usage: EntryUsage[];
72
74
  };
75
+ /** One place an asset is used: the source row, the field, a label and an admin link. */
76
+ export type AssetUsageItem = {
77
+ sourceKind: string;
78
+ sourceId: string;
79
+ sourceField: string;
80
+ title: string;
81
+ href: string | null;
82
+ };
73
83
  export type AdminOps = {
74
84
  pages: {
75
85
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -126,6 +136,23 @@ export type AdminOps = {
126
136
  save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
127
137
  setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
128
138
  };
139
+ assets: {
140
+ /** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
141
+ list(query?: AssetQuery): Promise<OpResult<AssetPage>>;
142
+ get(id: string): Promise<OpResult<AssetRecord>>;
143
+ /** Validates type and size against config.media, inserts a pending row, mints the signed upload URL (spec 2026-09-08 §6). */
144
+ beginUpload(input: {
145
+ name: string;
146
+ mime: string;
147
+ size: number;
148
+ }): Promise<OpResult<UploadTicket>>;
149
+ /** Marks the row ready with what the browser measured. */
150
+ finishUpload(id: string, dims: FinishInput): Promise<OpResult<AssetRecord>>;
151
+ update(id: string, meta: AssetMeta): Promise<OpResult<AssetRecord>>;
152
+ /** Blocked while any draft or published version references the asset. */
153
+ delete(id: string): Promise<OpResult<null>>;
154
+ usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
155
+ };
129
156
  preview: {
130
157
  enable(): Promise<OpResult<null>>;
131
158
  disable(): Promise<OpResult<null>>;
@@ -0,0 +1,7 @@
1
+ /** True when any text node in the tree has non-whitespace text. */
2
+ export declare function hasText(doc: unknown): boolean;
3
+ /** The document as plain JSON. ProseMirror builds node attrs with a null
4
+ * prototype, which React's server-action encoding refuses to serialize
5
+ * (it becomes a temporary reference and the server's structuredClone
6
+ * throws); a JSON round trip gives every object the plain prototype. */
7
+ export declare function plainDoc<T>(doc: T): T;
@@ -0,0 +1,18 @@
1
+ // Shape helpers over a TipTap document, used by the validator (an empty
2
+ // document is missing, spec 2026-09-08 §3). Pure, no TipTap import.
3
+ /** True when any text node in the tree has non-whitespace text. */
4
+ export function hasText(doc) {
5
+ if (!doc || typeof doc !== "object")
6
+ return false;
7
+ const n = doc;
8
+ if (typeof n.text === "string" && n.text.trim() !== "")
9
+ return true;
10
+ return Array.isArray(n.content) && n.content.some(hasText);
11
+ }
12
+ /** The document as plain JSON. ProseMirror builds node attrs with a null
13
+ * prototype, which React's server-action encoding refuses to serialize
14
+ * (it becomes a temporary reference and the server's structuredClone
15
+ * throws); a JSON round trip gives every object the plain prototype. */
16
+ export function plainDoc(doc) {
17
+ return JSON.parse(JSON.stringify(doc));
18
+ }
@@ -60,5 +60,10 @@ export type AdminRegistry = {
60
60
  homePageSlug: string;
61
61
  /** How deep editors may nest pages; 1 = flat. */
62
62
  pageDepth: number;
63
+ /** Upload limits, so a widget can refuse a file before the round trip. */
64
+ media: {
65
+ maxSize: number;
66
+ accept: string[];
67
+ };
63
68
  };
64
69
  export declare function serializeAdminConfig(config: ResolvedConfig): AdminRegistry;
@@ -67,5 +67,6 @@ export function serializeAdminConfig(config) {
67
67
  locales: config.locales,
68
68
  homePageSlug: config.homePageSlug,
69
69
  pageDepth: config.pageDepth,
70
+ media: { maxSize: config.media.maxSize, accept: [...config.media.accept] },
70
71
  };
71
72
  }
@@ -1,9 +1,8 @@
1
1
  import type { AdminOpAction } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
3
  import { type AuthConfig } from "./session.ts";
4
- export declare function AdminApp({ registry, segments, op, auth }: {
4
+ export declare function AdminApp({ registry, op, auth }: {
5
5
  registry: AdminRegistry;
6
- segments: string[];
7
6
  op: AdminOpAction;
8
7
  auth: AuthConfig;
9
8
  }): import("react").JSX.Element;
@@ -3,12 +3,15 @@
3
3
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // The admin shell: registry-derived nav (never separately configured),
5
5
  // list views in the main area, editor/entry views mounted by segment.
6
- // Internal routing = the catch-all's segments; plain <a> navigation.
6
+ // Internal routing is the shell's own (shell/router.ts): the route is read
7
+ // from the URL on mount, then pushState moves between views and the shell
8
+ // never reloads (spec 2026-09-08 client routing). The shell mounts from the
9
+ // admin LAYOUT (next/create-admin.tsx), never from the catch-all page.
7
10
  //
8
- // Layout is the design system's three columns: a 200px nav, then the main
9
- // column whose views own their own 48px top bar (the editor adds the third,
10
- // its 300px inspector). The shell is viewport-height; every column scrolls
11
- // inside itself.
11
+ // Layout is the design system's three columns: the 72px icon rail
12
+ // (shell/IconRail.tsx), then the main column whose views own their own
13
+ // 48px top bar (the editor adds the third, its inspector). The shell is
14
+ // viewport-height; every column scrolls inside itself.
12
15
  import { useState } from "react";
13
16
  import { makeClientOps } from "../client-ops.js";
14
17
  import { PagesList } from "./PagesList.js";
@@ -19,22 +22,16 @@ import { SharedForm } from "./SharedForm.js";
19
22
  import { EditorView } from "../editor/EditorView.js";
20
23
  import { LoginView } from "./LoginView.js";
21
24
  import { useAdminSession } from "./session.js";
22
- import { Logo, ThemeToggle } from "../ui/primitives.js";
23
- import { BASE } from "./base.js";
25
+ import { IconRail } from "./IconRail.js";
26
+ import { useRoute } from "./router.js";
24
27
  import { ErrorPane } from "./ErrorPane.js";
25
- /** Nav items the registry can't produce yet: shown, disabled, so the shape
26
- * of the product is visible — the same set the design system's SideNav has. */
27
- const PLANNED = ["Media", "Settings"];
28
- function Nav({ registry, active, email, onSignOut }) {
29
- const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
30
- const name = email?.split("@")[0] ?? "Editor";
31
- const initials = name.slice(0, 2).toUpperCase();
32
- return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), registry.shared.length > 0 && item(`${BASE}/shared`, "Shared content", "shared"), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
33
- }
34
- export function AdminApp({ registry, segments, op, auth }) {
28
+ import { MediaScreen } from "./MediaScreen.js";
29
+ import { AssetsProvider } from "../forms/assets-context.js";
30
+ export function AdminApp({ registry, op, auth }) {
35
31
  const session = useAdminSession(auth);
36
32
  const [ops] = useState(() => makeClientOps(op, session.token));
37
- const [head = "pages", tail] = segments.length ? segments : ["pages"];
33
+ const route = useRoute();
34
+ const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
38
35
  const isCollection = registry.collections.some((c) => c.name === head);
39
36
  const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
40
37
  if (session.state.status === "loading") {
@@ -55,7 +52,9 @@ export function AdminApp({ registry, segments, op, auth }) {
55
52
  main = _jsx(SharedList, { ops: ops, registry: registry });
56
53
  else if (head === "shared" && tail)
57
54
  main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
55
+ else if (head === "media")
56
+ main = _jsx(MediaScreen, {});
58
57
  else
59
58
  main = _jsx(ErrorPane, { message: "Not found." });
60
- return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));
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 ?? ""}`)] }) }));
61
60
  }
@@ -10,6 +10,7 @@ import { entryRows, entrySourceLocale } from "./entries-list.js";
10
10
  import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
11
11
  import { relativeTime } from "../ui/format.js";
12
12
  import { BASE } from "./base.js";
13
+ import { navigate } from "./router.js";
13
14
  import { ErrorPane } from "./ErrorPane.js";
14
15
  const LOCALE_KEY = "smoodly.entries.locale";
15
16
  /** The selected locale survives a reload within the session; a stored
@@ -49,7 +50,7 @@ export function EntriesList({ ops, registry, collection }) {
49
50
  if (error)
50
51
  return _jsx(ErrorPane, { message: error });
51
52
  const open = (id) => {
52
- window.location.href = `${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`;
53
+ navigate(`${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
53
54
  };
54
55
  const addLocale = async (row) => {
55
56
  const record = records?.find((r) => r.id === row.id);
@@ -72,7 +73,7 @@ export function EntriesList({ ops, registry, collection }) {
72
73
  open(row.id);
73
74
  };
74
75
  const rows = records ? entryRows(records, meta, registry, locale) : [];
75
- 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: () => { window.location.href = `${BASE}/${collection}/new?locale=${encodeURIComponent(locale)}`; }, children: "+ New entry" })] }), columns: [
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: [
76
77
  { label: "Title", width: "minmax(160px,1fr)" },
77
78
  { label: "Updated", width: "minmax(110px,160px)" },
78
79
  { label: "Status", width: "120px" },
@@ -15,29 +15,9 @@ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
15
15
  import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
16
16
  import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
17
17
  import { BASE } from "./base.js";
18
+ import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
19
+ import { navigate, useLeaveGuard } from "./router.js";
18
20
  const STATUSES = ["Draft", "Published"];
19
- /** The locale to edit: from the URL (the list links here with ?locale=), else the default. */
20
- function localeFromUrl(registry) {
21
- try {
22
- const l = new URLSearchParams(window.location.search).get("locale");
23
- if (l && registry.locales.supported.includes(l))
24
- return l;
25
- }
26
- catch {
27
- // no window — the default is fine
28
- }
29
- return registry.locales.default;
30
- }
31
- function writeLocaleToUrl(next) {
32
- try {
33
- const u = new URL(window.location.href);
34
- u.searchParams.set("locale", next);
35
- window.history.replaceState(null, "", u);
36
- }
37
- catch {
38
- // the URL is cosmetic here
39
- }
40
- }
41
21
  export function EntryForm({ ops, registry, collection, id }) {
42
22
  const meta = registry.collections.find((c) => c.name === collection);
43
23
  const isNew = id === "new";
@@ -52,6 +32,9 @@ export function EntryForm({ ops, registry, collection, id }) {
52
32
  const [adding, setAdding] = useState(false);
53
33
  const [reloadKey, setReloadKey] = useState(0);
54
34
  const [dirty, setDirty] = useState(false);
35
+ // A nav click, the Back arrow, a list row: the router asks before
36
+ // leaving unsaved changes; reload and close ask through beforeunload.
37
+ useLeaveGuard(dirty);
55
38
  const refTargets = useMemo(() => {
56
39
  const out = {};
57
40
  for (const [key, d] of Object.entries(meta?.fields ?? {})) {
@@ -123,7 +106,8 @@ export function EntryForm({ ops, registry, collection, id }) {
123
106
  const r = await ops.entries.create(collection, { locale, fields });
124
107
  if (applyResult(r, "Created.") && r.ok) {
125
108
  setDirty(false);
126
- window.location.href = `${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`;
109
+ // force: the create just landed, but React has not flushed setDirty(false) yet.
110
+ navigate(`${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`, { force: true });
127
111
  }
128
112
  }
129
113
  else {
@@ -212,7 +196,7 @@ export function EntryForm({ ops, registry, collection, id }) {
212
196
  return;
213
197
  const r = await ops.entries.delete(collection, id);
214
198
  if (r.ok)
215
- window.location.href = `${BASE}/${collection}`;
199
+ navigate(`${BASE}/${collection}`, { force: true });
216
200
  else
217
201
  setBanner({ tone: "error", text: r.message });
218
202
  };
@@ -0,0 +1,7 @@
1
+ import type { AdminRegistry } from "../serialize.ts";
2
+ export declare function IconRail({ registry, active, email, onSignOut }: {
3
+ registry: AdminRegistry;
4
+ active: string;
5
+ email?: string;
6
+ onSignOut: () => void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,73 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // packages/smoodly/src/admin/shell/IconRail.tsx
4
+ // The admin's primary navigation: the design system's 72px IconRail
5
+ // (design_handoff_smoodly_cms/components/navigation/IconRail), which
6
+ // replaced the 200px SideNav so the visual editor gets the width. Ported
7
+ // markup-for-markup; the handoff's click handlers are the shell's router.
8
+ //
9
+ // Items are registry-derived, never separately configured: Pages, then
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.
15
+ //
16
+ // Flyouts: click toggles, Esc closes and returns focus to the trigger,
17
+ // ↑/↓ move between entries, a pointer down outside closes. A child being
18
+ // active highlights its parent tile and the entry inside the menu.
19
+ import { useEffect, useId, useRef, useState } from "react";
20
+ import { ChevronDown, Image, LayoutPanelTop, Library, Settings2, Square } from "lucide-react";
21
+ import { ThemeToggle } from "../ui/primitives.js";
22
+ import { BASE } from "./base.js";
23
+ import { RouteLink } from "./RouteLink.js";
24
+ /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
25
+ const ICON = { size: 18, strokeWidth: 1.75, "aria-hidden": true };
26
+ /** The app icon, inlined: a package can't ship a file into the host app's
27
+ * /public. Brand blue on white in both themes, so no --logo-filter. */
28
+ function AppIcon() {
29
+ 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" })] }));
30
+ }
31
+ export function IconRail({ registry, active, email, onSignOut }) {
32
+ const root = useRef(null);
33
+ const uid = useId();
34
+ const [open, setOpen] = useState(null);
35
+ const menuId = (key) => `sm-rail-menu${uid}${key}`;
36
+ const toggle = (key) => setOpen((cur) => (cur === key ? null : key));
37
+ const close = () => setOpen(null);
38
+ // Opening moves focus into the menu; a pointer down outside closes it.
39
+ useEffect(() => {
40
+ if (!open || !root.current)
41
+ return;
42
+ root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
43
+ const onPointerDown = (e) => {
44
+ if (e.target instanceof Node && !root.current?.contains(e.target))
45
+ close();
46
+ };
47
+ document.addEventListener("pointerdown", onPointerDown);
48
+ return () => document.removeEventListener("pointerdown", onPointerDown);
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ }, [open]);
51
+ const onKeyDown = (e) => {
52
+ if (!open || !root.current)
53
+ return;
54
+ if (e.key === "Escape") {
55
+ close();
56
+ root.current.querySelector(`[aria-controls="${menuId(open)}"]`)?.focus();
57
+ }
58
+ else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
59
+ const items = Array.from(root.current.querySelectorAll(`#${CSS.escape(menuId(open))} [role=menuitem]`));
60
+ if (!items.length)
61
+ return;
62
+ e.preventDefault();
63
+ const i = items.indexOf(document.activeElement);
64
+ items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
65
+ }
66
+ };
67
+ const link = (key, label, icon, href) => (_jsx("li", { className: "sm-rail__item", children: href ? (_jsxs(RouteLink, { className: "sm-rail__btn", href: href, "aria-current": key === active ? "page" : undefined, onClick: close, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", children: label })] })) : (_jsxs("a", { className: "sm-rail__btn", "aria-disabled": true, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", children: label })] })) }));
68
+ const collections = registry.collections;
69
+ const collectionActive = collections.some((c) => c.name === active);
70
+ const name = email?.split("@")[0] ?? "Editor";
71
+ 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
+ }
@@ -2,11 +2,13 @@
2
2
  "use client";
3
3
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { TopBar } from "../ui/primitives.js";
5
+ import { navigate } from "./router.js";
5
6
  export function ListView({ title, count, actions, columns, rows, empty }) {
6
7
  const grid = { gridTemplateColumns: columns.map((c) => c.width).join(" ") };
7
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) => {
8
9
  const clickable = Boolean(r.href || r.onClick);
9
- const go = r.onClick ?? (r.href ? () => { window.location.href = r.href; } : undefined);
10
+ const href = r.href;
11
+ const go = r.onClick ?? (href ? () => { navigate(href); } : undefined);
10
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 === " ")) {
11
13
  e.preventDefault();
12
14
  go();
@@ -0,0 +1,8 @@
1
+ import { type ReactElement } from "react";
2
+ import type { AssetRecord } from "../../media.ts";
3
+ import type { AssetUsageItem } from "../ops.ts";
4
+ export declare function MediaScreen({ initialItems, initialSelected, initialUsage }: {
5
+ initialItems?: AssetRecord[];
6
+ initialSelected?: AssetRecord | null;
7
+ initialUsage?: AssetUsageItem[];
8
+ }): ReactElement;
@@ -0,0 +1,86 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The Media screen (spec 2026-09-08 media §10): the library grid full
4
+ // page with search, a kind filter and upload; an inspector on selection
5
+ // with the asset's metadata (saved on blur), its facts and where it is
6
+ // used. Delete stays disabled with the reason while anything uses it.
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { useAssets } from "../forms/assets-context.js";
9
+ import { uploadFile } from "../forms/upload.js";
10
+ import { MediaGrid } from "../forms/MediaGrid.js";
11
+ import { Banner, Button, Field, Segmented, TopBar } from "../ui/primitives.js";
12
+ import { RouteLink } from "./RouteLink.js";
13
+ import { KIND_FILTERS, acceptFor, formatBytes, kindOfFilter, usageLabel } from "./media-list.js";
14
+ const META = ["title", "alt", "caption", "credit"];
15
+ export function MediaScreen({ initialItems = [], initialSelected = null, initialUsage = [] }) {
16
+ const assets = useAssets();
17
+ const [items, setItems] = useState(initialItems);
18
+ const [search, setSearch] = useState("");
19
+ const [filter, setFilter] = useState("All");
20
+ const [selected, setSelected] = useState(initialSelected);
21
+ const [usage, setUsage] = useState(initialUsage);
22
+ const [meta, setMeta] = useState({
23
+ title: initialSelected?.title ?? "", alt: initialSelected?.alt ?? "", caption: initialSelected?.caption ?? "", credit: initialSelected?.credit ?? "",
24
+ });
25
+ const [busy, setBusy] = useState(false);
26
+ const [error, setError] = useState(null);
27
+ const load = useCallback(() => {
28
+ assets.ops.list({ kind: kindOfFilter(filter), search: search || undefined })
29
+ .then((r) => (r.ok ? setItems(r.data.items) : setError(r.message)))
30
+ .catch(() => setError("Couldn't reach the server."));
31
+ }, [assets, filter, search]);
32
+ useEffect(load, [load]);
33
+ useEffect(() => {
34
+ if (!selected)
35
+ return;
36
+ setMeta({ title: selected.title ?? "", alt: selected.alt ?? "", caption: selected.caption ?? "", credit: selected.credit ?? "" });
37
+ if (initialSelected?.id === selected.id)
38
+ return;
39
+ assets.ops.usage(selected.id).then((r) => setUsage(r.ok ? r.data : [])).catch(() => setUsage([]));
40
+ }, [selected, assets, initialSelected]);
41
+ const saveMeta = async (key) => {
42
+ if (!selected)
43
+ return;
44
+ const next = meta[key].trim() || null;
45
+ if ((selected[key] ?? null) === next)
46
+ return;
47
+ const r = await assets.ops.update(selected.id, { [key]: next });
48
+ if (!r.ok) {
49
+ setError(r.message);
50
+ return;
51
+ }
52
+ assets.remember(r.data);
53
+ setSelected(r.data);
54
+ setItems((all) => all.map((a) => (a.id === r.data.id ? r.data : a)));
55
+ };
56
+ const onFiles = async (files) => {
57
+ const file = files?.[0];
58
+ if (!file)
59
+ return;
60
+ setBusy(true);
61
+ setError(null);
62
+ const r = await uploadFile(assets.ops, file, assets.upload);
63
+ setBusy(false);
64
+ if (!r.ok) {
65
+ setError(r.message);
66
+ return;
67
+ }
68
+ assets.remember(r.data);
69
+ setSelected(r.data);
70
+ load();
71
+ };
72
+ const remove = async () => {
73
+ if (!selected || !window.confirm(`Delete "${selected.title ?? selected.filename}"? This cannot be undone.`))
74
+ return;
75
+ const r = await assets.ops.delete(selected.id);
76
+ if (!r.ok) {
77
+ setError(r.message);
78
+ return;
79
+ }
80
+ setSelected(null);
81
+ load();
82
+ };
83
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: "Media" }), _jsx("span", { className: "sm-count", children: items.length })] }), right: _jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { width: 220 } }), _jsx(Segmented, { options: KIND_FILTERS, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(["image", "video", "file"], assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }) }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsxs("div", { className: "sm-mediascreen", style: selected ? undefined : { gridTemplateColumns: "1fr" }, children: [_jsx("div", { className: "sm-mediascreen__grid", children: _jsx(MediaGrid, { items: items, selectedId: selected?.id, onSelect: (a) => setSelected(a.id === selected?.id ? null : a) }) }), selected && (_jsxs("aside", { className: "sm-mediascreen__inspector", children: [_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: selected.width && selected.height ? `${selected.width} / ${selected.height}` : "4 / 3" }, children: selected.kind === "image" && _jsx("img", { src: selected.url, alt: "", style: { left: 0, top: 0, width: "100%", height: "100%" } }) }), META.map((key) => (_jsx(Field, { label: key === "alt" ? "Alt text" : key[0].toUpperCase() + key.slice(1), children: _jsx("input", { className: "sm-input", value: meta[key], onChange: (e) => setMeta((m) => ({ ...m, [key]: e.target.value })), onBlur: () => { void saveMeta(key); } }) }, key))), _jsxs("div", { className: "sm-facts", children: [_jsx("span", { children: "File" }), _jsx("span", { children: selected.filename }), _jsx("span", { children: "Type" }), _jsx("span", { children: selected.mime }), _jsx("span", { children: "Size" }), _jsx("span", { children: formatBytes(selected.size) }), selected.width && selected.height && _jsxs(_Fragment, { children: [_jsx("span", { children: "Dimensions" }), _jsxs("span", { children: [selected.width, "\u00D7", selected.height] })] }), selected.duration !== null && _jsxs(_Fragment, { children: [_jsx("span", { children: "Duration" }), _jsxs("span", { children: [Math.round(selected.duration), " s"] })] })] }), _jsxs("div", { className: "sm-usage", children: [_jsx("span", { className: "sm-label", children: usageLabel(usage.length) }), usage.map((u, i) => u.href
84
+ ? _jsxs(RouteLink, { href: u.href, children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`)
85
+ : _jsxs("span", { children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`))] }), _jsx(Button, { variant: "secondary", disabled: usage.length > 0, title: usage.length > 0 ? "Remove it from those places first." : undefined, onClick: () => { void remove(); }, children: "Delete" })] }))] })] }));
86
+ }
@@ -12,6 +12,7 @@ import { ListView } from "./ListView.js";
12
12
  import { openTemplates, pagesTreeRows, sourceLocaleOf } from "./pages-tree.js";
13
13
  import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
14
14
  import { BASE } from "./base.js";
15
+ import { navigate } from "./router.js";
15
16
  import { ErrorPane } from "./ErrorPane.js";
16
17
  const LOCALE_KEY = "smoodly.pages.locale";
17
18
  /** The selected locale survives a reload within the session; a stored
@@ -56,7 +57,7 @@ export function PagesList({ ops, registry }) {
56
57
  next.add(id); return next; });
57
58
  /** The editor opens in the locale selected here. */
58
59
  const openEditor = (id) => {
59
- window.location.href = `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`;
60
+ navigate(`${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
60
61
  };
61
62
  /** Run an op; on success reload the list (and maybe navigate), else show the op's message. */
62
63
  const run = async (work, then) => {
@@ -167,7 +168,7 @@ export function PagesList({ ops, registry }) {
167
168
  if (!row.present)
168
169
  addLocale(row);
169
170
  else if (row.kind === "mount" && row.collection)
170
- window.location.href = `${BASE}/${row.collection}`;
171
+ navigate(`${BASE}/${row.collection}`);
171
172
  else if (row.template !== null)
172
173
  openEditor(row.id);
173
174
  else if (row.hasChildren)
@@ -0,0 +1,4 @@
1
+ import type { AnchorHTMLAttributes } from "react";
2
+ export declare function RouteLink({ href, onClick, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement> & {
3
+ href: string;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { isPlainLeftClick, navigate } from "./router.js";
4
+ export function RouteLink({ href, onClick, ...rest }) {
5
+ return (_jsx("a", { href: href, ...rest, onClick: (e) => {
6
+ onClick?.(e);
7
+ if (isPlainLeftClick(e, rest.target)) {
8
+ e.preventDefault();
9
+ navigate(href);
10
+ }
11
+ } }));
12
+ }