@sentroy-co/client-sdk 2.13.4 → 2.13.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentroy-co/client-sdk",
3
- "version": "2.13.4",
3
+ "version": "2.13.6",
4
4
  "description": "TypeScript SDK + CLI for the Sentroy platform — mail, storage, env vault + React components.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -101,6 +101,6 @@
101
101
  },
102
102
  "dependencies": {
103
103
  "motion": "^12.38.0",
104
- "react-advanced-cropper": "^0.20.1"
104
+ "react-mobile-cropper": "^0.10.0"
105
105
  }
106
106
  }
@@ -1,30 +1,21 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react"
2
- import { Cropper, type CropperRef } from "react-advanced-cropper"
2
+ import { Cropper, type CropperRef } from "react-mobile-cropper"
3
3
  import { motion, AnimatePresence } from "motion/react"
4
4
 
5
5
  /**
6
- * Image crop dialog — iOS Photos benzeri full-screen crop UI. Önceki
7
- * `react-easy-crop` implementation'ı drag UX ve preview render
8
- * tarafında zayıftı; `react-advanced-cropper` daha modern stencil
9
- * sistemi + native-feel pinch/zoom verir.
6
+ * Image crop dialog — iOS Photos benzeri full-screen crop UI.
7
+ * `react-mobile-cropper` üzerine kurulu; paket native olarak iOS-vari
8
+ * stencil + handler + transition davranışı veriyor (manual layout/icon
9
+ * gerek yok).
10
10
  *
11
- * **CSS:** Caller uygulamada `react-advanced-cropper/dist/style.css`'i
12
- * global olarak import edilmiş olmalıdır (örn. `globals.css` üzerinden veya
13
- * root layout). SDK içinden CSS import etsek `tsc`'nin CJS çıktısında
14
- * runtime `require("...css")` doğar, Next.js bunu bundle'a alamaz ve
15
- * "Module factory is not available" hatasıyla patlar.
11
+ * iOS Photos pattern:
12
+ * - Header: Cancel (sol) "Crop" başlığı (orta) Done (sağ)
13
+ * - Main: Cropper full width, stencil ile karartılmış kenar
14
+ * - Bottom toolbar: aspect chip'leri (Free / 1:1 / 4:3 / 16:9 / 3:2 / 9:16)
15
+ * + sağ tarafta tek rotate ikonu
16
16
  *
17
- * Akış (storage upload pipeline'ından preprocess hook):
18
- * - Aspect preset toolbar (1:1, 4:3, 16:9, 3:2, 9:16, Free)
19
- * - Rotate 90° CW/CCW butonları (R / Shift+R)
20
- * - Cropper'ın `getCanvas()` çıktısından **live preview**
21
- * thumbnail — kullanıcı Apply'a basmadan sonucu görür.
22
- * - Output pixel boyutu (örn. 1200×800) read-out.
23
- * - Apply: ref üzerinden `getCanvas().toBlob` → File.
24
- * - "Use original": orijinal File döner; Cancel: null.
25
- *
26
- * Tam ekran: `inset-0`, scrim'siz; consent flow gibi destination-only UX —
27
- * dialog her şeyi kaplar, dikkat dağıtmaz.
17
+ * Preview thumbnail kasıtlı olarak yok — iOS Photos'ta da yok; kullanıcı
18
+ * stencil'in içini direkt görüyor.
28
19
  *
29
20
  * Lazy subpath (`@sentroy-co/client-sdk/react/crop`) — ana SDK import'u
30
21
  * cropper bundle'ı yutmasın.
@@ -43,8 +34,7 @@ const ASPECT_PRESETS: Array<{
43
34
  { id: "9:16", label: "9:16", aspect: 9 / 16 },
44
35
  ]
45
36
 
46
- const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP — üstü tarayıcı memory peak'i riskli
47
- const PREVIEW_MAX_DIM = 240
37
+ const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP
48
38
 
49
39
  export interface CropDialogProps {
50
40
  open: boolean
@@ -55,8 +45,7 @@ export interface CropDialogProps {
55
45
  onClose: (result: File | null) => void
56
46
  /** Default aspect preset id'si — 'free' (default) veya '1:1', '16:9', vb. */
