@vespercms/admin 0.0.0-stage → 0.1.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -2
- package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-4fVeej3c.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +35 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +16 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/screens/status.d.ts +36 -0
- package/dist/shortcuts-CarKeDqp.js +4666 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6449 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/blank-date.d.ts +8 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +298 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +303 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +122 -0
- package/src/screens/EntryEditor.tsx +1817 -0
- package/src/screens/EntryList.tsx +272 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +477 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +738 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/screens/status.tsx +104 -0
- package/src/studio.css +168 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +315 -0
- package/src/widgets/blank-date.ts +32 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +290 -0
- package/src/widgets/union.tsx +233 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fromWallTime,
|
|
3
|
+
isoWithOffset,
|
|
4
|
+
offsetOf,
|
|
5
|
+
resolveTimeZone,
|
|
6
|
+
slugify,
|
|
7
|
+
wallTime,
|
|
8
|
+
zoneName,
|
|
9
|
+
} from '@vespercms/core';
|
|
10
|
+
import { RadioGroup, cx } from '@vespercms/ui';
|
|
11
|
+
import { useEffect, useState } from 'react';
|
|
12
|
+
import { useStudio } from '../app/context.tsx';
|
|
13
|
+
import { useEditor } from '../editor/context.tsx';
|
|
14
|
+
import { ChoosePageButton } from '../editor/PagePicker.tsx';
|
|
15
|
+
import type { WidgetProps } from '../types.ts';
|
|
16
|
+
import { inputClass } from './basic.tsx';
|
|
17
|
+
import { useBlankWhenEmpty } from './blank-date.ts';
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* The built-in widgets with behaviour of their own (spec §6.3.2). Every one
|
|
21
|
+
* keeps a plain native or ui-kit control as its primary, accessible control.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const HEX6 = /^#[0-9a-f]{6}$/i;
|
|
25
|
+
|
|
26
|
+
function describedByWith(describedBy: string | undefined, ...ids: (string | false)[]): string {
|
|
27
|
+
return [describedBy, ...ids].filter(Boolean).join(' ');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A small square showing a colour, with a hairline so pale colours stay visible. */
|
|
31
|
+
function Swatch({ color }: { color: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
className="inline-block size-6 shrink-0 rounded-sm border border-border-strong"
|
|
35
|
+
style={{ backgroundColor: color }}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Colour (spec §6.3.2). The hex text field is the primary control; the
|
|
42
|
+
* native picker is a convenience beside it. With a palette, it's a radio
|
|
43
|
+
* group of named swatches instead, each named in words.
|
|
44
|
+
*/
|
|
45
|
+
export function ColorWidget({
|
|
46
|
+
value,
|
|
47
|
+
onChange,
|
|
48
|
+
field,
|
|
49
|
+
id,
|
|
50
|
+
describedBy,
|
|
51
|
+
invalid,
|
|
52
|
+
disabled,
|
|
53
|
+
}: WidgetProps<string>) {
|
|
54
|
+
const palette = field.widgetOptions?.palette as Record<string, string> | undefined;
|
|
55
|
+
const current = typeof value === 'string' ? value : '';
|
|
56
|
+
|
|
57
|
+
if (palette && Object.keys(palette).length > 0) {
|
|
58
|
+
const options = Object.entries(palette).map(([name, hex]) => ({
|
|
59
|
+
value: hex,
|
|
60
|
+
label: `${name} (${hex})`,
|
|
61
|
+
prefix: <Swatch color={hex} />,
|
|
62
|
+
}));
|
|
63
|
+
if (current && !options.some((o) => o.value.toLowerCase() === current.toLowerCase())) {
|
|
64
|
+
options.push({
|
|
65
|
+
value: current,
|
|
66
|
+
label: `Current colour (${current})`,
|
|
67
|
+
prefix: <Swatch color={current} />,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return (
|
|
71
|
+
<RadioGroup
|
|
72
|
+
id={id}
|
|
73
|
+
aria-labelledby={`${id}-label`}
|
|
74
|
+
aria-describedby={describedBy}
|
|
75
|
+
options={options}
|
|
76
|
+
value={options.find((o) => o.value.toLowerCase() === current.toLowerCase())?.value ?? null}
|
|
77
|
+
onChange={onChange}
|
|
78
|
+
isInvalid={invalid}
|
|
79
|
+
isDisabled={disabled}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex items-center gap-3">
|
|
86
|
+
<input
|
|
87
|
+
id={id}
|
|
88
|
+
type="text"
|
|
89
|
+
value={current}
|
|
90
|
+
placeholder="#1a73e8"
|
|
91
|
+
spellCheck={false}
|
|
92
|
+
autoComplete="off"
|
|
93
|
+
onChange={(event) => {
|
|
94
|
+
onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
|
|
95
|
+
}}
|
|
96
|
+
aria-describedby={describedBy}
|
|
97
|
+
aria-invalid={invalid || undefined}
|
|
98
|
+
aria-required={field.required || undefined}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
className={cx(inputClass, 'max-w-40 font-mono')}
|
|
101
|
+
/>
|
|
102
|
+
{/* The picker can't show alpha, so 8-digit colours are text only. */}
|
|
103
|
+
{field.widgetOptions?.alpha !== true && (
|
|
104
|
+
<input
|
|
105
|
+
type="color"
|
|
106
|
+
aria-label={`Pick ${field.label.toLowerCase()} from a colour picker`}
|
|
107
|
+
value={HEX6.test(current) ? current.toLowerCase() : '#000000'}
|
|
108
|
+
onChange={(event) => {
|
|
109
|
+
onChange(event.target.value);
|
|
110
|
+
}}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
className="size-10 cursor-pointer rounded-md border border-border-strong bg-surface p-1 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)"
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Slug (spec §6.3.2): follows its source field (usually the title) until
|
|
121
|
+
* someone edits it by hand. It follows only on entries that haven't been
|
|
122
|
+
* saved yet, so a published page's URL never changes behind anyone's back.
|
|
123
|
+
*/
|
|
124
|
+
export function SlugWidget({
|
|
125
|
+
value,
|
|
126
|
+
onChange,
|
|
127
|
+
field,
|
|
128
|
+
id,
|
|
129
|
+
describedBy,
|
|
130
|
+
invalid,
|
|
131
|
+
disabled,
|
|
132
|
+
}: WidgetProps<string>) {
|
|
133
|
+
const editor = useEditor();
|
|
134
|
+
const from = typeof field.widgetOptions?.from === 'string' ? field.widgetOptions.from : undefined;
|
|
135
|
+
const source = from ? editor.values[from] : undefined;
|
|
136
|
+
const sourceText = typeof source === 'string' ? source : '';
|
|
137
|
+
const current = typeof value === 'string' ? value : '';
|
|
138
|
+
const [following, setFollowing] = useState(
|
|
139
|
+
() => editor.isNew && from !== undefined && (current === '' || current === toSlug(sourceText)),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!following) return;
|
|
144
|
+
const next = toSlug(sourceText);
|
|
145
|
+
if (next !== current) onChange(next === '' && !field.required ? undefined : next);
|
|
146
|
+
}, [following, sourceText, current, onChange, field.required]);
|
|
147
|
+
|
|
148
|
+
const sourceLabel = editor.collection.fields?.find((f) => f.path[0] === from)?.label ?? from;
|
|
149
|
+
return (
|
|
150
|
+
<>
|
|
151
|
+
<input
|
|
152
|
+
id={id}
|
|
153
|
+
type="text"
|
|
154
|
+
value={current}
|
|
155
|
+
spellCheck={false}
|
|
156
|
+
autoComplete="off"
|
|
157
|
+
onChange={(event) => {
|
|
158
|
+
setFollowing(false);
|
|
159
|
+
onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
|
|
160
|
+
}}
|
|
161
|
+
aria-describedby={describedByWith(describedBy, following && `${id}-follows`)}
|
|
162
|
+
aria-invalid={invalid || undefined}
|
|
163
|
+
aria-required={field.required || undefined}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
className={cx(inputClass, 'font-mono')}
|
|
166
|
+
/>
|
|
167
|
+
{following && sourceLabel && (
|
|
168
|
+
<p id={`${id}-follows`} className="text-small text-content-muted">
|
|
169
|
+
Follows the {sourceLabel.toLowerCase()} until you edit it.
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toSlug(text: string): string {
|
|
177
|
+
return slugify(text).replaceAll('/', '-');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** URL, with a link that opens it (spec §6.3.2). */
|
|
181
|
+
export function UrlWidget({
|
|
182
|
+
value,
|
|
183
|
+
onChange,
|
|
184
|
+
field,
|
|
185
|
+
id,
|
|
186
|
+
describedBy,
|
|
187
|
+
invalid,
|
|
188
|
+
disabled,
|
|
189
|
+
}: WidgetProps<string>) {
|
|
190
|
+
const { manifest } = useStudio();
|
|
191
|
+
const current = typeof value === 'string' ? value : '';
|
|
192
|
+
const openable = /^https?:\/\/\S+$/i.test(current);
|
|
193
|
+
// A schema that takes only full URLs (z.url()) gets the site's address in
|
|
194
|
+
// front of the page's path, which needs Astro's `site`; without it, no picker.
|
|
195
|
+
const site = manifest.site.urls?.site;
|
|
196
|
+
const pickable = field.format !== 'url' || site !== undefined;
|
|
197
|
+
return (
|
|
198
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
199
|
+
<input
|
|
200
|
+
id={id}
|
|
201
|
+
type="url"
|
|
202
|
+
inputMode="url"
|
|
203
|
+
value={current}
|
|
204
|
+
placeholder={field.placeholder ?? 'https://'}
|
|
205
|
+
onChange={(event) => {
|
|
206
|
+
onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
|
|
207
|
+
}}
|
|
208
|
+
aria-describedby={describedBy}
|
|
209
|
+
aria-invalid={invalid || undefined}
|
|
210
|
+
aria-required={field.required || undefined}
|
|
211
|
+
disabled={disabled}
|
|
212
|
+
className={inputClass}
|
|
213
|
+
/>
|
|
214
|
+
{openable && (
|
|
215
|
+
<a
|
|
216
|
+
href={current}
|
|
217
|
+
target="_blank"
|
|
218
|
+
rel="noopener noreferrer"
|
|
219
|
+
className="shrink-0 rounded-sm text-accent underline underline-offset-2 hover:text-accent-hover focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)"
|
|
220
|
+
>
|
|
221
|
+
Open <span className="sr-only">{current} in a new tab</span>
|
|
222
|
+
</a>
|
|
223
|
+
)}
|
|
224
|
+
{pickable && (
|
|
225
|
+
<ChoosePageButton
|
|
226
|
+
fieldLabel={field.label}
|
|
227
|
+
{...(field.format === 'url' && site ? { absolute: site } : {})}
|
|
228
|
+
{...(disabled ? { isDisabled: true } : {})}
|
|
229
|
+
onPick={(url) => {
|
|
230
|
+
onChange(url);
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Date and time in the site's time zone, named in text (spec §6.17). The
|
|
240
|
+
* value keeps its type: a Date stays a Date, written with that day's offset.
|
|
241
|
+
*/
|
|
242
|
+
export function DateTimeWidget({
|
|
243
|
+
value,
|
|
244
|
+
onChange,
|
|
245
|
+
field,
|
|
246
|
+
id,
|
|
247
|
+
describedBy,
|
|
248
|
+
invalid,
|
|
249
|
+
disabled,
|
|
250
|
+
}: WidgetProps<Date | string>) {
|
|
251
|
+
const { manifest } = useStudio();
|
|
252
|
+
const { zone, isSite } = resolveTimeZone(manifest.site.timeZone);
|
|
253
|
+
const instant =
|
|
254
|
+
value instanceof Date ? value : typeof value === 'string' && value ? new Date(value) : null;
|
|
255
|
+
const valid = instant !== null && !Number.isNaN(instant.getTime());
|
|
256
|
+
const name = zoneName(zone, valid ? instant : new Date());
|
|
257
|
+
const wall = valid ? wallTime(instant, zone) : '';
|
|
258
|
+
const blank = useBlankWhenEmpty(wall);
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
<input
|
|
262
|
+
id={id}
|
|
263
|
+
type="datetime-local"
|
|
264
|
+
value={wall}
|
|
265
|
+
{...blank}
|
|
266
|
+
onChange={(event) => {
|
|
267
|
+
if (event.target.value === '') {
|
|
268
|
+
onChange(undefined);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const next = fromWallTime(event.target.value, zone);
|
|
272
|
+
if (!next) return;
|
|
273
|
+
// A quoted string stays a string, with the offset written out.
|
|
274
|
+
onChange(typeof value === 'string' ? isoWithOffset(next, offsetOf(next) ?? 0) : next);
|
|
275
|
+
}}
|
|
276
|
+
aria-describedby={describedByWith(describedBy, `${id}-zone`)}
|
|
277
|
+
aria-invalid={invalid || undefined}
|
|
278
|
+
aria-required={field.required || undefined}
|
|
279
|
+
disabled={disabled}
|
|
280
|
+
required={field.required}
|
|
281
|
+
className={cx(inputClass, 'max-w-64')}
|
|
282
|
+
/>
|
|
283
|
+
<p id={`${id}-zone`} className="text-small text-content-muted">
|
|
284
|
+
{isSite
|
|
285
|
+
? `Times are in ${name}, the site’s time zone.`
|
|
286
|
+
: `Times are in your time zone, ${name}.`}
|
|
287
|
+
</p>
|
|
288
|
+
</>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import {
|
|
2
|
+
humanize,
|
|
3
|
+
isPlainObject,
|
|
4
|
+
variantFor,
|
|
5
|
+
type DataObject,
|
|
6
|
+
type DataValue,
|
|
7
|
+
type FieldModel,
|
|
8
|
+
type FieldVariant,
|
|
9
|
+
} from '@vespercms/core';
|
|
10
|
+
import { RadioGroup, Select } from '@vespercms/ui';
|
|
11
|
+
import { useRef } from 'react';
|
|
12
|
+
import { formatCount } from '../app/format.ts';
|
|
13
|
+
import { Field } from '../editor/Field.tsx';
|
|
14
|
+
import { initialValues, keyOf, type ValuePath } from '../editor/values.ts';
|
|
15
|
+
import type { WidgetProps } from '../types.ts';
|
|
16
|
+
import { RawWidget } from './basic.tsx';
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* A discriminated union of objects: one value of several kinds (DECISIONS.md,
|
|
20
|
+
* "Blocks are a discriminated union of objects"). In a list each item is a
|
|
21
|
+
* row named by its kind, and its "…" menu adds and changes kinds; on its own
|
|
22
|
+
* the field starts with a choice of kind. Either way the fields shown are the
|
|
23
|
+
* chosen kind's, and a kind the schema doesn't list is edited as YAML.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A new value of a kind: its key first, then its fields' own defaults, as a new entry gets them. */
|
|
27
|
+
export function kindValue(union: FieldModel, kind: FieldVariant): DataObject {
|
|
28
|
+
return { [union.discriminator ?? '']: kind.value, ...initialValues(kind.children) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A value that holds nothing a person typed: undefined, empty text, an empty list or object. */
|
|
32
|
+
function isBlank(value: DataValue): boolean {
|
|
33
|
+
if (value === undefined || value === null || value === '') return true;
|
|
34
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
35
|
+
return isPlainObject(value) && Object.values(value).every(isBlank);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type KindChange = {
|
|
39
|
+
value: DataObject;
|
|
40
|
+
/** Labels of the fields both kinds have, whose values carried over. */
|
|
41
|
+
kept: string[];
|
|
42
|
+
/** Labels of the fields with content the new kind has no place for. */
|
|
43
|
+
dropped: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A value moved to another kind. A field the new kind has under the same key
|
|
48
|
+
* and of the same kind keeps its value; the rest are dropped. `setAside` is
|
|
49
|
+
* what this value last held as the new kind, in this visit: changing back
|
|
50
|
+
* before saving brings it back.
|
|
51
|
+
*/
|
|
52
|
+
export function changeKind(
|
|
53
|
+
union: FieldModel,
|
|
54
|
+
value: DataValue,
|
|
55
|
+
to: FieldVariant,
|
|
56
|
+
setAside?: DataObject,
|
|
57
|
+
): KindChange {
|
|
58
|
+
const key = union.discriminator ?? '';
|
|
59
|
+
const from = variantFor(union, value);
|
|
60
|
+
const current = isPlainObject(value) ? value : {};
|
|
61
|
+
const defaults = initialValues(to.children);
|
|
62
|
+
const next: DataObject = { [key]: to.value };
|
|
63
|
+
const kept: string[] = [];
|
|
64
|
+
for (const child of to.children) {
|
|
65
|
+
const name = keyOf(child);
|
|
66
|
+
const was = from?.children.find((c) => keyOf(c) === name);
|
|
67
|
+
// An unknown kind's fields can't be compared, so any the new kind has are offered to it.
|
|
68
|
+
const fits = from ? was?.kind === child.kind : true;
|
|
69
|
+
const carried = fits ? current[name] : undefined;
|
|
70
|
+
if (carried !== undefined) {
|
|
71
|
+
next[name] = carried;
|
|
72
|
+
if (!isBlank(carried)) kept.push(child.label);
|
|
73
|
+
} else if (setAside?.[name] !== undefined) {
|
|
74
|
+
next[name] = setAside[name];
|
|
75
|
+
} else if (defaults[name] !== undefined) {
|
|
76
|
+
next[name] = defaults[name];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const dropped = Object.keys(current)
|
|
80
|
+
.filter((name) => name !== key && !(name in next) && !isBlank(current[name]))
|
|
81
|
+
.map((name) => from?.children.find((c) => keyOf(c) === name)?.label ?? humanize(name));
|
|
82
|
+
return { value: next, kept, dropped };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "Heading kept. Cards and Layout removed." for the status line after a change of kind. */
|
|
86
|
+
export function changeSummary(change: KindChange): string {
|
|
87
|
+
const list = (labels: string[]) =>
|
|
88
|
+
labels.length <= 1
|
|
89
|
+
? (labels[0] ?? '')
|
|
90
|
+
: `${labels.slice(0, -1).join(', ')} and ${labels.at(-1) ?? ''}`;
|
|
91
|
+
return [
|
|
92
|
+
change.kept.length > 0 ? `${list(change.kept)} kept.` : '',
|
|
93
|
+
change.dropped.length > 0 ? `${list(change.dropped)} removed.` : '',
|
|
94
|
+
]
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(' ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const NAME_KEYS = ['title', 'name', 'label', 'heading', 'text'];
|
|
100
|
+
const plain = (text: string) => text.replace(/[*_`]/g, '').trim();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How a list names an item of a kind: by its kind, with its own words after
|
|
104
|
+
* it ("Card grid", "This industry has rules"). On a page, the order of kinds
|
|
105
|
+
* is what someone scans for, and kinds are short names.
|
|
106
|
+
*/
|
|
107
|
+
export function kindRow(value: DataValue, union: FieldModel): { name: string; detail: string } {
|
|
108
|
+
const kind = variantFor(union, value);
|
|
109
|
+
if (!kind) {
|
|
110
|
+
const named = isPlainObject(value) ? value[union.discriminator ?? ''] : undefined;
|
|
111
|
+
return {
|
|
112
|
+
name: typeof named === 'string' && named ? `Unknown kind: ${named}` : 'No kind',
|
|
113
|
+
detail: '',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (!isPlainObject(value)) return { name: kind.label, detail: '' };
|
|
117
|
+
const shown = kind.children.filter((child) => !child.hidden);
|
|
118
|
+
for (const key of [kind.summary, ...NAME_KEYS, ...shown.map(keyOf)]) {
|
|
119
|
+
const text = key ? value[key] : undefined;
|
|
120
|
+
if (typeof text === 'string' && plain(text)) return { name: kind.label, detail: plain(text) };
|
|
121
|
+
}
|
|
122
|
+
const list = shown.find((child) => child.kind === 'array');
|
|
123
|
+
if (list) {
|
|
124
|
+
const held = value[keyOf(list)];
|
|
125
|
+
const many = list.label.toLowerCase();
|
|
126
|
+
const one = /[^s]s$/.test(many) ? many.slice(0, -1) : many;
|
|
127
|
+
return {
|
|
128
|
+
name: kind.label,
|
|
129
|
+
detail: formatCount(Array.isArray(held) ? held.length : 0, one, many),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { name: kind.label, detail: '' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A kind's fields, as an object's are shown. */
|
|
136
|
+
function KindFields({ kind, path }: { kind: FieldVariant; path: ValuePath }) {
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex flex-col gap-4">
|
|
139
|
+
{kind.children
|
|
140
|
+
.filter((child) => !child.hidden)
|
|
141
|
+
.map((child) => (
|
|
142
|
+
<Field key={keyOf(child)} field={child} path={[...path, keyOf(child)]} />
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const NONE = '';
|
|
149
|
+
|
|
150
|
+
type UnionProps = WidgetProps<DataValue> & { path: ValuePath; inRow?: boolean };
|
|
151
|
+
|
|
152
|
+
export function UnionWidget(props: UnionProps) {
|
|
153
|
+
const { value, onChange, field, path, id, describedBy, invalid, disabled, inRow } = props;
|
|
154
|
+
// What the value held as each kind it left, so changing back brings it back.
|
|
155
|
+
const setAside = useRef(new Map<string, DataObject>());
|
|
156
|
+
const kind = variantFor(field, value);
|
|
157
|
+
const kinds = field.variants ?? [];
|
|
158
|
+
const unknown = value !== undefined && value !== null && !kind;
|
|
159
|
+
|
|
160
|
+
const body = kind ? (
|
|
161
|
+
kind.children.length > 0 ? (
|
|
162
|
+
<KindFields kind={kind} path={path} />
|
|
163
|
+
) : inRow ? (
|
|
164
|
+
<p className="text-small text-content-muted">This kind has nothing more to fill in.</p>
|
|
165
|
+
) : null
|
|
166
|
+
) : unknown ? (
|
|
167
|
+
<div className="flex flex-col gap-2">
|
|
168
|
+
<label htmlFor={`${id}-yaml`} className="text-label text-content">
|
|
169
|
+
{kindRow(value, field).name}
|
|
170
|
+
</label>
|
|
171
|
+
<p id={`${id}-unknown`} className="text-small text-content-muted">
|
|
172
|
+
The site has no kind by this name. Change it to one it has, or edit it here.
|
|
173
|
+
</p>
|
|
174
|
+
<RawWidget
|
|
175
|
+
value={value}
|
|
176
|
+
onChange={onChange}
|
|
177
|
+
field={{ ...field, unknownReason: undefined }}
|
|
178
|
+
id={`${id}-yaml`}
|
|
179
|
+
describedBy={[describedBy, `${id}-unknown`].filter(Boolean).join(' ')}
|
|
180
|
+
invalid={invalid}
|
|
181
|
+
disabled={disabled}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
) : null;
|
|
185
|
+
|
|
186
|
+
// In a list, the row names the kind and its "…" menu changes it.
|
|
187
|
+
if (inRow) return body;
|
|
188
|
+
|
|
189
|
+
const choose = (next: string) => {
|
|
190
|
+
if (kind) setAside.current.set(kind.value, isPlainObject(value) ? value : {});
|
|
191
|
+
const to = kinds.find((one) => one.value === next);
|
|
192
|
+
if (!to) {
|
|
193
|
+
onChange(undefined);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
onChange(changeKind(field, value, to, setAside.current.get(to.value)).value);
|
|
197
|
+
};
|
|
198
|
+
const options = [
|
|
199
|
+
...(field.required ? [] : [{ value: NONE, label: 'None' }]),
|
|
200
|
+
...kinds.map((one) => ({ value: one.value, label: one.label })),
|
|
201
|
+
];
|
|
202
|
+
const selected = kind ? kind.value : value === undefined ? NONE : null;
|
|
203
|
+
const chooser =
|
|
204
|
+
options.length <= 4 ? (
|
|
205
|
+
<RadioGroup
|
|
206
|
+
id={`${id}-kind`}
|
|
207
|
+
label="Kind"
|
|
208
|
+
options={options}
|
|
209
|
+
value={selected}
|
|
210
|
+
onChange={choose}
|
|
211
|
+
isDisabled={disabled}
|
|
212
|
+
orientation={options.length <= 3 ? 'horizontal' : 'vertical'}
|
|
213
|
+
/>
|
|
214
|
+
) : (
|
|
215
|
+
<Select
|
|
216
|
+
id={`${id}-kind`}
|
|
217
|
+
label="Kind"
|
|
218
|
+
options={options}
|
|
219
|
+
value={selected}
|
|
220
|
+
onChange={(next) => {
|
|
221
|
+
choose(next ?? NONE);
|
|
222
|
+
}}
|
|
223
|
+
isDisabled={disabled}
|
|
224
|
+
/>
|
|
225
|
+
);
|
|
226
|
+
return (
|
|
227
|
+
<div className="flex flex-col gap-4">
|
|
228
|
+
{chooser}
|
|
229
|
+
{kind?.description && <p className="text-small text-content-muted">{kind.description}</p>}
|
|
230
|
+
{body}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|