@sentroy-co/client-sdk 2.0.0 → 2.2.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.
Files changed (47) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/react/MediaManager.d.ts +69 -0
  6. package/dist/react/MediaManager.d.ts.map +1 -0
  7. package/dist/react/MediaManager.js +216 -0
  8. package/dist/react/MediaManager.js.map +1 -0
  9. package/dist/react/index.d.ts +4 -0
  10. package/dist/react/index.d.ts.map +1 -0
  11. package/dist/react/index.js +13 -0
  12. package/dist/react/index.js.map +1 -0
  13. package/dist/react/lib/Lightbox.d.ts +18 -0
  14. package/dist/react/lib/Lightbox.d.ts.map +1 -0
  15. package/dist/react/lib/Lightbox.js +40 -0
  16. package/dist/react/lib/Lightbox.js.map +1 -0
  17. package/dist/react/lib/use-media-list.d.ts +21 -0
  18. package/dist/react/lib/use-media-list.d.ts.map +1 -0
  19. package/dist/react/lib/use-media-list.js +50 -0
  20. package/dist/react/lib/use-media-list.js.map +1 -0
  21. package/dist/react/lib/utils.d.ts +17 -0
  22. package/dist/react/lib/utils.d.ts.map +1 -0
  23. package/dist/react/lib/utils.js +62 -0
  24. package/dist/react/lib/utils.js.map +1 -0
  25. package/dist/resources/storage.d.ts +23 -0
  26. package/dist/resources/storage.d.ts.map +1 -0
  27. package/dist/resources/storage.js +31 -0
  28. package/dist/resources/storage.js.map +1 -0
  29. package/dist/types.d.ts +40 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +40 -4
  32. package/src/http.ts +151 -0
  33. package/src/index.ts +106 -0
  34. package/src/react/MediaManager.tsx +628 -0
  35. package/src/react/index.ts +13 -0
  36. package/src/react/lib/Lightbox.tsx +162 -0
  37. package/src/react/lib/use-media-list.ts +54 -0
  38. package/src/react/lib/utils.ts +73 -0
  39. package/src/resources/buckets.ts +50 -0
  40. package/src/resources/domains.ts +16 -0
  41. package/src/resources/inbox.ts +99 -0
  42. package/src/resources/mailboxes.ts +11 -0
  43. package/src/resources/media.ts +115 -0
  44. package/src/resources/send.ts +11 -0
  45. package/src/resources/storage.ts +28 -0
  46. package/src/resources/templates.ts +16 -0
  47. package/src/types.ts +316 -0
