smoodly 0.0.28 → 0.0.30
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 +123 -17
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/drift-copy.d.ts +19 -0
- package/dist/admin/drift-copy.js +89 -0
- package/dist/admin/drift.d.ts +76 -0
- package/dist/admin/drift.js +264 -0
- package/dist/admin/editor/CanvasWarning.d.ts +5 -0
- package/dist/admin/editor/CanvasWarning.js +10 -0
- package/dist/admin/editor/EditorView.js +113 -26
- package/dist/admin/editor/InlineRichtext.js +3 -1
- package/dist/admin/editor/Outline.d.ts +7 -0
- package/dist/admin/editor/Outline.js +8 -5
- package/dist/admin/editor/PageInspector.d.ts +6 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.js +26 -16
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -0
- package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
- package/dist/admin/editor/canvas-diagnosis.js +106 -0
- package/dist/admin/editor/crash-copy.d.ts +7 -0
- package/dist/admin/editor/crash-copy.js +13 -0
- package/dist/admin/editor/protocol.d.ts +2 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
- package/dist/admin/editor/useCanvasBridge.js +14 -7
- package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
- package/dist/admin/field-paths.d.ts +3 -0
- package/dist/admin/field-paths.js +26 -0
- package/dist/admin/forms/OrphanFields.d.ts +8 -0
- package/dist/admin/forms/OrphanFields.js +11 -0
- package/dist/admin/forms/RefPicker.js +6 -2
- package/dist/admin/forms/RichtextElementCard.js +9 -3
- package/dist/admin/forms/RichtextWidget.js +3 -1
- package/dist/admin/forms/TagsField.js +1 -1
- package/dist/admin/forms/richtext-form-context.d.ts +8 -0
- package/dist/admin/forms/richtext-form-context.js +5 -0
- package/dist/admin/health.d.ts +23 -0
- package/dist/admin/health.js +42 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/next/bridge.js +4 -2
- package/dist/admin/next/index.d.ts +0 -1
- package/dist/admin/next/index.js +6 -1
- package/dist/admin/ops-impl.js +40 -9
- package/dist/admin/ops.d.ts +13 -0
- package/dist/admin/shell/AddRedirectModal.js +1 -1
- package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
- package/dist/admin/shell/ContentHealthPage.js +37 -0
- package/dist/admin/shell/EntriesList.js +4 -4
- package/dist/admin/shell/EntryForm.js +20 -3
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +5 -4
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/PagesList.js +3 -3
- package/dist/admin/shell/SettingsView.js +6 -2
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/settings.d.ts +4 -0
- package/dist/admin/shell/settings.js +5 -2
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/ActionMenu.d.ts +20 -0
- package/dist/admin/ui/ActionMenu.js +33 -7
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.js +3 -3
- package/dist/admin/ui/DriftNotice.d.ts +6 -0
- package/dist/admin/ui/DriftNotice.js +10 -0
- package/dist/admin/ui/combo-box.d.ts +13 -0
- package/dist/admin/ui/combo-box.js +8 -0
- package/dist/admin/ui/confirm.d.ts +8 -0
- package/dist/admin/ui/confirm.js +17 -4
- package/dist/admin/ui/theme.js +29 -3
- package/dist/admin/validate.js +15 -47
- package/dist/custom/CustomSection.d.ts +1 -0
- package/dist/custom/CustomSection.js +4 -1
- package/dist/entry-store.d.ts +13 -0
- package/dist/entry-store.js +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/marks.d.ts +12 -0
- package/dist/marks.js +14 -0
- package/dist/next/canvas-boundary.d.ts +16 -0
- package/dist/next/canvas-boundary.js +25 -0
- package/dist/next/page-renderer.d.ts +3 -1
- package/dist/next/page-renderer.js +33 -4
- package/dist/pairing.js +10 -3
- package/dist/render.d.ts +12 -1
- package/dist/render.js +15 -4
- package/dist/section-crash.d.ts +11 -0
- package/dist/section-crash.js +16 -0
- package/dist/section-guard.d.ts +27 -0
- package/dist/section-guard.js +41 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +8 -0
- package/dist/supabase-entry-store.d.ts +2 -1
- package/dist/supabase-entry-store.js +27 -5
- package/dist/supabase-store.d.ts +2 -1
- package/dist/supabase-store.js +27 -0
- package/package.json +1 -1
package/dist/admin/ops-impl.js
CHANGED
|
@@ -7,6 +7,10 @@ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, global
|
|
|
7
7
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
8
8
|
import { ensureGlobalItems } from "./global-items.js";
|
|
9
9
|
import { validateCustomNode, validateFields, validateNode } from "./validate.js";
|
|
10
|
+
import { serializeAdminConfig } from "./serialize.js";
|
|
11
|
+
import { entryDrift, pageDrift } from "./drift.js";
|
|
12
|
+
import { publishErrors } from "./drift-copy.js";
|
|
13
|
+
import { buildHealthReport } from "./health.js";
|
|
10
14
|
import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
|
|
11
15
|
import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
12
16
|
import { withDefaults } from "../field-defaults.js";
|
|
@@ -112,12 +116,23 @@ export function createAdminOps(deps) {
|
|
|
112
116
|
const by = deps.actor?.email ?? undefined;
|
|
113
117
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
114
118
|
// Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
|
|
119
|
+
// The serialized registry drift walks (spec 2026-09-27 editor safety §2) — the admin client's shape.
|
|
120
|
+
let adminRegistryMemo = null;
|
|
121
|
+
const adminRegistry = () => (adminRegistryMemo ?? (adminRegistryMemo = serializeAdminConfig(config)));
|
|
115
122
|
const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, settings: e.schema.settings })));
|
|
116
123
|
// The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
|
|
117
124
|
const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections?.find((s) => s.schema.name === type)?.schema);
|
|
118
125
|
const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
|
|
119
126
|
/** One row per registration; null when materialization could not create it. */
|
|
120
127
|
const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
|
|
128
|
+
/** The published snapshot's drift for the editors' live-only line (spec 2026-09-27 editor safety §5). */
|
|
129
|
+
const liveEntryFindings = async (name, record, locale) => {
|
|
130
|
+
const row = record.locales[locale];
|
|
131
|
+
if (row?.status !== "published" || !row.publishedVersionId)
|
|
132
|
+
return [];
|
|
133
|
+
const snap = await entries.getVersion(name, row.publishedVersionId);
|
|
134
|
+
return snap ? entryDrift(adminRegistry(), name, { fields: snap.fields }) : [];
|
|
135
|
+
};
|
|
121
136
|
const fanOut = async (entryId) => {
|
|
122
137
|
if (!effects?.revalidate)
|
|
123
138
|
return;
|
|
@@ -317,7 +332,9 @@ export function createAdminOps(deps) {
|
|
|
317
332
|
const parentLocales = record.parentId === null
|
|
318
333
|
? config.locales.supported
|
|
319
334
|
: Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
|
|
320
|
-
|
|
335
|
+
const liveTree = record.locales[locale]?.publishedVersionId ? await pages.getPublishedTree(id, locale) : null;
|
|
336
|
+
const liveFindings = liveTree ? pageDrift(adminRegistry(), record.template, liveTree) : [];
|
|
337
|
+
return ok({ record, locale, draft, paths, urls, redirects, parentLocales, liveFindings });
|
|
321
338
|
}),
|
|
322
339
|
create: (input) => guard(async () => {
|
|
323
340
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -387,7 +404,10 @@ export function createAdminOps(deps) {
|
|
|
387
404
|
: err("blocked", `smoodly: "${row.slug}" has no draft to publish.`);
|
|
388
405
|
}
|
|
389
406
|
const fieldErrors = [];
|
|
390
|
-
|
|
407
|
+
// Only the page type's zones (spec 2026-09-27 editor safety §1): a removed zone's nodes never block.
|
|
408
|
+
const shapeZones = Object.keys(openTemplate(record.template)?.schema?.zones ?? {});
|
|
409
|
+
for (const zone of shapeZones) {
|
|
410
|
+
const nodes = Array.isArray(draft.zones[zone]) ? draft.zones[zone] : [];
|
|
391
411
|
for (const node of nodes) {
|
|
392
412
|
const schema = sectionFields(node.type);
|
|
393
413
|
if (!schema?.fields)
|
|
@@ -406,8 +426,9 @@ export function createAdminOps(deps) {
|
|
|
406
426
|
for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
|
|
407
427
|
fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
|
|
408
428
|
}
|
|
409
|
-
|
|
410
|
-
|
|
429
|
+
const refused = publishErrors(fieldErrors, pageDrift(adminRegistry(), record.template, draft));
|
|
430
|
+
if (refused.length > 0) {
|
|
431
|
+
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed problems first.`, refused);
|
|
411
432
|
}
|
|
412
433
|
const published = await pages.publish(id, locale, by, token);
|
|
413
434
|
// The parent's cached unit lists its published children.
|
|
@@ -630,7 +651,8 @@ export function createAdminOps(deps) {
|
|
|
630
651
|
const paths = await entries.pathsOf(collection, id);
|
|
631
652
|
const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
|
|
632
653
|
const redirects = await entries.redirectsOf(collection, id);
|
|
633
|
-
|
|
654
|
+
const liveFindings = await liveEntryFindings(collection, record, locale);
|
|
655
|
+
return ok({ record, locale, fields, parentLocales, urls, redirects, liveFindings });
|
|
634
656
|
}),
|
|
635
657
|
create: (collection, input) => guard(async () => {
|
|
636
658
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -675,7 +697,8 @@ export function createAdminOps(deps) {
|
|
|
675
697
|
return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
676
698
|
if (!record.locales[locale])
|
|
677
699
|
return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
|
|
678
|
-
const
|
|
700
|
+
const merged = entryFieldsIn(record, locale);
|
|
701
|
+
const fieldErrors = publishErrors(validateFields(merged, descriptors, elementDescs), entryDrift(adminRegistry(), collection, { fields: merged }));
|
|
679
702
|
if (fieldErrors.length > 0)
|
|
680
703
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
681
704
|
}
|
|
@@ -776,7 +799,8 @@ export function createAdminOps(deps) {
|
|
|
776
799
|
if (!record?.locales[locale])
|
|
777
800
|
return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
|
|
778
801
|
const usage = await entries.referencesTo(name, record.id);
|
|
779
|
-
|
|
802
|
+
const liveFindings = await liveEntryFindings(name, record, locale);
|
|
803
|
+
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage, liveFindings });
|
|
780
804
|
}),
|
|
781
805
|
save: (name, locale, fields, expected) => guard(async () => {
|
|
782
806
|
const unsupported = supportedLocale(locale);
|
|
@@ -818,8 +842,9 @@ export function createAdminOps(deps) {
|
|
|
818
842
|
const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...fields } = entryFieldsIn(record, locale);
|
|
819
843
|
const settingsDescs = item.section !== undefined ? sectionFields(item.section)?.settings : undefined;
|
|
820
844
|
const fieldErrors = validateNode({ fields, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: settingsDescs }, elementDescs);
|
|
821
|
-
|
|
822
|
-
|
|
845
|
+
const refused = publishErrors(fieldErrors, entryDrift(adminRegistry(), name, { fields: entryFieldsIn(record, locale) }));
|
|
846
|
+
if (refused.length > 0)
|
|
847
|
+
return err("validation", "Some fields need attention.", refused);
|
|
823
848
|
}
|
|
824
849
|
const changed = await entries.setStatus(name, record.id, locale, status, by, token);
|
|
825
850
|
// see save: the fan-out carries the item's own tag.
|
|
@@ -1052,6 +1077,12 @@ export function createAdminOps(deps) {
|
|
|
1052
1077
|
return ok(null);
|
|
1053
1078
|
}),
|
|
1054
1079
|
},
|
|
1080
|
+
health: {
|
|
1081
|
+
scan: () => guard(async () => {
|
|
1082
|
+
const [trees, rows] = await Promise.all([pages.scanTrees(), entries.scanEntries()]);
|
|
1083
|
+
return ok(buildHealthReport(adminRegistry(), trees, rows));
|
|
1084
|
+
}),
|
|
1085
|
+
},
|
|
1055
1086
|
schema: {
|
|
1056
1087
|
status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
|
|
1057
1088
|
},
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { MemberRole } from "../permissions.ts";
|
|
|
8
8
|
import type { FieldError } from "./validate.ts";
|
|
9
9
|
import type { DraftToken } from "../draft-token.ts";
|
|
10
10
|
import type { PathTarget } from "../paths.ts";
|
|
11
|
+
import type { Finding } from "./drift.ts";
|
|
12
|
+
import type { HealthReport } from "./health.ts";
|
|
11
13
|
export type OpError = {
|
|
12
14
|
ok: false;
|
|
13
15
|
code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
|
|
@@ -52,6 +54,8 @@ export type PageGetResult = {
|
|
|
52
54
|
* disables the rest with a reason. Every supported locale for a root
|
|
53
55
|
* page: a root can be added to any locale. */
|
|
54
56
|
parentLocales: string[];
|
|
57
|
+
/** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
|
|
58
|
+
liveFindings: Finding[];
|
|
55
59
|
};
|
|
56
60
|
export type EntryCreateInput = {
|
|
57
61
|
/** The one locale the entry is created in; others are added with addLocale. */
|
|
@@ -72,6 +76,8 @@ export type EntryGetResult = {
|
|
|
72
76
|
redirects: Record<string, string[]>;
|
|
73
77
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
74
78
|
parentLocales: string[];
|
|
79
|
+
/** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
|
|
80
|
+
liveFindings: Finding[];
|
|
75
81
|
};
|
|
76
82
|
export type GlobalListItem = {
|
|
77
83
|
name: string;
|
|
@@ -86,6 +92,8 @@ export type GlobalGetResult = {
|
|
|
86
92
|
fields: Record<string, unknown>;
|
|
87
93
|
/** Who links it: "used on N pages". */
|
|
88
94
|
usage: EntryUsage[];
|
|
95
|
+
/** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
|
|
96
|
+
liveFindings: Finding[];
|
|
89
97
|
};
|
|
90
98
|
/** A history row: the version id and when its content last changed (spec 2026-09-13 history §5). */
|
|
91
99
|
export type VersionSummary = {
|
|
@@ -264,6 +272,11 @@ export type AdminOps = {
|
|
|
264
272
|
/** Deletes a redirect row; a live row is never touched. */
|
|
265
273
|
remove(locale: string, path: string): Promise<OpResult<null>>;
|
|
266
274
|
};
|
|
275
|
+
/** Content health (spec 2026-09-27 editor safety §3): read-only, every member. */
|
|
276
|
+
health: {
|
|
277
|
+
/** Every page, entry and global item against the registry, draft and live; unregistered collections counted. */
|
|
278
|
+
scan(): Promise<OpResult<HealthReport>>;
|
|
279
|
+
};
|
|
267
280
|
schema: {
|
|
268
281
|
/** Every member. The shell calls it once on mount and blocks when behind. */
|
|
269
282
|
status(): Promise<OpResult<SchemaStatus>>;
|
|
@@ -25,5 +25,5 @@ export function AddRedirectModal({ locale, registry, targets, onClose, onAdd })
|
|
|
25
25
|
setError(await onAdd(path, target));
|
|
26
26
|
setBusy(false);
|
|
27
27
|
};
|
|
28
|
-
return (_jsx(Modal, { title: "Add redirect", width: 480, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Old address", error: path !== "" && problem ? REDIRECT_PATH_MESSAGE[problem] : undefined, children: _jsx("input", { className: "sm-input sm-input--mono", autoFocus: true, placeholder: "/old-address", value: path, onChange: (e) => setPath(e.target.value) }) }), _jsx(Field, { label: "Redirects to", asDiv: true, hint:
|
|
28
|
+
return (_jsx(Modal, { title: "Add redirect", width: 480, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Old address", error: path !== "" && problem ? REDIRECT_PATH_MESSAGE[problem] : undefined, children: _jsx("input", { className: "sm-input sm-input--mono", autoFocus: true, placeholder: "/old-address", value: path, onChange: (e) => setPath(e.target.value) }) }), _jsx(Field, { label: "Redirects to", asDiv: true, hint: `A page or entry that is live in ${locale}.`, children: _jsx(Combobox, { label: "Redirects to", options: targetOptions(targets), placeholder: "Type a title or an address", emptyText: `Nothing is live in ${locale} yet.`, selected: picked ? `${picked.title} · ${picked.path}` : undefined, onPick: (value) => setTarget(parseTargetValue(value)) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy || !target || path === "" || problem !== null, children: busy ? "Saving…" : "Save redirect" })] })] }) }));
|
|
29
29
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { AdminOps } from "../ops.ts";
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
import type { HealthReport } from "../health.ts";
|
|
5
|
+
export declare function ContentHealthPage({ ops, registry, initial }: {
|
|
6
|
+
ops: AdminOps;
|
|
7
|
+
registry: AdminRegistry;
|
|
8
|
+
/** Static-markup tests: a report without a scan. */
|
|
9
|
+
initial?: HealthReport | null;
|
|
10
|
+
}): ReactElement;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Settings › Content health (spec 2026-09-27 editor safety §4): the scan on
|
|
4
|
+
// open and on Rescan; "Blocks publishing" then "Left over", each group a
|
|
5
|
+
// headline over its documents with their locales, Draft/Live tags and an
|
|
6
|
+
// Open link. It only points — the editors fix. Every member.
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { Button, TopBar } from "../ui/primitives.js";
|
|
9
|
+
import { useAdminHref } from "./base.js";
|
|
10
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
11
|
+
import { RouteLink } from "./RouteLink.js";
|
|
12
|
+
import { GROUP_PREVIEW, healthSections } from "./health-list.js";
|
|
13
|
+
export function ContentHealthPage({ ops, registry, initial = null }) {
|
|
14
|
+
const [report, setReport] = useState(initial);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const scan = () => {
|
|
17
|
+
setError(null);
|
|
18
|
+
setReport(null);
|
|
19
|
+
ops.health.scan()
|
|
20
|
+
.then((r) => (r.ok ? setReport(r.data) : setError(r.message)))
|
|
21
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
22
|
+
};
|
|
23
|
+
useEffect(() => { if (!initial)
|
|
24
|
+
scan(); }, [ops, initial]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
25
|
+
if (error)
|
|
26
|
+
return _jsx(ErrorPane, { message: error, back: false, onRetry: scan });
|
|
27
|
+
const sections = report ? healthSections(report, registry) : null;
|
|
28
|
+
const brokenCount = sections ? sections.broken.length + sections.collections.length : 0;
|
|
29
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
|
|
30
|
+
}
|
|
31
|
+
function Group({ group }) {
|
|
32
|
+
const adminHref = useAdminHref();
|
|
33
|
+
const [all, setAll] = useState(false);
|
|
34
|
+
const rows = all ? group.rows : group.rows.slice(0, GROUP_PREVIEW);
|
|
35
|
+
const n = group.rows.length;
|
|
36
|
+
return (_jsxs("div", { className: "sm-health__group", children: [_jsxs("div", { className: "sm-health__headline", children: [_jsx("span", { children: group.headline }), _jsxs("span", { className: "sm-hint", children: [n, " ", n === 1 ? "document" : "documents"] })] }), rows.map((r) => (_jsxs("div", { className: "sm-row sm-health__row", children: [_jsx("span", { className: "sm-cell", children: r.title }), _jsx("span", { className: "sm-cell sm-hint", children: r.kind }), _jsx("span", { className: "sm-cell", children: r.locales.join(" ") }), _jsxs("span", { className: "sm-cell", children: [r.draft && _jsx("span", { className: "sm-badge", style: { background: "var(--status-draft-bg)", color: "var(--status-draft-fg)" }, children: "Draft" }), r.live && _jsx("span", { className: "sm-badge", style: { background: "var(--status-published-bg)", color: "var(--status-published-fg)" }, children: "Live" })] }), _jsx("span", { className: "sm-cell", children: _jsx(RouteLink, { href: adminHref(r.href), children: "Open \u2192" }) })] }, r.key))), !all && n > GROUP_PREVIEW && _jsxs(Button, { variant: "secondary", onClick: () => setAll(true), children: ["Show all ", n] })] }));
|
|
37
|
+
}
|
|
@@ -14,7 +14,7 @@ import { relativeTime } from "../ui/format.js";
|
|
|
14
14
|
import { useSortable } from "../ui/useSortable.js";
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { navigate } from "./router.js";
|
|
17
|
-
import { confirm } from "../ui/confirm.js";
|
|
17
|
+
import { confirm, notify } from "../ui/confirm.js";
|
|
18
18
|
import { ErrorPane } from "./ErrorPane.js";
|
|
19
19
|
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
20
20
|
import { usePresenceState } from "./presence-context.js";
|
|
@@ -53,7 +53,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
53
53
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
54
54
|
}
|
|
55
55
|
if (!result.ok) {
|
|
56
|
-
|
|
56
|
+
void notify({ title: "Couldn't move the entry", message: result.message });
|
|
57
57
|
throw new Error(result.message);
|
|
58
58
|
}
|
|
59
59
|
await load();
|
|
@@ -76,7 +76,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
76
76
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
77
77
|
}
|
|
78
78
|
if (!result.ok) {
|
|
79
|
-
|
|
79
|
+
void notify({ title: "Couldn't create the entry", message: result.message });
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
82
|
open(result.data.id);
|
|
@@ -102,7 +102,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
102
102
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
103
103
|
}
|
|
104
104
|
if (!result.ok) {
|
|
105
|
-
|
|
105
|
+
void notify({ title: `Couldn't add the entry to ${locale}`, message: result.message });
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
108
|
open(row.id);
|
|
@@ -40,6 +40,11 @@ import { confirm, confirmWithOption, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
|
40
40
|
import { draftToken } from "../../draft-token.js";
|
|
41
41
|
import { isStale, staleInfoOf } from "./stale.js";
|
|
42
42
|
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
43
|
+
import { DriftNotice } from "../ui/DriftNotice.js";
|
|
44
|
+
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
45
|
+
import { entryDrift } from "../drift.js";
|
|
46
|
+
import { misfitErrors } from "../drift-copy.js";
|
|
47
|
+
import { unsetAtPath } from "../field-paths.js";
|
|
43
48
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
44
49
|
import { editorsOf } from "./presence.js";
|
|
45
50
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
@@ -69,6 +74,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
69
74
|
const [history, setHistory] = useState(null);
|
|
70
75
|
const [snapshot, setSnapshot] = useState(null);
|
|
71
76
|
const [stale, setStale] = useState(null);
|
|
77
|
+
const [liveFindings, setLiveFindings] = useState([]);
|
|
72
78
|
const formRef = useRef(null);
|
|
73
79
|
const fieldsRef = useRef(fields);
|
|
74
80
|
fieldsRef.current = fields;
|
|
@@ -149,6 +155,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
149
155
|
setUrls(r.data.urls);
|
|
150
156
|
setRedirects(r.data.redirects);
|
|
151
157
|
setParentLocales(r.data.parentLocales);
|
|
158
|
+
setLiveFindings(r.data.liveFindings);
|
|
152
159
|
}
|
|
153
160
|
else
|
|
154
161
|
setNotice({ tone: "error", text: r.message });
|
|
@@ -156,6 +163,11 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
156
163
|
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
157
164
|
return () => { cancelled = true; };
|
|
158
165
|
}, [ops, collection, id, locale, reloadKey]);
|
|
166
|
+
// Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
|
|
167
|
+
const findings = useMemo(() => (fields && record?.locales[locale]
|
|
168
|
+
? entryDrift(registry, collection, { fields, shared: record.fields, own: record.locales[locale].fields })
|
|
169
|
+
: []), [fields, record, locale, registry, collection]);
|
|
170
|
+
const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
|
|
159
171
|
if (!meta)
|
|
160
172
|
return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
|
|
161
173
|
const row = record?.locales[locale];
|
|
@@ -406,7 +418,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
406
418
|
return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
|
|
407
419
|
};
|
|
408
420
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
409
|
-
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors:
|
|
421
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: markerErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
|
|
410
422
|
setFields((f) => ({ ...(f ?? {}), [key]: value }));
|
|
411
423
|
setDirty(true);
|
|
412
424
|
setTouched(true);
|
|
@@ -419,6 +431,11 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
419
431
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
420
432
|
},
|
|
421
433
|
{ label: heading },
|
|
422
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
423
|
-
revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (
|
|
434
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
435
|
+
revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [Object.entries(meta.fields).map(widget), _jsx(OrphanFields, { orphans: findings, onClear: (path) => {
|
|
436
|
+
setFields((f) => unsetAtPath(f ?? {}, path));
|
|
437
|
+
setDirty(true);
|
|
438
|
+
setTouched(true);
|
|
439
|
+
mark();
|
|
440
|
+
} })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
424
441
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** A whole-column failure: nothing to show but the message and a way
|
|
2
|
-
* back — none when the shell is mounted outside its own path
|
|
3
|
-
|
|
2
|
+
* back — none when the shell is mounted outside its own path — and a
|
|
3
|
+
* Retry when the caller can run the load again (Content health, 2026-09-27). */
|
|
4
|
+
export declare function ErrorPane({ message, back, onRetry }: {
|
|
4
5
|
message: string;
|
|
5
6
|
back?: boolean;
|
|
7
|
+
onRetry?: () => void;
|
|
6
8
|
}): import("react").JSX.Element;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// packages/smoodly/src/admin/shell/ErrorPane.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
-
import { Banner } from "../ui/primitives.js";
|
|
4
|
+
import { Banner, Button } from "../ui/primitives.js";
|
|
5
5
|
import { useAdminHref } from "./base.js";
|
|
6
6
|
/** A whole-column failure: nothing to show but the message and a way
|
|
7
|
-
* back — none when the shell is mounted outside its own path
|
|
8
|
-
|
|
7
|
+
* back — none when the shell is mounted outside its own path — and a
|
|
8
|
+
* Retry when the caller can run the load again (Content health, 2026-09-27). */
|
|
9
|
+
export function ErrorPane({ message, back = true, onRetry }) {
|
|
9
10
|
const adminHref = useAdminHref();
|
|
10
|
-
return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), back && _jsx("a", { href: adminHref("/pages"), style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
|
|
11
|
+
return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), onRetry && _jsx(Button, { variant: "secondary", style: { alignSelf: "flex-start" }, onClick: onRetry, children: "Retry" }), back && _jsx("a", { href: adminHref("/pages"), style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
|
|
11
12
|
}
|
|
@@ -34,6 +34,11 @@ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
|
34
34
|
import { draftToken } from "../../draft-token.js";
|
|
35
35
|
import { isStale, staleInfoOf } from "./stale.js";
|
|
36
36
|
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
37
|
+
import { DriftNotice } from "../ui/DriftNotice.js";
|
|
38
|
+
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
39
|
+
import { entryDrift } from "../drift.js";
|
|
40
|
+
import { misfitErrors } from "../drift-copy.js";
|
|
41
|
+
import { unsetAtPath } from "../field-paths.js";
|
|
37
42
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
38
43
|
import { editorsOf } from "./presence.js";
|
|
39
44
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
@@ -55,6 +60,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
55
60
|
const [history, setHistory] = useState(null);
|
|
56
61
|
const [snapshot, setSnapshot] = useState(null);
|
|
57
62
|
const [stale, setStale] = useState(null);
|
|
63
|
+
const [liveFindings, setLiveFindings] = useState([]);
|
|
58
64
|
const formRef = useRef(null);
|
|
59
65
|
const fieldsRef = useRef(fields);
|
|
60
66
|
fieldsRef.current = fields;
|
|
@@ -106,6 +112,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
106
112
|
setRecord(r.data.record);
|
|
107
113
|
setFields(r.data.fields);
|
|
108
114
|
setUsage(r.data.usage);
|
|
115
|
+
setLiveFindings(r.data.liveFindings);
|
|
109
116
|
}
|
|
110
117
|
else
|
|
111
118
|
setNotice({ tone: "error", text: r.message });
|
|
@@ -113,6 +120,11 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
113
120
|
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
114
121
|
return () => { cancelled = true; };
|
|
115
122
|
}, [ops, name, locale, reloadKey]);
|
|
123
|
+
// Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
|
|
124
|
+
const findings = useMemo(() => (fields && record?.locales[locale]
|
|
125
|
+
? entryDrift(registry, name, { fields, shared: record.fields, own: record.locales[locale].fields })
|
|
126
|
+
: []), [fields, record, locale, registry, name]);
|
|
127
|
+
const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
|
|
116
128
|
if (!item)
|
|
117
129
|
return _jsx("p", { style: { padding: 32 }, children: "No such global item." });
|
|
118
130
|
const row = record?.locales[locale];
|
|
@@ -292,12 +304,20 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
292
304
|
setTouched(true);
|
|
293
305
|
mark();
|
|
294
306
|
};
|
|
307
|
+
// An orphan's path names a visual item's namespaces as drift does (`settings.x`); the stored keys are `$settings` / `$styles`.
|
|
308
|
+
const storedPath = (p) => p.startsWith("settings.") ? `${SETTINGS_KEY}.${p.slice(9)}` : p.startsWith("styles.") ? `${STYLES_KEY}.${p.slice(7)}` : p;
|
|
309
|
+
const clearOrphan = (path) => {
|
|
310
|
+
setFields((f) => unsetAtPath(f ?? {}, storedPath(path)));
|
|
311
|
+
setDirty(true);
|
|
312
|
+
setTouched(true);
|
|
313
|
+
mark();
|
|
314
|
+
};
|
|
295
315
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
|
|
296
316
|
{
|
|
297
317
|
label: "Site", href: adminHref("/globals"),
|
|
298
318
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
299
319
|
},
|
|
300
320
|
{ label: item.title },
|
|
301
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
302
|
-
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors:
|
|
321
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
322
|
+
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
|
|
303
323
|
}
|
|
@@ -19,7 +19,7 @@ import { navigate } from "./router.js";
|
|
|
19
19
|
import { ErrorPane } from "./ErrorPane.js";
|
|
20
20
|
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
21
21
|
import { confirmLiveUrl } from "./live-url.js";
|
|
22
|
-
import { confirm } from "../ui/confirm.js";
|
|
22
|
+
import { confirm, notify } from "../ui/confirm.js";
|
|
23
23
|
import { PageFormDialog } from "./PageDialogs.js";
|
|
24
24
|
import { usePresenceState } from "./presence-context.js";
|
|
25
25
|
import { editorsOf } from "./presence.js";
|
|
@@ -95,11 +95,11 @@ export function PagesList({ ops, registry }) {
|
|
|
95
95
|
await load();
|
|
96
96
|
return null;
|
|
97
97
|
};
|
|
98
|
-
/** `attempt` for an action outside a dialog: the message goes to
|
|
98
|
+
/** `attempt` for an action outside a dialog: the message goes to a notify dialog. Resolves to whether it succeeded. */
|
|
99
99
|
const run = async (work, then) => {
|
|
100
100
|
const message = await attempt(work, then);
|
|
101
101
|
if (message)
|
|
102
|
-
|
|
102
|
+
void notify({ title: "That didn't work", message });
|
|
103
103
|
return message === null;
|
|
104
104
|
};
|
|
105
105
|
/** New page and New path (a folder in the tree) create in the SELECTED locale (spec §6),
|
|
@@ -8,11 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// unknown page is the shell's Not found pane. Each page names its
|
|
9
9
|
// capability (settings.ts): Members is for admins (members management
|
|
10
10
|
// §2) and an editor opening it gets the error pane; Redirects (spec
|
|
11
|
-
// 2026-09-26 redirects §6)
|
|
11
|
+
// 2026-09-26 redirects §6) and Content health (spec 2026-09-27 editor
|
|
12
|
+
// safety §4) are for every member. Until the role is known
|
|
12
13
|
// the area is loading.
|
|
13
14
|
import { useEffect } from "react";
|
|
14
15
|
import { useAdminHref } from "./base.js";
|
|
15
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
17
|
+
import { ContentHealthPage } from "./ContentHealthPage.js";
|
|
16
18
|
import { MembersPage } from "./MembersPage.js";
|
|
17
19
|
import { RedirectsPage } from "./RedirectsPage.js";
|
|
18
20
|
import { RouteLink } from "./RouteLink.js";
|
|
@@ -34,5 +36,7 @@ export function SettingsView({ ops, registry, tail, role, userId }) {
|
|
|
34
36
|
return _jsx(ErrorPane, { message: "Not found." });
|
|
35
37
|
if (!visible.some((p) => p.id === page))
|
|
36
38
|
return _jsx(ErrorPane, { message: "Settings are for admins." });
|
|
37
|
-
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops, userId: userId })
|
|
39
|
+
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops, userId: userId })
|
|
40
|
+
: page === "redirects" ? _jsx(RedirectsPage, { ops: ops, registry: registry })
|
|
41
|
+
: _jsx(ContentHealthPage, { ops: ops, registry: registry }) })] }));
|
|
38
42
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
2
|
+
import type { HealthReport } from "../health.ts";
|
|
3
|
+
export type HealthRow = {
|
|
4
|
+
key: string;
|
|
5
|
+
title: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
locales: string[];
|
|
8
|
+
draft: boolean;
|
|
9
|
+
live: boolean;
|
|
10
|
+
href: string;
|
|
11
|
+
};
|
|
12
|
+
export type HealthGroup = {
|
|
13
|
+
key: string;
|
|
14
|
+
headline: string;
|
|
15
|
+
rows: HealthRow[];
|
|
16
|
+
};
|
|
17
|
+
export type HealthSections = {
|
|
18
|
+
broken: HealthGroup[];
|
|
19
|
+
orphan: HealthGroup[];
|
|
20
|
+
collections: {
|
|
21
|
+
name: string;
|
|
22
|
+
headline: string;
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
/** A group shows this many rows, then "Show all N". */
|
|
26
|
+
export declare const GROUP_PREVIEW = 10;
|
|
27
|
+
export declare function healthSections(report: HealthReport, registry: AdminRegistry): HealthSections;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SEVERITY, groupKey } from "../drift.js";
|
|
2
|
+
import { driftHeadline } from "../drift-copy.js";
|
|
3
|
+
/** A group shows this many rows, then "Show all N". */
|
|
4
|
+
export const GROUP_PREVIEW = 10;
|
|
5
|
+
const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Site" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
|
|
6
|
+
const href = (doc, locale) => {
|
|
7
|
+
const q = `?locale=${encodeURIComponent(locale)}`;
|
|
8
|
+
if (doc.kind === "page")
|
|
9
|
+
return `/pages/${encodeURIComponent(doc.id)}${q}`;
|
|
10
|
+
if (doc.kind === "global")
|
|
11
|
+
return `/globals/${encodeURIComponent(doc.collection ?? "")}${q}`;
|
|
12
|
+
return `/${doc.collection}/${encodeURIComponent(doc.id)}${q}`;
|
|
13
|
+
};
|
|
14
|
+
export function healthSections(report, registry) {
|
|
15
|
+
const groups = new Map();
|
|
16
|
+
for (const doc of report.documents) {
|
|
17
|
+
for (const f of doc.findings) {
|
|
18
|
+
const key = groupKey(f);
|
|
19
|
+
let g = groups.get(key);
|
|
20
|
+
if (!g)
|
|
21
|
+
groups.set(key, (g = { severity: SEVERITY[f.kind], headline: driftHeadline(f, registry), rows: new Map() }));
|
|
22
|
+
const rowKey = `${doc.kind}:${doc.id}`;
|
|
23
|
+
const row = g.rows.get(rowKey);
|
|
24
|
+
if (!row) {
|
|
25
|
+
g.rows.set(rowKey, { key: rowKey, title: doc.title, kind: kindLabel(doc, registry), locales: [f.locale], draft: f.draft, live: f.live, href: href(doc, f.locale) });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (!row.locales.includes(f.locale))
|
|
29
|
+
row.locales.push(f.locale);
|
|
30
|
+
row.draft || (row.draft = f.draft);
|
|
31
|
+
row.live || (row.live = f.live);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const order = registry.locales.supported;
|
|
36
|
+
const out = (severity) => [...groups].filter(([, g]) => g.severity === severity)
|
|
37
|
+
.map(([key, g]) => ({ key, headline: g.headline, rows: [...g.rows.values()].map((r) => ({ ...r, locales: [...r.locales].sort((a, b) => order.indexOf(a) - order.indexOf(b)) })) }))
|
|
38
|
+
.sort((a, b) => b.rows.length - a.rows.length || a.headline.localeCompare(b.headline));
|
|
39
|
+
return {
|
|
40
|
+
broken: out("broken"),
|
|
41
|
+
orphan: out("orphan"),
|
|
42
|
+
collections: report.collections.map(({ name, count }) => ({
|
|
43
|
+
name,
|
|
44
|
+
headline: `Collection \`${name}\` no longer registered · ${count} ${count === 1 ? "entry" : "entries"}. Register it again or delete its ${count} ${count === 1 ? "entry" : "entries"}.`,
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -7,6 +7,10 @@ export declare const SETTINGS_PAGES: readonly [{
|
|
|
7
7
|
readonly id: "redirects";
|
|
8
8
|
readonly title: "Redirects";
|
|
9
9
|
readonly capability: "content.write";
|
|
10
|
+
}, {
|
|
11
|
+
readonly id: "health";
|
|
12
|
+
readonly title: "Content health";
|
|
13
|
+
readonly capability: "content.write";
|
|
10
14
|
}];
|
|
11
15
|
export type SettingsPage = (typeof SETTINGS_PAGES)[number];
|
|
12
16
|
export type SettingsPageId = SettingsPage["id"];
|