@vespercms/admin 0.0.0-stage → 0.1.0-next.0

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.
Files changed (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-KUkv11EB.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,267 @@
1
+ import { Extension, type JSONContent, type MarkdownToken } from '@tiptap/core';
2
+ import { Markdown, type MarkdownManager } from '@tiptap/markdown';
3
+ import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import { ToggleButton, ToolButton, Toolbar, cx } from '@vespercms/ui';
6
+ import { useEffect, useRef, useState } from 'react';
7
+ import { LinkDialog } from '../body/BodyEditor.tsx';
8
+ import { scrollClearOfToolbar, stuckToolbar } from '../body/scroll.ts';
9
+ import { shortcut } from '../body/shortcuts.ts';
10
+ import type { WidgetProps } from '../types.ts';
11
+ import { inputClass } from './basic.tsx';
12
+
13
+ /*
14
+ * Markdown in frontmatter (spec §6.3.2): a compact editor for short rich
15
+ * text, with bold, italic, code, links and (unless the field is inline)
16
+ * paragraphs and lists. Nothing is rewritten until someone edits it. A
17
+ * value that uses Markdown this editor can't show faithfully is edited as
18
+ * plain text, and the field says so.
19
+ */
20
+
21
+ const ALLOWED = new Set([
22
+ 'doc',
23
+ 'paragraph',
24
+ 'text',
25
+ 'hardBreak',
26
+ 'bulletList',
27
+ 'orderedList',
28
+ 'listItem',
29
+ ]);
30
+ const INLINE_ALLOWED = new Set(['doc', 'paragraph', 'text', 'hardBreak']);
31
+
32
+ function nodeTypes(node: JSONContent, found = new Set<string>()): Set<string> {
33
+ if (node.type) found.add(node.type);
34
+ for (const child of node.content ?? []) nodeTypes(child, found);
35
+ return found;
36
+ }
37
+
38
+ /*
39
+ * The tokens of marked, the lexer Tiptap parses with, that the editor holds.
40
+ * Tiptap's parser drops what no extension claims (a strikethrough's tildes,
41
+ * an image, a quote, a rule, a table, a link definition, a list in a
42
+ * one-line field), and the round trip can't see that: it's missing from both
43
+ * sides. HTML is a token too, so a code span holding `<` is still code.
44
+ */
45
+ const INLINE_TOKENS = new Set([
46
+ 'space',
47
+ 'paragraph',
48
+ 'text',
49
+ 'escape',
50
+ 'strong',
51
+ 'em',
52
+ 'codespan',
53
+ 'link',
54
+ 'br',
55
+ ]);
56
+ const TOKENS = new Set([...INLINE_TOKENS, 'list', 'list_item']);
57
+
58
+ /*
59
+ * Text marked keeps as text that the editor would change: a GFM footnote,
60
+ * which it writes back with the brackets escaped, and a named character
61
+ * reference Tiptap doesn't decode (any but `&lt;`, `&gt;`, `&quot;` and
62
+ * `&amp;`), which it shows as written and saves as `&amp;nbsp;`. Escaped
63
+ * characters are tokens of their own, so `\[^1]` and `\&nbsp;` don't match.
64
+ */
65
+ const LOSSY_TEXT = /\[\^|&(?!(?:lt|gt|quot|amp);)[A-Za-z][A-Za-z0-9]*;/;
66
+
67
+ function holds(tokens: MarkdownToken[], allowed: Set<string>): boolean {
68
+ return tokens.every(
69
+ (token) =>
70
+ allowed.has(token.type ?? '') &&
71
+ // A list item's text token carries its line's raw source, escapes and all.
72
+ !(token.type === 'text' && !token.tokens && LOSSY_TEXT.test(token.raw ?? '')) &&
73
+ holds(token.tokens ?? [], allowed) &&
74
+ holds(token.items ?? [], allowed),
75
+ );
76
+ }
77
+
78
+ /** Whether the compact editor can own this value: only tokens and nodes it holds, and it reads back the same. */
79
+ function fits(markdown: MarkdownManager, value: string, inline: boolean): boolean {
80
+ try {
81
+ const tokens = markdown.instance.lexer(value) as MarkdownToken[];
82
+ if (!holds(tokens, inline ? INLINE_TOKENS : TOKENS)) return false;
83
+ const doc = markdown.parse(value);
84
+ const allowed = inline ? INLINE_ALLOWED : ALLOWED;
85
+ if (![...nodeTypes(doc)].every((type) => allowed.has(type))) return false;
86
+ if (inline && (doc.content?.length ?? 0) > 1) return false;
87
+ const again = markdown.parse(markdown.serialize(doc));
88
+ return JSON.stringify(again) === JSON.stringify(doc);
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export function MarkdownWidget(props: WidgetProps<string>) {
95
+ const { value, onChange, field, id, describedBy, invalid, disabled } = props;
96
+ const inline = field.widgetOptions?.inline === true;
97
+ const [plain, setPlain] = useState(false);
98
+ const [linkOpen, setLinkOpen] = useState(false);
99
+ const onChangeRef = useRef(onChange);
100
+ useEffect(() => {
101
+ onChangeRef.current = onChange;
102
+ });
103
+ const toolbar = useRef<HTMLDivElement>(null);
104
+ const [extras] = useState(() =>
105
+ Extension.create({
106
+ name: 'vesperCompactKeys',
107
+ addKeyboardShortcuts() {
108
+ return {
109
+ 'Mod-k': () => {
110
+ setLinkOpen(true);
111
+ return true;
112
+ },
113
+ Escape: () => {
114
+ toolbar.current?.querySelector<HTMLElement>('button')?.focus();
115
+ return true;
116
+ },
117
+ // An inline field is one paragraph.
118
+ ...(inline ? { Enter: () => true, 'Shift-Enter': () => true } : {}),
119
+ };
120
+ },
121
+ }),
122
+ );
123
+
124
+ const editor = useEditor({
125
+ immediatelyRender: true,
126
+ shouldRerenderOnTransaction: false,
127
+ editable: !disabled,
128
+ extensions: [
129
+ StarterKit.configure({
130
+ heading: false,
131
+ blockquote: false,
132
+ codeBlock: false,
133
+ horizontalRule: false,
134
+ strike: false,
135
+ underline: false,
136
+ trailingNode: false,
137
+ ...(inline
138
+ ? { bulletList: false, orderedList: false, listItem: false, listKeymap: false }
139
+ : {}),
140
+ link: { openOnClick: false, autolink: true, defaultProtocol: 'https' },
141
+ }),
142
+ Markdown,
143
+ extras,
144
+ ],
145
+ editorProps: {
146
+ handleScrollToSelection: (view) => scrollClearOfToolbar(view, toolbar.current),
147
+ attributes: {
148
+ id,
149
+ role: 'textbox',
150
+ 'aria-multiline': inline ? 'false' : 'true',
151
+ 'aria-labelledby': `${id}-label`,
152
+ 'aria-describedby': [describedBy, `${id}-md`].filter(Boolean).join(' '),
153
+ ...(invalid ? { 'aria-invalid': 'true' } : {}),
154
+ ...(field.required ? { 'aria-required': 'true' } : {}),
155
+ class: cx(
156
+ 'vesper-body w-full px-3 py-2 text-body text-content outline-none',
157
+ inline ? 'min-h-10' : 'min-h-24',
158
+ ),
159
+ },
160
+ },
161
+ onCreate: ({ editor: created }) => {
162
+ const text = typeof value === 'string' ? value : '';
163
+ const markdown = created.markdown as MarkdownManager;
164
+ if (text !== '' && !fits(markdown, text, inline)) {
165
+ setPlain(true);
166
+ return;
167
+ }
168
+ created.commands.setContent(markdown.parse(text), { emitUpdate: false });
169
+ },
170
+ onUpdate: ({ editor: updated }) => {
171
+ const text = updated.getMarkdown().trim();
172
+ onChangeRef.current(text === '' && !field.required ? undefined : text);
173
+ },
174
+ });
175
+
176
+ const state = useEditorState({
177
+ editor,
178
+ selector: ({ editor: e }) => ({
179
+ bold: e.isActive('bold'),
180
+ italic: e.isActive('italic'),
181
+ code: e.isActive('code'),
182
+ link: e.isActive('link'),
183
+ }),
184
+ });
185
+
186
+ if (plain) {
187
+ return (
188
+ <>
189
+ <textarea
190
+ id={id}
191
+ rows={inline ? 2 : 5}
192
+ value={typeof value === 'string' ? value : ''}
193
+ onChange={(event) => {
194
+ onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
195
+ }}
196
+ aria-describedby={[describedBy, `${id}-md`].filter(Boolean).join(' ')}
197
+ aria-invalid={invalid || undefined}
198
+ aria-required={field.required || undefined}
199
+ disabled={disabled}
200
+ className={cx(inputClass, 'resize-y font-mono text-small')}
201
+ />
202
+ <p id={`${id}-md`} className="text-small text-content-muted">
203
+ This uses Markdown the compact editor can’t show, so it’s edited as text.
204
+ </p>
205
+ </>
206
+ );
207
+ }
208
+
209
+ return (
210
+ <div className="flex flex-col gap-2">
211
+ {/* Framed like the body: the toolbar and the text in one hairline box. */}
212
+ <div
213
+ className={cx(
214
+ 'rounded-md border border-border-strong bg-surface-raised',
215
+ invalid && 'border-danger',
216
+ disabled && 'opacity-60',
217
+ 'has-[.ProseMirror-focused]:outline-2 has-[.ProseMirror-focused]:outline-solid has-[.ProseMirror-focused]:outline-offset-2 has-[.ProseMirror-focused]:outline-focus',
218
+ 'has-[.ProseMirror-focused]:ring-6 has-[.ProseMirror-focused]:ring-(color:--glow-focus)',
219
+ )}
220
+ >
221
+ {/* Stuck below the bar while a long value scrolls, as the body's is. */}
222
+ <div ref={toolbar} className={stuckToolbar}>
223
+ <Toolbar label={`Formatting for ${field.label}`}>
224
+ <ToggleButton
225
+ icon="bold"
226
+ label="Bold"
227
+ shortcut={shortcut('Mod', 'B')}
228
+ isDisabled={disabled}
229
+ isSelected={state.bold}
230
+ onChange={() => editor.chain().focus().toggleBold().run()}
231
+ />
232
+ <ToggleButton
233
+ icon="italic"
234
+ label="Italic"
235
+ shortcut={shortcut('Mod', 'I')}
236
+ isDisabled={disabled}
237
+ isSelected={state.italic}
238
+ onChange={() => editor.chain().focus().toggleItalic().run()}
239
+ />
240
+ <ToggleButton
241
+ icon="code"
242
+ label="Code"
243
+ shortcut={shortcut('Mod', 'E')}
244
+ isDisabled={disabled}
245
+ isSelected={state.code}
246
+ onChange={() => editor.chain().focus().toggleCode().run()}
247
+ />
248
+ <ToolButton
249
+ icon="link"
250
+ label={state.link ? 'Edit link' : 'Link'}
251
+ shortcut={shortcut('Mod', 'K')}
252
+ isDisabled={disabled}
253
+ onPress={() => {
254
+ setLinkOpen(true);
255
+ }}
256
+ />
257
+ </Toolbar>
258
+ </div>
259
+ <EditorContent editor={editor} />
260
+ </div>
261
+ <p id={`${id}-md`} className="text-small text-content-muted">
262
+ Saved as Markdown. Escape moves to the formatting buttons.
263
+ </p>
264
+ <LinkDialog editor={editor} isOpen={linkOpen} onOpenChange={setLinkOpen} />
265
+ </div>
266
+ );
267
+ }
@@ -0,0 +1,196 @@
1
+ import { isPlainObject, type DataValue, type EntrySummary, type FieldModel } from '@vespercms/core';
2
+ import { Button, Combobox, type Option } from '@vespercms/ui';
3
+ import { useMemo, useRef } from 'react';
4
+ import { useStudio } from '../app/context.tsx';
5
+ import { useLoad } from '../app/load.ts';
6
+ import { useEditor } from '../editor/context.tsx';
7
+ import type { WidgetProps } from '../types.ts';
8
+
9
+ /*
10
+ * References (spec §6.2): pick entries of another collection by title. The
11
+ * file stores what Astro's reference() reads, an entry ID, and an existing
12
+ * `{ collection, id }` object keeps that shape.
13
+ */
14
+
15
+ /** The ID a stored reference points at. */
16
+ export function referenceId(value: DataValue): string | undefined {
17
+ if (typeof value === 'string') return value;
18
+ if (isPlainObject(value)) {
19
+ const id = value.id ?? value.slug;
20
+ if (typeof id === 'string') return id;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ /** A new reference in the same shape as the old one. */
26
+ function withId(previous: DataValue, id: string): DataValue {
27
+ return isPlainObject(previous) ? { ...previous, id } : id;
28
+ }
29
+
30
+ function useEntryOptions(collection: string | undefined) {
31
+ const { api, collection: find } = useStudio();
32
+ const editor = useEditor();
33
+ const [load] = useLoad<EntrySummary[]>(
34
+ () => (collection ? api.listEntries(collection) : Promise.resolve([])),
35
+ collection ?? '',
36
+ );
37
+ const entries = useMemo(() => (load.state === 'ready' ? load.value : []), [load]);
38
+ const options = useMemo(() => {
39
+ // An entry can't point at itself.
40
+ const others = entries.filter(
41
+ (e) => !(collection === editor.collection.name && e.id === editor.entryId),
42
+ );
43
+ const titles = new Map<string, number>();
44
+ for (const e of others) titles.set(e.title, (titles.get(e.title) ?? 0) + 1);
45
+ return others.map((e): Option => ({
46
+ value: e.id,
47
+ // Two entries with one title are told apart by their IDs.
48
+ label: (titles.get(e.title) ?? 0) > 1 ? `${e.title} (${e.id})` : e.title,
49
+ }));
50
+ }, [entries, collection, editor.collection.name, editor.entryId]);
51
+ const label = (collection ? find(collection)?.label : undefined) ?? collection ?? 'entries';
52
+ return { options, load, label };
53
+ }
54
+
55
+ function labelFor(options: Option[], id: string): string {
56
+ return options.find((o) => o.value === id)?.label ?? `${id} (not found)`;
57
+ }
58
+
59
+ function targetOf(field: FieldModel): string | undefined {
60
+ return field.reference?.collection ?? field.children?.[0]?.reference?.collection;
61
+ }
62
+
63
+ /** One reference: a searchable list of the other collection's entries. */
64
+ export function ReferenceWidget({
65
+ value,
66
+ onChange,
67
+ field,
68
+ id,
69
+ describedBy,
70
+ invalid,
71
+ disabled,
72
+ }: WidgetProps<DataValue>) {
73
+ const { options, load, label } = useEntryOptions(targetOf(field));
74
+ const current = referenceId(value);
75
+ const all =
76
+ current && !options.some((o) => o.value === current)
77
+ ? [...options, { value: current, label: labelFor(options, current) }]
78
+ : options;
79
+ return (
80
+ <Combobox
81
+ id={id}
82
+ aria-labelledby={`${id}-label`}
83
+ aria-describedby={describedBy}
84
+ options={all}
85
+ value={current ?? null}
86
+ onChange={(next) => {
87
+ onChange(next === null ? undefined : withId(value, next));
88
+ }}
89
+ placeholder={load.state === 'loading' ? 'Loading…' : `Search ${label.toLowerCase()}`}
90
+ isInvalid={invalid}
91
+ isDisabled={disabled}
92
+ />
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Several references, in order. Order usually matters (related posts), so
98
+ * each has Move up, Move down and Remove buttons, and new ones are added
99
+ * from a search box at the end.
100
+ */
101
+ export function ReferencesWidget({
102
+ value,
103
+ onChange,
104
+ field,
105
+ id,
106
+ describedBy,
107
+ invalid,
108
+ disabled,
109
+ }: WidgetProps<DataValue[]>) {
110
+ const { options, load, label } = useEntryOptions(targetOf(field));
111
+ const items: DataValue[] = Array.isArray(value) ? value : [];
112
+ const ids = items.map(referenceId);
113
+ const status = useRef<HTMLParagraphElement>(null);
114
+ const announce = (text: string) => {
115
+ if (status.current) status.current.textContent = text;
116
+ };
117
+ const update = (next: DataValue[]) => {
118
+ onChange(next.length === 0 && !field.required ? undefined : next);
119
+ };
120
+ const move = (from: number, to: number) => {
121
+ const next = [...items];
122
+ const [moved] = next.splice(from, 1);
123
+ next.splice(to, 0, moved);
124
+ update(next);
125
+ announce(`Moved ${labelFor(options, referenceId(moved) ?? '')} to position ${String(to + 1)}.`);
126
+ };
127
+ const available = options.filter((o) => !ids.includes(o.value));
128
+
129
+ return (
130
+ <div className="flex flex-col gap-3">
131
+ {items.length > 0 && (
132
+ <ol className="flex flex-col divide-y divide-border border-y border-border">
133
+ {items.map((item, index) => {
134
+ const name = labelFor(options, referenceId(item) ?? '');
135
+ return (
136
+ <li
137
+ key={`${String(referenceId(item))}-${String(index)}`}
138
+ className="flex flex-wrap items-center gap-2 py-2"
139
+ >
140
+ <span className="min-w-0 flex-1">{name}</span>
141
+ <Button
142
+ variant="quiet"
143
+ isDisabled={disabled || index === 0}
144
+ onPress={() => {
145
+ move(index, index - 1);
146
+ }}
147
+ aria-label={`Move ${name} up`}
148
+ >
149
+ Up
150
+ </Button>
151
+ <Button
152
+ variant="quiet"
153
+ isDisabled={disabled || index === items.length - 1}
154
+ onPress={() => {
155
+ move(index, index + 1);
156
+ }}
157
+ aria-label={`Move ${name} down`}
158
+ >
159
+ Down
160
+ </Button>
161
+ <Button
162
+ variant="quiet"
163
+ isDisabled={disabled}
164
+ onPress={() => {
165
+ update(items.filter((_, i) => i !== index));
166
+ announce(`Removed ${name}.`);
167
+ document.getElementById(id)?.focus();
168
+ }}
169
+ aria-label={`Remove ${name}`}
170
+ >
171
+ Remove
172
+ </Button>
173
+ </li>
174
+ );
175
+ })}
176
+ </ol>
177
+ )}
178
+ <Combobox
179
+ id={id}
180
+ aria-labelledby={`${id}-label`}
181
+ aria-describedby={describedBy}
182
+ options={available}
183
+ value={null}
184
+ onChange={(next) => {
185
+ if (next === null) return;
186
+ update([...items, next]);
187
+ announce(`Added ${labelFor(options, next)}.`);
188
+ }}
189
+ placeholder={load.state === 'loading' ? 'Loading…' : `Add from ${label.toLowerCase()}`}
190
+ isInvalid={invalid}
191
+ isDisabled={disabled}
192
+ />
193
+ <p ref={status} role="status" className="sr-only" />
194
+ </div>
195
+ );
196
+ }
@@ -0,0 +1,104 @@
1
+ import type { FieldKind, FieldModel } from '@vespercms/core';
2
+ import type { ComponentType } from 'react';
3
+ import type { ValuePath } from '../editor/values.ts';
4
+ import type { WidgetProps } from '../types.ts';
5
+ import {
6
+ DateWidget,
7
+ EmailWidget,
8
+ NumberWidget,
9
+ RadioWidget,
10
+ RawWidget,
11
+ SelectWidget,
12
+ SwitchWidget,
13
+ TagsWidget,
14
+ TextAreaWidget,
15
+ TextWidget,
16
+ } from './basic.tsx';
17
+ import { GeoWidget } from './geo.tsx';
18
+ import { GroupWidget, RepeaterWidget } from './group.tsx';
19
+ import { IconWidget } from './icon.tsx';
20
+ import { FileWidget, ImageWidget } from './image.tsx';
21
+ import { ReferencesWidget, ReferenceWidget } from './reference.tsx';
22
+ import { LazyMarkdownWidget } from './markdown-lazy.tsx';
23
+ import { ColorWidget, DateTimeWidget, SlugWidget, UrlWidget } from './special.tsx';
24
+ import { UnionWidget } from './union.tsx';
25
+
26
+ // Built-ins receive values of every shape; the registry can't be narrower.
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ export type BuiltinWidgetProps = WidgetProps<any> & {
29
+ path: ValuePath;
30
+ /** The field is a list item in its own row, which already names it (a union's kind). */
31
+ inRow?: boolean;
32
+ };
33
+
34
+ export type BuiltinWidget = {
35
+ component: ComponentType<BuiltinWidgetProps>;
36
+ /**
37
+ * Renders as a fieldset whose legend is the label (objects, lists, radio
38
+ * groups); `'boxed'` also draws a hairline around it (an image, a map).
39
+ */
40
+ group?: true | 'boxed';
41
+ /** Renders each child (or item) as its own field, so their errors show there. */
42
+ rendersChildren?: true;
43
+ /**
44
+ * At the form's first level it's a section: a hairline above, its own
45
+ * heading, more space (DECISIONS.md, "Top-level groups are sections").
46
+ * Deeper, an object is set off by an indent guide instead of a frame.
47
+ */
48
+ section?: true;
49
+ /** Ids of controls it renders for some of its children, as suffixes of its own id (`alt` → `-alt`). */
50
+ childControls?: Readonly<Record<string, string>>;
51
+ };
52
+
53
+ const BUILTIN: Record<string, BuiltinWidget> = {
54
+ text: { component: TextWidget },
55
+ textarea: { component: TextAreaWidget },
56
+ email: { component: EmailWidget },
57
+ url: { component: UrlWidget },
58
+ number: { component: NumberWidget },
59
+ switch: { component: SwitchWidget },
60
+ date: { component: DateWidget },
61
+ datetime: { component: DateTimeWidget },
62
+ select: { component: SelectWidget },
63
+ radio: { component: RadioWidget, group: true },
64
+ icon: { component: IconWidget },
65
+ tags: { component: TagsWidget },
66
+ multiselect: { component: TagsWidget },
67
+ group: { component: GroupWidget, group: true, rendersChildren: true, section: true },
68
+ union: { component: UnionWidget, group: true, rendersChildren: true, section: true },
69
+ repeater: { component: RepeaterWidget, group: true, rendersChildren: true, section: true },
70
+ raw: { component: RawWidget },
71
+ markdown: { component: LazyMarkdownWidget },
72
+ slug: { component: SlugWidget },
73
+ color: { component: ColorWidget },
74
+ geo: { component: GeoWidget, group: 'boxed' },
75
+ reference: { component: ReferenceWidget },
76
+ references: { component: ReferencesWidget, group: true },
77
+ image: { component: ImageWidget, group: 'boxed', childControls: { alt: '-alt' } },
78
+ file: { component: FileWidget, group: true },
79
+ };
80
+
81
+ const BY_KIND: Partial<Record<FieldKind, string>> = {
82
+ string: 'text',
83
+ number: 'number',
84
+ boolean: 'switch',
85
+ date: 'date',
86
+ enum: 'select',
87
+ object: 'group',
88
+ union: 'union',
89
+ array: 'repeater',
90
+ reference: 'reference',
91
+ image: 'image',
92
+ };
93
+
94
+ /**
95
+ * The built-in control for a field: its resolved widget, else the one its
96
+ * type implies (spec §6.3 step 3), else the raw YAML field (step 4).
97
+ */
98
+ export function builtinWidget(field: FieldModel): BuiltinWidget {
99
+ for (const name of [field.widget, field.inferredWidget, BY_KIND[field.kind]]) {
100
+ const widget = name ? BUILTIN[name] : undefined;
101
+ if (widget) return widget;
102
+ }
103
+ return BUILTIN.raw as BuiltinWidget;
104
+ }