@sentroy-co/client-sdk 2.4.5 → 2.5.2

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.
Files changed (41) hide show
  1. package/dist/http.d.ts +13 -0
  2. package/dist/http.d.ts.map +1 -1
  3. package/dist/http.js +62 -0
  4. package/dist/http.js.map +1 -1
  5. package/dist/react/MediaManager.d.ts +14 -0
  6. package/dist/react/MediaManager.d.ts.map +1 -1
  7. package/dist/react/MediaManager.js +21 -14
  8. package/dist/react/MediaManager.js.map +1 -1
  9. package/dist/react/crop/CropDialog.d.ts +15 -0
  10. package/dist/react/crop/CropDialog.d.ts.map +1 -0
  11. package/dist/react/crop/CropDialog.js +126 -0
  12. package/dist/react/crop/CropDialog.js.map +1 -0
  13. package/dist/react/crop/index.d.ts +7 -0
  14. package/dist/react/crop/index.d.ts.map +1 -0
  15. package/dist/react/crop/index.js +11 -0
  16. package/dist/react/crop/index.js.map +1 -0
  17. package/dist/react/index.d.ts +2 -0
  18. package/dist/react/index.d.ts.map +1 -1
  19. package/dist/react/index.js +5 -1
  20. package/dist/react/index.js.map +1 -1
  21. package/dist/react/lib/UploadQueuePanel.d.ts +20 -0
  22. package/dist/react/lib/UploadQueuePanel.d.ts.map +1 -0
  23. package/dist/react/lib/UploadQueuePanel.js +39 -0
  24. package/dist/react/lib/UploadQueuePanel.js.map +1 -0
  25. package/dist/react/lib/use-upload-queue.d.ts +51 -0
  26. package/dist/react/lib/use-upload-queue.d.ts.map +1 -0
  27. package/dist/react/lib/use-upload-queue.js +167 -0
  28. package/dist/react/lib/use-upload-queue.js.map +1 -0
  29. package/dist/resources/media.d.ts +8 -1
  30. package/dist/resources/media.d.ts.map +1 -1
  31. package/dist/resources/media.js +6 -2
  32. package/dist/resources/media.js.map +1 -1
  33. package/package.json +10 -1
  34. package/src/http.ts +85 -0
  35. package/src/react/MediaManager.tsx +41 -11
  36. package/src/react/crop/CropDialog.tsx +344 -0
  37. package/src/react/crop/index.ts +6 -0
  38. package/src/react/index.ts +10 -0
  39. package/src/react/lib/UploadQueuePanel.tsx +273 -0
  40. package/src/react/lib/use-upload-queue.ts +250 -0
  41. package/src/resources/media.ts +13 -4
