@narumitw/pi-image-drop 0.28.0 → 0.31.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.
- package/README.md +9 -3
- package/package.json +19 -8
- package/src/runtime.ts +1 -4
- package/src/server.ts +8 -2
- package/src/web/app.js +97 -537
- package/src/web/index.html +2 -102
- package/src/web/state.js +5 -114
- package/src/web/styles.css +2 -538
- package/src/web/ui/app.tsx +178 -0
- package/src/web/ui/client.ts +377 -0
- package/src/web/ui/components.tsx +615 -0
- package/src/web/ui/state.ts +149 -0
- package/src/web/ui/styles.css +572 -0
- package/src/web/ui/types.ts +56 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Container, Link, Theme } from "@radix-ui/themes";
|
|
2
|
+
import { StrictMode, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { imageDropClient } from "./client.js";
|
|
5
|
+
import {
|
|
6
|
+
ConnectionBlocker,
|
|
7
|
+
DraftSection,
|
|
8
|
+
DropZone,
|
|
9
|
+
HistorySection,
|
|
10
|
+
ImagePreviewDialog,
|
|
11
|
+
LoadingState,
|
|
12
|
+
MetadataNotice,
|
|
13
|
+
type PreviewSelection,
|
|
14
|
+
PrivacyNotice,
|
|
15
|
+
SessionHeader,
|
|
16
|
+
} from "./components.js";
|
|
17
|
+
import { canMutate } from "./state.js";
|
|
18
|
+
|
|
19
|
+
function useAppearance(): "dark" | "light" {
|
|
20
|
+
const query = "(prefers-color-scheme: dark)";
|
|
21
|
+
const [appearance, setAppearance] = useState<"dark" | "light">(() =>
|
|
22
|
+
window.matchMedia(query).matches ? "dark" : "light",
|
|
23
|
+
);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const media = window.matchMedia(query);
|
|
26
|
+
const update = () => setAppearance(media.matches ? "dark" : "light");
|
|
27
|
+
media.addEventListener("change", update);
|
|
28
|
+
return () => media.removeEventListener("change", update);
|
|
29
|
+
}, []);
|
|
30
|
+
return appearance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function App() {
|
|
34
|
+
const view = useSyncExternalStore(imageDropClient.subscribe, imageDropClient.getSnapshot);
|
|
35
|
+
const appearance = useAppearance();
|
|
36
|
+
const [dragActive, setDragActive] = useState(false);
|
|
37
|
+
const [preview, setPreview] = useState<PreviewSelection>();
|
|
38
|
+
const dragDepth = useRef(0);
|
|
39
|
+
const previewReturnFocus = useRef<HTMLElement | undefined>(undefined);
|
|
40
|
+
const state = view.state;
|
|
41
|
+
|
|
42
|
+
useEffect(() => imageDropClient.start(), []);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!state) return;
|
|
45
|
+
document.title = `${state.projectName} · Pi Image Drop`;
|
|
46
|
+
}, [state]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!view.focusTarget) return;
|
|
49
|
+
requestAnimationFrame(() => {
|
|
50
|
+
document
|
|
51
|
+
.querySelector<HTMLElement>(`[data-id="${CSS.escape(view.focusTarget ?? "")}"]`)
|
|
52
|
+
?.focus();
|
|
53
|
+
});
|
|
54
|
+
}, [view.focusTarget]);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!preview || !state) return;
|
|
57
|
+
const items = preview.collection === "items" ? state.batch.items : state.history.items;
|
|
58
|
+
if (!items.some((item) => item.id === preview.item.id)) closePreview();
|
|
59
|
+
});
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const paste = (event: ClipboardEvent) => {
|
|
62
|
+
if (!state || !canMutate(state.batch)) return;
|
|
63
|
+
const files = [...(event.clipboardData?.items ?? [])]
|
|
64
|
+
.filter((item) => item.kind === "file")
|
|
65
|
+
.map((item) => item.getAsFile())
|
|
66
|
+
.filter((file): file is File => Boolean(file));
|
|
67
|
+
if (files.length === 0) return;
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
void imageDropClient.addFiles(files);
|
|
70
|
+
};
|
|
71
|
+
const dragEnter = (event: DragEvent) => {
|
|
72
|
+
if (!state || !canMutate(state.batch) || !hasFiles(event)) return;
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
dragDepth.current += 1;
|
|
75
|
+
setDragActive(true);
|
|
76
|
+
};
|
|
77
|
+
const dragOver = (event: DragEvent) => {
|
|
78
|
+
if (!state || !canMutate(state.batch) || !hasFiles(event)) return;
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
};
|
|
81
|
+
const dragLeave = (event: DragEvent) => {
|
|
82
|
+
if (!hasFiles(event)) return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
85
|
+
if (dragDepth.current === 0) setDragActive(false);
|
|
86
|
+
};
|
|
87
|
+
const drop = (event: DragEvent) => {
|
|
88
|
+
if (!hasFiles(event)) return;
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
dragDepth.current = 0;
|
|
91
|
+
setDragActive(false);
|
|
92
|
+
if (state && canMutate(state.batch)) {
|
|
93
|
+
void imageDropClient.addFiles(event.dataTransfer?.files ?? null);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
document.addEventListener("paste", paste);
|
|
97
|
+
document.addEventListener("dragenter", dragEnter);
|
|
98
|
+
document.addEventListener("dragover", dragOver);
|
|
99
|
+
document.addEventListener("dragleave", dragLeave);
|
|
100
|
+
document.addEventListener("drop", drop);
|
|
101
|
+
return () => {
|
|
102
|
+
document.removeEventListener("paste", paste);
|
|
103
|
+
document.removeEventListener("dragenter", dragEnter);
|
|
104
|
+
document.removeEventListener("dragover", dragOver);
|
|
105
|
+
document.removeEventListener("dragleave", dragLeave);
|
|
106
|
+
document.removeEventListener("drop", drop);
|
|
107
|
+
};
|
|
108
|
+
}, [state]);
|
|
109
|
+
|
|
110
|
+
function openPreview(selection: PreviewSelection, trigger: HTMLElement) {
|
|
111
|
+
previewReturnFocus.current = trigger;
|
|
112
|
+
setPreview(selection);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function closePreview() {
|
|
116
|
+
setPreview(undefined);
|
|
117
|
+
requestAnimationFrame(() => previewReturnFocus.current?.focus());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Theme
|
|
122
|
+
accentColor="jade"
|
|
123
|
+
appearance={appearance}
|
|
124
|
+
grayColor="slate"
|
|
125
|
+
panelBackground="solid"
|
|
126
|
+
radius="large"
|
|
127
|
+
scaling="100%"
|
|
128
|
+
>
|
|
129
|
+
<Link className="skip-link" highContrast href="#drop-zone">
|
|
130
|
+
Skip to image staging
|
|
131
|
+
</Link>
|
|
132
|
+
<Container className="page-shell" size="4">
|
|
133
|
+
<SessionHeader state={state} />
|
|
134
|
+
<main id="main-content">
|
|
135
|
+
{state ? (
|
|
136
|
+
<>
|
|
137
|
+
<DropZone
|
|
138
|
+
dragActive={dragActive}
|
|
139
|
+
mutable={canMutate(state.batch)}
|
|
140
|
+
onFiles={(files) => void imageDropClient.addFiles(files)}
|
|
141
|
+
/>
|
|
142
|
+
<MetadataNotice />
|
|
143
|
+
<DraftSection
|
|
144
|
+
error={view.error}
|
|
145
|
+
highlightedId={view.highlightedId}
|
|
146
|
+
onPreview={openPreview}
|
|
147
|
+
state={state}
|
|
148
|
+
/>
|
|
149
|
+
<HistorySection onPreview={openPreview} state={state} />
|
|
150
|
+
<PrivacyNotice />
|
|
151
|
+
</>
|
|
152
|
+
) : (
|
|
153
|
+
<LoadingState />
|
|
154
|
+
)}
|
|
155
|
+
</main>
|
|
156
|
+
</Container>
|
|
157
|
+
<ImagePreviewDialog
|
|
158
|
+
onOpenChange={(open) => !open && closePreview()}
|
|
159
|
+
open={Boolean(preview)}
|
|
160
|
+
revision={state?.batch.revision ?? 0}
|
|
161
|
+
selection={preview}
|
|
162
|
+
/>
|
|
163
|
+
<ConnectionBlocker failure={view.connectionFailure} />
|
|
164
|
+
</Theme>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hasFiles(event: DragEvent): boolean {
|
|
169
|
+
return [...(event.dataTransfer?.types ?? [])].includes("Files");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const root = document.querySelector("#root");
|
|
173
|
+
if (!root) throw new Error("Pi Image Drop root is unavailable.");
|
|
174
|
+
createRoot(root).render(
|
|
175
|
+
<StrictMode>
|
|
176
|
+
<App />
|
|
177
|
+
</StrictMode>,
|
|
178
|
+
);
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attemptMutation,
|
|
3
|
+
canMutate,
|
|
4
|
+
moveItem,
|
|
5
|
+
moveItemBefore,
|
|
6
|
+
preferNewestState,
|
|
7
|
+
} from "./state.js";
|
|
8
|
+
import type {
|
|
9
|
+
ImageDropState,
|
|
10
|
+
ImageDropView,
|
|
11
|
+
RequestOptions,
|
|
12
|
+
RestageState,
|
|
13
|
+
UploadState,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
interface PendingUpload {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
size: number;
|
|
20
|
+
file: File;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class ApiError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
readonly status: number,
|
|
26
|
+
message: string,
|
|
27
|
+
) {
|
|
28
|
+
super(message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class ImageDropClient {
|
|
33
|
+
private readonly clientId = crypto.randomUUID();
|
|
34
|
+
private readonly listeners = new Set<() => void>();
|
|
35
|
+
private readonly pendingFiles = new Map<string, File>();
|
|
36
|
+
private view: ImageDropView = { error: "" };
|
|
37
|
+
private events?: EventSource;
|
|
38
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
39
|
+
private highlightTimer?: ReturnType<typeof setTimeout>;
|
|
40
|
+
private started = false;
|
|
41
|
+
|
|
42
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
43
|
+
this.listeners.add(listener);
|
|
44
|
+
return () => this.listeners.delete(listener);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
readonly getSnapshot = (): ImageDropView => this.view;
|
|
48
|
+
|
|
49
|
+
start(): void {
|
|
50
|
+
if (this.started) return;
|
|
51
|
+
this.started = true;
|
|
52
|
+
void this.initialize();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async addFiles(fileList: Iterable<File> | ArrayLike<File> | null): Promise<void> {
|
|
56
|
+
const state = this.view.state;
|
|
57
|
+
const files = fileList ? Array.from(fileList) : [];
|
|
58
|
+
if (!state || files.length === 0) return;
|
|
59
|
+
if (!canMutate(state.batch)) {
|
|
60
|
+
this.showError("This batch is already queued with Pi.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.clearError();
|
|
64
|
+
const items: PendingUpload[] = files.map((file) => ({
|
|
65
|
+
id: crypto.randomUUID(),
|
|
66
|
+
name: file.name || "pasted-image",
|
|
67
|
+
size: file.size,
|
|
68
|
+
file,
|
|
69
|
+
}));
|
|
70
|
+
try {
|
|
71
|
+
this.applyState(
|
|
72
|
+
await this.request<ImageDropState>("/api/items", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
json: {
|
|
75
|
+
revision: state.batch.revision,
|
|
76
|
+
items: items.map(({ id, name, size }) => ({ id, name, size })),
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
for (const item of items) this.pendingFiles.set(item.id, item.file);
|
|
81
|
+
this.emit();
|
|
82
|
+
await mapConcurrent(items, 4, (item) => this.upload(item));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
this.showError(errorMessage(error));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async retry(id: string): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
const file = this.pendingFiles.get(id);
|
|
91
|
+
const response = file
|
|
92
|
+
? await this.request<UploadState>(`/api/items/${id}/content`, {
|
|
93
|
+
method: "PUT",
|
|
94
|
+
body: file,
|
|
95
|
+
headers: { "content-type": "application/octet-stream" },
|
|
96
|
+
})
|
|
97
|
+
: await this.request<UploadState>(`/api/items/${id}/retry`, { method: "POST" });
|
|
98
|
+
this.applyState(response);
|
|
99
|
+
if (response.duplicateOf) this.highlight(response.duplicateOf);
|
|
100
|
+
if (!this.hasErroredItem(id)) this.pendingFiles.delete(id);
|
|
101
|
+
this.clearError();
|
|
102
|
+
this.emit();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
this.showError(
|
|
105
|
+
`${errorMessage(error)} Delete and choose the image again if its source is unavailable.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async remove(id: string): Promise<void> {
|
|
111
|
+
const state = this.view.state;
|
|
112
|
+
if (!state) return;
|
|
113
|
+
if (
|
|
114
|
+
await this.mutate(`/api/items/${id}?revision=${state.batch.revision}`, {
|
|
115
|
+
method: "DELETE",
|
|
116
|
+
})
|
|
117
|
+
) {
|
|
118
|
+
this.pendingFiles.delete(id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async move(id: string, direction: number): Promise<void> {
|
|
123
|
+
const state = this.view.state;
|
|
124
|
+
if (!state) return;
|
|
125
|
+
await this.reorder(moveItem(this.ids(), id, direction));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async moveBefore(id: string, targetId: string): Promise<void> {
|
|
129
|
+
await this.reorder(moveItemBefore(this.ids(), id, targetId));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async clearAll(): Promise<void> {
|
|
133
|
+
const state = this.view.state;
|
|
134
|
+
if (!state) return;
|
|
135
|
+
if (
|
|
136
|
+
await this.mutate("/api/clear", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
json: { revision: state.batch.revision },
|
|
139
|
+
})
|
|
140
|
+
) {
|
|
141
|
+
this.pendingFiles.clear();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async restageHistory(historyId: string): Promise<void> {
|
|
146
|
+
const state = this.view.state;
|
|
147
|
+
if (!state) return;
|
|
148
|
+
try {
|
|
149
|
+
const response = await this.request<RestageState>("/api/history/restage", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
json: {
|
|
152
|
+
revision: state.batch.revision,
|
|
153
|
+
items: [{ historyId, id: crypto.randomUUID() }],
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
this.applyState(response);
|
|
157
|
+
this.clearError();
|
|
158
|
+
this.emit();
|
|
159
|
+
const target = response.restage.addedIds[0] ?? response.restage.duplicates[0]?.existingId;
|
|
160
|
+
if (target) this.highlight(target);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.showError(errorMessage(error));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async deleteHistory(id: string): Promise<void> {
|
|
167
|
+
const state = this.view.state;
|
|
168
|
+
if (!state) return;
|
|
169
|
+
await this.mutate(`/api/history/${id}?revision=${state.batch.revision}`, {
|
|
170
|
+
method: "DELETE",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async clearHistory(): Promise<void> {
|
|
175
|
+
const state = this.view.state;
|
|
176
|
+
if (!state) return;
|
|
177
|
+
await this.mutate("/api/history/clear", {
|
|
178
|
+
method: "POST",
|
|
179
|
+
json: { revision: state.batch.revision },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async initialize(): Promise<void> {
|
|
184
|
+
try {
|
|
185
|
+
this.applyState(
|
|
186
|
+
await this.request<ImageDropState>("/api/lease", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
json: { clientId: this.clientId },
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
this.emit();
|
|
192
|
+
this.connectEvents();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.failConnection("Could not connect", errorMessage(error));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private connectEvents(): void {
|
|
199
|
+
this.events?.close();
|
|
200
|
+
const events = new EventSource(`/api/events?client=${encodeURIComponent(this.clientId)}`);
|
|
201
|
+
this.events = events;
|
|
202
|
+
events.addEventListener("state", (event) => {
|
|
203
|
+
clearTimeout(this.reconnectTimer);
|
|
204
|
+
this.applyState(JSON.parse(event.data) as ImageDropState);
|
|
205
|
+
this.reconcileFiles();
|
|
206
|
+
this.emit();
|
|
207
|
+
});
|
|
208
|
+
events.addEventListener("stale", (event) => {
|
|
209
|
+
events.close();
|
|
210
|
+
const payload = JSON.parse(event.data) as { message: string };
|
|
211
|
+
this.failConnection("Opened in another tab", payload.message);
|
|
212
|
+
});
|
|
213
|
+
events.addEventListener("session-ended", (event) => {
|
|
214
|
+
events.close();
|
|
215
|
+
const payload = JSON.parse(event.data) as { message: string };
|
|
216
|
+
this.failConnection("Pi session ended", payload.message);
|
|
217
|
+
});
|
|
218
|
+
events.onerror = () => {
|
|
219
|
+
clearTimeout(this.reconnectTimer);
|
|
220
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
221
|
+
try {
|
|
222
|
+
this.applyState(await this.request<ImageDropState>("/api/state"));
|
|
223
|
+
this.emit();
|
|
224
|
+
} catch {
|
|
225
|
+
events.close();
|
|
226
|
+
this.failConnection("Connection lost", "Run /image-drop in Pi for a new link.");
|
|
227
|
+
}
|
|
228
|
+
}, 2_000);
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async upload(item: PendingUpload): Promise<void> {
|
|
233
|
+
try {
|
|
234
|
+
const response = await this.request<UploadState>(`/api/items/${item.id}/content`, {
|
|
235
|
+
method: "PUT",
|
|
236
|
+
body: item.file,
|
|
237
|
+
headers: { "content-type": "application/octet-stream" },
|
|
238
|
+
});
|
|
239
|
+
this.applyState(response);
|
|
240
|
+
if (response.duplicateOf) this.highlight(response.duplicateOf);
|
|
241
|
+
if (!this.hasErroredItem(item.id)) this.pendingFiles.delete(item.id);
|
|
242
|
+
this.emit();
|
|
243
|
+
} catch (error) {
|
|
244
|
+
try {
|
|
245
|
+
if (!(error instanceof ApiError) || error.status === 413) {
|
|
246
|
+
this.applyState(
|
|
247
|
+
await this.request<ImageDropState>(`/api/items/${item.id}/fail`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
json: { error: `Upload failed: ${errorMessage(error)}` },
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
} else {
|
|
253
|
+
this.applyState(await this.request<ImageDropState>("/api/state"));
|
|
254
|
+
}
|
|
255
|
+
this.emit();
|
|
256
|
+
} catch {
|
|
257
|
+
// The Pi session may have disconnected while the upload failed.
|
|
258
|
+
}
|
|
259
|
+
this.showError(errorMessage(error));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async reorder(ids: string[]): Promise<void> {
|
|
264
|
+
const state = this.view.state;
|
|
265
|
+
if (!state || ids.every((id, index) => state.batch.items[index]?.id === id)) return;
|
|
266
|
+
await this.mutate("/api/order", {
|
|
267
|
+
method: "PUT",
|
|
268
|
+
json: { revision: state.batch.revision, ids },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async mutate(path: string, options: RequestOptions): Promise<boolean> {
|
|
273
|
+
const result = await attemptMutation(() => this.request<ImageDropState>(path, options));
|
|
274
|
+
if (!result.ok) {
|
|
275
|
+
this.showError(errorMessage(result.error));
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
this.applyState(result.value);
|
|
279
|
+
this.clearError();
|
|
280
|
+
this.emit();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
285
|
+
const headers = new Headers(options.headers);
|
|
286
|
+
headers.set("x-image-drop-client", this.clientId);
|
|
287
|
+
let body = options.body;
|
|
288
|
+
if (options.json !== undefined) {
|
|
289
|
+
headers.set("content-type", "application/json");
|
|
290
|
+
body = JSON.stringify(options.json);
|
|
291
|
+
}
|
|
292
|
+
const response = await fetch(path, { method: options.method, headers, body });
|
|
293
|
+
const data = (response.headers.get("content-type") ?? "").includes("application/json")
|
|
294
|
+
? ((await response.json()) as { error?: string } & T)
|
|
295
|
+
: undefined;
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
throw new ApiError(
|
|
298
|
+
response.status,
|
|
299
|
+
data?.error ?? `Image Drop request failed (${response.status})`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return data as T;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private ids(): string[] {
|
|
306
|
+
return this.view.state?.batch.items.map((item) => item.id) ?? [];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private hasErroredItem(id: string): boolean {
|
|
310
|
+
return Boolean(
|
|
311
|
+
this.view.state?.batch.items.some((item) => item.id === id && item.status === "error"),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private applyState(next: ImageDropState): void {
|
|
316
|
+
this.view = { ...this.view, state: preferNewestState(this.view.state, next) };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private reconcileFiles(): void {
|
|
320
|
+
for (const id of this.pendingFiles.keys()) {
|
|
321
|
+
if (!this.view.state?.batch.items.some((item) => item.id === id)) {
|
|
322
|
+
this.pendingFiles.delete(id);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private highlight(id: string): void {
|
|
328
|
+
clearTimeout(this.highlightTimer);
|
|
329
|
+
this.view = { ...this.view, highlightedId: id, focusTarget: id };
|
|
330
|
+
this.emit();
|
|
331
|
+
this.highlightTimer = setTimeout(() => {
|
|
332
|
+
this.view = { ...this.view, highlightedId: undefined, focusTarget: undefined };
|
|
333
|
+
this.emit();
|
|
334
|
+
}, 1_800);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private clearError(): void {
|
|
338
|
+
if (!this.view.error) return;
|
|
339
|
+
this.view = { ...this.view, error: "" };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private showError(error: string): void {
|
|
343
|
+
this.view = { ...this.view, error };
|
|
344
|
+
this.emit();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private failConnection(title: string, message: string): void {
|
|
348
|
+
this.view = { ...this.view, connectionFailure: { title, message } };
|
|
349
|
+
this.emit();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private emit(): void {
|
|
353
|
+
for (const listener of this.listeners) listener();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function mapConcurrent<T>(
|
|
358
|
+
values: readonly T[],
|
|
359
|
+
limit: number,
|
|
360
|
+
task: (value: T) => Promise<void>,
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
let cursor = 0;
|
|
363
|
+
await Promise.all(
|
|
364
|
+
Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
365
|
+
while (cursor < values.length) {
|
|
366
|
+
const value = values[cursor++];
|
|
367
|
+
if (value !== undefined) await task(value);
|
|
368
|
+
}
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function errorMessage(error: unknown): string {
|
|
374
|
+
return error instanceof Error ? error.message : String(error);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export const imageDropClient = new ImageDropClient();
|