smoodly 0.0.32 → 0.0.34
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 +77 -15
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +5 -3
- package/dist/admin/editor/PageSettings.js +7 -2
- package/dist/admin/editor/autosave.d.ts +2 -1
- package/dist/admin/editor/autosave.js +6 -0
- package/dist/admin/editor/useCanvasBridge.js +25 -2
- package/dist/admin/fixed-nodes.d.ts +9 -24
- package/dist/admin/fixed-nodes.js +36 -72
- package/dist/admin/forms/FieldWidget.d.ts +5 -1
- package/dist/admin/forms/FieldWidget.js +6 -3
- package/dist/admin/forms/slug-field.d.ts +17 -0
- package/dist/admin/forms/slug-field.js +39 -0
- package/dist/admin/health.js +9 -0
- package/dist/admin/next/create-admin.d.ts +4 -3
- package/dist/admin/next/create-admin.js +14 -3
- package/dist/admin/ops-impl.js +144 -14
- package/dist/admin/redirect-path.d.ts +3 -0
- package/dist/admin/redirect-path.js +9 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AddRedirectModal.js +2 -2
- package/dist/admin/shell/EntryForm.js +46 -6
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/PageDialogs.js +11 -5
- package/dist/admin/shell/PagesList.js +29 -12
- package/dist/admin/shell/live-url.d.ts +3 -1
- package/dist/admin/shell/live-url.js +5 -2
- package/dist/admin/shell/pages-tree.d.ts +19 -14
- package/dist/admin/shell/pages-tree.js +49 -16
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/ui/theme.js +1 -0
- package/dist/admin/validate.d.ts +2 -0
- package/dist/admin/validate.js +9 -0
- package/dist/anchors.d.ts +42 -0
- package/dist/anchors.js +72 -0
- package/dist/cli/export.js +3 -1
- package/dist/cli/import.d.ts +1 -0
- package/dist/cli/import.js +13 -2
- package/dist/cli/space-io.d.ts +1 -2
- package/dist/cli/space-io.js +5 -5
- package/dist/collections.js +15 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +53 -18
- package/dist/current-space.d.ts +4 -0
- package/dist/current-space.js +9 -0
- package/dist/entry-store.js +4 -2
- package/dist/env.d.ts +4 -3
- package/dist/env.js +15 -17
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/next/page-renderer.js +2 -2
- package/dist/path-index.d.ts +11 -0
- package/dist/path-index.js +52 -25
- package/dist/paths.d.ts +32 -12
- package/dist/paths.js +80 -21
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +7 -3
- package/dist/site.js +26 -15
- package/dist/space-transfer.d.ts +2 -0
- package/dist/space-transfer.js +8 -1
- package/dist/sql.js +35 -9
- package/dist/store.d.ts +15 -1
- package/dist/store.js +27 -6
- package/dist/supabase-entry-store.d.ts +0 -1
- package/dist/supabase-entry-store.js +6 -11
- package/dist/supabase-path-index.d.ts +12 -1
- package/dist/supabase-path-index.js +56 -19
- package/dist/supabase-store.d.ts +2 -0
- package/dist/supabase-store.js +30 -9
- package/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/package.json +1 -1
- package/dist/admin/shell/page-dialogs.d.ts +0 -9
- package/dist/admin/shell/page-dialogs.js +0 -19
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// What a slug box does (spec 2026-09-28 ASCII slugs §4–§6): pure, shared
|
|
2
|
+
// by the page dialogs, page Settings, the slug field widget and the entry
|
|
3
|
+
// form. The grammar and the slugifier live in paths.ts.
|
|
4
|
+
import { slugify } from "../../paths.js";
|
|
5
|
+
/** The slug follows its title until the editor types a slug of their
|
|
6
|
+
* own; a slug equal to what the previous title produced is still
|
|
7
|
+
* "following". */
|
|
8
|
+
export function followsTitle(slug, previousTitle) {
|
|
9
|
+
return slug === "" || slug === slugify(previousTitle);
|
|
10
|
+
}
|
|
11
|
+
/** Under a slug box whose source text makes no slug (§6). */
|
|
12
|
+
export const NO_LETTERS_HINT = "This title has no letters a slug can use — type one, like novosti.";
|
|
13
|
+
/** What a slug box holds after blur (§4): the slugified text; when
|
|
14
|
+
* nothing survives, the text as typed if it has letters (Новости), so
|
|
15
|
+
* publish can name the problem, and "" if it had none (" ", "---"). */
|
|
16
|
+
export function tidySlug(value) {
|
|
17
|
+
const tidy = slugify(value);
|
|
18
|
+
if (tidy !== "")
|
|
19
|
+
return tidy;
|
|
20
|
+
return /[\p{L}\p{N}]/u.test(value) ? value : "";
|
|
21
|
+
}
|
|
22
|
+
/** NO_LETTERS_HINT when `source` has text but no slug in it (§6). */
|
|
23
|
+
export function slugHint(source) {
|
|
24
|
+
return typeof source === "string" && source.trim() !== "" && slugify(source) === "" ? NO_LETTERS_HINT : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** The entry form's fields after `key` changed to `value` (§5): while the
|
|
27
|
+
* row has never been published, an `f.slug({ from })` slug that still
|
|
28
|
+
* follows the previous `from` value follows the new one. A published
|
|
29
|
+
* row's slug is a live URL and never moves on its own. */
|
|
30
|
+
export function withFollowingSlug(fields, key, value, from, neverPublished) {
|
|
31
|
+
const next = { ...fields, [key]: value };
|
|
32
|
+
if (from === undefined || key !== from || !neverPublished)
|
|
33
|
+
return next;
|
|
34
|
+
const slug = typeof fields.slug === "string" ? fields.slug : "";
|
|
35
|
+
const previous = typeof fields[from] === "string" ? fields[from] : "";
|
|
36
|
+
if (!followsTitle(slug, previous))
|
|
37
|
+
return next;
|
|
38
|
+
return { ...next, slug: slugify(typeof value === "string" ? value : "") };
|
|
39
|
+
}
|
package/dist/admin/health.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { entryFieldsIn } from "../entry-store.js";
|
|
2
2
|
import { entryDrift, mergeVersions, pageDrift } from "./drift.js";
|
|
3
|
+
import { isSegment } from "../paths.js";
|
|
3
4
|
export function buildHealthReport(registry, pages, entries) {
|
|
4
5
|
const documents = [];
|
|
5
6
|
const def = registry.locales.default;
|
|
@@ -30,6 +31,14 @@ export function buildHealthReport(registry, pages, entries) {
|
|
|
30
31
|
const lived = live[locale] ? entryDrift(registry, record.collection, { fields: live[locale] }) : [];
|
|
31
32
|
for (const f of mergeVersions(draft, lived))
|
|
32
33
|
findings.push({ ...f, locale });
|
|
34
|
+
// The slug is the row's column, never versioned: one value, live once published. Drift
|
|
35
|
+
// leaves it out (a marker would flash while it is typed); publish checks the grammar.
|
|
36
|
+
if (collection?.fields.slug?.type === "slug" && row.slug !== null && row.slug !== "" && !isSegment(row.slug)) {
|
|
37
|
+
findings.push({
|
|
38
|
+
kind: "misfit", path: "slug", subject: { scope: "collection", name: record.collection, field: "slug" },
|
|
39
|
+
value: row.slug, detail: { type: "slug" }, draft: true, live: row.status === "published", locale,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
33
42
|
}
|
|
34
43
|
if (findings.length === 0)
|
|
35
44
|
continue;
|
|
@@ -4,15 +4,16 @@ import type { AdminOpAction } from "../ops.ts";
|
|
|
4
4
|
import type { AuthConfig } from "../shell/session.ts";
|
|
5
5
|
import type { ContentConfig } from "../../env.ts";
|
|
6
6
|
/** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
7
|
-
* concurrency §5)
|
|
7
|
+
* concurrency §5), async because its space is asked of the database; absent or failed, presence
|
|
8
|
+
* is off and everything else works. */
|
|
8
9
|
export declare function createSmoodlyAdmin(input: {
|
|
9
10
|
config: ResolvedConfig;
|
|
10
11
|
op: AdminOpAction;
|
|
11
12
|
auth: () => AuthConfig;
|
|
12
|
-
content?: () => ContentConfig
|
|
13
|
+
content?: () => ContentConfig | Promise<ContentConfig>;
|
|
13
14
|
}): {
|
|
14
15
|
AdminPage: () => null;
|
|
15
16
|
AdminLayout: ({ children }: {
|
|
16
17
|
children: ReactNode;
|
|
17
|
-
}) => import("react").JSX.Element
|
|
18
|
+
}) => Promise<import("react").JSX.Element>;
|
|
18
19
|
};
|
|
@@ -3,7 +3,8 @@ import { serializeAdminConfig } from "../serialize.js";
|
|
|
3
3
|
import { AdminApp } from "../shell/AdminApp.js";
|
|
4
4
|
import { AdminStyles } from "../ui/theme.js";
|
|
5
5
|
/** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
6
|
-
* concurrency §5)
|
|
6
|
+
* concurrency §5), async because its space is asked of the database; absent or failed, presence
|
|
7
|
+
* is off and everything else works. */
|
|
7
8
|
export function createSmoodlyAdmin(input) {
|
|
8
9
|
const registry = serializeAdminConfig(input.config);
|
|
9
10
|
/** The catch-all page only makes every /admin URL exist. The shell is
|
|
@@ -23,8 +24,18 @@ export function createSmoodlyAdmin(input) {
|
|
|
23
24
|
* suppressHydrationWarning: the theme boot script sets `data-theme`
|
|
24
25
|
* on this element before React hydrates, on purpose (no white flash
|
|
25
26
|
* for a dark-mode editor), so the attribute differs from the server's. */
|
|
26
|
-
function AdminLayout({ children }) {
|
|
27
|
-
|
|
27
|
+
async function AdminLayout({ children }) {
|
|
28
|
+
// Presence is the one thing that needs it: a failed lookup (an unmigrated project, an
|
|
29
|
+
// unreachable database) is logged and turns presence off, so the shell still reaches
|
|
30
|
+
// sign-in, the schema notice or the admin-path ErrorPane.
|
|
31
|
+
let content;
|
|
32
|
+
try {
|
|
33
|
+
content = await input.content?.();
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
console.error("smoodly: editing presence is off —", e instanceof Error ? e.message : e);
|
|
37
|
+
}
|
|
38
|
+
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), content: content }), children] }));
|
|
28
39
|
}
|
|
29
40
|
return { AdminPage, AdminLayout };
|
|
30
41
|
}
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
|
|
1
|
+
import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
|
|
2
|
+
import { chainOf, isSegment, pagePathIn } from "../paths.js";
|
|
3
|
+
import { servedAnchor } from "../anchors.js";
|
|
2
4
|
import { distinctTags } from "../tags.js";
|
|
3
5
|
import { can, isMemberRole, normalizeRole } from "../permissions.js";
|
|
4
6
|
import { ASSET_COLLECTION } from "../media.js";
|
|
5
7
|
import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
|
|
6
8
|
import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
|
|
7
|
-
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
9
|
+
import { ensureFixedNodes, fixedNodeOf, fixedNodeSpecs } from "./fixed-nodes.js";
|
|
8
10
|
import { ensureGlobalItems } from "./global-items.js";
|
|
9
|
-
import { validateCustomNode, validateFields, validateNode } from "./validate.js";
|
|
11
|
+
import { SLUG_GRAMMAR_MESSAGE, validateCustomNode, validateFields, validateNode } from "./validate.js";
|
|
10
12
|
import { serializeAdminConfig } from "./serialize.js";
|
|
11
13
|
import { entryDrift, pageDrift } from "./drift.js";
|
|
12
14
|
import { publishErrors } from "./drift-copy.js";
|
|
@@ -15,7 +17,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
|
|
|
15
17
|
import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
16
18
|
import { withDefaults } from "../field-defaults.js";
|
|
17
19
|
import { elementSchemas } from "../richtext-walk.js";
|
|
18
|
-
import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "./redirect-path.js";
|
|
20
|
+
import { REDIRECT_PATH_MESSAGE, normalizeRedirectPath, redirectPathProblem } from "./redirect-path.js";
|
|
19
21
|
import { SCHEMA_VERSION } from "../schema-version.js";
|
|
20
22
|
import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
|
|
21
23
|
const ok = (data) => ({ ok: true, data });
|
|
@@ -305,6 +307,66 @@ export function createAdminOps(deps) {
|
|
|
305
307
|
? err("blocked", `smoodly: pages cannot be created under "${nameOf(parent)}" — the "${fixed.collection}" collection owns that URL space.`)
|
|
306
308
|
: null;
|
|
307
309
|
};
|
|
310
|
+
/** Who wins a path in a locale over a node hanging on `own` — its chain root's owner key, `''` in the editors'
|
|
311
|
+
* tree (spec 2026-09-29 anchored paths §5): the longest code address the path is or lies below, as the index
|
|
312
|
+
* serves it (§2). Null when that is the node's own anchor, or the editors' tree. */
|
|
313
|
+
const ownerOf = (path, locale, own = "") => {
|
|
314
|
+
const served = servedAnchor(config.anchors, locale, path);
|
|
315
|
+
return served === "" || served === own ? null : fixedNodeSpecs(config).find((s) => s.key === served) ?? null;
|
|
316
|
+
};
|
|
317
|
+
const pathOpts = { locales: config.locales, homePageSlug: config.homePageSlug, addresses: config.addresses };
|
|
318
|
+
/** A record's own anchor: its chain root's owner key, `''` in the editors' tree. */
|
|
319
|
+
const anchorOf = async (record) => {
|
|
320
|
+
let r = record;
|
|
321
|
+
while (r.parentId !== null) {
|
|
322
|
+
const up = await pages.getPage(r.parentId);
|
|
323
|
+
if (!up)
|
|
324
|
+
break;
|
|
325
|
+
r = up;
|
|
326
|
+
}
|
|
327
|
+
return r.fixed ?? "";
|
|
328
|
+
};
|
|
329
|
+
/** The refusal for a write that would put the node or its subtree where another code address wins (spec 2026-09-29
|
|
330
|
+
* anchored paths §5): the tree after `change`, derived in memory, checked against the config. Only a path the write
|
|
331
|
+
* changes counts: a page already hidden in one locale may still be renamed in another, or off the address. */
|
|
332
|
+
const landsOnCode = async (id, change) => {
|
|
333
|
+
const before = await pages.listPages();
|
|
334
|
+
const was = new Map(before.map((r) => [r.id, r]));
|
|
335
|
+
const all = before.map((r) => (r.id === id ? change(r) : r));
|
|
336
|
+
const byId = new Map(all.map((r) => [r.id, r]));
|
|
337
|
+
// A move into its own subtree is a cycle here; the store refuses it with its own message.
|
|
338
|
+
for (let up = byId.get(id).parentId; up !== null; up = byId.get(up)?.parentId ?? null)
|
|
339
|
+
if (up === id)
|
|
340
|
+
return null;
|
|
341
|
+
const inSubtree = (r) => r.id === id || (r.parentId !== null && byId.has(r.parentId) && inSubtree(byId.get(r.parentId)));
|
|
342
|
+
for (const node of all.filter(inSubtree)) {
|
|
343
|
+
const own = chainOf(node, (x) => byId.get(x))[0].fixed ?? "";
|
|
344
|
+
for (const locale of Object.keys(node.locales)) {
|
|
345
|
+
const path = pagePathIn(node, (x) => byId.get(x), locale, pathOpts);
|
|
346
|
+
if (path === null || path === pagePathIn(was.get(node.id), (x) => was.get(x), locale, pathOpts))
|
|
347
|
+
continue;
|
|
348
|
+
const owner = ownerOf(path, locale, own);
|
|
349
|
+
if (owner)
|
|
350
|
+
return belongsTo(path, owner);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
};
|
|
355
|
+
const belongsTo = (path, owner) => err("validation", `smoodly: "${path}" belongs to the ${owner.title} ${owner.collection ? "collection" : "page"}.`);
|
|
356
|
+
/** A parent where another code address wins — a page that sits there, hidden (§4) — takes no new children. */
|
|
357
|
+
const insideCode = async (parent) => {
|
|
358
|
+
const own = await anchorOf(parent);
|
|
359
|
+
for (const [locale, path] of Object.entries(await pages.pathsOf(parent.id))) {
|
|
360
|
+
const owner = ownerOf(path, locale, own);
|
|
361
|
+
if (owner)
|
|
362
|
+
return belongsTo(`/${owner.segments[locale]}`, owner);
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
};
|
|
366
|
+
/** A record whose owner no registration names any more: deleting it is all that is open (§5). */
|
|
367
|
+
const orphan = (record) => record.fixed && !fixedNodeOf(record, config)
|
|
368
|
+
? err("blocked", `smoodly: "${nameOf(record)}" is no longer in code — delete it, or register it again.`)
|
|
369
|
+
: null;
|
|
308
370
|
const guard = async (work) => {
|
|
309
371
|
try {
|
|
310
372
|
return await work();
|
|
@@ -313,6 +375,8 @@ export function createAdminOps(deps) {
|
|
|
313
375
|
return classify(e);
|
|
314
376
|
}
|
|
315
377
|
};
|
|
378
|
+
/** Where a new child may not go: a collection's record, a record no longer in code, a page where a code address wins. */
|
|
379
|
+
const parentGuard = async (parent) => mountGuard(parent) ?? orphan(parent) ?? (await insideCode(parent));
|
|
316
380
|
return {
|
|
317
381
|
pages: {
|
|
318
382
|
list: () => guard(async () => ok(await ensureFixedNodes(pages, config))),
|
|
@@ -350,17 +414,26 @@ export function createAdminOps(deps) {
|
|
|
350
414
|
return err("validation", `smoodly: page type "${input.template}" is fixed — Smoodly creates that page for you.`);
|
|
351
415
|
}
|
|
352
416
|
}
|
|
417
|
+
let parentPath = "";
|
|
418
|
+
let own = "";
|
|
353
419
|
if (input.parentId !== null) {
|
|
354
420
|
const parent = await pages.getPage(input.parentId);
|
|
355
421
|
if (!parent)
|
|
356
422
|
return err("not_found", `smoodly: no page "${input.parentId}".`);
|
|
357
|
-
const blocked =
|
|
423
|
+
const blocked = await parentGuard(parent);
|
|
358
424
|
if (blocked)
|
|
359
425
|
return blocked;
|
|
426
|
+
parentPath = (await pages.pathsOf(parent.id))[input.locale];
|
|
427
|
+
own = await anchorOf(parent);
|
|
360
428
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
429
|
+
// A code address is code's whether or not its record has landed yet (spec 2026-09-29 anchored paths §5).
|
|
430
|
+
if (parentPath !== undefined) {
|
|
431
|
+
// A root on the home slug serves "/" (isHomeNode), where a registered home page wins.
|
|
432
|
+
const home = input.parentId === null && input.locale === def && input.slug === config.homePageSlug;
|
|
433
|
+
const path = home ? "/" : `${parentPath}/${input.slug}`;
|
|
434
|
+
const owner = ownerOf(path, input.locale, own);
|
|
435
|
+
if (owner)
|
|
436
|
+
return belongsTo(path, owner);
|
|
364
437
|
}
|
|
365
438
|
const created = await pages.createPage({
|
|
366
439
|
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
@@ -445,6 +518,9 @@ export function createAdminOps(deps) {
|
|
|
445
518
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
446
519
|
if (!record.locales[locale])
|
|
447
520
|
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
521
|
+
const gone = orphan(record);
|
|
522
|
+
if (gone)
|
|
523
|
+
return gone;
|
|
448
524
|
if (isHomeNode(record))
|
|
449
525
|
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
|
|
450
526
|
if (fixedNodeOf(record, config))
|
|
@@ -512,7 +588,8 @@ export function createAdminOps(deps) {
|
|
|
512
588
|
if (fixedNodeOf(record, config)) {
|
|
513
589
|
return err("blocked", `smoodly: "${nameOf(record)}" is owned by code — remove the registration to remove it.`);
|
|
514
590
|
}
|
|
515
|
-
|
|
591
|
+
// A home page no longer in code may go too (§5): the registration that serves "/" now lands after it.
|
|
592
|
+
if (isHomeNode(record) && !orphan(record))
|
|
516
593
|
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be deleted.`);
|
|
517
594
|
await pages.deletePage(id);
|
|
518
595
|
// The cached unit is keyed by (locale, path) but tagged by the id
|
|
@@ -525,6 +602,9 @@ export function createAdminOps(deps) {
|
|
|
525
602
|
const record = await pages.getPage(id);
|
|
526
603
|
if (!record)
|
|
527
604
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
605
|
+
const gone = orphan(record);
|
|
606
|
+
if (gone)
|
|
607
|
+
return gone;
|
|
528
608
|
if (fixedNodeOf(record, config))
|
|
529
609
|
return owned(record, "rename it in the registration");
|
|
530
610
|
if (isHomeNode(record) && patch.slug !== undefined) {
|
|
@@ -535,6 +615,12 @@ export function createAdminOps(deps) {
|
|
|
535
615
|
return unsupported;
|
|
536
616
|
if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
|
|
537
617
|
return slugTaken();
|
|
618
|
+
if (patch.slug !== undefined) {
|
|
619
|
+
const slug = patch.slug;
|
|
620
|
+
const lands = await landsOnCode(id, (r) => r.locales[patch.locale] ? { ...r, locales: { ...r.locales, [patch.locale]: { ...r.locales[patch.locale], slug } } } : r);
|
|
621
|
+
if (lands)
|
|
622
|
+
return lands;
|
|
623
|
+
}
|
|
538
624
|
const { redirect, ...rest } = patch;
|
|
539
625
|
const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
|
|
540
626
|
// Every descendant's path changed with this segment; the parent
|
|
@@ -547,6 +633,9 @@ export function createAdminOps(deps) {
|
|
|
547
633
|
const record = await pages.getPage(id);
|
|
548
634
|
if (!record)
|
|
549
635
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
636
|
+
const gone = orphan(record);
|
|
637
|
+
if (gone)
|
|
638
|
+
return gone;
|
|
550
639
|
if (to.parentId === null && Object.values(record.locales).some((row) => adminSlug(row.slug)))
|
|
551
640
|
return slugTaken();
|
|
552
641
|
if (to.parentId !== null) {
|
|
@@ -559,10 +648,16 @@ export function createAdminOps(deps) {
|
|
|
559
648
|
const parent = await pages.getPage(to.parentId);
|
|
560
649
|
if (!parent)
|
|
561
650
|
return err("not_found", `smoodly: no page "${to.parentId}".`);
|
|
562
|
-
|
|
651
|
+
// A reorder among a holding page's children is no new placement.
|
|
652
|
+
const blocked = to.parentId === record.parentId ? null : await parentGuard(parent);
|
|
563
653
|
if (blocked)
|
|
564
654
|
return blocked;
|
|
565
655
|
}
|
|
656
|
+
if (to.parentId !== record.parentId) {
|
|
657
|
+
const lands = await landsOnCode(id, (r) => ({ ...r, parentId: to.parentId }));
|
|
658
|
+
if (lands)
|
|
659
|
+
return lands;
|
|
660
|
+
}
|
|
566
661
|
const { redirect, ...dest } = to;
|
|
567
662
|
const moved = await pages.move(id, dest, { redirect: redirect ?? true });
|
|
568
663
|
expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
|
|
@@ -573,6 +668,9 @@ export function createAdminOps(deps) {
|
|
|
573
668
|
const record = await pages.getPage(id);
|
|
574
669
|
if (!record)
|
|
575
670
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
671
|
+
const gone = orphan(record);
|
|
672
|
+
if (gone)
|
|
673
|
+
return gone;
|
|
576
674
|
const fixed = fixedNodeOf(record, config);
|
|
577
675
|
if (fixed?.collection) {
|
|
578
676
|
return err("blocked", `smoodly: "${nameOf(record)}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
|
|
@@ -591,8 +689,16 @@ export function createAdminOps(deps) {
|
|
|
591
689
|
const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
|
|
592
690
|
if (unsupported)
|
|
593
691
|
return unsupported;
|
|
594
|
-
|
|
692
|
+
const record = await pages.getPage(id);
|
|
693
|
+
if (!record)
|
|
595
694
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
695
|
+
const gone = orphan(record);
|
|
696
|
+
if (gone)
|
|
697
|
+
return gone;
|
|
698
|
+
// The new locale copies the source's slug: it may not land where code wins the address (spec 2026-09-29 anchored paths §5).
|
|
699
|
+
const lands = await landsOnCode(id, (r) => r.locales[options.from] && !r.locales[locale] ? { ...r, locales: { ...r.locales, [locale]: r.locales[options.from] } } : r);
|
|
700
|
+
if (lands)
|
|
701
|
+
return lands;
|
|
596
702
|
// A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
|
|
597
703
|
const added = await pages.addLocale(id, locale, { from: options.from }, by);
|
|
598
704
|
expireTree();
|
|
@@ -605,6 +711,9 @@ export function createAdminOps(deps) {
|
|
|
605
711
|
const record = await pages.getPage(id);
|
|
606
712
|
if (!record)
|
|
607
713
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
714
|
+
const gone = orphan(record);
|
|
715
|
+
if (gone)
|
|
716
|
+
return gone;
|
|
608
717
|
if (fixedNodeOf(record, config))
|
|
609
718
|
return owned(record, "it exists in every locale");
|
|
610
719
|
if (isHomeNode(record))
|
|
@@ -675,7 +784,27 @@ export function createAdminOps(deps) {
|
|
|
675
784
|
const descriptors = collectionFields(collection);
|
|
676
785
|
if (!descriptors)
|
|
677
786
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
678
|
-
|
|
787
|
+
// A published row's slug is its live address and is never versioned, so this
|
|
788
|
+
// save is its publish: the grammar holds here (spec 2026-09-28 ASCII slugs §3).
|
|
789
|
+
const slug = typeof fields.slug === "string" && fields.slug !== "" ? fields.slug : null;
|
|
790
|
+
if (descriptors.slug !== undefined && (slug === null || !isSegment(slug))) {
|
|
791
|
+
const row = (await entries.get(collection, id))?.locales[locale];
|
|
792
|
+
if (row?.status === "published" && row.slug !== null && row.slug !== slug) {
|
|
793
|
+
const message = slug === null ? "Required while the entry is published." : SLUG_GRAMMAR_MESSAGE;
|
|
794
|
+
return err("validation", "Some fields need attention.", [{ field: "slug", message }]);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
let entry;
|
|
798
|
+
try {
|
|
799
|
+
entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
|
|
800
|
+
}
|
|
801
|
+
catch (e) {
|
|
802
|
+
// A sibling's slug is the slug field's problem, not the whole draft's (spec 2026-09-28 ASCII slugs §5).
|
|
803
|
+
if (e instanceof Error && e.message === entryErrors.slugTaken(collection).message) {
|
|
804
|
+
return err("validation", "Some fields need attention.", [{ field: "slug", message: "Another entry here already uses this slug." }]);
|
|
805
|
+
}
|
|
806
|
+
throw e;
|
|
807
|
+
}
|
|
679
808
|
await fanOut(id);
|
|
680
809
|
expireCollection(collection);
|
|
681
810
|
return ok(entry);
|
|
@@ -1051,7 +1180,8 @@ export function createAdminOps(deps) {
|
|
|
1051
1180
|
}
|
|
1052
1181
|
return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
|
|
1053
1182
|
}),
|
|
1054
|
-
add: (locale,
|
|
1183
|
+
add: (locale, typed, target) => guard(async () => {
|
|
1184
|
+
const path = normalizeRedirectPath(typed);
|
|
1055
1185
|
const unsupported = supportedLocale(locale);
|
|
1056
1186
|
if (unsupported)
|
|
1057
1187
|
return unsupported;
|
|
@@ -1073,7 +1203,7 @@ export function createAdminOps(deps) {
|
|
|
1073
1203
|
const unsupported = supportedLocale(locale);
|
|
1074
1204
|
if (unsupported)
|
|
1075
1205
|
return unsupported;
|
|
1076
|
-
await pages.removeRedirect(locale, path);
|
|
1206
|
+
await pages.removeRedirect(locale, path); // the stored address, as list gave it
|
|
1077
1207
|
return ok(null);
|
|
1078
1208
|
}),
|
|
1079
1209
|
},
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** The one stored form of an old address (the renderer decodes a request the same way):
|
|
2
|
+
* a pasted `/s%C3%A4%C3%A4` and a typed `/sää` are one row. Checked after this. */
|
|
3
|
+
export declare const normalizeRedirectPath: (path: string) => string;
|
|
1
4
|
export type RedirectPathProblem = "empty" | "leading-slash" | "root" | "trailing-slash" | "double-slash" | "whitespace" | "query-or-hash" | "admin" | "extension";
|
|
2
5
|
export declare const REDIRECT_PATH_MESSAGE: Record<RedirectPathProblem, string>;
|
|
3
6
|
export declare function redirectPathProblem(path: string, opts: {
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
// The old-address check for a redirect by hand (spec 2026-09-26
|
|
2
|
+
// redirects §5). Pure: the Add dialog asks it for instant feedback and
|
|
3
|
+
// the op asks it again. The shape is "a path the catch-all will see":
|
|
4
|
+
// segments need not be slugs — old sites had capitals, dots and
|
|
5
|
+
// underscores — but nothing here may be unreachable by construction.
|
|
6
|
+
import { decodeSegment } from "../paths.js";
|
|
7
|
+
/** The one stored form of an old address (the renderer decodes a request the same way):
|
|
8
|
+
* a pasted `/s%C3%A4%C3%A4` and a typed `/sää` are one row. Checked after this. */
|
|
9
|
+
export const normalizeRedirectPath = (path) => path.split("/").map(decodeSegment).join("/");
|
|
1
10
|
export const REDIRECT_PATH_MESSAGE = {
|
|
2
11
|
empty: "Enter the old address.",
|
|
3
12
|
"leading-slash": "Start with a slash: /old-address.",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { Addresses } from "../addresses.ts";
|
|
2
3
|
import type { Descriptor } from "../fields.ts";
|
|
3
4
|
import type { ZonePolicy } from "../zones.ts";
|
|
4
5
|
export type AdminField = Record<string, unknown> & {
|
|
@@ -80,6 +81,8 @@ export type AdminRegistry = {
|
|
|
80
81
|
css: boolean;
|
|
81
82
|
/** Which slug the site root serves. */
|
|
82
83
|
homePageSlug: string;
|
|
84
|
+
/** Code-owned addresses by owner key (spec 2026-09-29 nested paths §3). */
|
|
85
|
+
addresses: Addresses;
|
|
83
86
|
/** How deep editors may nest pages; 1 = flat. */
|
|
84
87
|
pageDepth: number;
|
|
85
88
|
/** Upload limits, so a widget can refuse a file before the round trip. */
|
package/dist/admin/serialize.js
CHANGED
|
@@ -106,6 +106,7 @@ export function serializeAdminConfig(config) {
|
|
|
106
106
|
locales: config.locales,
|
|
107
107
|
...(config.timeZone !== undefined ? { timeZone: config.timeZone } : {}),
|
|
108
108
|
homePageSlug: config.homePageSlug,
|
|
109
|
+
addresses: config.addresses,
|
|
109
110
|
pageDepth: config.pageDepth,
|
|
110
111
|
media: { maxSize: config.media.maxSize, accept: [...config.media.accept] },
|
|
111
112
|
adminPath: config.admin.path,
|
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
// before the op repeats the check, and the target — one type-ahead over
|
|
6
6
|
// every page and routed entry live in the locale, "Title · /path".
|
|
7
7
|
import { useState } from "react";
|
|
8
|
-
import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "../redirect-path.js";
|
|
8
|
+
import { REDIRECT_PATH_MESSAGE, normalizeRedirectPath, redirectPathProblem } from "../redirect-path.js";
|
|
9
9
|
import { Combobox } from "../ui/Combobox.js";
|
|
10
10
|
import { Modal } from "../ui/Modal.js";
|
|
11
11
|
import { Banner, Button, Field } from "../ui/primitives.js";
|
|
@@ -15,7 +15,7 @@ export function AddRedirectModal({ locale, registry, targets, onClose, onAdd })
|
|
|
15
15
|
const [target, setTarget] = useState(null);
|
|
16
16
|
const [error, setError] = useState(null);
|
|
17
17
|
const [busy, setBusy] = useState(false);
|
|
18
|
-
const problem = redirectPathProblem(path, { adminPath: registry.adminPath, multilingual: registry.locales.supported.length > 1 });
|
|
18
|
+
const problem = redirectPathProblem(normalizeRedirectPath(path), { adminPath: registry.adminPath, multilingual: registry.locales.supported.length > 1 });
|
|
19
19
|
const picked = target ? targets.find((t) => t.target.kind === target.kind && t.target.id === target.id) : undefined;
|
|
20
20
|
const submit = async (e) => {
|
|
21
21
|
e.preventDefault();
|
|
@@ -48,8 +48,11 @@ import { unsetAtPath } from "../field-paths.js";
|
|
|
48
48
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
49
49
|
import { editorsOf } from "./presence.js";
|
|
50
50
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
51
|
+
import { slugHint, tidySlug, withFollowingSlug } from "../forms/slug-field.js";
|
|
51
52
|
/** A stable empty map for the hooks that run before the "no such collection" return. */
|
|
52
53
|
const NO_FIELDS = {};
|
|
54
|
+
/** True while the entry's slug box has focus: its field wrapper carries the path. */
|
|
55
|
+
const slugBoxFocused = () => typeof document !== "undefined" && document.activeElement instanceof HTMLElement && document.activeElement.closest('[data-sm-path="slug"]') !== null;
|
|
53
56
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
54
57
|
const adminHref = useAdminHref();
|
|
55
58
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
@@ -62,6 +65,9 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
62
65
|
const [parentLocales, setParentLocales] = useState(registry.locales.supported);
|
|
63
66
|
const [notice, setNotice] = useState(null);
|
|
64
67
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
68
|
+
/** A refused save's field errors (spec 2026-09-28 ASCII slugs): kept apart from the publish refusal's, which
|
|
69
|
+
* revalidation shrinks — a sibling's slug is no rule the form can check. Cleared by the next save that lands. */
|
|
70
|
+
const [saveErrors, setSaveErrors] = useState({});
|
|
65
71
|
const [adding, setAdding] = useState(false);
|
|
66
72
|
const [reloadKey, setReloadKey] = useState(0);
|
|
67
73
|
/** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
|
|
@@ -84,6 +90,8 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
84
90
|
const confirmedSlug = useRef(null);
|
|
85
91
|
/** The confirm's "Redirect the old address" answer for the save that follows it (spec 2026-09-26 redirects §6). */
|
|
86
92
|
const redirectRef = useRef(true);
|
|
93
|
+
/** A live slug change the autosave held back while its box had focus (spec 2026-09-28 ASCII slugs §4). */
|
|
94
|
+
const slugHeld = useRef(false);
|
|
87
95
|
// The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
|
|
88
96
|
/** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
89
97
|
const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
|
|
@@ -98,7 +106,23 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
98
106
|
// A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
|
|
99
107
|
const row = recordRef.current?.locales[locale];
|
|
100
108
|
const stored = row?.slug ?? null;
|
|
101
|
-
|
|
109
|
+
let next = typeof value.slug === "string" ? value.slug : null;
|
|
110
|
+
// A live slug changes when its box loses focus, never at a pause mid-typing (spec
|
|
111
|
+
// 2026-09-28 ASCII slugs §4): until then the rest of the draft saves with the live slug.
|
|
112
|
+
if (liveUrlChanges({ status: row?.status, from: stored, to: next }) && slugBoxFocused()) {
|
|
113
|
+
slugHeld.current = true;
|
|
114
|
+
value = { ...value, slug: stored };
|
|
115
|
+
next = stored;
|
|
116
|
+
}
|
|
117
|
+
// Tidied before the question, so the dialog asks about the URL that is saved.
|
|
118
|
+
if (next !== null && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
|
|
119
|
+
const tidy = tidySlug(next);
|
|
120
|
+
if (tidy !== next) {
|
|
121
|
+
next = tidy;
|
|
122
|
+
value = { ...value, slug: tidy };
|
|
123
|
+
setFields((f) => ({ ...(f ?? {}), slug: tidy }));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
102
126
|
if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
|
|
103
127
|
const current = recordRef.current;
|
|
104
128
|
const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
|
|
@@ -116,13 +140,19 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
116
140
|
if (r.ok) {
|
|
117
141
|
redirectRef.current = true;
|
|
118
142
|
setRecord(r.data);
|
|
143
|
+
setSaveErrors({});
|
|
119
144
|
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
120
145
|
}
|
|
121
146
|
else if (isStale(r))
|
|
122
147
|
setStale(staleInfoOf(r));
|
|
148
|
+
// A refused save (a sibling's slug, a live slug outside the grammar) marks its fields until a save lands.
|
|
149
|
+
else if (r.code === "validation" && r.fields)
|
|
150
|
+
setSaveErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
|
|
123
151
|
return r;
|
|
124
152
|
},
|
|
125
|
-
|
|
153
|
+
// A held slug is still unsaved: the form stays dirty until its box loses focus.
|
|
154
|
+
onSaved: () => { if (!slugHeld.current)
|
|
155
|
+
setDirty(false); },
|
|
126
156
|
});
|
|
127
157
|
// Reload and close ask only while a save is pending or in flight.
|
|
128
158
|
useLeaveGuard(dirty || saveState !== "saved");
|
|
@@ -143,6 +173,8 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
143
173
|
let cancelled = false;
|
|
144
174
|
setFields(null);
|
|
145
175
|
setFieldErrors({});
|
|
176
|
+
setSaveErrors({});
|
|
177
|
+
slugHeld.current = false;
|
|
146
178
|
setDirty(false);
|
|
147
179
|
setStale(null);
|
|
148
180
|
setTouched(false);
|
|
@@ -167,7 +199,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
167
199
|
const findings = useMemo(() => (fields && record?.locales[locale]
|
|
168
200
|
? entryDrift(registry, collection, { fields, shared: record.fields, own: record.locales[locale].fields })
|
|
169
201
|
: []), [fields, record, locale, registry, collection]);
|
|
170
|
-
const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
|
|
202
|
+
const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors, ...saveErrors }), [findings, fieldErrors, saveErrors]);
|
|
171
203
|
if (!meta)
|
|
172
204
|
return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
|
|
173
205
|
const row = record?.locales[locale];
|
|
@@ -418,8 +450,16 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
418
450
|
return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
|
|
419
451
|
};
|
|
420
452
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
421
|
-
|
|
422
|
-
|
|
453
|
+
// f.slug({ from }) (spec 2026-09-28 ASCII slugs §5–§6): the field a never-published slug follows.
|
|
454
|
+
const declaredFrom = meta.fields.slug?.from;
|
|
455
|
+
const slugFrom = typeof declaredFrom === "string" ? declaredFrom : undefined;
|
|
456
|
+
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, hint: key === "slug" && slugFrom && !fields?.slug ? slugHint(fields?.[slugFrom]) : undefined, onBlur: key === "slug" ? () => { if (slugHeld.current) {
|
|
457
|
+
slugHeld.current = false;
|
|
458
|
+
setDirty(true);
|
|
459
|
+
mark();
|
|
460
|
+
} } : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
|
|
461
|
+
const neverPublished = (recordRef.current?.locales[locale]?.publishedVersionId ?? null) === null;
|
|
462
|
+
setFields((f) => withFollowingSlug(f ?? {}, key, value, slugFrom, neverPublished));
|
|
423
463
|
setDirty(true);
|
|
424
464
|
setTouched(true);
|
|
425
465
|
mark();
|
|
@@ -431,7 +471,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
431
471
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
432
472
|
},
|
|
433
473
|
{ label: heading },
|
|
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)
|
|
474
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _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
475
|
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
476
|
setFields((f) => unsetAtPath(f ?? {}, path));
|
|
437
477
|
setDirty(true);
|
|
@@ -318,6 +318,6 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
318
318
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
319
319
|
},
|
|
320
320
|
{ label: item.title },
|
|
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)
|
|
321
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _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
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 })] })) })] })] }));
|
|
323
323
|
}
|