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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-KUkv11EB.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,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
+ }
package/src/studio.css ADDED
@@ -0,0 +1,163 @@
1
+ /*
2
+ * The studio's stylesheet: Tailwind plus Vesper's token budget. It is compiled
3
+ * when @vespercms/admin is published (dist/studio.css), so sites need no
4
+ * Tailwind of their own. In this workspace the source is used directly.
5
+ */
6
+ @import 'tailwindcss' source(none);
7
+ @import '@vespercms/ui/tokens.css';
8
+
9
+ @source '../../ui/src';
10
+ @source '.';
11
+
12
+ /*
13
+ * Figtree, self-hosted (DECISIONS.md, "The studio self-hosts Figtree"): two
14
+ * weights, latin only. The published build inlines both files into
15
+ * dist/studio.css, so /admin never asks Google or a CDN for anything.
16
+ * Characters outside the range fall back to the system stack.
17
+ */
18
+ @font-face {
19
+ font-family: Figtree;
20
+ font-style: normal;
21
+ font-weight: 400;
22
+ font-display: swap;
23
+ src: url('@fontsource/figtree/files/figtree-latin-400-normal.woff2') format('woff2');
24
+ unicode-range:
25
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
26
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
27
+ }
28
+
29
+ @font-face {
30
+ font-family: Figtree;
31
+ font-style: normal;
32
+ font-weight: 600;
33
+ font-display: swap;
34
+ src: url('@fontsource/figtree/files/figtree-latin-600-normal.woff2') format('woff2');
35
+ unicode-range:
36
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
37
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
38
+ }
39
+
40
+ @layer base {
41
+ /* The studio fills the page and never inherits the site's own styles. */
42
+ #vesper-studio {
43
+ min-height: 100vh;
44
+ }
45
+
46
+ h1,
47
+ h2,
48
+ h3 {
49
+ font-weight: var(--font-weight-semibold);
50
+ }
51
+ }
52
+
53
+ @layer components {
54
+ /* The geo field's map pin: a real button, so it has a visible focus ring. */
55
+ .vesper-map-pin {
56
+ width: 1.5rem;
57
+ height: 1.5rem;
58
+ border-radius: 9999px 9999px 9999px 0;
59
+ transform: rotate(-45deg);
60
+ background-color: var(--color-accent);
61
+ border: 2px solid var(--color-on-accent);
62
+ box-shadow: var(--shadow-overlay);
63
+ cursor: grab;
64
+ }
65
+
66
+ .vesper-map-pin:focus-visible {
67
+ outline: 3px solid var(--color-focus);
68
+ outline-offset: 3px;
69
+ }
70
+ }
71
+
72
+ @layer components {
73
+ /* The body's writing view: hierarchy from size and weight, not boxes (spec §10.2). */
74
+ .vesper-body > * + * {
75
+ margin-top: 0.75em;
76
+ }
77
+
78
+ .vesper-body h1 {
79
+ font-size: 1.5rem;
80
+ font-weight: var(--font-weight-semibold);
81
+ }
82
+
83
+ .vesper-body h2 {
84
+ font-size: 1.25rem;
85
+ font-weight: var(--font-weight-semibold);
86
+ }
87
+
88
+ /* Each level a step smaller, down to the text's own size; the last is muted to tell it apart. */
89
+ .vesper-body h3,
90
+ .vesper-body h4,
91
+ .vesper-body h5,
92
+ .vesper-body h6 {
93
+ font-size: 1.0625rem;
94
+ font-weight: var(--font-weight-semibold);
95
+ }
96
+
97
+ .vesper-body h4 {
98
+ font-size: 1rem;
99
+ }
100
+
101
+ .vesper-body h5,
102
+ .vesper-body h6 {
103
+ font-size: 0.9375rem;
104
+ }
105
+
106
+ .vesper-body h6 {
107
+ color: var(--color-content-muted);
108
+ }
109
+
110
+ .vesper-body ul {
111
+ list-style: disc;
112
+ padding-left: 1.5em;
113
+ }
114
+
115
+ .vesper-body ol {
116
+ list-style: decimal;
117
+ padding-left: 1.5em;
118
+ }
119
+
120
+ .vesper-body blockquote {
121
+ border-left: 1px solid var(--color-border-strong);
122
+ padding-left: 1em;
123
+ color: var(--color-content-muted);
124
+ }
125
+
126
+ .vesper-body code {
127
+ font-family: var(--font-mono);
128
+ font-size: 0.9em;
129
+ }
130
+
131
+ .vesper-body pre {
132
+ font-family: var(--font-mono);
133
+ font-size: 0.875rem;
134
+ background-color: var(--color-surface);
135
+ border: 1px solid var(--color-border);
136
+ border-radius: 0.375rem;
137
+ padding: 0.75em;
138
+ overflow-x: auto;
139
+ }
140
+
141
+ .vesper-body a {
142
+ color: var(--color-accent);
143
+ text-decoration: underline;
144
+ text-underline-offset: 2px;
145
+ }
146
+
147
+ .vesper-body img {
148
+ display: inline-block;
149
+ max-height: 12rem;
150
+ border-radius: 0.25rem;
151
+ }
152
+
153
+ .vesper-body hr {
154
+ border: 0;
155
+ border-top: 1px solid var(--color-border);
156
+ }
157
+
158
+ /* Only while the text has focus, as for the blocks' own outlines (views.tsx). */
159
+ .vesper-body.ProseMirror-focused .ProseMirror-selectednode {
160
+ outline: 2px solid var(--color-focus);
161
+ outline-offset: 2px;
162
+ }
163
+ }