57
47
  defaultAspect?: string
58
- /** Output JPEG quality 0-1 (default 0.92). Convert sonucu daima image/jpeg
59
- * veya orijinal MIME (PNG'ler için PNG korunur). */
48
+ /** Output JPEG quality 0-1 (default 0.92). PNG'ler için PNG korunur. */
60
49
  outputQuality?: number
61
50
  }
62
51
 
@@ -71,13 +60,7 @@ export function CropDialog({
71
60
  const [aspectId, setAspectId] = useState(defaultAspect)
72
61
  const [busy, setBusy] = useState(false)
73
62
  const [tooLarge, setTooLarge] = useState(false)
74
- const [outputSize, setOutputSize] = useState<{ w: number; h: number } | null>(
75
- null,
76
- )
77
- const [previewDataUrl, setPreviewDataUrl] = useState<string | null>(null)
78
-
79
63
  const cropperRef = useRef<CropperRef | null>(null)
80
- const previewRafRef = useRef<number | null>(null)
81
64
 
82
65
  // Object URL lifecycle
83
66
  useEffect(() => {
@@ -86,9 +69,6 @@ export function CropDialog({
86
69
  setImageUrl(url)
87
70
  setAspectId(defaultAspect)
88
71
  setTooLarge(false)
89
- setOutputSize(null)
90
- setPreviewDataUrl(null)
91
- // Pixel guard — large image decode tarayıcıyı çökertir
92
72
  const img = new Image()
93
73
  img.onload = () => {
94
74
  if (img.naturalWidth * img.naturalHeight > MAX_PIXEL_GUARD) {
@@ -96,98 +76,36 @@ export function CropDialog({
96
76
  }
97
77
  }
98
78
  img.src = url
99
- return () => {
100
- URL.revokeObjectURL(url)
101
- if (previewRafRef.current !== null) {
102
- cancelAnimationFrame(previewRafRef.current)
103
- previewRafRef.current = null
104
- }
105
- }
79
+ return () => URL.revokeObjectURL(url)
106
80
  }, [open, file, defaultAspect])
107
81
 
108
82
  const aspectRatio =
109
83
  ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
110
84
 
111
- // Aspect preset değişirse cropper coordinates'ini yeni aspect'e göre
112
- // güncelle. `setCoordinates` ile aspect'i zorla.
85
+ // Aspect preset değişirse stencil koordinatlarını yeni aspect'e snap'le
113
86
  useEffect(() => {
114
- if (!cropperRef.current) return
115
- if (aspectRatio === undefined) return
116
- const state = cropperRef.current.getState()
117
- if (!state) return
118
- const { coordinates } = state
119
- if (!coordinates) return
120
- const current = coordinates.width / coordinates.height
121
- if (Math.abs(current - aspectRatio) < 0.001) return
122
- // Aspect ratio'ya snap — width'i koru, height'i hesapla
123
- const newWidth = coordinates.width
124
- const newHeight = coordinates.width / aspectRatio
125
- cropperRef.current.setCoordinates({
126
- width: newWidth,
127
- height: newHeight,
128
- })
87
+ const cropper = cropperRef.current
88
+ if (!cropper || aspectRatio === undefined) return
89
+ const state = cropper.getState()
90
+ const c = state?.coordinates
91
+ if (!c) return
92
+ const newWidth = c.width
93
+ const newHeight = c.width / aspectRatio
94
+ cropper.setCoordinates({ width: newWidth, height: newHeight })
129
95
  }, [aspectRatio, aspectId])
130
96
 
131
- // Live preview render — cropper change event'inde getCanvas() ile
132
- // küçük thumbnail üret. RAF ile throttle.
133
- const renderPreview = useCallback(() => {
134
- if (previewRafRef.current !== null) {
135
- cancelAnimationFrame(previewRafRef.current)
136
- }
137
- previewRafRef.current = requestAnimationFrame(() => {
138
- previewRafRef.current = null
139
- const cropper = cropperRef.current
140
- if (!cropper) return
141
- // Önce gerçek crop pixel boyutunu öğren; preview canvas'ını aspect
142
- // koruyarak max edge PREVIEW_MAX_DIM'e fit ediyoruz. `getCanvas`
143
- // sadece `width` verince paket aspect'i koruyup height'i otomatik
144
- // hesaplıyor. `maxWidth/maxHeight` paket API'sinde yok — eski
145
- // çağrımız sessiz fail edip canvas üretmiyordu.
146
- const state = cropper.getState()
147
- const c = state?.coordinates
148
- if (!c || c.width === 0 || c.height === 0) return
149
- const scale =
150
- c.width >= c.height
151
- ? PREVIEW_MAX_DIM / c.width
152
- : PREVIEW_MAX_DIM / c.height
153
- const previewWidth = Math.max(1, Math.round(c.width * scale))
154
- const canvas = cropper.getCanvas({
155
- width: previewWidth,
156
- imageSmoothingEnabled: true,
157
- imageSmoothingQuality: "medium",
158
- })
159
- if (!canvas) return
160
- setOutputSize({ w: Math.round(c.width), h: Math.round(c.height) })
161
- // Data URL — küçük canvas; performant
162
- try {
163
- setPreviewDataUrl(canvas.toDataURL("image/jpeg", 0.7))
164
- } catch {
165
- // toDataURL nadir tainted-canvas durumunda fail edebilir; sessiz geç
166
- }
167
- })
168
- }, [])
169
-
170
- const handleCropperChange = useCallback(() => {
171
- renderPreview()
172
- }, [renderPreview])
173
-
174
- const handleCropperReady = useCallback(() => {
175
- renderPreview()
176
- }, [renderPreview])
177
-
178
97
  const handleApply = useCallback(async () => {
179
98
  const cropper = cropperRef.current
180
99
  if (!cropper) return
181
100
  setBusy(true)
182
101
  try {
183
- const canvas = cropper.getCanvas({
184
- imageSmoothingQuality: "high",
185
- })
102
+ const canvas = cropper.getCanvas({ imageSmoothingQuality: "high" })
186
103
  if (!canvas) {
187
104
  setBusy(false)
188
105
  return
189
106
  }
190
- const outputMime = file.type === "image/png" ? "image/png" : "image/jpeg"
107
+ const outputMime =
108
+ file.type === "image/png" ? "image/png" : "image/jpeg"
191
109
  const blob = await new Promise<Blob | null>((resolve) => {
192
110
  canvas.toBlob(
193
111
  (b) => resolve(b),
@@ -212,14 +130,11 @@ export function CropDialog({
212
130
 
213
131
  const handleUseOriginal = useCallback(() => onClose(file), [file, onClose])
214
132
  const handleCancel = useCallback(() => onClose(null), [onClose])
215
- const handleRotate = useCallback((delta: 90 | -90) => {
216
- cropperRef.current?.rotateImage(delta)
217
- }, [])
218
- const handleFlip = useCallback((axis: "h" | "v") => {
219
- cropperRef.current?.flipImage(axis === "h", axis === "v")
133
+ const handleRotate = useCallback(() => {
134
+ cropperRef.current?.rotateImage(90)
220
135
  }, [])
221
136
 
222
- // Keyboard shortcuts — ESC kapat, R rotate, F flip
137
+ // ESC kapat, R rotate
223
138
  useEffect(() => {
224
139
  if (!open) return
225
140
  const onKey = (e: KeyboardEvent) => {
@@ -236,7 +151,7 @@ export function CropDialog({
236
151
  }
237
152
  if (e.key === "r" || e.key === "R") {
238
153
  e.preventDefault()
239
- handleRotate(e.shiftKey ? -90 : 90)
154
+ handleRotate()
240
155
  }
241
156
  }
242
157
  window.addEventListener("keydown", onKey)
@@ -252,22 +167,36 @@ export function CropDialog({
252
167
  animate={{ opacity: 1 }}
253
168
  exit={{ opacity: 0 }}
254
169
  transition={{ duration: 0.2 }}
255
- // Tam ekranscrim değil, kendisi background; iOS Photos UX
256
- className="fixed inset-0 z-[60] flex flex-col bg-black text-white"
170
+ // Inline colorhost app'in tema değişkenleri text-white
171
+ // utility'sini override etse bile child icon/text bu rengi
172
+ // inherit eder (rotate ikonu, chip metinleri vs).
173
+ style={{ color: "#ffffff" }}
174
+ className="fixed inset-0 z-[60] flex flex-col bg-black"
257
175
  >
258
- {/* Header */}
259
- <div className="flex items-center justify-between gap-3 border-b border-white/10 bg-black/40 px-4 py-3 backdrop-blur-sm">
176
+ {/* Header — iOS Photos: Cancel sol / başlık orta / Done sağ */}
177
+ <header
178
+ className="flex items-center justify-between gap-3 px-4 py-3"
179
+ style={{
180
+ borderBottom: "1px solid rgba(255,255,255,0.08)",
181
+ background: "rgba(0,0,0,0.4)",
182
+ backdropFilter: "blur(8px)",
183
+ }}
184
+ >
260
185
  <button
261
186
  type="button"
262
187
  onClick={handleCancel}
263
188
  disabled={busy}
264
- className="rounded-md px-3 py-1.5 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
189
+ style={{ color: "rgba(255,255,255,0.85)" }}
190
+ className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-white/10 disabled:opacity-50"
265
191
  >
266
192
  Cancel
267
193
  </button>
268
194
  <div className="flex min-w-0 flex-col items-center text-center">
269
- <span className="text-sm font-semibold">Crop image</span>
270
- <span className="truncate max-w-xs text-[11px] text-white/50">
195
+ <span className="text-sm font-semibold">Crop</span>
196
+ <span
197
+ style={{ color: "rgba(255,255,255,0.5)" }}
198
+ className="truncate max-w-xs text-[11px]"
199
+ >
271
200
  {file.name}
272
201
  </span>
273
202
  </div>
@@ -275,19 +204,49 @@ export function CropDialog({
275
204
  type="button"
276
205
  onClick={handleApply}
277
206
  disabled={busy || tooLarge}
278
- // Inline color — host app'in Tailwind tema CSS değişkenleri
279
- // `text-black` / `bg-white` class'larını override edebiliyor
280
- // (Sentroy console gibi). Sabit hex ile kontrast garanti.
281
207
  style={{ backgroundColor: "#fff", color: "#0a0a0a" }}
282
208
  className="rounded-md px-3 py-1.5 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
283
209
  >
284
- {busy ? "Cropping…" : "Apply"}
210
+ {busy ? "Cropping…" : "Done"}
285
211
  </button>
212
+ </header>
213
+
214
+ {/* Main: cropper full bleed */}
215
+ <div className="relative flex-1 min-h-0 bg-black">
216
+ {tooLarge ? (
217
+ <div
218
+ style={{ color: "rgba(255,255,255,0.7)" }}
219
+ className="flex h-full w-full items-center justify-center p-6 text-center text-sm"
220
+ >
221
+ Image too large to crop in browser. Upload as-is or resize
222
+ beforehand.
223
+ </div>
224
+ ) : (
225
+ <Cropper
226
+ ref={cropperRef}
227
+ src={imageUrl}
228
+ stencilProps={{
229
+ aspectRatio: aspectRatio,
230
+ }}
231
+ className="sentroy-mobile-cropper"
232
+ />
233
+ )}
286
234
  </div>
287
235
 
288
- {/* Aspect + rotate toolbar */}
289
- <div className="flex flex-wrap items-center gap-2 border-b border-white/10 bg-black/30 px-3 py-2">
290
- <div className="flex flex-1 flex-wrap items-center gap-1">
236
+ {/* Bottom toolbar — aspect chips (sol) + rotate (sağ).
237
+ iOS Photos pattern. */}
238
+ <footer
239
+ className="flex items-center gap-2 px-3 py-3"
240
+ style={{
241
+ borderTop: "1px solid rgba(255,255,255,0.08)",
242
+ background: "rgba(0,0,0,0.4)",
243
+ backdropFilter: "blur(8px)",
244
+ }}
245
+ >
246
+ <div
247
+ className="flex flex-1 items-center gap-1 overflow-x-auto"
248
+ style={{ scrollbarWidth: "none" }}
249
+ >
291
250
  {ASPECT_PRESETS.map((p) => {
292
251
  const active = aspectId === p.id
293
252
  return (
@@ -298,155 +257,53 @@ export function CropDialog({
298
257
  style={
299
258
  active
300
259
  ? { backgroundColor: "#fff", color: "#0a0a0a" }
301
- : undefined
260
+ : { color: "rgba(255,255,255,0.7)" }
261
+ }
262
+ className={
263
+ "shrink-0 rounded-full px-3 py-1.5 text-xs transition-colors " +
264
+ (active ? "font-medium" : "hover:bg-white/10")
302
265
  }
303
- className={cls(
304
- "rounded-full px-3 py-1 text-xs transition-colors",
305
- active
306
- ? "font-medium"
307
- : "text-white/60 hover:bg-white/10 hover:text-white",
308
- )}
309
266
  >
310
267
  {p.label}
311
268
  </button>
312
269
  )
313
270
  })}
314
271
  </div>
315
- <div className="flex items-center gap-1">
316
- <ToolbarIconButton
317
- onClick={() => handleRotate(-90)}
318
- title="Rotate left (Shift+R)"
319
- ariaLabel="Rotate left"
320
- >
321
- <RotateLeftIcon />
322
- </ToolbarIconButton>
323
- <ToolbarIconButton
324
- onClick={() => handleRotate(90)}
325
- title="Rotate right (R)"
326
- ariaLabel="Rotate right"
327
- >
328
- <RotateRightIcon />
329
- </ToolbarIconButton>
330
- <span className="mx-1 h-5 w-px bg-white/15" />
331
- <ToolbarIconButton
332
- onClick={() => handleFlip("h")}
333
- title="Flip horizontal"
334
- ariaLabel="Flip horizontal"
335
- >
336
- <FlipHorizontalIcon />
337
- </ToolbarIconButton>
338
- <ToolbarIconButton
339
- onClick={() => handleFlip("v")}
340
- title="Flip vertical"
341
- ariaLabel="Flip vertical"
342
- >
343
- <FlipVerticalIcon />
344
- </ToolbarIconButton>
345
- </div>
346
- </div>
347
-
348
- {/* Main: cropper + side panel */}
349
- <div className="flex flex-1 min-h-0 flex-col md:flex-row">
350
- {/* Cropper stage */}
351
- <div className="relative flex-1 min-h-[240px] min-w-0 bg-black">
352
- {tooLarge ? (
353
- <div className="flex h-full w-full items-center justify-center p-6 text-center text-sm text-white/70">
354
- Image too large to crop in browser. Upload as-is or resize
355
- beforehand.
356
- </div>
357
- ) : (
358
- <Cropper
359
- ref={cropperRef}
360
- src={imageUrl}
361
- className="sentroy-cropper"
362
- // Stencil props — aspect lock + iOS-like rect stencil grid
363
- stencilProps={{
364
- aspectRatio: aspectRatio,
365
- grid: true,
366
- movable: true,
367
- resizable: true,
368
- }}
369
- // Background overlay'i koyu yap (image dışı kalan kısım)
370
- backgroundClassName="sentroy-cropper-background"
371
- onChange={handleCropperChange}
372
- onReady={handleCropperReady}
373
- />
374
- )}
375
- </div>
376
-
377
- {/* Side panel: live preview + readout. Mobile'da max-h ile
378
- clamp + overflow-y-auto — uzun bilgilerle taşmasın. */}
379
- <aside className="flex w-full shrink-0 flex-col gap-4 overflow-y-auto border-t border-white/10 bg-black/30 p-4 md:w-72 md:max-h-none md:border-l md:border-t-0 max-h-[42vh]">
380
- <div className="flex items-center justify-between">
381
- <span className="text-[10px] font-medium uppercase tracking-wider text-white/50">
382
- Preview
383
- </span>
384
- {outputSize && (
385
- <span className="font-mono text-[10px] text-white/40">
386
- {outputSize.w}×{outputSize.h}
387
- </span>
388
- )}
389
- </div>
390
- <div className="flex min-h-[160px] items-center justify-center rounded-lg border border-white/10 bg-black/50 p-3">
391
- {tooLarge ? (
392
- <span className="text-[11px] text-white/50">
393
- Preview unavailable
394
- </span>
395
- ) : previewDataUrl ? (
396
- /* eslint-disable-next-line @next/next/no-img-element */
397
- <img
398
- src={previewDataUrl}
399
- alt="Crop preview"
400
- className="max-h-[220px] max-w-full rounded-md object-contain shadow-md"
401
- />
402
- ) : (
403
- <span className="text-[11px] text-white/40">
404
- Adjust crop…
405
- </span>
406
- )}
407
- </div>
408
- <div className="flex flex-col gap-1.5 text-[11px] text-white/60">
409
- <Stat
410
- label="Aspect"
411
- value={
412
- ASPECT_PRESETS.find((p) => p.id === aspectId)?.label ??
413
- "Free"
414
- }
415
- />
416
- {outputSize && (
417
- <Stat
418
- label="Output"
419
- value={`${outputSize.w} × ${outputSize.h} px`}
420
- />
421
- )}
422
- <Stat
423
- label="Format"
424
- value={file.type === "image/png" ? "PNG" : "JPEG"}
425
- />
426
- </div>
427
- <button
428
- type="button"
429
- onClick={handleUseOriginal}
430
- disabled={busy}
431
- className="w-full rounded-md border border-white/20 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50 md:mt-auto"
432
- >
433
- Use original (skip crop)
434
- </button>
435
- </aside>
436
- </div>
272
+ <div
273
+ className="mx-2 hidden h-5 w-px md:block"
274
+ style={{ background: "rgba(255,255,255,0.15)" }}
275
+ />
276
+ <button
277
+ type="button"
278
+ onClick={handleRotate}
279
+ title="Rotate (R)"
280
+ aria-label="Rotate"
281
+ style={{ color: "rgba(255,255,255,0.85)" }}
282
+ className="inline-flex shrink-0 size-9 items-center justify-center rounded-full transition-colors hover:bg-white/10"
283
+ >
284
+ <RotateIcon />
285
+ </button>
286
+ <button
287
+ type="button"
288
+ onClick={handleUseOriginal}
289
+ disabled={busy}
290
+ style={{ color: "rgba(255,255,255,0.6)" }}
291
+ className="hidden shrink-0 rounded-md px-3 py-1.5 text-[11px] transition-colors hover:bg-white/10 disabled:opacity-50 sm:inline-flex"
292
+ >
293
+ Use original
294
+ </button>
295
+ </footer>
437
296
 
438
- {/* Cropper container — boyutsal classes; theme'in kendisi
439
- SDK build'inde emit edilen styles.css ile yüklenir
440
- (`@sentroy-co/client-sdk/react/crop/styles.css`). */}
297
+ {/* Cropper container sizing paketin kendi style.css'i çoğu
298
+ görseli sağlar; bizim sadece full-bleed boyutlama. Cropper'ın
299
+ ana rengini (stencil border + handler accent) `color`
300
+ property'si üzerinden geçir; root'a beyaz set ettik. */}
441
301
  <style>{`
442
- .sentroy-cropper {
302
+ .sentroy-mobile-cropper {
443
303
  height: 100%;
444
304
  width: 100%;
445
305
  background: #000;
446
306
  }
447
- .sentroy-cropper-background {
448
- background-color: rgba(0, 0, 0, 0.7);
449
- }
450
307
  `}</style>
451
308
  </motion.div>
452
309
  )}
@@ -454,44 +311,7 @@ export function CropDialog({
454
311
  )
455
312
  }
456
313
 
457
- function Stat({ label, value }: { label: string; value: string }) {
458
- return (
459
- <div className="flex items-center justify-between">
460
- <span>{label}</span>
461
- <span className="font-mono text-white/80">{value}</span>
462
- </div>
463
- )
464
- }
465
-
466
- function ToolbarIconButton({
467
- onClick,
468
- title,
469
- ariaLabel,
470
- children,
471
- }: {
472
- onClick: () => void
473
- title: string
474
- ariaLabel: string
475
- children: React.ReactNode
476
- }) {
477
- return (
478
- <button
479
- type="button"
480
- onClick={onClick}
481
- title={title}
482
- aria-label={ariaLabel}
483
- className="inline-flex size-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
484
- >
485
- {children}
486
- </button>
487
- )
488
- }
489
-
490
- function cls(...arr: Array<string | false | null | undefined>): string {
491
- return arr.filter(Boolean).join(" ")
492
- }
493
-
494
- function RotateLeftIcon() {
314
+ function RotateIcon() {
495
315
  return (
496
316
  <svg
497
317
  viewBox="0 0 24 24"
@@ -500,25 +320,7 @@ function RotateLeftIcon() {
500
320
  strokeWidth="2"
501
321
  strokeLinecap="round"
502
322
  strokeLinejoin="round"
503
- className="size-4"
504
- aria-hidden="true"
505
- >
506
- <path d="M3 12a9 9 0 1 0 3-6.7" />
507
- <path d="M3 4v5h5" />
508
- </svg>
509
- )
510
- }
511
-
512
- function RotateRightIcon() {
513
- return (
514
- <svg
515
- viewBox="0 0 24 24"
516
- fill="none"
517
- stroke="currentColor"
518
- strokeWidth="2"
519
- strokeLinecap="round"
520
- strokeLinejoin="round"
521
- className="size-4"
323
+ className="size-5"
522
324
  aria-hidden="true"
523
325
  >
524
326
  <path d="M21 12a9 9 0 1 1-3-6.7" />
@@ -526,41 +328,3 @@ function RotateRightIcon() {
526
328
  </svg>
527
329
  )
528
330
  }
529
-
530
- function FlipHorizontalIcon() {
531
- return (
532
- <svg
533
- viewBox="0 0 24 24"
534
- fill="none"
535
- stroke="currentColor"
536
- strokeWidth="2"
537
- strokeLinecap="round"
538
- strokeLinejoin="round"
539
- className="size-4"
540
- aria-hidden="true"
541
- >
542
- <path d="M12 3v18" />
543
- <path d="M16 7l4 5-4 5" />
544
- <path d="M8 7l-4 5 4 5" />
545
- </svg>
546
- )
547
- }
548
-
549
- function FlipVerticalIcon() {
550
- return (
551
- <svg
552
- viewBox="0 0 24 24"
553
- fill="none"
554
- stroke="currentColor"
555
- strokeWidth="2"
556
- strokeLinecap="round"
557
- strokeLinejoin="round"
558
- className="size-4"
559
- aria-hidden="true"
560
- >
561
- <path d="M3 12h18" />
562
- <path d="M7 8l5-4 5 4" />
563
- <path d="M7 16l5 4 5-4" />
564
- </svg>
565
- )
566
- }