@sentroy-co/client-sdk 2.13.1 → 2.13.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.
@@ -1,38 +1,35 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react"
2
- import Cropper from "react-easy-crop"
2
+ import { Cropper, type CropperRef } from "react-advanced-cropper"
3
3
  import { motion, AnimatePresence } from "motion/react"
4
+ import "react-advanced-cropper/dist/style.css"
4
5
 
5
6
  /**
6
- * Image crop dialog — `react-easy-crop` üzerine professional crop UI:
7
+ * Image crop dialog — iOS Photos benzeri full-screen crop UI. Önceki
8
+ * `react-easy-crop` implementation'ı drag UX ve preview render
9
+ * tarafında zayıftı; `react-advanced-cropper` daha modern stencil
10
+ * sistemi + native-feel pinch/zoom verir.
11
+ *
12
+ * Akış (storage upload pipeline'ından preprocess hook):
7
13
  * - Aspect preset toolbar (1:1, 4:3, 16:9, 3:2, 9:16, Free)
8
- * - Zoom slider (+/− ile de)
9
- * - Rotate 90° CW/CCW (`R` shortcut)
10
- * - Sağ panelde **live preview thumbnail**crop alanı her değiştiğinde
11
- * küçük canvas'a aynı transformasyonu uygulayıp output sonucunu gösterir;
12
- * kullanıcı Apply'a basmadan "ne çıkacak" göründüğü için
13
- * Photoshop / Figma seviyesinde feedback.
14
- * - Output pixel boyutu (örn. 1200×800) — RP'lerin export presetlerine
15
- * hizalanmak için.
16
- * - Apply → cropped Blob, Cancel → null, "Use original" → original File.
14
+ * - Rotate 90° CW/CCW butonları (R / Shift+R)
15
+ * - Cropper'ın `getCanvas()` çıktısından **live preview**
16
+ * thumbnail — kullanıcı Apply'a basmadan sonucu görür.
17
+ * - Output pixel boyutu (örn. 1200×800) read-out.
18
+ * - Apply: ref üzerinden `getCanvas().toBlob` File.
19
+ * - "Use original": orijinal File döner; Cancel: null.
17
20
  *
18
- * Ayrı bir entry point (`@sentroy-co/client-sdk/react/crop`) ana SDK
19
- * import'u `react-easy-crop`'u bundle'a çekmesin (lazy subpath).
21
+ * Tam ekran: `inset-0`, scrim'siz; consent flow gibi destination-only UX
22
+ * dialog her şeyi kaplar, dikkat dağıtmaz.
20
23
  *
21
- * `getCroppedBlob` rotation-aware: önce kaynak image rotate edilir,
22
- * sonra `croppedAreaPixels`'in döndürülmüş koordinat sisteminden çıkarılır.
23
- * react-easy-crop'un `croppedAreaPixels` çıktısı zaten rotation'a göre
24
- * transform edilmiş — biz canvas'a aynı rotation'ı uygulayıp aynı koordinat
25
- * sisteminde drawImage yapıyoruz.
24
+ * Lazy subpath (`@sentroy-co/client-sdk/react/crop`) ana SDK import'u
25
+ * cropper bundle'ı yutmasın.
26
26
  */
27
27
 
28
- interface CropArea {
29
- x: number
30
- y: number
31
- width: number
32
- height: number
33
- }
34
-
35
- const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }> = [
28
+ const ASPECT_PRESETS: Array<{
29
+ id: string
30
+ label: string
31
+ aspect: number | null
32
+ }> = [
36
33
  { id: "free", label: "Free", aspect: null },
37
34
  { id: "1:1", label: "1:1", aspect: 1 },
38
35
  { id: "16:9", label: "16:9", aspect: 16 / 9 },
@@ -42,7 +39,7 @@ const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }
42
39
  ]
43
40
 
44
41
  const MAX_PIXEL_GUARD = 50_000_000 // ~24 MP — üstü tarayıcı memory peak'i riskli
45
- const PREVIEW_MAX_DIM = 220 // sidebar thumbnail için max edge (px)
42
+ const PREVIEW_MAX_DIM = 240
46
43
 
