smoodly 0.0.57 → 0.0.59

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.
@@ -1,8 +1,9 @@
1
- import type { ReactElement } from "react";
1
+ import { type ReactElement } from "react";
2
2
  import type { AssetRecord } from "../../media.ts";
3
- export declare function AssetThumb({ asset, className }: {
3
+ export declare function AssetThumb({ asset, className, width }: {
4
4
  asset: AssetRecord;
5
5
  className?: string;
6
+ width?: 320 | 640;
6
7
  }): ReactElement;
7
8
  export declare function MediaGrid({ items, selectedId, onSelect }: {
8
9
  items: AssetRecord[];
@@ -1,15 +1,45 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The library grid the picker and the Media screen share: one card per
4
+ // asset — an image's thumbnail (a small cut where the service cuts, spec
5
+ // 2026-10-18 media on R2 and Images §9), a video's poster, a file's chip.
6
+ import { useEffect, useState } from "react";
3
7
  import { formatBytes } from "../shell/media-list.js";
8
+ import { useAssets } from "./assets-context.js";
4
9
  import { adminImageSrc } from "./crop-editor.js";
5
- export function AssetThumb({ asset, className }) {
10
+ /** A video's picture (2026-11-13): its poster, the frame the upload grabbed, drawn as the image it is — a cut where
11
+ * the service cuts, and nothing of the video's bytes fetched for a card. A video with no poster (the browser that
12
+ * uploaded it could not decode it, or its poster is gone) asks the browser for its opening frame instead: the
13
+ * `#t` fragment is what makes Safari draw one, and a browser that cannot decode it leaves the "Video" chip showing. */
14
+ function VideoThumb({ asset, className, width }) {
15
+ const assets = useAssets();
16
+ const [poster, setPoster] = useState(null);
17
+ const [failed, setFailed] = useState(false);
18
+ useEffect(() => {
19
+ let live = true;
20
+ setPoster(null);
21
+ setFailed(false);
22
+ if (asset.posterId)
23
+ void assets.get(asset.posterId).then((p) => { if (live) {
24
+ if (p && p.status === "ready")
25
+ setPoster(p);
26
+ else
27
+ setFailed(true);
28
+ } });
29
+ return () => { live = false; };
30
+ }, [assets, asset.posterId]);
31
+ if (poster && !failed)
32
+ return _jsx("img", { className: className, src: adminImageSrc(poster, width), alt: "", loading: "lazy", onError: () => setFailed(true) });
33
+ // The poster is on its way: an empty box, never a flash of the chip.
34
+ if (asset.posterId && !failed)
35
+ return _jsx("span", { className: className });
36
+ return (_jsxs("span", { className: `${className ?? ""} sm-thumb--chip sm-thumb--video`, children: ["Video", _jsx("video", { src: `${asset.url}#t=0.1`, muted: true, playsInline: true, preload: "metadata", tabIndex: -1 })] }));
37
+ }
38
+ export function AssetThumb({ asset, className, width = 320 }) {
6
39
  if (asset.kind === "image")
7
- return _jsx("img", { className: className, src: adminImageSrc(asset, 320), alt: "", loading: "lazy" });
8
- if (asset.kind === "video") {
9
- return asset.posterId
10
- ? _jsx("video", { className: className, src: asset.url, muted: true, playsInline: true, preload: "metadata" })
11
- : _jsx("span", { className: `${className ?? ""} sm-thumb--chip`, children: "Video" });
12
- }
40
+ return _jsx("img", { className: className, src: adminImageSrc(asset, width), alt: "", loading: "lazy" });
41
+ if (asset.kind === "video")
42
+ return _jsx(VideoThumb, { asset: asset, className: className, width: width });
13
43
  return _jsx("span", { className: `${className ?? ""} sm-thumb--chip`, children: asset.filename.split(".").pop()?.toUpperCase() ?? "File" });
14
44
  }
15
45
  export function MediaGrid({ items, selectedId, onSelect }) {
@@ -15,6 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  // takes the broken stripe and lists its stored fields read-only; a
16
16
  // registered one the field's toolset leaves out says so in its head.
17
17
  import { useState } from "react";
18
+ import { GripVertical } from "lucide-react";
18
19
  import { NodeViewWrapper } from "@tiptap/react";
19
20
  import { isHidden } from "../../conditions.js";
20
21
  import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
@@ -45,5 +46,5 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
45
46
  const scope = { fields, settings };
46
47
  const visibleFields = Object.entries(element?.fields ?? {}).filter(([, d]) => !isHidden(d, { self: fields, ...scope }));
47
48
  const visibleSettings = Object.entries(element?.settings ?? {}).filter(([, d]) => !isHidden(d, { self: settings, ...scope }));
48
- return (_jsxs(NodeViewWrapper, { className: cx(cardClass(selected, form.canvas), state.unknown && "sm-rtcard--broken"), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), state.outside && _jsx("span", { className: "sm-hint", children: "Not in this field's toolset" }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && !element && (_jsx("div", { className: "sm-rtcard__body", contentEditable: false, children: Object.entries(fields).map(([key, value]) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: key }), _jsx("span", { className: "sm-orphans__value", children: typeof value === "string" ? value : JSON.stringify(value) })] }, key))) })), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
49
+ return (_jsxs(NodeViewWrapper, { className: cx(cardClass(selected, form.canvas), state.unknown && "sm-rtcard--broken"), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__grip", title: "Drag to reorder", "aria-hidden": "true", children: _jsx(GripVertical, { size: 14, strokeWidth: 1.75 }) }), _jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), state.outside && _jsx("span", { className: "sm-hint", children: "Not in this field's toolset" }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && !element && (_jsx("div", { className: "sm-rtcard__body", contentEditable: false, children: Object.entries(fields).map(([key, value]) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: key }), _jsx("span", { className: "sm-orphans__value", children: typeof value === "string" ? value : JSON.stringify(value) })] }, key))) })), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
49
50
  }
@@ -9,6 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // store there; the bound renderer applies them. Attribute edits go
10
10
  // through updateAttributes so the outer history sees them.
11
11
  import { useEffect, useState } from "react";
12
+ import { GripVertical } from "lucide-react";
12
13
  import { NodeViewWrapper } from "@tiptap/react";
13
14
  import { useAssets } from "./assets-context.js";
14
15
  import { MediaPicker } from "./MediaPicker.js";
@@ -43,5 +44,5 @@ export function RichtextImageCard({ node, editor, getPos, updateAttributes, dele
43
44
  updateAttributes({ image: value.alt ? { asset: record.id, alt: value.alt } : { asset: record.id } });
44
45
  };
45
46
  const editable = asset !== null && asset.kind === "image" && !!asset.width && !!asset.height;
46
- return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, "data-drag-handle": true, children: [_jsxs("div", { className: "sm-rtcard__head", onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: "\u25A3" }), _jsx("span", { className: "sm-rtcard__title", children: "Image" }), _jsxs("span", { className: "sm-field__actions", children: [editable && _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => deleteNode(), children: "Remove" })] })] }), _jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [asset && id ? _jsx(ImagePreview, { asset: asset, value: { ...value, asset: id }, height: 160 }) : _jsx("div", { className: "sm-rtcard__thumb", style: { height: 80 } }), caption !== "" && _jsx("span", { className: "sm-rtcard__caption", children: caption })] }), picker && _jsx(MediaPicker, { kinds: ["image"], onPick: replace, onClose: () => setPicker(false) }), crop && asset && id && (_jsx(CropEditor, { asset: asset, descriptor: { type: "image" }, value: { ...value, asset: id }, caption: caption, onSave: (next, cap) => { updateAttributes({ image: next, caption: cap ?? "" }); setCrop(false); }, onClose: () => setCrop(false) }))] }));
47
+ return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, "data-drag-handle": true, children: [_jsxs("div", { className: "sm-rtcard__head", onClick: select, children: [_jsx("span", { className: "sm-rtcard__grip", title: "Drag to reorder", "aria-hidden": "true", children: _jsx(GripVertical, { size: 14, strokeWidth: 1.75 }) }), _jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: "\u25A3" }), _jsx("span", { className: "sm-rtcard__title", children: "Image" }), _jsxs("span", { className: "sm-field__actions", children: [editable && _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => deleteNode(), children: "Remove" })] })] }), _jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [asset && id ? _jsx(ImagePreview, { asset: asset, value: { ...value, asset: id }, height: 160 }) : _jsx("div", { className: "sm-rtcard__thumb", style: { height: 80 } }), caption !== "" && _jsx("span", { className: "sm-rtcard__caption", children: caption })] }), picker && _jsx(MediaPicker, { kinds: ["image"], onPick: replace, onClose: () => setPicker(false) }), crop && asset && id && (_jsx(CropEditor, { asset: asset, descriptor: { type: "image" }, value: { ...value, asset: id }, caption: caption, onSave: (next, cap) => { updateAttributes({ image: next, caption: cap ?? "" }); setCrop(false); }, onClose: () => setCrop(false) }))] }));
47
48
  }
