@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.
@@ -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
+ }