@narumitw/pi-image-drop 0.18.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/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +51 -0
- package/src/batch.ts +418 -0
- package/src/image-drop.ts +9 -0
- package/src/images.ts +408 -0
- package/src/pi-settings.ts +79 -0
- package/src/runtime.ts +417 -0
- package/src/server.ts +572 -0
- package/src/settings.ts +116 -0
- package/src/vendor.d.ts +30 -0
- package/src/web/app.js +403 -0
- package/src/web/index.html +74 -0
- package/src/web/state.js +61 -0
- package/src/web/styles.css +397 -0
package/src/vendor.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
declare module "bmp-js" {
|
|
2
|
+
interface DecodedBmp {
|
|
3
|
+
data: Buffer;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
export function decode(buffer: Buffer, withAlpha?: boolean): DecodedBmp;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare module "heic-decode" {
|
|
11
|
+
interface DecodedImage {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
data: Uint8ClampedArray;
|
|
15
|
+
}
|
|
16
|
+
interface DeferredImage {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
decode(): Promise<DecodedImage>;
|
|
20
|
+
}
|
|
21
|
+
interface DeferredImages extends Array<DeferredImage> {
|
|
22
|
+
dispose(): void;
|
|
23
|
+
}
|
|
24
|
+
interface Decode {
|
|
25
|
+
(options: { buffer: Uint8Array }): Promise<DecodedImage>;
|
|
26
|
+
all(options: { buffer: Uint8Array }): Promise<DeferredImages>;
|
|
27
|
+
}
|
|
28
|
+
const decode: Decode;
|
|
29
|
+
export default decode;
|
|
30
|
+
}
|
package/src/web/app.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canMutate,
|
|
3
|
+
formatBytes,
|
|
4
|
+
moveItem,
|
|
5
|
+
moveItemBefore,
|
|
6
|
+
preferNewestState,
|
|
7
|
+
summarizeBatch,
|
|
8
|
+
} from "/state.js";
|
|
9
|
+
|
|
10
|
+
const $ = (selector) => document.querySelector(selector);
|
|
11
|
+
const ui = {
|
|
12
|
+
session: $("#session-label"),
|
|
13
|
+
cwd: $("#cwd"),
|
|
14
|
+
drop: $("#drop-zone"),
|
|
15
|
+
choose: $("#choose-files"),
|
|
16
|
+
input: $("#file-input"),
|
|
17
|
+
status: $("#status"),
|
|
18
|
+
clear: $("#clear-all"),
|
|
19
|
+
error: $("#error-banner"),
|
|
20
|
+
grid: $("#grid"),
|
|
21
|
+
overlay: $("#connection-overlay"),
|
|
22
|
+
connectionTitle: $("#connection-title"),
|
|
23
|
+
connectionMessage: $("#connection-message"),
|
|
24
|
+
dialog: $("#clear-dialog"),
|
|
25
|
+
};
|
|
26
|
+
const clientId = crypto.randomUUID();
|
|
27
|
+
const pendingFiles = new Map();
|
|
28
|
+
let state;
|
|
29
|
+
let draggedId;
|
|
30
|
+
let highlightedId;
|
|
31
|
+
let reconnectTimer;
|
|
32
|
+
|
|
33
|
+
wire();
|
|
34
|
+
void start();
|
|
35
|
+
|
|
36
|
+
async function start() {
|
|
37
|
+
try {
|
|
38
|
+
state = await request("/api/lease", { method: "POST", json: { clientId } });
|
|
39
|
+
render();
|
|
40
|
+
connectEvents();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
connectionFailure("Could not connect", errorMessage(error));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function wire() {
|
|
47
|
+
ui.choose.addEventListener("click", () => ui.input.click());
|
|
48
|
+
ui.input.addEventListener("change", () => {
|
|
49
|
+
void addFiles([...ui.input.files]);
|
|
50
|
+
ui.input.value = "";
|
|
51
|
+
});
|
|
52
|
+
document.addEventListener("paste", (event) => {
|
|
53
|
+
const files = [...(event.clipboardData?.items ?? [])]
|
|
54
|
+
.filter((item) => item.kind === "file")
|
|
55
|
+
.map((item) => item.getAsFile())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (files.length === 0) return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
void addFiles(files);
|
|
60
|
+
});
|
|
61
|
+
for (const name of ["dragenter", "dragover"])
|
|
62
|
+
document.addEventListener(name, (event) => {
|
|
63
|
+
if (!hasFiles(event)) return;
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
ui.drop.classList.add("drag-active");
|
|
66
|
+
});
|
|
67
|
+
for (const name of ["dragleave", "drop"])
|
|
68
|
+
document.addEventListener(name, (event) => {
|
|
69
|
+
if (!hasFiles(event)) return;
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
ui.drop.classList.remove("drag-active");
|
|
72
|
+
if (name === "drop") void addFiles([...event.dataTransfer.files]);
|
|
73
|
+
});
|
|
74
|
+
ui.clear.addEventListener("click", () => {
|
|
75
|
+
ui.dialog.returnValue = "cancel";
|
|
76
|
+
ui.dialog.showModal();
|
|
77
|
+
});
|
|
78
|
+
ui.dialog.addEventListener("close", () => {
|
|
79
|
+
if (ui.dialog.returnValue === "confirm") void clearAll();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function connectEvents() {
|
|
84
|
+
const events = new EventSource(`/api/events?client=${encodeURIComponent(clientId)}`);
|
|
85
|
+
events.addEventListener("state", (event) => {
|
|
86
|
+
clearTimeout(reconnectTimer);
|
|
87
|
+
applyState(JSON.parse(event.data));
|
|
88
|
+
reconcileFiles();
|
|
89
|
+
render();
|
|
90
|
+
});
|
|
91
|
+
events.addEventListener("stale", (event) => {
|
|
92
|
+
events.close();
|
|
93
|
+
connectionFailure("Opened in another tab", JSON.parse(event.data).message);
|
|
94
|
+
});
|
|
95
|
+
events.addEventListener("session-ended", (event) => {
|
|
96
|
+
events.close();
|
|
97
|
+
connectionFailure("Pi session ended", JSON.parse(event.data).message);
|
|
98
|
+
});
|
|
99
|
+
events.onerror = () => {
|
|
100
|
+
clearTimeout(reconnectTimer);
|
|
101
|
+
reconnectTimer = setTimeout(async () => {
|
|
102
|
+
try {
|
|
103
|
+
applyState(await request("/api/state"));
|
|
104
|
+
render();
|
|
105
|
+
} catch {
|
|
106
|
+
events.close();
|
|
107
|
+
connectionFailure("Connection lost", "Run /image-drop in Pi for a new link.");
|
|
108
|
+
}
|
|
109
|
+
}, 2000);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function addFiles(files) {
|
|
114
|
+
if (!state || files.length === 0) return;
|
|
115
|
+
if (!canMutate(state.batch)) return showError("This batch is already queued with Pi.");
|
|
116
|
+
clearError();
|
|
117
|
+
const items = files.map((file) => ({
|
|
118
|
+
id: crypto.randomUUID(),
|
|
119
|
+
name: file.name || "pasted-image",
|
|
120
|
+
size: file.size,
|
|
121
|
+
file,
|
|
122
|
+
}));
|
|
123
|
+
try {
|
|
124
|
+
applyState(
|
|
125
|
+
await request("/api/items", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
json: {
|
|
128
|
+
revision: state.batch.revision,
|
|
129
|
+
items: items.map(({ id, name, size }) => ({ id, name, size })),
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
for (const item of items) pendingFiles.set(item.id, item.file);
|
|
134
|
+
render();
|
|
135
|
+
await mapConcurrent(items, 4, upload);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
showError(errorMessage(error));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function upload(item) {
|
|
142
|
+
try {
|
|
143
|
+
const response = await request(`/api/items/${item.id}/content`, {
|
|
144
|
+
method: "PUT",
|
|
145
|
+
body: item.file,
|
|
146
|
+
headers: { "content-type": "application/octet-stream" },
|
|
147
|
+
});
|
|
148
|
+
applyState(response);
|
|
149
|
+
if (response.duplicateOf) highlight(response.duplicateOf);
|
|
150
|
+
if (
|
|
151
|
+
!state.batch.items.some(
|
|
152
|
+
(candidate) => candidate.id === item.id && candidate.status === "error",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
pendingFiles.delete(item.id);
|
|
156
|
+
render();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
try {
|
|
159
|
+
if (!(error instanceof ApiError) || error.status === 413) {
|
|
160
|
+
applyState(
|
|
161
|
+
await request(`/api/items/${item.id}/fail`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
json: { error: `Upload failed: ${errorMessage(error)}` },
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
applyState(await request("/api/state"));
|
|
168
|
+
}
|
|
169
|
+
render();
|
|
170
|
+
} catch {
|
|
171
|
+
/* The session may be disconnected. */
|
|
172
|
+
}
|
|
173
|
+
showError(errorMessage(error));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function retry(id) {
|
|
178
|
+
try {
|
|
179
|
+
const file = pendingFiles.get(id);
|
|
180
|
+
const response = file
|
|
181
|
+
? await request(`/api/items/${id}/content`, {
|
|
182
|
+
method: "PUT",
|
|
183
|
+
body: file,
|
|
184
|
+
headers: { "content-type": "application/octet-stream" },
|
|
185
|
+
})
|
|
186
|
+
: await request(`/api/items/${id}/retry`, { method: "POST" });
|
|
187
|
+
applyState(response);
|
|
188
|
+
if (response.duplicateOf) highlight(response.duplicateOf);
|
|
189
|
+
if (!state.batch.items.some((item) => item.id === id && item.status === "error"))
|
|
190
|
+
pendingFiles.delete(id);
|
|
191
|
+
clearError();
|
|
192
|
+
render();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
showError(
|
|
195
|
+
`${errorMessage(error)} Delete and choose the image again if its source is unavailable.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function remove(id) {
|
|
201
|
+
await mutate(`/api/items/${id}?revision=${state.batch.revision}`, { method: "DELETE" });
|
|
202
|
+
pendingFiles.delete(id);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function reorder(ids) {
|
|
206
|
+
if (ids.every((id, index) => state.batch.items[index]?.id === id)) return;
|
|
207
|
+
await mutate("/api/order", { method: "PUT", json: { revision: state.batch.revision, ids } });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function clearAll() {
|
|
211
|
+
await mutate("/api/clear", { method: "POST", json: { revision: state.batch.revision } });
|
|
212
|
+
pendingFiles.clear();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function mutate(path, options) {
|
|
216
|
+
try {
|
|
217
|
+
applyState(await request(path, options));
|
|
218
|
+
clearError();
|
|
219
|
+
render();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
showError(errorMessage(error));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function render() {
|
|
226
|
+
if (!state) return;
|
|
227
|
+
document.title = `${state.projectName} · Pi Image Drop`;
|
|
228
|
+
ui.session.textContent = state.sessionName
|
|
229
|
+
? `${state.projectName} · ${state.sessionName}`
|
|
230
|
+
: state.projectName;
|
|
231
|
+
ui.cwd.textContent = state.cwd;
|
|
232
|
+
const summary = summarizeBatch(state.batch);
|
|
233
|
+
ui.status.textContent = `${summary.label} · ${formatBytes(summary.bytes)}`;
|
|
234
|
+
const mutable = canMutate(state.batch);
|
|
235
|
+
ui.clear.disabled = !mutable || summary.total === 0;
|
|
236
|
+
ui.choose.disabled = !mutable;
|
|
237
|
+
ui.input.disabled = !mutable;
|
|
238
|
+
ui.drop.classList.toggle("disabled", !mutable);
|
|
239
|
+
ui.drop.setAttribute("aria-disabled", String(!mutable));
|
|
240
|
+
ui.grid.replaceChildren(...state.batch.items.map((item, index) => card(item, index, mutable)));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function card(item, index, mutable) {
|
|
244
|
+
const article = document.createElement("article");
|
|
245
|
+
article.className = `image-card status-${item.status}${item.id === highlightedId ? " duplicate-highlight" : ""}`;
|
|
246
|
+
article.dataset.id = item.id;
|
|
247
|
+
article.draggable = mutable;
|
|
248
|
+
article.tabIndex = 0;
|
|
249
|
+
article.setAttribute("aria-label", `${index + 1}. ${item.name}, ${item.status}`);
|
|
250
|
+
article.addEventListener("dragstart", () => {
|
|
251
|
+
draggedId = item.id;
|
|
252
|
+
});
|
|
253
|
+
article.addEventListener("dragover", (event) => {
|
|
254
|
+
if (mutable && draggedId) event.preventDefault();
|
|
255
|
+
});
|
|
256
|
+
article.addEventListener("drop", (event) => {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
if (draggedId) void reorder(moveItemBefore(ids(), draggedId, item.id));
|
|
259
|
+
draggedId = undefined;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const preview = element("div", "preview");
|
|
263
|
+
if (item.status === "ready") {
|
|
264
|
+
const image = document.createElement("img");
|
|
265
|
+
image.src = `/api/items/${item.id}/preview?revision=${state.batch.revision}`;
|
|
266
|
+
image.alt = `Preview of ${item.name}`;
|
|
267
|
+
image.loading = "lazy";
|
|
268
|
+
preview.append(image);
|
|
269
|
+
} else {
|
|
270
|
+
const placeholder = element("span", "placeholder", item.status === "error" ? "!" : "…");
|
|
271
|
+
placeholder.setAttribute("aria-hidden", "true");
|
|
272
|
+
preview.append(placeholder);
|
|
273
|
+
}
|
|
274
|
+
article.append(preview);
|
|
275
|
+
|
|
276
|
+
const body = element("div", "card-body");
|
|
277
|
+
body.append(element("h3", "", item.name));
|
|
278
|
+
const dimensions = item.width && item.height ? ` · ${item.width}×${item.height}` : "";
|
|
279
|
+
body.append(
|
|
280
|
+
element("p", "meta", `${index + 1} · ${formatBytes(item.size)}${dimensions} · ${item.status}`),
|
|
281
|
+
);
|
|
282
|
+
if (item.sourceFormat && item.sourceFormat !== item.outputFormat)
|
|
283
|
+
body.append(
|
|
284
|
+
element(
|
|
285
|
+
"p",
|
|
286
|
+
"conversion",
|
|
287
|
+
`${item.sourceFormat.toUpperCase()} → ${item.outputFormat.toUpperCase()}`,
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
for (const note of item.notes ?? []) body.append(element("p", "note", note));
|
|
291
|
+
if (item.error) body.append(element("p", "item-error", item.error));
|
|
292
|
+
article.append(body);
|
|
293
|
+
|
|
294
|
+
const actions = element("div", "card-actions");
|
|
295
|
+
actions.append(
|
|
296
|
+
button("Move backward", "←", !mutable || index === 0, () =>
|
|
297
|
+
reorder(moveItem(ids(), item.id, -1)),
|
|
298
|
+
),
|
|
299
|
+
button("Move forward", "→", !mutable || index === state.batch.items.length - 1, () =>
|
|
300
|
+
reorder(moveItem(ids(), item.id, 1)),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
if (item.status === "error")
|
|
304
|
+
actions.append(button("Retry", "Retry", !mutable, () => retry(item.id)));
|
|
305
|
+
actions.append(button("Delete", "Delete", !mutable, () => remove(item.id), "delete"));
|
|
306
|
+
article.append(actions);
|
|
307
|
+
return article;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function button(label, text, disabled, action, className = "") {
|
|
311
|
+
const result = element("button", className, text);
|
|
312
|
+
result.type = "button";
|
|
313
|
+
result.disabled = disabled;
|
|
314
|
+
result.setAttribute("aria-label", label);
|
|
315
|
+
result.addEventListener("click", () => void action());
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function request(path, options = {}) {
|
|
320
|
+
const headers = new Headers(options.headers);
|
|
321
|
+
headers.set("x-image-drop-client", clientId);
|
|
322
|
+
let body = options.body;
|
|
323
|
+
if (options.json !== undefined) {
|
|
324
|
+
headers.set("content-type", "application/json");
|
|
325
|
+
body = JSON.stringify(options.json);
|
|
326
|
+
}
|
|
327
|
+
const response = await fetch(path, { method: options.method, headers, body });
|
|
328
|
+
const data = (response.headers.get("content-type") ?? "").includes("application/json")
|
|
329
|
+
? await response.json()
|
|
330
|
+
: undefined;
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
throw new ApiError(
|
|
333
|
+
response.status,
|
|
334
|
+
data?.error ?? `Image Drop request failed (${response.status})`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return data;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
class ApiError extends Error {
|
|
341
|
+
constructor(status, message) {
|
|
342
|
+
super(message);
|
|
343
|
+
this.status = status;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function element(tag, className, text) {
|
|
348
|
+
const result = document.createElement(tag);
|
|
349
|
+
if (className) result.className = className;
|
|
350
|
+
if (text !== undefined) result.textContent = text;
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
function ids() {
|
|
354
|
+
return state.batch.items.map((item) => item.id);
|
|
355
|
+
}
|
|
356
|
+
function hasFiles(event) {
|
|
357
|
+
return [...(event.dataTransfer?.types ?? [])].includes("Files");
|
|
358
|
+
}
|
|
359
|
+
function showError(text) {
|
|
360
|
+
ui.error.textContent = text;
|
|
361
|
+
ui.error.hidden = false;
|
|
362
|
+
}
|
|
363
|
+
function clearError() {
|
|
364
|
+
ui.error.hidden = true;
|
|
365
|
+
ui.error.textContent = "";
|
|
366
|
+
}
|
|
367
|
+
function errorMessage(error) {
|
|
368
|
+
return error instanceof Error ? error.message : String(error);
|
|
369
|
+
}
|
|
370
|
+
function connectionFailure(title, text) {
|
|
371
|
+
ui.connectionTitle.textContent = title;
|
|
372
|
+
ui.connectionMessage.textContent = text;
|
|
373
|
+
ui.overlay.hidden = false;
|
|
374
|
+
}
|
|
375
|
+
function reconcileFiles() {
|
|
376
|
+
for (const id of pendingFiles.keys())
|
|
377
|
+
if (!state.batch.items.some((item) => item.id === id)) pendingFiles.delete(id);
|
|
378
|
+
}
|
|
379
|
+
function highlight(id) {
|
|
380
|
+
highlightedId = id;
|
|
381
|
+
render();
|
|
382
|
+
requestAnimationFrame(() => document.querySelector(`[data-id="${CSS.escape(id)}"]`)?.focus());
|
|
383
|
+
setTimeout(() => {
|
|
384
|
+
highlightedId = undefined;
|
|
385
|
+
document
|
|
386
|
+
.querySelector(`[data-id="${CSS.escape(id)}"]`)
|
|
387
|
+
?.classList.remove("duplicate-highlight");
|
|
388
|
+
}, 1800);
|
|
389
|
+
}
|
|
390
|
+
function applyState(next) {
|
|
391
|
+
state = preferNewestState(state, next);
|
|
392
|
+
}
|
|
393
|
+
async function mapConcurrent(values, limit, task) {
|
|
394
|
+
let cursor = 0;
|
|
395
|
+
await Promise.all(
|
|
396
|
+
Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
397
|
+
while (cursor < values.length) {
|
|
398
|
+
const value = values[cursor++];
|
|
399
|
+
await task(value);
|
|
400
|
+
}
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>Pi Image Drop</title>
|
|
8
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="page-header">
|
|
12
|
+
<div>
|
|
13
|
+
<p class="eyebrow">Pi local image staging</p>
|
|
14
|
+
<h1>Image Drop</h1>
|
|
15
|
+
<p id="session-label" class="session-label">Connecting to Pi…</p>
|
|
16
|
+
</div>
|
|
17
|
+
<details>
|
|
18
|
+
<summary>Details</summary>
|
|
19
|
+
<dl>
|
|
20
|
+
<dt>Working directory</dt>
|
|
21
|
+
<dd id="cwd">—</dd>
|
|
22
|
+
</dl>
|
|
23
|
+
</details>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<main>
|
|
27
|
+
<section id="drop-zone" class="drop-zone" aria-labelledby="drop-title">
|
|
28
|
+
<div class="drop-copy">
|
|
29
|
+
<span class="drop-icon" aria-hidden="true">🖼️</span>
|
|
30
|
+
<h2 id="drop-title">Paste or drop images anywhere</h2>
|
|
31
|
+
<p>Use Ctrl+V or Command+V, drag files here, or choose files.</p>
|
|
32
|
+
<button id="choose-files" class="primary" type="button">Choose images</button>
|
|
33
|
+
<input id="file-input" type="file" multiple accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/heic,image/heif,image/avif,.bmp,.tif,.tiff,.heic,.heif,.avif" hidden />
|
|
34
|
+
</div>
|
|
35
|
+
</section>
|
|
36
|
+
|
|
37
|
+
<section class="batch" aria-labelledby="batch-title">
|
|
38
|
+
<div class="batch-toolbar">
|
|
39
|
+
<div>
|
|
40
|
+
<h2 id="batch-title">Next Pi message</h2>
|
|
41
|
+
<p id="status" role="status" aria-live="polite">No images staged</p>
|
|
42
|
+
</div>
|
|
43
|
+
<button id="clear-all" class="danger-secondary" type="button" disabled>Clear all</button>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="error-banner" class="banner error" role="alert" hidden></div>
|
|
46
|
+
<div id="grid" class="image-grid" aria-live="polite"></div>
|
|
47
|
+
</section>
|
|
48
|
+
|
|
49
|
+
<p class="privacy">
|
|
50
|
+
Images stay in this Pi process until you submit a Pi message. They are then sent to your configured model provider with that message.
|
|
51
|
+
</p>
|
|
52
|
+
</main>
|
|
53
|
+
|
|
54
|
+
<div id="connection-overlay" class="connection-overlay" role="alert" hidden>
|
|
55
|
+
<div>
|
|
56
|
+
<h2 id="connection-title">Connection lost</h2>
|
|
57
|
+
<p id="connection-message">Run <code>/image-drop</code> in Pi for a new link.</p>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<dialog id="clear-dialog">
|
|
62
|
+
<form method="dialog">
|
|
63
|
+
<h2>Clear every staged image?</h2>
|
|
64
|
+
<p>This removes the current batch from Pi memory.</p>
|
|
65
|
+
<div class="dialog-actions">
|
|
66
|
+
<button type="submit" value="cancel">Cancel</button>
|
|
67
|
+
<button type="submit" class="danger" value="confirm">Clear all</button>
|
|
68
|
+
</div>
|
|
69
|
+
</form>
|
|
70
|
+
</dialog>
|
|
71
|
+
|
|
72
|
+
<script type="module" src="/app.js"></script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
package/src/web/state.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function summarizeBatch(batch) {
|
|
2
|
+
const counts = { ready: 0, uploading: 0, error: 0 };
|
|
3
|
+
for (const item of batch.items ?? []) {
|
|
4
|
+
if (item.status === "ready") counts.ready += 1;
|
|
5
|
+
else if (item.status === "error") counts.error += 1;
|
|
6
|
+
else counts.uploading += 1;
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
...counts,
|
|
10
|
+
total: (batch.items ?? []).length,
|
|
11
|
+
bytes: Number(batch.totalSourceBytes ?? 0),
|
|
12
|
+
label: statusLabel(batch.phase, counts, (batch.items ?? []).length),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function statusLabel(phase, counts, total) {
|
|
17
|
+
if (phase === "empty" || total === 0) return "No images staged";
|
|
18
|
+
if (phase === "reserved") return `${total} ${plural(total, "image")} queued with Pi`;
|
|
19
|
+
const parts = [`${counts.ready}/${total} ready`];
|
|
20
|
+
if (counts.uploading > 0) parts.push(`${counts.uploading} uploading`);
|
|
21
|
+
if (counts.error > 0) parts.push(`${counts.error} need attention`);
|
|
22
|
+
return parts.join(" · ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function moveItem(ids, id, direction) {
|
|
26
|
+
const from = ids.indexOf(id);
|
|
27
|
+
if (from === -1) return [...ids];
|
|
28
|
+
const to = Math.max(0, Math.min(ids.length - 1, from + direction));
|
|
29
|
+
if (to === from) return [...ids];
|
|
30
|
+
const next = [...ids];
|
|
31
|
+
next.splice(from, 1);
|
|
32
|
+
next.splice(to, 0, id);
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function moveItemBefore(ids, id, targetId) {
|
|
37
|
+
if (id === targetId || !ids.includes(id) || !ids.includes(targetId)) return [...ids];
|
|
38
|
+
const next = ids.filter((candidate) => candidate !== id);
|
|
39
|
+
next.splice(next.indexOf(targetId), 0, id);
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function canMutate(batch) {
|
|
44
|
+
return batch.phase !== "reserved" && batch.phase !== "closed";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function preferNewestState(current, next) {
|
|
48
|
+
if (!current || next.batch.revision >= current.batch.revision) return next;
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatBytes(value) {
|
|
53
|
+
const bytes = Math.max(0, Number(value) || 0);
|
|
54
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
55
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
|
56
|
+
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function plural(count, noun) {
|
|
60
|
+
return count === 1 ? noun : `${noun}s`;
|
|
61
|
+
}
|