@@ -4,7 +4,12 @@
4
4
  // and keyboard shortcuts here, the toolbar in RichtextToolbar, paste in
5
5
  // richtext-paste. StarterKit supplies document, paragraph, text, hard
6
6
  // break, list item, list keymap and undo/redo; every gated node or mark
7
- // is switched off there and added back extended.
7
+ // is switched off there and added back extended. The gap cursor stays on:
8
+ // a card is an atom, so beside one that opens or closes the document it is
9
+ // the only place a caret can stand (2026-11-13: a field holding one card
10
+ // took no text, and typing replaced the selected card).
11
+ // The drop cursor too: a card is dragged by its head, and the line says
12
+ // where it lands; its look is the theme's (.sm-dropcursor), in both mounts.
8
13
  import { Extension, textblockTypeInputRule } from "@tiptap/core";
9
14
  import StarterKit from "@tiptap/starter-kit";
10
15
  import Heading from "@tiptap/extension-heading";
@@ -41,7 +46,7 @@ export function richtextExtensions(allowed, hooks) {
41
46
  heading: false, bold: false, italic: false, link: false, blockquote: false,
42
47
  bulletList: false, orderedList: false,
43
48
  code: false, codeBlock: false, strike: false, underline: false, horizontalRule: false,
44
- dropcursor: false, gapcursor: false, trailingNode: false,
49
+ dropcursor: { color: false, width: 2, class: "sm-dropcursor" }, trailingNode: false,
45
50
  }),
