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.
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/forms/MediaGrid.d.ts +3 -2
- package/dist/admin/forms/MediaGrid.js +37 -7
- package/dist/admin/forms/RichtextElementCard.js +2 -1
- package/dist/admin/forms/RichtextImageCard.js +2 -1
- package/dist/admin/forms/richtext-extensions.js +7 -2
- package/dist/admin/ops.d.ts +16 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/EntriesList.js +59 -9
- package/dist/admin/shell/IconRail.js +10 -6
- package/dist/admin/shell/ListView.d.ts +5 -1
- package/dist/admin/shell/ListView.js +3 -3
- package/dist/admin/shell/MediaScreen.js +2 -2
- package/dist/admin/shell/PagesList.js +44 -2
- package/dist/admin/shell/entries-list.d.ts +12 -0
- package/dist/admin/shell/entries-list.js +30 -1
- package/dist/admin/ui/theme.js +26 -2
- package/dist/assemble.js +3 -0
- package/dist/collections.d.ts +4 -0
- package/dist/collections.js +3 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/entry-store.d.ts +33 -4
- package/dist/entry-store.js +19 -6
- package/dist/paths.d.ts +34 -0
- package/dist/paths.js +47 -2
- package/dist/reads.d.ts +1 -1
- package/dist/site.d.ts +3 -1
- package/dist/site.js +1 -1
- package/dist/store.d.ts +7 -1
- package/dist/store.js +4 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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,
|
|
8
|
-
if (asset.kind === "video")
|
|
9
|
-
return asset
|
|
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,
|
|
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.
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -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;
|
package/dist/admin/serialize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
],
|
|
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
|
-
//
|
|
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
|
-
|
|
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: [
|
|
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). */
|