@nonext/husk 0.2.3
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/CHANGELOG.md +148 -0
- package/LICENSE +166 -0
- package/README.md +140 -0
- package/api/public-api.md +1070 -0
- package/dist/adapter-C_0mhENy.d.ts +320 -0
- package/dist/admin/admin.css +5 -0
- package/dist/admin/index.d.ts +265 -0
- package/dist/admin/index.js +393 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/tokens.css +597 -0
- package/dist/admin/ui.d.ts +3013 -0
- package/dist/admin/ui.js +9783 -0
- package/dist/admin/ui.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth-B2gBtHl5.d.ts +28 -0
- package/dist/can-Chy7zmx7.d.ts +69 -0
- package/dist/chunk-3I6337WJ.js +83 -0
- package/dist/chunk-3I6337WJ.js.map +1 -0
- package/dist/chunk-4Z6P3XCE.js +62 -0
- package/dist/chunk-4Z6P3XCE.js.map +1 -0
- package/dist/chunk-6E3ZS5Z5.js +1007 -0
- package/dist/chunk-6E3ZS5Z5.js.map +1 -0
- package/dist/chunk-77JDRXXU.js +3 -0
- package/dist/chunk-77JDRXXU.js.map +1 -0
- package/dist/chunk-FHYWJCG6.js +171 -0
- package/dist/chunk-FHYWJCG6.js.map +1 -0
- package/dist/chunk-JAE7HHMM.js +506 -0
- package/dist/chunk-JAE7HHMM.js.map +1 -0
- package/dist/chunk-NPDBQBLW.js +68 -0
- package/dist/chunk-NPDBQBLW.js.map +1 -0
- package/dist/chunk-ORMYARXD.js +6 -0
- package/dist/chunk-ORMYARXD.js.map +1 -0
- package/dist/chunk-R4O7APIX.js +51 -0
- package/dist/chunk-R4O7APIX.js.map +1 -0
- package/dist/chunk-ROLKRJWI.js +187 -0
- package/dist/chunk-ROLKRJWI.js.map +1 -0
- package/dist/chunk-SOTQ3WP5.js +31 -0
- package/dist/chunk-SOTQ3WP5.js.map +1 -0
- package/dist/chunk-TCFFHTY3.js +97 -0
- package/dist/chunk-TCFFHTY3.js.map +1 -0
- package/dist/chunk-TS7Y7GEZ.js +7 -0
- package/dist/chunk-TS7Y7GEZ.js.map +1 -0
- package/dist/chunk-VLGR5PZX.js +3 -0
- package/dist/chunk-VLGR5PZX.js.map +1 -0
- package/dist/chunk-ZW2PUSVS.js +40 -0
- package/dist/chunk-ZW2PUSVS.js.map +1 -0
- package/dist/chunk-ZYNBMYJ6.js +392 -0
- package/dist/chunk-ZYNBMYJ6.js.map +1 -0
- package/dist/cli/nonext-husk.js +3360 -0
- package/dist/cli/nonext-husk.js.map +1 -0
- package/dist/cli/templates/firestore.rules +393 -0
- package/dist/cli/templates/storage.rules +165 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cms-9fSA3uQV.d.ts +158 -0
- package/dist/core/index.d.ts +1267 -0
- package/dist/core/index.js +12 -0
- package/dist/core/index.js.map +1 -0
- package/dist/entry-D3kiE8T1.d.ts +163 -0
- package/dist/field-Diyf5siF.d.ts +158 -0
- package/dist/field-type-D25Vojb0.d.ts +157 -0
- package/dist/firebase/index.d.ts +251 -0
- package/dist/firebase/index.js +991 -0
- package/dist/firebase/index.js.map +1 -0
- package/dist/index-5ylaMBUH.d.ts +155 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/index.d.ts +101 -0
- package/dist/permissions/index.js +7 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/registry-CUC25qay.d.ts +59 -0
- package/dist/richtext/index.d.ts +85 -0
- package/dist/richtext/index.js +127 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/schema/index.d.ts +50 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/write-limits-Djmm6L3R.d.ts +215 -0
- package/package.json +136 -0
|
@@ -0,0 +1,3013 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, ButtonHTMLAttributes, Ref, InputHTMLAttributes, TextareaHTMLAttributes, TdHTMLAttributes, ThHTMLAttributes, ReactElement, KeyboardEvent, ComponentType } from 'react';
|
|
3
|
+
import { LucideIcon } from 'lucide-react';
|
|
4
|
+
import * as lucide from 'lucide';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The avatar. Canonical source: .docs/styleguide/04-components.md section 9.
|
|
8
|
+
*
|
|
9
|
+
* Three sizes, nothing else. The fallback is initials rather than a generic silhouette,
|
|
10
|
+
* because a sidebar full of identical silhouettes tells a reader nothing.
|
|
11
|
+
*/
|
|
12
|
+
type AvatarSize = 20 | 24 | 32;
|
|
13
|
+
interface AvatarProps {
|
|
14
|
+
/** The display name or email the initials come from, and the image's alt text. */
|
|
15
|
+
name: string;
|
|
16
|
+
src?: string;
|
|
17
|
+
size?: AvatarSize;
|
|
18
|
+
}
|
|
19
|
+
/** At most two letters, from the first two words, so a long name stays inside the circle. */
|
|
20
|
+
declare function initials(name: string): string;
|
|
21
|
+
declare function Avatar({ name, src, size }: AvatarProps): react.JSX.Element;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The icon vocabulary as plain data, with no Lucide import.
|
|
25
|
+
*
|
|
26
|
+
* `icon.tsx` maps every name here to a glyph, and the admin only ever asks for a name. The
|
|
27
|
+
* list lives in its own module because things that are not components need it: the
|
|
28
|
+
* navigation builder resolves a content type's declared icon against this vocabulary, and
|
|
29
|
+
* it runs on the server, where dragging a React component module in would put the icon set
|
|
30
|
+
* into a bundle that never renders one.
|
|
31
|
+
*
|
|
32
|
+
* Canonical source: .docs/styleguide/05-icons.md section 1.3.
|
|
33
|
+
*/
|
|
34
|
+
declare const ICON_NAMES: readonly ["brand", "create", "edit", "delete", "save", "close", "search", "filter", "sort-asc", "sort-desc", "navigate-into", "page-previous", "page-next", "expand", "collapse", "selected", "indeterminate", "more-actions", "drag-handle", "locked", "dashboard", "collection", "singleton", "media", "settings", "users", "permissions", "content-types", "system", "success", "warning", "error", "info", "external-link", "upload", "copy", "retry", "open-navigation", "bold", "italic", "strikethrough", "link", "unlink", "bullet-list", "numbered-list", "quote", "undo", "redo"];
|
|
35
|
+
type IconName = (typeof ICON_NAMES)[number];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The only place in the admin that may import from lucide-react.
|
|
39
|
+
*
|
|
40
|
+
* Two things are enforced here rather than trusted to a reviewer. Stroke width is 1.5
|
|
41
|
+
* (Lucide defaults to 2), and size is one of the six the guide allows, so no screen can
|
|
42
|
+
* introduce a seventh by typing a number. Canonical source:
|
|
43
|
+
* .docs/styleguide/05-icons.md sections 1 and 1.1.
|
|
44
|
+
*
|
|
45
|
+
* Icons are keyed by concept, not by drawing. A screen asks for `delete`, not for
|
|
46
|
+
* `trash-2`, so the same concept cannot pick up two different glyphs in two places, and
|
|
47
|
+
* changing the glyph for a concept is one edit here instead of a search across the app.
|
|
48
|
+
*/
|
|
49
|
+
declare const ICONS: {
|
|
50
|
+
readonly brand: LucideIcon;
|
|
51
|
+
readonly create: LucideIcon;
|
|
52
|
+
readonly edit: LucideIcon;
|
|
53
|
+
readonly delete: LucideIcon;
|
|
54
|
+
readonly save: LucideIcon;
|
|
55
|
+
readonly close: LucideIcon;
|
|
56
|
+
readonly search: LucideIcon;
|
|
57
|
+
readonly filter: LucideIcon;
|
|
58
|
+
readonly "sort-asc": LucideIcon;
|
|
59
|
+
readonly "sort-desc": LucideIcon;
|
|
60
|
+
readonly "navigate-into": LucideIcon;
|
|
61
|
+
readonly "page-previous": LucideIcon;
|
|
62
|
+
readonly "page-next": LucideIcon;
|
|
63
|
+
readonly expand: LucideIcon;
|
|
64
|
+
readonly collapse: LucideIcon;
|
|
65
|
+
readonly selected: LucideIcon;
|
|
66
|
+
readonly indeterminate: LucideIcon;
|
|
67
|
+
readonly "more-actions": LucideIcon;
|
|
68
|
+
readonly "drag-handle": LucideIcon;
|
|
69
|
+
readonly locked: LucideIcon;
|
|
70
|
+
readonly dashboard: LucideIcon;
|
|
71
|
+
readonly collection: LucideIcon;
|
|
72
|
+
readonly singleton: LucideIcon;
|
|
73
|
+
readonly media: LucideIcon;
|
|
74
|
+
readonly settings: LucideIcon;
|
|
75
|
+
readonly users: LucideIcon;
|
|
76
|
+
readonly permissions: LucideIcon;
|
|
77
|
+
readonly "content-types": LucideIcon;
|
|
78
|
+
readonly system: LucideIcon;
|
|
79
|
+
readonly success: LucideIcon;
|
|
80
|
+
readonly warning: LucideIcon;
|
|
81
|
+
readonly error: LucideIcon;
|
|
82
|
+
readonly info: LucideIcon;
|
|
83
|
+
readonly "external-link": LucideIcon;
|
|
84
|
+
readonly upload: LucideIcon;
|
|
85
|
+
readonly copy: LucideIcon;
|
|
86
|
+
readonly retry: LucideIcon;
|
|
87
|
+
readonly "open-navigation": LucideIcon;
|
|
88
|
+
readonly bold: LucideIcon;
|
|
89
|
+
readonly italic: LucideIcon;
|
|
90
|
+
readonly strikethrough: LucideIcon;
|
|
91
|
+
readonly link: LucideIcon;
|
|
92
|
+
readonly unlink: LucideIcon;
|
|
93
|
+
readonly "bullet-list": LucideIcon;
|
|
94
|
+
readonly "numbered-list": LucideIcon;
|
|
95
|
+
readonly quote: LucideIcon;
|
|
96
|
+
readonly undo: LucideIcon;
|
|
97
|
+
readonly redo: LucideIcon;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** 12 badge and check, 14 compact controls, 16 default, 18 section heading, 20 page header, 24 empty state. */
|
|
101
|
+
type IconSize = 12 | 14 | 16 | 18 | 20 | 24;
|
|
102
|
+
interface IconProps {
|
|
103
|
+
name: IconName;
|
|
104
|
+
size?: IconSize;
|
|
105
|
+
/**
|
|
106
|
+
* Set only where the icon is the whole message. An icon next to a text label is
|
|
107
|
+
* decorative and stays hidden from assistive technology.
|
|
108
|
+
*/
|
|
109
|
+
label?: string;
|
|
110
|
+
className?: string;
|
|
111
|
+
}
|
|
112
|
+
declare function Icon({ name, size, label, className }: IconProps): react.JSX.Element;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The badge. Canonical source: .docs/styleguide/04-components.md section 7.
|
|
116
|
+
*
|
|
117
|
+
* Not interactive, ever. A badge that needs a click is a button or a filter chip, and
|
|
118
|
+
* neither of those exists in this system yet.
|
|
119
|
+
*/
|
|
120
|
+
type BadgeVariant = "neutral" | "success" | "warning" | "danger" | "info" | "accent";
|
|
121
|
+
/**
|
|
122
|
+
* Entry status maps fixed, so no screen decides for itself what colour Published is.
|
|
123
|
+
* Canonical source: .docs/styleguide/04-components.md section 7.
|
|
124
|
+
*/
|
|
125
|
+
declare const ENTRY_STATUS_VARIANT: {
|
|
126
|
+
readonly draft: "neutral";
|
|
127
|
+
readonly published: "success";
|
|
128
|
+
readonly archived: "warning";
|
|
129
|
+
readonly error: "danger";
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* How a status is written on screen. Stored lowercase, displayed capitalized, decided once
|
|
133
|
+
* so two screens cannot disagree about whether it is "Published" or "published".
|
|
134
|
+
*/
|
|
135
|
+
declare const ENTRY_STATUS_LABEL: {
|
|
136
|
+
readonly draft: "Draft";
|
|
137
|
+
readonly published: "Published";
|
|
138
|
+
readonly archived: "Archived";
|
|
139
|
+
};
|
|
140
|
+
interface BadgeProps {
|
|
141
|
+
children: ReactNode;
|
|
142
|
+
variant?: BadgeVariant;
|
|
143
|
+
icon?: IconName;
|
|
144
|
+
}
|
|
145
|
+
declare function Badge({ children, variant, icon }: BadgeProps): react.JSX.Element;
|
|
146
|
+
/**
|
|
147
|
+
* The status dot. Canonical source: .docs/styleguide/04-components.md section 8.
|
|
148
|
+
* Never larger, never animated.
|
|
149
|
+
*/
|
|
150
|
+
type StatusTone = "neutral" | "success" | "warning" | "danger" | "info";
|
|
151
|
+
interface StatusDotProps {
|
|
152
|
+
tone?: StatusTone;
|
|
153
|
+
/** Set only where the dot is the whole message; otherwise the text beside it says it. */
|
|
154
|
+
label?: string;
|
|
155
|
+
}
|
|
156
|
+
declare function StatusDot({ tone, label }: StatusDotProps): react.JSX.Element;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The breadcrumb. Canonical source: .docs/styleguide/03-layout.md section 3.1.
|
|
160
|
+
*
|
|
161
|
+
* `caption`, `text-faint`, a `chevron-right` separator at 14px. It sits above the page
|
|
162
|
+
* title and is part of the page header, never a free-standing element: a trail with no
|
|
163
|
+
* destination under it is decoration.
|
|
164
|
+
*
|
|
165
|
+
* Items are data, not nodes, so the page header cannot be handed a styled link that
|
|
166
|
+
* disagrees with the trail beside it. The last item is the current page and is never a
|
|
167
|
+
* link, because a link to the page you are on is a dead control.
|
|
168
|
+
*/
|
|
169
|
+
interface BreadcrumbItem {
|
|
170
|
+
label: string;
|
|
171
|
+
/** Absent on the last item, and on any ancestor that has no screen of its own. */
|
|
172
|
+
href?: string;
|
|
173
|
+
}
|
|
174
|
+
interface BreadcrumbProps {
|
|
175
|
+
items: readonly BreadcrumbItem[];
|
|
176
|
+
className?: string;
|
|
177
|
+
}
|
|
178
|
+
declare function Breadcrumb({ items, className }: BreadcrumbProps): react.JSX.Element | null;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The button, with the four variants and three sizes the style guide allows and nothing
|
|
182
|
+
* else. Canonical source: .docs/styleguide/04-components.md section 2.
|
|
183
|
+
*
|
|
184
|
+
* A screen that needs a fifth appearance is a style guide change, not a prop.
|
|
185
|
+
*
|
|
186
|
+
* The leading icon is named, not passed as a node, so a caller cannot hand it a 20px
|
|
187
|
+
* glyph or an icon outside the vocabulary. Size follows the button size: 14px at `sm`,
|
|
188
|
+
* 16px otherwise.
|
|
189
|
+
*/
|
|
190
|
+
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
|
191
|
+
type ButtonSize = "sm" | "md" | "lg";
|
|
192
|
+
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
|
|
193
|
+
variant?: ButtonVariant;
|
|
194
|
+
size?: ButtonSize;
|
|
195
|
+
/** Replaces the leading icon with a spinner and takes the control out of the tab flow. */
|
|
196
|
+
loading?: boolean;
|
|
197
|
+
icon?: IconName;
|
|
198
|
+
type?: "button" | "submit" | "reset";
|
|
199
|
+
ref?: Ref<HTMLButtonElement>;
|
|
200
|
+
}
|
|
201
|
+
declare function Button({ variant, size, loading, icon, children, className, disabled, type, ref, ...rest }: ButtonProps): react.JSX.Element;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The checkbox. Canonical source: .docs/styleguide/04-components.md section 3.4.
|
|
205
|
+
*
|
|
206
|
+
* Controlled only, and `checked` carries the third state rather than a separate
|
|
207
|
+
* `indeterminate` prop, because "indeterminate and unchecked" is not a state a table
|
|
208
|
+
* header selection can be in and a two-prop API lets a caller express it anyway.
|
|
209
|
+
*
|
|
210
|
+
* The visible box is a span; the real input stays in the accessibility tree and keeps the
|
|
211
|
+
* native label association, Space activation and form participation. Hiding it visually
|
|
212
|
+
* rather than replacing it is what makes the whole label a click target for free.
|
|
213
|
+
*/
|
|
214
|
+
interface CheckboxProps {
|
|
215
|
+
checked: boolean | "indeterminate";
|
|
216
|
+
onChange: (checked: boolean) => void;
|
|
217
|
+
/** Sits to the right of the box. Omit it only when a nearby cell header names the column. */
|
|
218
|
+
label?: string;
|
|
219
|
+
/** Required when there is no visible label, for example the selection cell in a table. */
|
|
220
|
+
ariaLabel?: string;
|
|
221
|
+
name?: string;
|
|
222
|
+
value?: string;
|
|
223
|
+
disabled?: boolean;
|
|
224
|
+
id?: string;
|
|
225
|
+
"aria-describedby"?: string | undefined;
|
|
226
|
+
}
|
|
227
|
+
declare function Checkbox({ checked, onChange, label, ariaLabel, name, value, disabled, id, "aria-describedby": describedBy, }: CheckboxProps): react.JSX.Element;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The empty state. Canonical source: .docs/styleguide/04-components.md section 15.
|
|
231
|
+
*
|
|
232
|
+
* One icon, one heading, one description, at most one action. `action` is a single node
|
|
233
|
+
* rather than an array on purpose: the guide calls a second action a design error, and a
|
|
234
|
+
* type that cannot express it is cheaper than a review that has to catch it.
|
|
235
|
+
*/
|
|
236
|
+
interface EmptyStateProps {
|
|
237
|
+
icon?: IconName;
|
|
238
|
+
heading: string;
|
|
239
|
+
description?: string;
|
|
240
|
+
/** One primary or secondary button. */
|
|
241
|
+
action?: ReactNode;
|
|
242
|
+
}
|
|
243
|
+
declare function EmptyState({ icon, heading, description, action }: EmptyStateProps): react.JSX.Element;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Field composition: label, 8px, control, 6px, help or error.
|
|
247
|
+
* Canonical source: .docs/styleguide/04-components.md section 3.1.
|
|
248
|
+
*
|
|
249
|
+
* The error replaces the help text rather than joining it, so a field never shows two
|
|
250
|
+
* competing sentences about the same value.
|
|
251
|
+
*/
|
|
252
|
+
interface FieldProps {
|
|
253
|
+
label: string;
|
|
254
|
+
/** Renders the `*` marker and wires `aria-required` on the control. */
|
|
255
|
+
required?: boolean;
|
|
256
|
+
/** Renders the right-aligned "Optional" marker. Ignored when `required` is set. */
|
|
257
|
+
optional?: boolean;
|
|
258
|
+
help?: string | undefined;
|
|
259
|
+
error?: string | undefined;
|
|
260
|
+
children: (control: {
|
|
261
|
+
id: string;
|
|
262
|
+
describedBy: string | undefined;
|
|
263
|
+
invalid: boolean;
|
|
264
|
+
}) => ReactNode;
|
|
265
|
+
}
|
|
266
|
+
declare function Field({ label, required, optional, help, error, children, }: FieldProps): react.JSX.Element;
|
|
267
|
+
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
268
|
+
invalid?: boolean;
|
|
269
|
+
/**
|
|
270
|
+
* Inline validation in flight. Shows the spinner inside the trailing edge and takes the
|
|
271
|
+
* control read-only rather than disabled, so the value stays selectable and the label
|
|
272
|
+
* stays associated.
|
|
273
|
+
*/
|
|
274
|
+
loading?: boolean;
|
|
275
|
+
}
|
|
276
|
+
declare function TextInput({ invalid, loading, className, readOnly, ...rest }: TextInputProps): react.JSX.Element;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Form-level error: the failure that belongs to the submission rather than to one field.
|
|
280
|
+
* Canonical source: .docs/styleguide/04-components.md section 3.8.
|
|
281
|
+
*
|
|
282
|
+
* It is a live region because it usually appears after a submit, when focus is still on
|
|
283
|
+
* the button and nothing else on screen announces the refusal.
|
|
284
|
+
*/
|
|
285
|
+
declare function FormError({ children }: {
|
|
286
|
+
children: ReactNode;
|
|
287
|
+
}): react.JSX.Element;
|
|
288
|
+
|
|
289
|
+
declare const ROLES: readonly ["admin", "editor", "author"];
|
|
290
|
+
type Role = (typeof ROLES)[number];
|
|
291
|
+
/** Actions performed on the entries of one content type. */
|
|
292
|
+
type EntryAction = "read" | "create" | "update" | "delete";
|
|
293
|
+
/** Actions that are not scoped to a content type. */
|
|
294
|
+
type AdminAction = "manage-schema" | "manage-users" | "manage-settings" | "manage-media";
|
|
295
|
+
type Action = EntryAction | AdminAction;
|
|
296
|
+
/**
|
|
297
|
+
* Who may do what with one content type. A missing action falls back to the role defaults
|
|
298
|
+
* in `can()`, so an empty matrix means "use the defaults", not "deny everything".
|
|
299
|
+
*/
|
|
300
|
+
type PermissionMatrix = Partial<Record<EntryAction, readonly Role[]>>;
|
|
301
|
+
interface AuthUser {
|
|
302
|
+
uid: string;
|
|
303
|
+
email: string;
|
|
304
|
+
displayName: string | null;
|
|
305
|
+
role: Role;
|
|
306
|
+
disabled: boolean;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A reference from an entry to an item in the media library.
|
|
311
|
+
*
|
|
312
|
+
* Entries store the reference, not the media document, so renaming a file or editing its
|
|
313
|
+
* alt text does not require rewriting every entry that uses it. The denormalized `url` is
|
|
314
|
+
* a read convenience for the SDK and is refreshed by the adapter when the media item
|
|
315
|
+
* changes; `mediaId` is the source of truth.
|
|
316
|
+
*/
|
|
317
|
+
interface MediaReference {
|
|
318
|
+
mediaId: string;
|
|
319
|
+
url: string;
|
|
320
|
+
}
|
|
321
|
+
interface MediaItem {
|
|
322
|
+
id: string;
|
|
323
|
+
name: string;
|
|
324
|
+
/** MIME type, for example `image/webp`. */
|
|
325
|
+
type: string;
|
|
326
|
+
/** Bytes. */
|
|
327
|
+
size: number;
|
|
328
|
+
url: string;
|
|
329
|
+
/** Storage object path, relative to the bucket root. */
|
|
330
|
+
path: string;
|
|
331
|
+
alt: string | null;
|
|
332
|
+
/** Pixels. Null for non-images. */
|
|
333
|
+
width: number | null;
|
|
334
|
+
height: number | null;
|
|
335
|
+
createdAt: string;
|
|
336
|
+
createdBy: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
declare const FIELD_TYPES: readonly ["text", "textarea", "number", "boolean", "select", "date", "slug", "richtext", "image", "file", "relation", "repeater"];
|
|
340
|
+
type FieldType = (typeof FIELD_TYPES)[number];
|
|
341
|
+
/**
|
|
342
|
+
* The full rule vocabulary. Every rule compiles to the same runtime validator, which the
|
|
343
|
+
* admin form and the adapter both run, so a rule can never be enforced in one place only.
|
|
344
|
+
*/
|
|
345
|
+
interface ValidationRules {
|
|
346
|
+
required?: boolean;
|
|
347
|
+
minLength?: number;
|
|
348
|
+
maxLength?: number;
|
|
349
|
+
min?: number;
|
|
350
|
+
max?: number;
|
|
351
|
+
/** Serialized as a string, not a RegExp, because rules travel through Firestore. */
|
|
352
|
+
pattern?: string;
|
|
353
|
+
/** Rejects a value already held by another entry of the same content type. */
|
|
354
|
+
unique?: boolean;
|
|
355
|
+
}
|
|
356
|
+
interface FieldCommon {
|
|
357
|
+
label: string;
|
|
358
|
+
description?: string;
|
|
359
|
+
placeholder?: string;
|
|
360
|
+
validation?: ValidationRules;
|
|
361
|
+
/** Hidden fields still store and validate; they are simply not rendered in the form. */
|
|
362
|
+
hidden?: boolean;
|
|
363
|
+
readonly?: boolean;
|
|
364
|
+
}
|
|
365
|
+
interface TextField extends FieldCommon {
|
|
366
|
+
type: "text";
|
|
367
|
+
defaultValue?: string;
|
|
368
|
+
}
|
|
369
|
+
interface TextareaField extends FieldCommon {
|
|
370
|
+
type: "textarea";
|
|
371
|
+
rows?: number;
|
|
372
|
+
defaultValue?: string;
|
|
373
|
+
}
|
|
374
|
+
interface NumberField extends FieldCommon {
|
|
375
|
+
type: "number";
|
|
376
|
+
step?: number;
|
|
377
|
+
defaultValue?: number;
|
|
378
|
+
}
|
|
379
|
+
interface BooleanField extends FieldCommon {
|
|
380
|
+
type: "boolean";
|
|
381
|
+
defaultValue?: boolean;
|
|
382
|
+
}
|
|
383
|
+
interface SelectOption$1<TValue extends string = string> {
|
|
384
|
+
value: TValue;
|
|
385
|
+
label: string;
|
|
386
|
+
}
|
|
387
|
+
interface SelectField<TValue extends string = string> extends FieldCommon {
|
|
388
|
+
type: "select";
|
|
389
|
+
options: readonly SelectOption$1<TValue>[];
|
|
390
|
+
multiple?: boolean;
|
|
391
|
+
defaultValue?: TValue;
|
|
392
|
+
}
|
|
393
|
+
interface DateField extends FieldCommon {
|
|
394
|
+
type: "date";
|
|
395
|
+
/** `date` stores `YYYY-MM-DD`; `datetime` stores a full ISO 8601 instant. */
|
|
396
|
+
precision?: "date" | "datetime";
|
|
397
|
+
}
|
|
398
|
+
interface SlugField extends FieldCommon {
|
|
399
|
+
type: "slug";
|
|
400
|
+
/** Field key the slug is derived from until the editor overrides it. */
|
|
401
|
+
from?: string;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Rich text is stored as a serialized document, not as an HTML string, so the editor choice
|
|
405
|
+
* (settled in M5) stays an implementation detail and the value can be re-rendered rather
|
|
406
|
+
* than re-parsed.
|
|
407
|
+
*/
|
|
408
|
+
interface RichTextValue {
|
|
409
|
+
format: "husk-richtext-v1";
|
|
410
|
+
content: unknown;
|
|
411
|
+
}
|
|
412
|
+
interface RichtextField extends FieldCommon {
|
|
413
|
+
type: "richtext";
|
|
414
|
+
/** Marks and blocks the toolbar offers. Absent means the default set. */
|
|
415
|
+
features?: readonly string[];
|
|
416
|
+
}
|
|
417
|
+
interface ImageField extends FieldCommon {
|
|
418
|
+
type: "image";
|
|
419
|
+
accept?: readonly string[];
|
|
420
|
+
}
|
|
421
|
+
interface FileField extends FieldCommon {
|
|
422
|
+
type: "file";
|
|
423
|
+
accept?: readonly string[];
|
|
424
|
+
}
|
|
425
|
+
interface RelationField<TTo extends string = string> extends FieldCommon {
|
|
426
|
+
type: "relation";
|
|
427
|
+
/** Slug of the referenced content type. */
|
|
428
|
+
to: TTo;
|
|
429
|
+
many?: boolean;
|
|
430
|
+
}
|
|
431
|
+
interface RepeaterField<TFields extends FieldMap = FieldMap> extends FieldCommon {
|
|
432
|
+
type: "repeater";
|
|
433
|
+
fields: TFields;
|
|
434
|
+
min?: number;
|
|
435
|
+
max?: number;
|
|
436
|
+
}
|
|
437
|
+
type FieldDefinition = TextField | TextareaField | NumberField | BooleanField | SelectField | DateField | SlugField | RichtextField | ImageField | FileField | RelationField | RepeaterField;
|
|
438
|
+
type FieldMap = {
|
|
439
|
+
readonly [key: string]: FieldDefinition;
|
|
440
|
+
};
|
|
441
|
+
/** The value one field definition produces, before required-ness is applied. */
|
|
442
|
+
type FieldValue<TField extends FieldDefinition> = TField extends TextField | TextareaField | SlugField ? string : TField extends NumberField ? number : TField extends BooleanField ? boolean : TField extends DateField ? string : TField extends SelectField<infer TOption> ? TField extends {
|
|
443
|
+
multiple: true;
|
|
444
|
+
} ? TOption[] : TOption : TField extends RichtextField ? RichTextValue : TField extends ImageField | FileField ? MediaReference : TField extends RelationField ? TField extends {
|
|
445
|
+
many: true;
|
|
446
|
+
} ? string[] : string : TField extends RepeaterField<infer TFields> ? FieldValues<TFields>[] : never;
|
|
447
|
+
type IsRequired<TField extends FieldDefinition> = TField extends {
|
|
448
|
+
validation: {
|
|
449
|
+
required: true;
|
|
450
|
+
};
|
|
451
|
+
} ? true : false;
|
|
452
|
+
/**
|
|
453
|
+
* Read shape: every key is present. An optional field reads as `null` rather than
|
|
454
|
+
* `undefined`, so a consumer never has to distinguish "absent" from "empty".
|
|
455
|
+
*/
|
|
456
|
+
type FieldValues<TFields extends FieldMap> = {
|
|
457
|
+
[K in keyof TFields]: IsRequired<TFields[K]> extends true ? FieldValue<TFields[K]> : FieldValue<TFields[K]> | null;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
type ContentTypeKind = "collection" | "singleton";
|
|
461
|
+
/**
|
|
462
|
+
* A field that was removed from a content type while its stored values were kept.
|
|
463
|
+
*
|
|
464
|
+
* Removal is a state, not an absence. The definition is parked here instead of being
|
|
465
|
+
* deleted, so the admin can name the field in a warning, offer it back unchanged, and so a
|
|
466
|
+
* later purge knows what it is about to destroy. Nothing downstream of the registry reads
|
|
467
|
+
* this map: forms, validation, list columns and the SDK all read `fields`, which is why a
|
|
468
|
+
* removed field is inert everywhere without a single consumer having to filter it out.
|
|
469
|
+
*/
|
|
470
|
+
interface RemovedField {
|
|
471
|
+
/** Exactly the definition the field had at the moment it was removed. */
|
|
472
|
+
field: FieldDefinition;
|
|
473
|
+
/** ISO 8601. A plain string, because a stored content type is plain JSON. */
|
|
474
|
+
removedAt: string;
|
|
475
|
+
/** uid of whoever removed it. */
|
|
476
|
+
removedBy: string;
|
|
477
|
+
}
|
|
478
|
+
type RemovedFieldMap = {
|
|
479
|
+
readonly [key: string]: RemovedField;
|
|
480
|
+
};
|
|
481
|
+
/** Where a content type came from. Code always wins a slug conflict. See the registry. */
|
|
482
|
+
type ContentTypeSource = "code" | "database";
|
|
483
|
+
/**
|
|
484
|
+
* A content type with its field map erased to `FieldMap`.
|
|
485
|
+
*
|
|
486
|
+
* `ContentType<TFields>` is invariant in `TFields`, so a concrete content type produced by
|
|
487
|
+
* `defineContentType` is not assignable to `ContentType<FieldMap>`. Everything that handles
|
|
488
|
+
* content types generically (the registry, the adapter, the Schema Builder) takes this
|
|
489
|
+
* structural shape instead, which a concrete content type does satisfy. Authoring and
|
|
490
|
+
* inference keep the generic form.
|
|
491
|
+
*/
|
|
492
|
+
interface ContentTypeLike {
|
|
493
|
+
slug: string;
|
|
494
|
+
kind: ContentTypeKind;
|
|
495
|
+
label: string;
|
|
496
|
+
labelPlural?: string;
|
|
497
|
+
description?: string;
|
|
498
|
+
icon?: string;
|
|
499
|
+
fields: FieldMap;
|
|
500
|
+
/**
|
|
501
|
+
* The order the fields are shown in, as a list of field keys.
|
|
502
|
+
*
|
|
503
|
+
* It exists because `fields` is a map and a map does not keep an order once it has been
|
|
504
|
+
* through Firestore: a stored document returns its map keys sorted, not in the order they
|
|
505
|
+
* were written. A code-defined type never round trips and keeps its literal's order, so
|
|
506
|
+
* this key is absent there and the object order is used; the Schema Builder writes it on
|
|
507
|
+
* every save, which is what makes a reorder survive a reload.
|
|
508
|
+
*
|
|
509
|
+
* It is a hint, not a second source of truth. An unknown key in it is ignored and a field
|
|
510
|
+
* missing from it is appended, so a `fieldOrder` that has drifted from `fields` still
|
|
511
|
+
* renders every field exactly once.
|
|
512
|
+
*/
|
|
513
|
+
fieldOrder?: readonly string[];
|
|
514
|
+
removedFields?: RemovedFieldMap;
|
|
515
|
+
locked?: boolean;
|
|
516
|
+
titleField?: string;
|
|
517
|
+
defaultSort?: {
|
|
518
|
+
field: string;
|
|
519
|
+
direction: "asc" | "desc";
|
|
520
|
+
};
|
|
521
|
+
permissions?: PermissionMatrix;
|
|
522
|
+
schemaVersion?: number;
|
|
523
|
+
}
|
|
524
|
+
type ContentTypeValues<TType extends {
|
|
525
|
+
fields: FieldMap;
|
|
526
|
+
}> = FieldValues<TType["fields"]>;
|
|
527
|
+
|
|
528
|
+
declare const ENTRY_STATUSES: readonly ["draft", "published", "archived"];
|
|
529
|
+
type EntryStatus = (typeof ENTRY_STATUSES)[number];
|
|
530
|
+
/**
|
|
531
|
+
* Everything the adapter owns on an entry. Written on every mutation, never by a form.
|
|
532
|
+
* Timestamps are ISO 8601 strings at this boundary; the Firestore converter turns them
|
|
533
|
+
* into Timestamps on the way in and back on the way out.
|
|
534
|
+
*/
|
|
535
|
+
interface EntryMeta {
|
|
536
|
+
id: string;
|
|
537
|
+
status: EntryStatus;
|
|
538
|
+
createdAt: string;
|
|
539
|
+
createdBy: string;
|
|
540
|
+
updatedAt: string;
|
|
541
|
+
updatedBy: string;
|
|
542
|
+
publishedAt: string | null;
|
|
543
|
+
schemaVersion: number;
|
|
544
|
+
}
|
|
545
|
+
/** What a read returns: the metadata plus the typed field values. */
|
|
546
|
+
type Entry<TType extends AnyContentTypeShape = AnyContentTypeShape> = EntryMeta & ContentTypeValues<TType>;
|
|
547
|
+
/**
|
|
548
|
+
* Structural stand-in for `ContentType<TFields>`. `ContentType` is invariant in its field
|
|
549
|
+
* map (it uses `keyof TFields` for titleField and defaultSort), so a concrete content type
|
|
550
|
+
* is not assignable to `ContentType<FieldMap>` and cannot be used as a constraint here.
|
|
551
|
+
*/
|
|
552
|
+
type AnyContentTypeShape = {
|
|
553
|
+
fields: FieldMap;
|
|
554
|
+
};
|
|
555
|
+
interface FieldError {
|
|
556
|
+
/** Dotted path, so a repeater reports `blocks.2.title` rather than `blocks`. */
|
|
557
|
+
path: string;
|
|
558
|
+
rule: string;
|
|
559
|
+
message: string;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The one error taxonomy the admin renders from.
|
|
564
|
+
*
|
|
565
|
+
* Before this existed the treatments were real but scattered: the entry editor routed a
|
|
566
|
+
* `ValidationError` to the offending field and fell back to a form-level message, the
|
|
567
|
+
* Schema Builder put every failure in one banner, the media screens put every failure in a
|
|
568
|
+
* toast, and a permission refusal read as "something went wrong" in all three. Each of
|
|
569
|
+
* those was defensible on its own and none of them agreed with the others.
|
|
570
|
+
*
|
|
571
|
+
* This module is not a second system layered over them. It is the first one, factored out:
|
|
572
|
+
* the M6 rule that a validation error lands on the field and only an unroutable path
|
|
573
|
+
* reaches the form is preserved exactly, as the `field` treatment, and every other screen
|
|
574
|
+
* now asks the same question instead of answering it privately.
|
|
575
|
+
*
|
|
576
|
+
* Five categories, one treatment each:
|
|
577
|
+
*
|
|
578
|
+
* | category | treatment | what the user sees |
|
|
579
|
+
* |--------------|-----------|-----------------------------------------------------------|
|
|
580
|
+
* | `validation` | `field` | the message on the control that owns the path; never a bare toast |
|
|
581
|
+
* | `permission` | `banner` | a form-level notice naming the action, the subject and the role |
|
|
582
|
+
* | `not-found` | `state` | an empty state with a way back; no alert, no red |
|
|
583
|
+
* | `conflict` | `resolve` | a banner that names both resolutions and offers them |
|
|
584
|
+
* | `transport` | `retry` | a banner or state carrying the one action that can help |
|
|
585
|
+
*
|
|
586
|
+
* `unknown` is the sixth, for a failure that fits none of them. It takes the `banner`
|
|
587
|
+
* treatment and says plainly that the cause is unknown, rather than guessing at a category
|
|
588
|
+
* and offering a retry that cannot work.
|
|
589
|
+
*
|
|
590
|
+
* The toast is deliberately not a treatment. It is the announcement channel for a failure
|
|
591
|
+
* whose surface has already closed (a dialog that dismissed itself, a background upload),
|
|
592
|
+
* and when it is used it prints this same `title` and `message`, so a failure can never be
|
|
593
|
+
* worded twice.
|
|
594
|
+
*
|
|
595
|
+
* No React and no Firebase here, so the classification is testable in the node project and
|
|
596
|
+
* callable from the adapter as well as from a render.
|
|
597
|
+
*/
|
|
598
|
+
declare const ERROR_CATEGORIES: readonly ["validation", "permission", "not-found", "conflict", "transport", "unknown"];
|
|
599
|
+
type ErrorCategory = (typeof ERROR_CATEGORIES)[number];
|
|
600
|
+
/** The chrome a category is rendered in. Exactly one per category; see the table above. */
|
|
601
|
+
type ErrorTreatment = "field" | "banner" | "state" | "resolve" | "retry";
|
|
602
|
+
interface ClassifiedError {
|
|
603
|
+
category: ErrorCategory;
|
|
604
|
+
treatment: ErrorTreatment;
|
|
605
|
+
/** A short heading. Used as the banner's first line and as a toast title. */
|
|
606
|
+
title: string;
|
|
607
|
+
/** One or two sentences: what happened, and what the user can do about it. */
|
|
608
|
+
message: string;
|
|
609
|
+
/**
|
|
610
|
+
* The field errors, for `validation` only. Every other category leaves this empty, which
|
|
611
|
+
* is what stops a non-validation failure from being routed onto a control.
|
|
612
|
+
*/
|
|
613
|
+
fieldErrors: readonly FieldError[];
|
|
614
|
+
/** True only for `transport`. A retry of anything else repeats the same refusal. */
|
|
615
|
+
retryable: boolean;
|
|
616
|
+
/** What was thrown, kept for a boundary that wants to log it. Never rendered. */
|
|
617
|
+
cause: unknown;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* What covers the failure a React error boundary cannot see.
|
|
622
|
+
*
|
|
623
|
+
* A boundary catches a throw during render, in an effect, or in a lifecycle. It does not
|
|
624
|
+
* catch a rejected promise, and almost everything the admin does is a rejected promise: an
|
|
625
|
+
* adapter call, an upload, a listener. Every one of those is inside a `try`/`catch` today,
|
|
626
|
+
* but "every one" is a property of the code as it stands, not a mechanism, and the first
|
|
627
|
+
* handler somebody forgets is a failure that reaches nobody. The browser's own answer to
|
|
628
|
+
* an unhandled rejection is a console line the user will never see.
|
|
629
|
+
*
|
|
630
|
+
* So the admin listens at the window. An unhandled rejection is classified through the same
|
|
631
|
+
* taxonomy as a caught one and raised as an error toast, which does not auto-dismiss.
|
|
632
|
+
* `preventDefault` stops the default console report, because the toast now owns it.
|
|
633
|
+
*
|
|
634
|
+
* `error` is listened to for the same reason: a throw inside a `setTimeout` or an event
|
|
635
|
+
* handler registered outside React is not on any component's stack either.
|
|
636
|
+
*
|
|
637
|
+
* This is a net, not a treatment. Anything it catches is a missing `catch` somewhere, and
|
|
638
|
+
* the toast says so in the one place that was not meant to have to.
|
|
639
|
+
*/
|
|
640
|
+
interface ErrorCaptureProps {
|
|
641
|
+
/**
|
|
642
|
+
* Called for every captured failure, before the toast. For a host that ships errors to
|
|
643
|
+
* a reporter; the admin itself does not, because it has no service to ship them to.
|
|
644
|
+
*/
|
|
645
|
+
onCapture?: ((error: ClassifiedError) => void) | undefined;
|
|
646
|
+
}
|
|
647
|
+
declare function ErrorCapture({ onCapture }: ErrorCaptureProps): null;
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The in-place treatment for a classified error that is not a field error.
|
|
651
|
+
* Canonical source: .docs/styleguide/04-components.md section 19.
|
|
652
|
+
*
|
|
653
|
+
* One component for the `banner`, `resolve` and `retry` treatments, because they are one
|
|
654
|
+
* piece of chrome with a different actions slot, not three designs. Splitting them would
|
|
655
|
+
* be how a permission refusal and a transport failure end up looking like two products.
|
|
656
|
+
*
|
|
657
|
+
* `validation` never reaches here. Its treatment is the field itself, and the entry editor
|
|
658
|
+
* routes each error to the control that owns its path; only a path naming no rendered field
|
|
659
|
+
* falls back to a banner, which is the M6 rule this component inherits rather than replaces.
|
|
660
|
+
* `not-found` never reaches here either: it is a state, rendered by `ErrorScreen`.
|
|
661
|
+
*/
|
|
662
|
+
interface ErrorNoticeProps {
|
|
663
|
+
error: ClassifiedError;
|
|
664
|
+
/**
|
|
665
|
+
* The retry. Rendered only when the taxonomy says a retry is meaningful, so a screen
|
|
666
|
+
* cannot offer one for a refusal that will refuse again.
|
|
667
|
+
*/
|
|
668
|
+
onRetry?: (() => void) | undefined;
|
|
669
|
+
/** The resolutions for a `conflict`, which has two and must name both. */
|
|
670
|
+
actions?: ReactNode;
|
|
671
|
+
className?: string;
|
|
672
|
+
}
|
|
673
|
+
declare function ErrorNotice({ error, onRetry, actions, className }: ErrorNoticeProps): react.JSX.Element;
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The animated half of the icon system. Canonical source:
|
|
677
|
+
* .docs/styleguide/05-icons.md section 2.
|
|
678
|
+
*
|
|
679
|
+
* Morphicons is not a second icon family: it animates between two Lucide drawings. It
|
|
680
|
+
* consumes icon *data* rather than components, which is why this is the one file that
|
|
681
|
+
* imports the vanilla `lucide` package as well as `lucide-react` in `icon.tsx`. Both are
|
|
682
|
+
* pinned to the same version, so a morph and the static icon next to it are the same
|
|
683
|
+
* drawing.
|
|
684
|
+
*
|
|
685
|
+
* A morph is allowed only in the cases section 2.1 lists, so the glyph map is a closed set
|
|
686
|
+
* of four names rather than the whole vocabulary: a screen cannot animate an icon the
|
|
687
|
+
* guide says should not move. Names are checked against the static vocabulary, so a morph
|
|
688
|
+
* pair can never drift onto a concept that does not exist.
|
|
689
|
+
*/
|
|
690
|
+
declare const MORPH_GLYPHS: {
|
|
691
|
+
readonly "open-navigation": lucide.IconNode;
|
|
692
|
+
readonly close: lucide.IconNode;
|
|
693
|
+
readonly "navigate-into": lucide.IconNode;
|
|
694
|
+
readonly expand: lucide.IconNode;
|
|
695
|
+
readonly copy: lucide.IconNode;
|
|
696
|
+
readonly save: lucide.IconNode;
|
|
697
|
+
};
|
|
698
|
+
type MorphIconName = keyof typeof MORPH_GLYPHS;
|
|
699
|
+
interface MorphingIconProps {
|
|
700
|
+
name: MorphIconName;
|
|
701
|
+
/** The same six sizes the static icon allows. */
|
|
702
|
+
size?: 12 | 14 | 16 | 18 | 20 | 24;
|
|
703
|
+
/** Set only where the icon is the whole message; otherwise it stays decorative. */
|
|
704
|
+
label?: string;
|
|
705
|
+
className?: string;
|
|
706
|
+
}
|
|
707
|
+
declare function MorphingIcon({ name, size, label, className }: MorphingIconProps): react.JSX.Element;
|
|
708
|
+
|
|
709
|
+
interface IconButtonBase extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type" | "children" | "aria-label"> {
|
|
710
|
+
/** The accessible name, and the tooltip text unless `tooltip` overrides it. */
|
|
711
|
+
label: string;
|
|
712
|
+
tooltip?: string;
|
|
713
|
+
tooltipPlacement?: "top" | "bottom";
|
|
714
|
+
variant?: ButtonVariant;
|
|
715
|
+
size?: ButtonSize;
|
|
716
|
+
/**
|
|
717
|
+
* Turns the button into a toggle that reports a state: `aria-pressed` plus the pressed
|
|
718
|
+
* appearance from .docs/styleguide/04-components.md section 2.3. The guide allows it on the
|
|
719
|
+
* `ghost` variant only, which is the default here.
|
|
720
|
+
*/
|
|
721
|
+
pressed?: boolean;
|
|
722
|
+
loading?: boolean;
|
|
723
|
+
ref?: Ref<HTMLButtonElement>;
|
|
724
|
+
type?: "button" | "submit" | "reset";
|
|
725
|
+
}
|
|
726
|
+
type IconButtonProps = IconButtonBase & ({
|
|
727
|
+
icon: IconName;
|
|
728
|
+
morph?: false;
|
|
729
|
+
} | {
|
|
730
|
+
icon: MorphIconName;
|
|
731
|
+
morph: true;
|
|
732
|
+
});
|
|
733
|
+
declare function IconButton(props: IconButtonProps): react.JSX.Element;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The dropdown menu. Canonical source: .docs/styleguide/04-components.md section 11.
|
|
737
|
+
*
|
|
738
|
+
* Items are data rather than children, because every menu in the admin is generated: the
|
|
739
|
+
* row actions come from what the user may do to that entry, the sidebar overflow comes
|
|
740
|
+
* from the registry. A children-based API would invite a screen to hand-build one.
|
|
741
|
+
*
|
|
742
|
+
* Keyboard behaviour follows the menu button pattern: the trigger opens on Enter, Space or
|
|
743
|
+
* ArrowDown, focus moves into the list, arrows move the highlight past disabled items and
|
|
744
|
+
* separators, Escape closes and returns focus to the trigger, and Tab closes without
|
|
745
|
+
* grabbing focus back so the page's tab order is preserved.
|
|
746
|
+
*/
|
|
747
|
+
type MenuEntry = {
|
|
748
|
+
kind: "item";
|
|
749
|
+
id: string;
|
|
750
|
+
label: string;
|
|
751
|
+
icon?: IconName;
|
|
752
|
+
/** Renders the trailing check from section 11. */
|
|
753
|
+
selected?: boolean;
|
|
754
|
+
destructive?: boolean;
|
|
755
|
+
disabled?: boolean;
|
|
756
|
+
/** Right-aligned hint, for example "Ctrl S". Display only; the menu binds nothing. */
|
|
757
|
+
shortcut?: string;
|
|
758
|
+
} | {
|
|
759
|
+
kind: "separator";
|
|
760
|
+
id: string;
|
|
761
|
+
} | {
|
|
762
|
+
kind: "section";
|
|
763
|
+
id: string;
|
|
764
|
+
label: string;
|
|
765
|
+
};
|
|
766
|
+
interface MenuProps {
|
|
767
|
+
items: readonly MenuEntry[];
|
|
768
|
+
onSelect: (id: string) => void;
|
|
769
|
+
/** The trigger's accessible name, and its tooltip when the trigger is icon-only. */
|
|
770
|
+
label: string;
|
|
771
|
+
/** Set to render a labelled trigger instead of the icon-only default. */
|
|
772
|
+
triggerLabel?: string;
|
|
773
|
+
triggerIcon?: IconName;
|
|
774
|
+
triggerVariant?: ButtonVariant;
|
|
775
|
+
triggerSize?: ButtonSize;
|
|
776
|
+
/** Which edge of the trigger the menu lines up with. */
|
|
777
|
+
align?: "start" | "end";
|
|
778
|
+
/**
|
|
779
|
+
* Which side of the trigger the menu opens toward. Defaults to `"bottom"`, unchanged from
|
|
780
|
+
* every existing call site. Set to `"top"` for a trigger pinned to the bottom of a
|
|
781
|
+
* scrollable region, for example the sidebar's account menu, so the panel opens into the
|
|
782
|
+
* viewport instead of past it.
|
|
783
|
+
*/
|
|
784
|
+
side?: "top" | "bottom";
|
|
785
|
+
}
|
|
786
|
+
declare function Menu({ items, onSelect, label, triggerLabel, triggerIcon, triggerVariant, triggerSize, align, side, }: MenuProps): react.JSX.Element;
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* The metric tile: one label, one number.
|
|
790
|
+
* Canonical source: .docs/styleguide/03-layout.md section 4.2, with the internal spacing
|
|
791
|
+
* added to that section in the same change that added this component.
|
|
792
|
+
*
|
|
793
|
+
* Three states, and the third is the point. A figure the data layer could not produce
|
|
794
|
+
* renders as `Unavailable` with the reason beside it, because a plausible-looking wrong
|
|
795
|
+
* number on a dashboard is worse than an admitted gap: nobody checks a number that looks
|
|
796
|
+
* right.
|
|
797
|
+
*
|
|
798
|
+
* The value row keeps the `metric-lg` line height in every state, so a tile that is
|
|
799
|
+
* loading, resolved or unavailable occupies the same box and the grid never reflows.
|
|
800
|
+
*/
|
|
801
|
+
interface MetricProps {
|
|
802
|
+
label: string;
|
|
803
|
+
/** Ignored while `loading`, and when `unavailable` is set. */
|
|
804
|
+
value?: string | number;
|
|
805
|
+
/** One line under the value: the unit, or what the number counts. */
|
|
806
|
+
caption?: string;
|
|
807
|
+
icon?: IconName;
|
|
808
|
+
loading?: boolean;
|
|
809
|
+
/** Why there is no figure. Replaces the value and is shown in place of the caption. */
|
|
810
|
+
unavailable?: string;
|
|
811
|
+
className?: string;
|
|
812
|
+
}
|
|
813
|
+
declare function Metric({ label, value, caption, icon, loading, unavailable, className, }: MetricProps): react.JSX.Element;
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* The modal. Canonical source: .docs/styleguide/04-components.md section 12.
|
|
817
|
+
*
|
|
818
|
+
* The visual half is small; the behaviour is the component. Focus lands on the first
|
|
819
|
+
* interactive element, Tab cycles inside the dialog instead of walking out into the page
|
|
820
|
+
* behind it, Escape closes, closing returns focus to whatever opened it, and page scroll
|
|
821
|
+
* is locked while it is open.
|
|
822
|
+
*
|
|
823
|
+
* `dismissible` is false for destructive dialogs, which is what the guide means by "the
|
|
824
|
+
* backdrop click closes a non-destructive dialog only". Escape still works there: a user
|
|
825
|
+
* who wants out must always have a way out, and cancelling a delete is the safe outcome.
|
|
826
|
+
*/
|
|
827
|
+
type ModalWidth = "sm" | "md" | "lg" | "xl";
|
|
828
|
+
interface ModalProps {
|
|
829
|
+
open: boolean;
|
|
830
|
+
onClose: () => void;
|
|
831
|
+
title: string;
|
|
832
|
+
description?: string;
|
|
833
|
+
width?: ModalWidth;
|
|
834
|
+
/** Whether a backdrop click closes. False for destructive confirmations. */
|
|
835
|
+
dismissible?: boolean;
|
|
836
|
+
/** Right-aligned actions: cancel as secondary, confirm last. */
|
|
837
|
+
footer?: ReactNode;
|
|
838
|
+
children?: ReactNode;
|
|
839
|
+
}
|
|
840
|
+
declare function Modal({ open, onClose, title, description, width, dismissible, footer, children, }: ModalProps): react.ReactPortal | null;
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The pagination row under a table.
|
|
844
|
+
* Canonical source: .docs/styleguide/04-components.md section 6.
|
|
845
|
+
*
|
|
846
|
+
* Cursor paging, not page numbers: the guide rules numbered page lists out, and Firestore
|
|
847
|
+
* cannot answer "page 7" anyway without reading the six pages before it. Previous and next
|
|
848
|
+
* are therefore the only moves, and the caller holds the cursors.
|
|
849
|
+
*
|
|
850
|
+
* The range reads "1-25 of 248" when the total is known and "1-25" when it is not. A total
|
|
851
|
+
* is a separate count read that can be refused on its own, and inventing one would mislead
|
|
852
|
+
* the reader about how much is behind the list.
|
|
853
|
+
*/
|
|
854
|
+
interface PaginationProps {
|
|
855
|
+
/** 1-based index of the first row on this page. */
|
|
856
|
+
from: number;
|
|
857
|
+
/** 1-based index of the last row on this page. */
|
|
858
|
+
to: number;
|
|
859
|
+
total?: number | undefined;
|
|
860
|
+
hasPrevious: boolean;
|
|
861
|
+
hasNext: boolean;
|
|
862
|
+
onPrevious: () => void;
|
|
863
|
+
onNext: () => void;
|
|
864
|
+
/** Names what is being paged, for the arrows' accessible names. */
|
|
865
|
+
label: string;
|
|
866
|
+
}
|
|
867
|
+
declare function Pagination({ from, to, total, hasPrevious, hasNext, onPrevious, onNext, label, }: PaginationProps): react.JSX.Element;
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* The page header every admin screen opens with.
|
|
871
|
+
* Canonical source: .docs/styleguide/03-layout.md section 3.1.
|
|
872
|
+
*
|
|
873
|
+
* It owns the 24px gap to the first section, so a screen never restates it, and it is the
|
|
874
|
+
* only place the breadcrumb may appear. The title is an `h1`: one per document, and the
|
|
875
|
+
* shell around it contributes none.
|
|
876
|
+
*
|
|
877
|
+
* Title and description are strings rather than nodes. Every later screen is generated
|
|
878
|
+
* from the registry, and a header that accepts arbitrary markup is an invitation for one
|
|
879
|
+
* content type to look different from the rest.
|
|
880
|
+
*/
|
|
881
|
+
interface PageHeaderProps {
|
|
882
|
+
title: string;
|
|
883
|
+
description?: string;
|
|
884
|
+
/** The trail above the title. The last item is the current page. */
|
|
885
|
+
breadcrumb?: readonly BreadcrumbItem[];
|
|
886
|
+
/** Right side. Primary action last, per the guide. */
|
|
887
|
+
actions?: ReactNode;
|
|
888
|
+
className?: string;
|
|
889
|
+
}
|
|
890
|
+
declare function PageHeader({ title, description, breadcrumb, actions, className, }: PageHeaderProps): react.JSX.Element;
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The panel, in the two forms the system has.
|
|
894
|
+
* Canonical source: .docs/styleguide/03-layout.md sections 4.1, 4.2 and 4.3.
|
|
895
|
+
*
|
|
896
|
+
* Standard is the content container; `raised` is the metric tile. Neither carries a
|
|
897
|
+
* shadow, and the raised one always carries `border-subtle`, so a panel edge stays legible
|
|
898
|
+
* against `surface-base`.
|
|
899
|
+
*
|
|
900
|
+
* A panel is the second and last level of container the system allows. Nesting one inside
|
|
901
|
+
* another inside a third is a design error, not a layout choice.
|
|
902
|
+
*/
|
|
903
|
+
type PanelPadding = "none" | "compact" | "standard" | "large";
|
|
904
|
+
interface PanelProps {
|
|
905
|
+
children: ReactNode;
|
|
906
|
+
/**
|
|
907
|
+
* `none` is for a body that supplies its own inset: a table, or a row list separated by
|
|
908
|
+
* `border-subtle`. Padding there would sit on top of the row padding and double it.
|
|
909
|
+
*/
|
|
910
|
+
padding?: PanelPadding;
|
|
911
|
+
/** The raised metric panel from section 4.2. */
|
|
912
|
+
raised?: boolean;
|
|
913
|
+
className?: string;
|
|
914
|
+
}
|
|
915
|
+
declare function Panel({ children, padding, raised, className, }: PanelProps): react.JSX.Element;
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The upload progress bar.
|
|
919
|
+
* Canonical source: .docs/styleguide/04-components.md section 16.
|
|
920
|
+
*
|
|
921
|
+
* Uploads are the only thing in the system that gets one. Everywhere else the wait has no
|
|
922
|
+
* measurable length and a skeleton or a spinner is the honest control.
|
|
923
|
+
*
|
|
924
|
+
* The fill width is an inline style rather than a class, because it is a measured value
|
|
925
|
+
* and not a design decision: there is no token for "43 percent", and writing one as an
|
|
926
|
+
* arbitrary Tailwind class would be exactly the ad-hoc styling the lint rule forbids.
|
|
927
|
+
* Every other value on this element is a token.
|
|
928
|
+
*/
|
|
929
|
+
interface ProgressProps {
|
|
930
|
+
/** 0 to 1. Clamped, because a transferred byte count can briefly exceed the total. */
|
|
931
|
+
value: number;
|
|
932
|
+
/** Names what is progressing, for a reader who cannot see the bar. */
|
|
933
|
+
label: string;
|
|
934
|
+
className?: string;
|
|
935
|
+
}
|
|
936
|
+
declare function Progress({ value, label, className }: ProgressProps): react.JSX.Element;
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Sections and the vertical rhythm between them.
|
|
940
|
+
* Canonical source: .docs/styleguide/03-layout.md sections 3.1 and 3.2.
|
|
941
|
+
*
|
|
942
|
+
* The rhythm is owned in exactly two places: the page header carries the 24px to the first
|
|
943
|
+
* section, and `PageSections` carries the 32px between two sections. A `Section` therefore
|
|
944
|
+
* has no outer margin of its own, which is what stops a screen from adding one and drifting
|
|
945
|
+
* out of the system.
|
|
946
|
+
*/
|
|
947
|
+
declare function PageSections({ children, className, }: {
|
|
948
|
+
children: ReactNode;
|
|
949
|
+
className?: string;
|
|
950
|
+
}): react.JSX.Element;
|
|
951
|
+
interface SectionProps {
|
|
952
|
+
/** Omitted for a section that needs no heading; the rhythm is unchanged either way. */
|
|
953
|
+
heading?: string;
|
|
954
|
+
description?: string;
|
|
955
|
+
/** Section-level controls, right-aligned beside the heading. */
|
|
956
|
+
actions?: ReactNode;
|
|
957
|
+
children: ReactNode;
|
|
958
|
+
className?: string;
|
|
959
|
+
}
|
|
960
|
+
declare function Section({ heading, description, actions, children, className, }: SectionProps): react.JSX.Element;
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The select. Canonical source: .docs/styleguide/04-components.md section 3.3.
|
|
964
|
+
*
|
|
965
|
+
* It is a listbox rather than a native `<select>`, because the guide requires the trigger
|
|
966
|
+
* to match the text input exactly and the open list to be the menu surface from section
|
|
967
|
+
* 11 with a check on the selected option. A native select renders the operating system's
|
|
968
|
+
* popup, which no stylesheet reaches.
|
|
969
|
+
*
|
|
970
|
+
* That trade has a cost: everything the platform gives away for free (focus, typeahead,
|
|
971
|
+
* Escape, activedescendant) has to be implemented here, which is why the keyboard handler
|
|
972
|
+
* is the longest part of the file. A hidden input carries the value so the control still
|
|
973
|
+
* submits inside a plain form.
|
|
974
|
+
*/
|
|
975
|
+
interface SelectOption {
|
|
976
|
+
value: string;
|
|
977
|
+
label: string;
|
|
978
|
+
disabled?: boolean;
|
|
979
|
+
}
|
|
980
|
+
interface SelectProps {
|
|
981
|
+
options: readonly SelectOption[];
|
|
982
|
+
value: string | null;
|
|
983
|
+
onChange: (value: string) => void;
|
|
984
|
+
id?: string;
|
|
985
|
+
name?: string;
|
|
986
|
+
placeholder?: string;
|
|
987
|
+
invalid?: boolean;
|
|
988
|
+
disabled?: boolean;
|
|
989
|
+
"aria-describedby"?: string | undefined;
|
|
990
|
+
"aria-label"?: string | undefined;
|
|
991
|
+
}
|
|
992
|
+
declare function Select({ options, value, onChange, id, name, placeholder, invalid, disabled, "aria-describedby": describedBy, "aria-label": ariaLabel, }: SelectProps): react.JSX.Element;
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* The search field. Canonical source: .docs/styleguide/04-components.md section 3.2,
|
|
996
|
+
* placed by .docs/styleguide/03-layout.md section 5.1.
|
|
997
|
+
*
|
|
998
|
+
* The text input with a leading `search` icon and a trailing clear button that appears
|
|
999
|
+
* once there is something to clear. It holds no state and runs no query: what a search
|
|
1000
|
+
* means is the screen's business, and the screen is the one that has to be honest about
|
|
1001
|
+
* what its query can match.
|
|
1002
|
+
*/
|
|
1003
|
+
interface SearchInputProps {
|
|
1004
|
+
value: string;
|
|
1005
|
+
onChange: (value: string) => void;
|
|
1006
|
+
/** The accessible name. There is no visible label in a toolbar. */
|
|
1007
|
+
label: string;
|
|
1008
|
+
placeholder?: string;
|
|
1009
|
+
disabled?: boolean;
|
|
1010
|
+
/** Id of the caption that states what the search matches. */
|
|
1011
|
+
describedBy?: string | undefined;
|
|
1012
|
+
className?: string;
|
|
1013
|
+
}
|
|
1014
|
+
declare function SearchInput({ value, onChange, label, placeholder, disabled, describedBy, className, }: SearchInputProps): react.JSX.Element;
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* The skeleton. Canonical source: .docs/styleguide/04-components.md section 16.
|
|
1018
|
+
*
|
|
1019
|
+
* Skeletons are the default loading treatment and spinners are the exception, so this
|
|
1020
|
+
* component mirrors the shape of what it replaces rather than offering a generic grey
|
|
1021
|
+
* box: a text line is as tall as its type token's line height, a block carries the radius
|
|
1022
|
+
* of the component it stands in for.
|
|
1023
|
+
*
|
|
1024
|
+
* The pulse is behind `motion-safe`, because a reduced-motion user gets the layout
|
|
1025
|
+
* without the animation, which is the whole benefit of a skeleton anyway.
|
|
1026
|
+
*/
|
|
1027
|
+
type SkeletonLine = "heading-md" | "heading-sm" | "body" | "body-sm" | "label" | "caption" | "micro" | "metric-lg" | "metric-md";
|
|
1028
|
+
declare const RADII: {
|
|
1029
|
+
readonly xs: "rounded-xs";
|
|
1030
|
+
readonly sm: "rounded-sm";
|
|
1031
|
+
readonly md: "rounded-md";
|
|
1032
|
+
readonly lg: "rounded-lg";
|
|
1033
|
+
readonly full: "rounded-full";
|
|
1034
|
+
};
|
|
1035
|
+
interface SkeletonProps {
|
|
1036
|
+
/** Renders a text line at the given type token's line height and `radius-xs`. */
|
|
1037
|
+
line?: SkeletonLine;
|
|
1038
|
+
/** Ignored when `line` is set, which always uses `radius-xs`. */
|
|
1039
|
+
radius?: keyof typeof RADII;
|
|
1040
|
+
/** Width and, for a block, height. Token utilities only. */
|
|
1041
|
+
className?: string;
|
|
1042
|
+
/**
|
|
1043
|
+
* Announces the wait. Set it once per loading region, not once per skeleton, or a
|
|
1044
|
+
* screen reader reads "Loading" for every line on the page.
|
|
1045
|
+
*/
|
|
1046
|
+
label?: string;
|
|
1047
|
+
}
|
|
1048
|
+
declare function Skeleton({ line, radius, className, label }: SkeletonProps): react.JSX.Element;
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* The only spinner in the system.
|
|
1052
|
+
*
|
|
1053
|
+
* A spinner is allowed where the final structure is unknown or the wait sits inside a
|
|
1054
|
+
* control: skeletons cover everything else. See .docs/styleguide/04-components.md
|
|
1055
|
+
* section 16.
|
|
1056
|
+
*/
|
|
1057
|
+
interface SpinnerProps {
|
|
1058
|
+
/** 14px inside a field, 16px inside a button. The guide allows no other size. */
|
|
1059
|
+
size?: 14 | 16;
|
|
1060
|
+
label?: string;
|
|
1061
|
+
}
|
|
1062
|
+
declare function Spinner({ size, label }: SpinnerProps): react.JSX.Element;
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* The switch. Canonical source: .docs/styleguide/04-components.md section 3.6.
|
|
1066
|
+
*
|
|
1067
|
+
* Use it only for a boolean that takes effect the moment it is flipped. A boolean that is
|
|
1068
|
+
* saved with a form is a checkbox, because a switch promises the change already happened
|
|
1069
|
+
* and a pending form has not.
|
|
1070
|
+
*
|
|
1071
|
+
* It is a button with `role="switch"`, not a checkbox: screen readers announce "on" and
|
|
1072
|
+
* "off" for a switch and "checked" for a checkbox, and the difference is the whole point
|
|
1073
|
+
* of the control.
|
|
1074
|
+
*/
|
|
1075
|
+
interface SwitchProps {
|
|
1076
|
+
checked: boolean;
|
|
1077
|
+
onChange: (checked: boolean) => void;
|
|
1078
|
+
/** The accessible name. Required, since the track carries no text. */
|
|
1079
|
+
label: string;
|
|
1080
|
+
/** Renders `label` next to the track. Leave false where a field label already names it. */
|
|
1081
|
+
showLabel?: boolean;
|
|
1082
|
+
disabled?: boolean;
|
|
1083
|
+
id?: string;
|
|
1084
|
+
"aria-describedby"?: string | undefined;
|
|
1085
|
+
}
|
|
1086
|
+
declare function Switch({ checked, onChange, label, showLabel, disabled, id, "aria-describedby": describedBy, }: SwitchProps): react.JSX.Element;
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* The multi-line text control.
|
|
1090
|
+
* Canonical source: .docs/styleguide/04-components.md section 3.2.
|
|
1091
|
+
*
|
|
1092
|
+
* It repeats the input's box rather than reusing `INPUT_BASE`, because the guide gives the
|
|
1093
|
+
* textarea a different height, block padding and resize behaviour, and a shared base string
|
|
1094
|
+
* plus four overrides is harder to read than the eight declarations it replaces.
|
|
1095
|
+
*
|
|
1096
|
+
* Resize is vertical only: a control that can be dragged wider than its column breaks the
|
|
1097
|
+
* form grid, and no editor has ever wanted that.
|
|
1098
|
+
*/
|
|
1099
|
+
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
1100
|
+
invalid?: boolean;
|
|
1101
|
+
}
|
|
1102
|
+
declare function Textarea({ invalid, className, readOnly, rows, ...rest }: TextareaProps): react.JSX.Element;
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* The table shell. Canonical source: .docs/styleguide/04-components.md section 5.
|
|
1106
|
+
*
|
|
1107
|
+
* A shell, not a data grid: it fixes geometry, density and state styling, and knows
|
|
1108
|
+
* nothing about content types, sorting logic or selection logic. The list view generated
|
|
1109
|
+
* from the registry owns all of that and composes these parts.
|
|
1110
|
+
*
|
|
1111
|
+
* Density is fixed by the guide, so there is no size prop and no comfortable/compact
|
|
1112
|
+
* toggle. The table carries no outer border either: it lives inside a panel and shares
|
|
1113
|
+
* the panel's edge.
|
|
1114
|
+
*/
|
|
1115
|
+
interface TableProps {
|
|
1116
|
+
children: ReactNode;
|
|
1117
|
+
/** Names the table for screen readers when no caption is visible. */
|
|
1118
|
+
label?: string;
|
|
1119
|
+
className?: string;
|
|
1120
|
+
}
|
|
1121
|
+
declare function Table({ children, label, className }: TableProps): react.JSX.Element;
|
|
1122
|
+
declare function TableHead({ children }: {
|
|
1123
|
+
children: ReactNode;
|
|
1124
|
+
}): react.JSX.Element;
|
|
1125
|
+
declare function TableBody({ children }: {
|
|
1126
|
+
children: ReactNode;
|
|
1127
|
+
}): react.JSX.Element;
|
|
1128
|
+
type SortDirection = "asc" | "desc";
|
|
1129
|
+
interface TableHeaderCellProps extends Omit<ThHTMLAttributes<HTMLTableCellElement>, "onClick"> {
|
|
1130
|
+
children?: ReactNode;
|
|
1131
|
+
/** Numeric columns are right-aligned so their cells line up under the header. */
|
|
1132
|
+
numeric?: boolean;
|
|
1133
|
+
/** Fixed-width columns: the selection cell and the action cell. */
|
|
1134
|
+
width?: "selection" | "action";
|
|
1135
|
+
/** Passing this makes the header a sort button. `null` is the unsorted state. */
|
|
1136
|
+
sort?: {
|
|
1137
|
+
direction: SortDirection | null;
|
|
1138
|
+
onSort: () => void;
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
declare function TableHeaderCell({ children, numeric, width, sort, className, ...rest }: TableHeaderCellProps): react.JSX.Element;
|
|
1142
|
+
interface TableRowProps {
|
|
1143
|
+
children: ReactNode;
|
|
1144
|
+
selected?: boolean;
|
|
1145
|
+
disabled?: boolean;
|
|
1146
|
+
/** Set only when the whole row performs one action; it is what earns the pointer cursor. */
|
|
1147
|
+
onActivate?: () => void;
|
|
1148
|
+
className?: string;
|
|
1149
|
+
}
|
|
1150
|
+
declare function TableRow({ children, selected, disabled, onActivate, className, }: TableRowProps): react.JSX.Element;
|
|
1151
|
+
interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
|
|
1152
|
+
children?: ReactNode;
|
|
1153
|
+
/** The title column: brighter and medium weight. */
|
|
1154
|
+
primary?: boolean;
|
|
1155
|
+
/** Right-aligned, tabular figures. */
|
|
1156
|
+
numeric?: boolean;
|
|
1157
|
+
/** Muted, tabular figures. */
|
|
1158
|
+
date?: boolean;
|
|
1159
|
+
width?: "selection" | "action";
|
|
1160
|
+
}
|
|
1161
|
+
declare function TableCell({ children, primary, numeric, date, width, className, ...rest }: TableCellProps): react.JSX.Element;
|
|
1162
|
+
/** The empty state spanning the whole table body. */
|
|
1163
|
+
declare function TableEmptyRow({ colSpan, children, }: {
|
|
1164
|
+
colSpan: number;
|
|
1165
|
+
children: ReactNode;
|
|
1166
|
+
}): react.JSX.Element;
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Toasts. Canonical source: .docs/styleguide/04-components.md section 14.
|
|
1170
|
+
*
|
|
1171
|
+
* A provider plus a hook rather than a component a screen renders, because the thing that
|
|
1172
|
+
* raises a toast is usually a save handler three levels down from the region that shows
|
|
1173
|
+
* it, and threading state up through those levels is how toast stacks end up duplicated.
|
|
1174
|
+
*
|
|
1175
|
+
* Error toasts do not auto-dismiss and are announced as alerts. Everything else disappears
|
|
1176
|
+
* after five seconds and is announced politely, so a background save does not interrupt
|
|
1177
|
+
* what the user is reading.
|
|
1178
|
+
*/
|
|
1179
|
+
type ToastTone = "success" | "warning" | "error" | "info";
|
|
1180
|
+
interface ToastOptions {
|
|
1181
|
+
title: string;
|
|
1182
|
+
description?: string;
|
|
1183
|
+
tone?: ToastTone;
|
|
1184
|
+
}
|
|
1185
|
+
interface ToastApi {
|
|
1186
|
+
/** Returns the id, so a long-running action can dismiss its own toast when it finishes. */
|
|
1187
|
+
notify: (options: ToastOptions) => string;
|
|
1188
|
+
dismiss: (id: string) => void;
|
|
1189
|
+
}
|
|
1190
|
+
declare function useToast(): ToastApi;
|
|
1191
|
+
/**
|
|
1192
|
+
* The toast API, or null when no provider is mounted.
|
|
1193
|
+
*
|
|
1194
|
+
* For a screen where a toast is the secondary channel rather than the message itself: the
|
|
1195
|
+
* entry editor renders every failure it can attribute on the field that owns it and every
|
|
1196
|
+
* other failure at form level, and raises a toast on top of that. A host that forgot the
|
|
1197
|
+
* provider should lose the toast, not the screen.
|
|
1198
|
+
*/
|
|
1199
|
+
declare function useOptionalToast(): ToastApi | null;
|
|
1200
|
+
declare function ToastProvider({ children }: {
|
|
1201
|
+
children: ReactNode;
|
|
1202
|
+
}): react.JSX.Element;
|
|
1203
|
+
|
|
1204
|
+
interface TooltipProps {
|
|
1205
|
+
content: string;
|
|
1206
|
+
/** Placed above by default; `bottom` is for triggers near the top edge of the viewport. */
|
|
1207
|
+
placement?: "top" | "bottom";
|
|
1208
|
+
/** A single element that can receive `aria-describedby`, normally a button. */
|
|
1209
|
+
children: ReactElement<{
|
|
1210
|
+
"aria-describedby"?: string | undefined;
|
|
1211
|
+
}>;
|
|
1212
|
+
}
|
|
1213
|
+
declare function Tooltip({ content, placement, children }: TooltipProps): react.JSX.Element;
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* The unsaved-changes guard.
|
|
1217
|
+
*
|
|
1218
|
+
* Leaving an editor with unsaved work has two halves and they are two different
|
|
1219
|
+
* mechanisms, so they are written separately here rather than pretended to be one thing:
|
|
1220
|
+
*
|
|
1221
|
+
* - **Closing the tab or reloading** is `beforeunload`. The browser owns the wording, the
|
|
1222
|
+
* buttons and whether it asks at all; all a page can do is say that it has something to
|
|
1223
|
+
* lose. It is attached only while the form is dirty, because a listener that is always
|
|
1224
|
+
* attached makes every reload of a clean page ask a question nobody needed.
|
|
1225
|
+
* - **Navigating inside the admin** is ours. A sidebar link, the cancel button and any
|
|
1226
|
+
* other in-app route change are intercepted, the user is asked in the admin's own
|
|
1227
|
+
* dialog, and the navigation is resumed or dropped by their answer.
|
|
1228
|
+
*
|
|
1229
|
+
* The done criterion this exists to satisfy is exact: leaving a dirty form warns, leaving
|
|
1230
|
+
* a clean form does not. Which means a save has to make the warning stop. It does, because
|
|
1231
|
+
* `dirty` is a comparison against the values the adapter confirmed (`valuesChanged` in
|
|
1232
|
+
* core/entry-form.ts) and not a flag somebody has to remember to clear: the save writes
|
|
1233
|
+
* the new baseline, the comparison goes false, and both halves detach on the same render.
|
|
1234
|
+
*/
|
|
1235
|
+
/**
|
|
1236
|
+
* Warns before the tab closes or reloads while there is unsaved work.
|
|
1237
|
+
*
|
|
1238
|
+
* Standalone, so an editor still guards the unload path in a host that mounted no
|
|
1239
|
+
* provider. The listener exists only while `dirty` is true.
|
|
1240
|
+
*/
|
|
1241
|
+
declare function useBeforeUnloadWarning(dirty: boolean): void;
|
|
1242
|
+
interface UnsavedChangesProviderProps {
|
|
1243
|
+
/**
|
|
1244
|
+
* Resumes an intercepted link navigation. The admin cannot know the host's router, and
|
|
1245
|
+
* a host that supplies none falls back to a full document load, which is correct but
|
|
1246
|
+
* slower. The playground passes `router.push`.
|
|
1247
|
+
*/
|
|
1248
|
+
navigate?: ((href: string) => void) | undefined;
|
|
1249
|
+
children: ReactNode;
|
|
1250
|
+
}
|
|
1251
|
+
declare function UnsavedChangesProvider({ navigate, children, }: UnsavedChangesProviderProps): react.JSX.Element;
|
|
1252
|
+
/**
|
|
1253
|
+
* Registers a form's dirty state with the guard, and warns on unload while it is set.
|
|
1254
|
+
*
|
|
1255
|
+
* Called by the editor with the same `dirty` the save bar reads, so the two can never
|
|
1256
|
+
* disagree about whether there is anything to lose. Unmounting clears the registration:
|
|
1257
|
+
* a form that is gone has nothing left unsaved, and leaving the flag set would make every
|
|
1258
|
+
* later navigation ask.
|
|
1259
|
+
*/
|
|
1260
|
+
declare function useUnsavedChanges(dirty: boolean): void;
|
|
1261
|
+
/**
|
|
1262
|
+
* The gate an in-app navigation goes through.
|
|
1263
|
+
*
|
|
1264
|
+
* Without a provider it is the identity: the navigation happens. A host that mounted no
|
|
1265
|
+
* guard gets no dialog, not a broken button.
|
|
1266
|
+
*/
|
|
1267
|
+
declare function useConfirmLeave(): (proceed: () => void) => void;
|
|
1268
|
+
|
|
1269
|
+
/** A database-defined type that failed validation. Surfaced, never silently dropped. */
|
|
1270
|
+
interface InvalidContentType {
|
|
1271
|
+
slug: string;
|
|
1272
|
+
reason: string;
|
|
1273
|
+
definition: unknown;
|
|
1274
|
+
}
|
|
1275
|
+
interface RegistryEntry {
|
|
1276
|
+
type: ContentTypeLike;
|
|
1277
|
+
source: ContentTypeSource;
|
|
1278
|
+
locked: boolean;
|
|
1279
|
+
/**
|
|
1280
|
+
* A database-defined type with the same slug as this code-defined one. Code wins, but the
|
|
1281
|
+
* database copy is kept so the admin can show the collision instead of silently
|
|
1282
|
+
* discarding what an editor created.
|
|
1283
|
+
*/
|
|
1284
|
+
conflict?: {
|
|
1285
|
+
source: "database";
|
|
1286
|
+
type: ContentTypeLike;
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
interface Registry {
|
|
1290
|
+
readonly types: readonly RegistryEntry[];
|
|
1291
|
+
readonly conflicts: readonly RegistryEntry[];
|
|
1292
|
+
readonly invalid: readonly InvalidContentType[];
|
|
1293
|
+
get(slug: string): RegistryEntry | undefined;
|
|
1294
|
+
require(slug: string): RegistryEntry;
|
|
1295
|
+
has(slug: string): boolean;
|
|
1296
|
+
/** Entries whose winning definition came from this source. */
|
|
1297
|
+
bySource(source: ContentTypeSource): readonly RegistryEntry[];
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* The sidebar as data. Canonical source: .docs/styleguide/03-layout.md section 2.1.
|
|
1302
|
+
*
|
|
1303
|
+
* The whole point of the registry is that nothing in the interface knows the name of a
|
|
1304
|
+
* content type. This builder is the only place navigation is decided, it reads the
|
|
1305
|
+
* registry and `can()`, and it returns plain serializable data so a server component can
|
|
1306
|
+
* compute it and hand it to the client shell. Adding a content type therefore changes
|
|
1307
|
+
* data and nothing else: no route table, no switch, no new file.
|
|
1308
|
+
*
|
|
1309
|
+
* It is deliberately free of React, so the server entry can export it without pulling a
|
|
1310
|
+
* component into a module graph that never renders one.
|
|
1311
|
+
*/
|
|
1312
|
+
interface AdminNavItem {
|
|
1313
|
+
/** Stable across renders and unique inside the whole navigation. */
|
|
1314
|
+
key: string;
|
|
1315
|
+
label: string;
|
|
1316
|
+
href: string;
|
|
1317
|
+
icon: IconName;
|
|
1318
|
+
/**
|
|
1319
|
+
* Active on an exact path match only. The dashboard owns the admin root, so every
|
|
1320
|
+
* deeper route would otherwise light it up as well.
|
|
1321
|
+
*/
|
|
1322
|
+
exact?: boolean;
|
|
1323
|
+
}
|
|
1324
|
+
interface AdminNavGroup {
|
|
1325
|
+
key: string;
|
|
1326
|
+
/** Absent for the leading group and the static group, which carry no heading. */
|
|
1327
|
+
label?: string;
|
|
1328
|
+
items: readonly AdminNavItem[];
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
interface AdminShellProps {
|
|
1332
|
+
navigation: readonly AdminNavGroup[];
|
|
1333
|
+
user: AuthUser;
|
|
1334
|
+
/** The sidebar footer's menu trigger. The host owns it: the session is the host's. */
|
|
1335
|
+
accountMenu?: ReactNode;
|
|
1336
|
+
children: ReactNode;
|
|
1337
|
+
}
|
|
1338
|
+
declare function AdminShell({ navigation, user, accountMenu, children }: AdminShellProps): react.JSX.Element;
|
|
1339
|
+
|
|
1340
|
+
interface AdminSidebarProps {
|
|
1341
|
+
navigation: readonly AdminNavGroup[];
|
|
1342
|
+
/** The route the browser is on, for the active item. */
|
|
1343
|
+
pathname: string;
|
|
1344
|
+
user: AuthUser;
|
|
1345
|
+
/** The footer row's menu trigger. The host owns it: signing out is the host's session. */
|
|
1346
|
+
accountMenu?: ReactNode;
|
|
1347
|
+
/** Display and position only. The shell decides whether this is a grid column or a drawer. */
|
|
1348
|
+
className?: string;
|
|
1349
|
+
/** Set while the sidebar is the open drawer, so Escape and Tab can be handled there. */
|
|
1350
|
+
panelRef?: Ref<HTMLElement>;
|
|
1351
|
+
/** Called when a navigation item is followed, so the drawer can close behind it. */
|
|
1352
|
+
onNavigate?: () => void;
|
|
1353
|
+
/** Set while the sidebar is the open drawer, to keep Tab inside it. */
|
|
1354
|
+
onKeyDown?: (event: KeyboardEvent<HTMLElement>) => void;
|
|
1355
|
+
}
|
|
1356
|
+
declare function AdminSidebar({ navigation, pathname, user, accountMenu, className, panelRef, onNavigate, onKeyDown, }: AdminSidebarProps): react.JSX.Element;
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* The seam between the CMS and its storage.
|
|
1360
|
+
*
|
|
1361
|
+
* Everything above this interface (the admin, the SDK, the registry, the validator) is
|
|
1362
|
+
* written against `CmsAdapter` and knows nothing about Firestore. Everything below it lives
|
|
1363
|
+
* under `src/firebase/**`, which is the only place the Firebase SDK may be imported. If a
|
|
1364
|
+
* screen or an SDK method needs something this interface does not offer, the interface
|
|
1365
|
+
* grows; it is never bypassed.
|
|
1366
|
+
*
|
|
1367
|
+
* Values crossing this boundary are already in their read shape: timestamps are ISO 8601
|
|
1368
|
+
* strings, references are ids, and field values match what `FieldValues` describes. The
|
|
1369
|
+
* converters on the Firestore side own the translation in both directions.
|
|
1370
|
+
*/
|
|
1371
|
+
type FilterOperator = "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "not-in" | "array-contains" | "array-contains-any";
|
|
1372
|
+
interface EntryFilter {
|
|
1373
|
+
field: string;
|
|
1374
|
+
op: FilterOperator;
|
|
1375
|
+
value: unknown;
|
|
1376
|
+
}
|
|
1377
|
+
interface SortSpec {
|
|
1378
|
+
field: string;
|
|
1379
|
+
direction: "asc" | "desc";
|
|
1380
|
+
}
|
|
1381
|
+
interface ListQuery {
|
|
1382
|
+
filters?: readonly EntryFilter[];
|
|
1383
|
+
sort?: SortSpec;
|
|
1384
|
+
/**
|
|
1385
|
+
* Free text matched against the content type's `titleField`, as a prefix.
|
|
1386
|
+
*
|
|
1387
|
+
* What it does, exactly, because a search box that promises more than it delivers is
|
|
1388
|
+
* worse than one that says what it is: the adapter turns the term into a range scan
|
|
1389
|
+
* (`>= term`, `<= term + U+F8FF`) over the title field. That matches from the beginning
|
|
1390
|
+
* of the title and it is case sensitive, because Firestore compares strings by their
|
|
1391
|
+
* UTF-8 bytes and offers no substring, no fuzzy and no case-insensitive operator.
|
|
1392
|
+
* "Portfolio" is found by "Port", not by "port" and not by "folio".
|
|
1393
|
+
*
|
|
1394
|
+
* Two consequences a caller has to know about:
|
|
1395
|
+
*
|
|
1396
|
+
* - A range filter forces the first ordering onto the field it ranges over, so a search
|
|
1397
|
+
* is always ordered by title ascending and `sort` is ignored while one is active.
|
|
1398
|
+
* - A content type with no `titleField` has nothing to match against, so the adapter
|
|
1399
|
+
* rejects the query instead of silently returning everything. Ask `canSearch()` in
|
|
1400
|
+
* core/list-view.ts before offering the box.
|
|
1401
|
+
*/
|
|
1402
|
+
search?: string;
|
|
1403
|
+
/** Page size. The adapter caps it; see DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE. */
|
|
1404
|
+
limit?: number;
|
|
1405
|
+
/** Opaque cursor from a previous page. Never constructed by a caller. */
|
|
1406
|
+
cursor?: string | null;
|
|
1407
|
+
/**
|
|
1408
|
+
* `"any"` reads every status and is the admin default. The public SDK passes
|
|
1409
|
+
* `"published"`, which is what keeps drafts off a customer's website.
|
|
1410
|
+
*/
|
|
1411
|
+
status?: EntryStatus | "any";
|
|
1412
|
+
}
|
|
1413
|
+
interface Page<TItem> {
|
|
1414
|
+
items: readonly TItem[];
|
|
1415
|
+
/** Pass back as `ListQuery.cursor` to read the next page. Null when the page is the last. */
|
|
1416
|
+
cursor: string | null;
|
|
1417
|
+
hasMore: boolean;
|
|
1418
|
+
}
|
|
1419
|
+
interface MediaQuery {
|
|
1420
|
+
limit?: number;
|
|
1421
|
+
cursor?: string | null;
|
|
1422
|
+
/** MIME prefix, for example `image/`. */
|
|
1423
|
+
type?: string;
|
|
1424
|
+
}
|
|
1425
|
+
interface MediaInput {
|
|
1426
|
+
name: string;
|
|
1427
|
+
type: string;
|
|
1428
|
+
size: number;
|
|
1429
|
+
url: string;
|
|
1430
|
+
path: string;
|
|
1431
|
+
alt?: string | null;
|
|
1432
|
+
width?: number | null;
|
|
1433
|
+
height?: number | null;
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* What the media library occupies, summed from the metadata documents.
|
|
1437
|
+
*
|
|
1438
|
+
* `bytes` is the sum of the `size` recorded on each media document, not a reading of the
|
|
1439
|
+
* Storage bucket itself. Everything Husk uploads records its size, so the two agree; an
|
|
1440
|
+
* object placed in the bucket by something other than Husk is not counted, because Husk
|
|
1441
|
+
* has no document for it. Stated rather than hidden: a dashboard figure that silently
|
|
1442
|
+
* means something narrower than its label is worse than no figure.
|
|
1443
|
+
*/
|
|
1444
|
+
interface MediaUsage {
|
|
1445
|
+
/** Media documents. */
|
|
1446
|
+
count: number;
|
|
1447
|
+
/** Total bytes across those documents. */
|
|
1448
|
+
bytes: number;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* One entry that references a media item.
|
|
1452
|
+
*
|
|
1453
|
+
* `title` is what the warning prints. It is null when the entry's content type declares no
|
|
1454
|
+
* title field, or when the value is empty: the dialog then prints the id, which is the one
|
|
1455
|
+
* name that is always correct.
|
|
1456
|
+
*/
|
|
1457
|
+
interface MediaUsageReference {
|
|
1458
|
+
contentType: string;
|
|
1459
|
+
entryId: string;
|
|
1460
|
+
title: string | null;
|
|
1461
|
+
}
|
|
1462
|
+
interface MediaUsageQuery {
|
|
1463
|
+
/** How many referencing entries to name. Defaults to MEDIA_USAGE_LIMIT. */
|
|
1464
|
+
limit?: number;
|
|
1465
|
+
}
|
|
1466
|
+
/** What a reference refresh managed to rewrite, and what it did not. */
|
|
1467
|
+
interface MediaRefreshResult {
|
|
1468
|
+
updated: number;
|
|
1469
|
+
/** Entries whose denormalized url could not be rewritten. Named, never swallowed. */
|
|
1470
|
+
failed: readonly MediaUsageReference[];
|
|
1471
|
+
}
|
|
1472
|
+
interface MediaAdapter {
|
|
1473
|
+
list(query?: MediaQuery): Promise<Page<MediaItem>>;
|
|
1474
|
+
getById(mediaId: string): Promise<MediaItem | null>;
|
|
1475
|
+
getMany(mediaIds: readonly string[]): Promise<readonly MediaItem[]>;
|
|
1476
|
+
/**
|
|
1477
|
+
* Allocates a media id without writing anything.
|
|
1478
|
+
*
|
|
1479
|
+
* The upload path needs the id before the document exists, because the Storage object
|
|
1480
|
+
* path contains it and the bytes are written first. Nothing is reserved on the server:
|
|
1481
|
+
* the id is generated locally, and an id that is never used costs nothing.
|
|
1482
|
+
*/
|
|
1483
|
+
reserveId(): string;
|
|
1484
|
+
/**
|
|
1485
|
+
* Writes the metadata document. With `mediaId`, it is written at that id, which is how
|
|
1486
|
+
* an upload's bytes and its document end up naming each other.
|
|
1487
|
+
*/
|
|
1488
|
+
create(input: MediaInput, mediaId?: string): Promise<MediaItem>;
|
|
1489
|
+
update(mediaId: string, patch: Partial<MediaInput>): Promise<MediaItem>;
|
|
1490
|
+
delete(mediaId: string): Promise<void>;
|
|
1491
|
+
/**
|
|
1492
|
+
* Entries referencing this media item, so a delete can warn instead of breaking pages.
|
|
1493
|
+
*
|
|
1494
|
+
* Answered from the `mediaRefs` reverse index each entry write maintains, not from a
|
|
1495
|
+
* scan: see core/media-references.ts for what that costs and where it is blind.
|
|
1496
|
+
*/
|
|
1497
|
+
usages(mediaId: string, query?: MediaUsageQuery): Promise<readonly MediaUsageReference[]>;
|
|
1498
|
+
/**
|
|
1499
|
+
* Rewrites the denormalized `url` on every entry that references this media item.
|
|
1500
|
+
*
|
|
1501
|
+
* Only replace needs it. Overwriting the bytes of an existing item mints a new download
|
|
1502
|
+
* URL, and the copy of the old one denormalized into each entry would otherwise stay
|
|
1503
|
+
* until that entry is next saved. `mediaId` remains the source of truth either way, so a
|
|
1504
|
+
* refresh that fails degrades a stale convenience rather than breaking a reference.
|
|
1505
|
+
*/
|
|
1506
|
+
refreshReferences(mediaId: string, url: string): Promise<MediaRefreshResult>;
|
|
1507
|
+
/**
|
|
1508
|
+
* Count and total size of the whole library, for the dashboard's storage figure.
|
|
1509
|
+
* One aggregate read, not a walk over every page of media.
|
|
1510
|
+
*/
|
|
1511
|
+
usage(): Promise<MediaUsage>;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Schema operations act on `schemas/`, never on entries. A locked or code-defined type is
|
|
1515
|
+
* rejected here as well as in the UI, so bypassing the Schema Builder changes nothing.
|
|
1516
|
+
*/
|
|
1517
|
+
interface SchemaAdapter {
|
|
1518
|
+
list(): Promise<readonly ContentTypeLike[]>;
|
|
1519
|
+
get(slug: string): Promise<ContentTypeLike | null>;
|
|
1520
|
+
create(type: ContentTypeLike): Promise<ContentTypeLike>;
|
|
1521
|
+
update(slug: string, type: ContentTypeLike): Promise<ContentTypeLike>;
|
|
1522
|
+
delete(slug: string): Promise<void>;
|
|
1523
|
+
/**
|
|
1524
|
+
* Live view of every database-defined type, so the sidebar and the routes follow a
|
|
1525
|
+
* structure change without a rebuild and without a reload.
|
|
1526
|
+
*
|
|
1527
|
+
* One listener for the whole collection, feeding `RegistryStore.setDatabaseTypes`. What
|
|
1528
|
+
* it costs is written down on that interface. `onError` is called rather than thrown,
|
|
1529
|
+
* because a listener that dies silently leaves an admin quietly stale.
|
|
1530
|
+
*/
|
|
1531
|
+
watch(listener: (types: readonly ContentTypeLike[]) => void, onError?: (error: Error) => void): () => void;
|
|
1532
|
+
}
|
|
1533
|
+
interface FieldPurgeOptions {
|
|
1534
|
+
/** How many entries to clear in one call. Capped at MAX_FIELD_PURGE. */
|
|
1535
|
+
limit?: number;
|
|
1536
|
+
}
|
|
1537
|
+
interface FieldPurgeResult {
|
|
1538
|
+
/** Entries whose stored value was deleted by this call. */
|
|
1539
|
+
purged: number;
|
|
1540
|
+
/** True when the cap was hit and another call is needed to finish. */
|
|
1541
|
+
remaining: boolean;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* The stored values of a **removed** field, which is the only data the Schema Builder is
|
|
1545
|
+
* ever allowed to reach.
|
|
1546
|
+
*
|
|
1547
|
+
* It is a separate interface, and not two more methods on `SchemaAdapter`, so the M8
|
|
1548
|
+
* guardrail is structural rather than a comment: `SchemaAdapter` has no method that can
|
|
1549
|
+
* touch an entry, and this one refuses any key the content type still declares. The Schema
|
|
1550
|
+
* Builder writes schemas; purging is a different, separately named, separately confirmed
|
|
1551
|
+
* operation that happens to be reachable from the same screen.
|
|
1552
|
+
*/
|
|
1553
|
+
interface FieldDataAdapter {
|
|
1554
|
+
/**
|
|
1555
|
+
* How many entries of this content type hold a non-null value for this top-level field
|
|
1556
|
+
* key, so the removal warning can say "14 entries" rather than "some entries".
|
|
1557
|
+
*
|
|
1558
|
+
* One count aggregation, not a walk: `where(key, "!=", null)` plus `count()`. Firestore
|
|
1559
|
+
* indexes every top-level field automatically, so no composite index is needed, and an
|
|
1560
|
+
* aggregation is billed per thousand matched index entries rather than per document.
|
|
1561
|
+
*
|
|
1562
|
+
* Two limits, stated rather than hidden. A key nested inside a repeater row is not a
|
|
1563
|
+
* top-level field and cannot be counted this way, so `count` rejects one instead of
|
|
1564
|
+
* answering zero. And an entry written before the field existed simply has no such key,
|
|
1565
|
+
* which Firestore treats as not matching, which is the answer the warning wants.
|
|
1566
|
+
*/
|
|
1567
|
+
count(slug: string, key: string): Promise<number>;
|
|
1568
|
+
/**
|
|
1569
|
+
* Deletes the stored values of a removed field. The only destructive schema operation.
|
|
1570
|
+
*
|
|
1571
|
+
* Refused unless the content type lists the key under `removedFields`: a key that is
|
|
1572
|
+
* still live, or that the type has never heard of, is never purged. Admin only, and
|
|
1573
|
+
* bounded per call.
|
|
1574
|
+
*/
|
|
1575
|
+
purge(slug: string, key: string, options?: FieldPurgeOptions): Promise<FieldPurgeResult>;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* One stored entry as the migration runner sees it: its id, the version it is stamped at,
|
|
1579
|
+
* and its values in their stored form.
|
|
1580
|
+
*
|
|
1581
|
+
* Deliberately not an `Entry`. Reading an entry runs it through the content type's field
|
|
1582
|
+
* specs, and the whole point of a migration is that the stored document does not match
|
|
1583
|
+
* those specs yet, so converting it first would either fail or quietly drop the values the
|
|
1584
|
+
* migration was written to move.
|
|
1585
|
+
*/
|
|
1586
|
+
interface StoredEntryRecord {
|
|
1587
|
+
id: string;
|
|
1588
|
+
schemaVersion: number;
|
|
1589
|
+
values: Record<string, unknown>;
|
|
1590
|
+
}
|
|
1591
|
+
interface MigrationWrite {
|
|
1592
|
+
/** The stored version the transform read. The write is refused if it has moved. */
|
|
1593
|
+
expectedVersion: number;
|
|
1594
|
+
nextVersion: number;
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* The storage half of the migration runner. See core/migrations.ts for the runner itself.
|
|
1598
|
+
*
|
|
1599
|
+
* Kept off `SchemaAdapter` for the same reason `FieldDataAdapter` is: a schema operation
|
|
1600
|
+
* must not be able to reach an entry. A migration reaches every entry of a type, which is
|
|
1601
|
+
* precisely why it is a separately named, separately permissioned port rather than two
|
|
1602
|
+
* more methods on the interface the Schema Builder holds.
|
|
1603
|
+
*/
|
|
1604
|
+
interface MigrationAdapter {
|
|
1605
|
+
/** How many entries are stamped below the target version. One aggregation. */
|
|
1606
|
+
countBehind(slug: string, targetVersion: number): Promise<number>;
|
|
1607
|
+
/** A page of entries stamped below the target version, oldest version first. */
|
|
1608
|
+
listBehind(slug: string, targetVersion: number, query?: {
|
|
1609
|
+
limit?: number;
|
|
1610
|
+
}): Promise<Page<StoredEntryRecord>>;
|
|
1611
|
+
/**
|
|
1612
|
+
* Writes migrated values and restamps the entry, but only while the stored version is
|
|
1613
|
+
* still the one the transform read.
|
|
1614
|
+
*
|
|
1615
|
+
* Returns false when it has moved, which is a second runner having got there first and
|
|
1616
|
+
* is not an error. That conditional is what makes a migration safe to run twice and safe
|
|
1617
|
+
* to run from two places at once.
|
|
1618
|
+
*/
|
|
1619
|
+
applyMigration(slug: string, entryId: string, values: Record<string, unknown>, write: MigrationWrite): Promise<boolean>;
|
|
1620
|
+
}
|
|
1621
|
+
interface SettingsAdapter {
|
|
1622
|
+
get(key: string): Promise<Record<string, unknown> | null>;
|
|
1623
|
+
set(key: string, value: Record<string, unknown>): Promise<void>;
|
|
1624
|
+
}
|
|
1625
|
+
interface EntryInputData {
|
|
1626
|
+
/** Field values, already validated by the caller and validated again by the adapter. */
|
|
1627
|
+
values: Record<string, unknown>;
|
|
1628
|
+
status?: EntryStatus;
|
|
1629
|
+
}
|
|
1630
|
+
interface CmsAdapter {
|
|
1631
|
+
list(slug: string, query?: ListQuery): Promise<Page<Entry>>;
|
|
1632
|
+
get(slug: string, entryId: string): Promise<Entry | null>;
|
|
1633
|
+
/**
|
|
1634
|
+
* Looks an entry up by the value of the content type's `slug` field, not by document id.
|
|
1635
|
+
*
|
|
1636
|
+
* `status` narrows the lookup at the storage layer rather than after it, and on a public
|
|
1637
|
+
* website that is not an optimization. A visitor's read is unauthenticated, and the
|
|
1638
|
+
* security rules only let an unauthenticated query through when the query itself says
|
|
1639
|
+
* `status == "published"`; a lookup that asked for any status and filtered afterwards
|
|
1640
|
+
* would be refused outright. Defaults to `"any"`, which is the admin's uniqueness check.
|
|
1641
|
+
*/
|
|
1642
|
+
getBySlug(slug: string, value: string, options?: Pick<ListQuery, "status">): Promise<Entry | null>;
|
|
1643
|
+
/** Reads the one entry of a singleton. Rejects a collection. */
|
|
1644
|
+
getSingle(slug: string): Promise<Entry | null>;
|
|
1645
|
+
create(slug: string, input: EntryInputData): Promise<Entry>;
|
|
1646
|
+
update(slug: string, entryId: string, input: EntryInputData): Promise<Entry>;
|
|
1647
|
+
delete(slug: string, entryId: string): Promise<void>;
|
|
1648
|
+
/**
|
|
1649
|
+
* How many entries the same narrowing would return. Takes `search` as well, so the
|
|
1650
|
+
* list view's "1-25 of 248" counts the rows the reader is actually paging through.
|
|
1651
|
+
*/
|
|
1652
|
+
count(slug: string, query?: Pick<ListQuery, "filters" | "status" | "search">): Promise<number>;
|
|
1653
|
+
readonly media: MediaAdapter;
|
|
1654
|
+
readonly schemas: SchemaAdapter;
|
|
1655
|
+
readonly fieldData: FieldDataAdapter;
|
|
1656
|
+
readonly migrations: MigrationAdapter;
|
|
1657
|
+
readonly settings: SettingsAdapter;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* What a collection list is, derived from a content type.
|
|
1662
|
+
*
|
|
1663
|
+
* The screen renders this; it never decides it. Columns, the sortable set, the filter
|
|
1664
|
+
* controls and the query that reads a page are all computed here from the schema, which is
|
|
1665
|
+
* what makes a new content type a data change and not a screen change.
|
|
1666
|
+
*
|
|
1667
|
+
* Nothing in this file imports React, Firebase or the field registry, so it is domain
|
|
1668
|
+
* logic: it runs in the node test project, in the SDK, and in a server component.
|
|
1669
|
+
*/
|
|
1670
|
+
/** How a column's cells are typeset. The cell itself never inspects the field type. */
|
|
1671
|
+
type ColumnAlign = "text" | "numeric" | "date";
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Bytes as a short human-readable string: `0 B`, `940 B`, `1.5 KB`, `12 MB`.
|
|
1675
|
+
*
|
|
1676
|
+
* One decimal below ten so a small library does not round to a flat `1 MB`, none above it
|
|
1677
|
+
* where the extra digit is noise in a dashboard tile.
|
|
1678
|
+
*/
|
|
1679
|
+
declare function formatBytes(bytes: number): string;
|
|
1680
|
+
/**
|
|
1681
|
+
* An ISO 8601 timestamp as a date and time.
|
|
1682
|
+
*
|
|
1683
|
+
* An unparsable value comes back unchanged instead of as `Invalid Date`: a timestamp the
|
|
1684
|
+
* adapter could not convert is a data problem, and showing the raw value is what lets
|
|
1685
|
+
* somebody diagnose it.
|
|
1686
|
+
*/
|
|
1687
|
+
declare function formatDateTime(iso: string): string;
|
|
1688
|
+
/**
|
|
1689
|
+
* A date value as a reader sees it, at the precision it was stored with.
|
|
1690
|
+
*
|
|
1691
|
+
* A day-only value is formatted as a day: turning `2026-09-18` into a time would invent a
|
|
1692
|
+
* midnight nobody chose, and would move the date across a timezone boundary.
|
|
1693
|
+
*/
|
|
1694
|
+
declare function formatDate(value: string): string;
|
|
1695
|
+
/**
|
|
1696
|
+
* One field value as one table cell, decided by the value it got rather than by the field
|
|
1697
|
+
* type that produced it.
|
|
1698
|
+
*
|
|
1699
|
+
* This is why the list view has no `switch (field.type)`: the column already knows how it
|
|
1700
|
+
* is typeset (core/list-view.ts decides that once, from the schema), and the cell only has
|
|
1701
|
+
* to turn whatever the adapter returned into a line of text. A value the column cannot
|
|
1702
|
+
* render, and a value that was never set, both come back as `null`, which the cell renders
|
|
1703
|
+
* as its "not set" state rather than as a blank nobody can interpret.
|
|
1704
|
+
*/
|
|
1705
|
+
declare function formatCellValue(value: unknown, align: ColumnAlign): string | null;
|
|
1706
|
+
|
|
1707
|
+
interface CollectionListProps {
|
|
1708
|
+
adapter: CmsAdapter;
|
|
1709
|
+
type: ContentTypeLike;
|
|
1710
|
+
/** The signed-in user, for the permission answers behind the actions. */
|
|
1711
|
+
user: AuthUser;
|
|
1712
|
+
/** Opens an entry. Absent means the list is read-only in this host. */
|
|
1713
|
+
onOpen?: (entryId: string) => void;
|
|
1714
|
+
/** Starts a new entry. Shown only when `can()` allows creating one. */
|
|
1715
|
+
onCreate?: () => void;
|
|
1716
|
+
/**
|
|
1717
|
+
* Deletes entries. The list never deletes anything itself: it collects the selection,
|
|
1718
|
+
* checks the permission per entry, and hands the ids to the host's confirmation flow.
|
|
1719
|
+
*/
|
|
1720
|
+
onDelete?: (entryIds: readonly string[]) => void;
|
|
1721
|
+
/**
|
|
1722
|
+
* Re-reads the current page when it changes.
|
|
1723
|
+
*
|
|
1724
|
+
* A delete happens outside this screen (the confirmation dialog is the host's to mount,
|
|
1725
|
+
* because only the host knows where the flow lands), so the list needs a way to be told
|
|
1726
|
+
* that what it is showing is stale. A changed token re-runs the same query with the same
|
|
1727
|
+
* cursor, which is the honest refresh: remounting would silently drop the reader's
|
|
1728
|
+
* search, filters and page.
|
|
1729
|
+
*/
|
|
1730
|
+
refreshToken?: unknown;
|
|
1731
|
+
pageSize?: number;
|
|
1732
|
+
}
|
|
1733
|
+
declare function CollectionList({ adapter, type, user, onOpen, onCreate, onDelete, refreshToken, pageSize, }: CollectionListProps): react.JSX.Element;
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* The destructive confirmation for deleting entries.
|
|
1737
|
+
* Canonical source: .docs/styleguide/04-components.md section 12, "Destructive
|
|
1738
|
+
* confirmation".
|
|
1739
|
+
*
|
|
1740
|
+
* One dialog for every content type and for both the row action and the bulk selection,
|
|
1741
|
+
* because the only thing that differs between them is what is printed and how many
|
|
1742
|
+
* documents the adapter is asked to remove.
|
|
1743
|
+
*
|
|
1744
|
+
* What it is deliberate about:
|
|
1745
|
+
*
|
|
1746
|
+
* - **The phrase.** One entry asks for its name (its title, or its id when it has no
|
|
1747
|
+
* usable title); several ask for the count and the content type. `core/delete-
|
|
1748
|
+
* confirmation.ts` decides both and decides what counts as typing them.
|
|
1749
|
+
* - **The way out.** `dismissible` is false, so a stray backdrop click cannot start a
|
|
1750
|
+
* delete flow's only irreversible step. Escape still closes, because cancelling a
|
|
1751
|
+
* delete is the safe outcome and a user must always have a way out.
|
|
1752
|
+
* - **Partial failure.** Several deletes are several writes, and the second can fail
|
|
1753
|
+
* after the first succeeded. What did go through is reported to the caller anyway, so
|
|
1754
|
+
* the list does not keep showing rows that are gone, and the dialog stays open naming
|
|
1755
|
+
* what did not.
|
|
1756
|
+
*/
|
|
1757
|
+
interface DeleteEntriesDialogProps {
|
|
1758
|
+
adapter: CmsAdapter;
|
|
1759
|
+
type: ContentTypeLike;
|
|
1760
|
+
/** The entries to delete. Empty means the dialog is closed. */
|
|
1761
|
+
entryIds: readonly string[];
|
|
1762
|
+
onCancel: () => void;
|
|
1763
|
+
/**
|
|
1764
|
+
* The ids the adapter actually removed, and whether that was the whole batch.
|
|
1765
|
+
*
|
|
1766
|
+
* A batch is several writes and the second can fail after the first succeeded, so what
|
|
1767
|
+
* did go through is reported either way: the caller drops those rows, and leaves the
|
|
1768
|
+
* dialog open when `complete` is false, because it is still naming what did not.
|
|
1769
|
+
*/
|
|
1770
|
+
onDeleted: (entryIds: readonly string[], complete: boolean) => void;
|
|
1771
|
+
/** The signed-in role, so a refused delete can name who was refused. */
|
|
1772
|
+
role?: Role | undefined;
|
|
1773
|
+
}
|
|
1774
|
+
declare function DeleteEntriesDialog({ adapter, type, entryIds, onCancel, onDeleted, role, }: DeleteEntriesDialogProps): react.JSX.Element | null;
|
|
1775
|
+
/**
|
|
1776
|
+
* Whether this content type may be deleted at all.
|
|
1777
|
+
*
|
|
1778
|
+
* A singleton exists exactly once and its absence is not a state the schema describes, so
|
|
1779
|
+
* it has no delete flow. The domain guard `assertDeletable` refuses the write as well;
|
|
1780
|
+
* this only keeps the admin from offering a button the adapter would reject.
|
|
1781
|
+
*/
|
|
1782
|
+
declare function isDeletableType(type: ContentTypeLike): boolean;
|
|
1783
|
+
|
|
1784
|
+
/** Props every admin field input receives. Field types never read from a store directly. */
|
|
1785
|
+
interface FieldInputProps<TValue, TField extends FieldDefinition = FieldDefinition> {
|
|
1786
|
+
/** Dotted path, unique within the form: `title`, `blocks.2.heading`. */
|
|
1787
|
+
path: string;
|
|
1788
|
+
field: TField;
|
|
1789
|
+
value: TValue | null;
|
|
1790
|
+
onChange: (value: TValue | null) => void;
|
|
1791
|
+
onBlur?: (() => void) | undefined;
|
|
1792
|
+
errors: readonly FieldError[];
|
|
1793
|
+
disabled?: boolean | undefined;
|
|
1794
|
+
readonly?: boolean | undefined;
|
|
1795
|
+
}
|
|
1796
|
+
type FieldInputComponent<TValue, TField extends FieldDefinition = FieldDefinition> = ComponentType<FieldInputProps<TValue, TField>>;
|
|
1797
|
+
/** Everything a validator may know without touching the database. */
|
|
1798
|
+
interface ValidateCtx {
|
|
1799
|
+
path: string;
|
|
1800
|
+
contentType: AnyContentTypeShape;
|
|
1801
|
+
/** All values of the entry being validated, for cross-field rules. */
|
|
1802
|
+
entry: Record<string, unknown>;
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Recursion, for the two field types that contain other fields.
|
|
1806
|
+
*
|
|
1807
|
+
* `repeater` holds a whole field map, and it cannot convert or name what is inside it
|
|
1808
|
+
* without applying the same field type contract one level down. It must not reach for the
|
|
1809
|
+
* registry itself: the registry imports every spec, so a spec that imports the registry
|
|
1810
|
+
* closes a cycle. The caller that already holds the registry passes the recursion in
|
|
1811
|
+
* instead, and both members below are optional, so a spec that nests nothing ignores them
|
|
1812
|
+
* and a caller that has no registry still gets a working round trip.
|
|
1813
|
+
*/
|
|
1814
|
+
interface NestedConverter {
|
|
1815
|
+
toStored(fields: FieldMap, values: Record<string, unknown>): Record<string, unknown>;
|
|
1816
|
+
fromStored(fields: FieldMap, data: Record<string, unknown>): Record<string, unknown>;
|
|
1817
|
+
}
|
|
1818
|
+
/** Names the SDK-facing type of any field definition, one level down. */
|
|
1819
|
+
type TsTypeResolver = (field: FieldDefinition) => string;
|
|
1820
|
+
/**
|
|
1821
|
+
* Layer 5, as a value or as a function of the field.
|
|
1822
|
+
*
|
|
1823
|
+
* A plain string is the common case and stays exactly as it was. A function is what lets a
|
|
1824
|
+
* field type whose SDK type depends on its own options say so: `select` narrows on
|
|
1825
|
+
* `multiple`, `relation` on `many`, and `repeater` names the object its rows actually
|
|
1826
|
+
* hold. `FieldValue<TField>` in types/field.ts already narrows all three for a code-defined
|
|
1827
|
+
* type; this is what codegen can say for a type created in the Schema Builder, where the
|
|
1828
|
+
* field definition exists only at runtime.
|
|
1829
|
+
*/
|
|
1830
|
+
type FieldTsType<TField extends FieldDefinition = FieldDefinition> = string | ((field: TField, resolve: TsTypeResolver) => string);
|
|
1831
|
+
/**
|
|
1832
|
+
* How one per-type option is edited in the Schema Builder.
|
|
1833
|
+
*
|
|
1834
|
+
* A control, not a field type: `"choice"` is a fixed list of values, `"content-type"` is a
|
|
1835
|
+
* slug picked from the live registry, `"field-key"` is a sibling field of the same level.
|
|
1836
|
+
* The field editor implements this small vocabulary once, and every field type describes
|
|
1837
|
+
* its own options in it, which is what keeps `switch (field.type)` out of the screen.
|
|
1838
|
+
*/
|
|
1839
|
+
type FieldOptionControl = "text" | "number" | "boolean" | "choice" | "select-options" | "content-type" | "field-key" | "string-list" | "fields";
|
|
1840
|
+
interface FieldOptionDescriptor {
|
|
1841
|
+
/** The property this option writes on the field definition. */
|
|
1842
|
+
key: string;
|
|
1843
|
+
label: string;
|
|
1844
|
+
control: FieldOptionControl;
|
|
1845
|
+
help?: string;
|
|
1846
|
+
/** A field of this type is incomplete until the option has a value. */
|
|
1847
|
+
required?: boolean;
|
|
1848
|
+
/** The values a `"choice"` control offers. */
|
|
1849
|
+
choices?: readonly {
|
|
1850
|
+
value: string;
|
|
1851
|
+
label: string;
|
|
1852
|
+
}[];
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Layer 7: what the Schema Builder may configure on this field type, and what it starts as.
|
|
1856
|
+
*
|
|
1857
|
+
* It exists so that the field editor is generated from the registry exactly as every other
|
|
1858
|
+
* admin screen is. Without it, one screen would hold a list of which options belong to
|
|
1859
|
+
* which field type, and a field type added later would render as a bare label until
|
|
1860
|
+
* somebody remembered to edit that list.
|
|
1861
|
+
*/
|
|
1862
|
+
interface FieldEditorSpec {
|
|
1863
|
+
/** Name shown in the field type picker. */
|
|
1864
|
+
label: string;
|
|
1865
|
+
/** One line, shown under the name in the picker. */
|
|
1866
|
+
description: string;
|
|
1867
|
+
/** The shared validation rules this field type honours. See VALIDATION_RULE_CONTROLS. */
|
|
1868
|
+
rules: readonly (keyof ValidationRules)[];
|
|
1869
|
+
/** The per-type options, in the order the editor shows them. */
|
|
1870
|
+
options: readonly FieldOptionDescriptor[];
|
|
1871
|
+
/**
|
|
1872
|
+
* Everything a new field of this type needs besides `type` and `label`.
|
|
1873
|
+
*
|
|
1874
|
+
* A select with no options and a repeater with no nested fields are both invalid, so a
|
|
1875
|
+
* newly added field cannot start as `{ type, label }` alone.
|
|
1876
|
+
*/
|
|
1877
|
+
defaults: Record<string, unknown>;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* The seven-layer contract. A field type is not "added" until every member here is
|
|
1881
|
+
* implemented; a partial field type is a field type that behaves differently in the form
|
|
1882
|
+
* than it does on the server.
|
|
1883
|
+
*
|
|
1884
|
+
* 1. `name` schema definition: the discriminator a content type writes
|
|
1885
|
+
* 2. `toStored` /
|
|
1886
|
+
* `fromStored` stored shape: the round trip through Firestore
|
|
1887
|
+
* 3. `Input` admin input component
|
|
1888
|
+
* 4. `validate` validation, run identically by the form and by the adapter
|
|
1889
|
+
* 5. `tsType` SDK-facing type, used by codegen and by the Developer area
|
|
1890
|
+
* 6. `defaultValue` registration data: what a new entry starts with
|
|
1891
|
+
* 7. `editor` Schema Builder data: the options, rules and defaults of the type
|
|
1892
|
+
*/
|
|
1893
|
+
interface FieldTypeSpec<TValue, TField extends FieldDefinition = FieldDefinition> {
|
|
1894
|
+
name: FieldType;
|
|
1895
|
+
toStored(value: TValue | null, field: TField, nested?: NestedConverter): unknown;
|
|
1896
|
+
fromStored(raw: unknown, field: TField, nested?: NestedConverter): TValue | null;
|
|
1897
|
+
Input: FieldInputComponent<TValue, TField>;
|
|
1898
|
+
validate(value: TValue | null, rules: ValidationRules, ctx: ValidateCtx, field: TField): FieldError[];
|
|
1899
|
+
tsType: FieldTsType<TField>;
|
|
1900
|
+
defaultValue(field: TField): TValue | null;
|
|
1901
|
+
editor: FieldEditorSpec;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* A spec whose value and field types have been erased, for storage in the registry.
|
|
1905
|
+
*
|
|
1906
|
+
* The registry is heterogeneous by construction: one spec per field type, each with a
|
|
1907
|
+
* different value type. The erasure stops at this alias; every consumer goes back through
|
|
1908
|
+
* the typed `FieldTypeSpec` for the field it is actually handling.
|
|
1909
|
+
*/
|
|
1910
|
+
type AnyFieldTypeSpec = FieldTypeSpec<any, any>;
|
|
1911
|
+
interface FieldTypeRegistry {
|
|
1912
|
+
get(name: string): AnyFieldTypeSpec | undefined;
|
|
1913
|
+
require(name: string): AnyFieldTypeSpec;
|
|
1914
|
+
has(name: string): boolean;
|
|
1915
|
+
readonly names: readonly FieldType[];
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* What the status control may offer, and to whom.
|
|
1920
|
+
*
|
|
1921
|
+
* Status is the one piece of entry metadata an editor writes, so it is not a display: it
|
|
1922
|
+
* is a set of transitions out of the status the entry is in, each one answered by `can()`
|
|
1923
|
+
* against the entry itself. A denied transition keeps its reason rather than disappearing,
|
|
1924
|
+
* because "you cannot publish this" is information and an empty panel is not.
|
|
1925
|
+
*
|
|
1926
|
+
* Hiding or disabling a transition here is presentation. The adapter validates the write
|
|
1927
|
+
* and the security rules check the same matrix again, so a user who reaches past this list
|
|
1928
|
+
* gains nothing.
|
|
1929
|
+
*/
|
|
1930
|
+
interface StatusTransition {
|
|
1931
|
+
to: EntryStatus;
|
|
1932
|
+
/** Imperative label for the control: "Publish", "Unpublish", "Archive". */
|
|
1933
|
+
label: string;
|
|
1934
|
+
/** What it does to the public site, in one sentence. */
|
|
1935
|
+
effect: string;
|
|
1936
|
+
allowed: boolean;
|
|
1937
|
+
/** Why it is not offered. Null when it is. */
|
|
1938
|
+
reason: string | null;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* The entry editor metadata sidebar.
|
|
1943
|
+
* Canonical source: .docs/styleguide/03-layout.md section 5.2.1.
|
|
1944
|
+
*
|
|
1945
|
+
* Everything on it is owned by the adapter rather than by the form: status, the three
|
|
1946
|
+
* timestamps, and the uid that created the entry. The one control is the status
|
|
1947
|
+
* transition, and it is a control rather than a display because status is the single piece
|
|
1948
|
+
* of entry metadata an editor writes.
|
|
1949
|
+
*/
|
|
1950
|
+
/** What the admin can say about a uid. Enough to name someone, and nothing more. */
|
|
1951
|
+
interface AuthorInfo {
|
|
1952
|
+
uid: string;
|
|
1953
|
+
displayName?: string | null;
|
|
1954
|
+
email?: string | null;
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Resolves a uid to a person.
|
|
1958
|
+
*
|
|
1959
|
+
* An entry stores who created it as a uid, and a uid is not a name. Turning one into a
|
|
1960
|
+
* name is a read against the user documents, which the `CmsAdapter` does not expose and
|
|
1961
|
+
* the Users area (M9) owns. So it is a seam: a host that can answer supplies this, and a
|
|
1962
|
+
* host that cannot simply does not.
|
|
1963
|
+
*/
|
|
1964
|
+
type AuthorLookup = (uid: string) => Promise<AuthorInfo | null>;
|
|
1965
|
+
interface EntryMetadataProps {
|
|
1966
|
+
user: AuthUser;
|
|
1967
|
+
/** Null while the entry has not been created yet. */
|
|
1968
|
+
entry: Entry | null;
|
|
1969
|
+
/** The stored status. The badge reports what is saved, never what is intended. */
|
|
1970
|
+
status: EntryStatus;
|
|
1971
|
+
/**
|
|
1972
|
+
* The status the next save will write, when that is not the stored one. Set only after a
|
|
1973
|
+
* transition was refused by validation, so the editor can see why the badge has not
|
|
1974
|
+
* moved.
|
|
1975
|
+
*/
|
|
1976
|
+
pending?: EntryStatus | null;
|
|
1977
|
+
transitions: readonly StatusTransition[];
|
|
1978
|
+
onTransition: (to: EntryStatus) => void;
|
|
1979
|
+
/** A write is in flight, so no transition may start a second one. */
|
|
1980
|
+
busy?: boolean;
|
|
1981
|
+
resolveAuthor?: AuthorLookup | undefined;
|
|
1982
|
+
/**
|
|
1983
|
+
* The destructive actions, below the rule. A seam: the delete flow supplies the button,
|
|
1984
|
+
* and a host without one renders no section rather than a button that does nothing.
|
|
1985
|
+
*/
|
|
1986
|
+
actions?: ReactNode;
|
|
1987
|
+
}
|
|
1988
|
+
declare function EntryMetadata({ user, entry, status, pending, transitions, onTransition, busy, resolveAuthor, actions, }: EntryMetadataProps): react.JSX.Element;
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* The entry editor, generated from the content type.
|
|
1992
|
+
* Canonical source: .docs/styleguide/03-layout.md section 5.2.
|
|
1993
|
+
*
|
|
1994
|
+
* One screen shape serves creating and editing, because they differ in exactly two things:
|
|
1995
|
+
* whether there is an entry behind the form, and which adapter method the save calls.
|
|
1996
|
+
* Everything else (which fields exist, what they start at, what is valid, what the status
|
|
1997
|
+
* control offers) is read from the schema and from `can()`.
|
|
1998
|
+
*
|
|
1999
|
+
* There is no `switch (field.type)` here and there never may be. A field is rendered by
|
|
2000
|
+
* looking its spec up in the registry and mounting `spec.Input`, so a field type added in
|
|
2001
|
+
* `src/fields/index.ts` works in this form the day it is registered.
|
|
2002
|
+
*
|
|
2003
|
+
* Three things it is deliberate about:
|
|
2004
|
+
*
|
|
2005
|
+
* - **Where an error lands.** The shared validator and the adapter both report a
|
|
2006
|
+
* `FieldError` with a dotted path, and every input is mounted at its path, so an error
|
|
2007
|
+
* is routed to the control that owns it, including one inside a repeater row. Only an
|
|
2008
|
+
* error naming a field this form does not render reaches the form-level message, and
|
|
2009
|
+
* only a failure that is not about a field at all reaches a toast.
|
|
2010
|
+
* - **Which error is newer.** A client error is recomputed on every keystroke; a server
|
|
2011
|
+
* error is a verdict about what was submitted and no keystroke can retract it. See
|
|
2012
|
+
* `mergeIssues` in core/entry-form.ts.
|
|
2013
|
+
* - **Draft versus published.** A draft skips `required` and nothing else, and that is the
|
|
2014
|
+
* same option the adapter passes to the same validator on the write path.
|
|
2015
|
+
*
|
|
2016
|
+
* A singleton is the same screen with two answers changed, not a second screen. It reads
|
|
2017
|
+
* through `getSingle` instead of `get`, and a singleton that was never saved is a form with
|
|
2018
|
+
* no entry behind it rather than a missing-entry state: the document simply does not exist
|
|
2019
|
+
* yet, and the first save is the `create` that brings it into being at the id
|
|
2020
|
+
* `singletonEntryId` derives from the slug. It offers no delete, because `assertDeletable`
|
|
2021
|
+
* refuses one, and no list to go back to, because `assertListable` refuses that too.
|
|
2022
|
+
*
|
|
2023
|
+
* One thing is optimistic and it is named here so the rest is known not to be: a status
|
|
2024
|
+
* transition paints the new badge before the adapter answers, and a failure puts the exact
|
|
2025
|
+
* stored status back. See `optimistic` in EntryForm.
|
|
2026
|
+
*/
|
|
2027
|
+
interface EntryEditorProps {
|
|
2028
|
+
adapter: CmsAdapter;
|
|
2029
|
+
type: ContentTypeLike;
|
|
2030
|
+
user: AuthUser;
|
|
2031
|
+
/** Absent for a create screen. Present for an edit screen. */
|
|
2032
|
+
entryId?: string | undefined;
|
|
2033
|
+
/** Called after a successful write, so the host can route from create to edit. */
|
|
2034
|
+
onSaved?: ((entry: Entry, created: boolean) => void) | undefined;
|
|
2035
|
+
onCancel?: (() => void) | undefined;
|
|
2036
|
+
/** The seam the unsaved-changes guard attaches to. */
|
|
2037
|
+
onDirtyChange?: ((dirty: boolean) => void) | undefined;
|
|
2038
|
+
/** The seam the delete flow attaches to. No callback means no destructive action. */
|
|
2039
|
+
onDelete?: ((entryId: string) => void) | undefined;
|
|
2040
|
+
resolveAuthor?: AuthorLookup | undefined;
|
|
2041
|
+
/** Resolves a relation target slug, for the relation picker. */
|
|
2042
|
+
getType?: ((slug: string) => ContentTypeLike | undefined) | undefined;
|
|
2043
|
+
/** The field type registry. Defaults to the one every field type registers into. */
|
|
2044
|
+
registry?: FieldTypeRegistry | undefined;
|
|
2045
|
+
}
|
|
2046
|
+
declare function EntryEditor({ adapter, type, user, entryId, onSaved, onCancel, onDirtyChange, onDelete, resolveAuthor, getType, registry, }: EntryEditorProps): react.JSX.Element;
|
|
2047
|
+
|
|
2048
|
+
interface DashboardProps {
|
|
2049
|
+
adapter: CmsAdapter;
|
|
2050
|
+
/**
|
|
2051
|
+
* The content types this user may read. The caller filters with `can()`; a tile counting
|
|
2052
|
+
* something the adapter will refuse is a broken promise, not a preview.
|
|
2053
|
+
*/
|
|
2054
|
+
types: readonly ContentTypeLike[];
|
|
2055
|
+
/** How many recent entries to list. */
|
|
2056
|
+
recentLimit?: number;
|
|
2057
|
+
}
|
|
2058
|
+
declare function Dashboard({ adapter, types, recentLimit }: DashboardProps): react.JSX.Element;
|
|
2059
|
+
|
|
2060
|
+
/**
|
|
2061
|
+
* What the dashboard reads, and how.
|
|
2062
|
+
*
|
|
2063
|
+
* Separated from the component on purpose: this is the only part of the dashboard with a
|
|
2064
|
+
* data path, it goes through `CmsAdapter` and nothing else, and it is testable against a
|
|
2065
|
+
* stub adapter without rendering anything.
|
|
2066
|
+
*
|
|
2067
|
+
* Nothing here knows the name of a content type. The tiles and the recent list
|
|
2068
|
+
* are derived from the types it is handed, so a new content type changes the dashboard by
|
|
2069
|
+
* appearing in the registry and in no other way.
|
|
2070
|
+
*/
|
|
2071
|
+
interface DashboardCount {
|
|
2072
|
+
slug: string;
|
|
2073
|
+
label: string;
|
|
2074
|
+
kind: ContentTypeKind;
|
|
2075
|
+
icon: IconName;
|
|
2076
|
+
count: number;
|
|
2077
|
+
}
|
|
2078
|
+
interface DashboardRecentEntry {
|
|
2079
|
+
/** Unique across content types, which an entry id alone is not. */
|
|
2080
|
+
key: string;
|
|
2081
|
+
entryId: string;
|
|
2082
|
+
title: string;
|
|
2083
|
+
typeSlug: string;
|
|
2084
|
+
typeLabel: string;
|
|
2085
|
+
status: EntryStatus;
|
|
2086
|
+
updatedAt: string;
|
|
2087
|
+
}
|
|
2088
|
+
interface DashboardData {
|
|
2089
|
+
counts: readonly DashboardCount[];
|
|
2090
|
+
recent: readonly DashboardRecentEntry[];
|
|
2091
|
+
/**
|
|
2092
|
+
* Null when the adapter could not answer. The tile then says the figure is unavailable
|
|
2093
|
+
* rather than showing a zero that reads like an empty library.
|
|
2094
|
+
*/
|
|
2095
|
+
media: MediaUsage | null;
|
|
2096
|
+
}
|
|
2097
|
+
interface LoadDashboardOptions {
|
|
2098
|
+
/** How many recent entries the list shows. */
|
|
2099
|
+
recentLimit?: number;
|
|
2100
|
+
}
|
|
2101
|
+
declare const DEFAULT_RECENT_LIMIT = 8;
|
|
2102
|
+
/**
|
|
2103
|
+
* Reads everything the dashboard shows.
|
|
2104
|
+
*
|
|
2105
|
+
* Counts are one aggregate read per content type, including singletons: a singleton counts
|
|
2106
|
+
* 0 or 1, which is exactly the "is it filled in yet" answer its tile is asking.
|
|
2107
|
+
*
|
|
2108
|
+
* Recent entries are one small page per collection, merged and re-sorted here. The adapter
|
|
2109
|
+
* has no cross-content-type query and should not grow one for a dashboard: entries live in
|
|
2110
|
+
* a subcollection per type, so a single query over all of them would need a collection
|
|
2111
|
+
* group index and a shape the security rules cannot gate per type. Singletons are excluded
|
|
2112
|
+
* because they cannot be listed at all.
|
|
2113
|
+
*
|
|
2114
|
+
* A failure in any count or list rejects, and the screen shows its error state. A
|
|
2115
|
+
* dashboard that silently drops the one content type the reader came to check is worse
|
|
2116
|
+
* than one that says it could not load.
|
|
2117
|
+
*/
|
|
2118
|
+
declare function loadDashboard(adapter: CmsAdapter, types: readonly ContentTypeLike[], { recentLimit }?: LoadDashboardOptions): Promise<DashboardData>;
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* How the media library starts an upload, without knowing that Storage exists.
|
|
2122
|
+
*
|
|
2123
|
+
* The same seam as `RelationSource` for the relation picker, and for the same reason: the
|
|
2124
|
+
* screen lives under `src/admin/**`, where the Firebase SDK may not be imported, and it
|
|
2125
|
+
* still has to offer progress, cancel and retry. The host builds one with
|
|
2126
|
+
* `createMediaUploader` from `@nonext/husk/firebase` and hands it in.
|
|
2127
|
+
*
|
|
2128
|
+
* Every member below mirrors what `createMediaUpload` already returns, so the real
|
|
2129
|
+
* implementation is a pass-through and a test implementation is a few lines.
|
|
2130
|
+
*/
|
|
2131
|
+
type MediaUploadPhase = "idle" | "uploading" | "storing" | "done" | "cancelled" | "failed";
|
|
2132
|
+
interface MediaUploadProgress {
|
|
2133
|
+
phase: MediaUploadPhase;
|
|
2134
|
+
bytesTransferred: number;
|
|
2135
|
+
totalBytes: number;
|
|
2136
|
+
/** 0 to 1. Exactly 1 only once every byte is acknowledged. */
|
|
2137
|
+
fraction: number;
|
|
2138
|
+
/** 1 for the first run, 2 for the first retry, and so on. */
|
|
2139
|
+
attempt: number;
|
|
2140
|
+
}
|
|
2141
|
+
interface MediaUploadHandle {
|
|
2142
|
+
readonly mediaId: string;
|
|
2143
|
+
readonly path: string;
|
|
2144
|
+
readonly fileName: string;
|
|
2145
|
+
progress(): MediaUploadProgress;
|
|
2146
|
+
start(): Promise<MediaItem>;
|
|
2147
|
+
/** Runs it again to the same id and the same path, so remains are overwritten. */
|
|
2148
|
+
retry(): Promise<MediaItem>;
|
|
2149
|
+
cancel(): void;
|
|
2150
|
+
}
|
|
2151
|
+
interface MediaUploadRequest {
|
|
2152
|
+
onProgress?: (progress: MediaUploadProgress) => void;
|
|
2153
|
+
alt?: string | null;
|
|
2154
|
+
}
|
|
2155
|
+
interface MediaUploader {
|
|
2156
|
+
create(file: File, options?: MediaUploadRequest): MediaUploadHandle;
|
|
2157
|
+
/**
|
|
2158
|
+
* New bytes for an item that already exists, keeping its id and its object path.
|
|
2159
|
+
*
|
|
2160
|
+
* The MIME type may not change: the path is fixed for the life of the item and the rules
|
|
2161
|
+
* tie the path's extension to the declared type. The implementation refuses a mismatch
|
|
2162
|
+
* with a sentence rather than letting it surface as a permission error.
|
|
2163
|
+
*/
|
|
2164
|
+
replace(existing: MediaItem, file: File, options?: MediaUploadRequest): MediaUploadHandle;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/**
|
|
2168
|
+
* The media library, and the body of the media picker.
|
|
2169
|
+
* Canonical source: .docs/styleguide/03-layout.md section 5.3.
|
|
2170
|
+
*
|
|
2171
|
+
* One screen serves both because they differ in exactly two things: whether choosing an
|
|
2172
|
+
* item does anything, and whether a field's `accept` narrows what is offered. Everything
|
|
2173
|
+
* else (the grid, the upload rows, the detail panel, the delete flow) is the same code, so
|
|
2174
|
+
* the picker cannot fall behind the library or offer something the library would not.
|
|
2175
|
+
*
|
|
2176
|
+
* `accept` is honoured here and not only passed along. Every page the picker renders is
|
|
2177
|
+
* filtered through `pickableItems`, so a file of the wrong type is never on screen to be
|
|
2178
|
+
* clicked. The field re-checks the item that comes back, and that check is a backstop for
|
|
2179
|
+
* a picker somebody else supplied, not the thing that catches this one.
|
|
2180
|
+
*
|
|
2181
|
+
* Orphans are surfaced, never swept. "Unused only" runs a usage lookup for each item the
|
|
2182
|
+
* grid has loaded and marks the ones nothing references. Nothing is deleted as a
|
|
2183
|
+
* consequence, and nothing is deleted on a schedule: a file that no entry uses today may
|
|
2184
|
+
* be what tomorrow's entry needs, and that is not a judgement a CMS gets to make.
|
|
2185
|
+
*/
|
|
2186
|
+
interface MediaLibraryProps {
|
|
2187
|
+
adapter: CmsAdapter;
|
|
2188
|
+
user: AuthUser;
|
|
2189
|
+
/** Absent means the library is read-only: no upload, no replace. */
|
|
2190
|
+
uploader?: MediaUploader | undefined;
|
|
2191
|
+
/** Picker mode: the MIME patterns this slot accepts. */
|
|
2192
|
+
accept?: readonly string[] | undefined;
|
|
2193
|
+
/** Picker mode: choosing a tile calls this instead of opening the detail panel. */
|
|
2194
|
+
onPick?: ((item: MediaItem) => void) | undefined;
|
|
2195
|
+
/** Names a content type in a usage list. Defaults to the raw slug. */
|
|
2196
|
+
contentTypeLabel?: ((slug: string) => string) | undefined;
|
|
2197
|
+
}
|
|
2198
|
+
declare function MediaLibrary({ adapter, user, uploader, accept, onPick, contentTypeLabel, }: MediaLibraryProps): react.JSX.Element;
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* The real media picker, over the adapter and the library UI.
|
|
2202
|
+
*
|
|
2203
|
+
* The `image` and `file` field types have been written against `MediaPickerApi` since M5,
|
|
2204
|
+
* with an absent provider as a deliberate, rendered state. This is the implementation that
|
|
2205
|
+
* makes the seam real:
|
|
2206
|
+
*
|
|
2207
|
+
* - `resolve` is `adapter.media.getMany`. Ids with no document are simply absent from the
|
|
2208
|
+
* result, which is exactly how the control learns a reference is broken.
|
|
2209
|
+
* - `pick` opens the library in a modal and resolves when a tile is chosen, or with null
|
|
2210
|
+
* when the editor closes it. The promise is settled exactly once in either direction,
|
|
2211
|
+
* so a field that awaited it never hangs: the deferred is cleared the moment it is
|
|
2212
|
+
* settled, and closing the modal settles it.
|
|
2213
|
+
*
|
|
2214
|
+
* `accept` is honoured by the library itself, which filters every page it renders through
|
|
2215
|
+
* `pickableItems`. The field re-checks the item that comes back, and that check exists for
|
|
2216
|
+
* a picker somebody else supplied; against this one it can never be the thing that catches
|
|
2217
|
+
* a wrong type, because a wrong type was never on screen.
|
|
2218
|
+
*/
|
|
2219
|
+
interface AdminMediaPickerProviderProps {
|
|
2220
|
+
adapter: CmsAdapter;
|
|
2221
|
+
user: AuthUser;
|
|
2222
|
+
/** Absent means the picker can choose but not upload. */
|
|
2223
|
+
uploader?: MediaUploader | undefined;
|
|
2224
|
+
contentTypeLabel?: ((slug: string) => string) | undefined;
|
|
2225
|
+
children: ReactNode;
|
|
2226
|
+
}
|
|
2227
|
+
declare function AdminMediaPickerProvider({ adapter, user, uploader, contentTypeLabel, children, }: AdminMediaPickerProviderProps): react.JSX.Element;
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* The destructive confirmation for deleting a media item.
|
|
2231
|
+
* Canonical source: .docs/styleguide/04-components.md section 12, "Destructive
|
|
2232
|
+
* confirmation".
|
|
2233
|
+
*
|
|
2234
|
+
* It differs from the entry version in the one way that matters: a media item can be
|
|
2235
|
+
* referenced from somewhere else, and deleting it breaks whatever referenced it. The
|
|
2236
|
+
* dialog therefore reads the reverse index before it offers the button, and names every
|
|
2237
|
+
* entry it finds. A file with references is not refused, because sometimes removing it is
|
|
2238
|
+
* exactly the intention; it is made impossible to do by accident.
|
|
2239
|
+
*
|
|
2240
|
+
* Two things it says out loud rather than implies:
|
|
2241
|
+
*
|
|
2242
|
+
* - Where the list comes from. The count is the `mediaRefs` index each entry write
|
|
2243
|
+
* maintains, so an entry that has not been saved since the index existed will not be in
|
|
2244
|
+
* it. Claiming an unqualified "nothing uses this" would be a stronger statement than
|
|
2245
|
+
* the data supports.
|
|
2246
|
+
* - What deleting does not do. It removes the library record. The bytes in Storage are a
|
|
2247
|
+
* separate object with its own rules, and the caller is told whether that part also
|
|
2248
|
+
* went through.
|
|
2249
|
+
*
|
|
2250
|
+
* The typed confirmation is the file name, the same mechanic the entry dialog uses.
|
|
2251
|
+
*/
|
|
2252
|
+
interface MediaDeleteDialogProps {
|
|
2253
|
+
adapter: CmsAdapter;
|
|
2254
|
+
/** The item to delete. Null means the dialog is closed. */
|
|
2255
|
+
item: MediaItem | null;
|
|
2256
|
+
onCancel: () => void;
|
|
2257
|
+
onDeleted: (mediaId: string) => void;
|
|
2258
|
+
contentTypeLabel?: ((slug: string) => string) | undefined;
|
|
2259
|
+
}
|
|
2260
|
+
declare function MediaDeleteDialog({ adapter, item, onCancel, onDeleted, contentTypeLabel, }: MediaDeleteDialogProps): react.JSX.Element | null;
|
|
2261
|
+
|
|
2262
|
+
/**
|
|
2263
|
+
* One media item, opened from the grid.
|
|
2264
|
+
* Canonical source: .docs/styleguide/03-layout.md section 5.3.
|
|
2265
|
+
*
|
|
2266
|
+
* A modal rather than a third column, because the grid is already the widest thing on the
|
|
2267
|
+
* page and a column would squeeze the tiles below their minimum measure.
|
|
2268
|
+
*
|
|
2269
|
+
* What it is deliberate about:
|
|
2270
|
+
*
|
|
2271
|
+
* - **Alt text is the only editable field.** Everything else about a media item is a fact
|
|
2272
|
+
* about the file: changing the name, the type or the size here would describe bytes
|
|
2273
|
+
* that did not change. Replacing the bytes is a separate, named action.
|
|
2274
|
+
* - **Usage is read once, when the panel opens, and shown whether or not it is zero.**
|
|
2275
|
+
* An orphan is surfaced, never swept: a file nothing references today may be exactly
|
|
2276
|
+
* what tomorrow's entry needs, and the CMS does not get to decide that for an editor.
|
|
2277
|
+
* - **Copy reports what happened.** `navigator.clipboard` is absent outside a secure
|
|
2278
|
+
* context and rejects on a refused permission, so the button says "Copied" only when
|
|
2279
|
+
* the clipboard confirmed it, and points at the URL field otherwise.
|
|
2280
|
+
*/
|
|
2281
|
+
interface MediaDetailProps {
|
|
2282
|
+
adapter: CmsAdapter;
|
|
2283
|
+
user: AuthUser;
|
|
2284
|
+
item: MediaItem;
|
|
2285
|
+
onClose: () => void;
|
|
2286
|
+
/** Called with the updated item after alt text is saved. */
|
|
2287
|
+
onUpdated: (item: MediaItem) => void;
|
|
2288
|
+
/** Opens the destructive confirmation. Absent when this account may not delete. */
|
|
2289
|
+
onDelete?: ((item: MediaItem) => void) | undefined;
|
|
2290
|
+
/** Starts a replace. Absent when no uploader was supplied. */
|
|
2291
|
+
onReplace?: ((item: MediaItem) => void) | undefined;
|
|
2292
|
+
/** Names a content type in the usage list. Defaults to the raw slug. */
|
|
2293
|
+
contentTypeLabel?: ((slug: string) => string) | undefined;
|
|
2294
|
+
}
|
|
2295
|
+
declare function MediaDetail({ adapter, user, item, onClose, onUpdated, onDelete, onReplace, contentTypeLabel, }: MediaDetailProps): react.JSX.Element;
|
|
2296
|
+
|
|
2297
|
+
/**
|
|
2298
|
+
* One item in the media grid.
|
|
2299
|
+
* Canonical source: .docs/styleguide/03-layout.md section 5.3.
|
|
2300
|
+
*
|
|
2301
|
+
* The whole tile is one button, not a card with a button in it: the only thing a tile does
|
|
2302
|
+
* is open the item, and a click target that is smaller than the thing it represents is a
|
|
2303
|
+
* click target people miss.
|
|
2304
|
+
*
|
|
2305
|
+
* The preview is the original file. Husk stores originals only and resizes at render time
|
|
2306
|
+
* through `next/image` on a customer's site (see .docs/decisions/2026-09-18-media-variants.md),
|
|
2307
|
+
* so there is no thumbnail to point at here. `loading="lazy"` is what keeps a grid of
|
|
2308
|
+
* originals from being a page of full-size downloads.
|
|
2309
|
+
*/
|
|
2310
|
+
interface MediaTileProps {
|
|
2311
|
+
item: MediaItem;
|
|
2312
|
+
selected?: boolean;
|
|
2313
|
+
/** True when a usage check ran and found no entry referencing this item. */
|
|
2314
|
+
unused?: boolean;
|
|
2315
|
+
onOpen: (item: MediaItem) => void;
|
|
2316
|
+
}
|
|
2317
|
+
declare function MediaTile({ item, selected, unused, onOpen, }: MediaTileProps): react.JSX.Element;
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* The entries that reference a media item, by name.
|
|
2321
|
+
*
|
|
2322
|
+
* Shared by the detail panel and the delete confirmation, because a warning that lists
|
|
2323
|
+
* three entries and a panel that lists four would be two different claims about the same
|
|
2324
|
+
* question.
|
|
2325
|
+
*
|
|
2326
|
+
* An entry with no usable title prints its id. A content type the registry no longer knows
|
|
2327
|
+
* prints its slug. Neither case is hidden: a reference the admin cannot name is still a
|
|
2328
|
+
* reference, and "one of these is somewhere" is more useful than a shorter list.
|
|
2329
|
+
*/
|
|
2330
|
+
interface UsageListProps {
|
|
2331
|
+
usages: readonly MediaUsageReference[];
|
|
2332
|
+
contentTypeLabel?: ((slug: string) => string) | undefined;
|
|
2333
|
+
}
|
|
2334
|
+
declare function UsageList({ usages, contentTypeLabel }: UsageListProps): react.JSX.Element | null;
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* Copying a string to the clipboard, and admitting when it did not happen.
|
|
2338
|
+
*
|
|
2339
|
+
* `navigator.clipboard` is not always there. It is undefined outside a secure context,
|
|
2340
|
+
* which includes a customer previewing their admin over plain http on a LAN address, and
|
|
2341
|
+
* `writeText` rejects when the document is not focused or when the browser's permission
|
|
2342
|
+
* prompt is refused. A "Copied" toast in any of those cases is a lie the reader only
|
|
2343
|
+
* discovers after pasting nothing, so the result is reported instead of assumed.
|
|
2344
|
+
*
|
|
2345
|
+
* The fallback is the old `document.execCommand("copy")` path over an off-screen textarea.
|
|
2346
|
+
* It is deprecated and it is still the only thing that works without the async API, so it
|
|
2347
|
+
* is tried second rather than not at all. When both fail the caller is told, and the
|
|
2348
|
+
* library shows the URL in a field the reader can select by hand.
|
|
2349
|
+
*/
|
|
2350
|
+
type ClipboardOutcome = "copied" | "unavailable";
|
|
2351
|
+
declare function copyText(text: string): Promise<ClipboardOutcome>;
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* The decisions the media library makes that are not rendering.
|
|
2355
|
+
*
|
|
2356
|
+
* They live outside the components so each one can be tested without a DOM, and so the
|
|
2357
|
+
* picker and the standalone library cannot answer the same question two different ways.
|
|
2358
|
+
*/
|
|
2359
|
+
/**
|
|
2360
|
+
* The `accept` attribute for the library's file input, narrowed by what a field asks for.
|
|
2361
|
+
*
|
|
2362
|
+
* Two constraints meet here and both are real: `core/media-constraints.ts` says what the
|
|
2363
|
+
* product accepts at all, and a field's `accept` says what this particular slot accepts.
|
|
2364
|
+
* The intersection is what the browser's file chooser should offer. An intersection that
|
|
2365
|
+
* comes out empty falls back to the full product allowlist rather than to `accept=""`,
|
|
2366
|
+
* which browsers read as "anything": a chooser that offers everything and a refusal
|
|
2367
|
+
* afterwards is friendlier than a chooser that offers nothing and explains nothing.
|
|
2368
|
+
*/
|
|
2369
|
+
declare function uploadAccept(accept: readonly string[] | undefined): string;
|
|
2370
|
+
/**
|
|
2371
|
+
* The MIME prefix the adapter can narrow its query by, or undefined when it cannot.
|
|
2372
|
+
*
|
|
2373
|
+
* `MediaQuery.type` is a prefix range, which is the only "starts with" Firestore offers, so
|
|
2374
|
+
* it can serve `image/*` and nothing else. A field accepting exactly `image/jpeg`, or a mix
|
|
2375
|
+
* of families, gets no server-side narrowing and is filtered per page instead.
|
|
2376
|
+
*
|
|
2377
|
+
* This is an optimization and never the enforcement. `pickableItems` filters every page it
|
|
2378
|
+
* renders, whatever the query did, which is why a field's own re-check on the way back is
|
|
2379
|
+
* never the thing that catches a wrong type.
|
|
2380
|
+
*/
|
|
2381
|
+
declare function queryTypeFilter(accept: readonly string[] | undefined): string | undefined;
|
|
2382
|
+
/**
|
|
2383
|
+
* The items a picker may offer, out of a page it just read.
|
|
2384
|
+
*
|
|
2385
|
+
* The filter is applied to what is rendered rather than to what is chosen, so a field's
|
|
2386
|
+
* `accept` is honoured by the picker rather than merely passed to it. A page that filters
|
|
2387
|
+
* down to nothing is a short page, not an error: the cursor still points past it and the
|
|
2388
|
+
* next page is read on demand.
|
|
2389
|
+
*/
|
|
2390
|
+
declare function pickableItems(items: readonly MediaItem[], accept: readonly string[] | undefined): readonly MediaItem[];
|
|
2391
|
+
/** Whether the tile shows an image preview or its concept icon. */
|
|
2392
|
+
declare function isImageItem(item: MediaItem): boolean;
|
|
2393
|
+
/**
|
|
2394
|
+
* Whether this account may delete library items.
|
|
2395
|
+
*
|
|
2396
|
+
* Mirrors `allow delete: if isEditor()` in firestore.rules. Hiding the button is
|
|
2397
|
+
* presentation only; the rules refuse the write regardless of what the UI renders.
|
|
2398
|
+
*/
|
|
2399
|
+
declare function canDeleteMedia(user: AuthUser): boolean;
|
|
2400
|
+
/**
|
|
2401
|
+
* Whether this account may put new bytes over an existing item.
|
|
2402
|
+
*
|
|
2403
|
+
* Mirrors `mayWrite` in storage.rules: an editor may overwrite any object, an author only
|
|
2404
|
+
* one whose custom metadata names them, which for a Husk upload is the account that
|
|
2405
|
+
* created the media document.
|
|
2406
|
+
*/
|
|
2407
|
+
declare function canReplaceMedia(user: AuthUser, item: MediaItem): boolean;
|
|
2408
|
+
/** `1600 x 900`, or null for an item whose dimensions were never recorded. */
|
|
2409
|
+
declare function formatDimensions(item: MediaItem): string | null;
|
|
2410
|
+
/**
|
|
2411
|
+
* What the usage list says, in one sentence.
|
|
2412
|
+
*
|
|
2413
|
+
* The count is deliberately phrased as "at least" once the list hit its limit, because the
|
|
2414
|
+
* lookup stopped counting there and claiming an exact number it did not count would be the
|
|
2415
|
+
* one thing a delete warning must not do.
|
|
2416
|
+
*/
|
|
2417
|
+
declare function describeUsage(usages: readonly MediaUsageReference[], limit?: number): string;
|
|
2418
|
+
/**
|
|
2419
|
+
* The type filter the library toolbar offers.
|
|
2420
|
+
*
|
|
2421
|
+
* Each value is a MIME prefix, which is what `MediaQuery.type` narrows by: a range scan is
|
|
2422
|
+
* the only "starts with" Firestore has. The categories are the ones `ALLOWED_MEDIA_TYPES`
|
|
2423
|
+
* actually contains, so no option can come back empty for a structural reason.
|
|
2424
|
+
*/
|
|
2425
|
+
declare const MEDIA_FILTERS: readonly {
|
|
2426
|
+
value: string;
|
|
2427
|
+
label: string;
|
|
2428
|
+
}[];
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* A registry that can change while the admin is running.
|
|
2432
|
+
*
|
|
2433
|
+
* Creating a content type in the Schema Builder has to put it in the sidebar, give it
|
|
2434
|
+
* routes and give it working CRUD without a rebuild and without a reload. The code half of
|
|
2435
|
+
* the registry is fixed for the lifetime of the process; only the database half moves, so
|
|
2436
|
+
* this holds the code half and swaps the database half.
|
|
2437
|
+
*
|
|
2438
|
+
* **How the change arrives, and what it costs.** `SchemaAdapter.watch` opens one Firestore
|
|
2439
|
+
* listener on the whole `schemas` collection and hands every snapshot to `setDatabaseTypes`.
|
|
2440
|
+
* One listener per open admin tab. It bills the documents of the initial snapshot once, then
|
|
2441
|
+
* one document read per changed schema document, and nothing at all while nobody is editing
|
|
2442
|
+
* structure. `schemas` holds one small document per database-defined content type, so a
|
|
2443
|
+
* customer with twenty of them pays twenty reads when an admin tab opens.
|
|
2444
|
+
*
|
|
2445
|
+
* The alternative considered was refetching the collection on every navigation inside the
|
|
2446
|
+
* admin. It needs no listener, but it bills the whole collection again on every screen
|
|
2447
|
+
* change, which for one editor clicking through twenty screens in an hour is already an
|
|
2448
|
+
* order of magnitude more reads than the listener, and it is still stale in the tab where
|
|
2449
|
+
* nobody navigated. The listener is both cheaper in the steady state and the only one of
|
|
2450
|
+
* the two that actually refreshes without an interaction. See
|
|
2451
|
+
* .docs/decisions/2026-09-18-schema-field-removal.md.
|
|
2452
|
+
*
|
|
2453
|
+
* A snapshot that produces an identical registry is dropped rather than published, because
|
|
2454
|
+
* Firestore replays a local write as a second snapshot once the server acknowledges it and
|
|
2455
|
+
* a re-render of every admin screen for a change nobody made is a visible cost.
|
|
2456
|
+
*/
|
|
2457
|
+
interface RegistryStore {
|
|
2458
|
+
/** The current registry. Replaced, never mutated, so a React snapshot stays stable. */
|
|
2459
|
+
readonly registry: Registry;
|
|
2460
|
+
/**
|
|
2461
|
+
* Swaps the database half. Returns true when the registry actually changed, which is what
|
|
2462
|
+
* the caller subscribes for.
|
|
2463
|
+
*/
|
|
2464
|
+
setDatabaseTypes(types: readonly ContentTypeLike[]): boolean;
|
|
2465
|
+
/** Notified after every real change. Returns the unsubscribe. */
|
|
2466
|
+
subscribe(listener: (registry: Registry) => void): () => void;
|
|
2467
|
+
/**
|
|
2468
|
+
* Changes counter, monotonic. `useSyncExternalStore` compares it instead of deep
|
|
2469
|
+
* comparing the registry, and a server render can send it to the client to detect that
|
|
2470
|
+
* the two disagree.
|
|
2471
|
+
*/
|
|
2472
|
+
readonly revision: number;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/**
|
|
2476
|
+
* The React half of the live registry. The store itself is in core and knows no React.
|
|
2477
|
+
*
|
|
2478
|
+
* This is what makes the M8 promise concrete: a content type created in the Schema Builder
|
|
2479
|
+
* reaches the sidebar, the routes and every screen of every open tab without a rebuild and
|
|
2480
|
+
* without a reload, because the registry the admin renders from is a subscription rather
|
|
2481
|
+
* than a value computed once on the server.
|
|
2482
|
+
*
|
|
2483
|
+
* Firebase does not appear here. `watch` arrives as an argument, exactly as the media
|
|
2484
|
+
* uploader and the relation source do, so nothing under `src/admin/**` imports the SDK.
|
|
2485
|
+
*/
|
|
2486
|
+
/** The shape of `SchemaAdapter.watch`, without naming the adapter. */
|
|
2487
|
+
type SchemaWatch = (listener: (types: readonly ContentTypeLike[]) => void, onError?: (error: Error) => void) => () => void;
|
|
2488
|
+
interface LiveRegistry {
|
|
2489
|
+
registry: Registry;
|
|
2490
|
+
/**
|
|
2491
|
+
* Whether the database half has been read at least once.
|
|
2492
|
+
*
|
|
2493
|
+
* It matters for exactly one question, and it is the question a generated admin has to
|
|
2494
|
+
* get right: a slug that is in no content type yet may be an unknown route or may be a
|
|
2495
|
+
* database-defined type whose first snapshot has not landed. Answering 404 before the
|
|
2496
|
+
* listener has spoken would show a not-found screen for a content type that exists.
|
|
2497
|
+
* True immediately when no listener is running, because then there is nothing to wait for.
|
|
2498
|
+
*/
|
|
2499
|
+
synced: boolean;
|
|
2500
|
+
/**
|
|
2501
|
+
* The last listener error, or null.
|
|
2502
|
+
*
|
|
2503
|
+
* Surfaced rather than swallowed: a dead listener leaves the admin quietly stale, which
|
|
2504
|
+
* looks exactly like a working admin until somebody wonders where their content type
|
|
2505
|
+
* went.
|
|
2506
|
+
*/
|
|
2507
|
+
error: Error | null;
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Subscribes to a registry store, and feeds it from a schema listener when one is given.
|
|
2511
|
+
*
|
|
2512
|
+
* `watch` must be stable across renders (memoize it, or take it from a memoized adapter):
|
|
2513
|
+
* a new function every render would close and reopen the Firestore listener every render.
|
|
2514
|
+
*/
|
|
2515
|
+
declare function useLiveRegistry(store: RegistryStore, watch?: SchemaWatch): LiveRegistry;
|
|
2516
|
+
|
|
2517
|
+
/**
|
|
2518
|
+
* The Developer area's Content Types list.
|
|
2519
|
+
*
|
|
2520
|
+
* Every content type the admin knows about, from either source, in one table. The two
|
|
2521
|
+
* columns that only exist here are the point of the screen: **source** says whether a
|
|
2522
|
+
* definition lives in git or in the database, and **lock** says whether the Schema Builder
|
|
2523
|
+
* may touch it. Both are answered by the registry, never by the row.
|
|
2524
|
+
*
|
|
2525
|
+
* Two things that are easy to hide and must not be:
|
|
2526
|
+
*
|
|
2527
|
+
* - A slug defined in both sources. Code wins, the database definition is preserved on
|
|
2528
|
+
* `entry.conflict`, and the editor who created it is told it exists, is not live, and
|
|
2529
|
+
* why.
|
|
2530
|
+
* - A stored definition that failed validation. It is in `registry.invalid` rather than in
|
|
2531
|
+
* the table, and a definition that silently disappeared is indistinguishable from one
|
|
2532
|
+
* that was deleted.
|
|
2533
|
+
*/
|
|
2534
|
+
interface ContentTypesScreenProps {
|
|
2535
|
+
registry: Registry;
|
|
2536
|
+
adapter: CmsAdapter;
|
|
2537
|
+
user: AuthUser;
|
|
2538
|
+
/** Opens one content type. Read-only for a code-defined one; the screen says so. */
|
|
2539
|
+
onOpen: (slug: string) => void;
|
|
2540
|
+
/** Called with the slug of a freshly created type, so the host can route to it. */
|
|
2541
|
+
onCreated?: (slug: string) => void;
|
|
2542
|
+
/**
|
|
2543
|
+
* A listener error from the live registry, if the host is running one.
|
|
2544
|
+
*
|
|
2545
|
+
* Shown here rather than swallowed, because this screen is where somebody creates a type
|
|
2546
|
+
* and then waits for it to appear.
|
|
2547
|
+
*/
|
|
2548
|
+
liveError?: Error | null;
|
|
2549
|
+
}
|
|
2550
|
+
declare function ContentTypesScreen({ registry, adapter, user, onOpen, onCreated, liveError, }: ContentTypesScreenProps): react.JSX.Element;
|
|
2551
|
+
|
|
2552
|
+
/**
|
|
2553
|
+
* One content type in the Schema Builder: its settings, its fields, and what it has lost.
|
|
2554
|
+
*
|
|
2555
|
+
* `isSchemaReadOnly` is the single question this screen asks about whether it may edit
|
|
2556
|
+
* anything, and it is true for every code-sourced type, not only the ones marked `locked`.
|
|
2557
|
+
* A read-only type renders as a read-only type: the lock icon, a banner that says which of
|
|
2558
|
+
* the two reasons applies, no drag handles, no inputs, no remove buttons. The adapter
|
|
2559
|
+
* refuses the same writes independently, so the UI half is presentation and the rule lives
|
|
2560
|
+
* below it.
|
|
2561
|
+
*
|
|
2562
|
+
* Everything else on this screen is generated. The field list, the per-type options and the
|
|
2563
|
+
* validation controls all come from the field type registry, so this file names no field
|
|
2564
|
+
* type.
|
|
2565
|
+
*/
|
|
2566
|
+
interface ContentTypeEditorProps {
|
|
2567
|
+
adapter: CmsAdapter;
|
|
2568
|
+
registry: Registry;
|
|
2569
|
+
entry: RegistryEntry;
|
|
2570
|
+
user: AuthUser;
|
|
2571
|
+
/** Where the Content Types list is, for the breadcrumb. */
|
|
2572
|
+
backHref?: string;
|
|
2573
|
+
}
|
|
2574
|
+
declare function ContentTypeEditor({ adapter, registry, entry, user, backHref, }: ContentTypeEditorProps): react.JSX.Element;
|
|
2575
|
+
|
|
2576
|
+
interface CreateContentTypeDialogProps {
|
|
2577
|
+
open: boolean;
|
|
2578
|
+
registry: Registry;
|
|
2579
|
+
adapter: CmsAdapter;
|
|
2580
|
+
onClose: () => void;
|
|
2581
|
+
onCreated: (slug: string) => void;
|
|
2582
|
+
}
|
|
2583
|
+
declare function CreateContentTypeDialog(props: CreateContentTypeDialogProps): react.JSX.Element | null;
|
|
2584
|
+
|
|
2585
|
+
interface PermissionMatrixEditorProps {
|
|
2586
|
+
/** The content type being edited. Only `permissions` and the labels are read. */
|
|
2587
|
+
type: ContentTypeLike;
|
|
2588
|
+
/** True for a code-defined or locked type, and for a user without manage-schema. */
|
|
2589
|
+
readOnly: boolean;
|
|
2590
|
+
/** The next matrix, already normalized. `undefined` means "back to the role defaults". */
|
|
2591
|
+
onChange: (next: PermissionMatrix | undefined) => void;
|
|
2592
|
+
}
|
|
2593
|
+
declare function PermissionMatrixEditor({ type, readOnly, onChange, }: PermissionMatrixEditorProps): react.JSX.Element;
|
|
2594
|
+
|
|
2595
|
+
/**
|
|
2596
|
+
* The field editor as data: the order fields are shown in, what a new field starts as,
|
|
2597
|
+
* whether a key may be used, and which options are still unanswered.
|
|
2598
|
+
*
|
|
2599
|
+
* Nothing here renders and nothing here writes. The Schema Builder screen calls it to
|
|
2600
|
+
* decide what to show and what to enable, and the same functions are callable from a test
|
|
2601
|
+
* or a script without a DOM.
|
|
2602
|
+
*
|
|
2603
|
+
* None of it switches on a field type. The per-type answers come from each field type's own
|
|
2604
|
+
* `editor` spec through the registry, which is why adding a thirteenth field type adds it
|
|
2605
|
+
* to the Schema Builder with no edit in this file and none in any screen.
|
|
2606
|
+
*/
|
|
2607
|
+
interface FieldRow {
|
|
2608
|
+
key: string;
|
|
2609
|
+
field: FieldDefinition;
|
|
2610
|
+
}
|
|
2611
|
+
type FieldKeyRejection = "invalid" | "reserved" | "live" | "removed";
|
|
2612
|
+
interface FieldKeyAvailability {
|
|
2613
|
+
available: boolean;
|
|
2614
|
+
reason?: FieldKeyRejection;
|
|
2615
|
+
/** Ready to print, and it names what is in the way. */
|
|
2616
|
+
message?: string;
|
|
2617
|
+
}
|
|
2618
|
+
interface FieldOptionIssue {
|
|
2619
|
+
/** The option key the issue belongs to, so the control can show it. */
|
|
2620
|
+
option: string;
|
|
2621
|
+
message: string;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* The field editor: one list of fields, reorderable, each expanding into its own options.
|
|
2626
|
+
*
|
|
2627
|
+
* It renders no field type. It renders **descriptors**: every per-type option and every
|
|
2628
|
+
* validation rule on this screen comes from the field type's own `editor` spec through the
|
|
2629
|
+
* registry, and the only switch in this file is over the small control vocabulary those
|
|
2630
|
+
* descriptors are written in. Adding a thirteenth field type therefore adds its options
|
|
2631
|
+
* here with no edit to this file, which is the same rule that makes the list views and the
|
|
2632
|
+
* entry forms generated rather than written.
|
|
2633
|
+
*
|
|
2634
|
+
* Reordering works from the pointer and from the keyboard, and neither is a fallback for
|
|
2635
|
+
* the other: the handle is a real button, the arrow keys move the field, focus travels with
|
|
2636
|
+
* the row because the rows are keyed by field key, and every move is announced.
|
|
2637
|
+
*
|
|
2638
|
+
* A read-only field list has no handles, no inputs and no remove buttons. Not disabled
|
|
2639
|
+
* controls that still look like controls: a code-defined type is not edited here at all,
|
|
2640
|
+
* and the banner above the list says why.
|
|
2641
|
+
*/
|
|
2642
|
+
interface FieldRowsProps {
|
|
2643
|
+
rows: readonly FieldRow[];
|
|
2644
|
+
registry: FieldTypeRegistry;
|
|
2645
|
+
/** Relation targets, from the live registry. The editor never reads the registry itself. */
|
|
2646
|
+
contentTypes: readonly SelectOption[];
|
|
2647
|
+
readOnly: boolean;
|
|
2648
|
+
/**
|
|
2649
|
+
* Whether a row can be moved.
|
|
2650
|
+
*
|
|
2651
|
+
* False for a repeater's nested fields: their order has nowhere to be stored, because
|
|
2652
|
+
* `fieldOrder` describes a content type and a nested map is part of one field.
|
|
2653
|
+
*/
|
|
2654
|
+
reorderable?: boolean;
|
|
2655
|
+
/** Whether a key may be used here. Differs at the top level and inside a repeater. */
|
|
2656
|
+
checkKey: (key: string) => FieldKeyAvailability;
|
|
2657
|
+
onChange: (rows: readonly FieldRow[]) => void;
|
|
2658
|
+
/**
|
|
2659
|
+
* Removal, when it is not a plain list edit.
|
|
2660
|
+
*
|
|
2661
|
+
* At the top level it never is: removing a field has to go through the warning that
|
|
2662
|
+
* counts the entries holding a value, so the owner handles it. Inside a repeater the row
|
|
2663
|
+
* simply leaves the list.
|
|
2664
|
+
*/
|
|
2665
|
+
onRemove?: (key: string) => void;
|
|
2666
|
+
/** Names the group for assistive technology, and the add button. */
|
|
2667
|
+
label: string;
|
|
2668
|
+
issues?: readonly (FieldOptionIssue & {
|
|
2669
|
+
key: string;
|
|
2670
|
+
})[];
|
|
2671
|
+
}
|
|
2672
|
+
declare function FieldRows({ rows, registry, contentTypes, readOnly, reorderable, checkKey, onChange, onRemove, label, issues, }: FieldRowsProps): react.JSX.Element;
|
|
2673
|
+
|
|
2674
|
+
/**
|
|
2675
|
+
* The removal warning, with a number in it.
|
|
2676
|
+
*
|
|
2677
|
+
* "Some entries may hold data" is not a warning, it is a shrug. `fieldData.count` asks
|
|
2678
|
+
* Firestore how many entries of this content type actually hold a value for this key, and
|
|
2679
|
+
* the dialog prints that number, because it is also exactly the number a later purge would
|
|
2680
|
+
* destroy: count and purge describe the same population by construction. See
|
|
2681
|
+
* .docs/decisions/2026-09-18-schema-field-removal.md.
|
|
2682
|
+
*
|
|
2683
|
+
* The promise the copy makes is the one the code keeps: removing a field changes the
|
|
2684
|
+
* definition and touches no entry. Deleting the values is a separate, separately confirmed
|
|
2685
|
+
* action, which is why there is no checkbox here that does it in passing.
|
|
2686
|
+
*/
|
|
2687
|
+
interface RemoveFieldDialogProps {
|
|
2688
|
+
adapter: CmsAdapter;
|
|
2689
|
+
type: ContentTypeLike;
|
|
2690
|
+
/** The field to remove. Null means the dialog is closed. */
|
|
2691
|
+
fieldKey: string | null;
|
|
2692
|
+
onCancel: () => void;
|
|
2693
|
+
/** The definition change is the caller's: this dialog reads and confirms, never writes. */
|
|
2694
|
+
onConfirm: (fieldKey: string) => void;
|
|
2695
|
+
}
|
|
2696
|
+
declare function RemoveFieldDialog(props: RemoveFieldDialogProps): react.JSX.Element | null;
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* Deleting the stored values of a removed field. The only destructive schema operation.
|
|
2700
|
+
*
|
|
2701
|
+
* It is its own dialog, reached from its own place, with its own typed confirmation. That
|
|
2702
|
+
* separation is the design: removal and purge are two decisions with as much time between
|
|
2703
|
+
* them as the user wants, and no single confirmation can take a field from "this exists" to
|
|
2704
|
+
* "its data is gone".
|
|
2705
|
+
*
|
|
2706
|
+
* One bounded call clears at most `MAX_FIELD_PURGE` entries and reports whether more
|
|
2707
|
+
* remain, so a large purge is resumed rather than run as one unbounded write. The dialog
|
|
2708
|
+
* says how many went and offers the next pass rather than looping silently.
|
|
2709
|
+
*/
|
|
2710
|
+
interface PurgeFieldDialogProps {
|
|
2711
|
+
adapter: CmsAdapter;
|
|
2712
|
+
type: ContentTypeLike;
|
|
2713
|
+
/** The removed field whose values are being deleted. Null means the dialog is closed. */
|
|
2714
|
+
fieldKey: string | null;
|
|
2715
|
+
onCancel: () => void;
|
|
2716
|
+
/** Every value is gone: the caller drops the parked definition and saves. */
|
|
2717
|
+
onPurged: (fieldKey: string) => void;
|
|
2718
|
+
}
|
|
2719
|
+
declare function PurgeFieldDialog(props: PurgeFieldDialogProps): react.JSX.Element | null;
|
|
2720
|
+
|
|
2721
|
+
interface ErrorScreenProps {
|
|
2722
|
+
error: ClassifiedError;
|
|
2723
|
+
/** Shown above the panel. Omitted inside a boundary that already sits under a header. */
|
|
2724
|
+
title?: string | undefined;
|
|
2725
|
+
/** Rendered only when the taxonomy says a retry can help. */
|
|
2726
|
+
onRetry?: (() => void) | undefined;
|
|
2727
|
+
/** One action at most, per the empty-state rule: a link back, or a sign-out. */
|
|
2728
|
+
children?: ReactNode;
|
|
2729
|
+
}
|
|
2730
|
+
declare function ErrorScreen({ error, title, onRetry, children }: ErrorScreenProps): react.JSX.Element;
|
|
2731
|
+
|
|
2732
|
+
/**
|
|
2733
|
+
* The body of an admin route's error boundary.
|
|
2734
|
+
* Canonical source: .docs/styleguide/04-components.md section 19.
|
|
2735
|
+
*
|
|
2736
|
+
* A host mounts it from an `error.tsx`, which is where Next puts the boundary; this is the
|
|
2737
|
+
* fallback the boundary renders, classified through the same taxonomy as an error a screen
|
|
2738
|
+
* caught for itself. The host supplies one `error.tsx` per route segment so a failure in
|
|
2739
|
+
* the media library does not blank the whole admin, plus a `global-error.tsx` for the one
|
|
2740
|
+
* case a segment boundary cannot cover: a throw in the root layout itself.
|
|
2741
|
+
*
|
|
2742
|
+
* Two things it deliberately does not do:
|
|
2743
|
+
*
|
|
2744
|
+
* - **It never prints a stack trace or a raw digest string on its own line.** A boundary
|
|
2745
|
+
* that shows a blank page is bad; one that shows a stack is worse, because it looks
|
|
2746
|
+
* like the product broke open. In production Next replaces a server error's message
|
|
2747
|
+
* with a generic one and a digest, and the digest is appended to the sentence as a
|
|
2748
|
+
* reference rather than displayed as an error.
|
|
2749
|
+
* - **It never offers a retry the taxonomy refuses.** `reset` re-renders the segment,
|
|
2750
|
+
* which resolves a dropped connection and does nothing at all for a permission refusal.
|
|
2751
|
+
* Only a `transport` failure gets the button; everything else gets the way back the
|
|
2752
|
+
* host passed in, and the sidebar, which a route boundary keeps.
|
|
2753
|
+
*/
|
|
2754
|
+
interface RouteErrorProps {
|
|
2755
|
+
/** Exactly what Next hands an `error.tsx`. */
|
|
2756
|
+
error: Error & {
|
|
2757
|
+
digest?: string;
|
|
2758
|
+
};
|
|
2759
|
+
/** Next's re-render of the failed segment. */
|
|
2760
|
+
reset: () => void;
|
|
2761
|
+
/** Shown above the panel. Name the area, so a boundary says where it fired. */
|
|
2762
|
+
title?: string | undefined;
|
|
2763
|
+
/** The signed-in role, so a permission refusal can name who was refused. */
|
|
2764
|
+
role?: Role | undefined;
|
|
2765
|
+
/** One way out, for a failure a retry cannot fix. A link back to the dashboard. */
|
|
2766
|
+
children?: ReactNode;
|
|
2767
|
+
}
|
|
2768
|
+
declare function RouteError({ error, reset, title, role, children }: RouteErrorProps): react.JSX.Element;
|
|
2769
|
+
|
|
2770
|
+
/**
|
|
2771
|
+
* The 404 inside the admin.
|
|
2772
|
+
*
|
|
2773
|
+
* It renders inside the shell, with the sidebar still there, because a missing admin page
|
|
2774
|
+
* is a wrong turn inside the product and not an exit from it. Dropping someone onto a bare
|
|
2775
|
+
* framework error page takes away the one thing they need, which is a way back.
|
|
2776
|
+
*
|
|
2777
|
+
* Built from the empty state, centered in a panel, per .docs/styleguide/03-layout.md
|
|
2778
|
+
* section 5.5. The description deliberately says nothing about what does exist: a 404 is
|
|
2779
|
+
* also what an unauthenticated probe gets, and a list of real routes is an answer.
|
|
2780
|
+
*/
|
|
2781
|
+
interface NotFoundScreenProps {
|
|
2782
|
+
/** One action: a link back to the dashboard. */
|
|
2783
|
+
children?: ReactNode;
|
|
2784
|
+
}
|
|
2785
|
+
declare function NotFoundScreen({ children }: NotFoundScreenProps): react.JSX.Element;
|
|
2786
|
+
|
|
2787
|
+
/**
|
|
2788
|
+
* The Husk login screen.
|
|
2789
|
+
*
|
|
2790
|
+
* It owns no auth. The host passes `onSignIn`, which is expected to reject with an error
|
|
2791
|
+
* whose message can be shown, so the same screen serves the emulator, a real project, and
|
|
2792
|
+
* a test double without branching.
|
|
2793
|
+
*
|
|
2794
|
+
* Errors arrive from two directions and are rendered differently on purpose: a missing
|
|
2795
|
+
* field is the field's problem, a refused credential is the form's.
|
|
2796
|
+
*/
|
|
2797
|
+
interface LoginCredentials {
|
|
2798
|
+
email: string;
|
|
2799
|
+
password: string;
|
|
2800
|
+
}
|
|
2801
|
+
interface LoginScreenProps {
|
|
2802
|
+
onSignIn: (credentials: LoginCredentials) => Promise<void>;
|
|
2803
|
+
/** Set when the guard bounced an expired session here, so the user is told why. */
|
|
2804
|
+
expired?: boolean;
|
|
2805
|
+
}
|
|
2806
|
+
declare function LoginScreen({ onSignIn, expired }: LoginScreenProps): react.JSX.Element;
|
|
2807
|
+
|
|
2808
|
+
interface UnauthorizedScreenProps {
|
|
2809
|
+
/** The permission that was denied, used only to phrase the sentence. */
|
|
2810
|
+
action?: Action;
|
|
2811
|
+
/** The signed-in role, so the user can tell an access problem from a wrong account. */
|
|
2812
|
+
role?: string;
|
|
2813
|
+
/** One action at most: a link back, or a sign-out. */
|
|
2814
|
+
children?: ReactNode;
|
|
2815
|
+
}
|
|
2816
|
+
declare function UnauthorizedScreen({ action, role, children }: UnauthorizedScreenProps): react.JSX.Element;
|
|
2817
|
+
|
|
2818
|
+
/**
|
|
2819
|
+
* Where the `relation` field type gets its entries.
|
|
2820
|
+
*
|
|
2821
|
+
* The field type never touches Firestore, and it never touches the `CmsAdapter` directly
|
|
2822
|
+
* either: it asks this interface, and the admin supplies an implementation. That is what
|
|
2823
|
+
* lets the picker be rendered in a test, in the Schema Builder preview, or against a
|
|
2824
|
+
* different backend without the field type knowing.
|
|
2825
|
+
*/
|
|
2826
|
+
/** One row in the picker: the entry as it is named on screen, not the entry itself. */
|
|
2827
|
+
interface RelationOption {
|
|
2828
|
+
id: string;
|
|
2829
|
+
title: string;
|
|
2830
|
+
status: EntryStatus;
|
|
2831
|
+
}
|
|
2832
|
+
interface RelationSearchRequest {
|
|
2833
|
+
contentType: string;
|
|
2834
|
+
/** Free text. An empty string asks for the first page unfiltered. */
|
|
2835
|
+
search: string;
|
|
2836
|
+
limit?: number;
|
|
2837
|
+
cursor?: string | null;
|
|
2838
|
+
}
|
|
2839
|
+
interface RelationTypeInfo {
|
|
2840
|
+
/** Singular label of the target type, for "Choose an Article". */
|
|
2841
|
+
label: string;
|
|
2842
|
+
/**
|
|
2843
|
+
* Whether `search` narrows the result server-side. False when the target type declares
|
|
2844
|
+
* no `titleField`, because there is then no field to match against and the picker must
|
|
2845
|
+
* say so rather than silently ignore what was typed.
|
|
2846
|
+
*/
|
|
2847
|
+
searchable: boolean;
|
|
2848
|
+
/** False when the schema names a content type that does not exist. */
|
|
2849
|
+
known: boolean;
|
|
2850
|
+
}
|
|
2851
|
+
interface RelationSource {
|
|
2852
|
+
describe(contentType: string): RelationTypeInfo;
|
|
2853
|
+
search(request: RelationSearchRequest): Promise<Page<RelationOption>>;
|
|
2854
|
+
/**
|
|
2855
|
+
* Resolves stored ids to their display titles. Ids with no entry are absent from the
|
|
2856
|
+
* result, which is how the control learns a reference is broken.
|
|
2857
|
+
*/
|
|
2858
|
+
resolve(contentType: string, ids: readonly string[]): Promise<readonly RelationOption[]>;
|
|
2859
|
+
}
|
|
2860
|
+
interface RelationSourceOptions {
|
|
2861
|
+
adapter: CmsAdapter;
|
|
2862
|
+
/** The content type registry, as a lookup. Unknown slugs return undefined. */
|
|
2863
|
+
getType: (slug: string) => ContentTypeLike | undefined;
|
|
2864
|
+
}
|
|
2865
|
+
/**
|
|
2866
|
+
* The real source, over the adapter.
|
|
2867
|
+
*
|
|
2868
|
+
* Two things it deliberately does not do.
|
|
2869
|
+
*
|
|
2870
|
+
* It never lists a whole content type. Every read is one bounded page with a cursor, so a
|
|
2871
|
+
* picker over a type with fifty thousand entries costs the same as one over ten. The
|
|
2872
|
+
* search is a prefix range on the type's `titleField`, which Firestore answers from the
|
|
2873
|
+
* automatic single-field index; it is therefore case-sensitive and matches from the start
|
|
2874
|
+
* of the title, and `describe()` reports when it cannot narrow at all so the picker can say
|
|
2875
|
+
* so instead of appearing to ignore the box.
|
|
2876
|
+
*
|
|
2877
|
+
* It never resolves more ids than it was given. A single relation asks about one id and a
|
|
2878
|
+
* `many` relation asks about the ids already stored on the entry, which the field's
|
|
2879
|
+
* `maxLength` bounds. Nothing here fans out per customer project.
|
|
2880
|
+
*/
|
|
2881
|
+
declare function relationSourceFromAdapter({ adapter, getType, }: RelationSourceOptions): RelationSource;
|
|
2882
|
+
|
|
2883
|
+
interface RelationSourceProviderProps {
|
|
2884
|
+
value: RelationSource;
|
|
2885
|
+
children: ReactNode;
|
|
2886
|
+
}
|
|
2887
|
+
declare function RelationSourceProvider({ value, children }: RelationSourceProviderProps): react.JSX.Element;
|
|
2888
|
+
declare function useRelationSource(): RelationSource | null;
|
|
2889
|
+
|
|
2890
|
+
/**
|
|
2891
|
+
* What a field input may know about the form around it.
|
|
2892
|
+
*
|
|
2893
|
+
* `FieldInputProps` deliberately carries only one field's value, because a field type that
|
|
2894
|
+
* reaches into the form is a field type that cannot be rendered anywhere else. Two field
|
|
2895
|
+
* types genuinely need more than their own value: `slug` derives from a sibling, and any
|
|
2896
|
+
* field carrying `unique` needs someone who can ask the database. Both arrive through this
|
|
2897
|
+
* context rather than through props, so every other field type keeps its narrow surface.
|
|
2898
|
+
*
|
|
2899
|
+
* The context has a working default: no sibling values, no uniqueness check. A field type
|
|
2900
|
+
* rendered without a provider degrades to manual entry rather than throwing.
|
|
2901
|
+
*/
|
|
2902
|
+
/** Resolves to `true` when no other entry of this content type holds the value. */
|
|
2903
|
+
type UniqueCheck = (path: string, value: string) => Promise<boolean>;
|
|
2904
|
+
interface FieldFormContextValue {
|
|
2905
|
+
/** Current values of the entry being edited, keyed by field key. */
|
|
2906
|
+
values: Record<string, unknown>;
|
|
2907
|
+
/** The content type's fields, so a field can name the sibling it derives from. */
|
|
2908
|
+
fields?: FieldMap | undefined;
|
|
2909
|
+
checkUnique?: UniqueCheck | undefined;
|
|
2910
|
+
}
|
|
2911
|
+
interface FieldFormProviderProps {
|
|
2912
|
+
value: FieldFormContextValue;
|
|
2913
|
+
children: ReactNode;
|
|
2914
|
+
}
|
|
2915
|
+
declare function FieldFormProvider({ value, children }: FieldFormProviderProps): react.JSX.Element;
|
|
2916
|
+
declare function useFieldForm(): FieldFormContextValue;
|
|
2917
|
+
|
|
2918
|
+
/**
|
|
2919
|
+
* The label, help text and error wrapper every field type renders.
|
|
2920
|
+
*
|
|
2921
|
+
* It is one component rather than five copies of the same six lines, so the composition
|
|
2922
|
+
* from .docs/styleguide/04-components.md section 3.1 cannot drift between field types.
|
|
2923
|
+
* Only the control inside it differs.
|
|
2924
|
+
*
|
|
2925
|
+
* The first error wins: the guide allows exactly one sentence under a field, and a field
|
|
2926
|
+
* that is both too short and badly formatted is not clearer for saying so twice.
|
|
2927
|
+
*/
|
|
2928
|
+
interface FieldShellProps {
|
|
2929
|
+
field: FieldDefinition;
|
|
2930
|
+
errors: readonly FieldError[];
|
|
2931
|
+
/** Replaces `field.description`, for a field type that has something better to say. */
|
|
2932
|
+
help?: string | undefined;
|
|
2933
|
+
children: FieldProps["children"];
|
|
2934
|
+
}
|
|
2935
|
+
declare function FieldShell({ field, errors, help, children }: FieldShellProps): react.JSX.Element;
|
|
2936
|
+
|
|
2937
|
+
/**
|
|
2938
|
+
* The seam between the `image` and `file` field types and the media library.
|
|
2939
|
+
*
|
|
2940
|
+
* The library itself is M7. The field types are M5, and they must be complete before it
|
|
2941
|
+
* exists, so they are written against this interface rather than against Storage, the
|
|
2942
|
+
* `MediaAdapter`, or a screen. Two consequences, both deliberate:
|
|
2943
|
+
*
|
|
2944
|
+
* 1. The field type never learns that Firebase exists. It asks for an item by id and asks
|
|
2945
|
+
* someone else to open a picker; whoever supplies the context decides how.
|
|
2946
|
+
* 2. Until M7 supplies one, the context is absent, and the control renders the
|
|
2947
|
+
* "library not available" state instead of a dead button. A stored reference is still
|
|
2948
|
+
* shown and still saved, so an entry edited in that window loses nothing.
|
|
2949
|
+
*/
|
|
2950
|
+
interface MediaPickRequest {
|
|
2951
|
+
/** MIME patterns the field accepts, for example `["image/*"]`. Absent means anything. */
|
|
2952
|
+
accept?: readonly string[] | undefined;
|
|
2953
|
+
}
|
|
2954
|
+
interface MediaPickerApi {
|
|
2955
|
+
/**
|
|
2956
|
+
* Resolves stored references. Ids with no media document are simply absent from the
|
|
2957
|
+
* result, which is how the control learns a reference is broken.
|
|
2958
|
+
*/
|
|
2959
|
+
resolve(mediaIds: readonly string[]): Promise<readonly MediaItem[]>;
|
|
2960
|
+
/** Opens the library and resolves to the chosen item, or null when the editor cancels. */
|
|
2961
|
+
pick(request: MediaPickRequest): Promise<MediaItem | null>;
|
|
2962
|
+
}
|
|
2963
|
+
interface MediaPickerProviderProps {
|
|
2964
|
+
value: MediaPickerApi;
|
|
2965
|
+
children: ReactNode;
|
|
2966
|
+
}
|
|
2967
|
+
declare function MediaPickerProvider({ value, children }: MediaPickerProviderProps): react.JSX.Element;
|
|
2968
|
+
/** The installed picker, or null while the media library has not been mounted. */
|
|
2969
|
+
declare function useMediaPicker(): MediaPickerApi | null;
|
|
2970
|
+
/**
|
|
2971
|
+
* What the control knows about the item behind the stored reference.
|
|
2972
|
+
*
|
|
2973
|
+
* `missing` and `loading` are separate states for one reason: a control that showed
|
|
2974
|
+
* "missing" while the lookup was still in flight would accuse every reference of being
|
|
2975
|
+
* broken for as long as the network takes.
|
|
2976
|
+
*/
|
|
2977
|
+
type MediaLookup = {
|
|
2978
|
+
kind: "empty";
|
|
2979
|
+
} | {
|
|
2980
|
+
kind: "unavailable";
|
|
2981
|
+
} | {
|
|
2982
|
+
kind: "loading";
|
|
2983
|
+
} | {
|
|
2984
|
+
kind: "found";
|
|
2985
|
+
item: MediaItem;
|
|
2986
|
+
} | {
|
|
2987
|
+
kind: "missing";
|
|
2988
|
+
};
|
|
2989
|
+
/**
|
|
2990
|
+
* Resolves one stored reference to its media item.
|
|
2991
|
+
*
|
|
2992
|
+
* The resolved item is never written back into the value. A lookup is a read, and a field
|
|
2993
|
+
* whose lookup failed must save exactly what it was given: see the `missing` state in
|
|
2994
|
+
* `media-input.tsx`, which keeps the reference and never calls `onChange`.
|
|
2995
|
+
*/
|
|
2996
|
+
declare function useMediaLookup(reference: MediaReference | null): MediaLookup;
|
|
2997
|
+
|
|
2998
|
+
/**
|
|
2999
|
+
* The one registration point.
|
|
3000
|
+
*
|
|
3001
|
+
* The admin looks a field up here and renders `spec.Input`; it never switches on a field
|
|
3002
|
+
* name. Adding a field type means adding it to this array and nowhere else, and a screen
|
|
3003
|
+
* that needs `switch (field.type)` is telling you the abstraction has failed.
|
|
3004
|
+
*
|
|
3005
|
+
* Every field type the MVP promises is now here, which is what makes the rule checkable
|
|
3006
|
+
* rather than aspirational.
|
|
3007
|
+
*
|
|
3008
|
+
* `repeater` imports this module back, because it renders its nested fields through the
|
|
3009
|
+
* registry. The cycle is deliberate and safe: see the note in `repeater/input.tsx`.
|
|
3010
|
+
*/
|
|
3011
|
+
declare const fieldTypes: FieldTypeRegistry;
|
|
3012
|
+
|
|
3013
|
+
export { AdminMediaPickerProvider, type AdminMediaPickerProviderProps, AdminShell, type AdminShellProps, AdminSidebar, type AdminSidebarProps, type AuthorInfo, type AuthorLookup, Avatar, type AvatarProps, type AvatarSize, Badge, type BadgeProps, type BadgeVariant, Breadcrumb, type BreadcrumbItem, type BreadcrumbProps, Button, type ButtonProps, type ButtonSize, type ButtonVariant, Checkbox, type CheckboxProps, type ClipboardOutcome, CollectionList, type CollectionListProps, ContentTypeEditor, type ContentTypeEditorProps, ContentTypesScreen, type ContentTypesScreenProps, CreateContentTypeDialog, type CreateContentTypeDialogProps, DEFAULT_RECENT_LIMIT, Dashboard, type DashboardCount, type DashboardData, type DashboardProps, type DashboardRecentEntry, DeleteEntriesDialog, type DeleteEntriesDialogProps, ENTRY_STATUS_LABEL, ENTRY_STATUS_VARIANT, EmptyState, type EmptyStateProps, EntryEditor, type EntryEditorProps, EntryMetadata, type EntryMetadataProps, ErrorCapture, type ErrorCaptureProps, ErrorNotice, type ErrorNoticeProps, ErrorScreen, type ErrorScreenProps, Field, type FieldFormContextValue, FieldFormProvider, type FieldFormProviderProps, type FieldProps, FieldRows, type FieldRowsProps, FieldShell, type FieldShellProps, FormError, ICONS, Icon, IconButton, type IconButtonProps, type IconName, type IconProps, type IconSize, type LiveRegistry, type LoadDashboardOptions, type LoginCredentials, LoginScreen, type LoginScreenProps, MEDIA_FILTERS, MediaDeleteDialog, type MediaDeleteDialogProps, MediaDetail, type MediaDetailProps, MediaLibrary, type MediaLibraryProps, type MediaLookup, type MediaPickRequest, type MediaPickerApi, MediaPickerProvider, type MediaPickerProviderProps, MediaTile, type MediaTileProps, type MediaUploadHandle, type MediaUploadPhase, type MediaUploadProgress, type MediaUploadRequest, type MediaUploader, Menu, type MenuEntry, type MenuProps, Metric, type MetricProps, Modal, type ModalProps, type ModalWidth, type MorphIconName, MorphingIcon, type MorphingIconProps, NotFoundScreen, type NotFoundScreenProps, PageHeader, type PageHeaderProps, PageSections, Pagination, type PaginationProps, Panel, type PanelPadding, type PanelProps, PermissionMatrixEditor, type PermissionMatrixEditorProps, Progress, type ProgressProps, PurgeFieldDialog, type PurgeFieldDialogProps, type RelationOption, type RelationSearchRequest, type RelationSource, type RelationSourceOptions, RelationSourceProvider, type RelationSourceProviderProps, type RelationTypeInfo, RemoveFieldDialog, type RemoveFieldDialogProps, RouteError, type RouteErrorProps, type SchemaWatch, SearchInput, type SearchInputProps, Section, type SectionProps, Select, type SelectOption, type SelectProps, Skeleton, type SkeletonLine, type SkeletonProps, type SortDirection, Spinner, type SpinnerProps, StatusDot, type StatusDotProps, type StatusTone, Switch, type SwitchProps, Table, TableBody, TableCell, type TableCellProps, TableEmptyRow, TableHead, TableHeaderCell, type TableHeaderCellProps, type TableProps, TableRow, type TableRowProps, TextInput, type TextInputProps, Textarea, type TextareaProps, type ToastApi, type ToastOptions, ToastProvider, type ToastTone, Tooltip, type TooltipProps, UnauthorizedScreen, type UnauthorizedScreenProps, type UniqueCheck, UnsavedChangesProvider, type UnsavedChangesProviderProps, UsageList, type UsageListProps, canDeleteMedia, canReplaceMedia, copyText, describeUsage, fieldTypes, formatBytes, formatCellValue, formatDate, formatDateTime, formatDimensions, initials, isDeletableType, isImageItem, loadDashboard, pickableItems, queryTypeFilter, relationSourceFromAdapter, uploadAccept, useBeforeUnloadWarning, useConfirmLeave, useFieldForm, useLiveRegistry, useMediaLookup, useMediaPicker, useOptionalToast, useRelationSource, useToast, useUnsavedChanges };
|