@sentroy-co/client-sdk 2.4.4 → 2.5.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/dist/http.d.ts +13 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +62 -0
- package/dist/http.js.map +1 -1
- package/dist/react/MediaManager.d.ts +14 -0
- package/dist/react/MediaManager.d.ts.map +1 -1
- package/dist/react/MediaManager.js +21 -14
- package/dist/react/MediaManager.js.map +1 -1
- package/dist/react/MediaManagerTrigger.d.ts.map +1 -1
- package/dist/react/MediaManagerTrigger.js +4 -1
- package/dist/react/MediaManagerTrigger.js.map +1 -1
- package/dist/react/crop/CropDialog.d.ts +15 -0
- package/dist/react/crop/CropDialog.d.ts.map +1 -0
- package/dist/react/crop/CropDialog.js +126 -0
- package/dist/react/crop/CropDialog.js.map +1 -0
- package/dist/react/crop/index.d.ts +7 -0
- package/dist/react/crop/index.d.ts.map +1 -0
- package/dist/react/crop/index.js +11 -0
- package/dist/react/crop/index.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +5 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/UploadQueuePanel.d.ts +20 -0
- package/dist/react/lib/UploadQueuePanel.d.ts.map +1 -0
- package/dist/react/lib/UploadQueuePanel.js +39 -0
- package/dist/react/lib/UploadQueuePanel.js.map +1 -0
- package/dist/react/lib/use-upload-queue.d.ts +51 -0
- package/dist/react/lib/use-upload-queue.d.ts.map +1 -0
- package/dist/react/lib/use-upload-queue.js +131 -0
- package/dist/react/lib/use-upload-queue.js.map +1 -0
- package/dist/resources/media.d.ts +8 -1
- package/dist/resources/media.d.ts.map +1 -1
- package/dist/resources/media.js +6 -2
- package/dist/resources/media.js.map +1 -1
- package/package.json +10 -1
- package/src/http.ts +85 -0
- package/src/react/MediaManager.tsx +41 -11
- package/src/react/MediaManagerTrigger.tsx +5 -1
- package/src/react/crop/CropDialog.tsx +344 -0
- package/src/react/crop/index.ts +6 -0
- package/src/react/index.ts +10 -0
- package/src/react/lib/UploadQueuePanel.tsx +273 -0
- package/src/react/lib/use-upload-queue.ts +211 -0
- 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"
|
package/src/react/index.ts
CHANGED
|
@@ -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
|
+
}
|