@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.
- package/README.md +119 -2
- package/dist/BodyEditor-CAGNfofv.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-KUkv11EB.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +37 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +18 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/shortcuts-B_JMAvF9.js +4630 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6312 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +261 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +300 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +133 -0
- package/src/screens/EntryEditor.tsx +1803 -0
- package/src/screens/EntryList.tsx +274 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +459 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +718 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/studio.css +163 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +311 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +286 -0
- 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
|
+
}
|