@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.
Files changed (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.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-4fVeej3c.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 +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -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/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,1817 @@
1
+ import {
2
+ DEFAULT_URL_SETTINGS,
3
+ type A11yNote,
4
+ decodeData,
5
+ deepEqual,
6
+ encodeValue,
7
+ entryPath as treePathOf,
8
+ entryUrlKey,
9
+ formatUrl,
10
+ lineEndings,
11
+ scheduleTime,
12
+ type BodyFormat,
13
+ type BodyIssue,
14
+ type CollectionManifest,
15
+ type DataObject,
16
+ type DataValue,
17
+ type EntryDocument,
18
+ type FieldIssue,
19
+ type FieldModel,
20
+ type Manifest,
21
+ type Draft,
22
+ type PageTreeNode,
23
+ type Review,
24
+ type SaveRequest,
25
+ } from '@vespercms/core';
26
+ import {
27
+ Button,
28
+ Dialog,
29
+ ErrorSummary,
30
+ IconButton,
31
+ InlineStatus,
32
+ Link,
33
+ Menu,
34
+ Tabs,
35
+ TextArea,
36
+ TextField,
37
+ type MenuItem,
38
+ } from '@vespercms/ui';
39
+ import {
40
+ Suspense,
41
+ lazy,
42
+ useCallback,
43
+ useEffect,
44
+ useLayoutEffect,
45
+ useMemo,
46
+ useRef,
47
+ useState,
48
+ type ReactNode,
49
+ } from 'react';
50
+ import { ApiError } from '../app/api.ts';
51
+ import { useStudio } from '../app/context.tsx';
52
+ import { formatCount, formatRelative } from '../app/format.ts';
53
+ import { useLoad } from '../app/load.ts';
54
+ import { usePane } from '../app/pane-context.ts';
55
+ import { entriesChanged, onEntriesRewritten } from '../app/entries-changed.ts';
56
+ import { hasUnsavedChanges, hrefFor, navigate, setLeaveGuard } from '../app/router.ts';
57
+ import { Screen, useMeasure } from '../app/Screen.tsx';
58
+ import { arrangeFields } from '../editor/arrange.ts';
59
+ import { copiedValues } from '../editor/copy.ts';
60
+ import { EditorContext, useEditor, type EditorState } from '../editor/context.tsx';
61
+ import { Field } from '../editor/Field.tsx';
62
+ import { focusWhenShown, REVEAL_EVENT } from '../editor/reveal.ts';
63
+ import { issuePlace, issueTarget } from '../editor/target.ts';
64
+ import {
65
+ fieldId,
66
+ getIn,
67
+ initialValues,
68
+ keyOf,
69
+ pathKey,
70
+ setIn,
71
+ type ValuePath,
72
+ } from '../editor/values.ts';
73
+ import type { BodyChecks } from '../body/checks.ts';
74
+ import type { BodyApi } from '../body/BodyEditor.tsx';
75
+ import { isMac } from '../body/shortcuts.ts';
76
+ import { useNow } from '../schedule/now.ts';
77
+ import { ScheduleDialog } from '../schedule/ScheduleDialog.tsx';
78
+ import { ScheduleSummary } from '../schedule/ScheduleSummary.tsx';
79
+ import { holdUntil } from '../schedule/cloud.ts';
80
+ import { scheduleKeys, siteZone, timesOf, withTimes } from '../schedule/times.ts';
81
+ import { StateWords, entryState, isNewInDraft } from './status.tsx';
82
+ import { useEntryNotes } from '../a11y/useNotes.ts';
83
+ import {
84
+ ConflictCompare,
85
+ DeployLine,
86
+ RequestReviewDialog,
87
+ keptChanges,
88
+ keptKey,
89
+ useEntryDraft,
90
+ } from '../cloud/editor.tsx';
91
+ import { NotesDrawer, notesCount, type NoteGroup } from '../a11y/NotesDrawer.tsx';
92
+
93
+ // Tiptap and the Markdown tooling are most of the studio's weight, and only
94
+ // entries with a body need them: they load the first time one is opened.
95
+ const BodyEditor = lazy(() =>
96
+ import('../body/BodyEditor.tsx').then((module) => ({ default: module.BodyEditor })),
97
+ );
98
+
99
+ /**
100
+ * Opens an entry (or starts a new one when `id` is null) and hands it to the
101
+ * form. A reload after a conflict remounts the form with the new version.
102
+ * With `copy`, the entry is the original of a new one (Duplicate).
103
+ */
104
+ export function EntryEditor({
105
+ collection,
106
+ id,
107
+ parent,
108
+ copy = false,
109
+ }: {
110
+ collection: CollectionManifest;
111
+ id: string | null;
112
+ /** New pages in a tree collection: the parent page's path (spec §6.15.2). */
113
+ parent?: string;
114
+ copy?: boolean;
115
+ }) {
116
+ const { api } = useStudio();
117
+ const [load, reload] = useLoad<EntryDocument | null>(
118
+ () => (id === null ? Promise.resolve(null) : api.readEntry(collection.name, id)),
119
+ id ?? '',
120
+ );
121
+ // A move in the list beside it can write this entry's order number. Read
122
+ // it again, or the next save would find a version it didn't start from.
123
+ useEffect(
124
+ () =>
125
+ onEntriesRewritten(collection.name, (ids) => {
126
+ if (id !== null && ids.includes(id) && !hasUnsavedChanges()) reload();
127
+ }),
128
+ [collection.name, id, reload],
129
+ );
130
+
131
+ if (load.state === 'loading') {
132
+ return (
133
+ <Screen title="Loading…">
134
+ <p className="text-content-muted">Opening the entry…</p>
135
+ </Screen>
136
+ );
137
+ }
138
+ if (load.state === 'failed') {
139
+ return (
140
+ <Screen title="Couldn’t open this entry">
141
+ <p className="mb-4">{load.message}</p>
142
+ <Link icon="back" href={hrefFor({ screen: 'list', collection: collection.name })}>
143
+ Back to {collection.label}
144
+ </Link>
145
+ </Screen>
146
+ );
147
+ }
148
+ if (copy && load.value?.status !== 'editable') {
149
+ return (
150
+ <Screen title="This entry can’t be duplicated">
151
+ <p className="mb-4">
152
+ {load.value?.message ?? 'Only entries the studio can edit as fields can be duplicated.'}
153
+ </p>
154
+ <Link icon="back" href={hrefFor({ screen: 'list', collection: collection.name })}>
155
+ Back to {collection.label}
156
+ </Link>
157
+ </Screen>
158
+ );
159
+ }
160
+ // A copy in a tree goes beside its original, under the same parent page.
161
+ const copyParent =
162
+ copy && load.value && collection.presentation.tree ? parentPath(treePathOf(load.value.id)) : '';
163
+ return (
164
+ <EntryForm
165
+ key={load.value?.version ?? 'new'}
166
+ collection={collection}
167
+ doc={load.value}
168
+ copy={copy}
169
+ onReload={reload}
170
+ {...(parent !== undefined ? { parent } : copyParent ? { parent: copyParent } : {})}
171
+ />
172
+ );
173
+ }
174
+
175
+ type Saved = { values: DataObject; body: string; raw: string };
176
+ type Notice =
177
+ | { kind: 'saved'; at: Date; reformatted: boolean }
178
+ | { kind: 'conflict' | 'error' | 'expired'; text: string }
179
+ | null;
180
+ /** What happened when the entry's draft was published, or sent for review (the cloud). */
181
+ type CloudNotice =
182
+ | { kind: 'published'; commit: string }
183
+ | { kind: 'conflict'; path: string; published: string | null }
184
+ | { kind: 'refused' | 'kept' | 'nothing' | 'review-requested' | 'held'; text: string }
185
+ | null;
186
+
187
+ function EntryForm({
188
+ collection,
189
+ doc,
190
+ copy,
191
+ onReload,
192
+ parent,
193
+ }: {
194
+ collection: CollectionManifest;
195
+ /** The entry, or a copy's original. */
196
+ doc: EntryDocument | null;
197
+ /** A new entry that starts as a copy of `doc` (Duplicate, spec §10.3). */
198
+ copy: boolean;
199
+ onReload: () => void;
200
+ parent?: string;
201
+ }) {
202
+ const { api, siteUrl, manifest, loadMdxProps, account, cloud } = useStudio();
203
+ // GitHub mode publishes on save: the button and its words say so (spec §6.8).
204
+ const publishes = api.publishesOnSave();
205
+ /** The entry being edited: none yet for a new one, a copy included. */
206
+ const entry = copy ? null : doc;
207
+ const isNew = entry === null;
208
+ const status = doc?.status ?? 'editable';
209
+ /** The entry's file: known once it's saved, guessed for a new one. */
210
+ const [savedPath, setSavedPath] = useState<string | null>(entry?.path ?? null);
211
+ const pathRef = useRef(savedPath);
212
+ const filePath = savedPath ?? likelyPath(collection, parent);
213
+ // In the cloud, the member's grants decide what they may change (spec §7.5).
214
+ const access = cloud ? cloud.access(filePath) : null;
215
+ const editable = collection.editable && status !== 'read-only' && (access?.edit ?? true);
216
+ const sourceOnly = status === 'source-only';
217
+ const isContent = collection.entryKind === 'content';
218
+
219
+ // The body starts with the line break after the closing `---` (Astro's
220
+ // `body`), usually followed by a blank line. The textarea shows the text
221
+ // without those leading line breaks, and saves add them back.
222
+ const bodyPrefix = /^(?:\r?\n)+/.exec(doc?.body ?? '')?.[0] ?? '';
223
+ const bodyEol = lineEndings(doc?.body ?? '').dominant;
224
+ const initial = useMemo<Saved>(
225
+ () => ({
226
+ values: doc
227
+ ? copy
228
+ ? copiedValues(collection, decodeData(doc.data))
229
+ : decodeData(doc.data)
230
+ : initialValues(collection.fields),
231
+ body: (doc?.body ?? '').slice(bodyPrefix.length).replace(/\r\n/g, '\n'),
232
+ raw: (doc?.rawFrontmatter ?? '').replace(/^\r?\n/, ''),
233
+ }),
234
+ [doc, copy, collection, bodyPrefix.length],
235
+ );
236
+
237
+ /** Where unsaved changes are kept: by entry, with a copy apart from its original and from new entries. */
238
+ const keptId = copy && doc ? `${doc.id}\ncopy` : (entry?.id ?? null);
239
+ // Changes kept across a sign-in that expired mid-edit, put back once.
240
+ const [kept] = useState(() => takeKept(collection.name, keptId));
241
+ const [values, setValues] = useState<DataObject>(kept?.values ?? initial.values);
242
+ const [body, setBody] = useState(kept?.body ?? initial.body);
243
+ const [raw, setRaw] = useState(kept?.raw ?? initial.raw);
244
+ const [saved, setSaved] = useState<Saved>(initial);
245
+ const [version, setVersion] = useState<string | null>(entry?.version ?? null);
246
+ const [entryId, setEntryId] = useState<string | null>(entry?.id ?? null);
247
+ const [saving, setSaving] = useState(false);
248
+ const [issues, setIssues] = useState<FieldIssue[]>([]);
249
+ const [notice, setNotice] = useState<Notice>(null);
250
+ const [confirmDelete, setConfirmDelete] = useState(false);
251
+ // The cloud (spec §7.4): drafts save themselves, and Publish or Request review sends them on.
252
+ const { draft: entryDraft, review } = useEntryDraft(filePath);
253
+ const [cloudNotice, setCloudNotice] = useState<CloudNotice>(null);
254
+ const [publishing, setPublishing] = useState(false);
255
+ const [reviewOpen, setReviewOpen] = useState(false);
256
+ const [confirmDiscard, setConfirmDiscard] = useState(false);
257
+ /** The last draft save was refused by the entry's own checks: the status says so, quietly. */
258
+ const [draftBlocked, setDraftBlocked] = useState(false);
259
+ /** The cloud couldn't be reached: changes are kept on this device, and saving tries again. */
260
+ const [offline, setOffline] = useState(false);
261
+ const [retries, setRetries] = useState(0);
262
+ const [restored, setRestored] = useState(false);
263
+ /** The tab shown: Content (`main`), Details, or one the collection declares. */
264
+ const [tab, setTab] = useState('main');
265
+ // Among panes, the list is beside the entry already, so the bar needs no way back to it.
266
+ const pane = usePane();
267
+ /** Each successful save glows Save once (glow-accent). */
268
+ const [savedPulse, setSavedPulse] = useState(0);
269
+
270
+ const dirty =
271
+ !deepEqual(values, saved.values) || body !== saved.body || (sourceOnly && raw !== saved.raw);
272
+ const dirtyRef = useRef(dirty);
273
+ useEffect(() => {
274
+ dirtyRef.current = dirty;
275
+ }, [dirty]);
276
+
277
+ const arranged = useMemo(() => arrangeFields(collection), [collection]);
278
+ // As local mode has always had it; the cloud's draft lookups use `filePath`.
279
+ // A copy's relative paths start from its original's file, and saving
280
+ // rewrites them if the copy lands in another folder.
281
+ const entryPath = doc?.path ?? likelyPath(collection);
282
+ const format = bodyFormat(doc, collection);
283
+
284
+ // The body is checked as the site's build would check it (spec §6.5.1):
285
+ // live, a moment after typing stops, and again on save. Problems show on
286
+ // their blocks as they appear, and in the summary once a save is refused.
287
+ const [partials, setPartials] = useState<string[] | null>(null);
288
+ useEffect(() => {
289
+ if (!isContent || format !== 'mdoc') return;
290
+ let live = true;
291
+ api.listPartials().then(
292
+ (files) => {
293
+ if (live) setPartials(files);
294
+ },
295
+ () => {
296
+ // Without the list, partial files simply aren't checked here; the dev API still checks on save.
297
+ },
298
+ );
299
+ return () => {
300
+ live = false;
301
+ };
302
+ }, [api, isContent, format]);
303
+ // Markdoc, micromark and Zod load with the checks, on first use, like the body editor.
304
+ const checks = useMemo(() => {
305
+ let loaded: Promise<BodyChecks> | null = null;
306
+ return {
307
+ validate: async (text: string) => {
308
+ loaded ??= import('../body/checks.ts').then((module) =>
309
+ module.bodyChecks({ manifest, format, loadMdxProps, partials, entryPath }),
310
+ );
311
+ return (await loaded).validate(text);
312
+ },
313
+ };
314
+ }, [manifest, format, loadMdxProps, partials, entryPath]);
315
+ const [bodyIssues, setBodyIssues] = useState<BodyIssue[]>([]);
316
+ const [refusals, setRefusals] = useState(0);
317
+ /** The last save was refused by the publish gate alone. */
318
+ const [gateRefused, setGateRefused] = useState(false);
319
+ useEffect(() => {
320
+ if (!isContent) return;
321
+ let live = true;
322
+ const timer = setTimeout(() => {
323
+ void checks.validate(body).then((found) => {
324
+ if (live) setBodyIssues(found);
325
+ });
326
+ }, 300);
327
+ return () => {
328
+ live = false;
329
+ clearTimeout(timer);
330
+ };
331
+ }, [body, checks, isContent]);
332
+ const bodyErrors = bodyIssues.filter((issue) => issue.level === 'error');
333
+ const titleText = titleOf(values, arranged.title) || entryId || 'New entry';
334
+
335
+ // Issues are shown under the deepest field that's on screen for them.
336
+ const targets = useMemo(
337
+ () =>
338
+ issues.map((issue) => ({ issue, target: issueTarget(collection.fields ?? [], issue.path) })),
339
+ [issues, collection.fields],
340
+ );
341
+ const issueMap = useMemo(() => {
342
+ const map = new Map<string, string[]>();
343
+ for (const { issue, target } of targets) {
344
+ const key = pathKey(target.path);
345
+ map.set(key, [...(map.get(key) ?? []), issue.message]);
346
+ }
347
+ return map;
348
+ }, [targets]);
349
+
350
+ // Accessibility notes (spec §6.18): field notes go under their field, the
351
+ // body's under the body.
352
+ const fileUrl = useCallback((path: string) => api.fileUrl(path), [api]);
353
+ const found = useEntryNotes({
354
+ manifest,
355
+ collection,
356
+ values,
357
+ body: isContent && !sourceOnly ? body : undefined,
358
+ format,
359
+ entryPath,
360
+ fileUrl,
361
+ });
362
+ const { notes, recheck } = found;
363
+ // The publish gate (spec §6.18): notes on these rules refuse a save.
364
+ const blockOn = useMemo(
365
+ () => new Set<string>(manifest.site.a11y?.blockPublishOn ?? []),
366
+ [manifest.site.a11y],
367
+ );
368
+ const blocking = useMemo(() => notes.filter((n) => blockOn.has(n.rule)), [notes, blockOn]);
369
+ const bodyNotes = useMemo(() => notes.filter((n) => n.target.kind === 'body'), [notes]);
370
+ const noteMap = useMemo(() => {
371
+ const map = new Map<string, A11yNote[]>();
372
+ for (const note of notes) {
373
+ if (note.target.kind !== 'field') continue;
374
+ const key = pathKey(issueTarget(collection.fields ?? [], note.target.path).path);
375
+ map.set(key, [...(map.get(key) ?? []), note]);
376
+ }
377
+ return map;
378
+ }, [notes, collection.fields]);
379
+ const bodyApi = useRef<BodyApi | null>(null);
380
+ const [notesOpen, setNotesOpen] = useState(false);
381
+ /** A note whose field gets focus once the drawer has closed. */
382
+ const [goTo, setGoTo] = useState<A11yNote | null>(null);
383
+
384
+ /** Where a note is shown: its field's focus target and name, or the body. */
385
+ const placeOf = useCallback(
386
+ (note: A11yNote) => {
387
+ if (note.target.kind === 'body')
388
+ return { key: 'body', label: 'Body', focusId: 'entry-body', top: '' };
389
+ const target = issueTarget(collection.fields ?? [], note.target.path);
390
+ return {
391
+ key: pathKey(target.path) || 'entry',
392
+ label: issuePlace(collection.fields ?? [], note.target.path, values),
393
+ focusId: target.focusId,
394
+ top: String(note.target.path[0] ?? ''),
395
+ };
396
+ },
397
+ [collection.fields, values],
398
+ );
399
+ // Grouped under each field in the order the form shows them: title, main fields, body, Details.
400
+ const noteGroups = useMemo<NoteGroup[]>(() => {
401
+ const order = [
402
+ ...(arranged.title ? [keyOf(arranged.title)] : []),
403
+ ...arranged.main.map(keyOf),
404
+ '',
405
+ ...arranged.details.map(keyOf),
406
+ ];
407
+ const groups = new Map<string, NoteGroup & { rank: number }>();
408
+ for (const note of notes) {
409
+ const place = placeOf(note);
410
+ const group = groups.get(place.key) ?? {
411
+ key: place.key,
412
+ label: place.label,
413
+ notes: [],
414
+ rank: order.indexOf(place.top),
415
+ };
416
+ group.notes.push(note);
417
+ groups.set(place.key, group);
418
+ }
419
+ return [...groups.values()].sort((a, b) => a.rank - b.rank);
420
+ }, [notes, placeOf, arranged]);
421
+
422
+ const goToNote = useCallback(
423
+ (note: A11yNote) => {
424
+ if (note.target.kind === 'body') {
425
+ bodyApi.current?.reveal(note);
426
+ return;
427
+ }
428
+ // A field on another tab, or in a closed item, is shown first (editor/reveal.ts).
429
+ focusWhenShown(placeOf(note).focusId);
430
+ },
431
+ [placeOf],
432
+ );
433
+ // Once the drawer has closed. React Aria gives focus back to the count
434
+ // only if focus fell to the page, so moving it first wins.
435
+ useEffect(() => {
436
+ if (notesOpen || !goTo) return;
437
+ const timer = setTimeout(() => {
438
+ goToNote(goTo);
439
+ setGoTo(null);
440
+ }, 0);
441
+ return () => {
442
+ clearTimeout(timer);
443
+ };
444
+ }, [notesOpen, goTo, goToNote]);
445
+
446
+ const setValue = useCallback((path: ValuePath, value: DataValue) => {
447
+ setValues((current) => setIn(current, path, value));
448
+ }, []);
449
+ const editor = useMemo<EditorState>(
450
+ () => ({
451
+ collection,
452
+ values,
453
+ setValue,
454
+ issues: issueMap,
455
+ notes: noteMap,
456
+ disabled: !editable || sourceOnly,
457
+ isNew,
458
+ entryId,
459
+ entryPath,
460
+ }),
461
+ [
462
+ collection,
463
+ values,
464
+ setValue,
465
+ issueMap,
466
+ noteMap,
467
+ editable,
468
+ sourceOnly,
469
+ isNew,
470
+ entryId,
471
+ entryPath,
472
+ ],
473
+ );
474
+
475
+ /**
476
+ * Saves the entry, and says whether it was. In the cloud a save is a
477
+ * draft: `auto` is the save a moment after typing stops, which is quiet
478
+ * about problems (the status names them) and tries again while the cloud
479
+ * can't be reached.
480
+ */
481
+ const save = useCallback(
482
+ async ({
483
+ pastGate = false,
484
+ auto = false,
485
+ override,
486
+ }: {
487
+ pastGate?: boolean;
488
+ auto?: boolean;
489
+ /** Values to save in place of the form's, set in the same step (a schedule's times). */
490
+ override?: DataObject;
491
+ } = {}) => {
492
+ const current = override ?? values;
493
+ if (!editable || saving) return false;
494
+ setSaving(true);
495
+ if (!auto) setNotice(null);
496
+ // The publish gate, on the values as they are now (spec §6.18). Where Save
497
+ // only writes the file on this computer, "Save anyway" goes past it
498
+ // (DECISIONS.md, "The publish gate refuses a save"). A cloud draft isn't
499
+ // published, so the gate waits for Publish.
500
+ if (
501
+ !cloud &&
502
+ !pastGate &&
503
+ blockOn.size > 0 &&
504
+ (await recheck()).some((note) => blockOn.has(note.rule))
505
+ ) {
506
+ setGateRefused(true);
507
+ setRefusals((n) => n + 1);
508
+ setSaving(false);
509
+ return false;
510
+ }
511
+ setGateRefused(false);
512
+ const bodyChanged = body !== saved.body;
513
+ // A body the site couldn't be built with isn't saved (spec §6.5.1).
514
+ if (isContent && (bodyChanged || isNew)) {
515
+ const found = await checks.validate(body);
516
+ setBodyIssues(found);
517
+ if (found.some((issue) => issue.level === 'error')) {
518
+ if (auto) setDraftBlocked(true);
519
+ else setRefusals((n) => n + 1);
520
+ setSaving(false);
521
+ return false;
522
+ }
523
+ }
524
+ const request: SaveRequest = {
525
+ collection: collection.name,
526
+ id: entryId ?? '',
527
+ version,
528
+ ...(sourceOnly
529
+ ? { rawFrontmatter: `\n${raw.endsWith('\n') ? raw : `${raw}\n`}` }
530
+ : { data: encodeValue(current) }),
531
+ ...(isContent && (bodyChanged || isNew)
532
+ ? { body: bodyPrefix + (bodyEol === '\r\n' ? body.replace(/\n/g, '\r\n') : body) }
533
+ : {}),
534
+ ...(isNew && parent !== undefined ? { parent } : {}),
535
+ ...(copy && doc && version === null ? { copyOf: { id: doc.id } } : {}),
536
+ };
537
+ try {
538
+ const result = await api.saveEntry(request);
539
+ setOffline(false);
540
+ if (result.ok) {
541
+ const snapshot = { values: current, body, raw };
542
+ setSaved(snapshot);
543
+ dirtyRef.current = false;
544
+ setVersion(result.version);
545
+ setEntryId(result.id);
546
+ setSavedPath(result.path);
547
+ pathRef.current = result.path;
548
+ setIssues([]);
549
+ setRefusals(0);
550
+ setDraftBlocked(false);
551
+ setRetries(0);
552
+ setNotice({ kind: 'saved', at: new Date(), reformatted: result.reformatted });
553
+ // The list beside the editor shows the new title or status.
554
+ entriesChanged(collection.name);
555
+ if (!auto) setSavedPulse((n) => n + 1);
556
+ // "Save anyway" goes with the summary, so focus moves to Save.
557
+ if (pastGate) focusWhenShown(SAVE_ID);
558
+ if (isNew || result.id !== entryId) {
559
+ navigate(
560
+ { screen: 'edit', collection: collection.name, id: result.id },
561
+ { replace: true },
562
+ );
563
+ }
564
+ } else if (result.reason === 'invalid' && auto) {
565
+ // A draft is a file the site could build: until it is, it stays on this device.
566
+ setDraftBlocked(true);
567
+ } else if (result.reason === 'invalid') {
568
+ setIssues(result.issues);
569
+ // The dev API checks the body again with the site's real config.
570
+ if (result.bodyIssues) setBodyIssues(result.bodyIssues);
571
+ setRefusals((n) => n + 1);
572
+ } else {
573
+ setNotice({ kind: result.reason, text: result.message });
574
+ }
575
+ return result.ok;
576
+ } catch (error) {
577
+ if (cloud && error instanceof ApiError && error.status === 0) {
578
+ // The cloud can't be reached: the changes are kept on this device, and saving tries again.
579
+ setOffline(true);
580
+ setRetries((n) => n + 1);
581
+ if (!auto) setNotice({ kind: 'error', text: error.message });
582
+ } else if (error instanceof ApiError && error.status === 401 && account) {
583
+ // Kept for after the sign-in redirect, which would otherwise lose them.
584
+ keep(collection.name, entryId ?? keptId, { values: current, body, raw });
585
+ setNotice({ kind: 'expired', text: error.message });
586
+ } else {
587
+ setNotice({
588
+ kind: 'error',
589
+ text: error instanceof Error ? error.message : String(error),
590
+ });
591
+ }
592
+ return false;
593
+ } finally {
594
+ setSaving(false);
595
+ }
596
+ },
597
+ [
598
+ cloud,
599
+ editable,
600
+ saving,
601
+ body,
602
+ saved.body,
603
+ collection.name,
604
+ entryId,
605
+ version,
606
+ sourceOnly,
607
+ raw,
608
+ values,
609
+ isContent,
610
+ isNew,
611
+ bodyPrefix,
612
+ bodyEol,
613
+ api,
614
+ checks,
615
+ parent,
616
+ blockOn,
617
+ recheck,
618
+ account,
619
+ copy,
620
+ doc,
621
+ keptId,
622
+ ],
623
+ );
624
+
625
+ // Ctrl+S / ⌘S saves; unsaved changes are protected from navigation and reloads.
626
+ useEffect(() => {
627
+ const onKey = (event: KeyboardEvent) => {
628
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
629
+ event.preventDefault();
630
+ void save();
631
+ }
632
+ };
633
+ const onUnload = (event: BeforeUnloadEvent) => {
634
+ if (dirtyRef.current) event.preventDefault();
635
+ };
636
+ window.addEventListener('keydown', onKey);
637
+ window.addEventListener('beforeunload', onUnload);
638
+ return () => {
639
+ window.removeEventListener('keydown', onKey);
640
+ window.removeEventListener('beforeunload', onUnload);
641
+ };
642
+ }, [save]);
643
+ useEffect(() => setLeaveGuard(() => dirtyRef.current), []);
644
+
645
+ // The cloud: a draft saves itself a moment after typing stops (spec §7.4).
646
+ // A new entry waits for its first save by hand, since its file name comes
647
+ // from the title, and a half-typed title would make a poor one. While the
648
+ // cloud can't be reached, saving tries again, waiting longer each time.
649
+ useEffect(() => {
650
+ if (!cloud || isNew || !editable || !dirty || saving) return;
651
+ const wait = offline ? Math.min(60_000, 5_000 * 2 ** Math.max(0, retries - 1)) : 1_000;
652
+ const timer = setTimeout(() => void save({ auto: true }), wait);
653
+ return () => {
654
+ clearTimeout(timer);
655
+ };
656
+ }, [cloud, isNew, editable, dirty, saving, save, offline, retries]);
657
+
658
+ // Unsaved changes are kept on this device as they're typed (spec §6.9), and
659
+ // put back if the page reloads before they reach the cloud.
660
+ const keptAt = cloud ? keptKey(cloud.client.settings.project, collection.name, keptId) : null;
661
+ // Until the kept copy has been looked for, nothing is written or cleared:
662
+ // the editor opens clean, and clearing then would lose what it's about to restore.
663
+ const [keptChecked, setKeptChecked] = useState(false);
664
+ useEffect(() => {
665
+ if (!keptAt || !keptChecked) return;
666
+ if (!dirty) {
667
+ void keptChanges().delete(keptAt);
668
+ return;
669
+ }
670
+ const timer = setTimeout(() => {
671
+ void keptChanges().put(keptAt, { values: encodeValue(values), body, raw });
672
+ }, 300);
673
+ return () => {
674
+ clearTimeout(timer);
675
+ };
676
+ }, [keptAt, keptChecked, dirty, values, body, raw]);
677
+ useEffect(() => {
678
+ if (!keptAt) return;
679
+ let live = true;
680
+ void keptChanges()
681
+ .get(keptAt)
682
+ .then((found) => {
683
+ if (!live) return;
684
+ if (found && !kept) {
685
+ setValues(decodeData(found.values));
686
+ setBody(found.body);
687
+ setRaw(found.raw);
688
+ setRestored(true);
689
+ }
690
+ setKeptChecked(true);
691
+ });
692
+ return () => {
693
+ live = false;
694
+ };
695
+ // Once, when the entry opens.
696
+ // eslint-disable-next-line react-hooks/exhaustive-deps
697
+ }, [keptAt]);
698
+
699
+ /** Publishes the entry's draft, saving it first (spec §7.4). The publish gate applies here. */
700
+ const publishDraft = async (override?: DataObject) => {
701
+ if (!cloud || publishing) return;
702
+ setCloudNotice(null);
703
+ if (blockOn.size > 0 && (await recheck()).some((note) => blockOn.has(note.rule))) {
704
+ setGateRefused(true);
705
+ setRefusals((n) => n + 1);
706
+ return;
707
+ }
708
+ if ((dirtyRef.current || isNew || override) && !(await save(override ? { override } : {})))
709
+ return;
710
+ setPublishing(true);
711
+ try {
712
+ await cloud.drafts.refresh();
713
+ const path = pathRef.current ?? filePath;
714
+ const draft = cloud.drafts.draftOf(path);
715
+ if (!draft) {
716
+ setCloudNotice({ kind: 'nothing', text: 'There’s nothing new to publish.' });
717
+ return;
718
+ }
719
+ // A site that holds scheduled drafts keeps this one until its time (spec §6.17).
720
+ const until = holdUntil(
721
+ cloud.drafts.get().schedule,
722
+ timesOf(collection, override ?? values).publishAt,
723
+ );
724
+ if (until) {
725
+ const held = await cloud.drafts.hold([draft.id], until);
726
+ setCloudNotice(
727
+ held.ok
728
+ ? {
729
+ kind: 'held',
730
+ text: `Scheduled. Vesper keeps it as a draft until ${scheduleTime(until, siteZone(manifest).zone, new Date())}, then publishes it.`,
731
+ }
732
+ : { kind: 'refused', text: held.message },
733
+ );
734
+ return;
735
+ }
736
+ const result = await cloud.drafts.publish([draft.id]);
737
+ if (result.ok) setCloudNotice({ kind: 'published', commit: result.commit });
738
+ else if (result.reason === 'conflict') {
739
+ const conflict = result.conflicts.find((c) => c.path === path) ?? result.conflicts[0];
740
+ setCloudNotice({
741
+ kind: 'conflict',
742
+ path: conflict?.path ?? path,
743
+ published: conflict?.actual ?? null,
744
+ });
745
+ } else setCloudNotice({ kind: 'refused', text: result.message });
746
+ } catch (error) {
747
+ setCloudNotice({
748
+ kind: 'refused',
749
+ text: error instanceof Error ? error.message : String(error),
750
+ });
751
+ } finally {
752
+ setPublishing(false);
753
+ }
754
+ };
755
+
756
+ /** Saves the draft, then asks for review: for those who edit but don't publish (spec §7.4). */
757
+ const startReview = async () => {
758
+ if (!cloud) return;
759
+ setCloudNotice(null);
760
+ if ((dirtyRef.current || isNew) && !(await save())) return;
761
+ setReviewOpen(true);
762
+ };
763
+ const sendForReview = async (note: string) => {
764
+ if (!cloud) return;
765
+ try {
766
+ await cloud.drafts.refresh();
767
+ const draft = cloud.drafts.draftOf(pathRef.current ?? filePath);
768
+ if (!draft) {
769
+ setCloudNotice({ kind: 'nothing', text: 'There’s nothing new to review.' });
770
+ return;
771
+ }
772
+ await cloud.drafts.requestReview([draft.id], note);
773
+ setCloudNotice({
774
+ kind: 'review-requested',
775
+ text: 'Sent for review. An approver will publish it or ask for changes.',
776
+ });
777
+ } catch (error) {
778
+ setCloudNotice({
779
+ kind: 'refused',
780
+ text: error instanceof Error ? error.message : String(error),
781
+ });
782
+ } finally {
783
+ setReviewOpen(false);
784
+ }
785
+ };
786
+ const discardDraft = async () => {
787
+ setConfirmDiscard(false);
788
+ if (!cloud || !entryDraft) return;
789
+ await cloud.drafts.discard(entryDraft.id);
790
+ dirtyRef.current = false;
791
+ if (keptAt) await keptChanges().delete(keptAt);
792
+ onReload();
793
+ };
794
+
795
+ const deleteEntry = async () => {
796
+ if (!entryId || !version) return;
797
+ const result = await api.deleteEntry(collection.name, entryId, version);
798
+ setConfirmDelete(false);
799
+ if (result.ok) {
800
+ dirtyRef.current = false;
801
+ navigate({ screen: 'list', collection: collection.name }, { replace: true });
802
+ } else {
803
+ setNotice({
804
+ kind: result.reason === 'conflict' ? 'conflict' : 'error',
805
+ text: 'message' in result ? result.message : 'The entry couldn’t be deleted.',
806
+ });
807
+ }
808
+ };
809
+
810
+ // A function-form URL runs only where vesper.config.ts is loaded: in the
811
+ // browser in GitHub mode and the cloud, in the dev API in local mode. The
812
+ // site's page list has every entry's address worked out either way.
813
+ const functionUrl = useFunctionUrl(collection, isNew ? null : entryId, version);
814
+ const viewUrl =
815
+ !isNew && entryId && siteUrl !== undefined
816
+ ? typeof collection.presentation.url === 'string'
817
+ ? pageUrl(collection, entryId, saved.values, manifest)
818
+ : functionUrl
819
+ : null;
820
+ const editingLink = !isNew && entryId ? editingLinkOf(collection.name, entryId) : null;
821
+ /** A link to copy that the clipboard refused: shown, selected, to copy by hand. */
822
+ const [copyByHand, setCopyByHand] = useState<{ label: string; text: string } | null>(null);
823
+ /** "Link copied" stands in the status for a few seconds after a copy. */
824
+ const [linkCopied, setLinkCopied] = useState(0);
825
+ useEffect(() => {
826
+ if (linkCopied === 0) return;
827
+ const timer = setTimeout(() => {
828
+ setLinkCopied(0);
829
+ }, 4000);
830
+ return () => {
831
+ clearTimeout(timer);
832
+ };
833
+ }, [linkCopied]);
834
+ const copyLink = async (label: string, text: string) => {
835
+ try {
836
+ await navigator.clipboard.writeText(text);
837
+ setLinkCopied((n) => n + 1);
838
+ } catch {
839
+ // No clipboard over plain http, or permission refused.
840
+ setCopyByHand({ label, text });
841
+ }
842
+ };
843
+ const now = useNow();
844
+ // A published entry whose time to go live is still to come: in the cloud it
845
+ // can go back to a draft (call 9). With no draft there's no Publish ▾, so it's here.
846
+ const publishPending =
847
+ Boolean(cloud) &&
848
+ !isNew &&
849
+ !entryDraft &&
850
+ editable &&
851
+ access?.publish === true &&
852
+ collection.presentation.schedule !== undefined &&
853
+ (timesOf(collection, saved.values).publishAt?.getTime() ?? 0) > now.getTime();
854
+ const menuItems: MenuItem[] = [
855
+ ...(publishPending
856
+ ? [{ id: 'cancel-publish', label: 'Cancel schedule', icon: 'unschedule' as const }]
857
+ : []),
858
+ ...(viewUrl
859
+ ? [
860
+ { id: 'view', label: 'View on site', icon: 'view-on-site' as const },
861
+ { id: 'copy-page', label: 'Copy page address', icon: 'link' as const },
862
+ ]
863
+ : []),
864
+ ...(editingLink
865
+ ? [{ id: 'copy-link', label: 'Copy editing link', icon: 'link' as const }]
866
+ : []),
867
+ ...(!isNew && entryId && editable && status === 'editable' && !collection.presentation.singleton
868
+ ? [{ id: 'duplicate', label: 'Duplicate', icon: 'duplicate' as const }]
869
+ : []),
870
+ ...(cloud && entryDraft && editable
871
+ ? [{ id: 'discard', label: 'Discard draft…', isDanger: true, icon: 'delete' as const }]
872
+ : []),
873
+ ...(!isNew && editable && !collection.presentation.singleton
874
+ ? [{ id: 'delete', label: 'Delete…', isDanger: true, icon: 'delete' as const }]
875
+ : []),
876
+ ];
877
+
878
+ const statusText = cloud
879
+ ? cloudStatus({
880
+ saving,
881
+ publishing,
882
+ offline,
883
+ dirty,
884
+ draftBlocked,
885
+ isNew,
886
+ draft: entryDraft,
887
+ reviewState: review?.state,
888
+ published: cloudNotice?.kind === 'published',
889
+ })
890
+ : saving
891
+ ? publishes
892
+ ? 'Publishing…'
893
+ : 'Saving…'
894
+ : dirty
895
+ ? 'Unsaved changes'
896
+ : notice?.kind === 'saved'
897
+ ? `${publishes ? 'Published' : 'Saved'} ${formatRelative(notice.at.toISOString())}`
898
+ : isNew
899
+ ? 'Not saved yet'
900
+ : '';
901
+ const draft = values.draft === true;
902
+ const back = collection.presentation.singleton
903
+ ? { href: hrefFor({ screen: 'home' }), label: 'Content' }
904
+ : { href: hrefFor({ screen: 'list', collection: collection.name }), label: collection.label };
905
+
906
+ // Scheduling (spec §6.17): the schedule's words in the bar, Publish ▾ and the schedule in Details.
907
+ const schedule = collection.presentation.schedule;
908
+ const { zone } = siteZone(manifest);
909
+ const savedTimes = schedule ? timesOf(collection, saved.values) : {};
910
+ const later = (date: Date | undefined) => date !== undefined && date.getTime() > now.getTime();
911
+ /** Live already, so Schedule… sets only when it comes down (call 8). */
912
+ const live = !isNew && !later(savedTimes.publishAt);
913
+ const nowLabel = publishes ? 'Publish now' : 'Make it live now';
914
+ /** Sets the values and saves them, or publishes them where saves are drafts. */
915
+ const saveWith = async (next: DataObject) => {
916
+ setValues(next);
917
+ if (cloud) await publishDraft(next);
918
+ else await save({ override: next });
919
+ };
920
+ const withoutPublishTime = () => {
921
+ const { publishAt: _gone, ...rest } = timesOf(collection, values);
922
+ return withTimes(collection, values, rest);
923
+ };
924
+ // Publish ▾'s menu, for whoever presses the primary button to publish (or save).
925
+ const scheduleItems: MenuItem[] =
926
+ schedule && editable && (!cloud || access?.publish)
927
+ ? [
928
+ ...(!live || schedule.unpublish
929
+ ? [{ id: 'schedule', label: 'Schedule…', icon: 'schedule' as const }]
930
+ : []),
931
+ ...(later(savedTimes.publishAt)
932
+ ? [{ id: 'now', label: nowLabel, icon: 'publish-now' as const }]
933
+ : []),
934
+ // Without the cloud, a publish time stays until it's rescheduled: there's no draft to go back to (call 9).
935
+ ...(live && later(savedTimes.unpublishAt)
936
+ ? [{ id: 'cancel', label: 'Cancel schedule', icon: 'unschedule' as const }]
937
+ : []),
938
+ // In the cloud, a held draft stops waiting; a published entry's is in the "…" menu.
939
+ ...(cloud && !isNew && !live && entryDraft?.held
940
+ ? [{ id: 'cancel-publish', label: 'Cancel schedule', icon: 'unschedule' as const }]
941
+ : []),
942
+ ]
943
+ : [];
944
+ const cancelPublish = async () => {
945
+ if (!cloud) return;
946
+ setCloudNotice(null);
947
+ try {
948
+ if (entryDraft?.held) {
949
+ await cloud.drafts.release(entryDraft.held.event);
950
+ setCloudNotice({ kind: 'kept', text: 'It’s no longer scheduled, and stays a draft.' });
951
+ return;
952
+ }
953
+ const result = await cloud.drafts.backToDraft(pathRef.current ?? filePath, {
954
+ summary: `Take “${titleText}” off the schedule in ${collection.label}`,
955
+ trailers: { 'Vesper-Collection': collection.name },
956
+ });
957
+ setCloudNotice(
958
+ result.ok
959
+ ? { kind: 'kept', text: 'It’s off the schedule, and back to a draft.' }
960
+ : { kind: 'refused', text: result.message },
961
+ );
962
+ } catch (error) {
963
+ setCloudNotice({
964
+ kind: 'refused',
965
+ text: error instanceof Error ? error.message : String(error),
966
+ });
967
+ }
968
+ };
969
+ const [scheduling, setScheduling] = useState<'publish' | 'set' | null>(null);
970
+ const scheduleMenu =
971
+ scheduleItems.length > 0 ? (
972
+ <Menu
973
+ label="More ways to publish"
974
+ trigger={<IconButton icon="chevron" label="More ways to publish" />}
975
+ items={scheduleItems}
976
+ onAction={(action) => {
977
+ if (action === 'schedule') setScheduling('publish');
978
+ if (action === 'now') void saveWith(withoutPublishTime());
979
+ if (action === 'cancel') {
980
+ const { publishAt } = timesOf(collection, values);
981
+ void saveWith(withTimes(collection, values, publishAt ? { publishAt } : {}));
982
+ }
983
+ if (action === 'cancel-publish') void cancelPublish();
984
+ }}
985
+ />
986
+ ) : null;
987
+ /** The schedule's fields give way to its summary, where the first of them would be. */
988
+ const scheduledKeys = new Set(scheduleKeys(collection));
989
+ const fieldsOf = (fields: FieldModel[]) => {
990
+ let summarized = false;
991
+ return fields.flatMap((field) => {
992
+ if (!scheduledKeys.has(keyOf(field))) {
993
+ return [<Field key={keyOf(field)} field={field} path={[keyOf(field)]} />];
994
+ }
995
+ if (summarized) return [];
996
+ summarized = true;
997
+ return [
998
+ <ScheduleSummary
999
+ key="schedule"
1000
+ collection={collection}
1001
+ values={values}
1002
+ zone={zone}
1003
+ onChange={() => {
1004
+ setScheduling('set');
1005
+ }}
1006
+ />,
1007
+ ];
1008
+ });
1009
+ };
1010
+
1011
+ // What the entry is, its word coloured, then how its changes stand: never
1012
+ // empty, so the way back (or the title) above it always sits in the same place.
1013
+ const state =
1014
+ linkCopied > 0 || (isNew && !draft)
1015
+ ? null
1016
+ : entryState(
1017
+ collection,
1018
+ {
1019
+ draft,
1020
+ times: isNew ? null : saved.values,
1021
+ newDraft: isNewInDraft(entryDraft ? [entryDraft] : [], filePath),
1022
+ },
1023
+ { zone, cloud: Boolean(cloud), now },
1024
+ );
1025
+ const statusLine =
1026
+ linkCopied > 0 ? 'Link copied' : statusText || (editable ? 'No changes' : 'Read only');
1027
+ // Content, the collection's own tabs, then Details; no tab list when Content is all there is.
1028
+ const panels = sourceOnly
1029
+ ? []
1030
+ : [
1031
+ { id: 'main', label: 'Content', fields: arranged.main },
1032
+ ...arranged.tabs,
1033
+ ...(arranged.details.length > 0
1034
+ ? [{ id: 'details', label: 'Details', fields: arranged.details }]
1035
+ : []),
1036
+ ];
1037
+ const tabbed = panels.length > 1;
1038
+ const shownTab = panels.some((panel) => panel.id === tab) ? tab : 'main';
1039
+ /** A tab's name, with its problems in words so they're known before it's chosen. */
1040
+ const tabLabel = (panel: { label: string; fields: FieldModel[] }) => {
1041
+ const keys = new Set(panel.fields.map(keyOf));
1042
+ const count = issues.filter((issue) => keys.has(String(issue.path[0]))).length;
1043
+ return count > 0
1044
+ ? `${panel.label} (${formatCount(count, 'problem', 'problems')})`
1045
+ : panel.label;
1046
+ };
1047
+ /*
1048
+ * The bar keeps one height and one row whatever the status says (spec
1049
+ * §10.3). The status has a line of its own, under the way back (one screen
1050
+ * at a time) or the title (among panes), never empty, and cut short rather
1051
+ * than wrapped. The buttons never wrap or shift.
1052
+ */
1053
+ const [barRef, barWidth] = useMeasure('width', Number.POSITIVE_INFINITY);
1054
+ // The notes' count takes a button's room; where the bar has none, it's the "…" menu's first item.
1055
+ // A width of 0 is a bar not laid out (jsdom), so nothing is known about its room.
1056
+ const notesInMenu = notes.length > 0 && (!pane || (barWidth > 0 && barWidth < NOTES_ROOM));
1057
+ const moreItems: MenuItem[] = notesInMenu
1058
+ ? [
1059
+ {
1060
+ id: 'notes',
1061
+ label: notesCount(notes.length, blocking.length),
1062
+ icon: 'accessibility' as const,
1063
+ },
1064
+ ...menuItems,
1065
+ ]
1066
+ : menuItems;
1067
+ const bar = (
1068
+ <div ref={barRef} className="flex min-w-0 flex-1 items-center gap-3 sm:gap-4">
1069
+ <div className="flex min-w-0 flex-1 flex-col">
1070
+ {!pane && (
1071
+ <Link icon="back" href={back.href} className="min-w-0 self-start text-small">
1072
+ <span className="min-w-0 truncate">{back.label}</span>
1073
+ </Link>
1074
+ )}
1075
+ {/* Among panes, the title field is the pane's heading; with no field, the heading shows. */}
1076
+ {pane && Boolean(arranged.title) && !sourceOnly && (
1077
+ <span className="truncate font-semibold text-content">{titleText}</span>
1078
+ )}
1079
+ <InlineStatus dot busy={saving || publishing} className="h-5 min-w-0">
1080
+ <span className="min-w-0 truncate">
1081
+ <StateWords state={state} />
1082
+ {state ? ' · ' : ''}
1083
+ <span>{statusLine}</span>
1084
+ </span>
1085
+ </InlineStatus>
1086
+ </div>
1087
+ {notes.length > 0 && !notesInMenu && (
1088
+ // Outside the live status, so typing doesn't announce every change in the count.
1089
+ <Button
1090
+ variant="quiet"
1091
+ onPress={() => {
1092
+ setNotesOpen(true);
1093
+ }}
1094
+ className="shrink-0"
1095
+ >
1096
+ {notesCount(notes.length, blocking.length)}
1097
+ </Button>
1098
+ )}
1099
+ <div className="flex shrink-0 items-center gap-2">
1100
+ {editable && !cloud && (
1101
+ <Button
1102
+ id={SAVE_ID}
1103
+ variant="primary"
1104
+ onPress={() => void save()}
1105
+ isPending={saving}
1106
+ pulse={savedPulse}
1107
+ >
1108
+ {publishes ? 'Publish' : 'Save'}
1109
+ </Button>
1110
+ )}
1111
+ {editable && !cloud && scheduleMenu}
1112
+ {/* The cloud: a new entry's first save is by hand; after that drafts save themselves. */}
1113
+ {editable && cloud && isNew && (
1114
+ <Button onPress={() => void save()} isPending={saving}>
1115
+ Save draft
1116
+ </Button>
1117
+ )}
1118
+ {editable && cloud && (dirty || isNew || entryDraft) && !review && (
1119
+ <Button
1120
+ id={SAVE_ID}
1121
+ variant="primary"
1122
+ onPress={() => void (access?.publish ? publishDraft() : startReview())}
1123
+ isPending={publishing}
1124
+ pulse={savedPulse}
1125
+ >
1126
+ {access?.publish ? 'Publish' : 'Request review'}
1127
+ </Button>
1128
+ )}
1129
+ {editable && cloud && (dirty || isNew || entryDraft) && !review && scheduleMenu}
1130
+ {moreItems.length > 0 && (
1131
+ <Menu
1132
+ label={`More actions for ${titleText}`}
1133
+ items={moreItems}
1134
+ onAction={(action) => {
1135
+ if (action === 'notes') setNotesOpen(true);
1136
+ if (action === 'view' && viewUrl) window.open(viewUrl, '_blank', 'noopener');
1137
+ if (action === 'copy-page' && viewUrl) {
1138
+ void copyLink('Page address', pageAddress(viewUrl, manifest));
1139
+ }
1140
+ if (action === 'copy-link' && editingLink) {
1141
+ void copyLink('Editing link', editingLink);
1142
+ }
1143
+ if (action === 'duplicate' && entryId) {
1144
+ navigate({ screen: 'copy', collection: collection.name, id: entryId });
1145
+ }
1146
+ if (action === 'delete') setConfirmDelete(true);
1147
+ if (action === 'discard') setConfirmDiscard(true);
1148
+ if (action === 'cancel-publish') void cancelPublish();
1149
+ }}
1150
+ />
1151
+ )}
1152
+ </div>
1153
+ </div>
1154
+ );
1155
+
1156
+ const errors = [
1157
+ ...targets.map(({ issue, target }) => ({
1158
+ fieldId: target.focusId,
1159
+ message: `${target.label}: ${issue.message}`,
1160
+ })),
1161
+ // Body problems join the summary once a save is refused, and update as they're fixed.
1162
+ ...(refusals > 0
1163
+ ? bodyErrors.map((issue) => ({
1164
+ fieldId: 'entry-body',
1165
+ message: `Body, line ${String(issue.line)}: ${issue.message}`,
1166
+ }))
1167
+ : []),
1168
+ // So do notes the site's publish gate blocks on.
1169
+ ...(refusals > 0
1170
+ ? blocking.map((note) => {
1171
+ const place = placeOf(note);
1172
+ return {
1173
+ fieldId: place.focusId,
1174
+ message: `${place.label}, accessibility: ${note.subject ? `${note.subject}: ` : ''}${note.message}`,
1175
+ };
1176
+ })
1177
+ : []),
1178
+ ];
1179
+
1180
+ const bodyEditor = isContent ? (
1181
+ <Suspense
1182
+ fallback={
1183
+ <p role="status" className="text-small text-content-muted">
1184
+ Loading the body editor…
1185
+ </p>
1186
+ }
1187
+ >
1188
+ <BodyEditor
1189
+ value={body}
1190
+ onChange={setBody}
1191
+ format={format}
1192
+ partials={partials}
1193
+ issues={bodyIssues}
1194
+ notes={bodyNotes}
1195
+ apiRef={bodyApi}
1196
+ disabled={!editable}
1197
+ entryPath={entryPath}
1198
+ imageFolder={`${manifest.site.srcDir}/assets/${collection.name}`}
1199
+ headingStart={collection.presentation.headingStart ?? 2}
1200
+ />
1201
+ </Suspense>
1202
+ ) : null;
1203
+
1204
+ // A copy's heading says what it is until it's saved (the title field below already says " (copy)").
1205
+ const heading =
1206
+ copy && doc
1207
+ ? `${collection.presentation.tree ? 'New page' : 'New entry'}, a copy of ${titleOf(decodeData(doc.data), arranged.title) || doc.id}`
1208
+ : titleText;
1209
+ return (
1210
+ <Screen title={heading} hideTitle={Boolean(arranged.title) && !sourceOnly} bar={bar} column>
1211
+ <EditorContext.Provider value={editor}>
1212
+ <div className="mx-auto flex max-w-editor flex-col gap-6">
1213
+ {doc?.message && (
1214
+ <p className="rounded-md border border-warning bg-surface-raised p-4">{doc.message}</p>
1215
+ )}
1216
+ {notice?.kind === 'conflict' && (
1217
+ <div role="alert" className="rounded-md border border-danger bg-surface-raised p-4">
1218
+ <p className="mb-3">{notice.text}</p>
1219
+ <Button onPress={onReload}>Reload this entry</Button>
1220
+ </div>
1221
+ )}
1222
+ {notice?.kind === 'error' && (
1223
+ <p role="alert" className="rounded-md border border-danger bg-surface-raised p-4">
1224
+ {publishes ? 'Nothing was published.' : 'Nothing was saved.'} {notice.text}
1225
+ </p>
1226
+ )}
1227
+ {notice?.kind === 'expired' && account && (
1228
+ <div role="alert" className="rounded-md border border-danger bg-surface-raised p-4">
1229
+ <p className="mb-3">{notice.text} Your changes are kept.</p>
1230
+ <Button
1231
+ onPress={() => {
1232
+ dirtyRef.current = false;
1233
+ account.signInAgain();
1234
+ }}
1235
+ >
1236
+ Sign in again
1237
+ </Button>
1238
+ </div>
1239
+ )}
1240
+ {kept && dirty && !notice && (
1241
+ <p className="text-small text-content-muted">
1242
+ Your unsaved changes were kept while you signed in again.
1243
+ </p>
1244
+ )}
1245
+ {cloud && (
1246
+ <CloudNotices
1247
+ notice={cloudNotice}
1248
+ restored={restored && dirty}
1249
+ offline={offline}
1250
+ canEdit={editable}
1251
+ review={review}
1252
+ onKeep={async () => {
1253
+ if (!entryDraft || cloudNotice?.kind !== 'conflict') return;
1254
+ await cloud.drafts.keepMine(entryDraft.id, [cloudNotice.path]);
1255
+ setCloudNotice({
1256
+ kind: 'kept',
1257
+ text: 'Your draft is kept, now based on the published version. Publish it when you’re ready.',
1258
+ });
1259
+ }}
1260
+ onDiscard={() => void discardDraft()}
1261
+ draft={entryDraft}
1262
+ />
1263
+ )}
1264
+ {notice?.kind === 'saved' && publishes && (
1265
+ <p className="text-small text-content-muted">
1266
+ Published. It will be live after the site is next built.
1267
+ </p>
1268
+ )}
1269
+ {notice?.kind === 'saved' && notice.reformatted && (
1270
+ <p className="text-small text-content-muted">
1271
+ Saved. This file uses YAML features Vesper can’t edit in place, so its frontmatter was
1272
+ rewritten in a standard layout.
1273
+ </p>
1274
+ )}
1275
+ <ErrorSummary
1276
+ title={publishes ? 'The entry wasn’t published' : 'The entry wasn’t saved'}
1277
+ errors={errors}
1278
+ focusKey={refusals}
1279
+ onGo={(fieldId) => {
1280
+ // A field on another tab is shown first (editor/reveal.ts).
1281
+ focusWhenShown(fieldId);
1282
+ }}
1283
+ />
1284
+ {gateRefused && blocking.length > 0 && !api.publishesOnSave() && !cloud && (
1285
+ <div className="flex flex-col items-start gap-3">
1286
+ <p>
1287
+ This site doesn’t publish an entry with these notes. Saving here only changes the
1288
+ file on this computer, so you can save anyway and fix them before it’s published.
1289
+ </p>
1290
+ <Button onPress={() => void save({ pastGate: true })}>Save anyway</Button>
1291
+ </div>
1292
+ )}
1293
+ {isNew && parent !== undefined && <ParentNote collection={collection} parent={parent} />}
1294
+
1295
+ {sourceOnly ? (
1296
+ <TextArea
1297
+ label="Frontmatter (YAML)"
1298
+ description="Vesper can’t show this frontmatter as fields, so it’s edited as text. It’s checked against the site’s schema when you save."
1299
+ value={raw}
1300
+ onChange={setRaw}
1301
+ rows={Math.min(24, raw.split('\n').length + 2)}
1302
+ isDisabled={!editable}
1303
+ className="font-mono"
1304
+ />
1305
+ ) : (
1306
+ arranged.title && <TitleField field={arranged.title} />
1307
+ )}
1308
+
1309
+ {tabbed ? (
1310
+ // Going to a field on another tab (the error summary, Go to field) chooses that tab first.
1311
+ <TabsRevealing onReveal={setTab}>
1312
+ <Tabs
1313
+ label={titleText}
1314
+ selected={shownTab}
1315
+ onSelectedChange={setTab}
1316
+ tabs={panels.map((panel) => ({
1317
+ id: panel.id,
1318
+ label: tabLabel(panel),
1319
+ children: (
1320
+ <>
1321
+ {fieldsOf(panel.fields)}
1322
+ {panel.id === 'main' && bodyEditor}
1323
+ </>
1324
+ ),
1325
+ }))}
1326
+ />
1327
+ </TabsRevealing>
1328
+ ) : (
1329
+ <>
1330
+ {!sourceOnly && fieldsOf(arranged.main)}
1331
+ {bodyEditor}
1332
+ </>
1333
+ )}
1334
+ </div>
1335
+ </EditorContext.Provider>
1336
+
1337
+ <NotesDrawer
1338
+ isOpen={notesOpen}
1339
+ onOpenChange={setNotesOpen}
1340
+ groups={noteGroups}
1341
+ isBlocking={(note) => blockOn.has(note.rule)}
1342
+ onGo={(note) => {
1343
+ setGoTo(note);
1344
+ setNotesOpen(false);
1345
+ }}
1346
+ />
1347
+
1348
+ {schedule && scheduling && (
1349
+ <ScheduleDialog
1350
+ isOpen
1351
+ onOpenChange={(open) => {
1352
+ if (!open) setScheduling(null);
1353
+ }}
1354
+ collection={collection}
1355
+ initial={timesOf(collection, values)}
1356
+ live={live}
1357
+ purpose={scheduling}
1358
+ nowLabel={nowLabel}
1359
+ onSubmit={(times) => {
1360
+ const next = withTimes(collection, values, times);
1361
+ if (scheduling === 'publish') void saveWith(next);
1362
+ else setValues(next);
1363
+ }}
1364
+ onNow={() => void saveWith(withoutPublishTime())}
1365
+ />
1366
+ )}
1367
+ <Dialog
1368
+ title={`Copy the ${copyByHand?.label.toLowerCase() ?? 'link'}`}
1369
+ isOpen={copyByHand !== null}
1370
+ onOpenChange={(open) => {
1371
+ if (!open) setCopyByHand(null);
1372
+ }}
1373
+ >
1374
+ {(close) => (
1375
+ <CopyByHand
1376
+ label={copyByHand?.label ?? 'Link'}
1377
+ text={copyByHand?.text ?? ''}
1378
+ onDone={close}
1379
+ />
1380
+ )}
1381
+ </Dialog>
1382
+ <Dialog
1383
+ title={`Delete “${titleText}”?`}
1384
+ role="alertdialog"
1385
+ isOpen={confirmDelete}
1386
+ onOpenChange={setConfirmDelete}
1387
+ >
1388
+ <p className="mb-6">
1389
+ {cloud
1390
+ ? `This deletes ${doc?.path ?? 'the file'} in a draft. It leaves the site when the draft is published.`
1391
+ : `This deletes ${doc?.path ?? 'the file'}. In local mode the file is removed from disk; git can bring it back.`}
1392
+ </p>
1393
+ <div className="flex flex-wrap justify-end gap-3">
1394
+ <Button
1395
+ onPress={() => {
1396
+ setConfirmDelete(false);
1397
+ }}
1398
+ >
1399
+ Cancel
1400
+ </Button>
1401
+ <Button variant="danger" onPress={() => void deleteEntry()}>
1402
+ Delete
1403
+ </Button>
1404
+ </div>
1405
+ </Dialog>
1406
+ {cloud && (
1407
+ <>
1408
+ <Dialog
1409
+ title="Discard this draft?"
1410
+ role="alertdialog"
1411
+ isOpen={confirmDiscard}
1412
+ onOpenChange={setConfirmDiscard}
1413
+ >
1414
+ <p className="mb-6">
1415
+ The changes that haven’t been published are thrown away, and the entry goes back to
1416
+ the published version.
1417
+ </p>
1418
+ <div className="flex flex-wrap justify-end gap-3">
1419
+ <Button
1420
+ onPress={() => {
1421
+ setConfirmDiscard(false);
1422
+ }}
1423
+ >
1424
+ Keep the draft
1425
+ </Button>
1426
+ <Button variant="danger" onPress={() => void discardDraft()}>
1427
+ Discard draft
1428
+ </Button>
1429
+ </div>
1430
+ </Dialog>
1431
+ <RequestReviewDialog
1432
+ isOpen={reviewOpen}
1433
+ onOpenChange={setReviewOpen}
1434
+ onSend={sendForReview}
1435
+ />
1436
+ </>
1437
+ )}
1438
+ </Screen>
1439
+ );
1440
+ }
1441
+
1442
+ /** A link the clipboard refused, focused and selected so the shortcut copies it at once. */
1443
+ function CopyByHand({ label, text, onDone }: { label: string; text: string; onDone: () => void }) {
1444
+ const box = useRef<HTMLDivElement>(null);
1445
+ // Before React Aria's dialog effect, which then finds focus inside and
1446
+ // leaves it, as the crop dialog does.
1447
+ useLayoutEffect(() => {
1448
+ const input = box.current?.querySelector('input');
1449
+ input?.focus();
1450
+ input?.select();
1451
+ }, []);
1452
+ return (
1453
+ <div ref={box} className="flex flex-col gap-6">
1454
+ <TextField
1455
+ label={label}
1456
+ description={`Press ${isMac ? '⌘C' : 'Ctrl+C'} to copy.`}
1457
+ value={text}
1458
+ isReadOnly
1459
+ onFocus={(event) => {
1460
+ event.target.select();
1461
+ }}
1462
+ inputClassName="font-mono"
1463
+ />
1464
+ <div className="flex justify-end">
1465
+ <Button onPress={onDone}>Done</Button>
1466
+ </div>
1467
+ </div>
1468
+ );
1469
+ }
1470
+
1471
+ /** The editor's status in the cloud, in words (spec §10.4: status is text). */
1472
+ function cloudStatus(state: {
1473
+ saving: boolean;
1474
+ publishing: boolean;
1475
+ offline: boolean;
1476
+ dirty: boolean;
1477
+ draftBlocked: boolean;
1478
+ isNew: boolean;
1479
+ draft: Draft | undefined;
1480
+ reviewState: Review['state'] | undefined;
1481
+ published: boolean;
1482
+ }): string {
1483
+ if (state.publishing) return 'Publishing…';
1484
+ if (state.saving) return 'Saving draft…';
1485
+ if (state.offline) return 'Can’t reach Vesper. Kept on this device';
1486
+ if (state.dirty && state.draftBlocked) return 'Not saved yet: some fields need attention';
1487
+ if (state.dirty) return state.isNew ? 'Not saved yet' : 'Unsaved changes';
1488
+ if (state.reviewState === 'open') return 'Waiting for review';
1489
+ if (state.reviewState === 'changes-requested') return 'Changes requested';
1490
+ if (state.draft) return `Draft saved ${formatRelative(state.draft.updatedAt)}`;
1491
+ if (state.published) return 'Published';
1492
+ return state.isNew ? 'Not saved yet' : '';
1493
+ }
1494
+
1495
+ /** What happened with the entry's draft, under the bar (the cloud). */
1496
+ function CloudNotices({
1497
+ notice,
1498
+ restored,
1499
+ offline,
1500
+ canEdit,
1501
+ review,
1502
+ draft,
1503
+ onKeep,
1504
+ onDiscard,
1505
+ }: {
1506
+ notice: CloudNotice;
1507
+ restored: boolean;
1508
+ offline: boolean;
1509
+ canEdit: boolean;
1510
+ review: Review | undefined;
1511
+ draft: Draft | undefined;
1512
+ onKeep: () => Promise<void>;
1513
+ onDiscard: () => void;
1514
+ }) {
1515
+ const asked = review?.state === 'changes-requested' ? review.comments.at(-1) : undefined;
1516
+ return (
1517
+ <>
1518
+ {!canEdit && (
1519
+ <p className="text-small text-content-muted">You can view this entry, not change it.</p>
1520
+ )}
1521
+ {restored && (
1522
+ <p className="text-small text-content-muted">
1523
+ Your unsaved changes were kept on this device, and are back.
1524
+ </p>
1525
+ )}
1526
+ {offline && (
1527
+ <p className="text-small text-content-muted">
1528
+ Vesper can’t be reached. Your changes are kept on this device, and they’ll be saved when
1529
+ it’s back.
1530
+ </p>
1531
+ )}
1532
+ {asked && (
1533
+ <div className="rounded-md border border-warning bg-surface-raised p-4">
1534
+ <p className="m-0">
1535
+ {asked.by.name} asked for changes: {asked.body}
1536
+ </p>
1537
+ </div>
1538
+ )}
1539
+ {notice?.kind === 'published' && <DeployLine commit={notice.commit} />}
1540
+ {notice?.kind === 'conflict' && draft && (
1541
+ <ConflictCompare
1542
+ path={notice.path}
1543
+ draft={draft}
1544
+ publishedVersion={notice.published}
1545
+ onKeep={() => void onKeep()}
1546
+ onDiscard={onDiscard}
1547
+ />
1548
+ )}
1549
+ {notice?.kind === 'refused' && (
1550
+ <p role="alert" className="rounded-md border border-danger bg-surface-raised p-4">
1551
+ Nothing was published. {notice.text}
1552
+ </p>
1553
+ )}
1554
+ {(notice?.kind === 'kept' ||
1555
+ notice?.kind === 'nothing' ||
1556
+ notice?.kind === 'review-requested' ||
1557
+ notice?.kind === 'held') && <p className="text-small text-content-muted">{notice.text}</p>}
1558
+ </>
1559
+ );
1560
+ }
1561
+
1562
+ const SAVE_ID = 'entry-save';
1563
+ /** The bar's least width, in pixels, with room for the notes' count as a button (36rem). */
1564
+ const NOTES_ROOM = 576;
1565
+
1566
+ /** Where a new page will go in the tree: under its parent, named by title. */
1567
+ function ParentNote({ collection, parent }: { collection: CollectionManifest; parent: string }) {
1568
+ const { api } = useStudio();
1569
+ const [load] = useLoad(() => api.pageTree(collection.name), `${collection.name}:${parent}`);
1570
+ const find = (nodes: PageTreeNode[]): PageTreeNode | undefined => {
1571
+ for (const node of nodes) {
1572
+ if (node.path === parent) return node;
1573
+ const found = find(node.children);
1574
+ if (found) return found;
1575
+ }
1576
+ return undefined;
1577
+ };
1578
+ const title = load.state === 'ready' ? find(load.value.nodes)?.title : undefined;
1579
+ return (
1580
+ <p className="text-content-muted">
1581
+ This page will go under “{title ?? parent}”. Its address comes from its title.
1582
+ </p>
1583
+ );
1584
+ }
1585
+
1586
+ /** The title field, styled as the document's heading (spec §10.3). */
1587
+ function TitleField({ field }: { field: FieldModel }) {
1588
+ const editor = useEditor();
1589
+ const path = [keyOf(field)];
1590
+ const id = fieldId(path);
1591
+ const errors = editor.issues.get(pathKey(path)) ?? [];
1592
+ const value = getIn(editor.values, path);
1593
+ const text = typeof value === 'string' ? value : '';
1594
+ // A title wraps rather than being cut off mid-word with no sign there's
1595
+ // more: a one-line box at title size showed "…and who it'" of a 59-character
1596
+ // title in a 718px column. The box grows with its text and with the pane.
1597
+ const area = useRef<HTMLTextAreaElement>(null);
1598
+ useLayoutEffect(() => {
1599
+ const element = area.current;
1600
+ if (!element) return;
1601
+ const fit = () => {
1602
+ element.style.height = 'auto';
1603
+ element.style.height = `${String(element.scrollHeight)}px`;
1604
+ };
1605
+ fit();
1606
+ // The width it wraps at, never its own height, which fitting changes.
1607
+ const frame = element.parentElement;
1608
+ if (!frame || typeof ResizeObserver === 'undefined') return;
1609
+ const observer = new ResizeObserver(fit);
1610
+ observer.observe(frame);
1611
+ return () => {
1612
+ observer.disconnect();
1613
+ };
1614
+ }, [text]);
1615
+ return (
1616
+ <div className="flex flex-col gap-1">
1617
+ <label htmlFor={id} className="text-small font-semibold text-content-muted">
1618
+ {field.label}
1619
+ {!field.required && (
1620
+ <>
1621
+ {' '}
1622
+ <span className="font-normal">(optional)</span>
1623
+ </>
1624
+ )}
1625
+ </label>
1626
+ <textarea
1627
+ ref={area}
1628
+ id={id}
1629
+ rows={1}
1630
+ value={text}
1631
+ onKeyDown={(event) => {
1632
+ // Still one line of text: Enter adds no line break.
1633
+ if (event.key === 'Enter' && !event.nativeEvent.isComposing) event.preventDefault();
1634
+ }}
1635
+ onChange={(event) => {
1636
+ // A pasted line break becomes a space, as a one-line box would have it.
1637
+ const next = event.target.value.replace(/[^\S\r\n]*[\r\n]+[^\S\r\n]*/g, ' ');
1638
+ editor.setValue(path, next === '' && !field.required ? undefined : next);
1639
+ }}
1640
+ aria-invalid={errors.length > 0 || undefined}
1641
+ aria-required={field.required || undefined}
1642
+ aria-describedby={errors.length > 0 ? `${id}-error` : undefined}
1643
+ disabled={editor.disabled || field.readOnly === true}
1644
+ placeholder={field.placeholder ?? 'Untitled'}
1645
+ className="block w-full resize-none overflow-hidden rounded-md border border-transparent bg-transparent px-0 py-1 text-title text-content outline-none placeholder:text-content-muted focus-visible:border-border-strong focus-visible:px-2 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) aria-[invalid=true]:border-danger aria-[invalid=true]:px-2"
1646
+ />
1647
+ {errors.length > 0 && (
1648
+ <p id={`${id}-error`} className="text-small text-danger">
1649
+ {errors.join(' ')}
1650
+ </p>
1651
+ )}
1652
+ </div>
1653
+ );
1654
+ }
1655
+
1656
+ function titleOf(values: DataObject, title: FieldModel | undefined): string {
1657
+ if (!title) return '';
1658
+ const value = values[keyOf(title)];
1659
+ return typeof value === 'string' ? value.trim() : '';
1660
+ }
1661
+
1662
+ /**
1663
+ * The entry's page on the site, from the collection's URL pattern (spec
1664
+ * §6.15.1), written as the site writes it: base, trailing slash and all. A
1665
+ * function-form pattern runs only where vesper.config.ts is loaded, so
1666
+ * those entries have no "View on site" here.
1667
+ */
1668
+ function pageUrl(
1669
+ collection: CollectionManifest,
1670
+ id: string,
1671
+ data: DataObject,
1672
+ manifest: Manifest,
1673
+ ): string | null {
1674
+ const pattern = collection.presentation.url;
1675
+ if (typeof pattern !== 'string') return null;
1676
+ const settings = manifest.site.urls ?? {
1677
+ ...DEFAULT_URL_SETTINGS,
1678
+ ...(manifest.site.timeZone ? { timeZone: manifest.site.timeZone } : {}),
1679
+ };
1680
+ const key = entryUrlKey(pattern, { id, collection: collection.name, data }, settings);
1681
+ return key === null ? null : formatUrl(key, settings);
1682
+ }
1683
+
1684
+ /**
1685
+ * A function-form URL's page address, from the site's page list (spec
1686
+ * §6.15.1), looked up again after each save. Null for a string pattern,
1687
+ * which the editor works out itself, and while the list loads.
1688
+ */
1689
+ function useFunctionUrl(
1690
+ collection: CollectionManifest,
1691
+ id: string | null,
1692
+ version: string | null,
1693
+ ): string | null {
1694
+ const { api } = useStudio();
1695
+ const wanted = typeof collection.presentation.url === 'object' && id !== null;
1696
+ const [url, setUrl] = useState<string | null>(null);
1697
+ useEffect(() => {
1698
+ if (!wanted) return;
1699
+ let live = true;
1700
+ api.listPages().then(
1701
+ (pages) => {
1702
+ if (!live) return;
1703
+ setUrl(pages.find((p) => p.collection === collection.name && p.id === id)?.url ?? null);
1704
+ },
1705
+ () => {
1706
+ // Without the list there's no address to show; the rest of the editor works as usual.
1707
+ },
1708
+ );
1709
+ return () => {
1710
+ live = false;
1711
+ };
1712
+ }, [api, wanted, collection.name, id, version]);
1713
+ return wanted ? url : null;
1714
+ }
1715
+
1716
+ /**
1717
+ * The page's full address, to paste anywhere: on Astro's `site` when the
1718
+ * site sets one, so a link copied in local mode is the live one, and on
1719
+ * the studio's own origin otherwise.
1720
+ */
1721
+ function pageAddress(url: string, manifest: Manifest): string {
1722
+ const site = manifest.site.urls?.site;
1723
+ const origin = site ? new URL(site).origin : window.location.origin;
1724
+ return new URL(url, origin).href;
1725
+ }
1726
+
1727
+ /** The studio's address for an entry, which survives a reload on any host (hash routing). */
1728
+ function editingLinkOf(collection: string, id: string): string {
1729
+ return new URL(hrefFor({ screen: 'edit', collection, id }), window.location.href).href;
1730
+ }
1731
+
1732
+ /** A tree path's parent ('' for the top level). */
1733
+ function parentPath(path: string): string {
1734
+ return path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
1735
+ }
1736
+
1737
+ /**
1738
+ * Where a new entry will most likely be saved, so uploads can be referenced
1739
+ * relative to it before it exists: the collection's folder, or its file.
1740
+ */
1741
+ function likelyPath(collection: CollectionManifest, parent?: string): string {
1742
+ const { source } = collection;
1743
+ if (source.loader === 'file') return source.path;
1744
+ if (source.loader === 'glob') {
1745
+ // A new page in a tree goes in its parent's folder (spec §6.15.2), which a folder grant can cover.
1746
+ const folder = [source.base === '.' ? '' : source.base, parent ?? ''].filter(Boolean).join('/');
1747
+ return `${folder ? `${folder}/` : ''}new-entry.${source.formats[0] ?? 'md'}`;
1748
+ }
1749
+ return 'new-entry.md';
1750
+ }
1751
+
1752
+ /** The body's syntax: the file's own, or for a new entry the collection's first content format. */
1753
+ function bodyFormat(doc: EntryDocument | null, collection: CollectionManifest): BodyFormat {
1754
+ if (doc && (doc.format === 'md' || doc.format === 'mdx' || doc.format === 'mdoc'))
1755
+ return doc.format;
1756
+ const formats = collection.source.loader === 'glob' ? collection.source.formats : [];
1757
+ const first = formats.find((f) => f === 'md' || f === 'markdown' || f === 'mdx' || f === 'mdoc');
1758
+ return first === 'mdx' ? 'mdx' : first === 'mdoc' ? 'mdoc' : 'md';
1759
+ }
1760
+
1761
+ const KEPT_KEY = 'vesper.kept';
1762
+
1763
+ type Kept = { values: DataObject; body: string; raw: string };
1764
+
1765
+ /** Keeps an entry's unsaved changes in this tab while the editor signs in again. */
1766
+ function keep(collection: string, id: string | null, changes: Kept): void {
1767
+ try {
1768
+ sessionStorage.setItem(
1769
+ KEPT_KEY,
1770
+ JSON.stringify({ collection, id, ...changes, values: encodeValue(changes.values) }),
1771
+ );
1772
+ } catch {
1773
+ // Without sessionStorage they can't be kept; the notice still explains.
1774
+ }
1775
+ }
1776
+
1777
+ /** The kept changes for this entry, if any, removed as they're taken. */
1778
+ function takeKept(collection: string, id: string | null): Kept | null {
1779
+ try {
1780
+ const raw = sessionStorage.getItem(KEPT_KEY);
1781
+ if (!raw) return null;
1782
+ const found = JSON.parse(raw) as Omit<Kept, 'values'> & {
1783
+ collection: string;
1784
+ id: string | null;
1785
+ values: Parameters<typeof decodeData>[0];
1786
+ };
1787
+ if (found.collection !== collection || found.id !== id) return null;
1788
+ sessionStorage.removeItem(KEPT_KEY);
1789
+ return { values: decodeData(found.values), body: found.body, raw: found.raw };
1790
+ } catch {
1791
+ return null;
1792
+ }
1793
+ }
1794
+
1795
+ /** Chooses the tab that holds a field going to be focused (editor/reveal.ts). */
1796
+ function TabsRevealing({
1797
+ onReveal,
1798
+ children,
1799
+ }: {
1800
+ onReveal: (tab: string) => void;
1801
+ children: ReactNode;
1802
+ }) {
1803
+ const ref = useRef<HTMLDivElement>(null);
1804
+ useEffect(() => {
1805
+ const element = ref.current;
1806
+ if (!element) return;
1807
+ const reveal = (event: Event) => {
1808
+ const tab = (event.target as Element).closest('[data-tab]')?.getAttribute('data-tab');
1809
+ if (tab) onReveal(tab);
1810
+ };
1811
+ element.addEventListener(REVEAL_EVENT, reveal);
1812
+ return () => {
1813
+ element.removeEventListener(REVEAL_EVENT, reveal);
1814
+ };
1815
+ }, [onReveal]);
1816
+ return <div ref={ref}>{children}</div>;
1817
+ }