46
51
  Heading.extend({
47
52
  // Exact `#` counts, and only the allowed levels; `# ` does nothing when h1 is forbidden.
@@ -177,10 +177,12 @@ export type AdminOps = {
177
177
  title?: string;
178
178
  redirect?: boolean;
179
179
  }): Promise<OpResult<PageRecord>>;
180
+ /** `locale`: the locale whose list `index` counts in, the one on screen; under `locales.order: "perLocale"` only its order changes. */
180
181
  move(id: string, to: {
181
182
  parentId: string | null;
182
183
  index?: number;
183
184
  redirect?: boolean;
185
+ locale?: string;
184
186
  }): Promise<OpResult<PageRecord>>;
185
187
  /** "Add page here": a folder becomes a page. */
186
188
  setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
@@ -203,6 +205,18 @@ export type AdminOps = {
203
205
  distinct(collection: string, field: string, locale: string): Promise<OpResult<string[]>>;
204
206
  /** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
205
207
  get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
208
+ /** One page of a collection's list (2026-11-13): every entry, present in `locale` or not, in the collection's order
209
+ * as `locale` lists it, `limit` (1–500) of them after `offset`; `search` keeps the entries whose title field holds
210
+ * the text. Filtered, ordered and cut in the store; `total` counts every match. */
211
+ page(collection: string, query: {
212
+ locale: string;
213
+ limit: number;
214
+ offset?: number;
215
+ search?: string;
216
+ }): Promise<OpResult<{
217
+ entries: EntryRecord[];
218
+ total: number;
219
+ }>>;
206
220
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
207
221
  /** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
208
222
  save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null, options?: {
@@ -212,10 +226,12 @@ export type AdminOps = {
212
226
  /** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
213
227
  discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
214
228
  delete(collection: string, id: string): Promise<OpResult<null>>;
229
+ /** `locale` as on pages.move. */
215
230
  move(collection: string, id: string, to: {
216
231
  parentId: string | null;
217
232
  index?: number;
218
233
  redirect?: boolean;
234
+ locale?: string;
219
235
  }): Promise<OpResult<EntryRecord>>;
220
236
  /** Copies `from`'s localized fields and slug as the new locale's draft (spec 2026-09-06 §7). Expires nothing. */
221
237
  addLocale(collection: string, id: string, locale: string, options: {
@@ -34,6 +34,8 @@ export type AdminCollection = {
34
34
  depth: number;
35
35
  /** `order: "manual"` — the list drags into the `sort` order (spec 2026-09-09 §5). */
36
36
  manual: boolean;
37
+ /** `admin.showInNav` — its own item on the rail, out of the Collections menu (2026-11-13). */
38
+ inNav?: boolean;
37
39
  /** The collection has SEO & Social values (spec 2026-09-29 content API contract §7); each label
38
40
  * names, in lower case, the field an empty value falls back on — the hints' copy. */
39
41
  seo?: {
@@ -81,6 +83,7 @@ export type AdminRegistry = {
81
83
  locales: {
82
84
  default: string;
83
85
  supported: string[];
86
+ order?: "shared" | "perLocale";
84
87
  };
85
88
  /** The site's time zone when configured (spec 2026-09-25 date pickers §5); the datetime widget edits in it. */
86
89
  timeZone?: string;
@@ -81,6 +81,7 @@ export function serializeAdminConfig(config) {
81
81
  ...(c.path !== undefined ? { path: perLocale(c.path) } : {}),
82
82
  depth: collectionDepth(c),
83
83
  manual: collectionOrder(c) === "manual",
84
+ ...(c.admin?.showInNav === true ? { inNav: true } : {}),
84
85
  ...(c.seo !== undefined ? { seo: seoLabels(c) } : {}),
85
86
  };
86
87
  }),
@@ -1,13 +1,17 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // A collection's list for ONE selected locale: every record is a row;
4
4
  // a record absent from the selected locale is dimmed and its click is
5
5
  // "Add <locale>" (a confirm, then addLocale copying the source locale).
6
6
  // Opening a present row opens the form in the selected locale.
7
- import { useCallback, useEffect, useState } from "react";
7
+ // The list is read a page at a time (2026-11-13): 20 entries by default,
8
+ // 100 or 500 on the editor's choice (remembered), Previous / Next under
9
+ // the rows, and a filter on the title field above them. The service
10
+ // filters, orders and cuts; a change of any of them is a new read.
11
+ import { useCallback, useEffect, useRef, useState } from "react";
8
12
  import { ListView } from "./ListView.js";
9
- import { canReorder, entryRows, entrySourceLocale } from "./entries-list.js";
10
- import { Button, StatusBadge } from "../ui/primitives.js";
13
+ import { PAGE_SIZES, canReorder, entryRows, entrySourceLocale, pageRange, rememberPageSize, rememberedPageSize } from "./entries-list.js";
14
+ import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
11
15
  import { statusLabel } from "../ui/status-label.js";
12
16
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
13
17
  import { relativeTime } from "../ui/format.js";
@@ -24,6 +28,12 @@ export function EntriesList({ ops, registry, collection }) {
24
28
  const adminHref = useAdminHref();
25
29
  const meta = registry.collections.find((c) => c.name === collection);
26
30
  const [records, setRecords] = useState(null);
31
+ const [total, setTotal] = useState(0);
32
+ const [size, setSize] = useState(() => rememberedPageSize());
33
+ const [offset, setOffset] = useState(0);
34
+ /** The filter box's text, and the text the list was last asked for (the box's, a moment later). */
35
+ const [typed, setTyped] = useState("");
36
+ const [search, setSearch] = useState("");
27
37
  const [error, setError] = useState(null);
28
38
  const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
29
39
  const { others } = usePresenceState();
@@ -32,10 +42,46 @@ export function EntriesList({ ops, registry, collection }) {
32
42
  setLocale(next);
33
43
  rememberLocale(next);
34
44
  };
35
- const load = useCallback(() => ops.entries.list(collection)
36
- .then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
37
- .catch(() => setError("Couldn't reach the server.")), [ops, collection]);
45
+ // Only the latest read lands: a slow answer to an earlier filter never replaces a later one's.
46
+ const asked = useRef(0);
47
+ const load = useCallback(() => {
48
+ const mine = ++asked.current;
49
+ return ops.entries.page(collection, { locale, limit: size, offset, ...(search ? { search } : {}) })
50
+ .then((r) => {
51
+ if (mine !== asked.current)
52
+ return;
53
+ if (!r.ok) {
54
+ setError(r.message);
55
+ return;
56
+ }
57
+ // The page emptied under the reader (a delete, a narrower filter): step back to the last one that has rows.
58
+ if (r.data.entries.length === 0 && r.data.total > 0 && offset > 0) {
59
+ setOffset(Math.floor((r.data.total - 1) / size) * size);
60
+ return;
61
+ }
62
+ setRecords(r.data.entries);
63
+ setTotal(r.data.total);
64
+ })
65
+ .catch(() => { if (mine === asked.current)
66
+ setError("Couldn't reach the server."); });
67
+ }, [ops, collection, locale, size, offset, search]);
38
68
  useEffect(() => { void load(); }, [load]);
69
+ // Another collection starts at its first page with no filter.
70
+ useEffect(() => { setOffset(0); setTyped(""); setSearch(""); }, [collection]);
71
+ // The filter follows the typing after a short pause, back on the first page.
72
+ useEffect(() => {
73
+ const next = typed.trim();
74
+ if (next === search)
75
+ return;
76
+ const timer = setTimeout(() => { setSearch(next); setOffset(0); }, 250);
77
+ return () => clearTimeout(timer);
78
+ }, [typed, search]);
79
+ const pickSize = (next) => {
80
+ setSize(next);
81
+ rememberPageSize(next);
82
+ // Stay on the page that holds the first row on screen.
83
+ setOffset(Math.floor(offset / next) * next);
84
+ };
39
85
  // Drag (spec 2026-09-09 §5): a manual-order flat collection reorders
40
86
  // through entries.move; the rows are held in place until the reload lands.
41
87
  const rows = records && meta ? entryRows(records, meta, registry, locale) : [];
@@ -46,8 +92,9 @@ export function EntriesList({ ops, registry, collection }) {
46
92
  if (!record || from === to)
47
93
  return;
48
94
  let result;
95
+ // The index counts in the whole list, not in the page on screen.
49
96
  try {
50
- result = await ops.entries.move(collection, record.id, { parentId: record.parentId, index: to });
97
+ result = await ops.entries.move(collection, record.id, { parentId: record.parentId, index: offset + to, locale });
51
98
  }
52
99
  catch {
53
100
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -63,6 +110,7 @@ export function EntriesList({ ops, registry, collection }) {
63
110
  return _jsx(ErrorPane, { message: "No such collection." });
64
111
  if (error)
65
112
  return _jsx(ErrorPane, { message: error });
113
+ const range = pageRange(total, offset, rows.length);
66
114
  const open = (id) => {
67
115
  void navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
68
116
  };
@@ -111,7 +159,9 @@ export function EntriesList({ ops, registry, collection }) {
111
159
  { label: "Title", width: "minmax(160px,1fr)" },
112
160
  { label: "Updated", width: "minmax(110px,160px)" },
113
161
  { label: "Status", width: "170px" },
114
- ], empty: records ? "Nothing here yet." : "Loading…", sortable: canReorder(meta) ? { hook: sortable, canDrag: () => true } : undefined, rows: rows.map((row) => {
162
+ ], tools: meta.titleField && (_jsx("input", { className: "sm-input", type: "search", placeholder: "Filter by title\u2026", "aria-label": "Filter entries", value: typed, onChange: (e) => setTyped(e.target.value) })), foot: range && (_jsxs(_Fragment, { children: [_jsxs("span", { role: "status", children: [range.from, "\u2013", range.to, " of ", total] }), _jsxs("span", { className: "sm-table__pager", children: [_jsx("span", { children: "Per page" }), _jsx(Segmented, { options: PAGE_SIZES.map(String), value: String(size), onChange: (v) => pickSize(Number(v)), className: "sm-seg--size", option: (o) => ({ "aria-label": `Show ${o} entries a page` }) }), _jsx(Button, { variant: "secondary", size: "sm", disabled: offset === 0, onClick: () => setOffset(Math.max(0, offset - size)), children: "Previous" }), _jsx(Button, { variant: "secondary", size: "sm", disabled: offset + size >= total, onClick: () => setOffset(offset + size), children: "Next" })] })] })), empty: !records ? "Loading…" : search ? "No entries match the filter." : "Nothing here yet.",
163
+ // A filtered list is not the collection's order: its rows do not drag.
164
+ sortable: canReorder(meta) && !search ? { hook: sortable, canDrag: () => true } : undefined, rows: rows.map((row) => {
115
165
  const dim = row.present ? undefined : { opacity: 0.45 };
116
166
  return {
117
167
  key: row.id,
@@ -7,8 +7,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  // 200px SideNav so the visual editor gets the width. Ported markup-for-markup;
8
8
  // the handoff's click handlers are the shell's router.
9
9
  //
10
- // Items are registry-derived, never separately configured: Pages, then
11
- // Collections — one rail item whose flyout lists every collection — then
10
+ // Items are registry-derived, never separately configured: Pages, then a
11
+ // rail item for each collection registered with `admin.showInNav`
12
+ // (2026-11-13), then Collections — one rail item whose flyout lists every
13
+ // other collection, absent when none is left — then
12
14
  // Media, Globals (the global content items, when the registry has any;
13
15
  // the route is /globals), and Settings (the Settings area, spec
14
16
  // 2026-09-25; the route is /settings; every member has a page there since
@@ -21,7 +23,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
23
  // ↑/↓ move between entries, a pointer down outside closes. A child being
22
24
  // active highlights its parent tile and the entry inside the menu.
23
25
  import { useEffect, useId, useRef, useState } from "react";
24
- import { ExternalLink, Globe, Image, LibraryBig, PanelTop, RadioTower, Settings } from "lucide-react";
26
+ import { ExternalLink, Globe, Image, Layers, LibraryBig, PanelTop, RadioTower, Settings } from "lucide-react";
25
27
  import { tileCopy } from "./deploy-tile.js";
26
28
  import { ThemeToggle } from "../ui/primitives.js";
27
29
  import { useAdminHref } from "./base.js";
@@ -91,13 +93,15 @@ export function IconRail({ registry, active, email, settings = true, deploy, onS
91
93
  items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
92
94
  }
93
95
  };
94
- const link = (key, label, icon, href) => (_jsx("li", { className: "sm-rail__item", children: _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 })] }) }));
95
- const collections = registry.collections;
96
+ const link = (key, label, icon, href) => (_jsx("li", { className: "sm-rail__item", children: _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", title: label, children: label })] }) }, key));
97
+ // A collection with `admin.showInNav` is a rail item of its own; the rest stay in the Collections menu.
98
+ const pinned = registry.collections.filter((c) => c.inNav);
99
+ const collections = registry.collections.filter((c) => !c.inNav);
96
100
  const collectionActive = collections.some((c) => c.name === active);
97
101
  const collectionsOpen = open === "collections" || (hovered && open === null);
98
102
  const name = email?.split("@")[0] ?? "Editor";
99
103
  const initials = name.slice(0, 2).toUpperCase();
100
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsxs("div", { className: "sm-rail__item", children: [_jsx("button", { type: "button", className: "sm-rail__brand", title: "Smoodly", "aria-haspopup": "menu", "aria-expanded": open === "brand", "aria-controls": menuId("brand"), onClick: () => toggle("brand"), children: _jsx(AppIcon, {}) }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--brand", role: "menu", id: menuId("brand"), "data-open": open === "brand", children: [_jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().project, onClick: close, children: "This site at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().docs, onClick: close, children: "Documentation" }) }), _jsxs("li", { className: "sm-rail__menu-version", role: "presentation", children: ["Smoodly ", SMOODLY_VERSION] })] })] }), _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", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), deploy && (() => {
104
+ return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsxs("div", { className: "sm-rail__item", children: [_jsx("button", { type: "button", className: "sm-rail__brand", title: "Smoodly", "aria-haspopup": "menu", "aria-expanded": open === "brand", "aria-controls": menuId("brand"), onClick: () => toggle("brand"), children: _jsx(AppIcon, {}) }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--brand", role: "menu", id: menuId("brand"), "data-open": open === "brand", children: [_jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().project, onClick: close, children: "This site at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().docs, onClick: close, children: "Documentation" }) }), _jsxs("li", { className: "sm-rail__menu-version", role: "presentation", children: ["Smoodly ", SMOODLY_VERSION] })] })] }), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), pinned.map((c) => link(c.name, c.title, _jsx(Layers, { ...ICON }), adminHref(`/${c.name}`))), 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", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), deploy && (() => {
101
105
  const copy = tileCopy(deploy.status, Date.now());