@@ -0,0 +1,628 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "react"
8
+ import type { Sentroy } from ".."
9
+ import type { Bucket, Media } from "../types"
10
+ import { Lightbox } from "./lib/Lightbox"
11
+ import { useMediaList } from "./lib/use-media-list"
12
+ import {
13
+ cn,
14
+ detectKind,
15
+ formatBytes,
16
+ KIND_LABELS,
17
+ type MediaKind,
18
+ } from "./lib/utils"
19
+
20
+ /**
21
+ * MediaManager — Sentroy storage'a bağlanan tek-component dosya yöneticisi.
22
+ *
23
+ * Tasarım hedefleri:
24
+ * - Tek bir prop'la (`client`) tam workflow: bucket select → list → arama →
25
+ * upload → seç → preview (lightbox) → delete.
26
+ * - Ne single ne multi-file selection için zorla; `multiple` prop'u akış
27
+ * belirler. `onChange` her seçim değişikliğinde tetiklenir.
28
+ * - `initialValue` ile pre-selected file'lar (Media obje veya id string).
29
+ * - Tema override: kök `className` + `classNames` map ile alt-component
30
+ * class'larını override edebilir consumer (ileride farklı tema
31
+ * promptları için tek değişiklik noktası).
32
+ * - Spacebar selected item'i lightbox'ta açar; ESC kapatır.
33
+ * - Drag-drop + click-to-upload.
34
+ *
35
+ * Tailwind class kullanır ama kendisi Tailwind import etmez — host app'in
36
+ * Tailwind setup'ı kullanılır. Class çakışmalarında tailwind-merge yerine
37
+ * "consumer'ın className son geliyor" kuralı yeterli.
38
+ */
39
+
40
+ export interface MediaManagerClassNames {
41
+ root?: string
42
+ toolbar?: string
43
+ searchInput?: string
44
+ filterSelect?: string
45
+ uploadButton?: string
46
+ bucketSelect?: string
47
+ grid?: string
48
+ card?: string
49
+ cardSelected?: string
50
+ thumbnail?: string
51
+ cardMeta?: string
52
+ empty?: string
53
+ details?: string
54
+ dropZoneOverlay?: string
55
+ }
56
+
57
+ export interface MediaManagerProps {
58
+ /** Sentroy client instance — caller kendi access token'i ile yaratır. */
59
+ client: Sentroy
60
+ /**
61
+ * Başlangıçta açılacak bucket. Verilmezse component bucket list çeker
62
+ * ve ilkini açar; ilk render'da kullanıcı dropdown'tan değiştirebilir.
63
+ */
64
+ bucketSlug?: string
65
+ /** Birden fazla seçilebilir mi? Default false. */
66
+ multiple?: boolean
67
+ /** Yalnızca belirli MIME prefix'leri kabul et upload'ta — örn "image/*". */
68
+ accept?: string
69
+ /** Kullanıcının önceden seçtiği item'lar — Media obje ya da id string. */
70
+ initialValue?: Array<Media | string>
71
+ /** Seçim her değiştiğinde — tek seçimde array.length<=1. */
72
+ onChange?: (selected: Media[]) => void
73
+ /** Çift tık veya tek seçim "confirm" — picker dialog'larda useful. */
74
+ onSelect?: (selected: Media[]) => void
75
+ /** Bucket dropdown'unda hangi bucket'lar görünsün — gizli system
76
+ * bucket'larını filtrelemek için. */
77
+ bucketFilter?: (bucket: Bucket) => boolean
78
+ /** Custom kök class. */
79
+ className?: string
80
+ /** Alt-component class override haritası. */
81
+ classNames?: MediaManagerClassNames
82
+ /** Detail panel'i sağda göster mi (default true). */
83
+ showDetailsPane?: boolean
84
+ /** Toolbar'da bucket selector görünsün mü (default true). */
85
+ showBucketSelector?: boolean
86
+ }
87
+
88
+ const KIND_FILTERS: Array<{ value: MediaKind | "all"; label: string }> = [
89
+ { value: "all", label: "All" },
90
+ { value: "image", label: KIND_LABELS.image },
91
+ { value: "video", label: KIND_LABELS.video },
92
+ { value: "audio", label: KIND_LABELS.audio },
93
+ { value: "pdf", label: KIND_LABELS.pdf },
94
+ { value: "doc", label: KIND_LABELS.doc },
95
+ { value: "archive", label: KIND_LABELS.archive },
96
+ { value: "code", label: KIND_LABELS.code },
97
+ { value: "other", label: KIND_LABELS.other },
98
+ ]
99
+
100
+ export function MediaManager(props: MediaManagerProps) {
101
+ const {
102
+ client,
103
+ bucketSlug: initialBucketSlug,
104
+ multiple = false,
105
+ accept,
106
+ initialValue,
107
+ onChange,
108
+ onSelect,
109
+ bucketFilter,
110
+ className,
111
+ classNames: cls = {},
112
+ showDetailsPane = true,
113
+ showBucketSelector = true,
114
+ } = props
115
+
116
+ // ── Bucket state ───────────────────────────────────────────────────────
117
+ const [buckets, setBuckets] = useState<Bucket[]>([])
118
+ const [bucketsLoading, setBucketsLoading] = useState(false)
119
+ const [activeBucketSlug, setActiveBucketSlug] = useState<string | null>(
120
+ initialBucketSlug ?? null,
121
+ )
122
+
123
+ useEffect(() => {
124
+ setBucketsLoading(true)
125
+ client.buckets
126
+ .list()
127
+ .then((list) => {
128
+ const filtered = bucketFilter ? list.filter(bucketFilter) : list
129
+ setBuckets(filtered)
130
+ if (!activeBucketSlug && filtered.length > 0) {
131
+ setActiveBucketSlug(filtered[0].slug)
132
+ }
133
+ })
134
+ .catch(() => {
135
+ setBuckets([])
136
+ })
137
+ .finally(() => setBucketsLoading(false))
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [client])
140
+
141
+ // ── Media list ────────────────────────────────────────────────────────
142
+ const [refreshKey, setRefreshKey] = useState(0)
143
+ const { items, loading, error } = useMediaList({
144
+ client,
145
+ bucketSlug: activeBucketSlug,
146
+ refreshKey,
147
+ })
148
+
149
+ // ── Selection ──────────────────────────────────────────────────────────
150
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
151
+ const initial = new Set<string>()
152
+ for (const v of initialValue ?? []) {
153
+ initial.add(typeof v === "string" ? v : v.id)
154
+ }
155
+ return initial
156
+ })
157
+
158
+ // initialValue Media[] ise hemen onChange'i çağır ki parent state senkron
159
+ // başlasın. Sadece mount'ta.
160
+ useEffect(() => {
161
+ if (!initialValue || initialValue.length === 0) return
162
+ const objects = initialValue.filter(
163
+ (v): v is Media => typeof v !== "string",
164
+ )
165
+ if (objects.length > 0) onChange?.(objects)
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, [])
168
+
169
+ const toggleSelect = useCallback(
170
+ (media: Media) => {
171
+ setSelectedIds((prev) => {
172
+ const next = new Set(prev)
173
+ if (multiple) {
174
+ if (next.has(media.id)) next.delete(media.id)
175
+ else next.add(media.id)
176
+ } else {
177
+ // Single: aynısı tıklanırsa deselect
178
+ if (next.has(media.id) && next.size === 1) next.clear()
179
+ else {
180
+ next.clear()
181
+ next.add(media.id)
182
+ }
183
+ }
184
+ return next
185
+ })
186
+ },
187
+ [multiple],
188
+ )
189
+
190
+ const selected = useMemo(
191
+ () => items.filter((m) => selectedIds.has(m.id)),
192
+ [items, selectedIds],
193
+ )
194
+
195
+ // selected değiştiğinde onChange'i çağır
196
+ useEffect(() => {
197
+ onChange?.(selected)
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [selectedIds, items])
200
+
201
+ // ── Search + filter ────────────────────────────────────────────────────
202
+ const [search, setSearch] = useState("")
203
+ const [kindFilter, setKindFilter] = useState<MediaKind | "all">("all")
204
+
205
+ const visibleItems = useMemo(() => {
206
+ const q = search.trim().toLowerCase()
207
+ return items.filter((m) => {
208
+ if (q && !m.fileName.toLowerCase().includes(q)) return false
209
+ if (kindFilter !== "all" && detectKind(m) !== kindFilter) return false
210
+ return true
211
+ })
212
+ }, [items, search, kindFilter])
213
+
214
+ // ── Upload ─────────────────────────────────────────────────────────────
215
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
216
+ const [uploading, setUploading] = useState(false)
217
+ const [dragOver, setDragOver] = useState(false)
218
+
219
+ const uploadFiles = useCallback(
220
+ async (files: FileList | File[]) => {
221
+ if (!activeBucketSlug) return
222
+ setUploading(true)
223
+ try {
224
+ const list = Array.from(files)
225
+ for (const file of list) {
226
+ await client.media.upload(activeBucketSlug, { body: file })
227
+ }
228
+ setRefreshKey((k) => k + 1)
229
+ } finally {
230
+ setUploading(false)
231
+ }
232
+ },
233
+ [client, activeBucketSlug],
234
+ )
235
+
236
+ // ── Delete ─────────────────────────────────────────────────────────────
237
+ const [deletingId, setDeletingId] = useState<string | null>(null)
238
+ const handleDelete = useCallback(
239
+ async (media: Media) => {
240
+ if (!activeBucketSlug) return
241
+ const ok = window.confirm(`Delete ${media.fileName}?`)
242
+ if (!ok) return
243
+ setDeletingId(media.id)
244
+ try {
245
+ await client.media.delete(activeBucketSlug, media.id)
246
+ setSelectedIds((prev) => {
247
+ const next = new Set(prev)
248
+ next.delete(media.id)
249
+ return next
250
+ })
251
+ setRefreshKey((k) => k + 1)
252
+ } finally {
253
+ setDeletingId(null)
254
+ }
255
+ },
256
+ [client, activeBucketSlug],
257
+ )
258
+
259
+ // ── Lightbox (spacebar opens for active selection) ─────────────────────
260
+ const [lightboxIdx, setLightboxIdx] = useState<number | null>(null)
261
+ useEffect(() => {
262
+ const onKey = (e: KeyboardEvent) => {
263
+ if (e.code !== "Space") return
264
+ // Input/textarea içinde değilse spacebar lightbox'ı açar.
265
+ const tag = (e.target as HTMLElement)?.tagName?.toLowerCase()
266
+ if (tag === "input" || tag === "textarea" || tag === "select") return
267
+ if (selected.length === 0 || lightboxIdx !== null) return
268
+ e.preventDefault()
269
+ const idx = visibleItems.findIndex((m) => m.id === selected[0].id)
270
+ if (idx >= 0) setLightboxIdx(idx)
271
+ }
272
+ document.addEventListener("keydown", onKey)
273
+ return () => document.removeEventListener("keydown", onKey)
274
+ }, [selected, visibleItems, lightboxIdx])
275
+
276
+ // ── Render ─────────────────────────────────────────────────────────────
277
+ return (
278
+ <div
279
+ className={cn(
280
+ "flex flex-col gap-3 rounded-xl border bg-background text-foreground",
281
+ className,
282
+ cls.root,
283
+ )}
284
+ onDragOver={(e) => {
285
+ e.preventDefault()
286
+ setDragOver(true)
287
+ }}
288
+ onDragLeave={() => setDragOver(false)}
289
+ onDrop={(e) => {
290
+ e.preventDefault()
291
+ setDragOver(false)
292
+ if (e.dataTransfer.files.length > 0) uploadFiles(e.dataTransfer.files)
293
+ }}
294
+ >
295
+ {/* Toolbar */}
296
+ <div
297
+ className={cn(
298
+ "flex flex-wrap items-center gap-2 border-b px-3 py-2",
299
+ cls.toolbar,
300
+ )}
301
+ >
302
+ {showBucketSelector && (
303
+ <select
304
+ value={activeBucketSlug ?? ""}
305
+ onChange={(e) => setActiveBucketSlug(e.target.value || null)}
306
+ disabled={bucketsLoading || buckets.length === 0}
307
+ className={cn(
308
+ "h-8 rounded-md border bg-transparent px-2 text-xs",
309
+ cls.bucketSelect,
310
+ )}
311
+ >
312
+ {buckets.length === 0 && <option value="">No buckets</option>}
313
+ {buckets.map((b) => (
314
+ <option key={b.id} value={b.slug}>
315
+ {b.name}
316
+ </option>
317
+ ))}
318
+ </select>
319
+ )}
320
+ <input
321
+ type="search"
322
+ value={search}
323
+ onChange={(e) => setSearch(e.target.value)}
324
+ placeholder="Search files…"
325
+ className={cn(
326
+ "h-8 flex-1 rounded-md border bg-transparent px-2 text-xs",
327
+ cls.searchInput,
328
+ )}
329
+ />
330
+ <select
331
+ value={kindFilter}
332
+ onChange={(e) => setKindFilter(e.target.value as MediaKind | "all")}
333
+ className={cn(
334
+ "h-8 rounded-md border bg-transparent px-2 text-xs",
335
+ cls.filterSelect,
336
+ )}
337
+ >
338
+ {KIND_FILTERS.map((f) => (
339
+ <option key={f.value} value={f.value}>
340
+ {f.label}
341
+ </option>
342
+ ))}
343
+ </select>
344
+ <button
345
+ type="button"
346
+ onClick={() => fileInputRef.current?.click()}
347
+ disabled={!activeBucketSlug || uploading}
348
+ className={cn(
349
+ "h-8 rounded-md border bg-foreground px-3 text-xs font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50",
350
+ cls.uploadButton,
351
+ )}
352
+ >
353
+ {uploading ? "Uploading…" : "Upload"}
354
+ </button>
355
+ <input
356
+ ref={fileInputRef}
357
+ type="file"
358
+ multiple={multiple || true}
359
+ accept={accept}
360
+ className="hidden"
361
+ onChange={(e) => {
362
+ if (e.target.files && e.target.files.length > 0) {
363
+ uploadFiles(e.target.files)
364
+ e.target.value = ""
365
+ }
366
+ }}
367
+ />
368
+ </div>
369
+
370
+ {/* Grid + details */}
371
+ <div className="flex min-h-[280px] flex-1">
372
+ <div className="flex-1 overflow-y-auto p-3">
373
+ {loading && (
374
+ <div className="grid gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
375
+ {Array.from({ length: 8 }).map((_, i) => (
376
+ <div
377
+ key={i}
378
+ className="h-28 animate-pulse rounded-md bg-muted/50"
379
+ />
380
+ ))}
381
+ </div>
382
+ )}
383
+ {error && (
384
+ <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
385
+ {error}
386
+ </div>
387
+ )}
388
+ {!loading && !error && visibleItems.length === 0 && (
389
+ <div
390
+ className={cn(
391
+ "flex h-full flex-col items-center justify-center gap-2 py-8 text-center text-sm text-muted-foreground",
392
+ cls.empty,
393
+ )}
394
+ >
395
+ <span>No files match.</span>
396
+ <button
397
+ type="button"
398
+ onClick={() => fileInputRef.current?.click()}
399
+ className="text-xs underline"
400
+ >
401
+ Upload one
402
+ </button>
403
+ </div>
404
+ )}
405
+ {!loading && !error && visibleItems.length > 0 && (
406
+ <div
407
+ className={cn(
408
+ "grid gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
409
+ cls.grid,
410
+ )}
411
+ >
412
+ {visibleItems.map((media) => {
413
+ const isSel = selectedIds.has(media.id)
414
+ const kind = detectKind(media)
415
+ const thumb =
416
+ kind === "image" ? media.url || media.downloadUrl : null
417
+ return (
418
+ <button
419
+ key={media.id}
420
+ type="button"
421
+ onClick={() => toggleSelect(media)}
422
+ onDoubleClick={() => {
423
+ const idx = visibleItems.findIndex(
424
+ (m) => m.id === media.id,
425
+ )
426
+ if (idx >= 0) {
427
+ if (!isSel) toggleSelect(media)
428
+ setLightboxIdx(idx)
429
+ if (multiple === false && onSelect) {
430
+ onSelect([media])
431
+ }
432
+ }
433
+ }}
434
+ className={cn(
435
+ "group flex flex-col overflow-hidden rounded-md border text-start transition-all",
436
+ isSel
437
+ ? cn(
438
+ "border-foreground/60 ring-2 ring-foreground/30",
439
+ cls.cardSelected,
440
+ )
441
+ : "border-border hover:border-foreground/30",
442
+ cls.card,
443
+ )}
444
+ >
445
+ <div
446
+ className={cn(
447
+ "relative aspect-square overflow-hidden bg-muted/40",
448
+ cls.thumbnail,
449
+ )}
450
+ >
451
+ {thumb ? (
452
+ // eslint-disable-next-line @next/next/no-img-element
453
+ <img
454
+ src={thumb}
455
+ alt={media.alt ?? media.fileName}
456
+ className="size-full object-cover"
457
+ />
458
+ ) : (
459
+ <div className="flex size-full items-center justify-center text-[10px] uppercase text-muted-foreground">
460
+ {KIND_LABELS[kind]}
461
+ </div>
462
+ )}
463
+ {isSel && (
464
+ <div className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-foreground text-background">
465
+ <svg
466
+ width="12"
467
+ height="12"
468
+ viewBox="0 0 24 24"
469
+ fill="none"
470
+ stroke="currentColor"
471
+ strokeWidth="3"
472
+ >
473
+ <path d="M20 6L9 17l-5-5" />
474
+ </svg>
475
+ </div>
476
+ )}
477
+ </div>
478
+ <div
479
+ className={cn(
480
+ "flex flex-col gap-0.5 px-2 py-1.5 text-[11px]",
481
+ cls.cardMeta,
482
+ )}
483
+ >
484
+ <span className="truncate font-medium">
485
+ {media.fileName}
486
+ </span>
487
+ <span className="text-[10px] text-muted-foreground">
488
+ {formatBytes(media.size ?? 0)}
489
+ </span>
490
+ </div>
491
+ </button>
492
+ )
493
+ })}
494
+ </div>
495
+ )}
496
+ </div>
497
+
498
+ {showDetailsPane && (
499
+ <aside
500
+ className={cn(
501
+ "hidden w-64 shrink-0 flex-col gap-2 border-s bg-muted/10 p-3 lg:flex",
502
+ cls.details,
503
+ )}
504
+ >
505
+ {selected.length === 0 ? (
506
+ <div className="text-xs text-muted-foreground">
507
+ Select a file to see details. Press{" "}
508
+ <kbd className="rounded border bg-muted px-1 text-[10px]">
509
+ Space
510
+ </kbd>{" "}
511
+ to preview.
512
+ </div>
513
+ ) : selected.length === 1 ? (
514
+ (() => {
515
+ const m = selected[0]
516
+ const kind = detectKind(m)
517
+ return (
518
+ <>
519
+ <div className="aspect-square overflow-hidden rounded-md bg-muted/30">
520
+ {kind === "image" && (m.url || m.downloadUrl) ? (
521
+ // eslint-disable-next-line @next/next/no-img-element
522
+ <img
523
+ src={m.url || m.downloadUrl || ""}
524
+ alt={m.alt ?? m.fileName}
525
+ className="size-full object-cover"
526
+ />
527
+ ) : (
528
+ <div className="flex size-full items-center justify-center text-xs text-muted-foreground">
529
+ {KIND_LABELS[kind]}
530
+ </div>
531
+ )}
532
+ </div>
533
+ <div className="flex flex-col gap-0.5">
534
+ <span className="break-all text-xs font-medium">
535
+ {m.fileName}
536
+ </span>
537
+ <span className="text-[10px] text-muted-foreground">
538
+ {formatBytes(m.size ?? 0)}
539
+ {m.mimeType ? ` · ${m.mimeType}` : ""}
540
+ </span>
541
+ </div>
542
+ <div className="mt-auto flex items-center gap-2">
543
+ <button
544
+ type="button"
545
+ onClick={() => {
546
+ const idx = visibleItems.findIndex(
547
+ (it) => it.id === m.id,
548
+ )
549
+ if (idx >= 0) setLightboxIdx(idx)
550
+ }}
551
+ className="flex-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted/50"
552
+ >
553
+ Preview
554
+ </button>
555
+ <button
556
+ type="button"
557
+ onClick={() => handleDelete(m)}
558
+ disabled={deletingId === m.id}
559
+ className="flex-1 rounded-md border border-destructive/30 px-2 py-1 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
560
+ >
561
+ {deletingId === m.id ? "…" : "Delete"}
562
+ </button>
563
+ </div>
564
+ {onSelect && (
565
+ <button
566
+ type="button"
567
+ onClick={() => onSelect(selected)}
568
+ className="rounded-md bg-foreground px-2 py-1.5 text-[11px] font-medium text-background hover:opacity-90"
569
+ >
570
+ Use selection
571
+ </button>
572
+ )}
573
+ </>
574
+ )
575
+ })()
576
+ ) : (
577
+ <>
578
+ <div className="text-xs font-medium">
579
+ {selected.length} files selected
580
+ </div>
581
+ <div className="text-[10px] text-muted-foreground">
582
+ Total {formatBytes(
583
+ selected.reduce((s, m) => s + (m.size ?? 0), 0),
584
+ )}
585
+ </div>
586
+ {onSelect && (
587
+ <button
588
+ type="button"
589
+ onClick={() => onSelect(selected)}
590
+ className="mt-auto rounded-md bg-foreground px-2 py-1.5 text-[11px] font-medium text-background hover:opacity-90"
591
+ >
592
+ Use selection
593
+ </button>
594
+ )}
595
+ </>
596
+ )}
597
+ </aside>
598
+ )}
599
+ </div>
600
+
601
+ {/* Drop overlay */}
602
+ {dragOver && (
603
+ <div
604
+ className={cn(
605
+ "pointer-events-none absolute inset-0 rounded-xl border-2 border-dashed border-foreground/40 bg-foreground/5",
606
+ cls.dropZoneOverlay,
607
+ )}
608
+ />
609
+ )}
610
+
611
+ {/* Lightbox */}
612
+ {lightboxIdx !== null && visibleItems[lightboxIdx] && (
613
+ <Lightbox
614
+ media={visibleItems[lightboxIdx]}
615
+ onClose={() => setLightboxIdx(null)}
616
+ onPrev={
617
+ lightboxIdx > 0 ? () => setLightboxIdx(lightboxIdx - 1) : undefined
618
+ }
619
+ onNext={
620
+ lightboxIdx < visibleItems.length - 1
621
+ ? () => setLightboxIdx(lightboxIdx + 1)
622
+ : undefined
623
+ }
624
+ />
625
+ )}
626
+ </div>
627
+ )
628
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ MediaManager,
3
+ type MediaManagerProps,
4
+ type MediaManagerClassNames,
5
+ } from "./MediaManager"
6
+ export { Lightbox, type LightboxProps } from "./lib/Lightbox"
7
+ export {
8
+ cn,
9
+ formatBytes,
10
+ detectKind,
11
+ KIND_LABELS,
12
+ type MediaKind,
13
+ } from "./lib/utils"