smoodly 0.0.58 → 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/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/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 +12 -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
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
|
}
|
|
@@ -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). */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { localeStatusOf } from "./pages-tree.js";
|
|
2
|
+
import { orderIn } from "../../paths.js";
|
|
2
3
|
/** The entry's title in the selected locale, else the default locale's, else any locale's;
|
|
3
4
|
* "Untitled" when every one is empty (spec 2026-09-14 §4). Display only — a shared
|
|
4
5
|
* title field reads the same from every locale. */
|
|
@@ -23,7 +24,8 @@ export function localizedFieldKeys(meta) {
|
|
|
23
24
|
return Object.entries(meta.fields).filter(([key, d]) => key === "slug" || d.localized === true).map(([key]) => key);
|
|
24
25
|
}
|
|
25
26
|
export function entryRows(records, meta, registry, locale) {
|
|
26
|
-
|
|
27
|
+
// A manual order is the selected locale's own (2026-11-13); the list arrives in the default locale's.
|
|
28
|
+
return (meta.manual ? orderIn(records, locale) : records).map((record) => {
|
|
27
29
|
const row = record.locales[locale];
|
|
28
30
|
return {
|
|
29
31
|
id: record.id,
|
|
@@ -34,6 +36,33 @@ export function entryRows(records, meta, registry, locale) {
|
|
|
34
36
|
};
|
|
35
37
|
});
|
|
36
38
|
}
|
|
39
|
+
/** How many entries a list page shows: 20 unless the editor chose more (2026-11-13). */
|
|
40
|
+
export const PAGE_SIZES = [20, 100, 500];
|
|
41
|
+
const PAGE_SIZE_KEY = "smoodly-page-size";
|
|
42
|
+
/** The stored size when it is one of the three, else 20. */
|
|
43
|
+
export function resolvePageSize(stored) {
|
|
44
|
+
return PAGE_SIZES.find((s) => String(s) === stored) ?? PAGE_SIZES[0];
|
|
45
|
+
}
|
|
46
|
+
export function rememberedPageSize() {
|
|
47
|
+
let stored = null;
|
|
48
|
+
try {
|
|
49
|
+
stored = window.localStorage.getItem(PAGE_SIZE_KEY);
|
|
50
|
+
}
|
|
51
|
+
catch { /* storage unavailable: 20 is fine */ }
|
|
52
|
+
return resolvePageSize(stored);
|
|
53
|
+
}
|
|
54
|
+
export function rememberPageSize(size) {
|
|
55
|
+
try {
|
|
56
|
+
window.localStorage.setItem(PAGE_SIZE_KEY, String(size));
|
|
57
|
+
}
|
|
58
|
+
catch { /* a convenience only */ }
|
|
59
|
+
}
|
|
60
|
+
/** The pager's "21–40 of 134": null while the list fits the smallest page, where there is nothing to page. */
|
|
61
|
+
export function pageRange(total, offset, shown) {
|
|
62
|
+
if (total <= PAGE_SIZES[0] || shown === 0)
|
|
63
|
+
return null;
|
|
64
|
+
return { from: offset + 1, to: offset + shown };
|
|
65
|
+
}
|
|
37
66
|
/** The list drags only when the order IS the `sort` column and the rows
|
|
38
67
|
* are siblings: a nested collection's flat index is not a sibling index
|
|
39
68
|
* (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -85,7 +85,8 @@ const TOKENS_DARK = `
|
|
|
85
85
|
--frame:#0F1120; --sheet:${SHEET_DARK};
|
|
86
86
|
--canvas:#131526; --surface:#242840; --surface-form:#242840; --surface-inset:#1B1E30;
|
|
87
87
|
--menu-bg:#242840;
|
|
88
|
-
|
|
88
|
+
/* A field's edge is drawn in dark: a dialog's fields take the card's own tone there (2026-11-13). */
|
|
89
|
+
--input-bg:#1B1E30; --input-hover-bg:#1B1E30; --input-focus-bg:#1B1E30; --input-border:var(--line-dashed);
|
|
89
90
|
--line:#33384F; --line-dashed:#444A63; --line-soft:#33384F;
|
|
90
91
|
--text:#EEF0F5; --text-secondary:#C5C8D2; --text-muted:#9A9FB3; --text-subtle:#7C819A; --text-disabled:#4F5570;
|
|
91
92
|
--hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09); --control-hover:#2C3049; --canvas-ground:var(--control-hover);
|
|
@@ -233,7 +234,8 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
233
234
|
.sm-rail__btn[aria-current="page"]:hover,.sm-rail__btn[aria-expanded="true"]:hover{color:var(--rail-active-fg)}
|
|
234
235
|
.sm-rail__btn[aria-current="page"] .sm-rail__tile,.sm-rail__btn[aria-expanded="true"] .sm-rail__tile{background:var(--rail-active-bg)}
|
|
235
236
|
.sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
236
|
-
|
|
237
|
+
/* A collection's own title can be long: it ends in an ellipsis inside the rail (2026-11-13). */
|
|
238
|
+
.sm-rail__label{letter-spacing:.01em;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}
|
|
237
239
|
/* Not greyed: the reference renders every inactive item at --text and marks
|
|
238
240
|
the unavailable ones only by being inert (no pointer, no hover). */
|
|
239
241
|
.sm-rail__btn[aria-disabled="true"]{cursor:default}
|
|
@@ -562,6 +564,14 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
562
564
|
/* The rows sit in a centred column of at most 1200px inside the card,
|
|
563
565
|
under --table-top of air reserved for the coming search and filters. */
|
|
564
566
|
.sm-table{display:flex;flex-direction:column;padding:var(--table-pad);padding-top:var(--table-top);max-width:1200px;margin:0 auto}
|
|
567
|
+
/* The filter sits in that air, so the column heads stay where they are on a list without one (2026-11-13). */
|
|
568
|
+
.sm-table--tools{padding-top:20px}
|
|
569
|
+
.sm-table__tools{display:flex;align-items:center;gap:12px;height:40px;margin-bottom:20px;padding:0 8px}
|
|
570
|
+
.sm-table__tools .sm-input{max-width:320px}
|
|
571
|
+
/* The pager: the count on the left, the page size and Previous / Next on the right. */
|
|
572
|
+
.sm-table__foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:16px 8px 8px;color:var(--text-muted);font-size:var(--meta-size)}
|
|
573
|
+
.sm-table__pager{display:flex;align-items:center;gap:12px}
|
|
574
|
+
.sm-seg--size .sm-seg__opt{min-width:44px;font-size:var(--text-sm)}
|
|
565
575
|
.sm-table__head,.sm-row{display:grid;gap:12px;align-items:center;padding:0 8px}
|
|
566
576
|
.sm-table__head{
|
|
567
577
|
height:40px;border-bottom:var(--table-rule);font-family:var(--meta-font);font-weight:500;
|
package/dist/assemble.js
CHANGED
|
@@ -58,4 +58,7 @@ export function checkEntryQuery(schema, collection, o) {
|
|
|
58
58
|
if (o.limit !== undefined && (!Number.isInteger(o.limit) || o.limit < 0)) {
|
|
59
59
|
throw new Error(`smoodly: getEntries("${collection}"): limit must be a non-negative integer.`);
|
|
60
60
|
}
|
|
61
|
+
if (o.offset !== undefined && (!Number.isInteger(o.offset) || o.offset < 0)) {
|
|
62
|
+
throw new Error(`smoodly: getEntries("${collection}"): offset must be a non-negative integer.`);
|
|
63
|
+
}
|
|
61
64
|
}
|
package/dist/collections.d.ts
CHANGED
|
@@ -26,8 +26,11 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string
|
|
|
26
26
|
depth: number;
|
|
27
27
|
};
|
|
28
28
|
order?: EntryOrder;
|
|
29
|
+
/** `showInNav`: the collection gets its own item in the admin's main navigation, after Pages, in place of its
|
|
30
|
+
* line in the Collections menu (2026-11-13). Absent = in the menu. */
|
|
29
31
|
admin?: {
|
|
30
32
|
listColumns?: string[];
|
|
33
|
+
showInNav?: boolean;
|
|
31
34
|
};
|
|
32
35
|
/** Entries carry SEO & Social values (spec 2026-09-29 content API contract §7): stored under `$seo`, empty values falling back on `titleField` and the named fields. */
|
|
33
36
|
seo?: CollectionSeo;
|
|
@@ -48,6 +51,7 @@ export declare function collection<F extends BuilderMap, const N extends string
|
|
|
48
51
|
order?: EntryOrder;
|
|
49
52
|
admin?: {
|
|
50
53
|
listColumns?: string[];
|
|
54
|
+
showInNav?: boolean;
|
|
51
55
|
};
|
|
52
56
|
seo?: CollectionSeo;
|
|
53
57
|
}): CollectionSchema<F, N>;
|
package/dist/collections.js
CHANGED
|
@@ -11,6 +11,9 @@ export function collection(input) {
|
|
|
11
11
|
if (Object.prototype.hasOwnProperty.call(input.fields, SEO_KEY)) {
|
|
12
12
|
throw new Error(`smoodly: collection "${input.name}": "${SEO_KEY}" is a reserved key.`);
|
|
13
13
|
}
|
|
14
|
+
if (input.admin?.showInNav !== undefined && typeof input.admin.showInNav !== "boolean") {
|
|
15
|
+
throw new Error(`smoodly: collection "${input.name}": admin.showInNav is true or false.`);
|
|
16
|
+
}
|
|
14
17
|
if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
|
|
15
18
|
throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
|
|
16
19
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Descriptor } from "./fields.ts";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
3
|
import type { GlobalSchema } from "./global.ts";
|
|
4
|
+
import { type LocaleOrder } from "./paths.ts";
|
|
4
5
|
import { type Addresses } from "./addresses.ts";
|
|
5
6
|
import type { Anchors } from "./anchors.ts";
|
|
6
7
|
import { type ImageQuality } from "./media-cut.ts";
|
|
@@ -32,9 +33,12 @@ export type SmoodlyConfig = {
|
|
|
32
33
|
/** Global content: code-declared singletons (spec 2026-09-07). */
|
|
33
34
|
globals?: GlobalSchema[];
|
|
34
35
|
};
|
|
36
|
+
/** `order`: whether the page tree and manually ordered collections keep one order for every locale
|
|
37
|
+
* ("shared", the default) or each locale its own ("perLocale"): a drag then reorders the locale on screen alone. */
|
|
35
38
|
locales: {
|
|
36
39
|
default: string;
|
|
37
40
|
supported: string[];
|
|
41
|
+
order?: LocaleOrder;
|
|
38
42
|
};
|
|
39
43
|
/** The site's time zone, an IANA name such as "Europe/Helsinki" (spec
|
|
40
44
|
* 2026-09-25 date pickers §5): editors pick datetimes in it, views
|
package/dist/config.js
CHANGED
|
@@ -155,6 +155,10 @@ function assertLocales(config) {
|
|
|
155
155
|
const twice = supported.find((code, i) => supported.indexOf(code) !== i);
|
|
156
156
|
if (twice !== undefined)
|
|
157
157
|
throw new Error(`smoodly: the locale "${twice}" is listed twice in supported — list each locale once.`);
|
|
158
|
+
const order = config.locales.order;
|
|
159
|
+
if (order !== undefined && order !== "shared" && order !== "perLocale") {
|
|
160
|
+
throw new Error(`smoodly: locales.order is ${JSON.stringify(order)} — use "shared" (one order for every locale) or "perLocale".`);
|
|
161
|
+
}
|
|
158
162
|
}
|
|
159
163
|
function assertTimeZone(config) {
|
|
160
164
|
if (config.timeZone === undefined)
|