@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,302 @@
|
|
|
1
|
+
import { extensionOf, isImagePath, type MediaItem } from '@vespercms/core';
|
|
2
|
+
import { Button, Dialog, Icon, TextField } from '@vespercms/ui';
|
|
3
|
+
import { useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { FileImage } from '../app/file-url.tsx';
|
|
5
|
+
import { useStudio } from '../app/context.tsx';
|
|
6
|
+
import { useLoad } from '../app/load.ts';
|
|
7
|
+
import { fileSize, megabytes } from './hygiene.ts';
|
|
8
|
+
import { fileIcon } from './file-icon.ts';
|
|
9
|
+
import { checkUpload, sendUpload, type UploadStep } from './upload.ts';
|
|
10
|
+
|
|
11
|
+
export type MediaPickerProps = {
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
onOpenChange: (open: boolean) => void;
|
|
14
|
+
kind: 'image' | 'file';
|
|
15
|
+
/** File extensions a file field accepts, lower case without dots. Empty for any. */
|
|
16
|
+
extensions?: readonly string[];
|
|
17
|
+
/** Where a new upload goes, site-relative. */
|
|
18
|
+
folder: string;
|
|
19
|
+
/** Called with the chosen file's site-relative path, and what hygiene did to a new upload. */
|
|
20
|
+
onChoose: (path: string, notes?: readonly string[]) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Choose a file: upload a new one (after hygiene and a duplicate check) or
|
|
25
|
+
* pick one already in the site. One dialog, two plain sections.
|
|
26
|
+
*/
|
|
27
|
+
export function MediaPicker({
|
|
28
|
+
isOpen,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
kind,
|
|
31
|
+
extensions = [],
|
|
32
|
+
folder,
|
|
33
|
+
onChoose,
|
|
34
|
+
}: MediaPickerProps) {
|
|
35
|
+
return (
|
|
36
|
+
<Dialog
|
|
37
|
+
title={kind === 'image' ? 'Choose an image' : 'Choose a file'}
|
|
38
|
+
isOpen={isOpen}
|
|
39
|
+
onOpenChange={onOpenChange}
|
|
40
|
+
>
|
|
41
|
+
{isOpen && (
|
|
42
|
+
<PickerBody
|
|
43
|
+
kind={kind}
|
|
44
|
+
extensions={extensions}
|
|
45
|
+
folder={folder}
|
|
46
|
+
onChoose={(path, notes) => {
|
|
47
|
+
onChoose(path, notes);
|
|
48
|
+
onOpenChange(false);
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
</Dialog>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function PickerBody({
|
|
57
|
+
kind,
|
|
58
|
+
extensions = [],
|
|
59
|
+
folder,
|
|
60
|
+
onChoose,
|
|
61
|
+
}: Pick<MediaPickerProps, 'kind' | 'extensions' | 'folder' | 'onChoose'>) {
|
|
62
|
+
const { api, siteName, manifest } = useStudio();
|
|
63
|
+
const maxEdge = manifest.site.images?.maxUploadEdge;
|
|
64
|
+
const [media, reloadMedia] = useLoad(() => api.listMedia(), 'media');
|
|
65
|
+
const [step, setStep] = useState<UploadStep | { step: 'working'; text: string } | null>(null);
|
|
66
|
+
const [query, setQuery] = useState('');
|
|
67
|
+
const input = useRef<HTMLInputElement>(null);
|
|
68
|
+
|
|
69
|
+
const fits = (path: string) =>
|
|
70
|
+
kind === 'image'
|
|
71
|
+
? isImagePath(path)
|
|
72
|
+
: extensions.length === 0 || extensions.includes(extensionOf(path));
|
|
73
|
+
const items = useMemo(() => {
|
|
74
|
+
const needle = query.trim().toLowerCase();
|
|
75
|
+
return (media.state === 'ready' ? media.value : [])
|
|
76
|
+
.filter((item) => fits(item.path))
|
|
77
|
+
.filter((item) => needle === '' || item.path.toLowerCase().includes(needle));
|
|
78
|
+
// `fits` only reads props that key this body.
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [media, query]);
|
|
81
|
+
|
|
82
|
+
const upload = async (ready: Extract<UploadStep, { upload: unknown }>) => {
|
|
83
|
+
setStep({ step: 'working', text: 'Uploading…' });
|
|
84
|
+
// Chosen for a field: in GitHub mode it goes out with the entry's next publish.
|
|
85
|
+
const result = await sendUpload(api, ready.upload, folder, { withNextSave: true });
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
reloadMedia();
|
|
88
|
+
onChoose(result.path, ready.upload.notes);
|
|
89
|
+
} else {
|
|
90
|
+
setStep({ step: 'refused', message: result.message });
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const accept =
|
|
95
|
+
kind === 'image'
|
|
96
|
+
? 'image/*,.heic,.heif'
|
|
97
|
+
: extensions.length > 0
|
|
98
|
+
? extensions.map((e) => `.${e}`).join(',')
|
|
99
|
+
: undefined;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex flex-col gap-6">
|
|
103
|
+
<section aria-labelledby="picker-upload" className="flex flex-col gap-3">
|
|
104
|
+
<h3 id="picker-upload" className="text-heading-3">
|
|
105
|
+
Upload from your computer
|
|
106
|
+
</h3>
|
|
107
|
+
<p className="text-small text-content-muted">
|
|
108
|
+
{kind === 'image'
|
|
109
|
+
? `Large photos are scaled down, turned upright, and stripped of location data before they’re added to ${siteName}.`
|
|
110
|
+
: `The file is added to ${folder}.`}
|
|
111
|
+
</p>
|
|
112
|
+
<input
|
|
113
|
+
ref={input}
|
|
114
|
+
type="file"
|
|
115
|
+
accept={accept}
|
|
116
|
+
tabIndex={-1}
|
|
117
|
+
aria-hidden="true"
|
|
118
|
+
className="sr-only"
|
|
119
|
+
onChange={(event) => {
|
|
120
|
+
const file = event.target.files?.[0];
|
|
121
|
+
event.target.value = '';
|
|
122
|
+
if (!file) return;
|
|
123
|
+
setStep({ step: 'working', text: `Preparing ${file.name}…` });
|
|
124
|
+
void checkUpload(file, media.state === 'ready' ? media.value : [], {
|
|
125
|
+
accept: kind === 'image' ? 'image' : 'any',
|
|
126
|
+
...(maxEdge ? { maxEdge } : {}),
|
|
127
|
+
}).then((next) => {
|
|
128
|
+
if (next.step === 'ready') void upload(next);
|
|
129
|
+
else setStep(next);
|
|
130
|
+
});
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
<div>
|
|
134
|
+
<Button
|
|
135
|
+
icon="upload"
|
|
136
|
+
onPress={() => {
|
|
137
|
+
input.current?.click();
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{kind === 'image' ? 'Upload an image' : 'Upload a file'}
|
|
141
|
+
</Button>
|
|
142
|
+
</div>
|
|
143
|
+
<UploadStatus
|
|
144
|
+
step={step}
|
|
145
|
+
onUpload={(ready) => void upload(ready)}
|
|
146
|
+
onReuse={onChoose}
|
|
147
|
+
onCancel={() => {
|
|
148
|
+
setStep(null);
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
</section>
|
|
152
|
+
|
|
153
|
+
<section aria-labelledby="picker-existing" className="flex flex-col gap-3">
|
|
154
|
+
<h3 id="picker-existing" className="text-heading-3">
|
|
155
|
+
Or choose one already in the site
|
|
156
|
+
</h3>
|
|
157
|
+
<TextField
|
|
158
|
+
label="Search by file name"
|
|
159
|
+
type="search"
|
|
160
|
+
icon="search"
|
|
161
|
+
value={query}
|
|
162
|
+
onChange={setQuery}
|
|
163
|
+
/>
|
|
164
|
+
{media.state === 'loading' && (
|
|
165
|
+
<p className="text-small text-content-muted">Loading files…</p>
|
|
166
|
+
)}
|
|
167
|
+
{media.state === 'failed' && (
|
|
168
|
+
<p role="alert" className="text-danger">
|
|
169
|
+
Couldn’t list files: {media.message}
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
{media.state === 'ready' && items.length === 0 && (
|
|
173
|
+
<p className="text-content-muted">No {kind === 'image' ? 'images' : 'files'} found.</p>
|
|
174
|
+
)}
|
|
175
|
+
<ul className="grid max-h-80 grid-cols-1 gap-2 overflow-y-auto sm:grid-cols-2">
|
|
176
|
+
{items.map((item) => (
|
|
177
|
+
<li key={item.path}>
|
|
178
|
+
<MediaChoice item={item} onChoose={onChoose} />
|
|
179
|
+
</li>
|
|
180
|
+
))}
|
|
181
|
+
</ul>
|
|
182
|
+
</section>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function MediaChoice({ item, onChoose }: { item: MediaItem; onChoose: (path: string) => void }) {
|
|
188
|
+
const name = item.path.slice(item.path.lastIndexOf('/') + 1);
|
|
189
|
+
return (
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => {
|
|
193
|
+
onChoose(item.path);
|
|
194
|
+
}}
|
|
195
|
+
className="flex w-full items-center gap-3 rounded-md border border-border p-2 text-left hover:bg-surface-sunken focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)"
|
|
196
|
+
>
|
|
197
|
+
{isImagePath(item.path) ? (
|
|
198
|
+
<FileImage
|
|
199
|
+
path={item.path}
|
|
200
|
+
loading="lazy"
|
|
201
|
+
className="size-12 shrink-0 rounded-md object-cover"
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<span
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
className="flex size-12 shrink-0 items-center justify-center rounded-md bg-surface-sunken text-content-muted"
|
|
207
|
+
>
|
|
208
|
+
<Icon name={fileIcon(extensionOf(item.path))} size={24} />
|
|
209
|
+
</span>
|
|
210
|
+
)}
|
|
211
|
+
<span className="min-w-0">
|
|
212
|
+
<span className="block truncate font-semibold">{name}</span>
|
|
213
|
+
<span className="block font-mono text-caption break-all text-content-muted">
|
|
214
|
+
{item.path.slice(0, item.path.lastIndexOf('/'))} · {fileSize(item.size)}
|
|
215
|
+
</span>
|
|
216
|
+
</span>
|
|
217
|
+
</button>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function UploadStatus({
|
|
222
|
+
step,
|
|
223
|
+
onUpload,
|
|
224
|
+
onReuse,
|
|
225
|
+
onCancel,
|
|
226
|
+
}: {
|
|
227
|
+
step: UploadStep | { step: 'working'; text: string } | null;
|
|
228
|
+
onUpload: (ready: Extract<UploadStep, { upload: unknown }>) => void;
|
|
229
|
+
onReuse: (path: string) => void;
|
|
230
|
+
onCancel: () => void;
|
|
231
|
+
}) {
|
|
232
|
+
if (!step) return null;
|
|
233
|
+
if (step.step === 'working') return <p role="status">{step.text}</p>;
|
|
234
|
+
if (step.step === 'refused') {
|
|
235
|
+
return (
|
|
236
|
+
<p role="alert" className="text-danger">
|
|
237
|
+
{step.message}
|
|
238
|
+
</p>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const notes = step.upload.notes.length > 0 && (
|
|
242
|
+
<ul className="list-disc pl-6 text-small text-content-muted">
|
|
243
|
+
{step.upload.notes.map((note) => (
|
|
244
|
+
<li key={note}>{note}</li>
|
|
245
|
+
))}
|
|
246
|
+
</ul>
|
|
247
|
+
);
|
|
248
|
+
if (step.step === 'duplicate') {
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
role="alert"
|
|
252
|
+
className="flex flex-col gap-3 rounded-md border border-warning bg-surface-raised p-4"
|
|
253
|
+
>
|
|
254
|
+
<p>This file is already in the site as {step.existing}.</p>
|
|
255
|
+
<div className="flex flex-wrap gap-3">
|
|
256
|
+
<Button
|
|
257
|
+
onPress={() => {
|
|
258
|
+
onReuse(step.existing);
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
Use the existing file
|
|
262
|
+
</Button>
|
|
263
|
+
<Button
|
|
264
|
+
variant="quiet"
|
|
265
|
+
onPress={() => {
|
|
266
|
+
onUpload(step);
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
Upload a copy anyway
|
|
270
|
+
</Button>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (step.step === 'confirm-large') {
|
|
276
|
+
return (
|
|
277
|
+
<div
|
|
278
|
+
role="alert"
|
|
279
|
+
className="flex flex-col gap-3 rounded-md border border-warning bg-surface-raised p-4"
|
|
280
|
+
>
|
|
281
|
+
<p>
|
|
282
|
+
{step.upload.name} is {megabytes(step.upload.bytes.length)}. Large files make the site’s
|
|
283
|
+
repository and pages heavier. Upload it anyway?
|
|
284
|
+
</p>
|
|
285
|
+
{notes}
|
|
286
|
+
<div className="flex flex-wrap gap-3">
|
|
287
|
+
<Button
|
|
288
|
+
onPress={() => {
|
|
289
|
+
onUpload(step);
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
Upload it
|
|
293
|
+
</Button>
|
|
294
|
+
<Button variant="quiet" onPress={onCancel}>
|
|
295
|
+
Cancel
|
|
296
|
+
</Button>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IconName } from '@vespercms/ui';
|
|
2
|
+
|
|
3
|
+
const KINDS: [IconName, string[]][] = [
|
|
4
|
+
[
|
|
5
|
+
'file-text',
|
|
6
|
+
['pdf', 'doc', 'docx', 'odt', 'rtf', 'txt', 'md', 'mdx', 'mdoc', 'ppt', 'pptx', 'key'],
|
|
7
|
+
],
|
|
8
|
+
['file-sheet', ['csv', 'tsv', 'xls', 'xlsx', 'ods', 'numbers']],
|
|
9
|
+
['file-archive', ['zip', 'gz', 'tgz', 'tar', '7z', 'rar']],
|
|
10
|
+
['file-video', ['mp4', 'm4v', 'mov', 'webm', 'ogv']],
|
|
11
|
+
['file-audio', ['mp3', 'm4a', 'wav', 'ogg', 'oga', 'flac', 'aac']],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** The icon for a file that isn't an image, by its extension; a plain page when unknown. */
|
|
15
|
+
export function fileIcon(extension: string): IconName {
|
|
16
|
+
return KINDS.find(([, extensions]) => extensions.includes(extension))?.[0] ?? 'file';
|
|
17
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Just enough of each image format to make upload hygiene decisions without
|
|
3
|
+
* decoding pixels (spec §6.6.6): what a file really is, its size, its EXIF
|
|
4
|
+
* orientation, whether it carries location data, and how to drop metadata
|
|
5
|
+
* without re-encoding. Pure functions over bytes, so they're unit-tested.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type DetectedType =
|
|
9
|
+
'jpeg' | 'png' | 'gif' | 'webp' | 'avif' | 'heic' | 'svg' | 'pdf' | 'other';
|
|
10
|
+
|
|
11
|
+
const ascii = (bytes: Uint8Array, start: number, length: number) =>
|
|
12
|
+
String.fromCharCode(...bytes.subarray(start, start + length));
|
|
13
|
+
|
|
14
|
+
/** What a file is, from its first bytes rather than its name. */
|
|
15
|
+
export function detectType(bytes: Uint8Array): DetectedType {
|
|
16
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
|
|
17
|
+
if (ascii(bytes, 0, 8) === '\x89PNG\r\n\x1a\n') return 'png';
|
|
18
|
+
if (ascii(bytes, 0, 4) === 'GIF8') return 'gif';
|
|
19
|
+
if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP') return 'webp';
|
|
20
|
+
if (ascii(bytes, 0, 5) === '%PDF-') return 'pdf';
|
|
21
|
+
if (ascii(bytes, 4, 4) === 'ftyp') {
|
|
22
|
+
const size = view(bytes).getUint32(0);
|
|
23
|
+
const brands = [ascii(bytes, 8, 4)];
|
|
24
|
+
for (let at = 16; at + 4 <= Math.min(size, bytes.length); at += 4)
|
|
25
|
+
brands.push(ascii(bytes, at, 4));
|
|
26
|
+
if (brands.some((b) => b === 'avif' || b === 'avis')) return 'avif';
|
|
27
|
+
if (
|
|
28
|
+
brands.some((b) =>
|
|
29
|
+
['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'mif1', 'msf1'].includes(b),
|
|
30
|
+
)
|
|
31
|
+
) {
|
|
32
|
+
return 'heic';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const head = new TextDecoder()
|
|
36
|
+
.decode(bytes.subarray(0, 1024))
|
|
37
|
+
.replace(/^\uFEFF/, '')
|
|
38
|
+
.trimStart();
|
|
39
|
+
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(head))
|
|
40
|
+
return 'svg';
|
|
41
|
+
return 'other';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function view(bytes: Uint8Array): DataView {
|
|
45
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ImageFacts = {
|
|
49
|
+
width?: number;
|
|
50
|
+
height?: number;
|
|
51
|
+
/** EXIF orientation, 1–8. 1 (or absent) means the pixels are already upright. */
|
|
52
|
+
orientation?: number;
|
|
53
|
+
/** EXIF GPS data is present. */
|
|
54
|
+
hasLocation: boolean;
|
|
55
|
+
/** Any metadata a lossless strip would remove. */
|
|
56
|
+
hasMetadata: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// --- JPEG -----------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
type Segment = { marker: number; start: number; end: number };
|
|
62
|
+
|
|
63
|
+
/** Segments before the image data (SOS), with their byte ranges. */
|
|
64
|
+
function jpegSegments(bytes: Uint8Array): { segments: Segment[]; dataStart: number } {
|
|
65
|
+
const segments: Segment[] = [];
|
|
66
|
+
let at = 2;
|
|
67
|
+
while (at + 4 <= bytes.length && bytes[at] === 0xff) {
|
|
68
|
+
const marker = bytes[at + 1] ?? 0;
|
|
69
|
+
if (marker === 0xff) {
|
|
70
|
+
at += 1; // fill byte
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const length = view(bytes).getUint16(at + 2);
|
|
74
|
+
const end = at + 2 + length;
|
|
75
|
+
if (marker === 0xda) return { segments, dataStart: at };
|
|
76
|
+
segments.push({ marker, start: at, end });
|
|
77
|
+
at = end;
|
|
78
|
+
}
|
|
79
|
+
return { segments, dataStart: at };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Orientation and GPS presence from an EXIF (TIFF) block. */
|
|
83
|
+
function readExif(tiff: Uint8Array): { orientation?: number; hasLocation: boolean } {
|
|
84
|
+
if (tiff.length < 8) return { hasLocation: false };
|
|
85
|
+
const little = ascii(tiff, 0, 2) === 'II';
|
|
86
|
+
const data = view(tiff);
|
|
87
|
+
const u16 = (o: number) => data.getUint16(o, little);
|
|
88
|
+
const u32 = (o: number) => data.getUint32(o, little);
|
|
89
|
+
const ifd = u32(4);
|
|
90
|
+
if (ifd + 2 > tiff.length) return { hasLocation: false };
|
|
91
|
+
let orientation: number | undefined;
|
|
92
|
+
let hasLocation = false;
|
|
93
|
+
const count = u16(ifd);
|
|
94
|
+
for (let i = 0; i < count; i++) {
|
|
95
|
+
const entry = ifd + 2 + i * 12;
|
|
96
|
+
if (entry + 12 > tiff.length) break;
|
|
97
|
+
const tag = u16(entry);
|
|
98
|
+
if (tag === 0x0112) orientation = u16(entry + 8);
|
|
99
|
+
if (tag === 0x8825) hasLocation = true;
|
|
100
|
+
}
|
|
101
|
+
return { ...(orientation !== undefined ? { orientation } : {}), hasLocation };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const KEEP_APP = new Set([0xe0, 0xe2, 0xee]); // JFIF, ICC colour profile, Adobe colour transform
|
|
105
|
+
|
|
106
|
+
export function jpegFacts(bytes: Uint8Array): ImageFacts {
|
|
107
|
+
const facts: ImageFacts = { hasLocation: false, hasMetadata: false };
|
|
108
|
+
for (const s of jpegSegments(bytes).segments) {
|
|
109
|
+
const body = bytes.subarray(s.start + 4, s.end);
|
|
110
|
+
if (s.marker === 0xe1 && ascii(body, 0, 6) === 'Exif\0\0') {
|
|
111
|
+
Object.assign(facts, readExif(body.subarray(6)));
|
|
112
|
+
}
|
|
113
|
+
if ((s.marker >= 0xe1 && s.marker <= 0xef && !KEEP_APP.has(s.marker)) || s.marker === 0xfe) {
|
|
114
|
+
facts.hasMetadata = true;
|
|
115
|
+
}
|
|
116
|
+
// Start-of-frame markers carry the size (not DHT C4, JPG C8 or DAC CC).
|
|
117
|
+
if (s.marker >= 0xc0 && s.marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(s.marker)) {
|
|
118
|
+
facts.height = view(bytes).getUint16(s.start + 5);
|
|
119
|
+
facts.width = view(bytes).getUint16(s.start + 7);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return facts;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The same JPEG without EXIF, XMP, IPTC or comments. The image data is untouched. */
|
|
126
|
+
export function stripJpeg(bytes: Uint8Array): Uint8Array {
|
|
127
|
+
const { segments, dataStart } = jpegSegments(bytes);
|
|
128
|
+
const parts: Uint8Array[] = [bytes.subarray(0, 2)];
|
|
129
|
+
for (const s of segments) {
|
|
130
|
+
const metadata =
|
|
131
|
+
(s.marker >= 0xe1 && s.marker <= 0xef && !KEEP_APP.has(s.marker)) || s.marker === 0xfe;
|
|
132
|
+
if (!metadata) parts.push(bytes.subarray(s.start, s.end));
|
|
133
|
+
}
|
|
134
|
+
parts.push(bytes.subarray(dataStart));
|
|
135
|
+
return concat(parts);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- PNG ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const PNG_METADATA = new Set(['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME']);
|
|
141
|
+
|
|
142
|
+
function pngChunks(bytes: Uint8Array): { type: string; start: number; end: number }[] {
|
|
143
|
+
const chunks: { type: string; start: number; end: number }[] = [];
|
|
144
|
+
let at = 8;
|
|
145
|
+
while (at + 12 <= bytes.length) {
|
|
146
|
+
const length = view(bytes).getUint32(at);
|
|
147
|
+
const type = ascii(bytes, at + 4, 4);
|
|
148
|
+
const end = at + 12 + length;
|
|
149
|
+
chunks.push({ type, start: at, end });
|
|
150
|
+
at = end;
|
|
151
|
+
if (type === 'IEND') break;
|
|
152
|
+
}
|
|
153
|
+
return chunks;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function pngFacts(bytes: Uint8Array): ImageFacts {
|
|
157
|
+
const chunks = pngChunks(bytes);
|
|
158
|
+
const ihdr = chunks.find((c) => c.type === 'IHDR');
|
|
159
|
+
const exif = chunks.find((c) => c.type === 'eXIf');
|
|
160
|
+
return {
|
|
161
|
+
...(ihdr
|
|
162
|
+
? {
|
|
163
|
+
width: view(bytes).getUint32(ihdr.start + 8),
|
|
164
|
+
height: view(bytes).getUint32(ihdr.start + 12),
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
...(exif ? readExif(bytes.subarray(exif.start + 8, exif.end - 4)) : { hasLocation: false }),
|
|
168
|
+
hasMetadata: chunks.some((c) => PNG_METADATA.has(c.type)),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The same PNG without text, time or EXIF chunks. */
|
|
173
|
+
export function stripPng(bytes: Uint8Array): Uint8Array {
|
|
174
|
+
const parts = [bytes.subarray(0, 8)];
|
|
175
|
+
for (const c of pngChunks(bytes)) {
|
|
176
|
+
if (!PNG_METADATA.has(c.type)) parts.push(bytes.subarray(c.start, c.end));
|
|
177
|
+
}
|
|
178
|
+
return concat(parts);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- WebP -----------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
function webpChunks(bytes: Uint8Array): { type: string; start: number; end: number }[] {
|
|
184
|
+
const chunks: { type: string; start: number; end: number }[] = [];
|
|
185
|
+
let at = 12;
|
|
186
|
+
while (at + 8 <= bytes.length) {
|
|
187
|
+
const size = view(bytes).getUint32(at + 4, true);
|
|
188
|
+
const end = at + 8 + size + (size % 2);
|
|
189
|
+
chunks.push({ type: ascii(bytes, at, 4), start: at, end: Math.min(end, bytes.length) });
|
|
190
|
+
at = end;
|
|
191
|
+
}
|
|
192
|
+
return chunks;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function webpFacts(bytes: Uint8Array): ImageFacts {
|
|
196
|
+
const chunks = webpChunks(bytes);
|
|
197
|
+
const data = view(bytes);
|
|
198
|
+
const facts: ImageFacts = {
|
|
199
|
+
hasLocation: false,
|
|
200
|
+
hasMetadata: chunks.some((c) => c.type === 'EXIF' || c.type === 'XMP '),
|
|
201
|
+
};
|
|
202
|
+
const exif = chunks.find((c) => c.type === 'EXIF');
|
|
203
|
+
if (exif) {
|
|
204
|
+
let tiff = bytes.subarray(exif.start + 8, exif.end);
|
|
205
|
+
if (ascii(tiff, 0, 6) === 'Exif\0\0') tiff = tiff.subarray(6);
|
|
206
|
+
Object.assign(facts, readExif(tiff));
|
|
207
|
+
}
|
|
208
|
+
for (const c of chunks) {
|
|
209
|
+
const d = c.start + 8;
|
|
210
|
+
if (c.type === 'VP8X') {
|
|
211
|
+
facts.width = 1 + (data.getUint32(d + 4, true) & 0xffffff);
|
|
212
|
+
facts.height = 1 + (data.getUint32(d + 7, true) & 0xffffff);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
if (c.type === 'VP8 ') {
|
|
216
|
+
facts.width = data.getUint16(d + 6, true) & 0x3fff;
|
|
217
|
+
facts.height = data.getUint16(d + 8, true) & 0x3fff;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
if (c.type === 'VP8L') {
|
|
221
|
+
const bits = data.getUint32(d + 1, true);
|
|
222
|
+
facts.width = 1 + (bits & 0x3fff);
|
|
223
|
+
facts.height = 1 + ((bits >> 14) & 0x3fff);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return facts;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The same WebP without EXIF or XMP, with the header flags and size fixed up. */
|
|
231
|
+
export function stripWebp(bytes: Uint8Array): Uint8Array {
|
|
232
|
+
const parts: Uint8Array[] = [];
|
|
233
|
+
for (const c of webpChunks(bytes)) {
|
|
234
|
+
if (c.type === 'EXIF' || c.type === 'XMP ') continue;
|
|
235
|
+
const chunk = bytes.slice(c.start, c.end);
|
|
236
|
+
if (c.type === 'VP8X') chunk[8] = (chunk[8] ?? 0) & ~(0x08 | 0x04);
|
|
237
|
+
parts.push(chunk);
|
|
238
|
+
}
|
|
239
|
+
const body = concat(parts);
|
|
240
|
+
const out = new Uint8Array(12 + body.length);
|
|
241
|
+
out.set(bytes.subarray(0, 12));
|
|
242
|
+
out.set(body, 12);
|
|
243
|
+
new DataView(out.buffer).setUint32(4, out.length - 8, true);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function imageFacts(type: DetectedType, bytes: Uint8Array): ImageFacts {
|
|
248
|
+
if (type === 'jpeg') return jpegFacts(bytes);
|
|
249
|
+
if (type === 'png') return pngFacts(bytes);
|
|
250
|
+
if (type === 'webp') return webpFacts(bytes);
|
|
251
|
+
return { hasLocation: false, hasMetadata: false };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function concat(parts: Uint8Array[]): Uint8Array {
|
|
255
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
256
|
+
let at = 0;
|
|
257
|
+
for (const part of parts) {
|
|
258
|
+
out.set(part, at);
|
|
259
|
+
at += part.length;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|