47
44
  export interface CropDialogProps {
48
45
  open: boolean
@@ -67,99 +64,124 @@ export function CropDialog({
67
64
  }: CropDialogProps) {
68
65
  const [imageUrl, setImageUrl] = useState<string | null>(null)
69
66
  const [aspectId, setAspectId] = useState(defaultAspect)
70
- const [crop, setCrop] = useState({ x: 0, y: 0 })
71
- const [zoom, setZoom] = useState(1)
72
- const [rotation, setRotation] = useState(0)
73
- const [croppedAreaPixels, setCroppedAreaPixels] = useState<CropArea | null>(null)
74
67
  const [busy, setBusy] = useState(false)
75
68
  const [tooLarge, setTooLarge] = useState(false)
76
- const previewCanvasRef = useRef<HTMLCanvasElement | null>(null)
69
+ const [outputSize, setOutputSize] = useState<{ w: number; h: number } | null>(
70
+ null,
71
+ )
72
+ const [previewDataUrl, setPreviewDataUrl] = useState<string | null>(null)
73
+
74
+ const cropperRef = useRef<CropperRef | null>(null)
77
75
  const previewRafRef = useRef<number | null>(null)
78
- const imageElRef = useRef<HTMLImageElement | null>(null)
79
76
 
80
77
  // Object URL lifecycle
81
78
  useEffect(() => {
82
79
  if (!open) return
83
80
  const url = URL.createObjectURL(file)
84
81
  setImageUrl(url)
85
- setCrop({ x: 0, y: 0 })
86
- setZoom(1)
87
- setRotation(0)
88
82
  setAspectId(defaultAspect)
89
83
  setTooLarge(false)
90
- // Pixel guard + cache HTMLImageElement (preview drawImage source).
84
+ setOutputSize(null)
85
+ setPreviewDataUrl(null)
86
+ // Pixel guard — large image decode tarayıcıyı çökertir
91
87
  const img = new Image()
92
88
  img.onload = () => {
93
89
  if (img.naturalWidth * img.naturalHeight > MAX_PIXEL_GUARD) {
94
90
  setTooLarge(true)
95
91
  }
96
- imageElRef.current = img
97
92
  }
98
93
  img.src = url
99
94
  return () => {
100
95
  URL.revokeObjectURL(url)
101
- imageElRef.current = null
96
+ if (previewRafRef.current !== null) {
97
+ cancelAnimationFrame(previewRafRef.current)
98
+ previewRafRef.current = null
99
+ }
102
100
  }
103
101
  }, [open, file, defaultAspect])
104
102
 
105
- const onCropComplete = useCallback(
106
- (_area: CropArea, areaPixels: CropArea) => {
107
- setCroppedAreaPixels(areaPixels)
108
- },
109
- [],
110
- )
103
+ const aspectRatio =
104
+ ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
111
105
 
112
- // Live preview render crop / rotation değiştikçe sağ panel thumbnail'ı
113
- // güncelle. requestAnimationFrame ile throttle (drag sırasında her event'te
114
- // canvas çizmek pahalı).
106
+ // Aspect preset değişirse cropper coordinates'ini yeni aspect'e göre
107
+ // güncelle. `setCoordinates` ile aspect'i zorla.
115
108
  useEffect(() => {
116
- if (!croppedAreaPixels || !imageElRef.current || tooLarge) return
109
+ if (!cropperRef.current) return
110
+ if (aspectRatio === undefined) return
111
+ const state = cropperRef.current.getState()
112
+ if (!state) return
113
+ const { coordinates } = state
114
+ if (!coordinates) return
115
+ const current = coordinates.width / coordinates.height
116
+ if (Math.abs(current - aspectRatio) < 0.001) return
117
+ // Aspect ratio'ya snap — width'i koru, height'i hesapla
118
+ const newWidth = coordinates.width
119
+ const newHeight = coordinates.width / aspectRatio
120
+ cropperRef.current.setCoordinates({
121
+ width: newWidth,
122
+ height: newHeight,
123
+ })
124
+ }, [aspectRatio, aspectId])
125
+
126
+ // Live preview render — cropper change event'inde getCanvas() ile
127
+ // küçük thumbnail üret. RAF ile throttle.
128
+ const renderPreview = useCallback(() => {
117
129
  if (previewRafRef.current !== null) {
118
130
  cancelAnimationFrame(previewRafRef.current)
119
131
  }
120
132
  previewRafRef.current = requestAnimationFrame(() => {
121
- const canvas = previewCanvasRef.current
122
- const img = imageElRef.current
123
- if (!canvas || !img) return
124
- const area = croppedAreaPixels
125
- // Preview boyutu — aspect korunarak max edge PREVIEW_MAX_DIM
126
- const scale =
127
- area.width >= area.height
128
- ? PREVIEW_MAX_DIM / area.width
129
- : PREVIEW_MAX_DIM / area.height
130
- const pw = Math.max(1, Math.round(area.width * scale))
131
- const ph = Math.max(1, Math.round(area.height * scale))
132
- canvas.width = pw
133
- canvas.height = ph
134
- const ctx = canvas.getContext("2d")
135
- if (!ctx) return
136
- ctx.imageSmoothingQuality = "high"
137
- ctx.clearRect(0, 0, pw, ph)
138
- drawRotatedCrop(ctx, img, area, rotation, pw, ph)
139
133
  previewRafRef.current = null
140
- })
141
- return () => {
142
- if (previewRafRef.current !== null) {
143
- cancelAnimationFrame(previewRafRef.current)
144
- previewRafRef.current = null
134
+ const cropper = cropperRef.current
135
+ if (!cropper) return
136
+ const canvas = cropper.getCanvas({
137
+ // Preview canvas — düşük boyut, smooth (output quality değil)
138
+ maxWidth: PREVIEW_MAX_DIM * 2,
139
+ maxHeight: PREVIEW_MAX_DIM * 2,
140
+ imageSmoothingQuality: "medium",
141
+ })
142
+ if (!canvas) return
143
+ setOutputSize({ w: canvas.width, h: canvas.height })
144
+ // Data URL — küçük canvas; performant
145
+ try {
146
+ setPreviewDataUrl(canvas.toDataURL("image/jpeg", 0.7))
147
+ } catch {
148
+ // toDataURL nadir tainted-canvas durumunda fail edebilir; sessiz geç
145
149
  }
146
- }
147
- }, [croppedAreaPixels, rotation, tooLarge])
150
+ })
151
+ }, [])
148
152
 
