smoodly 0.0.13 → 0.0.15
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/README.md +87 -15
- package/dist/admin/editor/EditorView.js +12 -10
- package/dist/admin/editor/PageInspector.d.ts +21 -0
- package/dist/admin/editor/PageInspector.js +24 -0
- package/dist/admin/editor/PageSettings.d.ts +1 -3
- package/dist/admin/editor/PageSettings.js +7 -6
- package/dist/admin/editor/SeoSettings.d.ts +7 -0
- package/dist/admin/editor/SeoSettings.js +15 -0
- package/dist/admin/forms/FieldWidget.d.ts +1 -1
- package/dist/admin/forms/FieldWidget.js +24 -4
- package/dist/admin/ops-impl.js +72 -11
- package/dist/admin/serialize.d.ts +4 -0
- package/dist/admin/serialize.js +2 -0
- package/dist/admin/tree-ops.d.ts +2 -0
- package/dist/admin/tree-ops.js +5 -1
- package/dist/admin/ui/primitives.d.ts +9 -0
- package/dist/admin/ui/primitives.js +6 -0
- package/dist/admin/ui/resize.d.ts +1 -1
- package/dist/admin/ui/resize.js +1 -1
- package/dist/admin/ui/theme.js +16 -1
- package/dist/admin/validate.js +24 -3
- package/dist/collections.d.ts +6 -5
- package/dist/entry-store.d.ts +18 -1
- package/dist/entry-store.js +27 -2
- package/dist/field-defaults.d.ts +8 -0
- package/dist/field-defaults.js +18 -0
- package/dist/fields.d.ts +26 -1
- package/dist/fields.js +31 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +5 -3
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/next/meta.d.ts +7 -3
- package/dist/next/meta.js +18 -9
- package/dist/next/page-renderer.js +1 -1
- package/dist/next/queries.d.ts +36 -0
- package/dist/next/queries.js +87 -0
- package/dist/page-tree.d.ts +3 -0
- package/dist/page-tree.js +6 -0
- package/dist/page.d.ts +11 -3
- package/dist/page.js +26 -0
- package/dist/pairing.d.ts +2 -1
- package/dist/pairing.js +2 -9
- package/dist/render.d.ts +14 -7
- package/dist/render.js +2 -2
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +8 -0
- package/dist/revalidate.d.ts +6 -0
- package/dist/revalidate.js +6 -0
- package/dist/shared.d.ts +7 -7
- package/dist/site.d.ts +23 -1
- package/dist/site.js +83 -19
- package/dist/store.d.ts +4 -0
- package/dist/store.js +9 -0
- package/dist/supabase-entry-store.js +32 -10
- package/dist/supabase-store.d.ts +1 -0
- package/dist/supabase-store.js +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -255,9 +255,12 @@ Implemented:
|
|
|
255
255
|
collection list. The editor
|
|
256
256
|
is keyed by id, with a locale switcher loading that locale's own tree
|
|
257
257
|
and pointing the canvas at `urls[locale]` and, while no block is
|
|
258
|
-
selected, page settings (`admin/editor/
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
selected, page settings (`admin/editor/PageInspector.tsx`, spec
|
|
259
|
+
2026-09-10 page fields: **SEO & Social** — title, description, social
|
|
260
|
+
image, hide from search engines, under the tree's `meta` — and
|
|
261
|
+
**Settings** — the ACTIVE locale's title and slug through `pages.rename`,
|
|
262
|
+
the template's page fields under the tree's `fields`, remove from
|
|
263
|
+
locale). The entry form prefixes its slug field with the collection's
|
|
261
264
|
mount path. The serialized
|
|
262
265
|
registry carries `collections[].path`/`depth`, `pages[].slugs` and
|
|
263
266
|
`pageDepth`, and `defineConfig` validates `homePageSlug` — a real
|
|
@@ -278,12 +281,27 @@ Implemented:
|
|
|
278
281
|
is the two in sequence — `locate` then `load` — for a caller that just
|
|
279
282
|
wants the hit. A folder (null template), an
|
|
280
283
|
unpublished record outside draft mode, or a missing one is `null`.
|
|
281
|
-
On top of it the
|
|
282
|
-
|
|
283
|
-
`{
|
|
284
|
-
`
|
|
285
|
-
`
|
|
286
|
-
|
|
284
|
+
On top of it the content API for menus, sitemaps and custom routes,
|
|
285
|
+
published-only unless `draft: true` (spec 2026-09-11):
|
|
286
|
+
`getPagesTree({ locale, draft? })` (nested
|
|
287
|
+
`{ id, title, path, hasPage, fields, children }`), `getPage(pathOrId)`,
|
|
288
|
+
`getEntries(collection, { locale, parent?, order?, where?, limit?, draft? })`
|
|
289
|
+
— `where` is equality / any-of on shared fields and `limit` a SQL limit,
|
|
290
|
+
both run by the store — `getEntry(collection, idOrPath)` (an id, a
|
|
291
|
+
path, or a bare slug naming a root entry) and `getEntriesTree(collection)`.
|
|
292
|
+
In the app, read through `q` (next bullet) rather than the site layer.
|
|
293
|
+
- `createSmoodlyQueries` (`smoodly/next`, spec 2026-09-11) — the read
|
|
294
|
+
API the scaffold exports as `q`: `getPageTree({ locale })`,
|
|
295
|
+
`getPage(pathOrId)`, `getEntries("articles", { where, order, limit,
|
|
296
|
+
parent })`, `getEntry("articles", idOrPath)` (an id or a path — a bare
|
|
297
|
+
slug is refused, since its unit would sit under a tag no op fires) and
|
|
298
|
+
`getShared("header")`. Typed by the registry (names are checked, fields
|
|
299
|
+
typed), the live draft inside the editor preview, and outside it one
|
|
300
|
+
`unstable_cache` unit per distinct call under a tag the admin ops
|
|
301
|
+
expire: `pages:tree` (every page op that changes the listing),
|
|
302
|
+
`entries:<collection>` (every entry write), `page:<id>`, `entry:<id>`,
|
|
303
|
+
`shared:<name>`. `filterPageTree(items, keep)` prunes a tree for a nav
|
|
304
|
+
(a dropped parent drops its subtree).
|
|
287
305
|
- `smoodly/next` — `createPageRenderer`, the site-route seam over that
|
|
288
306
|
layer, bound once by the app like `createAdminOpHandler`. The two route
|
|
289
307
|
files call `renderSmoodlyPath(segments, { locale?, searchParams? })` and
|
|
@@ -340,7 +358,8 @@ Implemented:
|
|
|
340
358
|
registers a visual item, `smoodly.shared({ name, title, fields })` an
|
|
341
359
|
object item; one registration materializes as a singleton entry in
|
|
342
360
|
every supported locale. `site.getShared(item, { locale })` and the Next
|
|
343
|
-
glue's `
|
|
361
|
+
glue's `q.getShared("footer", { locale })` fetch it code-side (the
|
|
362
|
+
renderer's `getSmoodlyShared(item, { locale })` still works); the
|
|
344
363
|
editor places a visual item in a zone from the "+" picker as a linked
|
|
345
364
|
`{ type: "shared", fields: { item, ref } }` node the resolve pass
|
|
346
365
|
rewrites into the section, and `z.locked(footer)` binds a zone to one.
|
|
@@ -407,6 +426,64 @@ after both are on the registry. From the repo root:
|
|
|
407
426
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
408
427
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
409
428
|
|
|
429
|
+
## Follow-ups (logged 2026-09-11, queries spec)
|
|
430
|
+
|
|
431
|
+
- `where` on localized fields (a join on the locale row), ranges and
|
|
432
|
+
negation; any-of over an array-valued field (any-of compares the text
|
|
433
|
+
form of the field, so it covers selects, refs and numbers; on a
|
|
434
|
+
checkboxes, refList or list field `site.getEntries` refuses it rather
|
|
435
|
+
than match nothing — both adapters agree on the text form, so a
|
|
436
|
+
one-element array never matches its element).
|
|
437
|
+
- `where` filters a versioned collection's LIVE row, not its published
|
|
438
|
+
snapshot: an entry whose published version had `kind: "x"` and whose
|
|
439
|
+
draft changed it to `"y"` drops out of `where: { kind: "x" }` before
|
|
440
|
+
the change is published. The published gate is per locale row, the
|
|
441
|
+
filter is on the node's shared fields. Fix by filtering the snapshot
|
|
442
|
+
(a join on the version row) or by documenting that a versioned
|
|
443
|
+
collection's shared fields are live for filtering.
|
|
444
|
+
- A GIN index on `entries.fields` when a customer's collection is large
|
|
445
|
+
enough to need it; additive, no migration.
|
|
446
|
+
- OQ 26: whether page units should carry `pages:tree` too, replacing
|
|
447
|
+
the neighbour list.
|
|
448
|
+
- Scripts and the seed write through the stores and expire nothing; a
|
|
449
|
+
`revalidate` hook for out-of-band writers.
|
|
450
|
+
- `getEntriesTree` has no `q` counterpart yet; add one when a site
|
|
451
|
+
needs a nested collection nav.
|
|
452
|
+
- `getShared(["header", "footer", "contact"], { locale })` returning a
|
|
453
|
+
typed tuple, as sugar over the `Promise.all` the layout writes today.
|
|
454
|
+
Keep one cache unit per item under its own tag — a single unit tagged
|
|
455
|
+
with all three would expire the header whenever the footer is edited.
|
|
456
|
+
- `getEntry` by id goes through `site.getEntry`, which lists the whole
|
|
457
|
+
collection to find one row; a primary-key read first would be cheaper
|
|
458
|
+
on the uncached path (draft mode, first miss).
|
|
459
|
+
- The read API returns the site layer's shapes unprojected: `getEntries`
|
|
460
|
+
gives `{ entry: EntryRecord, path }` (the locales map, sort, parentId,
|
|
461
|
+
collection and timestamps ride along under `entry`), `getPage` gives
|
|
462
|
+
the renderer's `SitePage` (`record`, `tree`). Project in `queries.ts`,
|
|
463
|
+
at the public boundary, to `{ id, path, fields, updatedAt }` per entry
|
|
464
|
+
and a page without renderer internals; the tree item is already lean.
|
|
465
|
+
Do it before the Astro glue (OQ 29) wraps the same reads and before
|
|
466
|
+
a second site depends on the shape — it is a breaking change to `q`.
|
|
467
|
+
|
|
468
|
+
## Follow-ups (logged 2026-09-10, page fields and SEO spec)
|
|
469
|
+
|
|
470
|
+
- SEO for entry pages: entries have no `meta`; a `titleField`-based
|
|
471
|
+
title and a description field are the next step.
|
|
472
|
+
- A `styles` block on pages, when a case needs presentation separate
|
|
473
|
+
from content.
|
|
474
|
+
- Refs in page fields, once the refs index walks them.
|
|
475
|
+
- A canonical URL field and a search-result preview snippet in the
|
|
476
|
+
SEO & Social tab.
|
|
477
|
+
- Configurable SEO set (`meta: { seo: { twitter: true } }`) if the four
|
|
478
|
+
values prove too few.
|
|
479
|
+
- Link fields are raw: an image in a page field reaches a nav link as
|
|
480
|
+
`{ asset, alt }`, not a URL. Resolve on demand if a nav ever wants one.
|
|
481
|
+
- The social image URL goes through the default `next` loader at 1200px;
|
|
482
|
+
the cloud's transform loader (OQ 28) should serve a real 1200×630.
|
|
483
|
+
- Page-field labels derive from the key (`showInNavigation` → "Show In
|
|
484
|
+
Navigation"), as every field's do; a `.label()` builder option would
|
|
485
|
+
read better for long keys.
|
|
486
|
+
|
|
410
487
|
## Follow-ups (logged 2026-09-10, inline editing spec)
|
|
411
488
|
|
|
412
489
|
- Shared placements ignore field clicks: their fields live in the shared
|
|
@@ -713,9 +790,6 @@ the toolbar instead of pushing the content.
|
|
|
713
790
|
in the single-language scaffold). Adding `locale` to `PageContext` and
|
|
714
791
|
`EntryPageProps` would remove that dependency and let sections that
|
|
715
792
|
format dates stay synchronous.
|
|
716
|
-
- The content API is published-only, so the Articles index in the editor
|
|
717
|
-
canvas lists published articles while the hero above it is the draft.
|
|
718
|
-
A draft-aware `getEntries` for draft mode is the fix if that confuses.
|
|
719
793
|
- The image widget edits the URL only; `alt` is set by the seed and cannot
|
|
720
794
|
be changed in the admin.
|
|
721
795
|
- A per-locale collection path or fixed slug must name the default locale;
|
|
@@ -923,8 +997,6 @@ the toolbar instead of pushing the content.
|
|
|
923
997
|
- Nested entries have no admin UI beyond `entries.move`: nothing creates
|
|
924
998
|
a child entry or shows the entry tree, and the entry form's slug prefix
|
|
925
999
|
is the collection's mount base only, not the parent chain.
|
|
926
|
-
- SEO/meta fields in page settings — DESIGN.md OQ #17. Title and slug per
|
|
927
|
-
locale shipped; nothing writes `PageTree.meta` yet.
|
|
928
1000
|
- `pages.get` makes four sequential store round trips for a non-root page
|
|
929
1001
|
(`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
|
|
930
1002
|
`parentLocales`; three for a root, which has no parent to read), and the
|
|
@@ -20,10 +20,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
20
20
|
// The pieces live beside it — `useCanvasBridge` (geometry and messages),
|
|
21
21
|
// `usePageLocale` (switch/add/remove), `CanvasOverlay` and
|
|
22
22
|
// `overlay-geometry` (the chrome above the iframe), `InsertPicker`,
|
|
23
|
-
// `Outline`, `
|
|
23
|
+
// `Outline`, `PageInspector`, `NoticePane`.
|
|
24
24
|
//
|
|
25
25
|
// Chrome follows the design system's visual-editor screen: top bar, toned
|
|
26
|
-
// canvas around a white page,
|
|
26
|
+
// canvas around a white page, 360px inspector holding fields then outline.
|
|
27
27
|
//
|
|
28
28
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
29
29
|
// that knows about Next, and it ships to the site, not the admin.
|
|
@@ -33,14 +33,14 @@ import { descriptorAt, getAtPath } from "../field-paths.js";
|
|
|
33
33
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
34
34
|
import { revealField } from "../forms/reveal.js";
|
|
35
35
|
import { tabOf, tabsFor } from "../forms/tabs.js";
|
|
36
|
-
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
36
|
+
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
37
37
|
import { useDraftAutosave } from "./autosave.js";
|
|
38
38
|
import { CanvasOverlay } from "./CanvasOverlay.js";
|
|
39
39
|
import { InlineRichtext } from "./InlineRichtext.js";
|
|
40
40
|
import { InsertPicker } from "./InsertPicker.js";
|
|
41
41
|
import { NoticePane } from "./NoticePane.js";
|
|
42
42
|
import { Outline } from "./Outline.js";
|
|
43
|
-
import {
|
|
43
|
+
import { PageInspector } from "./PageInspector.js";
|
|
44
44
|
import { localeFromUrl } from "./locale-url.js";
|
|
45
45
|
import { plusButtonsFor } from "./overlay-geometry.js";
|
|
46
46
|
import { useCanvasBridge } from "./useCanvasBridge.js";
|
|
@@ -438,6 +438,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
438
438
|
const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
|
|
439
439
|
const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
|
|
440
440
|
const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
|
|
441
|
+
/** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
|
|
442
|
+
const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
|
|
441
443
|
// ── publish ──────────────────────────────────────────────────────────
|
|
442
444
|
const doPublish = async () => {
|
|
443
445
|
setBanner(null);
|
|
@@ -539,16 +541,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
539
541
|
if (!node || !section)
|
|
540
542
|
return null;
|
|
541
543
|
return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(node.fields, session.path), toolset: descriptorAt(section, session.path)?.toolset, onChange: (doc) => { inline.markChanged(); mutate((t) => patchNodePath(t, session.node, session.path, doc)); }, onEscape: inline.end }));
|
|
542
|
-
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) :
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
544
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
545
|
+
!isFixedNode(record, registry) &&
|
|
546
|
+
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
547
|
+
? () => { void doRemoveLocale(); }
|
|
548
|
+
: undefined })) : null, selected && (selectedSection || selectedShared) && (_jsxs("div", { className: "sm-inspector__body", children: [selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
547
549
|
reveal(p, selectedSection); }, onChange: (value) => {
|
|
548
550
|
doPatch(selected.id, ns, key, value);
|
|
549
551
|
if (ns === "fields")
|
|
550
552
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
551
|
-
} }, `${selected.id}.${ns}.${key}`))),
|
|
553
|
+
} }, `${selected.id}.${ns}.${key}`))), selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
|
|
552
554
|
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
553
555
|
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
|
|
554
556
|
})), onAddFirst: (zone) => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PageTree } from "../../render.tsx";
|
|
2
|
+
import type { PageRecord } from "../../store.ts";
|
|
3
|
+
import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
4
|
+
export type PageTab = "SEO & Social" | "Settings";
|
|
5
|
+
export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
|
|
6
|
+
export declare function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }: {
|
|
7
|
+
record: PageRecord;
|
|
8
|
+
locale: string;
|
|
9
|
+
registry: AdminRegistry;
|
|
10
|
+
tree: PageTree | null;
|
|
11
|
+
/** The page's path in this locale (the `paths` index). */
|
|
12
|
+
path: string | undefined;
|
|
13
|
+
fixed: boolean;
|
|
14
|
+
onRename(patch: {
|
|
15
|
+
title?: string;
|
|
16
|
+
slug?: string;
|
|
17
|
+
}): Promise<string | null>;
|
|
18
|
+
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
19
|
+
onRemoveLocale?: () => void;
|
|
20
|
+
onPatch(ns: "fields" | "meta", key: string, value: unknown): void;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The inspector with nothing selected (spec 2026-09-10 page fields §5):
|
|
4
|
+
// the page's title as the head, then SEO & Social (first and default when
|
|
5
|
+
// the template enables it) and Settings — title, slug, the template's
|
|
6
|
+
// page fields, remove from locale. Page tabs keep their own state and
|
|
7
|
+
// reset when the page changes.
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
10
|
+
import { Button, Tabs } from "../ui/primitives.js";
|
|
11
|
+
import { PageSettings } from "./PageSettings.js";
|
|
12
|
+
import { SeoSettings } from "./SeoSettings.js";
|
|
13
|
+
export function pageTabsFor(template) {
|
|
14
|
+
return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
|
|
15
|
+
}
|
|
16
|
+
export function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }) {
|
|
17
|
+
const template = registry.pages.find((p) => p.name === record.template);
|
|
18
|
+
const tabs = pageTabsFor(template);
|
|
19
|
+
const [tab, setTab] = useState(tabs[0]);
|
|
20
|
+
useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
21
|
+
const active = tabs.includes(tab) ? tab : tabs[0];
|
|
22
|
+
const title = record.locales[locale]?.title ?? "";
|
|
23
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
|
|
24
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageRecord } from "../../store.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
export declare function PageSettings({ record, locale, registry, path, fixed, onRename
|
|
3
|
+
export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
|
|
4
4
|
record: PageRecord;
|
|
5
5
|
locale: string;
|
|
6
6
|
registry: AdminRegistry;
|
|
@@ -12,6 +12,4 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
|
|
|
12
12
|
title?: string;
|
|
13
13
|
slug?: string;
|
|
14
14
|
}): Promise<string | null>;
|
|
15
|
-
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
16
|
-
onRemoveLocale?: () => void;
|
|
17
15
|
}): import("react").JSX.Element | null;
|
|
@@ -3,12 +3,13 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
// Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
|
|
4
4
|
// §6): the ACTIVE locale's title and slug, saved through pages.rename,
|
|
5
5
|
// and "Remove from this locale". The switcher in the canvas meta row
|
|
6
|
-
// selects the locale, so there are no per-locale tabs here.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// selects the locale, so there are no per-locale tabs here. Rendered on
|
|
7
|
+
// the Settings tab of PageInspector while no block is selected; the SEO
|
|
8
|
+
// tab and the remove-from-locale button live there (spec 2026-09-10 page
|
|
9
|
+
// fields §5).
|
|
9
10
|
import { useEffect, useState } from "react";
|
|
10
|
-
import {
|
|
11
|
-
export function PageSettings({ record, locale, registry, path, fixed, onRename
|
|
11
|
+
import { Field, SectionLabel } from "../ui/primitives.js";
|
|
12
|
+
export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
|
|
12
13
|
const row = record.locales[locale];
|
|
13
14
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
14
15
|
const [error, setError] = useState(null);
|
|
@@ -36,5 +37,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename,
|
|
|
36
37
|
void commit(key);
|
|
37
38
|
}
|
|
38
39
|
};
|
|
39
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) })
|
|
40
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) })] }));
|
|
40
41
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const SEO_TITLE_MAX = 60;
|
|
2
|
+
export declare const SEO_DESCRIPTION_MAX = 160;
|
|
3
|
+
export type SeoKey = "title" | "description" | "ogImage" | "noindex";
|
|
4
|
+
export declare function SeoSettings({ meta, onPatch }: {
|
|
5
|
+
meta: Record<string, unknown>;
|
|
6
|
+
onPatch(key: SeoKey, value: unknown): void;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { MediaWidget } from "../forms/MediaWidget.js";
|
|
4
|
+
import { Field, Switch } from "../ui/primitives.js";
|
|
5
|
+
export const SEO_TITLE_MAX = 60;
|
|
6
|
+
export const SEO_DESCRIPTION_MAX = 160;
|
|
7
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
8
|
+
function Counter({ n, max }) {
|
|
9
|
+
return _jsxs("span", { className: n > max ? "sm-counter--over" : undefined, children: [n, "/", max] });
|
|
10
|
+
}
|
|
11
|
+
export function SeoSettings({ meta, onPatch }) {
|
|
12
|
+
const title = str(meta.title);
|
|
13
|
+
const description = str(meta.description);
|
|
14
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(Field, { label: "Social image", hint: "1200\u00D7630 recommended", asDiv: true, children: _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v) }) }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
|
|
15
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactElement, ReactNode } from "react";
|
|
2
2
|
import type { AdminField } from "../serialize.ts";
|
|
3
|
-
export declare function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, }: {
|
|
3
|
+
export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, }: {
|
|
4
4
|
descriptor: AdminField;
|
|
5
5
|
value: unknown;
|
|
6
6
|
onChange(value: unknown): void;
|
|
@@ -3,12 +3,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { RichtextWidget } from "./RichtextWidget.js";
|
|
4
4
|
import { MediaWidget } from "./MediaWidget.js";
|
|
5
5
|
import { ListField } from "./ListField.js";
|
|
6
|
-
import {
|
|
6
|
+
import { optionsOf } from "../../fields.js";
|
|
7
|
+
import { Button, Field, IconButton, Switch } from "../ui/primitives.js";
|
|
7
8
|
import { patchObject } from "./list.js";
|
|
8
9
|
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
9
|
-
export function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, }) {
|
|
10
|
+
export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, }) {
|
|
10
11
|
const path = pathProp ?? fieldKey;
|
|
11
12
|
const message = errors?.[path];
|
|
13
|
+
// The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
|
|
14
|
+
const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
|
|
12
15
|
const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
|
|
13
16
|
let control;
|
|
14
17
|
let counter;
|
|
@@ -27,9 +30,26 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
|
|
|
27
30
|
case "number":
|
|
28
31
|
control = (_jsx("input", { className: "sm-input", type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
|
|
29
32
|
break;
|
|
33
|
+
case "boolean":
|
|
34
|
+
control = _jsx(Switch, { checked: value === true, onChange: onChange });
|
|
35
|
+
break;
|
|
30
36
|
case "select": {
|
|
31
|
-
const options =
|
|
32
|
-
control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o, children: o }, o))] }));
|
|
37
|
+
const options = optionsOf(d.options);
|
|
38
|
+
control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o.value, children: o.label }, o.value))] }));
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "radio": {
|
|
42
|
+
asDiv = true; // several inputs: never inside one implicit label
|
|
43
|
+
control = (_jsx("div", { className: "sm-choices", role: "radiogroup", children: optionsOf(d.options).map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "radio", name: path, value: o.value, checked: value === o.value, onChange: () => onChange(o.value) }), o.label] }, o.value))) }));
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "checkboxes": {
|
|
47
|
+
asDiv = true;
|
|
48
|
+
const chosen = Array.isArray(value) ? value : [];
|
|
49
|
+
const all = optionsOf(d.options);
|
|
50
|
+
// Stored in option order, whatever order the boxes were ticked in.
|
|
51
|
+
const toggle = (v, on) => onChange(all.map((o) => o.value).filter((x) => (x === v ? on : chosen.includes(x))));
|
|
52
|
+
control = (_jsx("div", { className: "sm-choices", children: all.map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "checkbox", checked: chosen.includes(o.value), onChange: (e) => toggle(o.value, e.target.checked) }), o.label] }, o.value))) }));
|
|
33
53
|
break;
|
|
34
54
|
}
|
|
35
55
|
case "richtext":
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
|
|
2
2
|
import { ASSET_COLLECTION } from "../media.js";
|
|
3
3
|
import { BASE } from "./shell/base.js";
|
|
4
|
-
import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
4
|
+
import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, sharedTag } from "../revalidate.js";
|
|
5
5
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
6
6
|
import { ensureSharedItems } from "./shared-items.js";
|
|
7
7
|
import { validateFields } from "./validate.js";
|
|
@@ -122,6 +122,36 @@ export function createAdminOps(deps) {
|
|
|
122
122
|
effects.revalidate(sharedTag(item.name));
|
|
123
123
|
}
|
|
124
124
|
};
|
|
125
|
+
/** An asset's metadata (alt, title, caption, credit) is read wherever media
|
|
126
|
+
* resolves (spec 2026-09-08 §9): the entry and page units that reference the
|
|
127
|
+
* asset, directly or through an entry, the shared items among those rows,
|
|
128
|
+
* and the list units of every referencing entry's collection. The walk is
|
|
129
|
+
* the entry fan-out's, seeded with the asset's id — which is no entry, so
|
|
130
|
+
* it gets no tag of its own. */
|
|
131
|
+
const expireAssetUsers = async (assetId) => {
|
|
132
|
+
if (!effects?.revalidate)
|
|
133
|
+
return;
|
|
134
|
+
const { pageIds, entryIds } = await affectedTargets({ pages, entries }, assetId);
|
|
135
|
+
const users = entryIds.filter((id) => id !== assetId);
|
|
136
|
+
for (const id of users)
|
|
137
|
+
effects.revalidate(entryTag(id));
|
|
138
|
+
for (const id of pageIds)
|
|
139
|
+
effects.revalidate(pageTag(id));
|
|
140
|
+
const touched = new Set(users);
|
|
141
|
+
for (const item of config.registry.shared ?? []) {
|
|
142
|
+
const row = (await entries.list(item.name))[0];
|
|
143
|
+
if (row && touched.has(row.id))
|
|
144
|
+
effects.revalidate(sharedTag(item.name));
|
|
145
|
+
}
|
|
146
|
+
for (const c of config.registry.collections ?? []) {
|
|
147
|
+
for (const id of users) {
|
|
148
|
+
if (await entries.get(c.name, id)) {
|
|
149
|
+
effects.revalidate(collectionTag(c.name));
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
125
155
|
/** Expire the cached units a tree change touched, in order, once each. */
|
|
126
156
|
const expirePages = (ids) => {
|
|
127
157
|
if (!effects?.revalidate)
|
|
@@ -129,6 +159,10 @@ export function createAdminOps(deps) {
|
|
|
129
159
|
for (const id of new Set(ids.filter((x) => typeof x === "string")))
|
|
130
160
|
effects.revalidate(pageTag(id));
|
|
131
161
|
};
|
|
162
|
+
/** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. */
|
|
163
|
+
const expireTree = () => effects?.revalidate?.(pagesTreeTag());
|
|
164
|
+
/** A collection's list units: every entry write, since a non-versioned collection's published read is its live row. */
|
|
165
|
+
const expireCollection = (name) => effects?.revalidate?.(collectionTag(name));
|
|
132
166
|
/** A node and every descendant, node first, in listPages() order. */
|
|
133
167
|
const subtreeIds = async (id) => {
|
|
134
168
|
const all = await pages.listPages();
|
|
@@ -244,10 +278,12 @@ export function createAdminOps(deps) {
|
|
|
244
278
|
// A root create competes with the code-owned slugs: make sure they exist first.
|
|
245
279
|
await ensureFixedNodes(pages, config);
|
|
246
280
|
}
|
|
247
|
-
|
|
281
|
+
const created = await pages.createPage({
|
|
248
282
|
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
249
283
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
250
|
-
})
|
|
284
|
+
});
|
|
285
|
+
expireTree();
|
|
286
|
+
return ok(created);
|
|
251
287
|
}),
|
|
252
288
|
saveDraft: (id, locale, tree) => guard(async () => {
|
|
253
289
|
const unsupported = supportedLocale(locale);
|
|
@@ -286,12 +322,18 @@ export function createAdminOps(deps) {
|
|
|
286
322
|
}
|
|
287
323
|
}
|
|
288
324
|
}
|
|
325
|
+
// The page's own fields (spec 2026-09-10 page fields §2); SEO meta is never validated.
|
|
326
|
+
const pageDescs = openTemplate(record.template)?.schema?.fields ?? {};
|
|
327
|
+
for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
|
|
328
|
+
fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
|
|
329
|
+
}
|
|
289
330
|
if (fieldErrors.length > 0) {
|
|
290
331
|
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
291
332
|
}
|
|
292
333
|
const published = await pages.publish(id, locale);
|
|
293
334
|
// The parent's cached unit lists its published children.
|
|
294
335
|
expirePages([published.id, published.parentId]);
|
|
336
|
+
expireTree();
|
|
295
337
|
return ok(published);
|
|
296
338
|
}),
|
|
297
339
|
delete: (id) => guard(async () => {
|
|
@@ -307,6 +349,7 @@ export function createAdminOps(deps) {
|
|
|
307
349
|
// The cached unit is keyed by (locale, path) but tagged by the id
|
|
308
350
|
// found at the first miss; the parent's unit lists its children.
|
|
309
351
|
expirePages([id, record.parentId]);
|
|
352
|
+
expireTree();
|
|
310
353
|
return ok(null);
|
|
311
354
|
}),
|
|
312
355
|
rename: (id, patch) => guard(async () => {
|
|
@@ -325,6 +368,7 @@ export function createAdminOps(deps) {
|
|
|
325
368
|
// Every descendant's path changed with this segment; the parent
|
|
326
369
|
// lists this node's title and path.
|
|
327
370
|
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
371
|
+
expireTree();
|
|
328
372
|
return ok(renamed);
|
|
329
373
|
}),
|
|
330
374
|
move: (id, to) => guard(async () => {
|
|
@@ -347,6 +391,7 @@ export function createAdminOps(deps) {
|
|
|
347
391
|
}
|
|
348
392
|
const moved = await pages.move(id, to);
|
|
349
393
|
expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
|
|
394
|
+
expireTree();
|
|
350
395
|
return ok(moved);
|
|
351
396
|
}),
|
|
352
397
|
setTemplate: (id, template) => guard(async () => {
|
|
@@ -363,7 +408,9 @@ export function createAdminOps(deps) {
|
|
|
363
408
|
if (registration.schema.slug !== undefined) {
|
|
364
409
|
return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
|
|
365
410
|
}
|
|
366
|
-
|
|
411
|
+
const withTemplate = await pages.setTemplate(id, template);
|
|
412
|
+
expireTree();
|
|
413
|
+
return ok(withTemplate);
|
|
367
414
|
}),
|
|
368
415
|
addLocale: (id, locale, options) => guard(async () => {
|
|
369
416
|
const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
|
|
@@ -371,8 +418,10 @@ export function createAdminOps(deps) {
|
|
|
371
418
|
return unsupported;
|
|
372
419
|
if (!(await pages.getPage(id)))
|
|
373
420
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
374
|
-
// A new locale is a draft: nothing public changes
|
|
375
|
-
|
|
421
|
+
// A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
|
|
422
|
+
const added = await pages.addLocale(id, locale, { from: options.from });
|
|
423
|
+
expireTree();
|
|
424
|
+
return ok(added);
|
|
376
425
|
}),
|
|
377
426
|
removeLocale: (id, locale) => guard(async () => {
|
|
378
427
|
const unsupported = supportedLocale(locale);
|
|
@@ -388,6 +437,7 @@ export function createAdminOps(deps) {
|
|
|
388
437
|
const removed = await pages.removeLocale(id, locale);
|
|
389
438
|
// The locale may have been published: its unit and the parent's listing go.
|
|
390
439
|
expirePages([id, record.parentId]);
|
|
440
|
+
expireTree();
|
|
391
441
|
return ok(removed);
|
|
392
442
|
}),
|
|
393
443
|
},
|
|
@@ -417,7 +467,9 @@ export function createAdminOps(deps) {
|
|
|
417
467
|
const fieldErrors = validateFields(input.fields, descriptors, elementDescs);
|
|
418
468
|
if (fieldErrors.length > 0)
|
|
419
469
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
420
|
-
|
|
470
|
+
const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
|
|
471
|
+
expireCollection(collection);
|
|
472
|
+
return ok(created);
|
|
421
473
|
}),
|
|
422
474
|
save: (collection, id, locale, fields) => guard(async () => {
|
|
423
475
|
const unsupported = supportedLocale(locale);
|
|
@@ -431,6 +483,7 @@ export function createAdminOps(deps) {
|
|
|
431
483
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
432
484
|
const entry = await entries.update(collection, id, locale, fields);
|
|
433
485
|
await fanOut(id);
|
|
486
|
+
expireCollection(collection);
|
|
434
487
|
return ok(entry);
|
|
435
488
|
}),
|
|
436
489
|
setStatus: (collection, id, locale, status) => guard(async () => {
|
|
@@ -439,24 +492,29 @@ export function createAdminOps(deps) {
|
|
|
439
492
|
return unsupported;
|
|
440
493
|
const entry = await entries.setStatus(collection, id, locale, status);
|
|
441
494
|
await fanOut(id);
|
|
495
|
+
expireCollection(collection);
|
|
442
496
|
return ok(entry);
|
|
443
497
|
}),
|
|
444
498
|
// The tag expires only after the delete succeeded; a blocked delete expires nothing.
|
|
445
|
-
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), ok(null))),
|
|
499
|
+
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
|
|
446
500
|
move: (collection, id, to) => guard(async () => {
|
|
447
501
|
const moved = await entries.move(collection, id, to);
|
|
448
502
|
if (effects?.revalidate) {
|
|
449
503
|
for (const entryId of await entrySubtreeIds(collection, id))
|
|
450
504
|
effects.revalidate(entryTag(entryId));
|
|
451
505
|
}
|
|
506
|
+
expireCollection(collection);
|
|
452
507
|
return ok(moved);
|
|
453
508
|
}),
|
|
454
|
-
// A new locale starts as a draft: nothing public changes,
|
|
509
|
+
// A new locale starts as a draft: nothing public changes, but the collection's
|
|
510
|
+
// list units are expired anyway (spec 2026-09-11 §2) so they can never go stale.
|
|
455
511
|
addLocale: (collection, id, locale, options) => guard(async () => {
|
|
456
512
|
const unsupported = supportedLocale(locale);
|
|
457
513
|
if (unsupported)
|
|
458
514
|
return unsupported;
|
|
459
|
-
|
|
515
|
+
const added = await entries.addLocale(collection, id, locale, { from: options.from });
|
|
516
|
+
expireCollection(collection);
|
|
517
|
+
return ok(added);
|
|
460
518
|
}),
|
|
461
519
|
removeLocale: (collection, id, locale) => guard(async () => {
|
|
462
520
|
const unsupported = supportedLocale(locale);
|
|
@@ -464,6 +522,7 @@ export function createAdminOps(deps) {
|
|
|
464
522
|
return unsupported;
|
|
465
523
|
const entry = await entries.removeLocale(collection, id, locale);
|
|
466
524
|
await fanOut(id);
|
|
525
|
+
expireCollection(collection);
|
|
467
526
|
return ok(entry);
|
|
468
527
|
}),
|
|
469
528
|
},
|
|
@@ -560,7 +619,9 @@ export function createAdminOps(deps) {
|
|
|
560
619
|
return err("validation", `smoodly: ${key} must be text.`);
|
|
561
620
|
patch[key] = v;
|
|
562
621
|
}
|
|
563
|
-
|
|
622
|
+
const updated = await assets.update(id, patch);
|
|
623
|
+
await expireAssetUsers(id);
|
|
624
|
+
return ok(updated);
|
|
564
625
|
}),
|
|
565
626
|
delete: (id) => guard(async () => (assets ? (await assets.delete(id), ok(null)) : noMedia())),
|
|
566
627
|
usage: (id) => guard(async () => {
|
|
@@ -38,6 +38,10 @@ export type AdminPageTemplate = {
|
|
|
38
38
|
/** The per-locale segments, only when the registration declares an object. */
|
|
39
39
|
slugs?: Record<string, string>;
|
|
40
40
|
zones: Record<string, ZonePolicy>;
|
|
41
|
+
/** The template's page fields (spec 2026-09-10 page fields §5), edited on the Settings tab. */
|
|
42
|
+
fields: Record<string, AdminField>;
|
|
43
|
+
/** `meta: { seo: true }` — the SEO & Social tab exists for this template. */
|
|
44
|
+
seo: boolean;
|
|
41
45
|
};
|
|
42
46
|
export type AdminShared = {
|
|
43
47
|
name: string;
|