102
106
  const id = menuId("deploy");
103
107
  return (_jsxs("div", { className: "sm-rail__item sm-rail__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: `sm-rail__tile${copy.lit ? " sm-rail__tile--lit" : ""}${copy.warn ? " sm-rail__tile--warn" : ""}`, children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-rail__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-rail__label", children: copy.label })] }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-rail__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-rail__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
@@ -4,13 +4,17 @@ export type ListColumn = {
4
4
  label: string;
5
5
  width: string;
6
6
  };
7
- export declare function ListView({ title, titleTrailing, actions, notice, columns, rows, empty, sortable }: {
7
+ export declare function ListView({ title, titleTrailing, actions, notice, tools, foot, columns, rows, empty, sortable }: {
8
8
  title: string;
9
9
  /** Rides right after the title — the locale picker, in the place every form's switch has. */
10
10
  titleTrailing?: ReactNode;
11
11
  actions?: ReactNode;
12
12
  /** Between the top bar and the list card — a Banner, as the Media screen places its error. */
13
13
  notice?: ReactNode;
14
+ /** In the gap above the column heads: the list's filter. */
15
+ tools?: ReactNode;
16
+ /** Under the last row: the count, the page size and the pager. */
17
+ foot?: ReactNode;
14
18
  columns: ListColumn[];
15
19
  rows: {
16
20
  key: string;
@@ -3,10 +3,10 @@
3
3
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { Grip, TopBar, cx } from "../ui/primitives.js";
5
5
  import { navigate } from "./router.js";
6
- export function ListView({ title, titleTrailing, actions, notice, columns, rows, empty, sortable }) {
6
+ export function ListView({ title, titleTrailing, actions, notice, tools, foot, columns, rows, empty, sortable }) {
7
7
  const widths = sortable ? ["24px", ...columns.map((c) => c.width)] : columns.map((c) => c.width);
8
8
  const grid = { gridTemplateColumns: widths.join(" ") };
9
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), notice && _jsx("div", { className: "sm-listnotice", children: notice }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
9
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), notice && _jsx("div", { className: "sm-listnotice", children: notice }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: cx("sm-table", tools ? "sm-table--tools" : undefined), children: [tools && _jsx("div", { className: "sm-table__tools", children: tools }), _jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
10
10
  const clickable = Boolean(r.href || r.onClick);
11
11
  const href = r.href;
12
12
  const go = r.onClick ?? (href ? () => { void navigate(href); } : undefined);
@@ -14,5 +14,5 @@ export function ListView({ title, titleTrailing, actions, notice, columns, rows,
14
14
  e.preventDefault();
15
15
  go();
16
16
  } }, children: [sortable && (_jsx("span", { className: "sm-cell sm-cell--grip", children: sortable.canDrag(i) && _jsx(Grip, { ...sortable.hook.handleProps(i) }) })), r.cells.map((cell, i) => _jsx("span", { className: "sm-cell", children: cell }, i))] }, r.key));
17
- }), rows.length === 0 && _jsx("div", { className: "sm-empty", children: empty ?? "Nothing here yet." })] })] }) })] }));
17
+ }), rows.length === 0 && _jsx("div", { className: "sm-empty", children: empty ?? "Nothing here yet." })] }), foot && _jsx("div", { className: "sm-table__foot", children: foot })] }) })] }));
18
18
  }
