@sentroy-co/client-sdk 2.1.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.
- package/dist/react/MediaManager.d.ts +69 -0
- package/dist/react/MediaManager.d.ts.map +1 -0
- package/dist/react/MediaManager.js +216 -0
- package/dist/react/MediaManager.js.map +1 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +13 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/lib/Lightbox.d.ts +18 -0
- package/dist/react/lib/Lightbox.d.ts.map +1 -0
- package/dist/react/lib/Lightbox.js +40 -0
- package/dist/react/lib/Lightbox.js.map +1 -0
- package/dist/react/lib/use-media-list.d.ts +21 -0
- package/dist/react/lib/use-media-list.d.ts.map +1 -0
- package/dist/react/lib/use-media-list.js +50 -0
- package/dist/react/lib/use-media-list.js.map +1 -0
- package/dist/react/lib/utils.d.ts +17 -0
- package/dist/react/lib/utils.d.ts.map +1 -0
- package/dist/react/lib/utils.js +62 -0
- package/dist/react/lib/utils.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +40 -4
- package/src/http.ts +151 -0
- package/src/index.ts +106 -0
- package/src/react/MediaManager.tsx +628 -0
- package/src/react/index.ts +13 -0
- package/src/react/lib/Lightbox.tsx +162 -0
- package/src/react/lib/use-media-list.ts +54 -0
- package/src/react/lib/utils.ts +73 -0
- package/src/resources/buckets.ts +50 -0
- package/src/resources/domains.ts +16 -0
- package/src/resources/inbox.ts +99 -0
- package/src/resources/mailboxes.ts +11 -0
- package/src/resources/media.ts +115 -0
- package/src/resources/send.ts +11 -0
- package/src/resources/storage.ts +28 -0
- package/src/resources/templates.ts +16 -0
- 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"
|