@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.
Files changed (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.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-4fVeej3c.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 +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -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/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,474 @@
1
+ import {
2
+ collectionOf,
3
+ defaultEntryId,
4
+ scheduleTime,
5
+ type Draft,
6
+ type Review,
7
+ } from '@vespercms/core';
8
+ import { Button, Checkbox, Dialog, Drawer, Link, TextArea } from '@vespercms/ui';
9
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
10
+ import { useStudio } from '../app/context.tsx';
11
+ import { formatCount, formatRelative } from '../app/format.ts';
12
+ import { hrefFor, navigate, parseRoute } from '../app/router.ts';
13
+ import { siteZone } from '../schedule/times.ts';
14
+ import { DeployLine, useDrafts } from './editor.tsx';
15
+
16
+ /*
17
+ * Changes (DECISIONS.md, "Drafts, reviews and Publish live behind a Changes
18
+ * button"): one quiet button in the top bar, its count in words, opening a
19
+ * drawer with the site's drafts and, for approvers, what waits for their
20
+ * review. "Publish 3 changes" is the drawer's one primary button. Only when
21
+ * the studio is connected to the cloud (spec §6.9).
22
+ */
23
+
24
+ type ChangesState = { open: boolean; setOpen: (open: boolean) => void };
25
+
26
+ const ChangesContext = createContext<ChangesState | null>(null);
27
+
28
+ /**
29
+ * `linked` is the `#/changes` address, where review emails send an approver:
30
+ * it opens the drawer, and closing the drawer goes back to `#/`, so a reload
31
+ * or Back doesn't open it again.
32
+ */
33
+ export function ChangesProvider({ children, linked }: { children: ReactNode; linked: boolean }) {
34
+ const [shown, setShown] = useState(false);
35
+ const open = linked || shown;
36
+ const setOpen = useCallback((next: boolean) => {
37
+ setShown(next);
38
+ if (!next && parseRoute(window.location.hash).screen === 'changes') {
39
+ navigate({ screen: 'home' }, { replace: true });
40
+ }
41
+ }, []);
42
+ const value = useMemo(() => ({ open, setOpen }), [open, setOpen]);
43
+ return (
44
+ <ChangesContext.Provider value={value}>
45
+ {children}
46
+ <ChangesDrawer />
47
+ </ChangesContext.Provider>
48
+ );
49
+ }
50
+
51
+ /** Opens and closes the drawer; null when the studio isn't connected. */
52
+ export function useChanges(): ChangesState | null {
53
+ return useContext(ChangesContext);
54
+ }
55
+
56
+ /** What waits for this member: drafts, and reviews they can answer. */
57
+ function useWaiting() {
58
+ const cloud = useStudio().cloud;
59
+ const { drafts, reviews } = useDrafts();
60
+ const toReview = cloud
61
+ ? reviews.filter(
62
+ (review) =>
63
+ review.requestedBy.id !== cloud.user.id &&
64
+ review.drafts.every((id) => {
65
+ const draft = drafts.find((d) => d.id === id);
66
+ return draft !== undefined && cloud.draftAccess(draft).publish;
67
+ }),
68
+ )
69
+ : [];
70
+ return { drafts, reviews, toReview };
71
+ }
72
+
73
+ /** The top bar's button: "3 changes", and how many wait for this member's review. */
74
+ export function ChangesButton() {
75
+ const changes = useChanges();
76
+ const { drafts, toReview } = useWaiting();
77
+ if (!changes || drafts.length === 0) return null;
78
+ const text = [
79
+ formatCount(drafts.length, 'change', 'changes'),
80
+ toReview.length > 0 ? `${String(toReview.length)} to review` : '',
81
+ ]
82
+ .filter(Boolean)
83
+ .join(', ');
84
+ return (
85
+ <Button
86
+ variant="quiet"
87
+ onPress={() => {
88
+ changes.setOpen(true);
89
+ }}
90
+ >
91
+ {text}
92
+ </Button>
93
+ );
94
+ }
95
+
96
+ /** How many drafts wait to be published: none when the studio isn't connected. */
97
+ export function useDraftsWaiting(): number {
98
+ const changes = useChanges();
99
+ const { drafts } = useDrafts();
100
+ return changes ? drafts.length : 0;
101
+ }
102
+
103
+ /**
104
+ * The line about drafts waiting to be published, which opens Changes: on
105
+ * Home, or among panes beside the collections.
106
+ */
107
+ export function DraftsWaiting() {
108
+ const changes = useChanges();
109
+ const count = useDraftsWaiting();
110
+ if (!changes || count === 0) return null;
111
+ return (
112
+ <p className="m-0 flex flex-wrap items-center gap-x-3 gap-y-1">
113
+ <span>
114
+ {count === 1
115
+ ? '1 draft is waiting to be published.'
116
+ : `${String(count)} drafts are waiting to be published.`}
117
+ </span>
118
+ <Button
119
+ variant="quiet"
120
+ onPress={() => {
121
+ changes.setOpen(true);
122
+ }}
123
+ >
124
+ See changes
125
+ </Button>
126
+ </p>
127
+ );
128
+ }
129
+
130
+ /** The entry a draft's file belongs to, for "Open". */
131
+ function entryOf(
132
+ manifest: Parameters<typeof collectionOf>[0],
133
+ path: string,
134
+ ): { collection: string; id: string } | null {
135
+ const owner = collectionOf(manifest, path);
136
+ if (!owner?.relative) return null;
137
+ return { collection: owner.collection.name, id: defaultEntryId(owner.relative) };
138
+ }
139
+
140
+ type Outcome =
141
+ | { kind: 'published'; commit: string; count: number }
142
+ | { kind: 'requested'; count: number }
143
+ | { kind: 'refused'; text: string }
144
+ | null;
145
+
146
+ function ChangesDrawer() {
147
+ const { cloud, manifest } = useStudio();
148
+ const changes = useChanges();
149
+ const { drafts, reviews, toReview } = useWaiting();
150
+ const [chosen, setChosen] = useState<ReadonlySet<string> | null>(null);
151
+ const [outcome, setOutcome] = useState<Outcome>(null);
152
+ const [busy, setBusy] = useState(false);
153
+ const [discarding, setDiscarding] = useState<Draft | null>(null);
154
+ if (!cloud || !changes) return null;
155
+
156
+ const publishes = cloud.member.role === 'admin' || cloud.member.role === 'editor';
157
+ // A held draft waits for its time (spec §6.17); it's cancelled or rescheduled from its entry.
158
+ const actionable = drafts.filter((d) => {
159
+ const access = cloud.draftAccess(d);
160
+ return !d.held && (publishes ? access.publish : access.edit && !d.review);
161
+ });
162
+ const zone = siteZone(manifest).zone;
163
+ // Everything actionable starts chosen, until the member changes that.
164
+ const selected = chosen ?? new Set(actionable.map((d) => d.id));
165
+ const picked = actionable.filter((d) => selected.has(d.id));
166
+
167
+ const act = async () => {
168
+ if (picked.length === 0) return;
169
+ setBusy(true);
170
+ setOutcome(null);
171
+ try {
172
+ if (publishes) {
173
+ const result = await cloud.drafts.publish(picked.map((d) => d.id));
174
+ if (result.ok)
175
+ setOutcome({ kind: 'published', commit: result.commit, count: picked.length });
176
+ else if (result.reason === 'conflict') {
177
+ setOutcome({
178
+ kind: 'refused',
179
+ text: `Someone published ${result.conflicts.map((c) => c.path).join(', ')} since its draft began. Open it to compare the two.`,
180
+ });
181
+ } else setOutcome({ kind: 'refused', text: result.message });
182
+ } else {
183
+ await cloud.drafts.requestReview(
184
+ picked.map((d) => d.id),
185
+ '',
186
+ );
187
+ setOutcome({ kind: 'requested', count: picked.length });
188
+ }
189
+ setChosen(null);
190
+ } catch (error) {
191
+ setOutcome({ kind: 'refused', text: error instanceof Error ? error.message : String(error) });
192
+ } finally {
193
+ setBusy(false);
194
+ }
195
+ };
196
+
197
+ return (
198
+ <Drawer
199
+ title="Changes"
200
+ isOpen={changes.open}
201
+ onOpenChange={(open) => {
202
+ changes.setOpen(open);
203
+ if (!open) setOutcome(null);
204
+ }}
205
+ >
206
+ <div className="flex flex-col gap-8">
207
+ {outcome?.kind === 'published' && (
208
+ <div className="flex flex-col gap-1">
209
+ <p className="m-0 font-semibold">
210
+ {outcome.count === 1
211
+ ? 'Published 1 change.'
212
+ : `Published ${String(outcome.count)} changes.`}
213
+ </p>
214
+ <DeployLine commit={outcome.commit} />
215
+ </div>
216
+ )}
217
+ {outcome?.kind === 'requested' && (
218
+ <p className="m-0">
219
+ Sent for review. An approver will publish {outcome.count === 1 ? 'it' : 'them'} or ask
220
+ for changes.
221
+ </p>
222
+ )}
223
+ {outcome?.kind === 'refused' && (
224
+ <p role="alert" className="m-0 rounded-md border border-danger bg-surface-raised p-4">
225
+ Nothing was published. {outcome.text}
226
+ </p>
227
+ )}
228
+
229
+ {toReview.length > 0 && (
230
+ <section aria-labelledby="changes-review" className="flex flex-col gap-4">
231
+ <h3 id="changes-review" className="m-0 text-heading-3 text-content">
232
+ Waiting for your review
233
+ </h3>
234
+ {toReview.map((review) => (
235
+ <ReviewItem key={review.id} review={review} drafts={drafts} onOutcome={setOutcome} />
236
+ ))}
237
+ </section>
238
+ )}
239
+
240
+ <section aria-labelledby="changes-drafts" className="flex flex-col gap-4">
241
+ <h3 id="changes-drafts" className="m-0 text-heading-3 text-content">
242
+ Drafts
243
+ </h3>
244
+ {drafts.length === 0 ? (
245
+ <p className="m-0 text-content-muted">Nothing is waiting to be published.</p>
246
+ ) : (
247
+ <ul className="m-0 flex list-none flex-col gap-4 p-0">
248
+ {drafts.map((draft) => {
249
+ const open = draft.files.map((f) => entryOf(manifest, f.path)).find(Boolean);
250
+ const review = reviews.find((r) => r.id === draft.review);
251
+ const canAct = actionable.includes(draft);
252
+ const meta = [
253
+ `${draft.updatedBy.name}, ${formatRelative(draft.updatedAt)}`,
254
+ draft.files.length > 1 ? formatCount(draft.files.length, 'file', 'files') : '',
255
+ review
256
+ ? review.state === 'open'
257
+ ? 'waiting for review'
258
+ : 'changes requested'
259
+ : '',
260
+ draft.held
261
+ ? `scheduled for ${scheduleTime(new Date(draft.held.at), zone, new Date())}`
262
+ : '',
263
+ ]
264
+ .filter(Boolean)
265
+ .join(' · ');
266
+ return (
267
+ <li key={draft.id} className="flex flex-col gap-1">
268
+ {canAct ? (
269
+ <Checkbox
270
+ isSelected={selected.has(draft.id)}
271
+ onChange={(on) => {
272
+ const next = new Set(selected);
273
+ if (on) next.add(draft.id);
274
+ else next.delete(draft.id);
275
+ setChosen(next);
276
+ }}
277
+ >
278
+ {draft.summary}
279
+ </Checkbox>
280
+ ) : (
281
+ <span>{draft.summary}</span>
282
+ )}
283
+ <span className="text-small text-content-muted">{meta}</span>
284
+ <span className="flex flex-wrap gap-3">
285
+ {open && (
286
+ <Link
287
+ href={hrefFor({
288
+ screen: 'edit',
289
+ collection: open.collection,
290
+ id: open.id,
291
+ })}
292
+ onPress={() => {
293
+ changes.setOpen(false);
294
+ }}
295
+ >
296
+ Open
297
+ </Link>
298
+ )}
299
+ {cloud.draftAccess(draft).edit && (
300
+ <Button
301
+ variant="quiet"
302
+ onPress={() => {
303
+ setDiscarding(draft);
304
+ }}
305
+ >
306
+ Discard…
307
+ </Button>
308
+ )}
309
+ </span>
310
+ </li>
311
+ );
312
+ })}
313
+ </ul>
314
+ )}
315
+ </section>
316
+
317
+ {actionable.length > 0 && (
318
+ <div>
319
+ <Button
320
+ variant="primary"
321
+ isPending={busy}
322
+ onPress={() => void act()}
323
+ isDisabled={picked.length === 0}
324
+ >
325
+ {publishes
326
+ ? `Publish ${formatCount(picked.length, 'change', 'changes')}`
327
+ : `Request review for ${formatCount(picked.length, 'change', 'changes')}`}
328
+ </Button>
329
+ </div>
330
+ )}
331
+ </div>
332
+ <Dialog
333
+ title={`Discard “${discarding?.summary ?? ''}”?`}
334
+ role="alertdialog"
335
+ isOpen={discarding !== null}
336
+ onOpenChange={(open) => {
337
+ if (!open) setDiscarding(null);
338
+ }}
339
+ >
340
+ <p className="mb-6">The changes that haven’t been published are thrown away.</p>
341
+ <div className="flex flex-wrap justify-end gap-3">
342
+ <Button
343
+ onPress={() => {
344
+ setDiscarding(null);
345
+ }}
346
+ >
347
+ Keep the draft
348
+ </Button>
349
+ <Button
350
+ variant="danger"
351
+ onPress={() => {
352
+ const draft = discarding;
353
+ setDiscarding(null);
354
+ if (draft) void cloud.drafts.discard(draft.id);
355
+ }}
356
+ >
357
+ Discard draft
358
+ </Button>
359
+ </div>
360
+ </Dialog>
361
+ </Drawer>
362
+ );
363
+ }
364
+
365
+ /** One review waiting for this approver: who asked, what they said, and the answers. */
366
+ function ReviewItem({
367
+ review,
368
+ drafts,
369
+ onOutcome,
370
+ }: {
371
+ review: Review;
372
+ drafts: readonly Draft[];
373
+ /** Published, the review closes, so the drawer says what happened. */
374
+ onOutcome: (outcome: Outcome) => void;
375
+ }) {
376
+ const cloud = useStudio().cloud;
377
+ const [asking, setAsking] = useState(false);
378
+ const [text, setText] = useState('');
379
+ const [busy, setBusy] = useState(false);
380
+ const [outcome, setOutcome] = useState<string | null>(null);
381
+ if (!cloud) return null;
382
+ const titled = `${review.requestedBy.name} asked ${formatRelative(review.requestedAt)}`;
383
+ const summaries = review.drafts
384
+ .map((id) => drafts.find((d) => d.id === id)?.summary)
385
+ .filter((s): s is string => Boolean(s));
386
+ const publish = async () => {
387
+ setBusy(true);
388
+ try {
389
+ const result = await cloud.drafts.publish(review.drafts);
390
+ if (result.ok)
391
+ onOutcome({ kind: 'published', commit: result.commit, count: review.drafts.length });
392
+ else if (result.reason === 'conflict') {
393
+ onOutcome({
394
+ kind: 'refused',
395
+ text: `Someone published ${result.conflicts.map((c) => c.path).join(', ')} since its draft began. Open it to compare the two.`,
396
+ });
397
+ } else onOutcome({ kind: 'refused', text: result.message });
398
+ } catch (error) {
399
+ onOutcome({ kind: 'refused', text: error instanceof Error ? error.message : String(error) });
400
+ } finally {
401
+ setBusy(false);
402
+ }
403
+ };
404
+ const askForChanges = async () => {
405
+ setBusy(true);
406
+ try {
407
+ await cloud.drafts.answer(review.id, 'request-changes', text.trim());
408
+ setOutcome(`Sent to ${review.requestedBy.name}.`);
409
+ setAsking(false);
410
+ setText('');
411
+ } finally {
412
+ setBusy(false);
413
+ }
414
+ };
415
+ return (
416
+ <article
417
+ aria-label={titled}
418
+ className="flex flex-col gap-2 rounded-md border border-border p-4"
419
+ >
420
+ <p className="m-0 font-semibold">{titled}</p>
421
+ <ul className="m-0 pl-6">
422
+ {summaries.map((summary) => (
423
+ <li key={summary}>{summary}</li>
424
+ ))}
425
+ </ul>
426
+ {review.comments.map((comment) => (
427
+ <p key={`${comment.at}-${comment.by.id}`} className="m-0 text-small">
428
+ <span className="font-semibold">{comment.by.name}:</span> {comment.body}
429
+ </p>
430
+ ))}
431
+ {outcome && (
432
+ <p role="status" className="m-0 text-small text-content-muted">
433
+ {outcome}
434
+ </p>
435
+ )}
436
+ {asking ? (
437
+ <div className="flex flex-col gap-2">
438
+ <TextArea label="What should change?" value={text} onChange={setText} />
439
+ <div className="flex flex-wrap gap-3">
440
+ <Button
441
+ isPending={busy}
442
+ isDisabled={text.trim() === ''}
443
+ onPress={() => void askForChanges()}
444
+ >
445
+ Send
446
+ </Button>
447
+ <Button
448
+ variant="quiet"
449
+ onPress={() => {
450
+ setAsking(false);
451
+ }}
452
+ >
453
+ Cancel
454
+ </Button>
455
+ </div>
456
+ </div>
457
+ ) : (
458
+ <div className="flex flex-wrap gap-3">
459
+ <Button isPending={busy} onPress={() => void publish()}>
460
+ Publish
461
+ </Button>
462
+ <Button
463
+ variant="quiet"
464
+ onPress={() => {
465
+ setAsking(true);
466
+ }}
467
+ >
468
+ Ask for changes
469
+ </Button>
470
+ </div>
471
+ )}
472
+ </article>
473
+ );
474
+ }