@narumitw/pi-image-drop 0.28.0 → 0.30.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 +15 -4
- 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,615 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowLeftIcon,
|
|
3
|
+
ArrowRightIcon,
|
|
4
|
+
ChevronDownIcon,
|
|
5
|
+
Cross2Icon,
|
|
6
|
+
ExclamationTriangleIcon,
|
|
7
|
+
ImageIcon,
|
|
8
|
+
InfoCircledIcon,
|
|
9
|
+
ReloadIcon,
|
|
10
|
+
TrashIcon,
|
|
11
|
+
UploadIcon,
|
|
12
|
+
} from "@radix-ui/react-icons";
|
|
13
|
+
import {
|
|
14
|
+
Badge,
|
|
15
|
+
Box,
|
|
16
|
+
Button,
|
|
17
|
+
Callout,
|
|
18
|
+
Card,
|
|
19
|
+
Code,
|
|
20
|
+
Flex,
|
|
21
|
+
Heading,
|
|
22
|
+
IconButton,
|
|
23
|
+
Spinner,
|
|
24
|
+
Text,
|
|
25
|
+
} from "@radix-ui/themes";
|
|
26
|
+
import { AlertDialog, Collapsible, Dialog } from "radix-ui";
|
|
27
|
+
import { useRef, useState } from "react";
|
|
28
|
+
import { imageDropClient } from "./client.js";
|
|
29
|
+
import {
|
|
30
|
+
canMutate,
|
|
31
|
+
draftPresentation,
|
|
32
|
+
formatBytes,
|
|
33
|
+
summarizeBatch,
|
|
34
|
+
summarizeHistory,
|
|
35
|
+
visibleItemNotes,
|
|
36
|
+
} from "./state.js";
|
|
37
|
+
import type {
|
|
38
|
+
ConnectionFailure,
|
|
39
|
+
ImageDropState,
|
|
40
|
+
PublicBatchItem,
|
|
41
|
+
PublicHistoryItem,
|
|
42
|
+
} from "./types.js";
|
|
43
|
+
|
|
44
|
+
export const ACCEPTED_IMAGES =
|
|
45
|
+
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/heic,image/heif,image/avif,.bmp,.tif,.tiff,.heic,.heif,.avif";
|
|
46
|
+
|
|
47
|
+
export interface PreviewSelection {
|
|
48
|
+
collection: "items" | "history";
|
|
49
|
+
item: PublicBatchItem | PublicHistoryItem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function SessionHeader({ state }: { state?: ImageDropState }) {
|
|
53
|
+
return (
|
|
54
|
+
<header className="page-header">
|
|
55
|
+
<Box className="session-identity">
|
|
56
|
+
<Text as="p" className="eyebrow" color="jade" size="1" weight="bold">
|
|
57
|
+
Pi local image staging
|
|
58
|
+
</Text>
|
|
59
|
+
<Heading as="h1" size="8">
|
|
60
|
+
Image Drop
|
|
61
|
+
</Heading>
|
|
62
|
+
<Text as="p" className="session-label" color="gray" size="2">
|
|
63
|
+
{state
|
|
64
|
+
? state.sessionName
|
|
65
|
+
? `${state.projectName} · ${state.sessionName}`
|
|
66
|
+
: state.projectName
|
|
67
|
+
: "Connecting to Pi…"}
|
|
68
|
+
</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
<Collapsible.Root className="session-details">
|
|
71
|
+
<Collapsible.Trigger asChild>
|
|
72
|
+
<Button color="gray" highContrast type="button" variant="ghost">
|
|
73
|
+
<InfoCircledIcon /> Session details <ChevronDownIcon className="disclosure-icon" />
|
|
74
|
+
</Button>
|
|
75
|
+
</Collapsible.Trigger>
|
|
76
|
+
<Collapsible.Content className="session-details-content">
|
|
77
|
+
<Text as="div" color="gray" size="1" weight="bold">
|
|
78
|
+
Working directory
|
|
79
|
+
</Text>
|
|
80
|
+
<Code id="cwd" size="1" variant="ghost">
|
|
81
|
+
{state?.cwd ?? "—"}
|
|
82
|
+
</Code>
|
|
83
|
+
</Collapsible.Content>
|
|
84
|
+
</Collapsible.Root>
|
|
85
|
+
</header>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function LoadingState() {
|
|
90
|
+
return (
|
|
91
|
+
<Flex align="center" className="loading-state" gap="2" justify="center" role="status">
|
|
92
|
+
<Spinner />
|
|
93
|
+
<Text color="gray">Connecting to this Pi session…</Text>
|
|
94
|
+
</Flex>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function DropZone({
|
|
99
|
+
dragActive,
|
|
100
|
+
mutable,
|
|
101
|
+
onFiles,
|
|
102
|
+
}: {
|
|
103
|
+
dragActive: boolean;
|
|
104
|
+
mutable: boolean;
|
|
105
|
+
onFiles: (files: FileList | null) => void;
|
|
106
|
+
}) {
|
|
107
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
108
|
+
return (
|
|
109
|
+
<section
|
|
110
|
+
aria-disabled={!mutable}
|
|
111
|
+
aria-labelledby="drop-title"
|
|
112
|
+
className={`drop-zone ${dragActive ? "drag-active" : ""} ${mutable ? "" : "disabled"}`}
|
|
113
|
+
id="drop-zone"
|
|
114
|
+
>
|
|
115
|
+
<div className="drop-copy">
|
|
116
|
+
<ImageIcon aria-hidden="true" className="drop-icon" />
|
|
117
|
+
<Box>
|
|
118
|
+
<Heading as="h2" id="drop-title" size="5">
|
|
119
|
+
Add images
|
|
120
|
+
</Heading>
|
|
121
|
+
<Text as="p" color="gray" size="2">
|
|
122
|
+
Paste anywhere, drop files here, or choose images.
|
|
123
|
+
</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
<Button disabled={!mutable} onClick={() => inputRef.current?.click()} type="button">
|
|
126
|
+
<UploadIcon /> Choose images
|
|
127
|
+
</Button>
|
|
128
|
+
<input
|
|
129
|
+
accept={ACCEPTED_IMAGES}
|
|
130
|
+
disabled={!mutable}
|
|
131
|
+
hidden
|
|
132
|
+
multiple
|
|
133
|
+
onChange={(event) => {
|
|
134
|
+
onFiles(event.target.files);
|
|
135
|
+
event.target.value = "";
|
|
136
|
+
}}
|
|
137
|
+
ref={inputRef}
|
|
138
|
+
type="file"
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
</section>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function DraftSection({
|
|
146
|
+
error,
|
|
147
|
+
highlightedId,
|
|
148
|
+
onPreview,
|
|
149
|
+
state,
|
|
150
|
+
}: {
|
|
151
|
+
error: string;
|
|
152
|
+
highlightedId?: string;
|
|
153
|
+
onPreview: (selection: PreviewSelection, trigger: HTMLElement) => void;
|
|
154
|
+
state: ImageDropState;
|
|
155
|
+
}) {
|
|
156
|
+
const summary = summarizeBatch(state.batch);
|
|
157
|
+
const presentation = draftPresentation(state.batch);
|
|
158
|
+
const mutable = canMutate(state.batch);
|
|
159
|
+
return (
|
|
160
|
+
<section aria-labelledby="batch-title" className="batch draft">
|
|
161
|
+
<div className="batch-toolbar">
|
|
162
|
+
<Box>
|
|
163
|
+
<Heading as="h2" id="batch-title" size="5">
|
|
164
|
+
Ready for next message
|
|
165
|
+
</Heading>
|
|
166
|
+
{presentation.status && (
|
|
167
|
+
<Text as="p" color="gray" id="status" size="2">
|
|
168
|
+
{presentation.status}
|
|
169
|
+
</Text>
|
|
170
|
+
)}
|
|
171
|
+
<Text as="p" className="next-step" id="next-step" role="status" size="2">
|
|
172
|
+
{presentation.guidance}
|
|
173
|
+
</Text>
|
|
174
|
+
</Box>
|
|
175
|
+
{summary.total > 0 && <ClearDraftDialog disabled={!mutable} />}
|
|
176
|
+
</div>
|
|
177
|
+
{error && (
|
|
178
|
+
<Callout.Root color="red" id="error-banner" role="alert" size="1">
|
|
179
|
+
<Callout.Icon>
|
|
180
|
+
<ExclamationTriangleIcon />
|
|
181
|
+
</Callout.Icon>
|
|
182
|
+
<Callout.Text>{error}</Callout.Text>
|
|
183
|
+
</Callout.Root>
|
|
184
|
+
)}
|
|
185
|
+
<DraftGrid
|
|
186
|
+
highlightedId={highlightedId}
|
|
187
|
+
mutable={mutable}
|
|
188
|
+
onPreview={onPreview}
|
|
189
|
+
state={state}
|
|
190
|
+
/>
|
|
191
|
+
</section>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function DraftGrid({
|
|
196
|
+
highlightedId,
|
|
197
|
+
mutable,
|
|
198
|
+
onPreview,
|
|
199
|
+
state,
|
|
200
|
+
}: {
|
|
201
|
+
highlightedId?: string;
|
|
202
|
+
mutable: boolean;
|
|
203
|
+
onPreview: (selection: PreviewSelection, trigger: HTMLElement) => void;
|
|
204
|
+
state: ImageDropState;
|
|
205
|
+
}) {
|
|
206
|
+
const [draggedId, setDraggedId] = useState("");
|
|
207
|
+
if (state.batch.items.length === 0) return null;
|
|
208
|
+
return (
|
|
209
|
+
<ol aria-live="polite" className="image-grid" id="grid">
|
|
210
|
+
{state.batch.items.map((item, index) => (
|
|
211
|
+
<Card asChild key={item.id} size="1">
|
|
212
|
+
<li
|
|
213
|
+
aria-label={`${index + 1}. ${item.name}, ${item.status}`}
|
|
214
|
+
className={`image-card status-${item.status} ${item.id === highlightedId ? "duplicate-highlight" : ""}`}
|
|
215
|
+
draggable={mutable}
|
|
216
|
+
onDragEnd={() => setDraggedId("")}
|
|
217
|
+
onDragOver={(event) => {
|
|
218
|
+
if (!mutable || !draggedId) return;
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
}}
|
|
221
|
+
onDragStart={() => setDraggedId(item.id)}
|
|
222
|
+
onDrop={(event) => {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
if (draggedId) void imageDropClient.moveBefore(draggedId, item.id);
|
|
225
|
+
setDraggedId("");
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<DraftPreview item={item} onPreview={onPreview} revision={state.batch.revision} />
|
|
229
|
+
<ImageDetails index={index} item={item} />
|
|
230
|
+
<Flex className="card-actions" gap="1" wrap="wrap">
|
|
231
|
+
<IconButton
|
|
232
|
+
aria-label="Move backward"
|
|
233
|
+
disabled={!mutable || index === 0}
|
|
234
|
+
onClick={() => void imageDropClient.move(item.id, -1)}
|
|
235
|
+
title="Move backward"
|
|
236
|
+
type="button"
|
|
237
|
+
variant="soft"
|
|
238
|
+
>
|
|
239
|
+
<ArrowLeftIcon />
|
|
240
|
+
</IconButton>
|
|
241
|
+
<IconButton
|
|
242
|
+
aria-label="Move forward"
|
|
243
|
+
disabled={!mutable || index === state.batch.items.length - 1}
|
|
244
|
+
onClick={() => void imageDropClient.move(item.id, 1)}
|
|
245
|
+
title="Move forward"
|
|
246
|
+
type="button"
|
|
247
|
+
variant="soft"
|
|
248
|
+
>
|
|
249
|
+
<ArrowRightIcon />
|
|
250
|
+
</IconButton>
|
|
251
|
+
{item.status === "error" && (
|
|
252
|
+
<Button
|
|
253
|
+
disabled={!mutable}
|
|
254
|
+
onClick={() => void imageDropClient.retry(item.id)}
|
|
255
|
+
type="button"
|
|
256
|
+
variant="soft"
|
|
257
|
+
>
|
|
258
|
+
<ReloadIcon /> Retry
|
|
259
|
+
</Button>
|
|
260
|
+
)}
|
|
261
|
+
<Button
|
|
262
|
+
color="red"
|
|
263
|
+
disabled={!mutable}
|
|
264
|
+
onClick={() => void imageDropClient.remove(item.id)}
|
|
265
|
+
type="button"
|
|
266
|
+
variant="ghost"
|
|
267
|
+
>
|
|
268
|
+
<TrashIcon /> Delete
|
|
269
|
+
</Button>
|
|
270
|
+
</Flex>
|
|
271
|
+
</li>
|
|
272
|
+
</Card>
|
|
273
|
+
))}
|
|
274
|
+
</ol>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function DraftPreview({
|
|
279
|
+
item,
|
|
280
|
+
onPreview,
|
|
281
|
+
revision,
|
|
282
|
+
}: {
|
|
283
|
+
item: PublicBatchItem;
|
|
284
|
+
onPreview: (selection: PreviewSelection, trigger: HTMLElement) => void;
|
|
285
|
+
revision: number;
|
|
286
|
+
}) {
|
|
287
|
+
if (item.status !== "ready") {
|
|
288
|
+
return (
|
|
289
|
+
<div className="preview">
|
|
290
|
+
<span aria-hidden="true" className="placeholder">
|
|
291
|
+
{item.status === "error" ? "!" : "…"}
|
|
292
|
+
</span>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return (
|
|
297
|
+
<IconButton
|
|
298
|
+
aria-label={`Enlarge preview of ${item.name}`}
|
|
299
|
+
className="preview preview-button"
|
|
300
|
+
data-id={item.id}
|
|
301
|
+
onClick={(event) => onPreview({ collection: "items", item }, event.currentTarget)}
|
|
302
|
+
type="button"
|
|
303
|
+
variant="ghost"
|
|
304
|
+
>
|
|
305
|
+
<img
|
|
306
|
+
alt={`Preview of ${item.name}`}
|
|
307
|
+
loading="lazy"
|
|
308
|
+
src={`/api/items/${item.id}/preview?revision=${revision}`}
|
|
309
|
+
/>
|
|
310
|
+
</IconButton>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function ImageDetails({ index, item }: { index: number; item: PublicBatchItem }) {
|
|
315
|
+
const dimensions = item.width && item.height ? ` · ${item.width}×${item.height}` : "";
|
|
316
|
+
return (
|
|
317
|
+
<Box className="card-body">
|
|
318
|
+
<Heading as="h3" size="3" title={item.name}>
|
|
319
|
+
{item.name}
|
|
320
|
+
</Heading>
|
|
321
|
+
<Flex align="center" gap="2" mt="1" wrap="wrap">
|
|
322
|
+
<Text color="gray" size="1">
|
|
323
|
+
{`${index + 1} · ${formatBytes(item.size)}${dimensions}`}
|
|
324
|
+
</Text>
|
|
325
|
+
<Badge color={item.status === "error" ? "red" : item.status === "ready" ? "jade" : "gray"}>
|
|
326
|
+
{item.status}
|
|
327
|
+
</Badge>
|
|
328
|
+
</Flex>
|
|
329
|
+
{item.sourceFormat && item.sourceFormat !== item.outputFormat && (
|
|
330
|
+
<Text as="p" className="conversion" size="1" weight="bold">
|
|
331
|
+
{`${item.sourceFormat.toUpperCase()} → ${item.outputFormat?.toUpperCase()}`}
|
|
332
|
+
</Text>
|
|
333
|
+
)}
|
|
334
|
+
{visibleItemNotes(item.notes).map((note) => (
|
|
335
|
+
<Text as="p" className="note" color="gray" key={note} size="1">
|
|
336
|
+
{note}
|
|
337
|
+
</Text>
|
|
338
|
+
))}
|
|
339
|
+
{item.error && (
|
|
340
|
+
<Text as="p" className="item-error" color="red" size="1">
|
|
341
|
+
{item.error}
|
|
342
|
+
</Text>
|
|
343
|
+
)}
|
|
344
|
+
</Box>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function HistorySection({
|
|
349
|
+
onPreview,
|
|
350
|
+
state,
|
|
351
|
+
}: {
|
|
352
|
+
onPreview: (selection: PreviewSelection, trigger: HTMLElement) => void;
|
|
353
|
+
state: ImageDropState;
|
|
354
|
+
}) {
|
|
355
|
+
const history = summarizeHistory(state.history);
|
|
356
|
+
const mutable = canMutate(state.batch);
|
|
357
|
+
return (
|
|
358
|
+
<section aria-labelledby="history-title" className="history">
|
|
359
|
+
<header className="history-toolbar">
|
|
360
|
+
<Box className="history-summary">
|
|
361
|
+
<Heading as="h2" id="history-title" size="4">
|
|
362
|
+
Previously sent
|
|
363
|
+
</Heading>
|
|
364
|
+
<Text as="p" color="gray" id="history-status" role="status" size="2">
|
|
365
|
+
{history.label}
|
|
366
|
+
</Text>
|
|
367
|
+
<Text as="p" className="history-note" color="gray" size="2">
|
|
368
|
+
<span id="history-retention">{history.usage}</span>. Images here are not attached again
|
|
369
|
+
unless you choose <strong>Add again</strong>. The oldest are removed at the configured
|
|
370
|
+
memory limit, and all are cleared when this Pi session ends.
|
|
371
|
+
</Text>
|
|
372
|
+
</Box>
|
|
373
|
+
{history.total > 0 && <ClearHistoryDialog />}
|
|
374
|
+
</header>
|
|
375
|
+
{history.total > 0 && (
|
|
376
|
+
<ol aria-live="polite" className="image-grid" id="history-grid">
|
|
377
|
+
{state.history.items.map((item, index) => (
|
|
378
|
+
<Card asChild key={item.id} size="1">
|
|
379
|
+
<li
|
|
380
|
+
aria-label={`${index + 1}. ${item.name}, sent this session`}
|
|
381
|
+
className="image-card history-card"
|
|
382
|
+
>
|
|
383
|
+
<IconButton
|
|
384
|
+
aria-label={`Enlarge preview of ${item.name}`}
|
|
385
|
+
className="preview preview-button"
|
|
386
|
+
onClick={(event) =>
|
|
387
|
+
onPreview({ collection: "history", item }, event.currentTarget)
|
|
388
|
+
}
|
|
389
|
+
type="button"
|
|
390
|
+
variant="ghost"
|
|
391
|
+
>
|
|
392
|
+
<img
|
|
393
|
+
alt={`Preview of sent ${item.name}`}
|
|
394
|
+
loading="lazy"
|
|
395
|
+
src={`/api/history/${item.id}/preview?revision=${state.batch.revision}`}
|
|
396
|
+
/>
|
|
397
|
+
</IconButton>
|
|
398
|
+
<Box className="card-body">
|
|
399
|
+
<Heading as="h3" size="3" title={item.name}>
|
|
400
|
+
{item.name}
|
|
401
|
+
</Heading>
|
|
402
|
+
<Text as="p" color="gray" size="1">
|
|
403
|
+
{`${index + 1} · ${formatBytes(item.size)} · ${item.width}×${item.height}`}
|
|
404
|
+
</Text>
|
|
405
|
+
{visibleItemNotes(item.notes).map((note) => (
|
|
406
|
+
<Text as="p" color="gray" key={note} size="1">
|
|
407
|
+
{note}
|
|
408
|
+
</Text>
|
|
409
|
+
))}
|
|
410
|
+
</Box>
|
|
411
|
+
<Flex className="card-actions" gap="1" wrap="wrap">
|
|
412
|
+
<Button
|
|
413
|
+
disabled={!mutable}
|
|
414
|
+
onClick={() => void imageDropClient.restageHistory(item.id)}
|
|
415
|
+
type="button"
|
|
416
|
+
variant="soft"
|
|
417
|
+
>
|
|
418
|
+
<ImageIcon /> Add again
|
|
419
|
+
</Button>
|
|
420
|
+
<Button
|
|
421
|
+
color="red"
|
|
422
|
+
onClick={() => void imageDropClient.deleteHistory(item.id)}
|
|
423
|
+
type="button"
|
|
424
|
+
variant="ghost"
|
|
425
|
+
>
|
|
426
|
+
<TrashIcon /> Delete
|
|
427
|
+
</Button>
|
|
428
|
+
</Flex>
|
|
429
|
+
</li>
|
|
430
|
+
</Card>
|
|
431
|
+
))}
|
|
432
|
+
</ol>
|
|
433
|
+
)}
|
|
434
|
+
</section>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function ClearDraftDialog({ disabled }: { disabled: boolean }) {
|
|
439
|
+
return (
|
|
440
|
+
<AlertDialog.Root>
|
|
441
|
+
<AlertDialog.Trigger asChild>
|
|
442
|
+
<Button color="red" disabled={disabled} type="button" variant="ghost">
|
|
443
|
+
<TrashIcon /> Clear all
|
|
444
|
+
</Button>
|
|
445
|
+
</AlertDialog.Trigger>
|
|
446
|
+
<AlertDialog.Portal>
|
|
447
|
+
<AlertDialog.Overlay className="dialog-overlay" />
|
|
448
|
+
<AlertDialog.Content className="dialog-content">
|
|
449
|
+
<AlertDialog.Title asChild>
|
|
450
|
+
<Heading as="h2" size="5">
|
|
451
|
+
Clear every staged image?
|
|
452
|
+
</Heading>
|
|
453
|
+
</AlertDialog.Title>
|
|
454
|
+
<AlertDialog.Description asChild>
|
|
455
|
+
<Text as="p" color="gray" size="2">
|
|
456
|
+
This removes the current batch from Pi memory.
|
|
457
|
+
</Text>
|
|
458
|
+
</AlertDialog.Description>
|
|
459
|
+
<Flex gap="3" justify="end">
|
|
460
|
+
<AlertDialog.Cancel asChild>
|
|
461
|
+
<Button color="gray" type="button" variant="soft">
|
|
462
|
+
Cancel
|
|
463
|
+
</Button>
|
|
464
|
+
</AlertDialog.Cancel>
|
|
465
|
+
<AlertDialog.Action asChild>
|
|
466
|
+
<Button color="red" onClick={() => void imageDropClient.clearAll()} type="button">
|
|
467
|
+
<TrashIcon /> Clear all
|
|
468
|
+
</Button>
|
|
469
|
+
</AlertDialog.Action>
|
|
470
|
+
</Flex>
|
|
471
|
+
</AlertDialog.Content>
|
|
472
|
+
</AlertDialog.Portal>
|
|
473
|
+
</AlertDialog.Root>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function ClearHistoryDialog() {
|
|
478
|
+
return (
|
|
479
|
+
<AlertDialog.Root>
|
|
480
|
+
<AlertDialog.Trigger asChild>
|
|
481
|
+
<Button color="red" type="button" variant="ghost">
|
|
482
|
+
<TrashIcon /> Clear history
|
|
483
|
+
</Button>
|
|
484
|
+
</AlertDialog.Trigger>
|
|
485
|
+
<AlertDialog.Portal>
|
|
486
|
+
<AlertDialog.Overlay className="dialog-overlay" />
|
|
487
|
+
<AlertDialog.Content className="dialog-content">
|
|
488
|
+
<AlertDialog.Title asChild>
|
|
489
|
+
<Heading as="h2" size="5">
|
|
490
|
+
Clear sent image history?
|
|
491
|
+
</Heading>
|
|
492
|
+
</AlertDialog.Title>
|
|
493
|
+
<AlertDialog.Description asChild>
|
|
494
|
+
<Text as="p" color="gray" size="2">
|
|
495
|
+
This releases every retained image from this Pi session. Images already sent to Pi or
|
|
496
|
+
a model provider are unaffected.
|
|
497
|
+
</Text>
|
|
498
|
+
</AlertDialog.Description>
|
|
499
|
+
<Flex gap="3" justify="end">
|
|
500
|
+
<AlertDialog.Cancel asChild>
|
|
501
|
+
<Button color="gray" type="button" variant="soft">
|
|
502
|
+
Cancel
|
|
503
|
+
</Button>
|
|
504
|
+
</AlertDialog.Cancel>
|
|
505
|
+
<AlertDialog.Action asChild>
|
|
506
|
+
<Button color="red" onClick={() => void imageDropClient.clearHistory()} type="button">
|
|
507
|
+
<TrashIcon /> Clear history
|
|
508
|
+
</Button>
|
|
509
|
+
</AlertDialog.Action>
|
|
510
|
+
</Flex>
|
|
511
|
+
</AlertDialog.Content>
|
|
512
|
+
</AlertDialog.Portal>
|
|
513
|
+
</AlertDialog.Root>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function ImagePreviewDialog({
|
|
518
|
+
onOpenChange,
|
|
519
|
+
open,
|
|
520
|
+
revision,
|
|
521
|
+
selection,
|
|
522
|
+
}: {
|
|
523
|
+
onOpenChange: (open: boolean) => void;
|
|
524
|
+
open: boolean;
|
|
525
|
+
revision: number;
|
|
526
|
+
selection?: PreviewSelection;
|
|
527
|
+
}) {
|
|
528
|
+
const item = selection?.item;
|
|
529
|
+
return (
|
|
530
|
+
<Dialog.Root onOpenChange={onOpenChange} open={open}>
|
|
531
|
+
<Dialog.Portal>
|
|
532
|
+
<Dialog.Overlay className="dialog-overlay preview-overlay" />
|
|
533
|
+
<Dialog.Content className="preview-content">
|
|
534
|
+
<Flex align="center" className="preview-header" justify="between">
|
|
535
|
+
<Dialog.Title asChild>
|
|
536
|
+
<Heading className="preview-title" size="3">
|
|
537
|
+
{item?.name ?? "Image preview"}
|
|
538
|
+
</Heading>
|
|
539
|
+
</Dialog.Title>
|
|
540
|
+
<Dialog.Close asChild>
|
|
541
|
+
<IconButton
|
|
542
|
+
aria-label="Close enlarged image"
|
|
543
|
+
color="gray"
|
|
544
|
+
type="button"
|
|
545
|
+
variant="soft"
|
|
546
|
+
>
|
|
547
|
+
<Cross2Icon />
|
|
548
|
+
</IconButton>
|
|
549
|
+
</Dialog.Close>
|
|
550
|
+
</Flex>
|
|
551
|
+
{selection && item && (
|
|
552
|
+
<Dialog.Close asChild>
|
|
553
|
+
<button
|
|
554
|
+
aria-label="Close enlarged image"
|
|
555
|
+
className="image-preview-dismiss"
|
|
556
|
+
type="button"
|
|
557
|
+
>
|
|
558
|
+
<img
|
|
559
|
+
alt={`Enlarged preview of ${item.name}`}
|
|
560
|
+
src={`/api/${selection.collection}/${item.id}/preview?revision=${revision}`}
|
|
561
|
+
/>
|
|
562
|
+
</button>
|
|
563
|
+
</Dialog.Close>
|
|
564
|
+
)}
|
|
565
|
+
</Dialog.Content>
|
|
566
|
+
</Dialog.Portal>
|
|
567
|
+
</Dialog.Root>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function PrivacyNotice() {
|
|
572
|
+
return (
|
|
573
|
+
<Text as="p" className="privacy" color="gray" size="2">
|
|
574
|
+
Draft and previously sent images stay in this Pi process until removed, evicted at the
|
|
575
|
+
retention limit, or the session ends. Sending a Pi message sends its staged images to your
|
|
576
|
+
configured model provider; deleting local history does not retract images already sent.
|
|
577
|
+
</Text>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function ConnectionBlocker({ failure }: { failure?: ConnectionFailure }) {
|
|
582
|
+
return (
|
|
583
|
+
<Dialog.Root open={Boolean(failure)}>
|
|
584
|
+
<Dialog.Portal>
|
|
585
|
+
<Dialog.Overlay className="connection-overlay" />
|
|
586
|
+
<Dialog.Content
|
|
587
|
+
className="connection-card"
|
|
588
|
+
onEscapeKeyDown={(event) => event.preventDefault()}
|
|
589
|
+
onPointerDownOutside={(event) => event.preventDefault()}
|
|
590
|
+
>
|
|
591
|
+
<Flex direction="column" gap="2">
|
|
592
|
+
<Dialog.Title asChild>
|
|
593
|
+
<Heading as="h2" size="5">
|
|
594
|
+
{failure?.title ?? "Connection lost"}
|
|
595
|
+
</Heading>
|
|
596
|
+
</Dialog.Title>
|
|
597
|
+
<Dialog.Description asChild>
|
|
598
|
+
<Text color="gray">
|
|
599
|
+
{failure?.message ?? "Run /image-drop in Pi for a new link."}
|
|
600
|
+
</Text>
|
|
601
|
+
</Dialog.Description>
|
|
602
|
+
</Flex>
|
|
603
|
+
</Dialog.Content>
|
|
604
|
+
</Dialog.Portal>
|
|
605
|
+
</Dialog.Root>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function MetadataNotice() {
|
|
610
|
+
return (
|
|
611
|
+
<Text as="p" className="collection-note" color="gray" size="2">
|
|
612
|
+
Sensitive image metadata removed from processed images.
|
|
613
|
+
</Text>
|
|
614
|
+
);
|
|
615
|
+
}
|