@@ -7,7 +7,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
7
7
  import { useCallback, useEffect, useState } from "react";
8
8
  import { useAssets } from "../forms/assets-context.js";
9
9
  import { uploadFile } from "../forms/upload.js";
10
- import { MediaGrid } from "../forms/MediaGrid.js";
10
+ import { AssetThumb, MediaGrid } from "../forms/MediaGrid.js";
11
11
  import { Banner, Button, Field, Segmented, TopBar, UploadProgress } from "../ui/primitives.js";
12
12
  import { confirm } from "../ui/confirm.js";
13
13
  import { adminImageSrc } from "../forms/crop-editor.js";
@@ -93,7 +93,7 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
93
93
  setSelected(null);
94
94
  load();
95
95
  };
96
- 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 }), progress !== null && _jsx("div", { className: "sm-mediascreen__upload", children: _jsx(UploadProgress, { fraction: progress }) }), _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: adminImageSrc(selected, 640), 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
96
+ 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 }), progress !== null && _jsx("div", { className: "sm-mediascreen__upload", children: _jsx(UploadProgress, { fraction: progress }) }), _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: [_jsxs("div", { className: "sm-media__thumb", style: { aspectRatio: selected.width && selected.height ? `${selected.width} / ${selected.height}` : "4 / 3" }, children: [selected.kind === "image" && _jsx("img", { src: adminImageSrc(selected, 640), alt: "", style: { left: 0, top: 0, width: "100%", height: "100%" } }), selected.kind === "video" && _jsx(AssetThumb, { asset: selected, className: "sm-mediacard__img", width: 640 })] }), 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
97
97
  ? _jsxs(RouteLink, { href: u.href, children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`)
98
98
  : _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" })] }))] })] }));
99
99
  }
@@ -4,7 +4,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
4
4
  // The Pages list: the tree of the design system's TreeTable for ONE
5
5
  // selected locale (spec 2026-09-06 §6), plus the row menu (New
6
6
  // subpage/subpath, Add page here, Add <locale>, Rename, Move, Remove
7
- // from <locale>, Delete) as a floating popover positioned from the
7
+ // from <locale>, Duplicate, Delete) as a floating popover positioned from the
8
8
  // triggering click. Structure is shared, so every node has a row; a node
9
9
  // absent from the selected locale is dimmed and offers "Add <locale>".
10
10
  import { useCallback, useEffect, useState } from "react";
@@ -24,6 +24,7 @@ import { PageFormDialog } from "./PageDialogs.js";
24
24
  import { usePresenceState } from "./presence-context.js";
25
25
  import { editorsOf } from "./presence.js";
26
26
  import { LockIcon } from "../ui/LockIcon.js";
27
+ import { draftToken } from "../../draft-token.js";
27
28
  export function PagesList({ ops, registry }) {
28
29
  const { others } = usePresenceState();
29
30
  const adminHref = useAdminHref();
@@ -71,7 +72,7 @@ export function PagesList({ ops, registry }) {
71
72
  const live = await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path }, codeAddressNote(row.path, registry, locale));
72
73
  if (!live.ok)
73
74
  return;
74
- const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index, redirect: live.redirect }));
75
+ const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index, redirect: live.redirect, locale }));
75
76
  if (!ok)
76
77
  throw new Error("move refused");
77
78
  },
@@ -120,6 +121,10 @@ export function PagesList({ ops, registry }) {
120
121
  };
121
122
  const rename = (row) => { if (row.record.locales[locale])
122
123
  setDialog({ kind: "rename", row }); };
124
+ /** Only a page of an open registration can be created again; a fixed page is code's one address. */
125
+ const canDuplicate = (row) => row.present && row.template !== null && creatable.some((t) => t.name === row.template);
126
+ const duplicate = (row) => { if (canDuplicate(row))
127
+ setDialog({ kind: "duplicate", row }); };
123
128
  const closeDialog = () => setDialog(null);
124
129
  /** The parent's path from a row's own: "/company/about-us" → "/company", "/about" → "/". */
125
130
  const parentPathOf = (path) => {
@@ -145,6 +150,8 @@ export function PagesList({ ops, registry }) {
145
150
  return attempt(() => ops.pages.setTemplate(dialog.row.id, values.template), (page) => openEditor(page.id));
146
151
  }
147
152
  const { row } = dialog;
153
+ if (dialog.kind === "duplicate")
154
+ return duplicateInto(row, values);
148
155
  const current = row.record.locales[locale];
149
156
  if (!current)
150
157
  return null;
@@ -156,6 +163,38 @@ export function PagesList({ ops, registry }) {
156
163
  return null;
157
164
  return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug, redirect: live.redirect }));
158
165
  };
166
+ /** Duplicate: a new draft page beside the original, in the SELECTED locale, carrying a copy of its draft
167
+ * (sections, page fields, SEO). Two ops, so a copy that fails after the create leaves the page and says so. */
168
+ const duplicateInto = async (row, values) => {
169
+ let source;
170
+ try {
171
+ source = await ops.pages.get(row.id, locale);
172
+ }
173
+ catch {
174
+ return "Couldn't reach the server.";
175
+ }
176
+ if (!source.ok)
177
+ return source.message;
178
+ const tree = source.data.draft;
179
+ return attempt(async () => {
180
+ const created = await ops.pages.create({ parentId: row.parentId, locale, slug: values.slug, title: values.title, template: row.template });
181
+ if (!created.ok || !tree)
182
+ return created;
183
+ const target = created.data.locales[locale];
184
+ let saved;
185
+ try {
186
+ saved = await ops.pages.saveDraft(created.data.id, locale, tree, target ? draftToken(target) : null);
187
+ }
188
+ catch {
189
+ saved = { ok: false, code: "internal", message: "Couldn't reach the server." };
190
+ }
191
+ if (!saved.ok) {
192
+ await load();
193
+ return { ...saved, message: `The page was created, but its content was not copied: ${saved.message}` };
194
+ }
195
+ return created;
196
+ }, (page) => openEditor(page.id));
197
+ };
159
198
  const addLocale = async (row) => {
160
199
  const from = sourceLocaleOf(row.record, registry);
161
200
  const ok = await confirm({
@@ -216,6 +255,8 @@ export function PagesList({ ops, registry }) {
216
255
  items.push({ label: "Add page here", run: () => addPageHere(row) });
217
256
  if (!row.fixed)
218
257
  items.push({ label: "Rename…", run: () => rename(row) });
258
+ if (canDuplicate(row))
259
+ items.push({ label: "Duplicate…", run: () => duplicate(row) });
219
260
  // Reordering is the grip's (drag, or its arrow keys) — no Move up / Move down here.
220
261
  if (multilingual && !row.fixed && !row.isHome && row.locales.length > 1) {
221
262
  items.push({ label: `Remove from ${locale}`, run: () => removeLocale(row) });
@@ -284,5 +325,6 @@ export function PagesList({ ops, registry }) {
284
325
  }, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: menu?.row.orphan ? "No longer in code — move its child pages out, then delete it." : "Owned by code — nothing to change here." }))] }) })), dialog?.kind === "new" && (_jsx(PageFormDialog, { title: dialog.node === "page" ? (dialog.parentId ? "New subpage" : "New page") : (dialog.parentId ? "New subpath" : "New path"), submitLabel: dialog.node === "page" ? "Create page" : "Create path", fields: { title: true, slug: true, template: dialog.node === "page" }, templates: creatable, parentPath: dialog.parentId ? (rows.find((r) => r.id === dialog.parentId)?.path ?? "/") : "/", onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
285
326
  closeDialog(); return message; }) })), dialog?.kind === "template" && (_jsx(PageFormDialog, { title: "Add page here", submitLabel: "Add page", fields: { template: true }, templates: creatable, onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
286
327
  closeDialog(); return message; }) })), dialog?.kind === "rename" && (_jsx(PageFormDialog, { title: "Rename page", submitLabel: "Rename", fields: { title: true, slug: !dialog.row.isHome }, initial: { title: dialog.row.record.locales[locale]?.title ?? "", slug: dialog.row.record.locales[locale]?.slug ?? "" }, parentPath: dialog.row.isHome ? undefined : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
328
+ closeDialog(); return message; }) })), dialog?.kind === "duplicate" && (_jsx(PageFormDialog, { title: "Duplicate page", submitLabel: "Duplicate", fields: { title: true, slug: true }, initial: { title: `${dialog.row.title} copy`, slug: `${dialog.row.record.locales[locale]?.slug ?? ""}-copy` }, parentPath: dialog.row.isHome ? "/" : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
287
329
  closeDialog(); return message; }) }))] }));
288
330
  }
@@ -19,6 +19,18 @@ export declare function entrySourceLocale(record: EntryRecord, registry: AdminRe
19
19
  * (the same split entry-store.ts's splitFields makes). */
20
20
  export declare function localizedFieldKeys(meta: AdminCollection): string[];
21
21
  export declare function entryRows(records: EntryRecord[], meta: AdminCollection, registry: AdminRegistry, locale: string): EntryRow[];
22
+ /** How many entries a list page shows: 20 unless the editor chose more (2026-11-13). */
23
+ export declare const PAGE_SIZES: readonly [20, 100, 500];
24
+ export type PageSize = (typeof PAGE_SIZES)[number];
25
+ /** The stored size when it is one of the three, else 20. */
26
+ export declare function resolvePageSize(stored: string | null | undefined): PageSize;
27
+ export declare function rememberedPageSize(): PageSize;
28
+ export declare function rememberPageSize(size: PageSize): void;
29
+ /** The pager's "21–40 of 134": null while the list fits the smallest page, where there is nothing to page. */
30
+ export declare function pageRange(total: number, offset: number, shown: number): {
31
+ from: number;
32
+ to: number;
33
+ } | null;
22
34
  /** The list drags only when the order IS the `sort` column and the rows
23
35
  * are siblings: a nested collection's flat index is not a sibling index
24
36
  * (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */