@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.
Files changed (44) hide show
  1. package/README.md +157 -1
  2. package/dist/http.d.ts +13 -2
  3. package/dist/http.d.ts.map +1 -1
  4. package/dist/http.js +22 -6
  5. package/dist/http.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +5 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/react/MediaManager.d.ts +13 -1
  11. package/dist/react/MediaManager.d.ts.map +1 -1
  12. package/dist/react/MediaManager.js +24 -8
  13. package/dist/react/MediaManager.js.map +1 -1
  14. package/dist/react/MediaManagerTrigger.d.ts +63 -0
  15. package/dist/react/MediaManagerTrigger.d.ts.map +1 -0
  16. package/dist/react/MediaManagerTrigger.js +81 -0
  17. package/dist/react/MediaManagerTrigger.js.map +1 -0
  18. package/dist/react/index.d.ts +2 -1
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +4 -1
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/Lightbox.d.ts.map +1 -1
  23. package/dist/react/lib/Lightbox.js +7 -1
  24. package/dist/react/lib/Lightbox.js.map +1 -1
  25. package/dist/react/lib/utils.d.ts +14 -0
  26. package/dist/react/lib/utils.d.ts.map +1 -1
  27. package/dist/react/lib/utils.js +36 -0
  28. package/dist/react/lib/utils.js.map +1 -1
  29. package/dist/thumbnails.d.ts +67 -0
  30. package/dist/thumbnails.d.ts.map +1 -0
  31. package/dist/thumbnails.js +106 -0
  32. package/dist/thumbnails.js.map +1 -0
  33. package/dist/types.d.ts +10 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/http.ts +26 -8
  37. package/src/index.ts +8 -0
  38. package/src/react/MediaManager.tsx +38 -8
  39. package/src/react/MediaManagerTrigger.tsx +260 -0
  40. package/src/react/index.ts +5 -0
  41. package/src/react/lib/Lightbox.tsx +8 -1
  42. package/src/react/lib/utils.ts +34 -0
  43. package/src/thumbnails.ts +128 -0
  44. 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
- /** Yalnızca belirli MIME prefix'leri kabul et upload'ta — örn "image/*". */
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)) next.delete(media.id)
175
- else next.add(media.id)
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" ? media.url || media.downloadUrl : null
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" && (m.url || m.downloadUrl) ? (
549
+ {kind === "image" &&
550
+ pickPresetThumbnailUrl(m, "card") ? (
521
551
  // eslint-disable-next-line @next/next/no-img-element
522
552
  <img
523
- src={m.url || m.downloadUrl || ""}
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
+ }
@@ -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
- const url = media.url || media.downloadUrl
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
@@ -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
- /** Access token (stk_...). Same token works for mail + storage. */
16
- accessToken: string
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
  }