149
- const aspect =
150
- ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
153
+ const handleCropperChange = useCallback(() => {
154
+ renderPreview()
155
+ }, [renderPreview])
156
+
157
+ const handleCropperReady = useCallback(() => {
158
+ renderPreview()
159
+ }, [renderPreview])
151
160
 
152
161
  const handleApply = useCallback(async () => {
153
- if (!imageUrl || !croppedAreaPixels) return
162
+ const cropper = cropperRef.current
163
+ if (!cropper) return
154
164
  setBusy(true)
155
165
  try {
156
- const blob = await getCroppedBlob(
157
- imageUrl,
158
- croppedAreaPixels,
159
- rotation,
160
- file.type,
161
- outputQuality,
162
- )
166
+ const canvas = cropper.getCanvas({
167
+ imageSmoothingQuality: "high",
168
+ })
169
+ if (!canvas) {
170
+ setBusy(false)
171
+ return
172
+ }
173
+ const outputMime = file.type === "image/png" ? "image/png" : "image/jpeg"
174
+ const blob = await new Promise<Blob | null>((resolve) => {
175
+ canvas.toBlob(
176
+ (b) => resolve(b),
177
+ outputMime,
178
+ outputMime === "image/jpeg" ? outputQuality : undefined,
179
+ )
180
+ })
181
+ if (!blob) {
182
+ setBusy(false)
183
+ return
184
+ }
163
185
  const ext = blob.type === "image/png" ? "png" : "jpg"
164
186
  const baseName = file.name.replace(/\.[^.]+$/, "")
165
187
  const cropped = new File([blob], `${baseName}.${ext}`, {
@@ -169,34 +191,33 @@ export function CropDialog({
169
191
  } finally {
170
192
  setBusy(false)
171
193
  }
172
- }, [imageUrl, croppedAreaPixels, rotation, file, onClose, outputQuality])
194
+ }, [file, onClose, outputQuality])
173
195
 
174
196
  const handleUseOriginal = useCallback(() => onClose(file), [file, onClose])
175
197
  const handleCancel = useCallback(() => onClose(null), [onClose])
176
- const handleRotate = useCallback(
177
- (delta: 90 | -90) => {
178
- setRotation((r) => {
179
- const next = (r + delta + 360) % 360
180
- return next
181
- })
182
- },
183
- [],
184
- )
198
+ const handleRotate = useCallback((delta: 90 | -90) => {
199
+ cropperRef.current?.rotateImage(delta)
200
+ }, [])
201
+ const handleFlip = useCallback((axis: "h" | "v") => {
202
+ cropperRef.current?.flipImage(axis === "h", axis === "v")
203
+ }, [])
185
204
 
186
- // ESC kapatır, R döndürür
205
+ // Keyboard shortcuts — ESC kapat, R rotate, F flip
187
206
  useEffect(() => {
188
207
  if (!open) return
189
208
  const onKey = (e: KeyboardEvent) => {
209
+ if (
210
+ e.target instanceof HTMLElement &&
211
+ (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
212
+ ) {
213
+ return
214
+ }
190
215
  if (e.key === "Escape") {
191
216
  e.stopPropagation()
192
217
  handleCancel()
193
- } else if (e.key === "r" || e.key === "R") {
194
- if (
195
- e.target instanceof HTMLElement &&
196
- (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
197
- ) {
198
- return
199
- }
218
+ return
219
+ }
220
+ if (e.key === "r" || e.key === "R") {
200
221
  e.preventDefault()
201
222
  handleRotate(e.shiftKey ? -90 : 90)
202
223
  }
@@ -205,13 +226,6 @@ export function CropDialog({
205
226
  return () => window.removeEventListener("keydown", onKey)
206
227
  }, [open, handleCancel, handleRotate])
207
228
 
208
- const outputWidth = croppedAreaPixels ? Math.round(croppedAreaPixels.width) : 0
209
- const outputHeight = croppedAreaPixels ? Math.round(croppedAreaPixels.height) : 0
210
- // Rotation 90° / 270° iken output dimensions swap edilir (canvas rotate
211
- // sonrası kullanıcı görsel olarak swap görür).
212
- const displayW = rotation % 180 === 0 ? outputWidth : outputHeight
213
- const displayH = rotation % 180 === 0 ? outputHeight : outputWidth
214
-
215
229
  return (
216
230
  <AnimatePresence>
217
231
  {open && imageUrl && (
@@ -221,334 +235,249 @@ export function CropDialog({
221
235
  animate={{ opacity: 1 }}
222
236
  exit={{ opacity: 0 }}
223
237
  transition={{ duration: 0.2 }}
224
- className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
225
- onClick={(e) => {
226
- if (e.target === e.currentTarget) handleCancel()
227
- }}
238
+ // Tam ekran scrim değil, kendisi background; iOS Photos UX
239
+ className="fixed inset-0 z-[60] flex flex-col bg-black text-white"
228
240
  >
229
- <motion.div
230
- initial={{ opacity: 0, scale: 0.96, y: 8 }}
231
- animate={{ opacity: 1, scale: 1, y: 0 }}
232
- exit={{ opacity: 0, scale: 0.98 }}
233
- transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
234
- className="flex h-[min(92vh,760px)] w-full max-w-5xl flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
235
- >
236
- {/* Header */}
237
- <div className="flex items-center justify-between gap-3 border-b px-4 py-3">
238
- <div className="flex flex-col min-w-0">
239
- <span className="text-sm font-semibold">Crop image</span>
240
- <span className="truncate text-xs text-muted-foreground">
241
- {file.name}
242
- </span>
243
- </div>
244
- <button
245
- type="button"
246
- onClick={handleCancel}
247
- className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
248
- aria-label="Cancel"
249
- >
250
- <svg
251
- viewBox="0 0 24 24"
252
- fill="none"
253
- stroke="currentColor"
254
- strokeWidth="2"
255
- strokeLinecap="round"
256
- strokeLinejoin="round"
257
- className="size-4"
258
- >
259
- <path d="M18 6 6 18M6 6l12 12" />
260
- </svg>
261
- </button>
241
+ {/* Header */}
242
+ <div className="flex items-center justify-between gap-3 border-b border-white/10 bg-black/40 px-4 py-3 backdrop-blur-sm">
243
+ <button
244
+ type="button"
245
+ onClick={handleCancel}
246
+ disabled={busy}
247
+ 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"
248
+ >
249
+ Cancel
250
+ </button>
251
+ <div className="flex min-w-0 flex-col items-center text-center">
252
+ <span className="text-sm font-semibold">Crop image</span>
253
+ <span className="truncate max-w-xs text-[11px] text-white/50">
254
+ {file.name}
255
+ </span>
262
256
  </div>
257
+ <button
258
+ type="button"
259
+ onClick={handleApply}
260
+ disabled={busy || tooLarge}
261
+ className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
262
+ >
263
+ {busy ? "Cropping…" : "Apply"}
264
+ </button>
265
+ </div>
263
266
 
264
- {/* Aspect + rotate toolbar */}
265
- <div className="flex flex-wrap items-center gap-1 border-b bg-muted/20 px-3 py-2">
266
- <div className="flex flex-1 flex-wrap items-center gap-1">
267
- {ASPECT_PRESETS.map((p) => (
268
- <button
269
- key={p.id}
270
- type="button"
271
- onClick={() => setAspectId(p.id)}
272
- className={cls(
273
- "rounded-md px-2.5 py-1 text-xs transition-colors",
274
- aspectId === p.id
275
- ? "bg-foreground text-background"
276
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
277
- )}
278
- >
279
- {p.label}
280
- </button>
281
- ))}
282
- </div>
283
- <div className="ms-auto flex items-center gap-1">
267
+ {/* Aspect + rotate toolbar */}
268
+ <div className="flex flex-wrap items-center gap-2 border-b border-white/10 bg-black/30 px-3 py-2">
269
+ <div className="flex flex-1 flex-wrap items-center gap-1">
270
+ {ASPECT_PRESETS.map((p) => (
284
271
  <button
272
+ key={p.id}
285
273
  type="button"
286
- onClick={() => handleRotate(-90)}
287
- title="Rotate left (Shift+R)"
288
- aria-label="Rotate left"
289
- className="rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
290
- >
291
- <RotateLeftIcon />
292
- </button>
293
- <button
294
- type="button"
295
- onClick={() => handleRotate(90)}
296
- title="Rotate right (R)"
297
- aria-label="Rotate right"
298
- className="rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
274
+ onClick={() => setAspectId(p.id)}
275
+ className={cls(
276
+ "rounded-full px-3 py-1 text-xs transition-colors",
277
+ aspectId === p.id
278
+ ? "bg-white text-black"
279
+ : "text-white/60 hover:bg-white/10 hover:text-white",
280
+ )}
299
281
  >
300
- <RotateRightIcon />
282
+ {p.label}
301
283
  </button>
302
- </div>
284
+ ))}
285
+ </div>
286
+ <div className="flex items-center gap-1">
287
+ <ToolbarIconButton
288
+ onClick={() => handleRotate(-90)}
289
+ title="Rotate left (Shift+R)"
290
+ ariaLabel="Rotate left"
291
+ >
292
+ <RotateLeftIcon />
293
+ </ToolbarIconButton>
294
+ <ToolbarIconButton
295
+ onClick={() => handleRotate(90)}
296
+ title="Rotate right (R)"
297
+ ariaLabel="Rotate right"
298
+ >
299
+ <RotateRightIcon />
300
+ </ToolbarIconButton>
301
+ <span className="mx-1 h-5 w-px bg-white/15" />
302
+ <ToolbarIconButton
303
+ onClick={() => handleFlip("h")}
304
+ title="Flip horizontal"
305
+ ariaLabel="Flip horizontal"
306
+ >
307
+ <FlipHorizontalIcon />
308
+ </ToolbarIconButton>
309
+ <ToolbarIconButton
310
+ onClick={() => handleFlip("v")}
311
+ title="Flip vertical"
312
+ ariaLabel="Flip vertical"
313
+ >
314
+ <FlipVerticalIcon />
315
+ </ToolbarIconButton>
303
316
  </div>
317
+ </div>
304
318
 
305
- {/* Body: cropper + preview sidebar */}
306
- <div className="flex flex-1 min-h-0 flex-col overflow-hidden md:flex-row">
307
- {/* Cropper canvas */}
308
- <div className="relative flex-1 bg-black">
309
- {tooLarge ? (
310
- <div className="flex h-full w-full items-center justify-center p-6 text-center text-sm text-white/70">
311
- Image too large to crop in browser. Upload as-is or resize
312
- beforehand.
313
- </div>
314
- ) : (
315
- <Cropper
316
- image={imageUrl}
317
- crop={crop}
318
- zoom={zoom}
319
- rotation={rotation}
320
- aspect={aspect}
321
- onCropChange={setCrop}
322
- onCropComplete={onCropComplete}
323
- onZoomChange={setZoom}
324
- onRotationChange={setRotation}
325
- showGrid
326
- objectFit="contain"
327
- />
319
+ {/* Main: cropper + side panel */}
320
+ <div className="flex flex-1 min-h-0 flex-col md:flex-row">
321
+ {/* Cropper stage */}
322
+ <div className="relative flex-1 bg-black">
323
+ {tooLarge ? (
324
+ <div className="flex h-full w-full items-center justify-center p-6 text-center text-sm text-white/70">
325
+ Image too large to crop in browser. Upload as-is or resize
326
+ beforehand.
327
+ </div>
328
+ ) : (
329
+ <Cropper
330
+ ref={cropperRef}
331
+ src={imageUrl}
332
+ className="sentroy-cropper"
333
+ // Stencil props — aspect lock + iOS-like rect stencil grid
334
+ stencilProps={{
335
+ aspectRatio: aspectRatio,
336
+ grid: true,
337
+ movable: true,
338
+ resizable: true,
339
+ }}
340
+ // Background overlay'i koyu yap (image dışı kalan kısım)
341
+ backgroundClassName="sentroy-cropper-background"
342
+ onChange={handleCropperChange}
343
+ onReady={handleCropperReady}
344
+ />
345
+ )}
346
+ </div>
347
+
348
+ {/* Side panel: live preview + readout */}
349
+ <aside className="flex w-full shrink-0 flex-col gap-4 border-t border-white/10 bg-black/30 p-4 md:w-72 md:border-l md:border-t-0">
350
+ <div className="flex items-center justify-between">
351
+ <span className="text-[10px] font-medium uppercase tracking-wider text-white/50">
352
+ Preview
353
+ </span>
354
+ {outputSize && (
355
+ <span className="font-mono text-[10px] text-white/40">
356
+ {outputSize.w}×{outputSize.h}
357
+ </span>
328
358
  )}
329
359
  </div>
330
-
331
- {/* Preview sidebar */}
332
- <div className="flex w-full shrink-0 flex-col gap-3 border-t bg-muted/10 p-4 md:w-64 md:border-l md:border-t-0">
333
- <div className="flex items-center justify-between">
334
- <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
335
- Preview
360
+ <div className="flex min-h-[160px] items-center justify-center rounded-lg border border-white/10 bg-black/50 p-3">
361
+ {tooLarge ? (
362
+ <span className="text-[11px] text-white/50">
363
+ Preview unavailable
336
364
  </span>
337
- {displayW > 0 && displayH > 0 && (
338
- <span className="font-mono text-[10px] text-muted-foreground">
339
- {displayW}×{displayH}
340
- </span>
341
- )}
342
- </div>
343
- <div className="flex min-h-[140px] items-center justify-center rounded-lg border border-dashed bg-background/40 p-2">
344
- {tooLarge ? (
345
- <span className="text-[11px] text-muted-foreground">
346
- Preview unavailable
347
- </span>
348
- ) : croppedAreaPixels ? (
349
- <canvas
350
- ref={previewCanvasRef}
351
- className="max-h-[220px] max-w-full rounded-sm shadow-sm"
352
- />
353
- ) : (
354
- <span className="text-[11px] text-muted-foreground">
355
- Adjust crop…
356
- </span>
357
- )}
358
- </div>
359
- <div className="flex flex-col gap-1 text-[11px] text-muted-foreground">
360
- <div className="flex items-center justify-between">
361
- <span>Aspect</span>
362
- <span className="font-mono">
363
- {ASPECT_PRESETS.find((p) => p.id === aspectId)?.label ??
364
- "Free"}
365
- </span>
366
- </div>
367
- <div className="flex items-center justify-between">
368
- <span>Rotation</span>
369
- <span className="font-mono">{rotation}°</span>
370
- </div>
371
- <div className="flex items-center justify-between">
372
- <span>Zoom</span>
373
- <span className="font-mono">{zoom.toFixed(2)}×</span>
374
- </div>
375
- </div>
365
+ ) : previewDataUrl ? (
366
+ /* eslint-disable-next-line @next/next/no-img-element */
367
+ <img
368
+ src={previewDataUrl}
369
+ alt="Crop preview"
370
+ className="max-h-[240px] max-w-full rounded-md object-contain shadow-md"
371
+ />
372
+ ) : (
373
+ <span className="text-[11px] text-white/40">
374
+ Adjust crop…
375
+ </span>
376
+ )}
376
377
  </div>
377
- </div>
378
-
379
- {/* Zoom + actions */}
380
- <div className="flex flex-col gap-3 border-t bg-muted/20 px-4 py-3">
381
- {!tooLarge && (
382
- <div className="flex items-center gap-3">
383
- <span className="text-xs text-muted-foreground">Zoom</span>
384
- <button
385
- type="button"
386
- onClick={() => setZoom((z) => Math.max(1, z - 0.1))}
387
- className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted/50"
388
- >
389
-
390
- </button>
391
- <input
392
- type="range"
393
- min={1}
394
- max={3}
395
- step={0.05}
396
- value={zoom}
397
- onChange={(e) => setZoom(Number(e.target.value))}
398
- className="flex-1 accent-foreground"
378
+ <div className="flex flex-col gap-1.5 text-[11px] text-white/60">
379
+ <Stat
380
+ label="Aspect"
381
+ value={
382
+ ASPECT_PRESETS.find((p) => p.id === aspectId)?.label ??
383
+ "Free"
384
+ }
385
+ />
386
+ {outputSize && (
387
+ <Stat
388
+ label="Output"
389
+ value={`${outputSize.w} × ${outputSize.h} px`}
399
390
  />
400
- <button
401
- type="button"
402
- onClick={() => setZoom((z) => Math.min(3, z + 0.1))}
403
- className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted/50"
404
- >
405
- +
406
- </button>
407
- <button
408
- type="button"
409
- onClick={() => {
410
- setZoom(1)
411
- setRotation(0)
412
- setCrop({ x: 0, y: 0 })
413
- }}
414
- className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted/50"
415
- >
416
- Reset
417
- </button>
418
- </div>
419
- )}
420
- <div className="flex items-center justify-end gap-2">
421
- <button
422
- type="button"
423
- onClick={handleCancel}
424
- disabled={busy}
425
- className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted/50"
426
- >
427
- Cancel
428
- </button>
429
- <button
430
- type="button"
431
- onClick={handleUseOriginal}
432
- disabled={busy}
433
- className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted/50"
434
- >
435
- Use original
436
- </button>
437
- <button
438
- type="button"
439
- onClick={handleApply}
440
- disabled={busy || tooLarge || !croppedAreaPixels}
441
- className="rounded-md bg-foreground px-3 py-1.5 text-xs font-medium text-background hover:opacity-90 disabled:opacity-50"
442
- >
443
- {busy ? "Cropping…" : "Apply crop"}
444
- </button>
391
+ )}
392
+ <Stat
393
+ label="Format"
394
+ value={file.type === "image/png" ? "PNG" : "JPEG"}
395
+ />
445
396
  </div>
446
- </div>
447
- </motion.div>
397
+ <button
398
+ type="button"
399
+ onClick={handleUseOriginal}
400
+ disabled={busy}
401
+ className="mt-auto 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"
402
+ >
403
+ Use original (skip crop)
404
+ </button>
405
+ </aside>
406
+ </div>
407
+
408
+ {/* Local cropper styles — paket default'unu Sentroy paletine
409
+ align ediyoruz. Stencil border ve grid çizgisini ince-keskin
410
+ tutuyoruz (iOS Photos benzeri). */}
411
+ <style>{`
412
+ .sentroy-cropper {
413
+ height: 100%;
414
+ width: 100%;
415
+ background: #000;
416
+ }
417
+ .sentroy-cropper-background {
418
+ background-color: rgba(0, 0, 0, 0.7);
419
+ }
420
+ .sentroy-cropper .advanced-cropper-stencil-overlay {
421
+ background: rgba(0, 0, 0, 0.55);
422
+ }
423
+ .sentroy-cropper .advanced-cropper-rectangle-stencil__draggable-area {
424
+ border: 1px solid rgba(255, 255, 255, 0.95);
425
+ }
426
+ .sentroy-cropper .advanced-cropper-line-wrapper {
427
+ background: rgba(255, 255, 255, 0.95);
428
+ }
429
+ .sentroy-cropper .advanced-cropper-handler-wrapper--west-north,
430
+ .sentroy-cropper .advanced-cropper-handler-wrapper--north-east,
431
+ .sentroy-cropper .advanced-cropper-handler-wrapper--east-south,
432
+ .sentroy-cropper .advanced-cropper-handler-wrapper--south-west {
433
+ width: 22px;
434
+ height: 22px;
435
+ }
436
+ .sentroy-cropper .advanced-cropper-handler-wrapper__draggable {
437
+ background: #fff;
438
+ border: 2px solid #000;
439
+ width: 12px;
440
+ height: 12px;
441
+ border-radius: 2px;
442
+ }
443
+ `}</style>
448
444
  </motion.div>
449
445
  )}
450
446
  </AnimatePresence>
451
447
  )
452
448
  }
453
449
 
454
- /**
455
- * Rotated crop'u canvas'a yaz. Rotation 0° fast-path; aksi halde önce
456
- * "rotated bounding box" canvas'ı oluştur (orijinal image'in döndürülmüş
457
- * versiyonu), sonra croppedAreaPixels koordinatlarında çıkar.
458
- *
459
- * react-easy-crop's `croppedAreaPixels` rotation-aware: koordinatlar
460
- * orijinal image'i rotasyon merkezinden döndürdükten sonraki "visual"
461
- * bounding box üzerinden hesaplanır — biz aynı transform'u canvas'a
462
- * uygulayıp aynı koordinatlardan drawImage yapıyoruz.
463
- */
464
- function drawRotatedCrop(
465
- ctx: CanvasRenderingContext2D,
466
- image: HTMLImageElement,
467
- area: CropArea,
468
- rotation: number,
469
- dstW: number,
470
- dstH: number,
471
- ): void {
472
- if (rotation === 0) {
473
- ctx.drawImage(
474
- image,
475
- area.x,
476
- area.y,
477
- area.width,
478
- area.height,
479
- 0,
480
- 0,
481
- dstW,
482
- dstH,
483
- )
484
- return
485
- }
486
- // Build a rotated source canvas at the size of the rotated bounding box,
487
- // then crop from it.
488
- const rad = (rotation * Math.PI) / 180
489
- const sin = Math.abs(Math.sin(rad))
490
- const cos = Math.abs(Math.cos(rad))
491
- const iw = image.naturalWidth
492
- const ih = image.naturalHeight
493
- const bbW = iw * cos + ih * sin
494
- const bbH = iw * sin + ih * cos
495
- const tmp = document.createElement("canvas")
496
- tmp.width = bbW
497
- tmp.height = bbH
498
- const tctx = tmp.getContext("2d")
499
- if (!tctx) return
500
- tctx.translate(bbW / 2, bbH / 2)
501
- tctx.rotate(rad)
502
- tctx.drawImage(image, -iw / 2, -ih / 2)
503
- ctx.drawImage(
504
- tmp,
505
- area.x,
506
- area.y,
507
- area.width,
508
- area.height,
509
- 0,
510
- 0,
511
- dstW,
512
- dstH,
450
+ function Stat({ label, value }: { label: string; value: string }) {
451
+ return (
452
+ <div className="flex items-center justify-between">
453
+ <span>{label}</span>
454
+ <span className="font-mono text-white/80">{value}</span>
455
+ </div>
513
456
  )
514
457
  }
515
458
 
516
- /**
517
- * Canvas ile crop area'yı çıkar + Blob döndür.
518
- * Output MIME: PNG ise PNG, diğerleri JPEG (transparency yoksa).
519
- */
520
- async function getCroppedBlob(
521
- imageUrl: string,
522
- area: CropArea,
523
- rotation: number,
524
- sourceMime: string,
525
- quality: number,
526
- ): Promise<Blob> {
527
- const image = await loadImage(imageUrl)
528
- const canvas = document.createElement("canvas")
529
- canvas.width = area.width
530
- canvas.height = area.height
531
- const ctx = canvas.getContext("2d")
532
- if (!ctx) throw new Error("Canvas 2D context unavailable")
533
- ctx.imageSmoothingQuality = "high"
534
- drawRotatedCrop(ctx, image, area, rotation, area.width, area.height)
535
- const outputMime = sourceMime === "image/png" ? "image/png" : "image/jpeg"
536
- return new Promise<Blob>((resolve, reject) => {
537
- canvas.toBlob(
538
- (blob) => (blob ? resolve(blob) : reject(new Error("toBlob returned null"))),
539
- outputMime,
540
- outputMime === "image/jpeg" ? quality : undefined,
541
- )
542
- })
543
- }
544
-
545
- function loadImage(url: string): Promise<HTMLImageElement> {
546
- return new Promise((resolve, reject) => {
547
- const img = new Image()
548
- img.onload = () => resolve(img)
549
- img.onerror = reject
550
- img.src = url
551
- })
459
+ function ToolbarIconButton({
460
+ onClick,
461
+ title,
462
+ ariaLabel,
463
+ children,
464
+ }: {
465
+ onClick: () => void
466
+ title: string
467
+ ariaLabel: string
468
+ children: React.ReactNode
469
+ }) {
470
+ return (
471
+ <button
472
+ type="button"
473
+ onClick={onClick}
474
+ title={title}
475
+ aria-label={ariaLabel}
476
+ className="inline-flex size-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
477
+ >
478
+ {children}
479
+ </button>
480
+ )
552
481
  }
553
482
 
554
483
  function cls(...arr: Array<string | false | null | undefined>): string {
@@ -564,7 +493,7 @@ function RotateLeftIcon() {
564
493
  strokeWidth="2"
565
494
  strokeLinecap="round"
566
495
  strokeLinejoin="round"
567
- className="size-3.5"
496
+ className="size-4"
568
497
  aria-hidden="true"
569
498
  >
570
499
  <path d="M3 12a9 9 0 1 0 3-6.7" />
@@ -582,7 +511,7 @@ function RotateRightIcon() {
582
511
  strokeWidth="2"
583
512
  strokeLinecap="round"
584
513
  strokeLinejoin="round"
585
- className="size-3.5"
514
+ className="size-4"
586
515
  aria-hidden="true"
587
516
  >
588
517
  <path d="M21 12a9 9 0 1 1-3-6.7" />
@@ -590,3 +519,41 @@ function RotateRightIcon() {
590
519
  </svg>
591
520
  )
592
521
  }
522
+
523
+ function FlipHorizontalIcon() {
524
+ return (
525
+ <svg
526
+ viewBox="0 0 24 24"
527
+ fill="none"
528
+ stroke="currentColor"
529
+ strokeWidth="2"
530
+ strokeLinecap="round"
531
+ strokeLinejoin="round"
532
+ className="size-4"
533
+ aria-hidden="true"
534
+ >
535
+ <path d="M12 3v18" />
536
+ <path d="M16 7l4 5-4 5" />
537
+ <path d="M8 7l-4 5 4 5" />
538
+ </svg>
539
+ )
540
+ }
541
+
542
+ function FlipVerticalIcon() {
543
+ return (
544
+ <svg
545
+ viewBox="0 0 24 24"
546
+ fill="none"
547
+ stroke="currentColor"
548
+ strokeWidth="2"
549
+ strokeLinecap="round"
550
+ strokeLinejoin="round"
551
+ className="size-4"
552
+ aria-hidden="true"
553
+ >
554
+ <path d="M3 12h18" />
555
+ <path d="M7 8l5-4 5 4" />
556
+ <path d="M7 16l5 4 5-4" />
557
+ </svg>
558
+ )
559
+ }