smoodly 0.0.12 → 0.0.14
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 +294 -40
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +135 -28
- package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
- package/dist/admin/editor/FloatingToolbar.js +14 -0
- package/dist/admin/editor/InlineRichtext.d.ts +9 -0
- package/dist/admin/editor/InlineRichtext.js +50 -0
- package/dist/admin/editor/Outline.d.ts +16 -10
- package/dist/admin/editor/Outline.js +13 -10
- package/dist/admin/editor/PageInspector.d.ts +21 -0
- package/dist/admin/editor/PageInspector.js +24 -0
- package/dist/admin/editor/PageSettings.d.ts +1 -3
- package/dist/admin/editor/PageSettings.js +7 -6
- package/dist/admin/editor/SeoSettings.d.ts +7 -0
- package/dist/admin/editor/SeoSettings.js +15 -0
- package/dist/admin/editor/inline-dom.d.ts +27 -0
- package/dist/admin/editor/inline-dom.js +43 -0
- package/dist/admin/editor/inline-session.d.ts +30 -0
- package/dist/admin/editor/inline-session.js +19 -0
- package/dist/admin/editor/protocol.d.ts +22 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
- package/dist/admin/editor/useCanvasBridge.js +12 -7
- package/dist/admin/editor/useInlineSession.d.ts +46 -0
- package/dist/admin/editor/useInlineSession.js +244 -0
- package/dist/admin/field-paths.d.ts +13 -0
- package/dist/admin/field-paths.js +49 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -2
- package/dist/admin/forms/FieldWidget.js +35 -43
- package/dist/admin/forms/ListField.d.ts +10 -0
- package/dist/admin/forms/ListField.js +42 -0
- package/dist/admin/forms/MediaWidget.js +10 -3
- package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
- package/dist/admin/forms/RichtextElementCard.js +37 -0
- package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
- package/dist/admin/forms/RichtextImageCard.js +42 -0
- package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
- package/dist/admin/forms/RichtextInsertMenu.js +6 -0
- package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
- package/dist/admin/forms/RichtextPopovers.js +25 -0
- package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
- package/dist/admin/forms/RichtextToolbar.js +4 -2
- package/dist/admin/forms/RichtextWidget.d.ts +4 -1
- package/dist/admin/forms/RichtextWidget.js +23 -120
- package/dist/admin/forms/elements-context.d.ts +7 -0
- package/dist/admin/forms/elements-context.js +13 -0
- package/dist/admin/forms/reveal.d.ts +5 -0
- package/dist/admin/forms/reveal.js +47 -0
- package/dist/admin/forms/richtext-extensions.d.ts +8 -0
- package/dist/admin/forms/richtext-extensions.js +11 -0
- package/dist/admin/forms/richtext-form-context.d.ts +16 -0
- package/dist/admin/forms/richtext-form-context.js +18 -0
- package/dist/admin/forms/richtext-insert.d.ts +38 -0
- package/dist/admin/forms/richtext-insert.js +37 -0
- package/dist/admin/forms/richtext-nodes.d.ts +37 -0
- package/dist/admin/forms/richtext-nodes.js +85 -0
- package/dist/admin/forms/richtext-paste.js +5 -0
- package/dist/admin/forms/tabs.d.ts +3 -0
- package/dist/admin/forms/tabs.js +6 -0
- package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
- package/dist/admin/forms/useRichtextEditor.js +281 -0
- package/dist/admin/next/bridge.js +51 -20
- package/dist/admin/ops-impl.js +85 -19
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +18 -1
- package/dist/admin/shell/AdminApp.js +2 -1
- package/dist/admin/shell/EntriesList.js +34 -12
- package/dist/admin/shell/EntryForm.js +22 -15
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/ListView.d.ts +9 -2
- package/dist/admin/shell/ListView.js +7 -6
- package/dist/admin/shell/LoginView.js +2 -2
- package/dist/admin/shell/PagesList.js +53 -24
- package/dist/admin/shell/SharedForm.js +17 -11
- package/dist/admin/shell/SharedList.js +5 -3
- package/dist/admin/shell/entries-list.d.ts +4 -0
- package/dist/admin/shell/entries-list.js +6 -0
- package/dist/admin/shell/pages-tree.d.ts +24 -0
- package/dist/admin/shell/pages-tree.js +59 -1
- package/dist/admin/tree-ops.d.ts +11 -1
- package/dist/admin/tree-ops.js +32 -1
- package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
- package/dist/admin/ui/LocaleSwitcher.js +28 -8
- package/dist/admin/ui/Modal.js +14 -2
- package/dist/admin/ui/ResizeHandle.d.ts +1 -0
- package/dist/admin/ui/ResizeHandle.js +127 -0
- package/dist/admin/ui/primitives.d.ts +38 -4
- package/dist/admin/ui/primitives.js +35 -10
- package/dist/admin/ui/resize.d.ts +16 -0
- package/dist/admin/ui/resize.js +23 -0
- package/dist/admin/ui/sortable.d.ts +23 -0
- package/dist/admin/ui/sortable.js +57 -0
- package/dist/admin/ui/theme.d.ts +3 -1
- package/dist/admin/ui/theme.js +527 -165
- package/dist/admin/ui/useSortable.d.ts +35 -0
- package/dist/admin/ui/useSortable.js +220 -0
- package/dist/admin/validate.d.ts +6 -1
- package/dist/admin/validate.js +52 -12
- package/dist/collections.d.ts +6 -5
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +23 -3
- package/dist/entry-store.js +31 -5
- package/dist/field-defaults.d.ts +8 -0
- package/dist/field-defaults.js +18 -0
- package/dist/fields.d.ts +30 -1
- package/dist/fields.js +33 -3
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +16 -7
- package/dist/index.js +10 -4
- package/dist/marks.d.ts +53 -0
- package/dist/marks.js +43 -0
- package/dist/media.d.ts +3 -2
- package/dist/media.js +48 -6
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/next/meta.d.ts +7 -3
- package/dist/next/meta.js +18 -9
- package/dist/next/page-renderer.js +1 -1
- package/dist/next/queries.d.ts +36 -0
- package/dist/next/queries.js +87 -0
- package/dist/page-tree.d.ts +3 -0
- package/dist/page-tree.js +6 -0
- package/dist/page.d.ts +11 -3
- package/dist/page.js +26 -0
- package/dist/pairing.d.ts +2 -1
- package/dist/pairing.js +2 -9
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.d.ts +14 -7
- package/dist/render.js +4 -3
- package/dist/resolve.d.ts +12 -2
- package/dist/resolve.js +16 -7
- package/dist/revalidate.d.ts +6 -0
- package/dist/revalidate.js +6 -0
- package/dist/richtext/index.d.ts +12 -0
- package/dist/richtext/index.js +9 -0
- package/dist/richtext-render.d.ts +22 -2
- package/dist/richtext-render.js +40 -13
- package/dist/richtext-walk.d.ts +44 -0
- package/dist/richtext-walk.js +46 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -1
- package/dist/section-wrapper.js +2 -1
- package/dist/shared.d.ts +7 -7
- package/dist/site.d.ts +25 -1
- package/dist/site.js +89 -22
- package/dist/store.d.ts +7 -1
- package/dist/store.js +13 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +34 -11
- package/dist/supabase-store.d.ts +1 -0
- package/dist/supabase-store.js +14 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- package/package.json +6 -1
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
// The
|
|
3
|
+
// The Globals list (shared content, spec 2026-09-07 §6; renamed in the
|
|
4
|
+
// UI 2026-09-09, the route stays /shared): every registered item is
|
|
4
5
|
// a row, edited on its own page. Nothing here is created or deleted by
|
|
5
6
|
// editors — the registry declares the items and ops.shared.list
|
|
6
7
|
// materializes them — so there is no "+ New".
|
|
7
8
|
import { useCallback, useEffect, useState } from "react";
|
|
8
9
|
import { ListView } from "./ListView.js";
|
|
9
10
|
import { sharedRows } from "./shared-list.js";
|
|
10
|
-
import {
|
|
11
|
+
import { StatusBadge } from "../ui/primitives.js";
|
|
12
|
+
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
11
13
|
import { relativeTime } from "../ui/format.js";
|
|
12
14
|
import { BASE } from "./base.js";
|
|
13
15
|
import { ErrorPane } from "./ErrorPane.js";
|
|
@@ -44,7 +46,7 @@ export function SharedList({ ops, registry }) {
|
|
|
44
46
|
if (error)
|
|
45
47
|
return _jsx(ErrorPane, { message: error });
|
|
46
48
|
const rows = items ? sharedRows(items, registry, locale) : [];
|
|
47
|
-
return (_jsx(ListView, { title: "
|
|
49
|
+
return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
|
|
48
50
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
49
51
|
{ label: "Kind", width: "90px" },
|
|
50
52
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
@@ -19,3 +19,7 @@ export declare function entrySourceLocale(record: EntryRecord, registry: AdminRe
|
|
|
19
19
|
* (the same split entry-store.ts's splitFields makes). */
|
|
20
20
|
export declare function localizedFieldKeys(meta: AdminCollection): string[];
|
|
21
21
|
export declare function entryRows(records: EntryRecord[], meta: AdminCollection, registry: AdminRegistry, locale: string): EntryRow[];
|
|
22
|
+
/** The list drags only when the order IS the `sort` column and the rows
|
|
23
|
+
* are siblings: a nested collection's flat index is not a sibling index
|
|
24
|
+
* (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */
|
|
25
|
+
export declare function canReorder(meta: AdminCollection): boolean;
|
|
@@ -34,3 +34,9 @@ export function entryRows(records, meta, registry, locale) {
|
|
|
34
34
|
};
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
+
/** The list drags only when the order IS the `sort` column and the rows
|
|
38
|
+
* are siblings: a nested collection's flat index is not a sibling index
|
|
39
|
+
* (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */
|
|
40
|
+
export function canReorder(meta) {
|
|
41
|
+
return meta.manual && meta.depth === 1;
|
|
42
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PageLocale, PageRecord } from "../../store.ts";
|
|
2
2
|
import type { AdminCollection, AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
3
|
+
import type { Projection } from "../ui/useSortable.ts";
|
|
3
4
|
type NodeLike = {
|
|
4
5
|
parentId: string | null;
|
|
5
6
|
locales: Record<string, {
|
|
@@ -54,4 +55,27 @@ export type PagesTreeRow = {
|
|
|
54
55
|
record: PageRecord;
|
|
55
56
|
};
|
|
56
57
|
export declare function pagesTreeRows(records: PageRecord[], registry: AdminRegistry, locale: string, collapsed: ReadonlySet<string>): PagesTreeRow[];
|
|
58
|
+
export type TreeProjection = Projection;
|
|
59
|
+
/** Every row drags: root order is the listing's order, nothing code owns.
|
|
60
|
+
* Fixed nodes and the home page stay at the root — projectDrop refuses
|
|
61
|
+
* them any deeper slot, the move op refuses any other parent. */
|
|
62
|
+
export declare const canDragRow: (_row: PagesTreeRow) => boolean;
|
|
63
|
+
/** Whether a row may only reorder among the root siblings. */
|
|
64
|
+
export declare const rootOnly: (row: PagesTreeRow) => boolean;
|
|
65
|
+
/** Levels below a node: 0 for a leaf. From the records, because the
|
|
66
|
+
* dragged node's rows are collapsed away during the drag. */
|
|
67
|
+
export declare function subtreeHeight(records: {
|
|
68
|
+
id: string;
|
|
69
|
+
parentId: string | null;
|
|
70
|
+
}[], id: string): number;
|
|
71
|
+
/**
|
|
72
|
+
* Reads a flat drop slot as a tree position. `rows` still hold the dragged
|
|
73
|
+
* row at `from` (its subtree collapsed); `slot` is its index in the
|
|
74
|
+
* resulting flat order; `xDrift` is the pointer's horizontal travel and
|
|
75
|
+
* every `indent` px of it is one level. Returns null for a slot the tree
|
|
76
|
+
* refuses: a parent that cannot have children, a subtree that would
|
|
77
|
+
* pass the depth limit, or any depth but the root for a row that is
|
|
78
|
+
* code-owned or the site root.
|
|
79
|
+
*/
|
|
80
|
+
export declare function projectDrop(rows: PagesTreeRow[], from: number, slot: number, xDrift: number, height: number, pageDepth: number, indent?: number): TreeProjection | null;
|
|
57
81
|
export {};
|
|
@@ -84,7 +84,7 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
84
84
|
template: r.template,
|
|
85
85
|
templateTitle: mount
|
|
86
86
|
? r.template === null ? "Collection" : `Collection · ${templateTitle(r.template)}`
|
|
87
|
-
: r.template === null ? "
|
|
87
|
+
: r.template === null ? "Path" : templateTitle(r.template),
|
|
88
88
|
status: !row || r.template === null ? null : localeStatusOf(row),
|
|
89
89
|
// A child needs its parent in the locale (the parent rule), so an absent node offers none.
|
|
90
90
|
canHaveChildren: present && !mount && !isHome && depth + 1 < registry.pageDepth,
|
|
@@ -97,3 +97,61 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
97
97
|
walk(buildPageTree(records, locale, opts), 0, null);
|
|
98
98
|
return rows;
|
|
99
99
|
}
|
|
100
|
+
/** Every row drags: root order is the listing's order, nothing code owns.
|
|
101
|
+
* Fixed nodes and the home page stay at the root — projectDrop refuses
|
|
102
|
+
* them any deeper slot, the move op refuses any other parent. */
|
|
103
|
+
export const canDragRow = (_row) => true;
|
|
104
|
+
/** Whether a row may only reorder among the root siblings. */
|
|
105
|
+
export const rootOnly = (row) => row.fixed || row.isHome;
|
|
106
|
+
/** Levels below a node: 0 for a leaf. From the records, because the
|
|
107
|
+
* dragged node's rows are collapsed away during the drag. */
|
|
108
|
+
export function subtreeHeight(records, id) {
|
|
109
|
+
const children = records.filter((r) => r.parentId === id);
|
|
110
|
+
if (children.length === 0)
|
|
111
|
+
return 0;
|
|
112
|
+
return 1 + Math.max(...children.map((c) => subtreeHeight(records, c.id)));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Reads a flat drop slot as a tree position. `rows` still hold the dragged
|
|
116
|
+
* row at `from` (its subtree collapsed); `slot` is its index in the
|
|
117
|
+
* resulting flat order; `xDrift` is the pointer's horizontal travel and
|
|
118
|
+
* every `indent` px of it is one level. Returns null for a slot the tree
|
|
119
|
+
* refuses: a parent that cannot have children, a subtree that would
|
|
120
|
+
* pass the depth limit, or any depth but the root for a row that is
|
|
121
|
+
* code-owned or the site root.
|
|
122
|
+
*/
|
|
123
|
+
export function projectDrop(rows, from, slot, xDrift, height, pageDepth, indent = 20) {
|
|
124
|
+
const others = rows.filter((_, i) => i !== from);
|
|
125
|
+
const above = slot > 0 ? others[slot - 1] : null;
|
|
126
|
+
const below = slot < others.length ? others[slot] : null;
|
|
127
|
+
const maxDepth = above ? above.depth + 1 : 0;
|
|
128
|
+
const minDepth = below ? below.depth : 0;
|
|
129
|
+
if (minDepth > maxDepth)
|
|
130
|
+
return null;
|
|
131
|
+
const wanted = rows[from].depth + Math.round(xDrift / indent);
|
|
132
|
+
const depth = Math.max(minDepth, Math.min(maxDepth, wanted));
|
|
133
|
+
if (depth + height >= pageDepth)
|
|
134
|
+
return null;
|
|
135
|
+
if (depth > 0 && rootOnly(rows[from]))
|
|
136
|
+
return null;
|
|
137
|
+
let parent = null;
|
|
138
|
+
if (depth > 0) {
|
|
139
|
+
for (let i = slot - 1; i >= 0; i--) {
|
|
140
|
+
if (others[i].depth === depth - 1) {
|
|
141
|
+
parent = others[i];
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!parent || !parent.canHaveChildren)
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const parentId = parent?.id ?? null;
|
|
149
|
+
// A collapsed parent hides its children: the slot after it is "last child".
|
|
150
|
+
if (parent && parent.hasChildren && !parent.expanded)
|
|
151
|
+
return { depth, parentId, index: undefined };
|
|
152
|
+
let index = 0;
|
|
153
|
+
for (let i = 0; i < slot; i++)
|
|
154
|
+
if (others[i].parentId === parentId)
|
|
155
|
+
index++;
|
|
156
|
+
return { depth, parentId, index };
|
|
157
|
+
}
|
package/dist/admin/tree-ops.d.ts
CHANGED
|
@@ -6,7 +6,14 @@ export declare function findNode(tree: PageTree, id: string): TreeNode | null;
|
|
|
6
6
|
export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
|
|
7
7
|
export declare function removeNode(tree: PageTree, id: string): PageTree;
|
|
8
8
|
export declare function moveNode(tree: PageTree, id: string, dir: -1 | 1): PageTree;
|
|
9
|
+
/** Places `id` at `index` in its zone's RESULTING order (removed first,
|
|
10
|
+
* then spliced in — moveItem's convention); clamped, and the same tree
|
|
11
|
+
* when nothing changes. The drop path; `moveNode` stays for the arrows. */
|
|
12
|
+
export declare function moveNodeTo(tree: PageTree, id: string, index: number): PageTree;
|
|
9
13
|
export declare function patchNode(tree: PageTree, id: string, ns: "fields" | "styles" | "meta", key: string, value: unknown): PageTree;
|
|
14
|
+
/** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
|
|
15
|
+
* `points.2.heading` sets one item's heading and copies only the spine. */
|
|
16
|
+
export declare function patchNodePath(tree: PageTree, id: string, path: string, value: unknown): PageTree;
|
|
10
17
|
export declare function canInsert(policy: ZonePolicy | undefined, count: number): boolean;
|
|
11
18
|
export declare function allowedSections(policy: ZonePolicy | undefined, all: AdminSection[]): AdminSection[];
|
|
12
19
|
/** The visual items the picker offers in a zone: materialized (an id is
|
|
@@ -15,7 +22,8 @@ export declare function allowedShared(policy: ZonePolicy | undefined, shared: Ad
|
|
|
15
22
|
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
16
23
|
* names the registration, `ref` is the item's row id. */
|
|
17
24
|
export declare function placementNode(item: AdminShared, ref: string, id: string): TreeNode;
|
|
18
|
-
|
|
25
|
+
/** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
|
|
26
|
+
export declare function nodeFromSample(section: Pick<AdminSection, "name" | "fields" | "styles" | "sample">, id: string): TreeNode;
|
|
19
27
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
20
28
|
* remove — so someone has to put it there. This does, from the component's
|
|
21
29
|
* sample (or, for a zone bound to a shared item, a placement), for every
|
|
@@ -24,3 +32,5 @@ export declare function nodeFromSample(section: AdminSection, id: string): TreeN
|
|
|
24
32
|
* schema. Returns the same tree when nothing changed. */
|
|
25
33
|
export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], sharedIds?: Record<string, string>): PageTree;
|
|
26
34
|
export declare function newNodeId(type: string): string;
|
|
35
|
+
/** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
|
|
36
|
+
export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;
|
package/dist/admin/tree-ops.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SHARED_NODE_TYPE } from "../shared.js";
|
|
2
|
+
import { setAtPath } from "./field-paths.js";
|
|
2
3
|
export function zoneOf(tree, id) {
|
|
3
4
|
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
4
5
|
if (nodes.some((n) => n.id === id))
|
|
@@ -40,12 +41,37 @@ export function moveNode(tree, id, dir) {
|
|
|
40
41
|
[list[i], list[j]] = [list[j], list[i]];
|
|
41
42
|
return withZone(tree, zone, list);
|
|
42
43
|
}
|
|
44
|
+
/** Places `id` at `index` in its zone's RESULTING order (removed first,
|
|
45
|
+
* then spliced in — moveItem's convention); clamped, and the same tree
|
|
46
|
+
* when nothing changes. The drop path; `moveNode` stays for the arrows. */
|
|
47
|
+
export function moveNodeTo(tree, id, index) {
|
|
48
|
+
const zone = zoneOf(tree, id);
|
|
49
|
+
if (!zone)
|
|
50
|
+
return tree;
|
|
51
|
+
const list = tree.zones[zone];
|
|
52
|
+
const from = list.findIndex((n) => n.id === id);
|
|
53
|
+
const to = Math.max(0, Math.min(index, list.length - 1));
|
|
54
|
+
if (from === to)
|
|
55
|
+
return tree;
|
|
56
|
+
const next = [...list];
|
|
57
|
+
const [moved] = next.splice(from, 1);
|
|
58
|
+
next.splice(to, 0, moved);
|
|
59
|
+
return withZone(tree, zone, next);
|
|
60
|
+
}
|
|
43
61
|
export function patchNode(tree, id, ns, key, value) {
|
|
44
62
|
const zone = zoneOf(tree, id);
|
|
45
63
|
if (!zone)
|
|
46
64
|
return tree;
|
|
47
65
|
return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: { ...(n[ns] ?? {}), [key]: value } } : n)));
|
|
48
66
|
}
|
|
67
|
+
/** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
|
|
68
|
+
* `points.2.heading` sets one item's heading and copies only the spine. */
|
|
69
|
+
export function patchNodePath(tree, id, path, value) {
|
|
70
|
+
const zone = zoneOf(tree, id);
|
|
71
|
+
if (!zone)
|
|
72
|
+
return tree;
|
|
73
|
+
return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, fields: setAtPath(n.fields, path, value) } : n)));
|
|
74
|
+
}
|
|
49
75
|
export function canInsert(policy, count) {
|
|
50
76
|
if (!policy || policy.kind === "locked")
|
|
51
77
|
return false;
|
|
@@ -85,11 +111,12 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
|
|
|
85
111
|
* broken-image icon or a stray zero, worse than nothing at all. With no
|
|
86
112
|
* sample, leave the key out of `fields` entirely rather than seed a lie. */
|
|
87
113
|
const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
|
|
114
|
+
/** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
|
|
88
115
|
export function nodeFromSample(section, id) {
|
|
89
116
|
const fields = {};
|
|
90
117
|
const styles = {};
|
|
91
118
|
for (const [key, d] of Object.entries(section.fields)) {
|
|
92
|
-
const sampled = section.sample?.[key];
|
|
119
|
+
const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
|
|
93
120
|
if (sampled !== undefined)
|
|
94
121
|
fields[key] = sampled;
|
|
95
122
|
else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
|
|
@@ -141,3 +168,7 @@ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
|
|
|
141
168
|
export function newNodeId(type) {
|
|
142
169
|
return `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
|
143
170
|
}
|
|
171
|
+
/** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
|
|
172
|
+
export function patchPage(tree, ns, key, value) {
|
|
173
|
+
return { ...tree, [ns]: { ...(tree[ns] ?? {}), [key]: value } };
|
|
174
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { AdminRegistry } from "../serialize.ts";
|
|
2
|
+
/** Above this many supported locales the switch is a dropdown. */
|
|
3
|
+
export declare const SEGMENTED_MAX = 3;
|
|
4
|
+
/** "fi" → "Fi": the locale code as the pill reads it. */
|
|
5
|
+
export declare const localeLabel: (locale: string) => string;
|
|
2
6
|
export declare function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }: {
|
|
3
7
|
registry: AdminRegistry;
|
|
4
8
|
/** The locales the record has; `[value]` while nothing is loaded. */
|
|
@@ -9,3 +13,9 @@ export declare function LocaleSwitcher({ registry, present, parentLocales, value
|
|
|
9
13
|
onSwitch(locale: string): void;
|
|
10
14
|
onAdd(locale: string): void;
|
|
11
15
|
}): import("react").JSX.Element;
|
|
16
|
+
/** The lists' locale selector: every locale exists, nothing to add. */
|
|
17
|
+
export declare function LocalePicker({ locales, value, onChange }: {
|
|
18
|
+
locales: readonly string[];
|
|
19
|
+
value: string;
|
|
20
|
+
onChange(locale: string): void;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Segmented } from "./primitives.js";
|
|
4
|
+
/** Above this many supported locales the switch is a dropdown. */
|
|
5
|
+
export const SEGMENTED_MAX = 3;
|
|
6
|
+
/** "fi" → "Fi": the locale code as the pill reads it. */
|
|
7
|
+
export const localeLabel = (locale) => locale.charAt(0).toUpperCase() + locale.slice(1);
|
|
3
8
|
export function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }) {
|
|
4
|
-
const
|
|
9
|
+
const supported = registry.locales.supported;
|
|
10
|
+
const missing = supported.filter((l) => !present.includes(l) && l !== value);
|
|
11
|
+
const blocked = (l) => !parentLocales.includes(l);
|
|
12
|
+
const reason = (l) => `add ${l} to the parent first`;
|
|
13
|
+
if (supported.length <= SEGMENTED_MAX) {
|
|
14
|
+
return (_jsx(Segmented, { options: supported, value: value, disabled: adding, label: localeLabel, className: "sm-seg--locale", option: (l) => {
|
|
15
|
+
if (!missing.includes(l))
|
|
16
|
+
return undefined;
|
|
17
|
+
return blocked(l)
|
|
18
|
+
? { disabled: true, title: reason(l), className: "sm-seg__opt--absent" }
|
|
19
|
+
: { title: `Not in ${l} yet`, className: "sm-seg__opt--absent" };
|
|
20
|
+
}, onChange: onSwitch }));
|
|
21
|
+
}
|
|
5
22
|
return (_jsxs("select", { className: "sm-input", style: { width: "auto" }, value: value, "aria-label": "Locale", onChange: (e) => { const v = e.target.value; if (v.startsWith("+"))
|
|
6
23
|
onAdd(v.slice(1));
|
|
7
24
|
else
|
|
8
|
-
onSwitch(v); }, children: [present.map((l) => _jsx("option", { value: l, children: l }, l)), !present.includes(value) && _jsxs("option", { value: value, children: [value, " (not yet)"] }), missing.length > 0 && (_jsx("optgroup", { label: "Add locale", disabled: adding, children: missing.map((l) =>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
25
|
+
onSwitch(v); }, children: [present.map((l) => _jsx("option", { value: l, children: localeLabel(l) }, l)), !present.includes(value) && _jsxs("option", { value: value, children: [localeLabel(value), " (not yet)"] }), missing.length > 0 && (_jsx("optgroup", { label: "Add locale", disabled: adding, children: missing.map((l) => blocked(l)
|
|
26
|
+
? _jsxs("option", { value: `+${l}`, disabled: true, title: reason(l), children: ["+ ", localeLabel(l), " \u2014 ", reason(l)] }, l)
|
|
27
|
+
: _jsxs("option", { value: `+${l}`, children: ["+ ", localeLabel(l)] }, l)) }))] }));
|
|
28
|
+
}
|
|
29
|
+
/** The lists' locale selector: every locale exists, nothing to add. */
|
|
30
|
+
export function LocalePicker({ locales, value, onChange }) {
|
|
31
|
+
if (locales.length <= SEGMENTED_MAX) {
|
|
32
|
+
return _jsx(Segmented, { options: locales, value: value, onChange: onChange, label: localeLabel, className: "sm-seg--locale" });
|
|
33
|
+
}
|
|
34
|
+
return (_jsx("select", { className: "sm-input", style: { width: "auto" }, value: value, "aria-label": "Locale", onChange: (e) => onChange(e.target.value), children: locales.map((l) => _jsx("option", { value: l, children: localeLabel(l) }, l)) }));
|
|
15
35
|
}
|
package/dist/admin/ui/Modal.js
CHANGED
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The one modal: a fixed overlay (click outside or Escape closes) and a
|
|
4
4
|
// centred panel with a title row. Same overlay shape as InsertPicker.
|
|
5
|
-
|
|
5
|
+
// Rendered through a portal into the admin wrapper: a picker opened from
|
|
6
|
+
// a card inside the canvas iframe must not be fixed to the iframe's
|
|
7
|
+
// viewport (spec 2026-09-10 §5), and every modal escapes its parent's
|
|
8
|
+
// overflow the same way. Static rendering (no document) keeps it inline.
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
6
11
|
import { IconButton } from "./primitives.js";
|
|
12
|
+
function adminRoot() {
|
|
13
|
+
if (typeof document === "undefined")
|
|
14
|
+
return null;
|
|
15
|
+
return document.querySelector(".sm-admin") ?? document.body;
|
|
16
|
+
}
|
|
7
17
|
export function Modal({ title, width = 720, onClose, children, footer }) {
|
|
18
|
+
const [root] = useState(adminRoot);
|
|
8
19
|
useEffect(() => {
|
|
9
20
|
const onKey = (e) => { if (e.key === "Escape")
|
|
10
21
|
onClose(); };
|
|
11
22
|
window.addEventListener("keydown", onKey);
|
|
12
23
|
return () => window.removeEventListener("keydown", onKey);
|
|
13
24
|
}, [onClose]);
|
|
14
|
-
|
|
25
|
+
const modal = (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: "sm-modal__body", children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
|
|
26
|
+
return root ? createPortal(modal, root) : modal;
|
|
15
27
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ResizeHandle(): import("react").JSX.Element;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The inspector's drag-to-resize handle (spec 2026-09-10 §3–§5). Rendered
|
|
4
|
+
// between the main column and <aside class="sm-inspector"> inside .sm-editor.
|
|
5
|
+
// A drag writes --inspector-w straight onto the .sm-admin wrapper — no React
|
|
6
|
+
// state, so the canvas iframe is never re-rendered mid-gesture; only the
|
|
7
|
+
// aria-valuenow re-renders, on release and after a keyboard step.
|
|
8
|
+
import { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { INSPECTOR_W_KEY, MIN_INSPECTOR_W, RESIZE_STEP, clampInspectorW, maxInspectorW } from "./resize.js";
|
|
10
|
+
/** The wrapper the variable lives on, the grid row, and the inspector itself. */
|
|
11
|
+
const parts = (handle) => {
|
|
12
|
+
const admin = handle.closest(".sm-admin");
|
|
13
|
+
const grid = handle.parentElement;
|
|
14
|
+
const aside = handle.nextElementSibling;
|
|
15
|
+
return {
|
|
16
|
+
admin: admin instanceof HTMLElement ? admin : null,
|
|
17
|
+
grid: grid instanceof HTMLElement ? grid : null,
|
|
18
|
+
aside: aside instanceof HTMLElement ? aside : null,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const apply = (admin, px) => admin.style.setProperty("--inspector-w", `${px}px`);
|
|
22
|
+
/** Private mode and blocked site data both throw; the width still changes. */
|
|
23
|
+
const remember = (px) => {
|
|
24
|
+
try {
|
|
25
|
+
if (px === null)
|
|
26
|
+
localStorage.removeItem(INSPECTOR_W_KEY);
|
|
27
|
+
else
|
|
28
|
+
localStorage.setItem(INSPECTOR_W_KEY, String(px));
|
|
29
|
+
}
|
|
30
|
+
catch { /* not remembered */ }
|
|
31
|
+
};
|
|
32
|
+
export function ResizeHandle() {
|
|
33
|
+
const ref = useRef(null);
|
|
34
|
+
const drag = useRef(null);
|
|
35
|
+
const [now, setNow] = useState(null);
|
|
36
|
+
// A hidden tab lays out at zero width (seen in the desktop pane's walk):
|
|
37
|
+
// zero is "unknown", not a value — measured again on focus.
|
|
38
|
+
const measure = () => {
|
|
39
|
+
const el = ref.current;
|
|
40
|
+
if (!el)
|
|
41
|
+
return;
|
|
42
|
+
const { aside } = parts(el);
|
|
43
|
+
if (aside && aside.offsetWidth > 0)
|
|
44
|
+
setNow(aside.offsetWidth);
|
|
45
|
+
};
|
|
46
|
+
useEffect(measure, []);
|
|
47
|
+
/** Clamp, write and remember one width; the aria value follows. */
|
|
48
|
+
const commit = (px) => {
|
|
49
|
+
const el = ref.current;
|
|
50
|
+
if (!el)
|
|
51
|
+
return;
|
|
52
|
+
const { admin, grid } = parts(el);
|
|
53
|
+
if (!admin || !grid)
|
|
54
|
+
return;
|
|
55
|
+
const next = clampInspectorW(px, grid.clientWidth);
|
|
56
|
+
apply(admin, next);
|
|
57
|
+
remember(next);
|
|
58
|
+
setNow(next);
|
|
59
|
+
};
|
|
60
|
+
const reset = () => {
|
|
61
|
+
const el = ref.current;
|
|
62
|
+
if (!el)
|
|
63
|
+
return;
|
|
64
|
+
const { admin } = parts(el);
|
|
65
|
+
if (!admin)
|
|
66
|
+
return;
|
|
67
|
+
admin.style.removeProperty("--inspector-w");
|
|
68
|
+
remember(null);
|
|
69
|
+
measure();
|
|
70
|
+
};
|
|
71
|
+
const onPointerDown = (e) => {
|
|
72
|
+
if (e.button !== 0 || drag.current)
|
|
73
|
+
return;
|
|
74
|
+
const { admin, grid, aside } = parts(e.currentTarget);
|
|
75
|
+
if (!admin || !grid || !aside)
|
|
76
|
+
return;
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
79
|
+
drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, last: aside.offsetWidth };
|
|
80
|
+
admin.classList.add("sm-admin--resizing");
|
|
81
|
+
};
|
|
82
|
+
const onPointerMove = (e) => {
|
|
83
|
+
const d = drag.current;
|
|
84
|
+
if (!d || e.pointerId !== d.id)
|
|
85
|
+
return;
|
|
86
|
+
const { admin } = parts(e.currentTarget);
|
|
87
|
+
if (!admin)
|
|
88
|
+
return;
|
|
89
|
+
// The handle sits on the inspector's left edge: moving left widens it.
|
|
90
|
+
d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW);
|
|
91
|
+
apply(admin, d.last);
|
|
92
|
+
};
|
|
93
|
+
// Also fires from releasePointerCapture below, when drag.current is already null.
|
|
94
|
+
const finish = (e) => {
|
|
95
|
+
const d = drag.current;
|
|
96
|
+
if (!d || e.pointerId !== d.id)
|
|
97
|
+
return;
|
|
98
|
+
drag.current = null;
|
|
99
|
+
const { admin } = parts(e.currentTarget);
|
|
100
|
+
if (e.currentTarget.hasPointerCapture(d.id))
|
|
101
|
+
e.currentTarget.releasePointerCapture(d.id);
|
|
102
|
+
admin?.classList.remove("sm-admin--resizing");
|
|
103
|
+
remember(d.last);
|
|
104
|
+
setNow(d.last);
|
|
105
|
+
};
|
|
106
|
+
const onKeyDown = (e) => {
|
|
107
|
+
const { grid, aside } = parts(e.currentTarget);
|
|
108
|
+
if (!grid || !aside || drag.current)
|
|
109
|
+
return;
|
|
110
|
+
const w = aside.offsetWidth;
|
|
111
|
+
let next = null;
|
|
112
|
+
if (e.key === "ArrowLeft")
|
|
113
|
+
next = w + RESIZE_STEP;
|
|
114
|
+
else if (e.key === "ArrowRight")
|
|
115
|
+
next = w - RESIZE_STEP;
|
|
116
|
+
else if (e.key === "Home")
|
|
117
|
+
next = MIN_INSPECTOR_W;
|
|
118
|
+
else if (e.key === "End")
|
|
119
|
+
next = maxInspectorW(grid.clientWidth);
|
|
120
|
+
if (next === null)
|
|
121
|
+
return;
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
e.stopPropagation(); // the editor's window keydown must not see these
|
|
124
|
+
commit(next);
|
|
125
|
+
};
|
|
126
|
+
return (_jsx("div", { ref: ref, className: "sm-resize", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize inspector", "aria-valuemin": MIN_INSPECTOR_W, "aria-valuenow": now ?? undefined, title: "Drag to resize \u00B7 double-click to reset", tabIndex: 0, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: finish, onPointerCancel: finish, onLostPointerCapture: finish, onDoubleClick: reset, onKeyDown: onKeyDown, onFocus: measure }));
|
|
127
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
|
|
2
2
|
import type { SaveState } from "../editor/autosave.ts";
|
|
3
3
|
/** The wordmark, inlined: a package can't ship a file into the host app's
|
|
4
|
-
* /public.
|
|
4
|
+
* /public. Its colour is the --logo-color token (accent in the warm look). */
|
|
5
5
|
export declare function Logo({ height }: {
|
|
6
6
|
height?: number;
|
|
7
7
|
}): import("react").JSX.Element;
|
|
@@ -27,11 +27,26 @@ export declare function IconButton({ label, tone, size, children, ...rest }: {
|
|
|
27
27
|
size?: number;
|
|
28
28
|
children: ReactNode;
|
|
29
29
|
} & ButtonHTMLAttributes<HTMLButtonElement>): import("react").JSX.Element;
|
|
30
|
+
/** An on/off control in the design system's control language: a pill
|
|
31
|
+
* track, a knob, the accent when on. A button, so it sits inside a
|
|
32
|
+
* field's implicit label and the label click toggles it. */
|
|
33
|
+
export declare function Switch({ checked, onChange, disabled, label }: {
|
|
34
|
+
checked: boolean;
|
|
35
|
+
onChange(next: boolean): void;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
label?: string;
|
|
38
|
+
}): import("react").JSX.Element;
|
|
39
|
+
/** The drag handle every sortable row carries at its left edge (spec
|
|
40
|
+
* 2026-09-09 §2): a button, so it is focusable and takes the arrow keys;
|
|
41
|
+
* `touch-action: none` (theme) so a finger on it drags instead of scrolling. */
|
|
42
|
+
export declare function Grip(props: ButtonHTMLAttributes<HTMLButtonElement>): import("react").JSX.Element;
|
|
30
43
|
/** Label + optional counter above a control — the handoff's Input/Textarea head.
|
|
31
44
|
* `asDiv` for groups holding more than one control: an implicit <label>
|
|
32
45
|
* activates its first labelable descendant, so wrapping several is a trap. */
|
|
33
|
-
export declare function Field({ label, counter, hint, error, asDiv, children, style, className }: {
|
|
46
|
+
export declare function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }: {
|
|
34
47
|
label?: ReactNode;
|
|
48
|
+
/** A small glyph after the label — the field's "same in every language" mark. */
|
|
49
|
+
mark?: ReactNode;
|
|
35
50
|
counter?: ReactNode;
|
|
36
51
|
hint?: ReactNode;
|
|
37
52
|
error?: ReactNode;
|
|
@@ -39,17 +54,27 @@ export declare function Field({ label, counter, hint, error, asDiv, children, st
|
|
|
39
54
|
children: ReactNode;
|
|
40
55
|
style?: CSSProperties;
|
|
41
56
|
className?: string;
|
|
57
|
+
/** The field's dotted path, for the canvas reveal (spec 2026-09-10 §8). */
|
|
58
|
+
path?: string;
|
|
42
59
|
}): import("react").JSX.Element;
|
|
60
|
+
/** The mark beside a field that is NOT `.localized()`: a globe-off, so a
|
|
61
|
+
* multilingual form needs no "shared across languages" grouping. */
|
|
62
|
+
export declare function UnlocalizedMark(): import("react").JSX.Element;
|
|
43
63
|
export declare function TopBar({ left, right }: {
|
|
44
64
|
left: ReactNode;
|
|
45
65
|
right?: ReactNode;
|
|
46
66
|
}): import("react").JSX.Element;
|
|
47
67
|
/** `items` are crumbs; every one but the last is a link when given an href.
|
|
48
|
-
* Links route in place: the shell never reloads for a crumb.
|
|
68
|
+
* Links route in place: the shell never reloads for a crumb. A crumb whose
|
|
69
|
+
* label starts with "/" (a page path) brings its own slash, so no
|
|
70
|
+
* separator is drawn before it. A crumb's `trailing` node renders right
|
|
71
|
+
* after it — the locale switch rides the first crumb, so it keeps its
|
|
72
|
+
* place however long the title or path after it grows. */
|
|
49
73
|
export declare function Breadcrumb({ items, backHref }: {
|
|
50
74
|
items: {
|
|
51
75
|
label: string;
|
|
52
76
|
href?: string;
|
|
77
|
+
trailing?: ReactNode;
|
|
53
78
|
}[];
|
|
54
79
|
backHref?: string;
|
|
55
80
|
}): import("react").JSX.Element;
|
|
@@ -58,12 +83,21 @@ export declare function Tabs<T extends string>({ tabs, value, onChange }: {
|
|
|
58
83
|
value: T | undefined;
|
|
59
84
|
onChange(tab: T): void;
|
|
60
85
|
}): import("react").JSX.Element;
|
|
61
|
-
export declare function Segmented<T extends string>({ options, value, onChange, stretch, disabled }: {
|
|
86
|
+
export declare function Segmented<T extends string>({ options, value, onChange, stretch, disabled, label, option, className }: {
|
|
62
87
|
options: readonly T[];
|
|
63
88
|
value: T;
|
|
64
89
|
onChange(v: T): void;
|
|
65
90
|
stretch?: boolean;
|
|
66
91
|
disabled?: boolean;
|
|
92
|
+
className?: string;
|
|
93
|
+
/** What an option reads as; the value itself by default. */
|
|
94
|
+
label?(o: T): ReactNode;
|
|
95
|
+
/** Per-option state: disabled with a reason, or a modifier class (an absent locale). */
|
|
96
|
+
option?(o: T): {
|
|
97
|
+
disabled?: boolean;
|
|
98
|
+
title?: string;
|
|
99
|
+
className?: string;
|
|
100
|
+
} | undefined;
|
|
67
101
|
}): import("react").JSX.Element;
|
|
68
102
|
export declare function StatusBadge({ status }: {
|
|
69
103
|
status: string;
|