@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.
@@ -0,0 +1,149 @@
1
+ import type { BatchPhase, ItemStatus, PublicBatchState, PublicHistoryState } from "./types.js";
2
+
3
+ interface BatchLike {
4
+ phase: BatchPhase | string;
5
+ items?: Array<{ status?: ItemStatus | string }>;
6
+ totalSourceBytes?: number;
7
+ }
8
+
9
+ interface HistoryLike {
10
+ items?: unknown[];
11
+ totalBytes?: number;
12
+ maxImages?: number;
13
+ maxBytes?: number;
14
+ }
15
+
16
+ export function summarizeBatch(batch: BatchLike) {
17
+ const counts = { ready: 0, uploading: 0, error: 0 };
18
+ for (const item of batch.items ?? []) {
19
+ if (item.status === "ready") counts.ready += 1;
20
+ else if (item.status === "error") counts.error += 1;
21
+ else counts.uploading += 1;
22
+ }
23
+ return {
24
+ ...counts,
25
+ total: (batch.items ?? []).length,
26
+ bytes: Number(batch.totalSourceBytes ?? 0),
27
+ label: statusLabel(batch.phase, counts, (batch.items ?? []).length),
28
+ };
29
+ }
30
+
31
+ export function summarizeHistory(history?: HistoryLike) {
32
+ const total = (history?.items ?? []).length;
33
+ const bytes = Number(history?.totalBytes ?? 0);
34
+ const maxImages = Number(history?.maxImages ?? 0);
35
+ const maxBytes = Number(history?.maxBytes ?? 0);
36
+ return {
37
+ total,
38
+ bytes,
39
+ maxImages,
40
+ maxBytes,
41
+ label:
42
+ total === 0
43
+ ? "No images sent yet"
44
+ : `${total} ${plural(total, "image")} · ${formatBytes(bytes)}`,
45
+ usage: `${total}/${maxImages} images · ${formatBytes(bytes)} of ${formatBytes(maxBytes)}`,
46
+ };
47
+ }
48
+
49
+ const SHARED_METADATA_NOTE = "Sensitive image metadata removed";
50
+
51
+ export function draftPresentation(batch: BatchLike) {
52
+ const summary = summarizeBatch(batch);
53
+ return {
54
+ status: summary.total === 0 ? "" : `${summary.label} · ${formatBytes(summary.bytes)}`,
55
+ guidance: draftGuidance(batch),
56
+ };
57
+ }
58
+
59
+ export function draftGuidance(batch: BatchLike): string {
60
+ const summary = summarizeBatch(batch);
61
+ if (batch.phase === "closed") return "This Pi session is no longer accepting images.";
62
+ if (batch.phase === "reserved") {
63
+ return "Queued with Pi. These images will be attached when Pi sends this message.";
64
+ }
65
+ if (summary.total === 0) return "Choose images to add them to your next Pi message.";
66
+ if (summary.error > 0) {
67
+ return `Fix or delete ${summary.error} ${plural(summary.error, "image")} that ${summary.error === 1 ? "needs" : "need"} attention before sending from Pi.`;
68
+ }
69
+ if (summary.uploading > 0) {
70
+ return `Wait for ${summary.uploading} ${plural(summary.uploading, "image")} to finish processing before sending from Pi.`;
71
+ }
72
+ return `Return to Pi and send a non-empty message. ${summary.ready} ready ${plural(summary.ready, "image")} will be attached automatically.`;
73
+ }
74
+
75
+ export function statusLabel(
76
+ phase: BatchPhase | string,
77
+ counts: { ready: number; uploading: number; error: number },
78
+ total: number,
79
+ ): string {
80
+ if (phase === "empty" || total === 0) return "No images staged";
81
+ if (phase === "reserved") return `${total} ${plural(total, "image")} queued with Pi`;
82
+ const parts = [`${counts.ready}/${total} ready`];
83
+ if (counts.uploading > 0) parts.push(`${counts.uploading} uploading`);
84
+ if (counts.error > 0) parts.push(`${counts.error} need attention`);
85
+ return parts.join(" · ");
86
+ }
87
+
88
+ export function visibleItemNotes(notes: unknown): string[] {
89
+ return Array.isArray(notes)
90
+ ? notes.filter(
91
+ (note): note is string => typeof note === "string" && note !== SHARED_METADATA_NOTE,
92
+ )
93
+ : [];
94
+ }
95
+
96
+ export function moveItem(ids: readonly string[], id: string, direction: number): string[] {
97
+ const from = ids.indexOf(id);
98
+ if (from === -1) return [...ids];
99
+ const to = Math.max(0, Math.min(ids.length - 1, from + direction));
100
+ if (to === from) return [...ids];
101
+ const next = [...ids];
102
+ next.splice(from, 1);
103
+ next.splice(to, 0, id);
104
+ return next;
105
+ }
106
+
107
+ export function moveItemBefore(ids: readonly string[], id: string, targetId: string): string[] {
108
+ if (id === targetId || !ids.includes(id) || !ids.includes(targetId)) return [...ids];
109
+ const next = ids.filter((candidate) => candidate !== id);
110
+ next.splice(next.indexOf(targetId), 0, id);
111
+ return next;
112
+ }
113
+
114
+ export function canMutate(batch: Pick<PublicBatchState, "phase"> | { phase: string }): boolean {
115
+ return batch.phase !== "reserved" && batch.phase !== "closed";
116
+ }
117
+
118
+ export function preferNewestState<T extends { batch: { revision: number } }>(
119
+ current: T | undefined,
120
+ next: T,
121
+ ): T {
122
+ if (!current || next.batch.revision >= current.batch.revision) return next;
123
+ return current;
124
+ }
125
+
126
+ export async function attemptMutation<T>(
127
+ operation: () => Promise<T>,
128
+ ): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
129
+ try {
130
+ return { ok: true, value: await operation() };
131
+ } catch (error) {
132
+ return { ok: false, error };
133
+ }
134
+ }
135
+
136
+ export function formatBytes(value: number): string {
137
+ const bytes = Math.max(0, Number(value) || 0);
138
+ if (bytes < 1024) return `${bytes} B`;
139
+ if (bytes < 1024 * 1024) {
140
+ return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
141
+ }
142
+ return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
143
+ }
144
+
145
+ function plural(count: number, noun: string): string {
146
+ return count === 1 ? noun : `${noun}s`;
147
+ }
148
+
149
+ export type { PublicBatchState, PublicHistoryState };
@@ -0,0 +1,572 @@
1
+ @import "@radix-ui/themes/styles.css";
2
+ @import "@radix-ui/colors/jade.css";
3
+ @import "@radix-ui/colors/jade-dark.css";
4
+ @import "@radix-ui/colors/red.css";
5
+ @import "@radix-ui/colors/red-dark.css";
6
+
7
+ :root {
8
+ color-scheme: light dark;
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ html {
16
+ min-width: 280px;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ min-height: 100vh;
22
+ background: var(--color-background);
23
+ }
24
+
25
+ .radix-themes {
26
+ min-height: 100vh;
27
+ --image-drop-shadow: 0 10px 30px color-mix(in srgb, var(--gray-12) 10%, transparent);
28
+ }
29
+
30
+ button,
31
+ label,
32
+ [data-radix-collection-item] {
33
+ touch-action: manipulation;
34
+ }
35
+
36
+ .rt-BaseButton,
37
+ .rt-IconButton,
38
+ .image-preview-dismiss {
39
+ min-height: 44px;
40
+ }
41
+
42
+ .rt-IconButton {
43
+ min-width: 44px;
44
+ }
45
+
46
+ .rt-BaseButton:focus-visible,
47
+ .rt-IconButton:focus-visible,
48
+ .image-card:focus-visible,
49
+ .image-preview-dismiss:focus-visible,
50
+ .skip-link:focus-visible {
51
+ outline: 3px solid color-mix(in srgb, var(--jade-9) 55%, transparent);
52
+ outline-offset: 3px;
53
+ }
54
+
55
+ .skip-link {
56
+ position: fixed;
57
+ z-index: 100;
58
+ top: var(--space-2);
59
+ left: var(--space-2);
60
+ padding: var(--space-2) var(--space-3);
61
+ border-radius: var(--radius-3);
62
+ color: white;
63
+ background: var(--jade-9);
64
+ transform: translateY(-180%);
65
+ }
66
+
67
+ .skip-link:focus {
68
+ transform: none;
69
+ }
70
+
71
+ .page-shell {
72
+ width: min(1120px, calc(100% - 2rem));
73
+ max-width: 1120px;
74
+ margin-inline: auto;
75
+ }
76
+
77
+ .page-header {
78
+ display: flex;
79
+ align-items: flex-end;
80
+ justify-content: space-between;
81
+ gap: var(--space-6);
82
+ padding: var(--space-6) 0 var(--space-4);
83
+ }
84
+
85
+ .session-identity {
86
+ min-width: 0;
87
+ }
88
+
89
+ .eyebrow {
90
+ margin: 0 0 var(--space-1);
91
+ letter-spacing: 0.08em;
92
+ text-transform: uppercase;
93
+ }
94
+
95
+ .session-label {
96
+ margin: var(--space-2) 0 0;
97
+ overflow-wrap: anywhere;
98
+ }
99
+
100
+ .session-details {
101
+ position: relative;
102
+ align-self: center;
103
+ max-width: min(28rem, 100%);
104
+ }
105
+
106
+ .disclosure-icon {
107
+ transition: transform 150ms ease;
108
+ }
109
+
110
+ .session-details > [data-state="open"] .disclosure-icon {
111
+ transform: rotate(180deg);
112
+ }
113
+
114
+ .session-details-content {
115
+ position: absolute;
116
+ z-index: 10;
117
+ top: calc(100% + var(--space-1));
118
+ right: 0;
119
+ width: min(28rem, 85vw);
120
+ padding: var(--space-3);
121
+ border: 1px solid var(--gray-a6);
122
+ border-radius: var(--radius-4);
123
+ background: var(--color-panel-solid);
124
+ box-shadow: var(--image-drop-shadow);
125
+ }
126
+
127
+ .session-details-content #cwd {
128
+ display: block;
129
+ margin-top: var(--space-2);
130
+ white-space: normal;
131
+ overflow-wrap: anywhere;
132
+ }
133
+
134
+ main {
135
+ padding-bottom: var(--space-7);
136
+ }
137
+
138
+ .loading-state {
139
+ min-height: 14rem;
140
+ }
141
+
142
+ .drop-zone {
143
+ display: grid;
144
+ min-height: 160px;
145
+ place-items: center;
146
+ border: 2px dashed var(--gray-a8);
147
+ border-radius: var(--radius-5);
148
+ padding: var(--space-5) var(--space-6);
149
+ background: var(--color-panel-solid);
150
+ box-shadow: var(--image-drop-shadow);
151
+ transition:
152
+ border-color 150ms,
153
+ background 150ms,
154
+ transform 150ms;
155
+ }
156
+
157
+ .drop-zone.drag-active {
158
+ border-color: var(--jade-9);
159
+ background: color-mix(in srgb, var(--jade-4) 45%, var(--color-panel-solid));
160
+ transform: scale(1.006);
161
+ }
162
+
163
+ .drop-zone.disabled {
164
+ opacity: 0.66;
165
+ }
166
+
167
+ .drop-copy {
168
+ display: grid;
169
+ width: min(48rem, 100%);
170
+ grid-template-columns: auto minmax(0, 1fr) auto;
171
+ align-items: center;
172
+ gap: var(--space-4);
173
+ }
174
+
175
+ .drop-copy h2,
176
+ .drop-copy p {
177
+ margin: 0;
178
+ }
179
+
180
+ .drop-copy p {
181
+ margin-top: var(--space-1);
182
+ }
183
+
184
+ .drop-icon {
185
+ width: 2.2rem;
186
+ height: 2.2rem;
187
+ color: var(--jade-9);
188
+ }
189
+
190
+ .collection-note {
191
+ margin: var(--space-3) 0 0;
192
+ }
193
+
194
+ .batch {
195
+ margin-top: var(--space-3);
196
+ }
197
+
198
+ .batch-toolbar {
199
+ display: flex;
200
+ align-items: center;
201
+ justify-content: space-between;
202
+ gap: var(--space-4);
203
+ margin-bottom: var(--space-3);
204
+ }
205
+
206
+ .batch-toolbar h2,
207
+ .batch-toolbar p {
208
+ margin: 0;
209
+ }
210
+
211
+ .batch-toolbar .next-step {
212
+ max-width: 48rem;
213
+ margin-top: var(--space-1);
214
+ color: var(--gray-12);
215
+ font-weight: 600;
216
+ }
217
+
218
+ #error-banner {
219
+ margin: var(--space-3) 0;
220
+ }
221
+
222
+ .history {
223
+ margin-top: var(--space-6);
224
+ border-top: 1px solid var(--gray-a6);
225
+ padding-top: var(--space-5);
226
+ }
227
+
228
+ .history-toolbar {
229
+ display: flex;
230
+ align-items: flex-start;
231
+ justify-content: flex-start;
232
+ gap: var(--space-4);
233
+ margin-bottom: var(--space-3);
234
+ }
235
+
236
+ .history-summary {
237
+ min-width: 0;
238
+ max-width: 58rem;
239
+ }
240
+
241
+ .history-summary h2,
242
+ .history-summary p {
243
+ margin: 0;
244
+ }
245
+
246
+ .history-note {
247
+ margin-top: var(--space-2);
248
+ }
249
+
250
+ .image-grid {
251
+ display: grid;
252
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 245px), 1fr));
253
+ gap: var(--space-4);
254
+ margin: 0;
255
+ padding: 0;
256
+ list-style: none;
257
+ }
258
+
259
+ .image-card {
260
+ display: grid;
261
+ grid-template-rows: 170px 1fr auto;
262
+ min-width: 0;
263
+ overflow: hidden;
264
+ padding: 0;
265
+ border-color: var(--gray-a6);
266
+ background: var(--color-panel-solid);
267
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--gray-12) 7%, transparent);
268
+ }
269
+
270
+ .image-card:only-child {
271
+ max-width: 360px;
272
+ }
273
+
274
+ .image-card.status-error {
275
+ border-color: var(--red-9);
276
+ }
277
+
278
+ .image-card.duplicate-highlight {
279
+ animation: duplicate 1.8s ease;
280
+ }
281
+
282
+ .history .image-card {
283
+ box-shadow: none;
284
+ }
285
+
286
+ .preview {
287
+ display: grid;
288
+ overflow: hidden;
289
+ place-items: center;
290
+ background: var(--gray-3);
291
+ }
292
+
293
+ .preview img {
294
+ width: 100%;
295
+ height: 100%;
296
+ object-fit: contain;
297
+ }
298
+
299
+ .preview-button {
300
+ width: 100%;
301
+ height: 170px;
302
+ min-height: 0;
303
+ border: 0;
304
+ border-radius: 0;
305
+ padding: 0;
306
+ background: var(--gray-3);
307
+ cursor: zoom-in;
308
+ }
309
+
310
+ .placeholder {
311
+ display: grid;
312
+ width: 3rem;
313
+ height: 3rem;
314
+ place-items: center;
315
+ border-radius: 50%;
316
+ background: var(--color-panel-solid);
317
+ color: var(--gray-11);
318
+ font-size: 1.4rem;
319
+ font-weight: 800;
320
+ }
321
+
322
+ .card-body {
323
+ min-width: 0;
324
+ padding: var(--space-3);
325
+ }
326
+
327
+ .card-body h3 {
328
+ margin: 0;
329
+ overflow: hidden;
330
+ text-overflow: ellipsis;
331
+ white-space: nowrap;
332
+ }
333
+
334
+ .card-body p {
335
+ margin: var(--space-2) 0 0;
336
+ overflow-wrap: anywhere;
337
+ }
338
+
339
+ .conversion {
340
+ color: var(--jade-11);
341
+ }
342
+
343
+ .item-error {
344
+ color: var(--red-11);
345
+ }
346
+
347
+ .card-actions {
348
+ padding: 0 var(--space-3) var(--space-3);
349
+ }
350
+
351
+ .card-actions .rt-Button {
352
+ flex: 1 1 auto;
353
+ }
354
+
355
+ .privacy {
356
+ max-width: 56rem;
357
+ margin: var(--space-5) auto 0;
358
+ text-align: center;
359
+ }
360
+
361
+ .dialog-overlay {
362
+ position: fixed;
363
+ z-index: 80;
364
+ inset: 0;
365
+ background: color-mix(in srgb, var(--gray-12) 68%, transparent);
366
+ animation: overlay-in 140ms ease-out;
367
+ }
368
+
369
+ .dialog-content {
370
+ position: fixed;
371
+ z-index: 81;
372
+ top: 50%;
373
+ left: 50%;
374
+ display: grid;
375
+ width: min(92vw, 30rem);
376
+ gap: var(--space-4);
377
+ padding: var(--space-5);
378
+ border: 1px solid var(--gray-a6);
379
+ border-radius: var(--radius-5);
380
+ color: var(--gray-12);
381
+ background: var(--color-panel-solid);
382
+ box-shadow: var(--image-drop-shadow);
383
+ transform: translate(-50%, -50%);
384
+ animation: content-in 160ms ease-out;
385
+ }
386
+
387
+ .dialog-content p {
388
+ margin: 0;
389
+ }
390
+
391
+ .preview-overlay {
392
+ background: color-mix(in srgb, var(--gray-12) 88%, transparent);
393
+ }
394
+
395
+ .preview-content {
396
+ position: fixed;
397
+ z-index: 81;
398
+ inset: 3dvh 2vw;
399
+ display: grid;
400
+ grid-template-rows: auto minmax(0, 1fr);
401
+ overflow: hidden;
402
+ border: 1px solid var(--gray-a6);
403
+ border-radius: var(--radius-5);
404
+ background: var(--color-panel-solid);
405
+ box-shadow: var(--image-drop-shadow);
406
+ animation: preview-in 160ms ease-out;
407
+ }
408
+
409
+ .preview-header {
410
+ gap: var(--space-4);
411
+ padding: var(--space-3) var(--space-4);
412
+ border-bottom: 1px solid var(--gray-a6);
413
+ }
414
+
415
+ .preview-title {
416
+ overflow: hidden;
417
+ text-overflow: ellipsis;
418
+ white-space: nowrap;
419
+ }
420
+
421
+ .image-preview-dismiss {
422
+ display: grid;
423
+ width: 100%;
424
+ height: 100%;
425
+ min-height: 0;
426
+ place-items: center;
427
+ border: 0;
428
+ border-radius: 0;
429
+ padding: var(--space-4);
430
+ background: var(--gray-2);
431
+ cursor: zoom-out;
432
+ }
433
+
434
+ .image-preview-dismiss img {
435
+ width: 100%;
436
+ height: 100%;
437
+ min-height: 0;
438
+ object-fit: contain;
439
+ }
440
+
441
+ .connection-overlay {
442
+ position: fixed;
443
+ z-index: 100;
444
+ inset: 0;
445
+ background: color-mix(in srgb, var(--gray-12) 78%, transparent);
446
+ }
447
+
448
+ .connection-card {
449
+ position: fixed;
450
+ z-index: 101;
451
+ top: 50%;
452
+ left: 50%;
453
+ width: min(30rem, calc(100% - 2rem));
454
+ padding: var(--space-5);
455
+ border: 1px solid var(--gray-a6);
456
+ border-radius: var(--radius-5);
457
+ background: var(--color-panel-solid);
458
+ box-shadow: var(--image-drop-shadow);
459
+ transform: translate(-50%, -50%);
460
+ }
461
+
462
+ .connection-card h2 {
463
+ margin: 0;
464
+ }
465
+
466
+ @keyframes duplicate {
467
+ 0%,
468
+ 100% {
469
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--gray-12) 7%, transparent);
470
+ }
471
+ 20%,
472
+ 70% {
473
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--jade-9) 45%, transparent);
474
+ }
475
+ }
476
+
477
+ @keyframes overlay-in {
478
+ from {
479
+ opacity: 0;
480
+ }
481
+ }
482
+
483
+ @keyframes content-in {
484
+ from {
485
+ opacity: 0;
486
+ transform: translate(-50%, -48%) scale(0.98);
487
+ }
488
+ }
489
+
490
+ @keyframes preview-in {
491
+ from {
492
+ opacity: 0;
493
+ transform: scale(0.99);
494
+ }
495
+ }
496
+
497
+ @media (max-width: 640px) {
498
+ .page-shell {
499
+ width: min(100% - 1rem, 1120px);
500
+ }
501
+
502
+ .page-header {
503
+ align-items: flex-start;
504
+ flex-direction: column;
505
+ gap: var(--space-2);
506
+ padding-top: var(--space-5);
507
+ }
508
+
509
+ .session-details {
510
+ align-self: flex-start;
511
+ }
512
+
513
+ .session-details-content {
514
+ right: auto;
515
+ left: 0;
516
+ }
517
+
518
+ .drop-zone {
519
+ min-height: 210px;
520
+ padding: var(--space-4);
521
+ text-align: center;
522
+ }
523
+
524
+ .drop-copy {
525
+ grid-template-columns: 1fr;
526
+ justify-items: center;
527
+ gap: var(--space-2);
528
+ }
529
+
530
+ .batch-toolbar,
531
+ .history-toolbar {
532
+ align-items: flex-start;
533
+ flex-direction: column;
534
+ gap: var(--space-2);
535
+ }
536
+
537
+ .image-grid {
538
+ grid-template-columns: 1fr;
539
+ }
540
+
541
+ .image-card {
542
+ grid-template-columns: 112px 1fr;
543
+ grid-template-rows: auto auto;
544
+ }
545
+
546
+ .preview,
547
+ .preview-button {
548
+ width: 112px;
549
+ height: auto;
550
+ min-height: 150px;
551
+ grid-row: 1 / 3;
552
+ }
553
+
554
+ .card-actions {
555
+ padding-top: var(--space-3);
556
+ }
557
+
558
+ .preview-content {
559
+ inset: 1dvh 1vw;
560
+ }
561
+ }
562
+
563
+ @media (prefers-reduced-motion: reduce) {
564
+ .radix-themes *,
565
+ .radix-themes *::before,
566
+ .radix-themes *::after {
567
+ scroll-behavior: auto;
568
+ transition-duration: 0.01ms;
569
+ animation-duration: 0.01ms;
570
+ animation-iteration-count: 1;
571
+ }
572
+ }