@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,294 @@
1
+ import {
2
+ decodeData,
3
+ encodeValue,
4
+ scheduleTime,
5
+ zoneName,
6
+ type EntryDocument,
7
+ type ScheduledChange,
8
+ } from '@vespercms/core';
9
+ import { Link, Menu, Table, cx, type TableColumn } from '@vespercms/ui';
10
+ import { useState } from 'react';
11
+ import { useStudio } from '../app/context.tsx';
12
+ import { useLoad } from '../app/load.ts';
13
+ import { entriesChanged } from '../app/entries-changed.ts';
14
+ import { hrefFor } from '../app/router.ts';
15
+ import { Screen } from '../app/Screen.tsx';
16
+ import { entryShownTo } from '../cloud/access.ts';
17
+ import { useDrafts } from '../cloud/editor.tsx';
18
+ import { cloudEventOf, cloudStateWords, holdUntil } from './cloud.ts';
19
+ import { useNow } from './now.ts';
20
+ import { ScheduleDialog } from './ScheduleDialog.tsx';
21
+ import { siteZone, timesOf, withTimes, type ScheduleTimes } from './times.ts';
22
+
23
+ /*
24
+ * The Scheduled view (spec §6.17, call 11): every change still to come
25
+ * across the site, soonest first, read from the files in every mode. It's
26
+ * a screen reached from Home's line and from a list's Scheduled filter,
27
+ * never a navigation item. A row's "…" menu reschedules it, and cancels a
28
+ * time to come down. A publish can't be cancelled where there are no
29
+ * drafts to go back to (call 9), so it's rescheduled instead; in the
30
+ * cloud it can, and each change's state shows beside it.
31
+ */
32
+
33
+ const COLUMNS: TableColumn[] = [
34
+ { id: 'at', label: 'When' },
35
+ { id: 'entry', label: 'Entry', isRowHeader: true },
36
+ { id: 'change', label: 'Change' },
37
+ { id: 'collection', label: 'Collection' },
38
+ { id: 'actions', label: 'Actions' },
39
+ ];
40
+ /** Connected to the cloud, where each change stands comes after what it is. */
41
+ const CLOUD_COLUMNS: TableColumn[] = [
42
+ ...COLUMNS.slice(0, 3),
43
+ { id: 'state', label: 'State' },
44
+ ...COLUMNS.slice(3),
45
+ ];
46
+
47
+ type Row = ScheduledChange & { id: string; entryId: string };
48
+
49
+ export function ScheduledView() {
50
+ const { api, manifest, cloud } = useStudio();
51
+ const { schedule: cloudSchedule, drafts } = useDrafts();
52
+ const [load, reload] = useLoad(() => api.listScheduled(), 'scheduled');
53
+ const { zone, isSite } = siteZone(manifest);
54
+ const [editing, setEditing] = useState<{ row: Row; doc: EntryDocument } | null>(null);
55
+ const [outcome, setOutcome] = useState('');
56
+ const now = useNow();
57
+ const rows: Row[] =
58
+ load.state === 'ready'
59
+ ? load.value
60
+ .filter((change) => entryShownTo(cloud, change.path))
61
+ .map((change) => ({
62
+ ...change,
63
+ entryId: change.id,
64
+ id: `${change.collection}/${change.id}/${change.change}`,
65
+ }))
66
+ : [];
67
+ const collectionOf = (name: string) => manifest.collections.find((c) => c.name === name);
68
+ const mayChange = (row: Row) =>
69
+ collectionOf(row.collection)?.editable === true && (cloud?.access(row.path).edit ?? true);
70
+
71
+ /** Saves new times into the entry; in the cloud, publishes the draft where the member may. */
72
+ const apply = async (row: Row, doc: EntryDocument, times: ScheduleTimes, done: string) => {
73
+ const collection = collectionOf(row.collection);
74
+ if (!collection) return;
75
+ setOutcome('');
76
+ const next = withTimes(collection, decodeData(doc.data), times);
77
+ const result = await api.saveEntry({
78
+ collection: row.collection,
79
+ id: row.entryId,
80
+ version: doc.version,
81
+ data: encodeValue(next),
82
+ });
83
+ if (!result.ok) {
84
+ setOutcome(
85
+ `Nothing changed. ${'message' in result ? result.message : 'The entry has problems to fix in its editor first.'}`,
86
+ );
87
+ return;
88
+ }
89
+ let text = done;
90
+ if (cloud) {
91
+ await cloud.drafts.refresh();
92
+ const draft = cloud.drafts.draftOf(result.path);
93
+ const until = holdUntil(cloud.drafts.get().schedule, times.publishAt);
94
+ if (draft && cloud.access(result.path).publish) {
95
+ const sent = until
96
+ ? await cloud.drafts.hold([draft.id], until)
97
+ : await cloud.drafts.publish([draft.id]);
98
+ if (!sent.ok) text = `${done} It’s saved as a draft, not published yet.`;
99
+ } else if (draft) text = `${done} It’s saved as a draft for review.`;
100
+ }
101
+ entriesChanged(row.collection);
102
+ setOutcome(text);
103
+ reload();
104
+ };
105
+
106
+ /** In the cloud, a publish still to come is cancelled: a held draft stops waiting, a published entry goes back to a draft. */
107
+ const cancelPublish = async (row: Row) => {
108
+ if (!cloud) return;
109
+ setOutcome('');
110
+ const held = cloud.drafts.draftOf(row.path)?.held;
111
+ if (held) await cloud.drafts.release(held.event);
112
+ else {
113
+ const result = await cloud.drafts.backToDraft(row.path, {
114
+ summary: `Take “${row.title}” off the schedule in ${collectionOf(row.collection)?.label ?? row.collection}`,
115
+ trailers: { 'Vesper-Collection': row.collection },
116
+ });
117
+ if (!result.ok) {
118
+ setOutcome(`Nothing changed. ${result.message}`);
119
+ return;
120
+ }
121
+ }
122
+ entriesChanged(row.collection);
123
+ setOutcome(`“${row.title}” is no longer scheduled. It’s a draft.`);
124
+ reload();
125
+ };
126
+ /** A publish in the cloud can be cancelled when it's held, or published with no draft over it. */
127
+ const mayCancelPublish = (row: Row) => {
128
+ if (!cloud || row.change !== 'publish' || !cloud.access(row.path).publish) return false;
129
+ const draft = cloud.drafts.draftOf(row.path);
130
+ return !draft || draft.held !== undefined;
131
+ };
132
+
133
+ // Held changes that couldn't be published at their time, in words, for whoever may fix them.
134
+ const failures = (cloudSchedule?.events ?? []).flatMap((event) => {
135
+ if (event.state !== 'failed' || !event.held) return [];
136
+ const draft = drafts.find((d) => event.held?.drafts.includes(d.id));
137
+ if (!draft || !cloud?.draftAccess(draft).edit) return [];
138
+ return [
139
+ {
140
+ id: event.id,
141
+ text: `“${draft.summary}”, scheduled for ${scheduleTime(new Date(event.at), zone, now)}, wasn’t published. ${event.message ?? ''}`,
142
+ },
143
+ ];
144
+ });
145
+
146
+ const open = async (row: Row) => {
147
+ setOutcome('');
148
+ try {
149
+ setEditing({ row, doc: await api.readEntry(row.collection, row.entryId) });
150
+ } catch (error) {
151
+ setOutcome(error instanceof Error ? error.message : String(error));
152
+ }
153
+ };
154
+
155
+ const editingCollection = editing ? collectionOf(editing.row.collection) : undefined;
156
+ const editingTimes =
157
+ editing && editingCollection ? timesOf(editingCollection, decodeData(editing.doc.data)) : {};
158
+
159
+ return (
160
+ <Screen title="Scheduled">
161
+ <p className="mb-6 max-w-2xl text-content-muted">
162
+ Changes still to come across the site, soonest first.{' '}
163
+ {isSite
164
+ ? `Times are in ${zoneName(zone)}, the site’s time zone.`
165
+ : `Times are in your time zone, ${zoneName(zone)}.`}
166
+ </p>
167
+ {load.state === 'failed' && (
168
+ <p role="alert" className="mb-6 text-danger">
169
+ Couldn’t read what’s scheduled: {load.message}
170
+ </p>
171
+ )}
172
+ <p role="status" className={cx('mb-6 max-w-2xl', !outcome && 'sr-only')}>
173
+ {outcome}
174
+ </p>
175
+ {failures.map((failure) => (
176
+ <p key={failure.id} className="mb-6 max-w-2xl text-danger">
177
+ {failure.text}
178
+ </p>
179
+ ))}
180
+ {load.state === 'loading' ? (
181
+ <p className="text-small text-content-muted">Reading the schedule…</p>
182
+ ) : (
183
+ <Table
184
+ label="Scheduled changes"
185
+ columns={cloudSchedule ? CLOUD_COLUMNS : COLUMNS}
186
+ rows={rows}
187
+ emptyState="Nothing is scheduled."
188
+ renderCell={(row, column) => {
189
+ if (column === 'at') return scheduleTime(new Date(row.at), zone, now);
190
+ if (column === 'state' && cloudSchedule) {
191
+ return cloudStateWords(
192
+ cloudSchedule,
193
+ cloudEventOf(cloudSchedule, row),
194
+ new Date(row.at),
195
+ zone,
196
+ );
197
+ }
198
+ if (column === 'entry') {
199
+ return (
200
+ <Link
201
+ href={hrefFor({ screen: 'edit', collection: row.collection, id: row.entryId })}
202
+ >
203
+ {row.title}
204
+ </Link>
205
+ );
206
+ }
207
+ if (column === 'change') return row.change === 'publish' ? 'Goes live' : 'Comes down';
208
+ if (column === 'collection')
209
+ return collectionOf(row.collection)?.label ?? row.collection;
210
+ if (!mayChange(row)) return null;
211
+ return (
212
+ <Menu
213
+ label={`Actions for ${row.title}, ${row.change === 'publish' ? 'going live' : 'coming down'}`}
214
+ items={[
215
+ { id: 'reschedule', label: 'Reschedule…', icon: 'schedule' },
216
+ ...(row.change === 'unpublish'
217
+ ? [{ id: 'cancel', label: 'Cancel', icon: 'unschedule' as const }]
218
+ : []),
219
+ ...(mayCancelPublish(row)
220
+ ? [{ id: 'cancel-publish', label: 'Cancel', icon: 'unschedule' as const }]
221
+ : []),
222
+ ]}
223
+ onAction={(action) => {
224
+ if (action === 'reschedule') void open(row);
225
+ if (action === 'cancel-publish') void cancelPublish(row);
226
+ if (action === 'cancel') {
227
+ void api.readEntry(row.collection, row.entryId).then((doc) => {
228
+ const collection = collectionOf(row.collection);
229
+ if (!collection) return;
230
+ const times = timesOf(collection, decodeData(doc.data));
231
+ return apply(
232
+ row,
233
+ doc,
234
+ { ...(times.publishAt ? { publishAt: times.publishAt } : {}) },
235
+ `“${row.title}” no longer comes down.`,
236
+ );
237
+ });
238
+ }
239
+ }}
240
+ />
241
+ );
242
+ }}
243
+ />
244
+ )}
245
+ {editing && editingCollection && (
246
+ <ScheduleDialog
247
+ isOpen
248
+ onOpenChange={(open) => {
249
+ if (!open) setEditing(null);
250
+ }}
251
+ collection={editingCollection}
252
+ initial={editingTimes}
253
+ live={!(editingTimes.publishAt && editingTimes.publishAt.getTime() > now.getTime())}
254
+ purpose="publish"
255
+ nowLabel={api.publishesOnSave() ? 'Publish now' : 'Make it live now'}
256
+ onSubmit={(times) => {
257
+ const { row, doc } = editing;
258
+ void apply(row, doc, times, `“${row.title}” is rescheduled.`);
259
+ }}
260
+ onNow={() => {
261
+ const { row, doc } = editing;
262
+ const { publishAt: _now, ...rest } = editingTimes;
263
+ void apply(row, doc, rest, `“${row.title}” goes live at the next build.`);
264
+ }}
265
+ />
266
+ )}
267
+ </Screen>
268
+ );
269
+ }
270
+
271
+ /**
272
+ * "3 scheduled changes", linking to the Scheduled view: on Home, and among
273
+ * panes at the top of the collections. Absent when nothing is scheduled.
274
+ */
275
+ export function ScheduledLine({ className }: { className?: string }) {
276
+ const { api, manifest, cloud } = useStudio();
277
+ const scheduled = manifest.collections.some((c) => c.presentation.schedule);
278
+ const [load] = useLoad(
279
+ () => (scheduled ? api.listScheduled() : Promise.resolve([])),
280
+ 'scheduled-line',
281
+ );
282
+ const count =
283
+ load.state === 'ready'
284
+ ? load.value.filter((change) => entryShownTo(cloud, change.path)).length
285
+ : 0;
286
+ if (count === 0) return null;
287
+ return (
288
+ <p className={cx('m-0 text-small', className)}>
289
+ <Link href={hrefFor({ screen: 'scheduled' })}>
290
+ {count === 1 ? '1 scheduled change' : `${String(count)} scheduled changes`}
291
+ </Link>
292
+ </p>
293
+ );
294
+ }
@@ -0,0 +1,66 @@
1
+ import type { CloudScheduledEvent, ScheduleResponse } from '@vespercms/core';
2
+
3
+ /*
4
+ * Scheduling connected to the cloud (spec §6.17): exact times, held drafts
5
+ * and where each change stands. Everything here is absent when the studio
6
+ * isn't connected (spec §6.9), where the files alone say what's scheduled.
7
+ */
8
+
9
+ /** The time of day in the site's zone ("8:00 AM"), for a row whose day is beside it. */
10
+ function clock(at: Date, zone: string): string {
11
+ return new Intl.DateTimeFormat('en-US', {
12
+ timeZone: zone,
13
+ hour: 'numeric',
14
+ minute: '2-digit',
15
+ }).format(at);
16
+ }
17
+
18
+ /** The cloud's own word on a change in the files, matched by file, change and instant. */
19
+ export function cloudEventOf(
20
+ schedule: ScheduleResponse | undefined,
21
+ change: { path: string; change: 'publish' | 'unpublish'; at: string },
22
+ ): CloudScheduledEvent | undefined {
23
+ const at = Date.parse(change.at);
24
+ return schedule?.events.find(
25
+ (event) =>
26
+ event.path === change.path && event.change === change.change && Date.parse(event.at) === at,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Where a change still to come stands, in words: "Waiting in Vesper until
32
+ * 8:00 AM" for held drafts, "Rebuild at 8:00 AM" with a trigger, and "Next
33
+ * build after 8:00 AM" without one.
34
+ */
35
+ export function cloudStateWords(
36
+ schedule: ScheduleResponse,
37
+ event: CloudScheduledEvent | undefined,
38
+ at: Date,
39
+ zone: string,
40
+ ): string {
41
+ if (event?.state === 'held') return `Waiting in Vesper until ${clock(at, zone)}`;
42
+ return schedule.trigger ? `Rebuild at ${clock(at, zone)}` : `Next build after ${clock(at, zone)}`;
43
+ }
44
+
45
+ /**
46
+ * True when the cloud keeps the time exactly: it asks for a rebuild then,
47
+ * or it publishes held drafts then, which starts the host's build. Only the
48
+ * publish waits in the cloud; a time to come down is a plain field.
49
+ */
50
+ export function cloudKeepsTime(
51
+ schedule: ScheduleResponse | undefined,
52
+ change: 'publish' | 'unpublish',
53
+ ): boolean {
54
+ if (!schedule) return false;
55
+ return schedule.trigger !== null || (change === 'publish' && schedule.holdScheduledDrafts);
56
+ }
57
+
58
+ /** When the cloud should hold a draft rather than publish it: the site holds, and its time is still to come. */
59
+ export function holdUntil(
60
+ schedule: ScheduleResponse | undefined,
61
+ publishAt: Date | undefined,
62
+ now: Date = new Date(),
63
+ ): Date | null {
64
+ if (!schedule?.holdScheduledDrafts || !publishAt) return null;
65
+ return publishAt.getTime() > now.getTime() ? publishAt : null;
66
+ }
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * The time, again each minute, so words that depend on it ("Scheduled ·
5
+ * …") change on their own once a time passes, and a render stays pure.
6
+ */
7
+ export function useNow(): Date {
8
+ const [now, setNow] = useState(() => new Date());
9
+ useEffect(() => {
10
+ const timer = setInterval(() => {
11
+ setNow(new Date());
12
+ }, 60_000);
13
+ return () => {
14
+ clearInterval(timer);
15
+ };
16
+ }, []);
17
+ return now;
18
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ isoWithOffset,
3
+ offsetOf,
4
+ publishWindow,
5
+ resolveTimeZone,
6
+ scheduleStatus,
7
+ type CollectionManifest,
8
+ type DataObject,
9
+ type DataValue,
10
+ type Manifest,
11
+ type SchedulePresentation,
12
+ } from '@vespercms/core';
13
+
14
+ /*
15
+ * An entry's schedule in the studio (spec §6.17): when it goes live, when it
16
+ * comes down and where visitors go then, read from and written to the fields
17
+ * its collection's `schedule` names. The words and the times come from core,
18
+ * so the editor, the lists and the Scheduled view say the same thing.
19
+ */
20
+
21
+ /** When an entry goes live and comes down, and where its page sends visitors after. */
22
+ export type ScheduleTimes = { publishAt?: Date; unpublishAt?: Date; redirect?: string };
23
+
24
+ /** The site's zone, for showing and entering times, or the viewer's own when it has none. */
25
+ export function siteZone(manifest: Manifest): { zone: string; isSite: boolean } {
26
+ return resolveTimeZone(manifest.site.timeZone);
27
+ }
28
+
29
+ /** The field that holds where visitors go after, when the collection's schema has one. */
30
+ export function redirectField(collection: CollectionManifest): string | null {
31
+ const schedule = collection.presentation.schedule;
32
+ if (!schedule?.unpublish) return null;
33
+ const name = schedule.unpublishRedirect ?? 'unpublishRedirect';
34
+ return collection.fields?.some((field) => field.path.length === 1 && field.path[0] === name)
35
+ ? name
36
+ : null;
37
+ }
38
+
39
+ /** The schedule's field names, the redirect's when the schema has it. */
40
+ export function scheduleKeys(collection: CollectionManifest): string[] {
41
+ const schedule = collection.presentation.schedule;
42
+ if (!schedule) return [];
43
+ const redirect = redirectField(collection);
44
+ return [
45
+ schedule.publishAt,
46
+ ...(schedule.unpublish ? [schedule.unpublishAt] : []),
47
+ ...(redirect ? [redirect] : []),
48
+ ];
49
+ }
50
+
51
+ export function timesOf(collection: CollectionManifest, values: DataObject): ScheduleTimes {
52
+ const { publishAt, unpublishAt } = publishWindow(collection.presentation.schedule, values);
53
+ const field = redirectField(collection);
54
+ const redirect = field ? values[field] : undefined;
55
+ return {
56
+ ...(publishAt ? { publishAt } : {}),
57
+ ...(unpublishAt ? { unpublishAt } : {}),
58
+ ...(typeof redirect === 'string' && redirect.trim() !== '' ? { redirect } : {}),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The values with these times in place of the old ones: a time left out is
64
+ * removed. A time stored as a quoted string stays a string, with its offset
65
+ * written out, as the date-time field keeps it.
66
+ */
67
+ export function withTimes(
68
+ collection: CollectionManifest,
69
+ values: DataObject,
70
+ times: ScheduleTimes,
71
+ ): DataObject {
72
+ const schedule = collection.presentation.schedule;
73
+ if (!schedule) return values;
74
+ const changes = new Map<string, DataValue | undefined>();
75
+ const put = (key: string, value: DataValue | undefined) => {
76
+ changes.set(key, value);
77
+ };
78
+ const time = (key: string, date: Date | undefined) =>
79
+ date && typeof values[key] === 'string' ? isoWithOffset(date, offsetOf(date) ?? 0) : date;
80
+ put(schedule.publishAt, time(schedule.publishAt, times.publishAt));
81
+ if (schedule.unpublish) put(schedule.unpublishAt, time(schedule.unpublishAt, times.unpublishAt));
82
+ const field = redirectField(collection);
83
+ if (field) put(field, times.redirect?.trim() ? times.redirect.trim() : undefined);
84
+ // A time left out goes; the rest keep their place in the entry's values.
85
+ const next: DataObject = {};
86
+ for (const [key, value] of Object.entries(values)) {
87
+ const changed = changes.has(key) ? changes.get(key) : value;
88
+ if (changed !== undefined) next[key] = changed;
89
+ }
90
+ for (const [key, value] of changes) if (value !== undefined && !(key in next)) next[key] = value;
91
+ return next;
92
+ }
93
+
94
+ /** The entry's schedule as status words, or null when there's nothing to say. */
95
+ export function scheduleWords(
96
+ schedule: SchedulePresentation | undefined,
97
+ data: DataValue,
98
+ zone: string,
99
+ now: Date = new Date(),
100
+ ): string | null {
101
+ return scheduleStatus(schedule, data, now, zone);
102
+ }
103
+
104
+ /** An entry row's schedule (ISO times) as values under the schedule's field names. */
105
+ export function rowData(
106
+ schedule: SchedulePresentation | undefined,
107
+ times: { publishAt?: string; unpublishAt?: string } | undefined,
108
+ ): DataObject {
109
+ if (!schedule || !times) return {};
110
+ return {
111
+ ...(times.publishAt ? { [schedule.publishAt]: times.publishAt } : {}),
112
+ ...(times.unpublishAt ? { [schedule.unpublishAt]: times.unpublishAt } : {}),
113
+ };
114
+ }
115
+
116
+ /** Whether a row has a change still to come: it goes live or comes down later. */
117
+ export function hasUpcoming(
118
+ times: { publishAt?: string; unpublishAt?: string } | undefined,
119
+ now: Date = new Date(),
120
+ ): boolean {
121
+ const later = (iso: string | undefined) =>
122
+ iso !== undefined && new Date(iso).getTime() > now.getTime();
123
+ return later(times?.publishAt) || later(times?.unpublishAt);
124
+ }
125
+
126
+ /** "every hour", "every 15 minutes", "every 2 hours". */
127
+ export function everyText(minutes: number): string {
128
+ if (minutes % 60 === 0) {
129
+ const hours = minutes / 60;
130
+ return hours === 1 ? 'every hour' : `every ${String(hours)} hours`;
131
+ }
132
+ return `every ${String(minutes)} minutes`;
133
+ }