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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-KUkv11EB.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,220 @@
1
+ import { UPLOAD_MAX_BYTES, UPLOAD_WARN_BYTES } from '@vespercms/core';
2
+ import DOMPurify from 'dompurify';
3
+ import {
4
+ detectType,
5
+ imageFacts,
6
+ stripJpeg,
7
+ stripPng,
8
+ stripWebp,
9
+ type DetectedType,
10
+ } from './formats.ts';
11
+
12
+ /*
13
+ * Upload hygiene (spec §6.6.6), in the browser, before anything is saved:
14
+ * - photos larger than `maxEdge` are scaled down;
15
+ * - EXIF orientation is applied to the pixels, then location and other
16
+ * personal metadata are removed (losslessly when the pixels don't change);
17
+ * - HEIC photos become JPEG where the browser can decode them;
18
+ * - SVG is sanitized: no scripts, handlers, javascript: links or foreign HTML;
19
+ * - files over the hard limit are refused, and large ones flagged.
20
+ * PNG, WebP, AVIF, SVG and GIF keep their format.
21
+ */
22
+
23
+ export type PreparedUpload = {
24
+ bytes: Uint8Array;
25
+ name: string;
26
+ type: DetectedType;
27
+ /** What hygiene did, in words, for the editor ("Removed location data"). */
28
+ notes: string[];
29
+ /** Larger than the warning size: the editor confirms before uploading. */
30
+ large: boolean;
31
+ width?: number;
32
+ height?: number;
33
+ };
34
+
35
+ export type PrepareResult = { ok: true; upload: PreparedUpload } | { ok: false; message: string };
36
+
37
+ export type HygieneOptions = {
38
+ maxEdge?: number;
39
+ /** Only images are accepted (an image field), or anything (a file field). */
40
+ accept?: 'image' | 'any';
41
+ };
42
+
43
+ const IMAGE_TYPES = new Set<DetectedType>(['jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'svg']);
44
+
45
+ export async function prepareUpload(
46
+ file: File,
47
+ options: HygieneOptions = {},
48
+ ): Promise<PrepareResult> {
49
+ const maxEdge = options.maxEdge ?? 3000;
50
+ const original = new Uint8Array(await file.arrayBuffer());
51
+ const type = detectType(original);
52
+ if (options.accept === 'image' && !IMAGE_TYPES.has(type)) {
53
+ return {
54
+ ok: false,
55
+ message: `${file.name} isn’t an image this site can use (JPEG, PNG, WebP, AVIF, GIF or SVG).`,
56
+ };
57
+ }
58
+
59
+ const notes: string[] = [];
60
+ let bytes: Uint8Array = original;
61
+ let name = file.name;
62
+ let width: number | undefined;
63
+ let height: number | undefined;
64
+
65
+ if (type === 'svg') {
66
+ const clean = sanitizeSvg(new TextDecoder().decode(original));
67
+ if (clean.removed) notes.push('Removed scripts and unsafe links from the SVG');
68
+ bytes = new TextEncoder().encode(clean.svg);
69
+ } else if (type === 'heic') {
70
+ const converted = await reencode(new Blob([original]), 'image/jpeg', maxEdge);
71
+ if (!converted) {
72
+ return {
73
+ ok: false,
74
+ message:
75
+ 'This browser can’t convert iPhone (HEIC) photos. Export the photo as JPEG first, or upload it from Safari.',
76
+ };
77
+ }
78
+ bytes = converted.bytes;
79
+ ({ width, height } = converted);
80
+ name = name.replace(/\.(heic|heif)$/i, '') + '.jpg';
81
+ notes.push('Converted from HEIC to JPEG', 'Removed location and camera data');
82
+ } else if (type === 'jpeg' || type === 'png' || type === 'webp') {
83
+ const facts = imageFacts(type, original);
84
+ ({ width, height } = facts);
85
+ const sideways = (facts.orientation ?? 1) !== 1;
86
+ const tooBig = Math.max(facts.width ?? 0, facts.height ?? 0) > maxEdge;
87
+ if (sideways || tooBig) {
88
+ // The pixels change, so re-encode (which also drops all metadata).
89
+ const mime = type === 'jpeg' ? 'image/jpeg' : type === 'png' ? 'image/png' : 'image/webp';
90
+ const converted = await reencode(new Blob([original], { type: mime }), mime, maxEdge);
91
+ if (converted) {
92
+ bytes = converted.bytes;
93
+ if (tooBig)
94
+ notes.push(
95
+ `Resized from ${String(facts.width)}×${String(facts.height)} to ${String(converted.width)}×${String(converted.height)}`,
96
+ );
97
+ if (sideways) notes.push('Turned the photo upright');
98
+ ({ width, height } = converted);
99
+ } else {
100
+ bytes = strip(type, original);
101
+ notes.push('Couldn’t resize this image in this browser, so it was kept at full size');
102
+ }
103
+ } else {
104
+ bytes = strip(type, original);
105
+ }
106
+ if (facts.hasLocation) notes.push('Removed location data');
107
+ else if (facts.hasMetadata) notes.push('Removed camera and editing metadata');
108
+ }
109
+
110
+ if (bytes.length > UPLOAD_MAX_BYTES) {
111
+ return {
112
+ ok: false,
113
+ message: `${name} is ${megabytes(bytes.length)} even after processing. The limit is ${megabytes(UPLOAD_MAX_BYTES)}.`,
114
+ };
115
+ }
116
+ return {
117
+ ok: true,
118
+ upload: {
119
+ bytes,
120
+ name,
121
+ type: type === 'heic' ? 'jpeg' : type,
122
+ notes,
123
+ large: bytes.length > UPLOAD_WARN_BYTES,
124
+ ...(width !== undefined ? { width } : {}),
125
+ ...(height !== undefined ? { height } : {}),
126
+ },
127
+ };
128
+ }
129
+
130
+ function strip(type: 'jpeg' | 'png' | 'webp', bytes: Uint8Array): Uint8Array {
131
+ if (type === 'jpeg') return stripJpeg(bytes);
132
+ if (type === 'png') return stripPng(bytes);
133
+ return stripWebp(bytes);
134
+ }
135
+
136
+ export function megabytes(n: number): string {
137
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
138
+ }
139
+
140
+ /** "812 bytes", "48 KB", "3.2 MB". */
141
+ export function fileSize(n: number): string {
142
+ if (n < 1024) return `${String(n)} bytes`;
143
+ if (n < 1024 * 1024) return `${String(Math.round(n / 1024))} KB`;
144
+ return megabytes(n);
145
+ }
146
+
147
+ /**
148
+ * Decodes, applies EXIF orientation, scales to fit `maxEdge` and encodes.
149
+ * Returns null where the browser can't decode the file (HEIC outside
150
+ * Safari) or has no canvas.
151
+ */
152
+ async function reencode(
153
+ blob: Blob,
154
+ mime: string,
155
+ maxEdge: number,
156
+ ): Promise<{ bytes: Uint8Array; width: number; height: number } | null> {
157
+ if (typeof createImageBitmap !== 'function') return null;
158
+ let bitmap: ImageBitmap;
159
+ try {
160
+ bitmap = await createImageBitmap(blob, { imageOrientation: 'from-image' });
161
+ } catch {
162
+ return null;
163
+ }
164
+ const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
165
+ const width = Math.round(bitmap.width * scale);
166
+ const height = Math.round(bitmap.height * scale);
167
+ const canvas = document.createElement('canvas');
168
+ canvas.width = width;
169
+ canvas.height = height;
170
+ const context = canvas.getContext('2d');
171
+ if (!context) return null;
172
+ context.imageSmoothingQuality = 'high';
173
+ context.drawImage(bitmap, 0, 0, width, height);
174
+ bitmap.close();
175
+ const out = await new Promise<Blob | null>((resolve) => {
176
+ canvas.toBlob(resolve, mime, mime === 'image/png' ? undefined : 0.9);
177
+ });
178
+ // A browser that can't write this format hands back PNG; keep the original then.
179
+ if (!out || out.type !== mime) return null;
180
+ return { bytes: new Uint8Array(await out.arrayBuffer()), width, height };
181
+ }
182
+
183
+ /**
184
+ * Removes anything that can run or navigate: scripts, event handlers,
185
+ * javascript: and data: links, embedded HTML, and animations that rewrite
186
+ * links or handlers. Drawing content is kept as it was.
187
+ */
188
+ export function sanitizeSvg(svg: string): { svg: string; removed: boolean } {
189
+ const purify = DOMPurify(window);
190
+ let removed = false;
191
+ purify.addHook('uponSanitizeElement', (node, data) => {
192
+ if (data.tagName === 'set' || /^animate/i.test(data.tagName)) {
193
+ const target = (node as Element).getAttribute('attributeName') ?? '';
194
+ if (/^(on|href$|xlink:href$)/i.test(target)) {
195
+ node.parentNode?.removeChild(node);
196
+ removed = true;
197
+ }
198
+ }
199
+ });
200
+ purify.addHook('uponSanitizeAttribute', (_node, data) => {
201
+ if (
202
+ /^(href|xlink:href)$/i.test(data.attrName) &&
203
+ /^\s*(javascript|data|vbscript):/i.test(data.attrValue)
204
+ ) {
205
+ data.keepAttr = false;
206
+ removed = true;
207
+ }
208
+ });
209
+ const clean = purify.sanitize(svg, {
210
+ USE_PROFILES: { svg: true, svgFilters: true },
211
+ FORBID_TAGS: ['foreignObject', 'script', 'iframe', 'embed', 'object'],
212
+ });
213
+ // DOMPurify always reports the <body> it parsed into; anything else was really removed.
214
+ if (purify.removed.some((r) => !('element' in r) || r.element.nodeName !== 'BODY'))
215
+ removed = true;
216
+ return {
217
+ svg: clean.startsWith('<svg') ? clean : `<svg xmlns="http://www.w3.org/2000/svg"></svg>`,
218
+ removed,
219
+ };
220
+ }
@@ -0,0 +1,47 @@
1
+ import { gitBlobSha, type MediaItem } from '@vespercms/core';
2
+ import type { StudioApi } from '../app/api.ts';
3
+ import { prepareUpload, type HygieneOptions, type PreparedUpload } from './hygiene.ts';
4
+
5
+ /*
6
+ * The upload flow shared by the image field, the file field and the media
7
+ * browser: hygiene, then a duplicate check against the site's files by
8
+ * content hash (spec §6.6.6), then the upload itself.
9
+ */
10
+
11
+ export type UploadStep =
12
+ | { step: 'refused'; message: string }
13
+ /** The same bytes are already in the site: reuse that file, or upload a copy. */
14
+ | { step: 'duplicate'; upload: PreparedUpload; existing: string }
15
+ /** Large after processing: the editor confirms first. */
16
+ | { step: 'confirm-large'; upload: PreparedUpload }
17
+ | { step: 'ready'; upload: PreparedUpload };
18
+
19
+ export async function checkUpload(
20
+ file: File,
21
+ media: readonly MediaItem[],
22
+ options: HygieneOptions,
23
+ ): Promise<UploadStep> {
24
+ const prepared = await prepareUpload(file, options);
25
+ if (!prepared.ok) return { step: 'refused', message: prepared.message };
26
+ // The site's files are keyed by git blob SHA (MediaItem.hash), so the upload is too.
27
+ const hash = await gitBlobSha(prepared.upload.bytes);
28
+ const existing = media.find((item) => item.hash === hash);
29
+ if (existing) return { step: 'duplicate', upload: prepared.upload, existing: existing.path };
30
+ if (prepared.upload.large) return { step: 'confirm-large', upload: prepared.upload };
31
+ return { step: 'ready', upload: prepared.upload };
32
+ }
33
+
34
+ export async function sendUpload(
35
+ api: StudioApi,
36
+ upload: PreparedUpload,
37
+ folder: string,
38
+ options: { withNextSave?: boolean } = {},
39
+ ): Promise<{ ok: true; path: string } | { ok: false; message: string }> {
40
+ const result = await api.upload(
41
+ new Blob([upload.bytes as BlobPart]),
42
+ upload.name,
43
+ folder,
44
+ options,
45
+ );
46
+ return result.ok ? { ok: true, path: result.path } : result;
47
+ }
package/src/mount.tsx ADDED
@@ -0,0 +1,20 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { App } from './app/App.tsx';
4
+ import { applyAppearance, readAppearance } from './app/appearance.ts';
5
+ import type { StudioOptions } from './types.ts';
6
+
7
+ /** Renders the studio into `element`. Returns a function that unmounts it. */
8
+ export function mountStudio(element: Element, options: StudioOptions): () => void {
9
+ // Again, after admin.astro's script: a site whose policy blocks inline scripts still gets the choice.
10
+ applyAppearance(readAppearance());
11
+ const root = createRoot(element);
12
+ root.render(
13
+ <StrictMode>
14
+ <App {...options} />
15
+ </StrictMode>,
16
+ );
17
+ return () => {
18
+ root.unmount();
19
+ };
20
+ }
@@ -0,0 +1,300 @@
1
+ import {
2
+ fromWallTime,
3
+ isRepeatedTime,
4
+ scheduleDay,
5
+ scheduleTime,
6
+ wallTime,
7
+ zoneName,
8
+ type CollectionManifest,
9
+ } from '@vespercms/core';
10
+ import { Button, Dialog, TextField, cx } from '@vespercms/ui';
11
+ import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
12
+ import { useStudio } from '../app/context.tsx';
13
+ import { useDrafts } from '../cloud/editor.tsx';
14
+ import { ChoosePageButton } from '../editor/PagePicker.tsx';
15
+ import { inputClass } from '../widgets/basic.tsx';
16
+ import { cloudKeepsTime } from './cloud.ts';
17
+ import { useNow } from './now.ts';
18
+ import { everyText, redirectField, siteZone, type ScheduleTimes } from './times.ts';
19
+
20
+ /*
21
+ * Schedule… (spec §6.17; DECISIONS.md, "Where scheduling lives in the
22
+ * studio"): when an entry goes live and when it comes down, in the site's
23
+ * time zone, and where visitors go then. One line says how exact the time
24
+ * is, since the site changes only when it's built.
25
+ */
26
+
27
+ export type ScheduleDialogProps = {
28
+ isOpen: boolean;
29
+ onOpenChange: (open: boolean) => void;
30
+ collection: CollectionManifest;
31
+ initial: ScheduleTimes;
32
+ /** Already live: only when it comes down can be set (call 8). */
33
+ live: boolean;
34
+ /**
35
+ * `publish`: Schedule… from Publish ▾, which saves or publishes with the
36
+ * times. `set`: Change schedule… from Details, which only sets them.
37
+ */
38
+ purpose: 'publish' | 'set';
39
+ /** What going live without a time is called here: "Publish now", or "Save now" in local mode. */
40
+ nowLabel: string;
41
+ onSubmit: (times: ScheduleTimes) => void;
42
+ /** Goes live now instead, when the chosen time has passed. */
43
+ onNow?: () => void;
44
+ };
45
+
46
+ export function ScheduleDialog(props: ScheduleDialogProps) {
47
+ return (
48
+ <Dialog
49
+ title={props.purpose === 'publish' ? 'Schedule' : 'Change schedule'}
50
+ isOpen={props.isOpen}
51
+ onOpenChange={props.onOpenChange}
52
+ >
53
+ {(close) => <ScheduleForm {...props} onClose={close} />}
54
+ </Dialog>
55
+ );
56
+ }
57
+
58
+ type Problems = { publishAt?: string; unpublishAt?: string };
59
+
60
+ function ScheduleForm({
61
+ collection,
62
+ initial,
63
+ live,
64
+ purpose,
65
+ nowLabel,
66
+ onSubmit,
67
+ onNow,
68
+ onClose,
69
+ }: ScheduleDialogProps & { onClose: () => void }) {
70
+ const { manifest, account, cloud } = useStudio();
71
+ const cloudSchedule = useDrafts().schedule;
72
+ const { zone, isSite } = siteZone(manifest);
73
+ const schedule = collection.presentation.schedule;
74
+ const redirect = redirectField(collection);
75
+ const [goesLive, setGoesLive] = useState(() =>
76
+ initial.publishAt ? wallTime(initial.publishAt, zone) : '',
77
+ );
78
+ const [comesDown, setComesDown] = useState(() =>
79
+ initial.unpublishAt ? wallTime(initial.unpublishAt, zone) : '',
80
+ );
81
+ const [sendTo, setSendTo] = useState(initial.redirect ?? '');
82
+ const [problems, setProblems] = useState<Problems>({});
83
+ const form = useRef<HTMLFormElement>(null);
84
+ // The first time is what the dialog is for. Before React Aria's dialog
85
+ // effect, which then finds focus inside and leaves it, as the crop dialog does.
86
+ useLayoutEffect(() => {
87
+ form.current?.querySelector<HTMLInputElement>('input[type="datetime-local"]')?.focus();
88
+ }, []);
89
+ const ids = { zone: useId(), live: useId(), down: useId(), accuracy: useId() };
90
+ const now = useNow();
91
+ const publishAt = !live && goesLive ? fromWallTime(goesLive, zone) : null;
92
+ const unpublishAt = schedule?.unpublish && comesDown ? fromWallTime(comesDown, zone) : null;
93
+
94
+ const submit = (event: { preventDefault: () => void }) => {
95
+ event.preventDefault();
96
+ const found: Problems = {};
97
+ if (!live && purpose === 'publish' && !publishAt) found.publishAt = 'Choose when it goes live.';
98
+ else if (publishAt && publishAt.getTime() <= now.getTime())
99
+ found.publishAt = 'That time has passed.';
100
+ if (unpublishAt && unpublishAt.getTime() <= now.getTime())
101
+ found.unpublishAt = 'That time has passed.';
102
+ else if (unpublishAt && publishAt && unpublishAt.getTime() <= publishAt.getTime())
103
+ found.unpublishAt = 'It comes down before it goes live. Choose a later time.';
104
+ setProblems(found);
105
+ if (found.publishAt || found.unpublishAt) return;
106
+ onSubmit({
107
+ ...(live
108
+ ? initial.publishAt
109
+ ? { publishAt: initial.publishAt }
110
+ : {}
111
+ : publishAt
112
+ ? { publishAt }
113
+ : {}),
114
+ ...(unpublishAt ? { unpublishAt } : {}),
115
+ ...(unpublishAt && sendTo.trim() ? { redirect: sendTo.trim() } : {}),
116
+ });
117
+ onClose();
118
+ };
119
+
120
+ // How exact the first time is: the site changes only when it's built,
121
+ // unless the cloud rebuilds at the time or publishes held drafts then.
122
+ const first = publishAt ?? unpublishAt;
123
+ const verb = publishAt ? 'Goes live' : 'Comes down';
124
+ const rebuild = manifest.site.rebuildEvery;
125
+ const accuracy = first
126
+ ? cloudKeepsTime(cloudSchedule, publishAt ? 'publish' : 'unpublish')
127
+ ? `${verb} at ${scheduleTime(first, zone, now)}.`
128
+ : rebuild
129
+ ? `${verb} at the first rebuild after ${scheduleTime(first, zone, now)}. This site rebuilds ${everyText(rebuild)}.`
130
+ : `${verb} the next time the site is built after ${scheduleTime(first, zone, now)}.`
131
+ : null;
132
+ const name = zoneName(zone, first ?? now);
133
+ // Who can read it before then: the cloud says when it holds drafts, GitHub when the repository is public.
134
+ const early = !publishAt
135
+ ? null
136
+ : cloudSchedule?.holdScheduledDrafts
137
+ ? 'Vesper keeps it as a draft until then, and publishes it at that time.'
138
+ : (cloudSchedule?.publicRepository ?? (!cloud && account?.publicRepository))
139
+ ? `This site’s repository is public, so anyone can read this there before it goes live.${cloudSchedule ? ' A site admin can have Vesper keep scheduled changes until their time.' : ''}`
140
+ : null;
141
+
142
+ return (
143
+ <form ref={form} onSubmit={submit} noValidate className="flex flex-col gap-6">
144
+ <p id={ids.zone} className="m-0 text-small text-content-muted">
145
+ {isSite
146
+ ? `Times are in ${name}, the site’s time zone.`
147
+ : `Times are in your time zone, ${name}.`}
148
+ </p>
149
+ {!live && (
150
+ <TimeField
151
+ id={ids.live}
152
+ label={purpose === 'publish' ? 'Goes live' : 'Goes live (optional)'}
153
+ value={goesLive}
154
+ onChange={setGoesLive}
155
+ zone={zone}
156
+ now={now}
157
+ describedBy={`${ids.zone} ${ids.accuracy}`}
158
+ problem={problems.publishAt}
159
+ action={
160
+ problems.publishAt === 'That time has passed.' && onNow ? (
161
+ <Button
162
+ variant="quiet"
163
+ onPress={() => {
164
+ onNow();
165
+ onClose();
166
+ }}
167
+ >
168
+ {`${nowLabel} instead`}
169
+ </Button>
170
+ ) : null
171
+ }
172
+ />
173
+ )}
174
+ {schedule?.unpublish && (
175
+ <TimeField
176
+ id={ids.down}
177
+ label="Comes down (optional)"
178
+ value={comesDown}
179
+ onChange={setComesDown}
180
+ zone={zone}
181
+ now={now}
182
+ describedBy={`${ids.zone} ${ids.accuracy}`}
183
+ problem={problems.unpublishAt}
184
+ />
185
+ )}
186
+ {redirect && schedule?.unpublish && (
187
+ <div className="flex flex-col gap-2">
188
+ <TextField
189
+ label="Then send visitors to (optional)"
190
+ description="A page on this site, or a full address. Without one, the page’s address is simply gone."
191
+ value={sendTo}
192
+ onChange={setSendTo}
193
+ inputClassName="font-mono"
194
+ />
195
+ <ChoosePageButton fieldLabel="Then send visitors to" onPick={setSendTo} />
196
+ </div>
197
+ )}
198
+ {accuracy && (
199
+ <p id={ids.accuracy} className="m-0 text-small">
200
+ {accuracy}
201
+ </p>
202
+ )}
203
+ {early && <p className="m-0 text-small">{early}</p>}
204
+ <div className="flex flex-wrap justify-end gap-3">
205
+ <Button onPress={onClose}>Cancel</Button>
206
+ <Button variant="primary" type="submit">
207
+ {purpose === 'publish' ? 'Schedule' : 'Change schedule'}
208
+ </Button>
209
+ </div>
210
+ </form>
211
+ );
212
+ }
213
+
214
+ /** A date and time in the site's zone, with what a DST change does to it said beside it. */
215
+ function TimeField({
216
+ id,
217
+ label,
218
+ value,
219
+ onChange,
220
+ zone,
221
+ now,
222
+ describedBy,
223
+ problem,
224
+ action,
225
+ }: {
226
+ id: string;
227
+ label: string;
228
+ value: string;
229
+ onChange: (value: string) => void;
230
+ zone: string;
231
+ now: Date;
232
+ describedBy: string;
233
+ problem?: string | undefined;
234
+ action?: ReactNode;
235
+ }) {
236
+ const note = value ? dstNote(value, zone, now) : null;
237
+ const described = [describedBy, note ? `${id}-note` : '', problem ? `${id}-problem` : '']
238
+ .filter(Boolean)
239
+ .join(' ');
240
+ return (
241
+ <div className="flex flex-col gap-2">
242
+ <label htmlFor={id} className="text-label font-semibold text-content">
243
+ {label}
244
+ </label>
245
+ <input
246
+ id={id}
247
+ type="datetime-local"
248
+ value={value}
249
+ onChange={(event) => {
250
+ onChange(event.target.value);
251
+ }}
252
+ aria-describedby={described}
253
+ aria-invalid={problem ? true : undefined}
254
+ className={cx(inputClass, 'max-w-64')}
255
+ />
256
+ {note && (
257
+ <p id={`${id}-note`} className="m-0 text-small text-content-muted">
258
+ {note}
259
+ </p>
260
+ )}
261
+ {problem && (
262
+ <p
263
+ id={`${id}-problem`}
264
+ className="m-0 flex flex-wrap items-center gap-x-3 text-small text-danger"
265
+ >
266
+ {problem}
267
+ {action}
268
+ </p>
269
+ )}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ /** "2:30 AM" from a wall time's own text. */
275
+ function clockOf(wall: string): string {
276
+ const [hours = 0, minutes = 0] = (wall.split('T')[1] ?? '').split(':').map(Number);
277
+ return `${String(hours % 12 || 12)}:${String(minutes).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`;
278
+ }
279
+
280
+ /**
281
+ * What a daylight-saving change does to a wall time: a skipped one moves
282
+ * forward ("2:30 AM doesn't happen on Sun, Mar 14; this is 3:30 AM."), and
283
+ * a repeated one is the first ("1:30 AM happens twice that night; this is
284
+ * the first, EDT.").
285
+ */
286
+ export function dstNote(wall: string, zone: string, now: Date): string | null {
287
+ const instant = fromWallTime(wall, zone);
288
+ if (!instant) return null;
289
+ const actual = wallTime(instant, zone);
290
+ if (actual !== wall.slice(0, 16)) {
291
+ return `${clockOf(wall)} doesn’t happen on ${scheduleDay(instant, zone, now)}; this is ${clockOf(actual)}.`;
292
+ }
293
+ if (isRepeatedTime(instant, zone)) {
294
+ const short = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'short' })
295
+ .formatToParts(instant)
296
+ .find((part) => part.type === 'timeZoneName')?.value;
297
+ return `${clockOf(wall)} happens twice that night; this is the first${short ? `, ${short}` : ''}.`;
298
+ }
299
+ return null;
300
+ }
@@ -0,0 +1,59 @@
1
+ import { scheduleTime, type CollectionManifest, type DataObject } from '@vespercms/core';
2
+ import { Button } from '@vespercms/ui';
3
+ import { useId } from 'react';
4
+ import { useEditor } from '../editor/context.tsx';
5
+ import { fieldId, pathKey } from '../editor/values.ts';
6
+ import { useNow } from './now.ts';
7
+ import { scheduleKeys, timesOf } from './times.ts';
8
+
9
+ /**
10
+ * The schedule in Details (spec §6.17), in place of its raw fields: "Goes
11
+ * live Sat, Oct 31, 8:00 AM · Comes down Sun, Nov 8, 12:00 AM", in the site's
12
+ * zone, and Change schedule… to set the times in the Schedule dialog.
13
+ */
14
+ export function ScheduleSummary({
15
+ collection,
16
+ values,
17
+ zone,
18
+ onChange,
19
+ }: {
20
+ collection: CollectionManifest;
21
+ values: DataObject;
22
+ zone: string;
23
+ onChange: () => void;
24
+ }) {
25
+ const editor = useEditor();
26
+ const id = useId();
27
+ const schedule = collection.presentation.schedule;
28
+ const times = timesOf(collection, values);
29
+ const now = useNow();
30
+ const later = (date: Date) => date.getTime() > now.getTime();
31
+ const parts = [
32
+ times.publishAt
33
+ ? `${later(times.publishAt) ? 'Goes live' : 'Went live'} ${scheduleTime(times.publishAt, zone, now)}`
34
+ : '',
35
+ times.unpublishAt
36
+ ? `${later(times.unpublishAt) ? 'Comes down' : 'Came down'} ${scheduleTime(times.unpublishAt, zone, now)}`
37
+ : '',
38
+ times.unpublishAt && times.redirect ? `then visitors go to ${times.redirect}` : '',
39
+ ].filter(Boolean);
40
+ // A refused save's problems with the times show here, where the times are.
41
+ const problems = scheduleKeys(collection).flatMap(
42
+ (key) => editor.issues.get(pathKey([key])) ?? [],
43
+ );
44
+ return (
45
+ <div role="group" aria-labelledby={id} className="flex flex-col items-start gap-2">
46
+ <p id={id} className="m-0 text-label font-semibold text-content">
47
+ Schedule
48
+ </p>
49
+ <p className="m-0">{parts.length > 0 ? parts.join(' · ') : 'Not scheduled.'}</p>
50
+ {problems.length > 0 && <p className="m-0 text-small text-danger">{problems.join(' ')}</p>}
51
+ {!editor.disabled && schedule && (
52
+ // Its id is the time field's, so the error summary's links land here.
53
+ <Button id={fieldId([schedule.publishAt])} onPress={onChange}>
54
+ Change schedule…
55
+ </Button>
56
+ )}
57
+ </div>
58
+ );
59
+ }