@sentroy-co/client-sdk 2.2.1 → 2.4.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.
- package/README.md +157 -1
- package/dist/http.d.ts +13 -2
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +22 -6
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/react/MediaManager.d.ts +13 -1
- package/dist/react/MediaManager.d.ts.map +1 -1
- package/dist/react/MediaManager.js +24 -8
- package/dist/react/MediaManager.js.map +1 -1
- package/dist/react/MediaManagerTrigger.d.ts +63 -0
- package/dist/react/MediaManagerTrigger.d.ts.map +1 -0
- package/dist/react/MediaManagerTrigger.js +81 -0
- package/dist/react/MediaManagerTrigger.js.map +1 -0
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/Lightbox.d.ts.map +1 -1
- package/dist/react/lib/Lightbox.js +7 -1
- package/dist/react/lib/Lightbox.js.map +1 -1
- package/dist/react/lib/utils.d.ts +14 -0
- package/dist/react/lib/utils.d.ts.map +1 -1
- package/dist/react/lib/utils.js +36 -0
- package/dist/react/lib/utils.js.map +1 -1
- package/dist/thumbnails.d.ts +67 -0
- package/dist/thumbnails.d.ts.map +1 -0
- package/dist/thumbnails.js +106 -0
- package/dist/thumbnails.js.map +1 -0
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/http.ts +26 -8
- package/src/index.ts +8 -0
- package/src/react/MediaManager.tsx +38 -8
- package/src/react/MediaManagerTrigger.tsx +260 -0
- package/src/react/index.ts +5 -0
- package/src/react/lib/Lightbox.tsx +8 -1
- package/src/react/lib/utils.ts +34 -0
- package/src/thumbnails.ts +128 -0
- package/src/types.ts +10 -2
|
@@ -9,11 +9,13 @@ import type { Sentroy } from ".."
|
|
|
9
9
|
import type { Bucket, Media } from "../types"
|
|
10
10
|
import { Lightbox } from "./lib/Lightbox"
|
|
11
11
|
import { useMediaList } from "./lib/use-media-list"
|
|
12
|
+
import { pickPresetThumbnailUrl } from "../thumbnails"
|
|
12
13
|
import {
|
|
13
14
|
cn,
|
|
14
15
|
detectKind,
|
|
15
16
|
formatBytes,
|
|
16
17
|
KIND_LABELS,
|
|
18
|
+
matchAccept,
|
|
17
19
|
type MediaKind,
|
|
18
20
|
} from "./lib/utils"
|
|
19
21
|
|
|
@@ -64,7 +66,19 @@ export interface MediaManagerProps {
|
|
|
64
66
|
bucketSlug?: string
|
|
65
67
|
/** Birden fazla seçilebilir mi? Default false. */
|
|
66
68
|
multiple?: boolean
|
|
67
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Multi-mode'da cap. multiple=true iken cap'e ulaşılınca yeni seçim
|
|
71
|
+
* sessizce engellenir. multiple=false iken yok sayılır (single = 1).
|
|
72
|
+
* Default: undefined (cap yok).
|
|
73
|
+
*/
|
|
74
|
+
maxItems?: number
|
|
75
|
+
/**
|
|
76
|
+
* Upload + grid filter için MIME pattern — örn `"image/*"`,
|
|
77
|
+
* `"image/png,image/jpeg"`, `"video/*,application/pdf"`.
|
|
78
|
+
* - Upload `<input type="file">` `accept` özniteliğine geçer.
|
|
79
|
+
* - Grid'de `accept` ile uyumlu olmayan item'lar gizlenir
|
|
80
|
+
* (kullanıcı yine de bucket'ında görebilir ama picker'da seçemez).
|
|
81
|
+
*/
|
|
68
82
|
accept?: string
|
|
69
83
|
/** Kullanıcının önceden seçtiği item'lar — Media obje ya da id string. */
|
|
70
84
|
initialValue?: Array<Media | string>
|
|
@@ -102,6 +116,7 @@ export function MediaManager(props: MediaManagerProps) {
|
|
|
102
116
|
client,
|
|
103
117
|
bucketSlug: initialBucketSlug,
|
|
104
118
|
multiple = false,
|
|
119
|
+
maxItems,
|
|
105
120
|
accept,
|
|
106
121
|
initialValue,
|
|
107
122
|
onChange,
|
|
@@ -171,8 +186,16 @@ export function MediaManager(props: MediaManagerProps) {
|
|
|
171
186
|
setSelectedIds((prev) => {
|
|
172
187
|
const next = new Set(prev)
|
|
173
188
|
if (multiple) {
|
|
174
|
-
if (next.has(media.id))
|
|
175
|
-
|
|
189
|
+
if (next.has(media.id)) {
|
|
190
|
+
next.delete(media.id)
|
|
191
|
+
} else {
|
|
192
|
+
// maxItems cap — multi-mode'da limite ulaşıldıysa yeni
|
|
193
|
+
// seçimi engelle (mevcut state'i değiştirmeden döndür).
|
|
194
|
+
if (typeof maxItems === "number" && next.size >= maxItems) {
|
|
195
|
+
return prev
|
|
196
|
+
}
|
|
197
|
+
next.add(media.id)
|
|
198
|
+
}
|
|
176
199
|
} else {
|
|
177
200
|
// Single: aynısı tıklanırsa deselect
|
|
178
201
|
if (next.has(media.id) && next.size === 1) next.clear()
|
|
@@ -184,7 +207,7 @@ export function MediaManager(props: MediaManagerProps) {
|
|
|
184
207
|
return next
|
|
185
208
|
})
|
|
186
209
|
},
|
|
187
|
-
[multiple],
|
|
210
|
+
[multiple, maxItems],
|
|
188
211
|
)
|
|
189
212
|
|
|
190
213
|
const selected = useMemo(
|
|
@@ -207,9 +230,10 @@ export function MediaManager(props: MediaManagerProps) {
|
|
|
207
230
|
return items.filter((m) => {
|
|
208
231
|
if (q && !m.fileName.toLowerCase().includes(q)) return false
|
|
209
232
|
if (kindFilter !== "all" && detectKind(m) !== kindFilter) return false
|
|
233
|
+
if (accept && !matchAccept(m, accept)) return false
|
|
210
234
|
return true
|
|
211
235
|
})
|
|
212
|
-
}, [items, search, kindFilter])
|
|
236
|
+
}, [items, search, kindFilter, accept])
|
|
213
237
|
|
|
214
238
|
// ── Upload ─────────────────────────────────────────────────────────────
|
|
215
239
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
@@ -412,8 +436,13 @@ export function MediaManager(props: MediaManagerProps) {
|
|
|
412
436
|
{visibleItems.map((media) => {
|
|
413
437
|
const isSel = selectedIds.has(media.id)
|
|
414
438
|
const kind = detectKind(media)
|
|
439
|
+
// Grid card 200-300 px display — orijinal 4K JPG yerine
|
|
440
|
+
// "card" preset (~500px) thumbnail. Gerçek thumbnail yoksa
|
|
441
|
+
// helper orijinale fallback yapar.
|
|
415
442
|
const thumb =
|
|
416
|
-
kind === "image"
|
|
443
|
+
kind === "image"
|
|
444
|
+
? pickPresetThumbnailUrl(media, "card") ?? null
|
|
445
|
+
: null
|
|
417
446
|
return (
|
|
418
447
|
<button
|
|
419
448
|
key={media.id}
|
|
@@ -517,10 +546,11 @@ export function MediaManager(props: MediaManagerProps) {
|
|
|
517
546
|
return (
|
|
518
547
|
<>
|
|
519
548
|
<div className="aspect-square overflow-hidden rounded-md bg-muted/30">
|
|
520
|
-
{kind === "image" &&
|
|
549
|
+
{kind === "image" &&
|
|
550
|
+
pickPresetThumbnailUrl(m, "card") ? (
|
|
521
551
|
// eslint-disable-next-line @next/next/no-img-element
|
|
522
552
|
<img
|
|
523
|
-
src={m
|
|
553
|
+
src={pickPresetThumbnailUrl(m, "card") ?? ""}
|
|
524
554
|
alt={m.alt ?? m.fileName}
|
|
525
555
|
className="size-full object-cover"
|
|
526
556
|
/>
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type ReactNode } from "react"
|
|
2
|
+
import { createPortal } from "react-dom"
|
|
3
|
+
import type { Media } from "../types"
|
|
4
|
+
import { MediaManager, type MediaManagerProps } from "./MediaManager"
|
|
5
|
+
import { cn } from "./lib/utils"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MediaManagerTrigger — herhangi bir consumer-defined öğe (button, avatar
|
|
9
|
+
* thumb, ikon, vb.) tıklandığında MediaManager'ı modal içinde açan
|
|
10
|
+
* sarmalayıcı.
|
|
11
|
+
*
|
|
12
|
+
* Tasarım hedefi: kullanıcı kendi tetikleyicisini (`trigger` prop) verir,
|
|
13
|
+
* tıklanma + modal yönetimi + confirm/cancel akışı SDK tarafında olur.
|
|
14
|
+
* Böylece consumer her seferinde Dialog state ve render boilerplate'ini
|
|
15
|
+
* yazmak zorunda kalmaz — sadece "şu butonum şu callback'i tetiklesin"
|
|
16
|
+
* kadar kısa olur.
|
|
17
|
+
*
|
|
18
|
+
* Modal Tailwind utility class kullanır (host app'in design token'ları)
|
|
19
|
+
* ve `react-dom` portal ile `<body>`'ye render edilir — parent
|
|
20
|
+
* overflow:hidden / transform stacking context'lerine takılmaz.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <MediaManagerTrigger
|
|
25
|
+
* client={client}
|
|
26
|
+
* trigger={<Button>Change avatar</Button>}
|
|
27
|
+
* maxItems={1}
|
|
28
|
+
* accept="image/*"
|
|
29
|
+
* onSelect={(media) => console.log(media[0].url)}
|
|
30
|
+
* />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface MediaManagerTriggerProps
|
|
35
|
+
extends Omit<MediaManagerProps, "onSelect" | "onChange" | "multiple"> {
|
|
36
|
+
/** Tıklanabilir herhangi bir öğe — button, image, div, vb. */
|
|
37
|
+
trigger: ReactNode
|
|
38
|
+
/**
|
|
39
|
+
* Maksimum seçilebilir item sayısı. 1 = single mode, >1 = multi up to
|
|
40
|
+
* cap. Default 1. Cap'e ulaşılınca yeni item seçimi sessizce engellenir.
|
|
41
|
+
*/
|
|
42
|
+
maxItems?: number
|
|
43
|
+
/** Confirm — kullanıcı "Use selection" butonuna bastığında çağrılır. */
|
|
44
|
+
onSelect: (selected: Media[]) => void
|
|
45
|
+
/** Modal başlığı. Default "Select media". */
|
|
46
|
+
title?: string
|
|
47
|
+
/** Modal description satırı (opsiyonel). */
|
|
48
|
+
description?: string
|
|
49
|
+
/**
|
|
50
|
+
* Controlled mode — open state'ini parent yönetmek isterse.
|
|
51
|
+
* Default uncontrolled (kendi içinde useState).
|
|
52
|
+
*/
|
|
53
|
+
open?: boolean
|
|
54
|
+
/** Controlled mode için open değişikliği callback'i. */
|
|
55
|
+
onOpenChange?: (open: boolean) => void
|
|
56
|
+
/** Modal panel class override. */
|
|
57
|
+
modalClassName?: string
|
|
58
|
+
/** Trigger wrapper class — default `inline-block cursor-pointer`. */
|
|
59
|
+
triggerClassName?: string
|
|
60
|
+
/** Confirm butonu metni. Default "Use selection". */
|
|
61
|
+
confirmLabel?: string
|
|
62
|
+
/** Cancel butonu metni. Default "Cancel". */
|
|
63
|
+
cancelLabel?: string
|
|
64
|
+
/** Trigger'ın disabled durumu — modal açılmaz. */
|
|
65
|
+
disabled?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function MediaManagerTrigger(props: MediaManagerTriggerProps) {
|
|
69
|
+
const {
|
|
70
|
+
trigger,
|
|
71
|
+
maxItems = 1,
|
|
72
|
+
onSelect,
|
|
73
|
+
title = "Select media",
|
|
74
|
+
description,
|
|
75
|
+
open: controlledOpen,
|
|
76
|
+
onOpenChange,
|
|
77
|
+
modalClassName,
|
|
78
|
+
triggerClassName,
|
|
79
|
+
confirmLabel = "Use selection",
|
|
80
|
+
cancelLabel = "Cancel",
|
|
81
|
+
disabled = false,
|
|
82
|
+
...mmProps
|
|
83
|
+
} = props
|
|
84
|
+
|
|
85
|
+
const [internalOpen, setInternalOpen] = useState(false)
|
|
86
|
+
const [selected, setSelected] = useState<Media[]>([])
|
|
87
|
+
const [mounted, setMounted] = useState(false)
|
|
88
|
+
|
|
89
|
+
// SSR guard — portal yalnızca client'ta.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setMounted(true)
|
|
92
|
+
}, [])
|
|
93
|
+
|
|
94
|
+
const isControlled = controlledOpen !== undefined
|
|
95
|
+
const open = isControlled ? controlledOpen : internalOpen
|
|
96
|
+
const setOpen = useCallback(
|
|
97
|
+
(v: boolean) => {
|
|
98
|
+
if (!isControlled) setInternalOpen(v)
|
|
99
|
+
onOpenChange?.(v)
|
|
100
|
+
if (!v) setSelected([])
|
|
101
|
+
},
|
|
102
|
+
[isControlled, onOpenChange],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// ESC — modal'ı kapat.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!open) return
|
|
108
|
+
const onKey = (e: KeyboardEvent) => {
|
|
109
|
+
if (e.key === "Escape") {
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
setOpen(false)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
document.addEventListener("keydown", onKey)
|
|
115
|
+
return () => document.removeEventListener("keydown", onKey)
|
|
116
|
+
}, [open, setOpen])
|
|
117
|
+
|
|
118
|
+
// Body scroll lock — modal açıkken arka planda scroll olmasın.
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!open) return
|
|
121
|
+
const original = document.body.style.overflow
|
|
122
|
+
document.body.style.overflow = "hidden"
|
|
123
|
+
return () => {
|
|
124
|
+
document.body.style.overflow = original
|
|
125
|
+
}
|
|
126
|
+
}, [open])
|
|
127
|
+
|
|
128
|
+
const handleConfirm = useCallback(() => {
|
|
129
|
+
onSelect(selected)
|
|
130
|
+
setOpen(false)
|
|
131
|
+
}, [onSelect, selected, setOpen])
|
|
132
|
+
|
|
133
|
+
const handleTriggerClick = useCallback(() => {
|
|
134
|
+
if (disabled) return
|
|
135
|
+
setOpen(true)
|
|
136
|
+
}, [disabled, setOpen])
|
|
137
|
+
|
|
138
|
+
// Trigger — span'a click handler bağla. Consumer'ın trigger'ı zaten
|
|
139
|
+
// button olabilir; bu durumda nested button HTML invalid olur ama
|
|
140
|
+
// tarayıcılar tolere eder. İstenirse triggerClassName ile span yerine
|
|
141
|
+
// başka semantik kullanılabilir (consumer kendi trigger'ında onClick
|
|
142
|
+
// override edemez — modal handler'ı her zaman çalışır).
|
|
143
|
+
const triggerNode = (
|
|
144
|
+
<span
|
|
145
|
+
role="button"
|
|
146
|
+
tabIndex={disabled ? -1 : 0}
|
|
147
|
+
aria-haspopup="dialog"
|
|
148
|
+
aria-expanded={open}
|
|
149
|
+
aria-disabled={disabled}
|
|
150
|
+
onClick={handleTriggerClick}
|
|
151
|
+
onKeyDown={(e) => {
|
|
152
|
+
if (disabled) return
|
|
153
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
setOpen(true)
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
className={cn(
|
|
159
|
+
"inline-block",
|
|
160
|
+
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
|
161
|
+
triggerClassName,
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{trigger}
|
|
165
|
+
</span>
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const modalNode =
|
|
169
|
+
open && mounted ? (
|
|
170
|
+
<div
|
|
171
|
+
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
|
172
|
+
role="dialog"
|
|
173
|
+
aria-modal="true"
|
|
174
|
+
aria-labelledby="sentroy-mm-title"
|
|
175
|
+
>
|
|
176
|
+
<div
|
|
177
|
+
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
178
|
+
onClick={() => setOpen(false)}
|
|
179
|
+
/>
|
|
180
|
+
<div
|
|
181
|
+
className={cn(
|
|
182
|
+
"relative z-10 flex h-[85vh] w-full max-w-5xl flex-col gap-3 rounded-xl border bg-background p-4 shadow-2xl",
|
|
183
|
+
modalClassName,
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
<div className="flex items-start justify-between gap-2">
|
|
187
|
+
<div className="flex flex-col gap-0.5">
|
|
188
|
+
<h2 id="sentroy-mm-title" className="text-base font-semibold">
|
|
189
|
+
{title}
|
|
190
|
+
</h2>
|
|
191
|
+
{description && (
|
|
192
|
+
<p className="text-xs text-muted-foreground">{description}</p>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => setOpen(false)}
|
|
198
|
+
className="rounded-md p-1 text-muted-foreground hover:bg-muted/50"
|
|
199
|
+
aria-label="Close"
|
|
200
|
+
>
|
|
201
|
+
<svg
|
|
202
|
+
width="16"
|
|
203
|
+
height="16"
|
|
204
|
+
viewBox="0 0 24 24"
|
|
205
|
+
fill="none"
|
|
206
|
+
stroke="currentColor"
|
|
207
|
+
strokeWidth="2"
|
|
208
|
+
>
|
|
209
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
210
|
+
</svg>
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
214
|
+
<MediaManager
|
|
215
|
+
{...mmProps}
|
|
216
|
+
multiple={maxItems > 1}
|
|
217
|
+
maxItems={maxItems}
|
|
218
|
+
onChange={setSelected}
|
|
219
|
+
className={cn("h-full", mmProps.className)}
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
|
223
|
+
<div className="text-xs text-muted-foreground">
|
|
224
|
+
{selected.length === 0
|
|
225
|
+
? maxItems === 1
|
|
226
|
+
? "Select an item"
|
|
227
|
+
: `Select up to ${maxItems} items`
|
|
228
|
+
: maxItems === 1
|
|
229
|
+
? "1 item selected"
|
|
230
|
+
: `${selected.length} / ${maxItems} selected`}
|
|
231
|
+
</div>
|
|
232
|
+
<div className="flex items-center gap-2">
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => setOpen(false)}
|
|
236
|
+
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted/50"
|
|
237
|
+
>
|
|
238
|
+
{cancelLabel}
|
|
239
|
+
</button>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={handleConfirm}
|
|
243
|
+
disabled={selected.length === 0}
|
|
244
|
+
className="rounded-md bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
245
|
+
>
|
|
246
|
+
{confirmLabel}
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
) : null
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
{triggerNode}
|
|
257
|
+
{mounted && modalNode ? createPortal(modalNode, document.body) : null}
|
|
258
|
+
</>
|
|
259
|
+
)
|
|
260
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -3,11 +3,16 @@ export {
|
|
|
3
3
|
type MediaManagerProps,
|
|
4
4
|
type MediaManagerClassNames,
|
|
5
5
|
} from "./MediaManager"
|
|
6
|
+
export {
|
|
7
|
+
MediaManagerTrigger,
|
|
8
|
+
type MediaManagerTriggerProps,
|
|
9
|
+
} from "./MediaManagerTrigger"
|
|
6
10
|
export { Lightbox, type LightboxProps } from "./lib/Lightbox"
|
|
7
11
|
export {
|
|
8
12
|
cn,
|
|
9
13
|
formatBytes,
|
|
10
14
|
detectKind,
|
|
15
|
+
matchAccept,
|
|
11
16
|
KIND_LABELS,
|
|
12
17
|
type MediaKind,
|
|
13
18
|
} from "./lib/utils"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect } from "react"
|
|
2
2
|
import type { Media } from "../../types"
|
|
3
|
+
import { pickPresetThumbnailUrl } from "../../thumbnails"
|
|
3
4
|
import { detectKind, formatBytes } from "./utils"
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -40,7 +41,13 @@ export function Lightbox({
|
|
|
40
41
|
}, [onClose, onPrev, onNext])
|
|
41
42
|
|
|
42
43
|
const kind = detectKind(media)
|
|
43
|
-
|
|
44
|
+
// Lightbox modal — büyük preview ama 4K/orijinal gerekmez. Image
|
|
45
|
+
// için "preview" preset (~960px) kullan, video/audio/diğerlerinde
|
|
46
|
+
// orijinal URL'i akıt.
|
|
47
|
+
const url =
|
|
48
|
+
kind === "image"
|
|
49
|
+
? pickPresetThumbnailUrl(media, "preview") ?? media.url ?? media.downloadUrl
|
|
50
|
+
: media.url ?? media.downloadUrl
|
|
44
51
|
|
|
45
52
|
return (
|
|
46
53
|
<div
|
package/src/react/lib/utils.ts
CHANGED
|
@@ -71,3 +71,37 @@ export const KIND_LABELS: Record<MediaKind, string> = {
|
|
|
71
71
|
code: "Code",
|
|
72
72
|
other: "Other",
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `<input accept="...">` semantics — pattern listesi virgülle ayrılır,
|
|
77
|
+
* her parça:
|
|
78
|
+
* - `image/*` → MIME wildcard (image/png, image/jpeg ✓)
|
|
79
|
+
* - `image/png` → MIME exact match
|
|
80
|
+
* - `.png` → file extension (case-insensitive)
|
|
81
|
+
*
|
|
82
|
+
* Hiçbir parça eşleşmezse false. Accept boş/undefined olursa caller
|
|
83
|
+
* filter'ı atlamalı — bu fonksiyon o yüzden boş string'i false döner.
|
|
84
|
+
*/
|
|
85
|
+
export function matchAccept(
|
|
86
|
+
file: { mimeType?: string | null; fileName?: string },
|
|
87
|
+
accept: string,
|
|
88
|
+
): boolean {
|
|
89
|
+
const patterns = accept
|
|
90
|
+
.split(",")
|
|
91
|
+
.map((s) => s.trim().toLowerCase())
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
if (patterns.length === 0) return false
|
|
94
|
+
const mt = (file.mimeType ?? "").toLowerCase()
|
|
95
|
+
const fn = (file.fileName ?? "").toLowerCase()
|
|
96
|
+
for (const p of patterns) {
|
|
97
|
+
if (p.startsWith(".")) {
|
|
98
|
+
if (fn.endsWith(p)) return true
|
|
99
|
+
} else if (p.endsWith("/*")) {
|
|
100
|
+
const prefix = p.slice(0, -1) // "image/*" → "image/"
|
|
101
|
+
if (mt.startsWith(prefix)) return true
|
|
102
|
+
} else if (p === mt) {
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media thumbnail URL helpers — display target'ına göre uygun boyutta
|
|
3
|
+
* URL döndürür. Orijinal kaliteyi avatar/grid card gibi küçük yerlerde
|
|
4
|
+
* göstermek bandwidth ve render cost açısından mantıksız.
|
|
5
|
+
*
|
|
6
|
+
* CDN tarafı upload sırasında image'lar için pre-generated thumbnail'lar
|
|
7
|
+
* üretir (`imageMeta.thumbnails[]`). Bu helper:
|
|
8
|
+
* 1. Hedef boyutu kapsayacak en küçük thumbnail'ı seçer (yoksa en büyük).
|
|
9
|
+
* 2. Thumbnail URL'i exposed ise direkt onu kullanır.
|
|
10
|
+
* 3. Değilse `media.url`'in CDN prefix'ine `thumbnail.fileName` ekleyerek
|
|
11
|
+
* URL inşa eder ({cdn-base}/{bucketId}/{thumbnail.fileName}).
|
|
12
|
+
* 4. Hiçbir public URL yoksa download endpoint'ini `?quality=N` ile döner.
|
|
13
|
+
* 5. En kötü durumda media.downloadUrl ya da undefined.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Media, MediaThumbnail } from "./types"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Yardımcı fonksiyona verilebilecek minimum Media subset — Media tipinin
|
|
20
|
+
* tamamını import etmek istemeyen consumer'lar için.
|
|
21
|
+
*/
|
|
22
|
+
export type ThumbnailSourceMedia = Pick<
|
|
23
|
+
Media,
|
|
24
|
+
"url" | "downloadUrl" | "imageMeta" | "type"
|
|
25
|
+
>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Display hedef boyutuna göre uygun thumbnail URL'sini döndür.
|
|
29
|
+
*
|
|
30
|
+
* @param media Sentroy media object (list / get / upload result).
|
|
31
|
+
* @param targetPx Display'in maksimum boyutu (genişlik veya yükseklik) px.
|
|
32
|
+
* Retina için `2x` ile çağırın: `pickThumbnailUrl(m, 56*2)`.
|
|
33
|
+
*
|
|
34
|
+
* @returns URL string ya da hiçbir şey üretilemezse `undefined`.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const avatarUrl = pickThumbnailUrl(media, 56 * 2) // 112px target
|
|
39
|
+
* <img src={avatarUrl} className="size-14 rounded-full" />
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* const cardUrl = pickThumbnailUrl(media, 320)
|
|
45
|
+
* const fullUrl = media.url // grid'te küçük, lightbox'ta orijinal
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function pickThumbnailUrl(
|
|
49
|
+
media: ThumbnailSourceMedia,
|
|
50
|
+
targetPx: number,
|
|
51
|
+
): string | undefined {
|
|
52
|
+
// Image değilse veya thumbnail listesi boşsa — orijinal URL.
|
|
53
|
+
const thumbs = media.imageMeta?.thumbnails
|
|
54
|
+
if (!thumbs || thumbs.length === 0 || media.type !== "image") {
|
|
55
|
+
return media.url ?? media.downloadUrl
|
|
56
|
+
}
|
|
57
|
+
if (!targetPx || targetPx <= 0) {
|
|
58
|
+
return media.url ?? media.downloadUrl
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// En yakın "kapsayan" thumbnail — width >= target olan en küçük; yoksa
|
|
62
|
+
// en büyük (target'tan küçük olsa bile en az bozulmayı verir).
|
|
63
|
+
const sorted = [...thumbs].sort((a, b) => a.width - b.width)
|
|
64
|
+
const fit =
|
|
65
|
+
sorted.find((t) => t.width >= targetPx) ?? sorted[sorted.length - 1]
|
|
66
|
+
|
|
67
|
+
// Backend bazı endpoint'lerde thumbnail'ın kendi URL'ini de döndüyor
|
|
68
|
+
// (CdnUploadResult.imageMeta.thumbnails[].url). Tipte opsiyonel olarak
|
|
69
|
+
// değil ama runtime'da gelirse direkt kullanırız.
|
|
70
|
+
const fitWithUrl = fit as MediaThumbnail & { url?: string }
|
|
71
|
+
if (typeof fitWithUrl.url === "string" && fitWithUrl.url.length > 0) {
|
|
72
|
+
return fitWithUrl.url
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Pattern fallback: original URL'in son `/`'ından sonraki kısmı atıp
|
|
76
|
+
// thumbnail'ın fileName'ini ekle. Backend pattern'ı:
|
|
77
|
+
// {cdn}/{bucketId}/{originalFileName} ← media.url
|
|
78
|
+
// {cdn}/{bucketId}/{thumbnailFileName} ← inşa edilen
|
|
79
|
+
if (media.url) {
|
|
80
|
+
const slash = media.url.lastIndexOf("/")
|
|
81
|
+
if (slash >= 0) {
|
|
82
|
+
// Query string varsa düşür — thumbnail için anlamsız.
|
|
83
|
+
const base = media.url.substring(0, slash + 1)
|
|
84
|
+
const cleanBase = base.split("?")[0]
|
|
85
|
+
return cleanBase + fit.fileName
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Public URL hiç yoksa proxy download endpoint'ini quality=N ile çağır.
|
|
90
|
+
if (media.downloadUrl) {
|
|
91
|
+
const sep = media.downloadUrl.includes("?") ? "&" : "?"
|
|
92
|
+
return `${media.downloadUrl}${sep}quality=${fit.width}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Yaygın preset boyutları — semantik isimle çağırmak isteyen consumer
|
|
100
|
+
* için kısayol. Retina-aware: avatar ufacık olduğundan @2x; orta boy
|
|
101
|
+
* preview için orijinal yerine ~640.
|
|
102
|
+
*
|
|
103
|
+
* Manuel `targetPx` vermek istemediğinde:
|
|
104
|
+
* `pickPresetThumbnailUrl(media, "avatar")`.
|
|
105
|
+
*/
|
|
106
|
+
export const THUMBNAIL_PRESETS = {
|
|
107
|
+
/** Avatar / round chip — 28-64px display, 2x retina için ~120 hedef. */
|
|
108
|
+
avatar: 128,
|
|
109
|
+
/** List/grid card — 200-300px display, ~500 hedef. */
|
|
110
|
+
card: 500,
|
|
111
|
+
/** Modal preview — büyük ama orijinali yormayan ~960. */
|
|
112
|
+
preview: 960,
|
|
113
|
+
/** Hero / fullbleed — 1280-1920 display, neredeyse orijinal. */
|
|
114
|
+
hero: 1600,
|
|
115
|
+
} as const
|
|
116
|
+
|
|
117
|
+
export type ThumbnailPreset = keyof typeof THUMBNAIL_PRESETS
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `pickThumbnailUrl`'in semantik kısayolu — display amacını isimle ifade
|
|
121
|
+
* et, helper preset → px mapping'ini halletsin.
|
|
122
|
+
*/
|
|
123
|
+
export function pickPresetThumbnailUrl(
|
|
124
|
+
media: ThumbnailSourceMedia,
|
|
125
|
+
preset: ThumbnailPreset,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
return pickThumbnailUrl(media, THUMBNAIL_PRESETS[preset])
|
|
128
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -12,8 +12,16 @@ export interface SentroyClientConfig {
|
|
|
12
12
|
baseUrl: string
|
|
13
13
|
/** Company slug */
|
|
14
14
|
companySlug: string
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Access token (`stk_...`). Same token works for mail + storage.
|
|
17
|
+
*
|
|
18
|
+
* Optional: when omitted, the client uses **cookie auth**
|
|
19
|
+
* (`credentials: "include"` on every fetch) — useful for browser code
|
|
20
|
+
* running inside the Sentroy site itself, where the user's session
|
|
21
|
+
* cookie is already valid against `sentroy.com`. End users never have
|
|
22
|
+
* to paste an API key when the SDK is embedded in our own UI.
|
|
23
|
+
*/
|
|
24
|
+
accessToken?: string
|
|
17
25
|
/** Request timeout in milliseconds (default: 30000) */
|
|
18
26
|
timeout?: number
|
|
19
27
|
}
|