@@ -0,0 +1,344 @@
1
+ import { useCallback, useEffect, useState } from "react"
2
+ import Cropper from "react-easy-crop"
3
+ import { motion, AnimatePresence } from "motion/react"
4
+
5
+ /**
6
+ * Image crop dialog — `react-easy-crop` üzerine ince bir wrapper. Storage
7
+ * upload akışında `preprocessFile` hook'unun içinde çağrılır:
8
+ * - Aspect preset toolbar (1:1, 4:3, 16:9, 3:2, 9:16, Free)
9
+ * - Zoom slider (+/− ile de)
10
+ * - Pan + touch built-in (`react-easy-crop`)
11
+ * - Apply → cropped Blob, Cancel → null, "Use original" → original File
12
+ *
13
+ * Ayrı bir entry point (`@sentroy-co/client-sdk/react/crop`) — ana SDK
14
+ * import'u `react-easy-crop`'u bundle'a çekmesin (lazy subpath).
15
+ *
16
+ * Lazy çağrı pattern: Caller tarafında `await openCropDialog(file)` yardımcı
17
+ * fonksiyonu (bkz `./openCropDialog`) modal mount/unmount'u yönetir,
18
+ * Promise<File | null> döner.
19
+ */
20
+
21
+ interface CropArea {
22
+ x: number
23
+ y: number
24
+ width: number
25
+ height: number
26
+ }
27
+
28
+ const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }> = [
29
+ { id: "free", label: "Free", aspect: null },
30
+ { id: "1:1", label: "1:1", aspect: 1 },
31
+ { id: "16:9", label: "16:9", aspect: 16 / 9 },
32
+ { id: "4:3", label: "4:3", aspect: 4 / 3 },
33
+ { id: "3:2", label: "3:2", aspect: 3 / 2 },
34
+ { id: "9:16", label: "9:16", aspect: 9 / 16 },
35
+ ]
36
+
37
+ const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP — üstü tarayıcı memory peak'i riskli
38
+
39
+ export interface CropDialogProps {
40
+ open: boolean
41
+ /** Crop edilecek dosya. Image MIME değilse modal hiç açılmaz (caller'da
42
+ * filter). */
43
+ file: File
44
+ /** Apply: cropped File döner. Cancel: null. Use original: orijinal File. */
45
+ onClose: (result: File | null) => void
46
+ /** Default aspect preset id'si — 'free' (default) veya '1:1', '16:9', vb. */
47
+ defaultAspect?: string
48
+ /** Output JPEG quality 0-1 (default 0.92). Convert sonucu daima image/jpeg
49
+ * veya orijinal MIME (PNG'ler için PNG korunur). */
50
+ outputQuality?: number
51
+ }
52
+
53
+ export function CropDialog({
54
+ open,
55
+ file,
56
+ onClose,
57
+ defaultAspect = "free",
58
+ outputQuality = 0.92,
59
+ }: CropDialogProps) {
60
+ const [imageUrl, setImageUrl] = useState<string | null>(null)
61
+ const [aspectId, setAspectId] = useState(defaultAspect)
62
+ const [crop, setCrop] = useState({ x: 0, y: 0 })
63
+ const [zoom, setZoom] = useState(1)
64
+ const [croppedAreaPixels, setCroppedAreaPixels] = useState<CropArea | null>(null)
65
+ const [busy, setBusy] = useState(false)
66
+ const [tooLarge, setTooLarge] = useState(false)
67
+
68
+ // Object URL lifecycle
69
+ useEffect(() => {
70
+ if (!open) return
71
+ const url = URL.createObjectURL(file)
72
+ setImageUrl(url)
73
+ setCrop({ x: 0, y: 0 })
74
+ setZoom(1)
75
+ setAspectId(defaultAspect)
76
+ setTooLarge(false)
77
+ // Pixel guard — large image decode tarayıcıyı çökertir
78
+ const img = new Image()
79
+ img.onload = () => {
80
+ if (img.naturalWidth * img.naturalHeight > MAX_PIXEL_GUARD) {
81
+ setTooLarge(true)
82
+ }
83
+ }
84
+ img.src = url
85
+ return () => URL.revokeObjectURL(url)
86
+ }, [open, file, defaultAspect])
87
+
88
+ const onCropComplete = useCallback(
89
+ (_area: CropArea, areaPixels: CropArea) => {
90
+ setCroppedAreaPixels(areaPixels)
91
+ },
92
+ [],
93
+ )
94
+
95
+ const aspect =
96
+ ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
97
+
98
+ const handleApply = useCallback(async () => {
99
+ if (!imageUrl || !croppedAreaPixels) return
100
+ setBusy(true)
101
+ try {
102
+ const blob = await getCroppedBlob(imageUrl, croppedAreaPixels, file.type, outputQuality)
103
+ // Cropped File — orijinal name'i koru ama uzantı output type'ına göre
104
+ const ext = blob.type === "image/png" ? "png" : "jpg"
105
+ const baseName = file.name.replace(/\.[^.]+$/, "")
106
+ const cropped = new File([blob], `${baseName}.${ext}`, {
107
+ type: blob.type,
108
+ })
109
+ onClose(cropped)
110
+ } finally {
111
+ setBusy(false)
112
+ }
113
+ }, [imageUrl, croppedAreaPixels, file, onClose, outputQuality])
114
+
115
+ const handleUseOriginal = useCallback(() => onClose(file), [file, onClose])
116
+ const handleCancel = useCallback(() => onClose(null), [onClose])
117
+
118
+ // ESC kapatır
119
+ useEffect(() => {
120
+ if (!open) return
121
+ const onKey = (e: KeyboardEvent) => {
122
+ if (e.key === "Escape") {
123
+ e.stopPropagation()
124
+ handleCancel()
125
+ }
126
+ }
127
+ window.addEventListener("keydown", onKey)
128
+ return () => window.removeEventListener("keydown", onKey)
129
+ }, [open, handleCancel])
130
+
131
+ return (
132
+ <AnimatePresence>
133
+ {open && imageUrl && (
134
+ <motion.div
135
+ key="backdrop"
136
+ initial={{ opacity: 0 }}
137
+ animate={{ opacity: 1 }}
138
+ exit={{ opacity: 0 }}
139
+ transition={{ duration: 0.2 }}
140
+ // z-index ana MediaManager modal'ından yüksek (nested)
141
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
142
+ onClick={(e) => {
143
+ if (e.target === e.currentTarget) handleCancel()
144
+ }}
145
+ >
146
+ <motion.div
147
+ initial={{ opacity: 0, scale: 0.96, y: 8 }}
148
+ animate={{ opacity: 1, scale: 1, y: 0 }}
149
+ exit={{ opacity: 0, scale: 0.98 }}
150
+ transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
151
+ className="flex h-[min(90vh,720px)] w-full max-w-3xl flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
152
+ >
153
+ {/* Header */}
154
+ <div className="flex items-center justify-between gap-3 border-b px-4 py-3">
155
+ <div className="flex flex-col">
156
+ <span className="text-sm font-semibold">Crop image</span>
157
+ <span className="truncate text-xs text-muted-foreground">
158
+ {file.name}
159
+ </span>
160
+ </div>
161
+ <button
162
+ type="button"
163
+ onClick={handleCancel}
164
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
165
+ aria-label="Cancel"
166
+ >
167
+ <svg
168
+ viewBox="0 0 24 24"
169
+ fill="none"
170
+ stroke="currentColor"
171
+ strokeWidth="2"
172
+ strokeLinecap="round"
173
+ strokeLinejoin="round"
174
+ className="size-4"
175
+ >
176
+ <path d="M18 6 6 18M6 6l12 12" />
177
+ </svg>
178
+ </button>
179
+ </div>
180
+
181
+ {/* Aspect toolbar */}
182
+ <div className="flex flex-wrap items-center gap-1 border-b bg-muted/20 px-3 py-2">
183
+ {ASPECT_PRESETS.map((p) => (
184
+ <button
185
+ key={p.id}
186
+ type="button"
187
+ onClick={() => setAspectId(p.id)}
188
+ className={cls(
189
+ "rounded-md px-2.5 py-1 text-xs transition-colors",
190
+ aspectId === p.id
191
+ ? "bg-foreground text-background"
192
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
193
+ )}
194
+ >
195
+ {p.label}
196
+ </button>
197
+ ))}
198
+ </div>
199
+
200
+ {/* Cropper canvas */}
201
+ <div className="relative flex-1 bg-black">
202
+ {tooLarge ? (
203
+ <div className="flex h-full w-full items-center justify-center p-6 text-center text-sm text-white/70">
204
+ Image too large to crop in browser. Upload as-is or resize
205
+ beforehand.
206
+ </div>
207
+ ) : (
208
+ <Cropper
209
+ image={imageUrl}
210
+ crop={crop}
211
+ zoom={zoom}
212
+ aspect={aspect}
213
+ onCropChange={setCrop}
214
+ onCropComplete={onCropComplete}
215
+ onZoomChange={setZoom}
216
+ showGrid
217
+ objectFit="contain"
218
+ />
219
+ )}
220
+ </div>
221
+
222
+ {/* Zoom + actions */}
223
+ <div className="flex flex-col gap-3 border-t bg-muted/20 px-4 py-3">
224
+ {!tooLarge && (
225
+ <div className="flex items-center gap-3">
226
+ <span className="text-xs text-muted-foreground">Zoom</span>
227
+ <button
228
+ type="button"
229
+ onClick={() => setZoom((z) => Math.max(1, z - 0.1))}
230
+ className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted/50"
231
+ >
232
+
233
+ </button>
234
+ <input
235
+ type="range"
236
+ min={1}
237
+ max={3}
238
+ step={0.05}
239
+ value={zoom}
240
+ onChange={(e) => setZoom(Number(e.target.value))}
241
+ className="flex-1 accent-foreground"
242
+ />
243
+ <button
244
+ type="button"
245
+ onClick={() => setZoom((z) => Math.min(3, z + 0.1))}
246
+ className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted/50"
247
+ >
248
+ +
249
+ </button>
250
+ <button
251
+ type="button"
252
+ onClick={() => {
253
+ setZoom(1)
254
+ setCrop({ x: 0, y: 0 })
255
+ }}
256
+ className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted/50"
257
+ >
258
+ Reset
259
+ </button>
260
+ </div>
261
+ )}
262
+ <div className="flex items-center justify-end gap-2">
263
+ <button
264
+ type="button"
265
+ onClick={handleCancel}
266
+ disabled={busy}
267
+ className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted/50"
268
+ >
269
+ Cancel
270
+ </button>
271
+ <button
272
+ type="button"
273
+ onClick={handleUseOriginal}
274
+ disabled={busy}
275
+ className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted/50"
276
+ >
277
+ Use original
278
+ </button>
279
+ <button
280
+ type="button"
281
+ onClick={handleApply}
282
+ disabled={busy || tooLarge || !croppedAreaPixels}
283
+ className="rounded-md bg-foreground px-3 py-1.5 text-xs font-medium text-background hover:opacity-90 disabled:opacity-50"
284
+ >
285
+ {busy ? "Cropping…" : "Apply crop"}
286
+ </button>
287
+ </div>
288
+ </div>
289
+ </motion.div>
290
+ </motion.div>
291
+ )}
292
+ </AnimatePresence>
293
+ )
294
+ }
295
+
296
+ /**
297
+ * Canvas ile crop area'yı çıkar + Blob döndür.
298
+ * Output MIME: PNG ise PNG, diğerleri JPEG (transparency yoksa).
299
+ */
300
+ async function getCroppedBlob(
301
+ imageUrl: string,
302
+ area: CropArea,
303
+ sourceMime: string,
304
+ quality: number,
305
+ ): Promise<Blob> {
306
+ const image = await loadImage(imageUrl)
307
+ const canvas = document.createElement("canvas")
308
+ canvas.width = area.width
309
+ canvas.height = area.height
310
+ const ctx = canvas.getContext("2d")
311
+ if (!ctx) throw new Error("Canvas 2D context unavailable")
312
+ ctx.drawImage(
313
+ image,
314
+ area.x,
315
+ area.y,
316
+ area.width,
317
+ area.height,
318
+ 0,
319
+ 0,
320
+ area.width,
321
+ area.height,
322
+ )
323
+ const outputMime = sourceMime === "image/png" ? "image/png" : "image/jpeg"
324
+ return new Promise<Blob>((resolve, reject) => {
325
+ canvas.toBlob(
326
+ (blob) => (blob ? resolve(blob) : reject(new Error("toBlob returned null"))),
327
+ outputMime,
328
+ outputMime === "image/jpeg" ? quality : undefined,
329
+ )
330
+ })
331
+ }
332
+
333
+ function loadImage(url: string): Promise<HTMLImageElement> {
334
+ return new Promise((resolve, reject) => {
335
+ const img = new Image()
336
+ img.onload = () => resolve(img)
337
+ img.onerror = reject
338
+ img.src = url
339
+ })
340
+ }
341
+
342
+ function cls(...arr: Array<string | false | null | undefined>): string {
343
+ return arr.filter(Boolean).join(" ")
344
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Crop subpath — `@sentroy-co/client-sdk/react/crop` üzerinden lazy import
3
+ * için ayrı bundle entry. `react-easy-crop` ana SDK import'unda yer almaz;
4
+ * sadece crop kullanan consumer'lar bu modülü çekince bundle'a girer.
5
+ */
6
+ export { CropDialog, type CropDialogProps } from "./CropDialog"
@@ -8,6 +8,16 @@ export {
8
8
  type MediaManagerTriggerProps,
9
9
  } from "./MediaManagerTrigger"
10
10
  export { Lightbox, type LightboxProps } from "./lib/Lightbox"
11
+ export {
12
+ useUploadQueue,
13
+ type UploadEntry,
14
+ type UseUploadQueueOptions,
15
+ type UseUploadQueueResult,
16
+ } from "./lib/use-upload-queue"
17
+ export {
18
+ UploadQueuePanel,
19
+ type UploadQueuePanelProps,
20
+ } from "./lib/UploadQueuePanel"
11
21
  export {
12
22
  cn,
13
23
  formatBytes,
@@ -0,0 +1,273 @@
1
+ import { motion, AnimatePresence } from "motion/react"
2
+ import type { UploadEntry } from "./use-upload-queue"
3
+ import { cn, formatBytes } from "./utils"
4
+
5
+ /**
6
+ * Upload queue panel — MediaManager içinde grid'in altında collapsible bar.
7
+ *
8
+ * Tasarım:
9
+ * - Header: aktif sayı + total progress + clear-done butonu
10
+ * - Liste: her entry filename + circular progress + cancel/retry/remove
11
+ * - Animations: motion/react ile stagger entry, smooth progress, completion
12
+ * checkmark, error shake. `motion` paketi ~3KB minified — framer-motion
13
+ * yerine bilinçli seçim (SDK bundle hassasiyeti).
14
+ */
15
+
16
+ export interface UploadQueuePanelProps {
17
+ entries: UploadEntry[]
18
+ onCancel: (id: string) => void
19
+ onRemove: (id: string) => void
20
+ onClearDone: () => void
21
+ className?: string
22
+ }
23
+
24
+ export function UploadQueuePanel({
25
+ entries,
26
+ onCancel,
27
+ onRemove,
28
+ onClearDone,
29
+ className,
30
+ }: UploadQueuePanelProps) {
31
+ if (entries.length === 0) return null
32
+
33
+ const active = entries.filter(
34
+ (e) => e.status === "queued" || e.status === "uploading",
35
+ ).length
36
+ const done = entries.filter((e) => e.status === "done").length
37
+ const failed = entries.filter(
38
+ (e) => e.status === "error" || e.status === "canceled",
39
+ ).length
40
+
41
+ // Aggregate progress (toplam loaded / toplam total)
42
+ const totalLoaded = entries.reduce((s, e) => s + e.loaded, 0)
43
+ const totalSize = entries.reduce((s, e) => s + e.total, 0)
44
+ const aggPercent = totalSize > 0 ? Math.round((totalLoaded / totalSize) * 100) : 0
45
+
46
+ return (
47
+ <motion.div
48
+ initial={{ opacity: 0, y: 8 }}
49
+ animate={{ opacity: 1, y: 0 }}
50
+ transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
51
+ className={cn(
52
+ "border-t bg-card/50 backdrop-blur-sm",
53
+ className,
54
+ )}
55
+ >
56
+ {/* Header */}
57
+ <div className="flex items-center justify-between gap-3 border-b px-3 py-2">
58
+ <div className="flex items-center gap-3 text-xs">
59
+ {active > 0 && (
60
+ <span className="flex items-center gap-1.5 text-foreground">
61
+ <span className="relative flex size-2">
62
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
63
+ <span className="relative inline-flex size-2 rounded-full bg-blue-500" />
64
+ </span>
65
+ {active} uploading
66
+ </span>
67
+ )}
68
+ {done > 0 && (
69
+ <span className="text-emerald-600 dark:text-emerald-400">
70
+ {done} done
71
+ </span>
72
+ )}
73
+ {failed > 0 && (
74
+ <span className="text-destructive">{failed} failed</span>
75
+ )}
76
+ </div>
77
+ <div className="flex items-center gap-2 text-xs">
78
+ {totalSize > 0 && (
79
+ <span className="tabular-nums text-muted-foreground">
80
+ {formatBytes(totalLoaded)} / {formatBytes(totalSize)} ·{" "}
81
+ {aggPercent}%
82
+ </span>
83
+ )}
84
+ {(done > 0 || failed > 0) && (
85
+ <button
86
+ type="button"
87
+ onClick={onClearDone}
88
+ className="rounded-md border px-2 py-0.5 text-[10px] hover:bg-muted/50"
89
+ >
90
+ Clear
91
+ </button>
92
+ )}
93
+ </div>
94
+ </div>
95
+
96
+ {/* List */}
97
+ <div className="max-h-48 overflow-y-auto py-1">
98
+ <AnimatePresence initial={false}>
99
+ {entries.map((entry, i) => (
100
+ <UploadRow
101
+ key={entry.id}
102
+ entry={entry}
103
+ index={i}
104
+ onCancel={() => onCancel(entry.id)}
105
+ onRemove={() => onRemove(entry.id)}
106
+ />
107
+ ))}
108
+ </AnimatePresence>
109
+ </div>
110
+ </motion.div>
111
+ )
112
+ }
113
+
114
+ function UploadRow({
115
+ entry,
116
+ index,
117
+ onCancel,
118
+ onRemove,
119
+ }: {
120
+ entry: UploadEntry
121
+ index: number
122
+ onCancel: () => void
123
+ onRemove: () => void
124
+ }) {
125
+ const percent =
126
+ entry.total > 0 ? Math.round((entry.loaded / entry.total) * 100) : 0
127
+ const isTerminal =
128
+ entry.status === "done" ||
129
+ entry.status === "error" ||
130
+ entry.status === "canceled"
131
+
132
+ return (
133
+ <motion.div
134
+ layout
135
+ initial={{ opacity: 0, x: -8 }}
136
+ animate={{ opacity: 1, x: 0 }}
137
+ exit={{ opacity: 0, height: 0 }}
138
+ transition={{
139
+ duration: 0.22,
140
+ ease: [0.22, 1, 0.36, 1],
141
+ delay: index < 5 ? index * 0.04 : 0,
142
+ }}
143
+ className={cn(
144
+ "flex items-center gap-3 px-3 py-2",
145
+ entry.status === "error" && "bg-destructive/5",
146
+ )}
147
+ >
148
+ {/* Status indicator */}
149
+ <div className="flex size-7 shrink-0 items-center justify-center">
150
+ {entry.status === "uploading" && (
151
+ <CircularProgress percent={percent} />
152
+ )}
153
+ {entry.status === "queued" && (
154
+ <span className="size-2 animate-pulse rounded-full bg-muted-foreground/40" />
155
+ )}
156
+ {entry.status === "done" && <CheckmarkAnim />}
157
+ {entry.status === "error" && (
158
+ <span className="text-base text-destructive">!</span>
159
+ )}
160
+ {entry.status === "canceled" && (
161
+ <span className="text-xs text-muted-foreground">×</span>
162
+ )}
163
+ </div>
164
+
165
+ {/* Filename + meta */}
166
+ <div className="flex min-w-0 flex-1 flex-col">
167
+ <span className="truncate text-xs font-medium" title={entry.file.name}>
168
+ {entry.file.name}
169
+ </span>
170
+ <span className="text-[10px] tabular-nums text-muted-foreground">
171
+ {entry.status === "uploading" && (
172
+ <>
173
+ {formatBytes(entry.loaded)} / {formatBytes(entry.total)} ·{" "}
174
+ {percent}%
175
+ </>
176
+ )}
177
+ {entry.status === "queued" && (
178
+ <>{formatBytes(entry.total)} · queued</>
179
+ )}
180
+ {entry.status === "done" && (
181
+ <span className="text-emerald-600 dark:text-emerald-400">
182
+ uploaded · {formatBytes(entry.total)}
183
+ </span>
184
+ )}
185
+ {entry.status === "error" && (
186
+ <span className="text-destructive">
187
+ {entry.error ?? "failed"}
188
+ </span>
189
+ )}
190
+ {entry.status === "canceled" && <>canceled</>}
191
+ </span>
192
+ </div>
193
+
194
+ {/* Action */}
195
+ <button
196
+ type="button"
197
+ onClick={isTerminal ? onRemove : onCancel}
198
+ className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
199
+ aria-label={isTerminal ? "Remove from list" : "Cancel upload"}
200
+ >
201
+ <svg
202
+ xmlns="http://www.w3.org/2000/svg"
203
+ viewBox="0 0 24 24"
204
+ fill="none"
205
+ stroke="currentColor"
206
+ strokeWidth="2"
207
+ strokeLinecap="round"
208
+ strokeLinejoin="round"
209
+ className="size-3.5"
210
+ >
211
+ <path d="M18 6 6 18M6 6l12 12" />
212
+ </svg>
213
+ </button>
214
+ </motion.div>
215
+ )
216
+ }
217
+
218
+ function CircularProgress({ percent }: { percent: number }) {
219
+ const radius = 10
220
+ const circumference = 2 * Math.PI * radius
221
+ const offset = circumference - (percent / 100) * circumference
222
+ return (
223
+ <svg viewBox="0 0 24 24" className="size-6 -rotate-90">
224
+ <circle
225
+ cx="12"
226
+ cy="12"
227
+ r={radius}
228
+ fill="none"
229
+ stroke="currentColor"
230
+ strokeWidth="2"
231
+ className="text-muted/40"
232
+ />
233
+ <motion.circle
234
+ cx="12"
235
+ cy="12"
236
+ r={radius}
237
+ fill="none"
238
+ stroke="currentColor"
239
+ strokeWidth="2.5"
240
+ strokeLinecap="round"
241
+ strokeDasharray={circumference}
242
+ initial={false}
243
+ animate={{ strokeDashoffset: offset }}
244
+ transition={{ duration: 0.3, ease: "easeOut" }}
245
+ className="text-blue-500"
246
+ />
247
+ </svg>
248
+ )
249
+ }
250
+
251
+ function CheckmarkAnim() {
252
+ return (
253
+ <motion.svg
254
+ viewBox="0 0 24 24"
255
+ fill="none"
256
+ stroke="currentColor"
257
+ strokeWidth="2.5"
258
+ strokeLinecap="round"
259
+ strokeLinejoin="round"
260
+ className="size-5 text-emerald-500"
261
+ initial={{ scale: 0.6, opacity: 0 }}
262
+ animate={{ scale: 1, opacity: 1 }}
263
+ transition={{ duration: 0.3, ease: [0.34, 1.56, 0.64, 1] }}
264
+ >
265
+ <motion.path
266
+ d="M20 6 9 17l-5-5"
267
+ initial={{ pathLength: 0 }}
268
+ animate={{ pathLength: 1 }}
269
+ transition={{ duration: 0.4, ease: "easeOut", delay: 0.05 }}
270
+ />
271
+ </motion.svg>
272
+ )
273
+ }