@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,522 @@
1
+ import {
2
+ calendarDate,
3
+ decodeValue,
4
+ encodeValue,
5
+ parseRedirectsCsv,
6
+ redirectKey,
7
+ type CsvImport,
8
+ type PageLink,
9
+ type RedirectsDocument,
10
+ type RedirectStatus,
11
+ } from '@vespercms/core';
12
+ import {
13
+ Button,
14
+ Dialog,
15
+ FileButton,
16
+ Menu,
17
+ RadioGroup,
18
+ SuggestField,
19
+ Table,
20
+ TextField,
21
+ type TableColumn,
22
+ } from '@vespercms/ui';
23
+ import { useMemo, useState } from 'react';
24
+ import { useStudio } from '../app/context.tsx';
25
+ import { useLoad } from '../app/load.ts';
26
+ import { Screen } from '../app/Screen.tsx';
27
+
28
+ /*
29
+ * The Redirects screen (spec §6.15.5), behind the top bar's Site menu. Moves
30
+ * add redirects on their own; this is for the rest: short links, and
31
+ * bringing an old WordPress or Drupal site over from a CSV export. Every
32
+ * save goes through the same cleanup as the build (chains collapsed, loops
33
+ * and redirects onto real pages removed).
34
+ */
35
+
36
+ type Row = {
37
+ id: string;
38
+ index: number;
39
+ from: string;
40
+ to: string;
41
+ status: RedirectStatus;
42
+ reason: string;
43
+ at: Date | null;
44
+ };
45
+
46
+ type Editing = { index: number | null; from: string; to: string; permanent: boolean };
47
+
48
+ const COLUMNS: TableColumn[] = [
49
+ { id: 'from', label: 'Old address', isRowHeader: true },
50
+ { id: 'to', label: 'Goes to' },
51
+ { id: 'kind', label: 'Kind' },
52
+ { id: 'reason', label: 'Why' },
53
+ { id: 'at', label: 'Added' },
54
+ { id: 'actions', label: 'Actions' },
55
+ ];
56
+
57
+ export function Redirects() {
58
+ const { api, manifest } = useStudio();
59
+ const [load, reload] = useLoad(() => api.readRedirects(), 'redirects');
60
+ const [pagesLoad] = useLoad(() => api.listPages(), 'pages');
61
+ const [doc, setDoc] = useState<RedirectsDocument | null>(null);
62
+ const [query, setQuery] = useState('');
63
+ const [editing, setEditing] = useState<Editing | null>(null);
64
+ const [deleting, setDeleting] = useState<Row | null>(null);
65
+ const [importing, setImporting] = useState<{ name: string; parsed: CsvImport } | null>(null);
66
+ const [notice, setNotice] = useState<{ kind: 'done' | 'error'; text: string } | null>(null);
67
+
68
+ const current = doc ?? (load.state === 'ready' ? load.value : null);
69
+ const pages = pagesLoad.state === 'ready' ? pagesLoad.value : [];
70
+ const rows = useMemo(() => (current ? toRows(current.redirects) : []), [current]);
71
+ const shown = useMemo(() => {
72
+ const needle = query.trim().toLowerCase();
73
+ return needle === ''
74
+ ? rows
75
+ : rows.filter(
76
+ (r) => r.from.toLowerCase().includes(needle) || r.to.toLowerCase().includes(needle),
77
+ );
78
+ }, [rows, query]);
79
+
80
+ const today = () => encodeValue(calendarDate(new Date(), manifest.site.timeZone));
81
+
82
+ /** Saves a whole new list. Returns the problem in words, or null when it saved. */
83
+ const save = async (redirects: unknown[], done: string): Promise<string | null> => {
84
+ if (!current) return 'The redirects haven’t loaded yet.';
85
+ const result = await api.saveRedirects({ version: current.version, redirects });
86
+ if (!result.ok) {
87
+ if (result.reason === 'conflict') reload();
88
+ return result.message;
89
+ }
90
+ const { ok: _ok, ...next } = result;
91
+ setDoc(next);
92
+ setNotice({ kind: 'done', text: done });
93
+ return null;
94
+ };
95
+
96
+ return (
97
+ <Screen title="Redirects">
98
+ <p className="mb-6 max-w-2xl text-content-muted">
99
+ A redirect sends visitors from an old address to a new one. Moving a page adds them for you.
100
+ Add your own for short links, or import a list when you bring an old site over.
101
+ </p>
102
+ {load.state === 'failed' && (
103
+ <p role="alert" className="mb-6 text-danger">
104
+ Couldn’t load the redirects: {load.message}
105
+ </p>
106
+ )}
107
+ <div className="mb-6 flex flex-wrap items-end gap-4">
108
+ <TextField
109
+ label="Search"
110
+ type="search"
111
+ icon="search"
112
+ value={query}
113
+ onChange={setQuery}
114
+ className="w-full max-w-xs"
115
+ />
116
+ <FileButton
117
+ icon="import"
118
+ accept={['.csv', 'text/csv', 'text/plain']}
119
+ onSelect={(file) => {
120
+ void file.text().then((text) => {
121
+ setImporting({ name: file.name, parsed: parseRedirectsCsv(text) });
122
+ });
123
+ }}
124
+ >
125
+ Import from CSV…
126
+ </FileButton>
127
+ <Button
128
+ variant="primary"
129
+ icon="add"
130
+ className="ml-auto"
131
+ isDisabled={!current}
132
+ onPress={() => {
133
+ setEditing({ index: null, from: '', to: '', permanent: true });
134
+ }}
135
+ >
136
+ Add redirect
137
+ </Button>
138
+ </div>
139
+ <p role="status" className="mb-4 text-content-muted">
140
+ {notice?.kind === 'done' ? notice.text : ''}
141
+ </p>
142
+ {notice?.kind === 'error' && (
143
+ <p role="alert" className="mb-4 text-danger">
144
+ {notice.text}
145
+ </p>
146
+ )}
147
+ {current && current.warnings.length > 0 && (
148
+ <section aria-labelledby="redirect-warnings" className="mb-6 max-w-2xl">
149
+ <h2 id="redirect-warnings" className="mb-2 text-heading-2">
150
+ Needs a look
151
+ </h2>
152
+ <ul className="list-disc pl-6">
153
+ {current.warnings.map((warning, i) => (
154
+ <li key={`${warning.from}:${String(i)}`}>{warning.message}</li>
155
+ ))}
156
+ </ul>
157
+ </section>
158
+ )}
159
+ {load.state === 'loading' ? (
160
+ <p className="text-small text-content-muted">Loading redirects…</p>
161
+ ) : (
162
+ <Table
163
+ label="Redirects"
164
+ columns={COLUMNS}
165
+ rows={shown}
166
+ emptyState={rows.length === 0 ? 'No redirects yet.' : 'No redirects match.'}
167
+ renderCell={(row, column) => {
168
+ // Addresses are set in mono, the old one as the row's name.
169
+ if (column === 'from')
170
+ return <span className="font-mono text-small font-semibold">{row.from}</span>;
171
+ if (column === 'to') return <span className="font-mono text-small">{row.to}</span>;
172
+ if (column === 'kind') return isPermanent(row.status) ? 'Permanent' : 'Temporary';
173
+ if (column === 'reason')
174
+ return <span className="text-content-muted">{reasonText(row.reason)}</span>;
175
+ if (column === 'at') {
176
+ return (
177
+ <span className="text-content-muted">
178
+ {row.at
179
+ ? row.at.toLocaleDateString(undefined, { dateStyle: 'medium', timeZone: 'UTC' })
180
+ : ''}
181
+ </span>
182
+ );
183
+ }
184
+ return (
185
+ <Menu
186
+ label={`Actions for the redirect from ${row.from}`}
187
+ items={[
188
+ { id: 'edit', label: 'Edit…', icon: 'edit' },
189
+ { id: 'delete', label: 'Delete…', isDanger: true, icon: 'delete' },
190
+ ]}
191
+ onAction={(action) => {
192
+ if (action === 'edit') {
193
+ setEditing({
194
+ index: row.index,
195
+ from: row.from,
196
+ to: row.to,
197
+ permanent: isPermanent(row.status),
198
+ });
199
+ } else {
200
+ setDeleting(row);
201
+ }
202
+ }}
203
+ />
204
+ );
205
+ }}
206
+ />
207
+ )}
208
+
209
+ {editing && current && (
210
+ <RedirectDialog
211
+ editing={editing}
212
+ pages={pages}
213
+ onClose={() => {
214
+ setEditing(null);
215
+ }}
216
+ onSave={async (next) => {
217
+ const from = normalizeFrom(next.from);
218
+ const list = [...current.redirects];
219
+ const value = {
220
+ ...(editing.index !== null ? (list[editing.index] as object) : {}),
221
+ from,
222
+ to: next.to.trim(),
223
+ status: next.permanent ? 301 : 302,
224
+ ...(editing.index === null ? { reason: 'manual', at: today() } : {}),
225
+ };
226
+ if (editing.index === null) list.push(value);
227
+ else list[editing.index] = value;
228
+ const problem = await save(
229
+ list,
230
+ editing.index === null
231
+ ? `Added a redirect from ${from}.`
232
+ : `Saved the redirect from ${from}.`,
233
+ );
234
+ if (!problem) setEditing(null);
235
+ return problem;
236
+ }}
237
+ />
238
+ )}
239
+
240
+ {deleting && current && (
241
+ <Dialog
242
+ title="Delete this redirect?"
243
+ role="alertdialog"
244
+ isOpen
245
+ onOpenChange={(open) => {
246
+ if (!open) setDeleting(null);
247
+ }}
248
+ >
249
+ <p>
250
+ Visitors to {deleting.from} will no longer be sent to {deleting.to}. If nothing else is
251
+ at that address, they’ll see the site’s “not found” page.
252
+ </p>
253
+ <div className="flex flex-wrap justify-end gap-3">
254
+ <Button
255
+ onPress={() => {
256
+ setDeleting(null);
257
+ }}
258
+ >
259
+ Cancel
260
+ </Button>
261
+ <Button
262
+ variant="danger"
263
+ onPress={() => {
264
+ const list = current.redirects.filter((_, i) => i !== deleting.index);
265
+ void save(list, `Deleted the redirect from ${deleting.from}.`).then((problem) => {
266
+ if (problem) setNotice({ kind: 'error', text: problem });
267
+ setDeleting(null);
268
+ });
269
+ }}
270
+ >
271
+ Delete
272
+ </Button>
273
+ </div>
274
+ </Dialog>
275
+ )}
276
+
277
+ {importing && current && (
278
+ <ImportDialog
279
+ name={importing.name}
280
+ parsed={importing.parsed}
281
+ existing={rows}
282
+ onClose={() => {
283
+ setImporting(null);
284
+ }}
285
+ onImport={async () => {
286
+ const list = [...current.redirects];
287
+ const at = today();
288
+ for (const row of importing.parsed.rows) {
289
+ const value = {
290
+ from: row.from,
291
+ to: row.to,
292
+ status: row.status,
293
+ reason: 'import',
294
+ at,
295
+ };
296
+ const index = list.findIndex(
297
+ (item) => redirectKey(String((item as { from?: unknown }).from)) === row.from,
298
+ );
299
+ if (index === -1) list.push(value);
300
+ else list[index] = value;
301
+ }
302
+ const count = importing.parsed.rows.length;
303
+ const problem = await save(
304
+ list,
305
+ `Imported ${String(count)} ${count === 1 ? 'redirect' : 'redirects'}.`,
306
+ );
307
+ if (!problem) setImporting(null);
308
+ return problem;
309
+ }}
310
+ />
311
+ )}
312
+ </Screen>
313
+ );
314
+ }
315
+
316
+ function RedirectDialog({
317
+ editing,
318
+ pages,
319
+ onClose,
320
+ onSave,
321
+ }: {
322
+ editing: Editing;
323
+ pages: PageLink[];
324
+ onClose: () => void;
325
+ onSave: (next: Editing) => Promise<string | null>;
326
+ }) {
327
+ const [from, setFrom] = useState(editing.from);
328
+ const [to, setTo] = useState(editing.to);
329
+ const [permanent, setPermanent] = useState(editing.permanent);
330
+ const [problem, setProblem] = useState<string | null>(null);
331
+ const [busy, setBusy] = useState(false);
332
+ const suggestions = useMemo(
333
+ () => pages.map((page) => ({ value: page.url, label: page.title, detail: page.url })),
334
+ [pages],
335
+ );
336
+ return (
337
+ <Dialog
338
+ title={editing.index === null ? 'Add a redirect' : 'Edit redirect'}
339
+ isOpen
340
+ onOpenChange={(open) => {
341
+ if (!open) onClose();
342
+ }}
343
+ >
344
+ <form
345
+ className="flex flex-col gap-4"
346
+ onSubmit={(event) => {
347
+ event.preventDefault();
348
+ const missing =
349
+ from.trim() === ''
350
+ ? 'Enter the old address, like /apply-now.'
351
+ : to.trim() === ''
352
+ ? 'Choose where it goes: a page on this site or a full web address.'
353
+ : null;
354
+ if (missing) {
355
+ setProblem(missing);
356
+ return;
357
+ }
358
+ setBusy(true);
359
+ void onSave({ ...editing, from, to, permanent }).then((result) => {
360
+ setBusy(false);
361
+ setProblem(result);
362
+ });
363
+ }}
364
+ >
365
+ <TextField
366
+ label="Old address"
367
+ description="The path visitors use, like /apply-now."
368
+ value={from}
369
+ onChange={setFrom}
370
+ />
371
+ <SuggestField
372
+ label="Goes to"
373
+ description="Search pages by title, or type a path or a full web address."
374
+ value={to}
375
+ onChange={setTo}
376
+ suggestions={suggestions}
377
+ inputMode="url"
378
+ />
379
+ <RadioGroup
380
+ label="Kind"
381
+ value={permanent ? 'permanent' : 'temporary'}
382
+ onChange={(value) => {
383
+ setPermanent(value === 'permanent');
384
+ }}
385
+ options={[
386
+ { value: 'permanent', label: 'Permanent: the page has moved for good' },
387
+ { value: 'temporary', label: 'Temporary: it will come back' },
388
+ ]}
389
+ />
390
+ {problem && (
391
+ <p role="alert" className="text-danger">
392
+ {problem}
393
+ </p>
394
+ )}
395
+ <div className="flex flex-wrap justify-end gap-3">
396
+ <Button onPress={onClose}>Cancel</Button>
397
+ <Button type="submit" variant="primary" isPending={busy}>
398
+ {editing.index === null ? 'Add redirect' : 'Save redirect'}
399
+ </Button>
400
+ </div>
401
+ </form>
402
+ </Dialog>
403
+ );
404
+ }
405
+
406
+ function ImportDialog({
407
+ name,
408
+ parsed,
409
+ existing,
410
+ onClose,
411
+ onImport,
412
+ }: {
413
+ name: string;
414
+ parsed: CsvImport;
415
+ existing: Row[];
416
+ onClose: () => void;
417
+ onImport: () => Promise<string | null>;
418
+ }) {
419
+ const [problem, setProblem] = useState<string | null>(null);
420
+ const [busy, setBusy] = useState(false);
421
+ const known = new Set(existing.map((r) => redirectKey(r.from)));
422
+ const replacing = parsed.rows.filter((r) => known.has(r.from)).length;
423
+ const adding = parsed.rows.length - replacing;
424
+ return (
425
+ <Dialog
426
+ title={`Import ${name}`}
427
+ isOpen
428
+ onOpenChange={(open) => {
429
+ if (!open) onClose();
430
+ }}
431
+ >
432
+ {parsed.rows.length === 0 && parsed.problems.length === 0 ? (
433
+ <p>This file has no rows. Each row needs an old address and a new one.</p>
434
+ ) : (
435
+ <>
436
+ <p>
437
+ {parsed.rows.length === 0
438
+ ? 'None of the rows can be imported.'
439
+ : `${String(adding)} new ${adding === 1 ? 'redirect' : 'redirects'}${replacing > 0 ? `, and ${String(replacing)} that replace ${replacing === 1 ? 'one' : 'ones'} already here` : ''}.`}{' '}
440
+ Chains and loops are tidied up when they’re saved.
441
+ </p>
442
+ {parsed.problems.length > 0 && (
443
+ <section aria-labelledby="import-skipped">
444
+ <h3 id="import-skipped" className="mb-1 text-heading-3">
445
+ {parsed.problems.length === 1
446
+ ? '1 row is skipped'
447
+ : `${String(parsed.problems.length)} rows are skipped`}
448
+ </h3>
449
+ <ul className="max-h-48 list-disc overflow-auto pl-6">
450
+ {parsed.problems.map((p) => (
451
+ <li key={p.line}>
452
+ Row {p.line}: {p.message}
453
+ </li>
454
+ ))}
455
+ </ul>
456
+ </section>
457
+ )}
458
+ </>
459
+ )}
460
+ {problem && (
461
+ <p role="alert" className="text-danger">
462
+ {problem}
463
+ </p>
464
+ )}
465
+ <div className="flex flex-wrap justify-end gap-3">
466
+ <Button onPress={onClose}>Cancel</Button>
467
+ {parsed.rows.length > 0 && (
468
+ <Button
469
+ variant="primary"
470
+ isPending={busy}
471
+ onPress={() => {
472
+ setBusy(true);
473
+ void onImport().then((result) => {
474
+ setBusy(false);
475
+ setProblem(result);
476
+ });
477
+ }}
478
+ >
479
+ {`Import ${String(parsed.rows.length)} ${parsed.rows.length === 1 ? 'redirect' : 'redirects'}`}
480
+ </Button>
481
+ )}
482
+ </div>
483
+ </Dialog>
484
+ );
485
+ }
486
+
487
+ function toRows(list: unknown[]): Row[] {
488
+ return list.flatMap((item, index) => {
489
+ const value = decodeValue(item);
490
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return [];
491
+ const { from, to, status, reason, at } = value as Record<string, unknown>;
492
+ if (typeof from !== 'string' || typeof to !== 'string') return [];
493
+ return [
494
+ {
495
+ id: `${String(index)}:${from}`,
496
+ index,
497
+ from,
498
+ to,
499
+ status: (typeof status === 'number' ? status : 301) as RedirectStatus,
500
+ reason: typeof reason === 'string' ? reason : '',
501
+ at: at instanceof Date ? at : null,
502
+ },
503
+ ];
504
+ });
505
+ }
506
+
507
+ function isPermanent(status: number): boolean {
508
+ return status === 301 || status === 308;
509
+ }
510
+
511
+ function reasonText(reason: string): string {
512
+ if (reason === 'move') return 'A page moved';
513
+ if (reason === 'manual') return 'Added here';
514
+ if (reason === 'import') return 'Imported';
515
+ return reason;
516
+ }
517
+
518
+ function normalizeFrom(from: string): string {
519
+ const trimmed = from.trim();
520
+ const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
521
+ return path.length > 1 ? path.replace(/\/+$/, '') : path;
522
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /**
4
+ * Shown when this build of the studio has no storage back end: a deployed
5
+ * site before GitHub mode (Phase 3) exists. It's outside the studio context,
6
+ * so it draws its own landmarks.
7
+ */
8
+ export function Unavailable({ siteName }: { siteName: string }) {
9
+ useEffect(() => {
10
+ document.title = `Editing unavailable · ${siteName}`;
11
+ }, [siteName]);
12
+ return (
13
+ <div className="afterglow min-h-screen">
14
+ <main id="main" className="mx-auto max-w-2xl px-4 py-16">
15
+ <h1 className="mb-4 text-heading-1 text-content">Editing isn’t available here yet</h1>
16
+ <p className="mb-3">
17
+ This copy of {siteName} was built without a way to save changes. For now, content is
18
+ edited while the site runs on a developer’s computer.
19
+ </p>
20
+ <p className="text-content-muted">
21
+ Developers: run <code className="font-mono text-small">astro dev</code> and open{' '}
22
+ <code className="font-mono text-small">/admin</code> there.
23
+ </p>
24
+ </main>
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,104 @@
1
+ import {
2
+ scheduleState,
3
+ type CollectionManifest,
4
+ type DataValue,
5
+ type Draft,
6
+ type EntryStatus,
7
+ } from '@vespercms/core';
8
+ import { StatusPill, type StatusTone } from '@vespercms/ui';
9
+
10
+ /*
11
+ * An entry's state as one coloured word and the words after it (DECISIONS.md,
12
+ * "Status words have colour"): Draft, Scheduled, Live or Unpublished. The
13
+ * words are the ones the studio already said; only the first takes a colour,
14
+ * so what a screen reader hears, and what type-ahead matches, is unchanged.
15
+ */
16
+
17
+ export type EntryState = {
18
+ tone: StatusTone;
19
+ /** "Draft", "Scheduled", "Live" or "Unpublished". */
20
+ word: string;
21
+ /** What follows the word, its joiner included: " · Sat, Oct 31, 8:00 AM". */
22
+ rest: string;
23
+ };
24
+
25
+ /**
26
+ * What an entry is now. `times` is the entry's data with its schedule's
27
+ * fields, or null where they aren't known (a page tree's rows), which then
28
+ * never claims Live. Live is said only where an entry could be something
29
+ * else: a collection with a schedule or a `draft` field, or any in the cloud,
30
+ * where a new entry starts as a draft. Elsewhere every entry is live, and
31
+ * saying so on every row would only be noise.
32
+ */
33
+ export function entryState(
34
+ collection: CollectionManifest,
35
+ entry: { draft: boolean; times: DataValue | null; newDraft?: boolean },
36
+ options: { zone: string; cloud: boolean; now?: Date },
37
+ ): EntryState | null {
38
+ const schedule =
39
+ entry.times === null
40
+ ? null
41
+ : scheduleState(
42
+ collection.presentation.schedule,
43
+ entry.times,
44
+ options.now ?? new Date(),
45
+ options.zone,
46
+ );
47
+ if (entry.draft) {
48
+ return {
49
+ tone: 'neutral',
50
+ word: 'Draft',
51
+ rest: schedule ? ` · ${schedule.word}${schedule.rest}` : '',
52
+ };
53
+ }
54
+ if (schedule) {
55
+ return {
56
+ tone:
57
+ schedule.phase === 'scheduled'
58
+ ? 'scheduled'
59
+ : schedule.phase === 'live'
60
+ ? 'live'
61
+ : 'neutral',
62
+ word: schedule.word,
63
+ rest: schedule.rest,
64
+ };
65
+ }
66
+ if (entry.newDraft) return { tone: 'neutral', word: 'Draft', rest: '' };
67
+ const couldBeOther =
68
+ options.cloud ||
69
+ collection.presentation.schedule !== undefined ||
70
+ (collection.fields ?? []).some((field) => field.path.length === 1 && field.path[0] === 'draft');
71
+ if (entry.times === null && collection.presentation.schedule !== undefined) return null;
72
+ return couldBeOther ? { tone: 'live', word: 'Live', rest: '' } : null;
73
+ }
74
+
75
+ /** Whether a file is in a draft that would create it: in the cloud, an entry never published. */
76
+ export function isNewInDraft(drafts: readonly Draft[], path: string | undefined): boolean {
77
+ return (
78
+ path !== undefined &&
79
+ drafts.some((draft) =>
80
+ draft.files.some((file) => file.path === path && file.op === 'put' && file.base === null),
81
+ )
82
+ );
83
+ }
84
+
85
+ /** "Read only" or "Edit as YAML": what can be done with the entry, after its state. */
86
+ export function kindText(status: EntryStatus | undefined): string {
87
+ return status === 'read-only' ? 'Read only' : status === 'source-only' ? 'Edit as YAML' : '';
88
+ }
89
+
90
+ /** The state as plain words, for names and type-ahead. */
91
+ export function stateText(state: EntryState | null): string {
92
+ return state ? `${state.word}${state.rest}` : '';
93
+ }
94
+
95
+ /** The state's word on its colour, then the words after it as text. */
96
+ export function StateWords({ state }: { state: EntryState | null }) {
97
+ if (!state) return null;
98
+ return (
99
+ <>
100
+ <StatusPill tone={state.tone}>{state.word}</StatusPill>
101
+ {state.rest}
102
+ </>
103
+ );
104
+ }