@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.
- package/README.md +119 -2
- package/dist/BodyEditor-_mkh0BSQ.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-4fVeej3c.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 +35 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +16 -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/screens/status.d.ts +36 -0
- package/dist/shortcuts-CarKeDqp.js +4666 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6449 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/blank-date.d.ts +8 -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 +298 -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 +303 -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 +122 -0
- package/src/screens/EntryEditor.tsx +1817 -0
- package/src/screens/EntryList.tsx +272 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +477 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +738 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/screens/status.tsx +104 -0
- package/src/studio.css +168 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +315 -0
- package/src/widgets/blank-date.ts +32 -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 +290 -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
|
+
}
|
|
@@ -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
|
+
}
|