@vespercms/admin 0.0.0-stage → 0.1.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -2
- package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-4fVeej3c.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +35 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +16 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/screens/status.d.ts +36 -0
- package/dist/shortcuts-CarKeDqp.js +4666 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6449 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/blank-date.d.ts +8 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +298 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +303 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +122 -0
- package/src/screens/EntryEditor.tsx +1817 -0
- package/src/screens/EntryList.tsx +272 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +477 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +738 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/screens/status.tsx +104 -0
- package/src/studio.css +168 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +315 -0
- package/src/widgets/blank-date.ts +32 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +290 -0
- package/src/widgets/union.tsx +233 -0
|
@@ -0,0 +1,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
|
+
}
|