@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,281 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloudError,
|
|
3
|
+
changeAccess,
|
|
4
|
+
editsAnything,
|
|
5
|
+
encodeChange,
|
|
6
|
+
fileAccess,
|
|
7
|
+
fromBase64,
|
|
8
|
+
wholeSite,
|
|
9
|
+
type CloudClient,
|
|
10
|
+
type CloudUser,
|
|
11
|
+
type CommitTrailers,
|
|
12
|
+
type DeployStatus,
|
|
13
|
+
type Draft,
|
|
14
|
+
type HoldResponse,
|
|
15
|
+
type Manifest,
|
|
16
|
+
type Member,
|
|
17
|
+
type PublishResponse,
|
|
18
|
+
type Review,
|
|
19
|
+
type ScheduleResponse,
|
|
20
|
+
} from '@vespercms/core';
|
|
21
|
+
import { ApiError, type StudioApi } from '../app/api.ts';
|
|
22
|
+
import { createBrowserApi } from '../app/browser-api.ts';
|
|
23
|
+
import { keyValueStore } from '../app/idb.ts';
|
|
24
|
+
import type { CloudSettings } from './session.ts';
|
|
25
|
+
import { CloudStorage } from './storage.ts';
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* The studio connected to the cloud (spec §6.9): its back end, the drafts
|
|
29
|
+
* and reviews screens subscribe to, and what the signed-in member may do.
|
|
30
|
+
* Screens find it on the studio as `cloud`; when it's absent, every cloud
|
|
31
|
+
* feature is absent too, never disabled (spec §6.9).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type CloudConnection = {
|
|
35
|
+
user: CloudUser;
|
|
36
|
+
member: Member;
|
|
37
|
+
siteName: string;
|
|
38
|
+
dashboardUrl: string;
|
|
39
|
+
client: CloudClient;
|
|
40
|
+
storage: CloudStorage;
|
|
41
|
+
drafts: DraftsStore;
|
|
42
|
+
/** What the member may do to a file. */
|
|
43
|
+
access: (path: string) => { edit: boolean; publish: boolean };
|
|
44
|
+
/** What the member may do with a whole draft: the API refuses the same (spec §7.5). */
|
|
45
|
+
draftAccess: (draft: Draft) => { edit: boolean; publish: boolean };
|
|
46
|
+
/** True when the member's grants cover the whole site (the redirects, settings outside collections). */
|
|
47
|
+
wholeSite: boolean;
|
|
48
|
+
/** True when the member may change anything at all. */
|
|
49
|
+
editsAnything: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type DraftsSnapshot = {
|
|
53
|
+
drafts: readonly Draft[];
|
|
54
|
+
reviews: readonly Review[];
|
|
55
|
+
version: string;
|
|
56
|
+
/**
|
|
57
|
+
* How the site publishes on schedule, and where each change stands (spec
|
|
58
|
+
* §6.17). Absent on a site with no scheduled collection, or until read.
|
|
59
|
+
*/
|
|
60
|
+
schedule?: ScheduleResponse;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Drafts and reviews as screens see them, kept current after every change. */
|
|
64
|
+
export class DraftsStore {
|
|
65
|
+
private snapshot: DraftsSnapshot = { drafts: [], reviews: [], version: '' };
|
|
66
|
+
private readonly listeners = new Set<() => void>();
|
|
67
|
+
private readonly client: CloudClient;
|
|
68
|
+
private readonly storage: CloudStorage;
|
|
69
|
+
private readonly scheduled: boolean;
|
|
70
|
+
|
|
71
|
+
/** `scheduled`: the site has a scheduled collection, so the schedule is read with the drafts. */
|
|
72
|
+
constructor(client: CloudClient, storage: CloudStorage, scheduled = false) {
|
|
73
|
+
this.client = client;
|
|
74
|
+
this.storage = storage;
|
|
75
|
+
this.scheduled = scheduled;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
79
|
+
this.listeners.add(listener);
|
|
80
|
+
return () => this.listeners.delete(listener);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
get = (): DraftsSnapshot => this.snapshot;
|
|
84
|
+
|
|
85
|
+
/** Called by the storage whenever the drafts change. */
|
|
86
|
+
setDrafts(drafts: readonly Draft[], version: string): void {
|
|
87
|
+
this.snapshot = { ...this.snapshot, drafts, version };
|
|
88
|
+
this.emit();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private emit(): void {
|
|
92
|
+
for (const listener of this.listeners) listener();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Asks the cloud for the drafts and reviews again, and the schedule where the site has one. */
|
|
96
|
+
async refresh(): Promise<void> {
|
|
97
|
+
this.storage.invalidate();
|
|
98
|
+
const [{ drafts, version }, { reviews }, schedule] = await Promise.all([
|
|
99
|
+
this.storage.drafts(),
|
|
100
|
+
this.client.reviews(),
|
|
101
|
+
// The schedule only adds words; without it, scheduling works as in GitHub mode.
|
|
102
|
+
this.scheduled
|
|
103
|
+
? this.client.schedule().catch(() => this.snapshot.schedule)
|
|
104
|
+
: Promise.resolve(undefined),
|
|
105
|
+
]);
|
|
106
|
+
this.snapshot = { drafts, reviews, version, ...(schedule ? { schedule } : {}) };
|
|
107
|
+
this.emit();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The draft holding a file, if one does. */
|
|
111
|
+
draftOf(path: string): Draft | undefined {
|
|
112
|
+
return this.snapshot.drafts.find((d) => d.files.some((f) => f.path === path));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async publish(ids: readonly string[]): Promise<PublishResponse> {
|
|
116
|
+
const result = await this.client.publish(ids);
|
|
117
|
+
await this.refresh();
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async discard(id: string): Promise<void> {
|
|
122
|
+
await this.client.discardDraft(id);
|
|
123
|
+
await this.refresh();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async keepMine(id: string, paths: readonly string[]): Promise<void> {
|
|
127
|
+
await this.client.rebaseDraft(id, paths);
|
|
128
|
+
await this.refresh();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async requestReview(ids: readonly string[], note: string): Promise<Review> {
|
|
132
|
+
const review = await this.client.requestReview({ drafts: [...ids], ...(note ? { note } : {}) });
|
|
133
|
+
await this.refresh();
|
|
134
|
+
return review;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async answer(id: string, action: 'comment' | 'request-changes', body: string): Promise<Review> {
|
|
138
|
+
const review = await this.client.answerReview(id, { action, body });
|
|
139
|
+
await this.refresh();
|
|
140
|
+
return review;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
status(commit: string): Promise<DeployStatus> {
|
|
144
|
+
return this.client.status(commit);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Holds drafts in the cloud until `at`, to be published then (`holdScheduledDrafts`). */
|
|
148
|
+
async hold(ids: readonly string[], at: Date): Promise<HoldResponse> {
|
|
149
|
+
const result = await this.client.hold(ids, at.toISOString());
|
|
150
|
+
await this.refresh();
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A held draft stops waiting, and stays a draft. */
|
|
155
|
+
async release(event: string): Promise<void> {
|
|
156
|
+
await this.client.release(event);
|
|
157
|
+
await this.refresh();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Cancels a published entry's time to go live (call 9): one publish takes
|
|
162
|
+
* its file out of the site, and its content comes back as a draft. The
|
|
163
|
+
* file isn't live yet, so visitors see no change.
|
|
164
|
+
*/
|
|
165
|
+
async backToDraft(
|
|
166
|
+
path: string,
|
|
167
|
+
message: { summary: string; trailers?: CommitTrailers },
|
|
168
|
+
): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
169
|
+
const words = (result: { ok: false; reason: string; message?: string }) =>
|
|
170
|
+
result.message ?? 'Someone changed it since you opened it. Reload to see the new version.';
|
|
171
|
+
const version = (await this.client.tree()).entries.find((e) => e.path === path)?.version;
|
|
172
|
+
if (!version)
|
|
173
|
+
return { ok: false, message: 'It isn’t published, so there’s nothing to cancel.' };
|
|
174
|
+
const text = (await this.client.blobs([version])).blobs[version];
|
|
175
|
+
if (text === undefined) return { ok: false, message: 'Its text couldn’t be read. Try again.' };
|
|
176
|
+
const removal = await this.client.saveDraft({
|
|
177
|
+
changes: [{ op: 'delete', path, expectedVersion: version }],
|
|
178
|
+
message,
|
|
179
|
+
});
|
|
180
|
+
if (!removal.ok) {
|
|
181
|
+
await this.refresh();
|
|
182
|
+
return { ok: false, message: words(removal) };
|
|
183
|
+
}
|
|
184
|
+
const published = removal.draft ? await this.client.publish([removal.draft.id]) : null;
|
|
185
|
+
if (!published?.ok) {
|
|
186
|
+
if (removal.draft) await this.client.discardDraft(removal.draft.id);
|
|
187
|
+
await this.refresh();
|
|
188
|
+
return { ok: false, message: published ? words(published) : 'Nothing changed.' };
|
|
189
|
+
}
|
|
190
|
+
const kept = await this.client.saveDraft({
|
|
191
|
+
changes: [encodeChange({ op: 'put', path, bytes: fromBase64(text), expectedVersion: null })],
|
|
192
|
+
message,
|
|
193
|
+
});
|
|
194
|
+
await this.refresh();
|
|
195
|
+
return kept.ok
|
|
196
|
+
? { ok: true }
|
|
197
|
+
: {
|
|
198
|
+
ok: false,
|
|
199
|
+
message: `It’s off the site, but its draft couldn’t be saved: ${words(kept)} It’s in the site’s history.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The cloud's errors in the studio's terms: plain words, and a status the screens understand. */
|
|
205
|
+
export function cloudApiError(error: unknown, onExpired?: () => void): unknown {
|
|
206
|
+
if (error instanceof CloudError) {
|
|
207
|
+
if (error.status === 401) {
|
|
208
|
+
onExpired?.();
|
|
209
|
+
return new ApiError(401, 'Your Vesper sign-in has ended. Sign in again to keep working.');
|
|
210
|
+
}
|
|
211
|
+
return new ApiError(error.status, error.message);
|
|
212
|
+
}
|
|
213
|
+
if (error instanceof TypeError) {
|
|
214
|
+
return new ApiError(0, 'Vesper can’t be reached. Your changes are kept on this device.');
|
|
215
|
+
}
|
|
216
|
+
return error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type ConnectOptions = {
|
|
220
|
+
settings: CloudSettings;
|
|
221
|
+
client: CloudClient;
|
|
222
|
+
user: CloudUser;
|
|
223
|
+
member: Member;
|
|
224
|
+
siteName: string;
|
|
225
|
+
manifest: Manifest;
|
|
226
|
+
loadMdxProps: () => Promise<Readonly<Record<string, unknown>>>;
|
|
227
|
+
loadUrlFunctions?: Parameters<typeof createBrowserApi>[0]['loadUrlFunctions'];
|
|
228
|
+
routes?: readonly (readonly [string, string])[];
|
|
229
|
+
onExpired?: () => void;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** The studio's back end over the cloud, and the connection screens use. */
|
|
233
|
+
export function connect(options: ConnectOptions): { api: StudioApi; connection: CloudConnection } {
|
|
234
|
+
let store: DraftsStore | null = null;
|
|
235
|
+
const storage = new CloudStorage({
|
|
236
|
+
client: options.client,
|
|
237
|
+
cache: keyValueStore<Uint8Array>('vesper-cloud-blobs', 'blobs'),
|
|
238
|
+
onDrafts: (drafts, version) => store?.setDrafts(drafts, version),
|
|
239
|
+
});
|
|
240
|
+
store = new DraftsStore(
|
|
241
|
+
options.client,
|
|
242
|
+
storage,
|
|
243
|
+
options.manifest.collections.some((c) => c.presentation.schedule),
|
|
244
|
+
);
|
|
245
|
+
const api = createBrowserApi({
|
|
246
|
+
storage,
|
|
247
|
+
manifest: options.manifest,
|
|
248
|
+
schemas: options.settings.schemas,
|
|
249
|
+
loadMdxProps: options.loadMdxProps,
|
|
250
|
+
...(options.loadUrlFunctions ? { loadUrlFunctions: options.loadUrlFunctions } : {}),
|
|
251
|
+
...(options.routes ? { routes: options.routes } : {}),
|
|
252
|
+
publishesOnSave: false,
|
|
253
|
+
mapError: (error) => cloudApiError(error, options.onExpired),
|
|
254
|
+
});
|
|
255
|
+
const member = options.member;
|
|
256
|
+
const connection: CloudConnection = {
|
|
257
|
+
user: options.user,
|
|
258
|
+
member,
|
|
259
|
+
siteName: options.siteName,
|
|
260
|
+
dashboardUrl: `${options.settings.app.replace(/\/+$/, '')}/sites/${encodeURIComponent(options.settings.project)}`,
|
|
261
|
+
client: options.client,
|
|
262
|
+
storage,
|
|
263
|
+
drafts: store,
|
|
264
|
+
access: (path) => fileAccess(member, options.manifest, path),
|
|
265
|
+
draftAccess: (draft) => {
|
|
266
|
+
const result = changeAccess(
|
|
267
|
+
member,
|
|
268
|
+
options.manifest,
|
|
269
|
+
draft.files.map((file) =>
|
|
270
|
+
file.op === 'delete'
|
|
271
|
+
? { op: 'delete', path: file.path, expectedVersion: file.base }
|
|
272
|
+
: { op: 'put', path: file.path, bytes: new Uint8Array(), expectedVersion: file.base },
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
return result.ok ? { edit: true, publish: result.publish } : { edit: false, publish: false };
|
|
276
|
+
},
|
|
277
|
+
wholeSite: wholeSite(member),
|
|
278
|
+
editsAnything: editsAnything(member),
|
|
279
|
+
};
|
|
280
|
+
return { api, connection };
|
|
281
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeUtf8,
|
|
3
|
+
fromBase64,
|
|
4
|
+
type DeployStatus,
|
|
5
|
+
type Draft,
|
|
6
|
+
type Review,
|
|
7
|
+
} from '@vespercms/core';
|
|
8
|
+
import { Button, Dialog, Link, TextArea } from '@vespercms/ui';
|
|
9
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
10
|
+
import { useStudio } from '../app/context.tsx';
|
|
11
|
+
import { keyValueStore } from '../app/idb.ts';
|
|
12
|
+
import type { CloudConnection, DraftsSnapshot } from './connection.ts';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The cloud's parts of the entry editor (spec §7.4): the entry's draft and
|
|
16
|
+
* its review, deploy status after a publish, the side-by-side compare for a
|
|
17
|
+
* conflict, and the review request. The editor itself stays one screen for
|
|
18
|
+
* every mode; these appear only when the studio is connected (spec §6.9).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const EMPTY: DraftsSnapshot = { drafts: [], reviews: [], version: '' };
|
|
22
|
+
const noSubscribe = () => () => undefined;
|
|
23
|
+
|
|
24
|
+
/** The drafts and reviews, current after every change. Empty when not connected. */
|
|
25
|
+
export function useDrafts(): DraftsSnapshot {
|
|
26
|
+
const cloud = useStudio().cloud;
|
|
27
|
+
return useSyncExternalStore(
|
|
28
|
+
cloud ? cloud.drafts.subscribe : noSubscribe,
|
|
29
|
+
cloud ? cloud.drafts.get : () => EMPTY,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The draft holding a file, and the review that draft waits in. */
|
|
34
|
+
export function useEntryDraft(path: string): {
|
|
35
|
+
draft: Draft | undefined;
|
|
36
|
+
review: Review | undefined;
|
|
37
|
+
} {
|
|
38
|
+
const { drafts, reviews } = useDrafts();
|
|
39
|
+
const draft = drafts.find((d) => d.files.some((f) => f.path === path));
|
|
40
|
+
const review = draft?.review ? reviews.find((r) => r.id === draft.review) : undefined;
|
|
41
|
+
return { draft, review };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the host reported for a published commit, asked again until the build is done. */
|
|
45
|
+
export function useDeployStatus(commit: string | null): DeployStatus | null {
|
|
46
|
+
const cloud = useStudio().cloud;
|
|
47
|
+
const [status, setStatus] = useState<DeployStatus | null>(null);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!cloud || !commit) return;
|
|
50
|
+
let live = true;
|
|
51
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
52
|
+
let tries = 0;
|
|
53
|
+
const ask = async () => {
|
|
54
|
+
const next = await cloud.drafts.status(commit).catch(() => null);
|
|
55
|
+
if (!live) return;
|
|
56
|
+
if (next) setStatus(next);
|
|
57
|
+
// A build takes minutes; asking every 10 seconds for 15 minutes covers most hosts.
|
|
58
|
+
if ((!next || next.state === 'building' || next.state === 'unknown') && ++tries < 90) {
|
|
59
|
+
timer = setTimeout(() => void ask(), 10_000);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
void ask();
|
|
63
|
+
return () => {
|
|
64
|
+
live = false;
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
};
|
|
67
|
+
}, [cloud, commit]);
|
|
68
|
+
return status;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The plain sentence after a publish (spec §6.16): building, live, failed, or nothing reported. */
|
|
72
|
+
export function DeployLine({ commit }: { commit: string }) {
|
|
73
|
+
const status = useDeployStatus(commit);
|
|
74
|
+
const state = status?.state ?? 'unknown';
|
|
75
|
+
const text =
|
|
76
|
+
state === 'live'
|
|
77
|
+
? 'Published. It’s live on the site.'
|
|
78
|
+
: state === 'failed'
|
|
79
|
+
? 'Published, but the site’s build failed, so it isn’t live yet.'
|
|
80
|
+
: state === 'building'
|
|
81
|
+
? 'Published. The site is building now.'
|
|
82
|
+
: 'Published. It will be live after the site is next built.';
|
|
83
|
+
return (
|
|
84
|
+
<p className="text-small text-content-muted">
|
|
85
|
+
{text}
|
|
86
|
+
{status?.url && state !== 'unknown' && (
|
|
87
|
+
<>
|
|
88
|
+
{' '}
|
|
89
|
+
<Link href={status.url} target="_blank" rel="noopener">
|
|
90
|
+
{state === 'failed' ? 'See what went wrong' : 'See the build'}
|
|
91
|
+
</Link>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
</p>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A file's text at a version, through the cloud. */
|
|
99
|
+
async function textAt(cloud: CloudConnection, version: string | null): Promise<string> {
|
|
100
|
+
if (!version) return '';
|
|
101
|
+
const { blobs } = await cloud.client.blobs([version]);
|
|
102
|
+
const found = blobs[version];
|
|
103
|
+
return found === undefined ? '' : (decodeUtf8(fromBase64(found)) ?? '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A change published since the draft began (spec §7.4): the published file
|
|
108
|
+
* and the draft side by side, and the editor's choice. Nothing is
|
|
109
|
+
* overwritten silently.
|
|
110
|
+
*/
|
|
111
|
+
export function ConflictCompare({
|
|
112
|
+
path,
|
|
113
|
+
draft,
|
|
114
|
+
publishedVersion,
|
|
115
|
+
onKeep,
|
|
116
|
+
onDiscard,
|
|
117
|
+
}: {
|
|
118
|
+
path: string;
|
|
119
|
+
draft: Draft;
|
|
120
|
+
publishedVersion: string | null;
|
|
121
|
+
onKeep: () => void;
|
|
122
|
+
onDiscard: () => void;
|
|
123
|
+
}) {
|
|
124
|
+
const cloud = useStudio().cloud;
|
|
125
|
+
const [texts, setTexts] = useState<{ published: string; mine: string } | null>(null);
|
|
126
|
+
const file = draft.files.find((f) => f.path === path);
|
|
127
|
+
const mineVersion = file?.op === 'put' ? file.version : null;
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!cloud) return;
|
|
130
|
+
let live = true;
|
|
131
|
+
void Promise.all([textAt(cloud, publishedVersion), textAt(cloud, mineVersion)]).then(
|
|
132
|
+
([published, mine]) => {
|
|
133
|
+
if (live) setTexts({ published, mine });
|
|
134
|
+
},
|
|
135
|
+
() => {
|
|
136
|
+
if (live) setTexts({ published: '', mine: '' });
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
return () => {
|
|
140
|
+
live = false;
|
|
141
|
+
};
|
|
142
|
+
}, [cloud, publishedVersion, mineVersion]);
|
|
143
|
+
return (
|
|
144
|
+
<section
|
|
145
|
+
aria-labelledby="conflict-title"
|
|
146
|
+
className="flex flex-col gap-4 rounded-md border border-danger bg-surface-raised p-4"
|
|
147
|
+
>
|
|
148
|
+
<h2 id="conflict-title" className="m-0 text-heading-3 text-content">
|
|
149
|
+
Someone published this entry since your draft began
|
|
150
|
+
</h2>
|
|
151
|
+
<p className="m-0">
|
|
152
|
+
Nothing was published. Compare the two, then keep your draft (it replaces theirs when you
|
|
153
|
+
publish) or use theirs.
|
|
154
|
+
</p>
|
|
155
|
+
{texts ? (
|
|
156
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
157
|
+
<CompareColumn title="Published now" text={texts.published} />
|
|
158
|
+
<CompareColumn title="Your draft" text={texts.mine} />
|
|
159
|
+
</div>
|
|
160
|
+
) : (
|
|
161
|
+
<p role="status" className="m-0 text-content-muted">
|
|
162
|
+
Loading both versions…
|
|
163
|
+
</p>
|
|
164
|
+
)}
|
|
165
|
+
<div className="flex flex-wrap gap-3">
|
|
166
|
+
<Button onPress={onKeep}>Keep my draft</Button>
|
|
167
|
+
<Button onPress={onDiscard}>Use the published version</Button>
|
|
168
|
+
</div>
|
|
169
|
+
</section>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function CompareColumn({ title, text }: { title: string; text: string }) {
|
|
174
|
+
return (
|
|
175
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
176
|
+
<h3 className="m-0 text-label text-content">{title}</h3>
|
|
177
|
+
{/* A scrolling box must be reachable by keyboard to be read (WCAG 2.1.1). */}
|
|
178
|
+
<pre
|
|
179
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
180
|
+
tabIndex={0}
|
|
181
|
+
aria-label={title}
|
|
182
|
+
className="m-0 max-h-96 overflow-auto rounded-sm border border-border bg-surface p-3 font-mono text-small whitespace-pre-wrap"
|
|
183
|
+
>
|
|
184
|
+
{text}
|
|
185
|
+
</pre>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Asks for review, with an optional note for the approvers. */
|
|
191
|
+
export function RequestReviewDialog({
|
|
192
|
+
isOpen,
|
|
193
|
+
onOpenChange,
|
|
194
|
+
onSend,
|
|
195
|
+
}: {
|
|
196
|
+
isOpen: boolean;
|
|
197
|
+
onOpenChange: (open: boolean) => void;
|
|
198
|
+
onSend: (note: string) => Promise<void>;
|
|
199
|
+
}) {
|
|
200
|
+
const [note, setNote] = useState('');
|
|
201
|
+
const [sending, setSending] = useState(false);
|
|
202
|
+
return (
|
|
203
|
+
<Dialog title="Request review" isOpen={isOpen} onOpenChange={onOpenChange}>
|
|
204
|
+
<div className="flex flex-col gap-4">
|
|
205
|
+
<p className="m-0">
|
|
206
|
+
An approver for this site gets an email, and can publish your changes or ask for more.
|
|
207
|
+
</p>
|
|
208
|
+
<TextArea label="Note for the approver (optional)" value={note} onChange={setNote} />
|
|
209
|
+
<div className="flex flex-wrap justify-end gap-3">
|
|
210
|
+
<Button
|
|
211
|
+
onPress={() => {
|
|
212
|
+
onOpenChange(false);
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
Cancel
|
|
216
|
+
</Button>
|
|
217
|
+
<Button
|
|
218
|
+
variant="primary"
|
|
219
|
+
isPending={sending}
|
|
220
|
+
onPress={() => {
|
|
221
|
+
setSending(true);
|
|
222
|
+
void onSend(note.trim()).finally(() => {
|
|
223
|
+
setSending(false);
|
|
224
|
+
setNote('');
|
|
225
|
+
});
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
Send for review
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</Dialog>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Unsaved changes kept on this device while the cloud can't be reached
|
|
238
|
+
* (spec §6.9): written as they're typed, cleared once a draft is saved.
|
|
239
|
+
*/
|
|
240
|
+
export type KeptChanges = { values: unknown; body: string; raw: string };
|
|
241
|
+
|
|
242
|
+
let keptStore: ReturnType<typeof keyValueStore<KeptChanges>> | null = null;
|
|
243
|
+
export function keptChanges() {
|
|
244
|
+
keptStore ??= keyValueStore<KeptChanges>('vesper-cloud-unsaved', 'entries');
|
|
245
|
+
return keptStore;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function keptKey(project: string, collection: string, id: string | null): string {
|
|
249
|
+
return `${project}:${collection}:${id ?? '(new)'}`;
|
|
250
|
+
}
|