@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,47 @@
|
|
|
1
|
+
import { Combobox, Icon, type Option } from '@vespercms/ui';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useStudio } from '../app/context.tsx';
|
|
4
|
+
import type { WidgetProps } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An icon field (`fields.icon`): a searchable list of the names it offers,
|
|
8
|
+
* each with its icon before it, and the chosen one's icon in the field. The
|
|
9
|
+
* drawings come from the manifest, which holds only the icons the site's
|
|
10
|
+
* fields offer, so the studio carries no icon set of its own. The file
|
|
11
|
+
* stores the name.
|
|
12
|
+
*/
|
|
13
|
+
export function IconWidget({
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
field,
|
|
17
|
+
id,
|
|
18
|
+
describedBy,
|
|
19
|
+
invalid,
|
|
20
|
+
disabled,
|
|
21
|
+
}: WidgetProps<string>) {
|
|
22
|
+
const { manifest } = useStudio();
|
|
23
|
+
const options = useMemo(() => {
|
|
24
|
+
const drawings = manifest.icons ?? {};
|
|
25
|
+
return (field.constraints?.options ?? []).map((option): Option => {
|
|
26
|
+
const node = Object.hasOwn(drawings, option.value) ? drawings[option.value] : undefined;
|
|
27
|
+
// A name Lucide doesn't have is listed by name, in line with the rest.
|
|
28
|
+
return { ...option, prefix: node ? <Icon node={node} /> : <span className="block size-5" /> };
|
|
29
|
+
});
|
|
30
|
+
}, [field, manifest.icons]);
|
|
31
|
+
return (
|
|
32
|
+
<Combobox
|
|
33
|
+
id={id}
|
|
34
|
+
aria-labelledby={`${id}-label`}
|
|
35
|
+
aria-describedby={describedBy}
|
|
36
|
+
options={options}
|
|
37
|
+
value={typeof value === 'string' ? value : null}
|
|
38
|
+
onChange={(next) => {
|
|
39
|
+
onChange(next ?? undefined);
|
|
40
|
+
}}
|
|
41
|
+
placeholder="Search icons"
|
|
42
|
+
isRequired={field.required}
|
|
43
|
+
isInvalid={invalid}
|
|
44
|
+
isDisabled={disabled}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dirOf,
|
|
3
|
+
extensionOf,
|
|
4
|
+
imageValueOf,
|
|
5
|
+
isPlainObject,
|
|
6
|
+
mediaReference,
|
|
7
|
+
resolveMediaPath,
|
|
8
|
+
siteRatios,
|
|
9
|
+
type DataValue,
|
|
10
|
+
type FieldModel,
|
|
11
|
+
type ImageSize,
|
|
12
|
+
} from '@vespercms/core';
|
|
13
|
+
import { Button, Checkbox, Disclosure, cx } from '@vespercms/ui';
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
import { useStudio } from '../app/context.tsx';
|
|
16
|
+
import { useFileUrl } from '../app/file-url.tsx';
|
|
17
|
+
import { useEditor } from '../editor/context.tsx';
|
|
18
|
+
import { pathKey, type ValuePath } from '../editor/values.ts';
|
|
19
|
+
import { FieldNote, FieldNotes } from '../a11y/FieldNotes.tsx';
|
|
20
|
+
import { MediaPicker } from '../media/MediaPicker.tsx';
|
|
21
|
+
import type { WidgetProps } from '../types.ts';
|
|
22
|
+
import { inputClass } from './basic.tsx';
|
|
23
|
+
import { CropPreviews, type Ratio } from './crops.tsx';
|
|
24
|
+
import { FocalPointControl } from './focal-point.tsx';
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* The image field (spec §6.6.2) and the file field (§6.3.2). Uploads go
|
|
28
|
+
* through the media picker: hygiene, duplicate check, then into the assets
|
|
29
|
+
* folder, and the entry stores a path relative to its own file, so Astro's
|
|
30
|
+
* image() resolves it. A `fields.image()` field also has a focal point and
|
|
31
|
+
* crops per ratio, behind one "Focal point & crops" disclosure (§6.6.4).
|
|
32
|
+
* They only ever change the entry: the image file is never touched.
|
|
33
|
+
*
|
|
34
|
+
* Replacing the image drops its focal point and crops, which were chosen for
|
|
35
|
+
* the old picture, but keeps its alt text and the decorative option, with a
|
|
36
|
+
* note beside them until the editor changes one (DECISIONS.md, "Replacing an
|
|
37
|
+
* image clears its geometry, not its words").
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Where new uploads for this field go: beside the current file, else `src/assets/<collection>`. */
|
|
41
|
+
function uploadFolder(current: string | null, srcDir: string, collection: string): string {
|
|
42
|
+
return current ? dirOf(current) : `${srcDir}/assets/${collection}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fileName(path: string): string {
|
|
46
|
+
return path.slice(path.lastIndexOf('/') + 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The ratios a field previews: the ones it lists, else every ratio the site has (spec §6.6.3). */
|
|
50
|
+
export function fieldRatios(field: FieldModel, site: Record<string, number>): Ratio[] {
|
|
51
|
+
const listed = field.widgetOptions?.ratios;
|
|
52
|
+
const names = Array.isArray(listed)
|
|
53
|
+
? listed.filter((name): name is string => typeof name === 'string' && name in site)
|
|
54
|
+
: Object.keys(site);
|
|
55
|
+
return names.map((name) => ({ name, ratio: site[name] ?? 1 }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An image: preview, choose or replace, alt text written for this page, and
|
|
60
|
+
* a "Decorative image" option that stores `alt: ""` and `decorative: true`.
|
|
61
|
+
* A plain `image()` field stores just the path, with no alt text here.
|
|
62
|
+
*/
|
|
63
|
+
export function ImageWidget({
|
|
64
|
+
value,
|
|
65
|
+
onChange,
|
|
66
|
+
field,
|
|
67
|
+
id,
|
|
68
|
+
describedBy,
|
|
69
|
+
invalid,
|
|
70
|
+
disabled,
|
|
71
|
+
path,
|
|
72
|
+
}: WidgetProps<DataValue> & { path: ValuePath }) {
|
|
73
|
+
const { manifest } = useStudio();
|
|
74
|
+
const editor = useEditor();
|
|
75
|
+
const [picking, setPicking] = useState(false);
|
|
76
|
+
const [notes, setNotes] = useState<readonly string[]>([]);
|
|
77
|
+
// The image the editor replaced the old one with, while its alt text is unchecked.
|
|
78
|
+
const [replacedWith, setReplacedWith] = useState<string | null>(null);
|
|
79
|
+
|
|
80
|
+
const objectShape = field.kind === 'object';
|
|
81
|
+
const record = isPlainObject(value) ? value : {};
|
|
82
|
+
const src = objectShape ? record.src : value;
|
|
83
|
+
const srcText = typeof src === 'string' ? src : '';
|
|
84
|
+
const sitePath = srcText ? resolveMediaPath(srcText, editor.entryPath) : null;
|
|
85
|
+
const fileUrl = useFileUrl(sitePath);
|
|
86
|
+
const hasAlt = objectShape && (field.children ?? []).some((c) => c.path.at(-1) === 'alt');
|
|
87
|
+
const hasDecorative =
|
|
88
|
+
objectShape && (field.children ?? []).some((c) => c.path.at(-1) === 'decorative');
|
|
89
|
+
const decorative = record.decorative === true;
|
|
90
|
+
const alt = typeof record.alt === 'string' ? record.alt : '';
|
|
91
|
+
// Accessibility notes about the alt text (spec §6.18), shown beside it.
|
|
92
|
+
const altNotes = (editor.notes?.get(pathKey(path)) ?? []).filter(
|
|
93
|
+
(note) => note.target.kind === 'field' && note.target.path[path.length] === 'alt',
|
|
94
|
+
);
|
|
95
|
+
const childNamed = (name: string) =>
|
|
96
|
+
objectShape && (field.children ?? []).some((c) => c.path.at(-1) === name);
|
|
97
|
+
const hasFocus = childNamed('focus');
|
|
98
|
+
const hasCrops = childNamed('crops');
|
|
99
|
+
const [size, setSize] = useState<ImageSize | null>(null);
|
|
100
|
+
const [sizeOf, setSizeOf] = useState(srcText);
|
|
101
|
+
if (sizeOf !== srcText) {
|
|
102
|
+
// A new image: its size, and so its crops, are measured again.
|
|
103
|
+
setSizeOf(srcText);
|
|
104
|
+
setSize(null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const replaced = replacedWith === srcText;
|
|
108
|
+
const replacedNote = decorative
|
|
109
|
+
? 'You replaced the image. Check that it’s still decorative.'
|
|
110
|
+
: 'You replaced the image. Check that the alt text still describes it.';
|
|
111
|
+
|
|
112
|
+
const setSrc = (path: string) => {
|
|
113
|
+
// The same image again changes nothing, not even how its path is written.
|
|
114
|
+
if (path === sitePath) return;
|
|
115
|
+
const reference = mediaReference(path, editor.entryPath);
|
|
116
|
+
if (!objectShape) {
|
|
117
|
+
onChange(reference);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { focus: _focus, crops: _crops, ...rest } = record;
|
|
121
|
+
onChange({ ...rest, src: reference });
|
|
122
|
+
if (srcText && (alt !== '' || decorative)) setReplacedWith(reference);
|
|
123
|
+
};
|
|
124
|
+
const setRecord = (next: Record<string, DataValue>) => {
|
|
125
|
+
onChange(next);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="flex flex-col gap-4">
|
|
130
|
+
{srcText ? (
|
|
131
|
+
<div className="flex flex-wrap items-start gap-4">
|
|
132
|
+
{sitePath ? (
|
|
133
|
+
fileUrl && (
|
|
134
|
+
<img
|
|
135
|
+
src={fileUrl}
|
|
136
|
+
alt=""
|
|
137
|
+
className="max-h-48 max-w-full rounded-md border border-border object-contain"
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
) : (
|
|
141
|
+
<p className="text-small text-content-muted">
|
|
142
|
+
The studio can’t show a preview of {srcText}.
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
146
|
+
<p id={`${id}-file`} className="font-mono text-small break-all">
|
|
147
|
+
{fileName(srcText)}
|
|
148
|
+
</p>
|
|
149
|
+
<div className="flex flex-wrap gap-2">
|
|
150
|
+
<Button
|
|
151
|
+
id={id}
|
|
152
|
+
isDisabled={disabled}
|
|
153
|
+
aria-describedby={[describedBy, `${id}-file`].filter(Boolean).join(' ')}
|
|
154
|
+
icon="replace-image"
|
|
155
|
+
onPress={() => {
|
|
156
|
+
setPicking(true);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
Replace image
|
|
160
|
+
</Button>
|
|
161
|
+
<Button
|
|
162
|
+
variant="quiet"
|
|
163
|
+
isDisabled={disabled}
|
|
164
|
+
onPress={() => {
|
|
165
|
+
setNotes([]);
|
|
166
|
+
setReplacedWith(null);
|
|
167
|
+
onChange(
|
|
168
|
+
field.required ? (objectShape ? { ...record, src: '' } : '') : undefined,
|
|
169
|
+
);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
Remove image
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
) : (
|
|
178
|
+
<div>
|
|
179
|
+
<Button
|
|
180
|
+
id={id}
|
|
181
|
+
isDisabled={disabled}
|
|
182
|
+
aria-describedby={describedBy}
|
|
183
|
+
onPress={() => {
|
|
184
|
+
setPicking(true);
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
Choose image
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
{notes.length > 0 && (
|
|
192
|
+
<p role="status" className="text-small text-content-muted">
|
|
193
|
+
Added. {notes.join('. ')}.
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{hasAlt && !decorative && (
|
|
198
|
+
<div className="flex flex-col gap-1">
|
|
199
|
+
<label htmlFor={`${id}-alt`} className="text-label text-content">
|
|
200
|
+
Alt text
|
|
201
|
+
</label>
|
|
202
|
+
<textarea
|
|
203
|
+
id={`${id}-alt`}
|
|
204
|
+
rows={2}
|
|
205
|
+
value={alt}
|
|
206
|
+
onChange={(event) => {
|
|
207
|
+
setReplacedWith(null);
|
|
208
|
+
setRecord({ ...record, alt: event.target.value });
|
|
209
|
+
}}
|
|
210
|
+
aria-describedby={[
|
|
211
|
+
`${id}-alt-help`,
|
|
212
|
+
altNotes.length > 0 ? `${id}-alt-a11y` : '',
|
|
213
|
+
replaced ? `${id}-replaced` : '',
|
|
214
|
+
describedBy,
|
|
215
|
+
]
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.join(' ')}
|
|
218
|
+
aria-invalid={invalid || undefined}
|
|
219
|
+
aria-required="true"
|
|
220
|
+
disabled={disabled}
|
|
221
|
+
className={cx(inputClass, 'resize-y')}
|
|
222
|
+
/>
|
|
223
|
+
<p id={`${id}-alt-help`} className="text-small text-content-muted">
|
|
224
|
+
Describe what this image communicates on this page.
|
|
225
|
+
</p>
|
|
226
|
+
{altNotes.length > 0 && <FieldNotes id={`${id}-alt-a11y`} notes={altNotes} />}
|
|
227
|
+
{replaced && <FieldNote id={`${id}-replaced`}>{replacedNote}</FieldNote>}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
{hasDecorative && (
|
|
231
|
+
<Checkbox
|
|
232
|
+
isSelected={decorative}
|
|
233
|
+
isDisabled={disabled}
|
|
234
|
+
aria-describedby={decorative && replaced ? `${id}-replaced` : undefined}
|
|
235
|
+
onChange={(checked) => {
|
|
236
|
+
setReplacedWith(null);
|
|
237
|
+
if (checked) {
|
|
238
|
+
setRecord({ ...record, alt: '', decorative: true });
|
|
239
|
+
} else {
|
|
240
|
+
const { decorative: _decorative, ...rest } = record;
|
|
241
|
+
setRecord(rest);
|
|
242
|
+
}
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
Decorative image (it adds nothing a reader needs)
|
|
246
|
+
</Checkbox>
|
|
247
|
+
)}
|
|
248
|
+
{hasDecorative && decorative && replaced && (
|
|
249
|
+
<FieldNote id={`${id}-replaced`}>{replacedNote}</FieldNote>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{sitePath && (hasFocus || hasCrops) && (
|
|
253
|
+
<Disclosure title="Focal point & crops">
|
|
254
|
+
<div className="flex flex-col gap-6">
|
|
255
|
+
{hasFocus && fileUrl && (
|
|
256
|
+
<FocalPointControl
|
|
257
|
+
id={id}
|
|
258
|
+
src={fileUrl}
|
|
259
|
+
focus={imageValueOf(record).focus}
|
|
260
|
+
disabled={disabled}
|
|
261
|
+
onImageSize={setSize}
|
|
262
|
+
onChange={(focus) => {
|
|
263
|
+
const { focus: _old, ...rest } = record;
|
|
264
|
+
setRecord(focus ? { ...rest, focus } : rest);
|
|
265
|
+
}}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
268
|
+
{hasCrops && fileUrl && (
|
|
269
|
+
<CropPreviews
|
|
270
|
+
id={id}
|
|
271
|
+
src={fileUrl}
|
|
272
|
+
size={size}
|
|
273
|
+
value={imageValueOf(record)}
|
|
274
|
+
ratios={fieldRatios(field, siteRatios(manifest.site.images?.ratios))}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
onChange={(crops) => {
|
|
277
|
+
const { crops: _old, ...rest } = record;
|
|
278
|
+
setRecord(Object.keys(crops).length > 0 ? { ...rest, crops } : rest);
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
282
|
+
{hasCrops && !hasFocus && fileUrl && (
|
|
283
|
+
<img
|
|
284
|
+
src={fileUrl}
|
|
285
|
+
alt=""
|
|
286
|
+
hidden
|
|
287
|
+
onLoad={(event) => {
|
|
288
|
+
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
|
|
289
|
+
if (width > 0 && height > 0) setSize({ width, height });
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
</Disclosure>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
<MediaPicker
|
|
298
|
+
isOpen={picking}
|
|
299
|
+
onOpenChange={setPicking}
|
|
300
|
+
kind="image"
|
|
301
|
+
folder={uploadFolder(sitePath, manifest.site.srcDir, editor.collection.name)}
|
|
302
|
+
onChoose={(path, uploadNotes) => {
|
|
303
|
+
setNotes(uploadNotes ?? []);
|
|
304
|
+
setSrc(path);
|
|
305
|
+
// Focus returns to the button that opened the picker.
|
|
306
|
+
requestAnimationFrame(() => {
|
|
307
|
+
document.getElementById(id)?.focus();
|
|
308
|
+
});
|
|
309
|
+
}}
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Extensions a `fields.file({ accept })` allows: `application/pdf` or `.pdf` → `pdf`. */
|
|
316
|
+
function acceptedExtensions(accept: unknown): string[] {
|
|
317
|
+
const list = typeof accept === 'string' ? accept.split(',') : Array.isArray(accept) ? accept : [];
|
|
318
|
+
const byMime: Record<string, string[]> = {
|
|
319
|
+
'application/pdf': ['pdf'],
|
|
320
|
+
'video/mp4': ['mp4'],
|
|
321
|
+
'audio/mpeg': ['mp3'],
|
|
322
|
+
'text/csv': ['csv'],
|
|
323
|
+
};
|
|
324
|
+
return list.flatMap((item) => {
|
|
325
|
+
const text = String(item).trim().toLowerCase();
|
|
326
|
+
if (text.startsWith('.')) return [text.slice(1)];
|
|
327
|
+
return byMime[text] ?? [];
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A file (PDF and so on): its name, type and size, with choose, replace and remove. */
|
|
332
|
+
export function FileWidget({
|
|
333
|
+
value,
|
|
334
|
+
onChange,
|
|
335
|
+
field,
|
|
336
|
+
id,
|
|
337
|
+
describedBy,
|
|
338
|
+
disabled,
|
|
339
|
+
}: WidgetProps<string>) {
|
|
340
|
+
const editor = useEditor();
|
|
341
|
+
const [picking, setPicking] = useState(false);
|
|
342
|
+
const current = typeof value === 'string' ? value : '';
|
|
343
|
+
const sitePath = current ? resolveMediaPath(current, editor.entryPath) : null;
|
|
344
|
+
const fileUrl = useFileUrl(sitePath);
|
|
345
|
+
const extensions = acceptedExtensions(field.widgetOptions?.accept);
|
|
346
|
+
return (
|
|
347
|
+
<div className="flex flex-col gap-2">
|
|
348
|
+
{current && (
|
|
349
|
+
<p id={`${id}-file`} className="text-small">
|
|
350
|
+
{sitePath && fileUrl ? (
|
|
351
|
+
<a
|
|
352
|
+
href={fileUrl}
|
|
353
|
+
target="_blank"
|
|
354
|
+
rel="noopener noreferrer"
|
|
355
|
+
className="text-accent underline underline-offset-2"
|
|
356
|
+
>
|
|
357
|
+
{fileName(current)}
|
|
358
|
+
</a>
|
|
359
|
+
) : (
|
|
360
|
+
fileName(current)
|
|
361
|
+
)}{' '}
|
|
362
|
+
<span className="text-content-muted">
|
|
363
|
+
({extensionOf(current).toUpperCase() || 'file'})
|
|
364
|
+
</span>
|
|
365
|
+
</p>
|
|
366
|
+
)}
|
|
367
|
+
<div className="flex flex-wrap gap-2">
|
|
368
|
+
<Button
|
|
369
|
+
id={id}
|
|
370
|
+
isDisabled={disabled}
|
|
371
|
+
aria-describedby={
|
|
372
|
+
[describedBy, current ? `${id}-file` : ''].filter(Boolean).join(' ') || undefined
|
|
373
|
+
}
|
|
374
|
+
onPress={() => {
|
|
375
|
+
setPicking(true);
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
378
|
+
{current ? 'Replace file' : 'Choose file'}
|
|
379
|
+
</Button>
|
|
380
|
+
{current && (
|
|
381
|
+
<Button
|
|
382
|
+
variant="quiet"
|
|
383
|
+
isDisabled={disabled}
|
|
384
|
+
onPress={() => {
|
|
385
|
+
onChange(field.required ? '' : undefined);
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
Remove file
|
|
389
|
+
</Button>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
<MediaPicker
|
|
393
|
+
isOpen={picking}
|
|
394
|
+
onOpenChange={setPicking}
|
|
395
|
+
kind="file"
|
|
396
|
+
extensions={extensions}
|
|
397
|
+
folder={sitePath ? dirOf(sitePath) : `public/files`}
|
|
398
|
+
onChoose={(path) => {
|
|
399
|
+
onChange(mediaReference(path, editor.entryPath));
|
|
400
|
+
requestAnimationFrame(() => {
|
|
401
|
+
document.getElementById(id)?.focus();
|
|
402
|
+
});
|
|
403
|
+
}}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Suspense, lazy } from 'react';
|
|
2
|
+
import type { WidgetProps } from '../types.ts';
|
|
3
|
+
|
|
4
|
+
// The compact Markdown editor is built on Tiptap, which loads the first time
|
|
5
|
+
// a Markdown field is shown (it shares the chunk with the body editor).
|
|
6
|
+
const MarkdownWidget = lazy(() =>
|
|
7
|
+
import('./markdown.tsx').then((module) => ({ default: module.MarkdownWidget })),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export function LazyMarkdownWidget(props: WidgetProps<string>) {
|
|
11
|
+
return (
|
|
12
|
+
<Suspense
|
|
13
|
+
fallback={
|
|
14
|
+
<p role="status" className="text-small text-content-muted">
|
|
15
|
+
Loading the editor…
|
|
16
|
+
</p>
|
|
17
|
+
}
|
|
18
|
+
>
|
|
19
|
+
<MarkdownWidget {...props} />
|
|
20
|
+
</Suspense>
|
|
21
|
+
);
|
|
22
|
+
}
|