@sentroy-co/client-sdk 2.13.1 → 2.13.3

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,40 @@
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
4
 
5
5
  /**
6
- * Image crop dialog — `react-easy-crop` üzerine professional crop UI:
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.
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.
16
+ *
17
+ * Akış (storage upload pipeline'ından preprocess hook):
7
18
  * - 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.
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.
17
25
  *
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).
26
+ * Tam ekran: `inset-0`, scrim'siz; consent flow gibi destination-only UX
27
+ * dialog her şeyi kaplar, dikkat dağıtmaz.
20
28
  *
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.
29
+ * Lazy subpath (`@sentroy-co/client-sdk/react/crop`) ana SDK import'u
30
+ * cropper bundle'ı yutmasın.
26
31
  */
27
32
 
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 }> = [
33
+ const ASPECT_PRESETS: Array<{
34
+ id: string
35
+ label: string
36
+ aspect: number | null
37
+ }> = [
36
38
  { id: "free", label: "Free", aspect: null },
37
39
  { id: "1:1", label: "1:1", aspect: 1 },
38
40
  { id: "16:9", label: "16:9", aspect: 16 / 9 },
@@ -42,7 +44,7 @@ const ASPECT_PRESETS: Array<{ id: string; label: string; aspect: number | null }
42
44
  ]
43
45
 
44
46
  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)
47
+ const PREVIEW_MAX_DIM = 240
46
48
 
47
49
  export interface CropDialogProps {
48
50
  open: boolean
@@ -67,99 +69,124 @@ export function CropDialog({
67
69
  }: CropDialogProps) {
68
70
  const [imageUrl, setImageUrl] = useState<string | null>(null)
69
71
  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
72
  const [busy, setBusy] = useState(false)
75
73
  const [tooLarge, setTooLarge] = useState(false)
76
- const previewCanvasRef = useRef<HTMLCanvasElement | null>(null)
74
+ const [outputSize, setOutputSize] = useState<{ w: number; h: number } | null>(
75
+ null,
76
+ )
77
+ const [previewDataUrl, setPreviewDataUrl] = useState<string | null>(null)
78
+
79
+ const cropperRef = useRef<CropperRef | null>(null)
77
80
  const previewRafRef = useRef<number | null>(null)
78
- const imageElRef = useRef<HTMLImageElement | null>(null)
79
81
 
80
82
  // Object URL lifecycle
81
83
  useEffect(() => {
82
84
  if (!open) return
83
85
  const url = URL.createObjectURL(file)
84
86
  setImageUrl(url)
85
- setCrop({ x: 0, y: 0 })
86
- setZoom(1)
87
- setRotation(0)
88
87
  setAspectId(defaultAspect)
89
88
  setTooLarge(false)
90
- // Pixel guard + cache HTMLImageElement (preview drawImage source).
89
+ setOutputSize(null)
90
+ setPreviewDataUrl(null)
91
+ // Pixel guard — large image decode tarayıcıyı çökertir
91
92
  const img = new Image()
92
93
  img.onload = () => {
93
94
  if (img.naturalWidth * img.naturalHeight > MAX_PIXEL_GUARD) {
94
95
  setTooLarge(true)
95
96
  }
96
- imageElRef.current = img
97
97
  }
98
98
  img.src = url
99
99
  return () => {
100
100
  URL.revokeObjectURL(url)
101
- imageElRef.current = null
101
+ if (previewRafRef.current !== null) {
102
+ cancelAnimationFrame(previewRafRef.current)
103
+ previewRafRef.current = null
104
+ }
102
105
  }
103
106
  }, [open, file, defaultAspect])
104
107
 
105
- const onCropComplete = useCallback(
106
- (_area: CropArea, areaPixels: CropArea) => {
107
- setCroppedAreaPixels(areaPixels)
108
- },
109
- [],
110
- )
108
+ const aspectRatio =
109
+ ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
111
110
 
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ı).
111
+ // Aspect preset değişirse cropper coordinates'ini yeni aspect'e göre
112
+ // güncelle. `setCoordinates` ile aspect'i zorla.
115
113
  useEffect(() => {
116
- if (!croppedAreaPixels || !imageElRef.current || tooLarge) return
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
+ })
129
+ }, [aspectRatio, aspectId])
130
+
131
+ // Live preview render — cropper change event'inde getCanvas() ile
132
+ // küçük thumbnail üret. RAF ile throttle.
133
+ const renderPreview = useCallback(() => {
117
134
  if (previewRafRef.current !== null) {
118
135
  cancelAnimationFrame(previewRafRef.current)
119
136
  }
120
137
  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
138
  previewRafRef.current = null
140
- })
141
- return () => {
142
- if (previewRafRef.current !== null) {
143
- cancelAnimationFrame(previewRafRef.current)
144
- previewRafRef.current = null
139
+ const cropper = cropperRef.current
140
+ if (!cropper) return
141
+ const canvas = cropper.getCanvas({
142
+ // Preview canvas — düşük boyut, smooth (output quality değil)
143
+ maxWidth: PREVIEW_MAX_DIM * 2,
144
+ maxHeight: PREVIEW_MAX_DIM * 2,
145
+ imageSmoothingQuality: "medium",
146
+ })
147
+ if (!canvas) return
148
+ setOutputSize({ w: canvas.width, h: canvas.height })
149
+ // Data URL — küçük canvas; performant
150
+ try {
151
+ setPreviewDataUrl(canvas.toDataURL("image/jpeg", 0.7))
152
+ } catch {
153
+ // toDataURL nadir tainted-canvas durumunda fail edebilir; sessiz geç
145
154
  }
146
- }
147
- }, [croppedAreaPixels, rotation, tooLarge])
155
+ })
156
+ }, [])
148
157
 
149
- const aspect =
150
- ASPECT_PRESETS.find((p) => p.id === aspectId)?.aspect ?? undefined
158
+ const handleCropperChange = useCallback(() => {
159
+ renderPreview()
160
+ }, [renderPreview])
161
+
162
+ const handleCropperReady = useCallback(() => {
163
+ renderPreview()
164
+ }, [renderPreview])
151
165
 
152
166
  const handleApply = useCallback(async () => {
153
- if (!imageUrl || !croppedAreaPixels) return
167
+ const cropper = cropperRef.current
168
+ if (!cropper) return
154
169
  setBusy(true)
155
170
  try {
156
- const blob = await getCroppedBlob(
157
- imageUrl,
158
- croppedAreaPixels,
159
- rotation,
160
- file.type,
161
- outputQuality,
162
- )
171
+ const canvas = cropper.getCanvas({
172
+ imageSmoothingQuality: "high",
173
+ })
174
+ if (!canvas) {
175
+ setBusy(false)
176
+ return
177
+ }
178
+ const outputMime = file.type === "image/png" ? "image/png" : "image/jpeg"
179
+ const blob = await new Promise<Blob | null>((resolve) => {
180
+ canvas.toBlob(
181
+ (b) => resolve(b),
182
+ outputMime,
183
+ outputMime === "image/jpeg" ? outputQuality : undefined,
184
+ )
185
+ })
186
+ if (!blob) {
187
+ setBusy(false)
188
+ return
189
+ }
163
190
  const ext = blob.type === "image/png" ? "png" : "jpg"
164
191
  const baseName = file.name.replace(/\.[^.]+$/, "")
165
192
  const cropped = new File([blob], `${baseName}.${ext}`, {
@@ -169,34 +196,33 @@ export function CropDialog({
169
196
  } finally {
170
197
  setBusy(false)
171
198
  }
172
- }, [imageUrl, croppedAreaPixels, rotation, file, onClose, outputQuality])
199
+ }, [file, onClose, outputQuality])
173
200
 
174
201
  const handleUseOriginal = useCallback(() => onClose(file), [file, onClose])
175
202
  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
- )
203
+ const handleRotate = useCallback((delta: 90 | -90) => {
204
+ cropperRef.current?.rotateImage(delta)
205
+ }, [])
206
+ const handleFlip = useCallback((axis: "h" | "v") => {
207
+ cropperRef.current?.flipImage(axis === "h", axis === "v")
208
+ }, [])
185
209
 
186
- // ESC kapatır, R döndürür
210
+ // Keyboard shortcuts — ESC kapat, R rotate, F flip
187
211
  useEffect(() => {
188
212
  if (!open) return
189
213
  const onKey = (e: KeyboardEvent) => {
214
+ if (
215
+ e.target instanceof HTMLElement &&
216
+ (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
217
+ ) {
218
+ return
219
+ }
190
220
  if (e.key === "Escape") {
191
221
  e.stopPropagation()
192
222
  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
- }
223
+ return
224
+ }
225
+ if (e.key === "r" || e.key === "R") {
200
226
  e.preventDefault()
201
227
  handleRotate(e.shiftKey ? -90 : 90)
202
228
  }
@@ -205,13 +231,6 @@ export function CropDialog({
205
231
  return () => window.removeEventListener("keydown", onKey)
206
232
  }, [open, handleCancel, handleRotate])
207
233
 
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
234
  return (
216
235
  <AnimatePresence>
217
236
  {open && imageUrl && (
@@ -221,334 +240,249 @@ export function CropDialog({
221
240
  animate={{ opacity: 1 }}
222
241
  exit={{ opacity: 0 }}
223
242
  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
- }}
243
+ // Tam ekran scrim değil, kendisi background; iOS Photos UX
244
+ className="fixed inset-0 z-[60] flex flex-col bg-black text-white"
228
245
  >
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>
246
+ {/* Header */}
247
+ <div className="flex items-center justify-between gap-3 border-b border-white/10 bg-black/40 px-4 py-3 backdrop-blur-sm">
248
+ <button
249
+ type="button"
250
+ onClick={handleCancel}
251
+ disabled={busy}
252
+ 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"
253
+ >
254
+ Cancel
255
+ </button>
256
+ <div className="flex min-w-0 flex-col items-center text-center">
257
+ <span className="text-sm font-semibold">Crop image</span>
258
+ <span className="truncate max-w-xs text-[11px] text-white/50">
259
+ {file.name}
260
+ </span>
262
261
  </div>
262
+ <button
263
+ type="button"
264
+ onClick={handleApply}
265
+ disabled={busy || tooLarge}
266
+ className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
267
+ >
268
+ {busy ? "Cropping…" : "Apply"}
269
+ </button>
270
+ </div>
263
271
 
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">
272
+ {/* Aspect + rotate toolbar */}
273
+ <div className="flex flex-wrap items-center gap-2 border-b border-white/10 bg-black/30 px-3 py-2">
274
+ <div className="flex flex-1 flex-wrap items-center gap-1">
275
+ {ASPECT_PRESETS.map((p) => (
284
276
  <button
277
+ key={p.id}
285
278
  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"
279
+ onClick={() => setAspectId(p.id)}
280
+ className={cls(
281
+ "rounded-full px-3 py-1 text-xs transition-colors",
282
+ aspectId === p.id
283
+ ? "bg-white text-black"
284
+ : "text-white/60 hover:bg-white/10 hover:text-white",
285
+ )}
299
286
  >
300
- <RotateRightIcon />
287
+ {p.label}
301
288
  </button>
302
- </div>
289
+ ))}
290
+ </div>
291
+ <div className="flex items-center gap-1">
292
+ <ToolbarIconButton
293
+ onClick={() => handleRotate(-90)}
294
+ title="Rotate left (Shift+R)"
295
+ ariaLabel="Rotate left"
296
+ >
297
+ <RotateLeftIcon />
298
+ </ToolbarIconButton>
299
+ <ToolbarIconButton
300
+ onClick={() => handleRotate(90)}
301
+ title="Rotate right (R)"
302
+ ariaLabel="Rotate right"
303
+ >
304
+ <RotateRightIcon />
305
+ </ToolbarIconButton>
306
+ <span className="mx-1 h-5 w-px bg-white/15" />
307
+ <ToolbarIconButton
308
+ onClick={() => handleFlip("h")}
309
+ title="Flip horizontal"
310
+ ariaLabel="Flip horizontal"
311
+ >
312
+ <FlipHorizontalIcon />
313
+ </ToolbarIconButton>
314
+ <ToolbarIconButton
315
+ onClick={() => handleFlip("v")}
316
+ title="Flip vertical"
317
+ ariaLabel="Flip vertical"
318
+ >
319
+ <FlipVerticalIcon />
320
+ </ToolbarIconButton>
303
321
  </div>
322
+ </div>
304
323
 
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
- />
324
+ {/* Main: cropper + side panel */}
325
+ <div className="flex flex-1 min-h-0 flex-col md:flex-row">
326
+ {/* Cropper stage */}
327
+ <div className="relative flex-1 bg-black min-h-[240px]">
328
+ {tooLarge ? (
329
+ <div className="flex h-full w-full items-center justify-center p-6 text-center text-sm text-white/70">
330
+ Image too large to crop in browser. Upload as-is or resize
331
+ beforehand.
332
+ </div>
333
+ ) : (
334
+ <Cropper
335
+ ref={cropperRef}
336
+ src={imageUrl}
337
+ className="sentroy-cropper"
338
+ // Stencil props — aspect lock + iOS-like rect stencil grid
339
+ stencilProps={{
340
+ aspectRatio: aspectRatio,
341
+ grid: true,
342
+ movable: true,
343
+ resizable: true,
344
+ }}
345
+ // Background overlay'i koyu yap (image dışı kalan kısım)
346
+ backgroundClassName="sentroy-cropper-background"
347
+ onChange={handleCropperChange}
348
+ onReady={handleCropperReady}
349
+ />
350
+ )}
351
+ </div>
352
+
353
+ {/* Side panel: live preview + readout */}
354
+ <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">
355
+ <div className="flex items-center justify-between">
356
+ <span className="text-[10px] font-medium uppercase tracking-wider text-white/50">
357
+ Preview
358
+ </span>
359
+ {outputSize && (
360
+ <span className="font-mono text-[10px] text-white/40">
361
+ {outputSize.w}×{outputSize.h}
362
+ </span>
328
363
  )}
329
364
  </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
365
+ <div className="flex min-h-[160px] items-center justify-center rounded-lg border border-white/10 bg-black/50 p-3">
366
+ {tooLarge ? (
367
+ <span className="text-[11px] text-white/50">
368
+ Preview unavailable
336
369
  </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>
370
+ ) : previewDataUrl ? (
371
+ /* eslint-disable-next-line @next/next/no-img-element */
372
+ <img
373
+ src={previewDataUrl}
374
+ alt="Crop preview"
375
+ className="max-h-[240px] max-w-full rounded-md object-contain shadow-md"
376
+ />
377
+ ) : (
378
+ <span className="text-[11px] text-white/40">
379
+ Adjust crop…
380
+ </span>
381
+ )}
376
382
  </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"
383
+ <div className="flex flex-col gap-1.5 text-[11px] text-white/60">
384
+ <Stat
385
+ label="Aspect"
386
+ value={
387
+ ASPECT_PRESETS.find((p) => p.id === aspectId)?.label ??
388
+ "Free"
389
+ }
390
+ />
391
+ {outputSize && (
392
+ <Stat
393
+ label="Output"
394
+ value={`${outputSize.w} × ${outputSize.h} px`}
399
395
  />
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>
396
+ )}
397
+ <Stat
398
+ label="Format"
399
+ value={file.type === "image/png" ? "PNG" : "JPEG"}
400
+ />
445
401
  </div>
446
- </div>
447
- </motion.div>
402
+ <button
403
+ type="button"
404
+ onClick={handleUseOriginal}
405
+ disabled={busy}
406
+ 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"
407
+ >
408
+ Use original (skip crop)
409
+ </button>
410
+ </aside>
411
+ </div>
412
+
413
+ {/* Local cropper styles — paket default'unu Sentroy paletine
414
+ align ediyoruz. Stencil border ve grid çizgisini ince-keskin
415
+ tutuyoruz (iOS Photos benzeri). */}
416
+ <style>{`
417
+ .sentroy-cropper {
418
+ height: 100%;
419
+ width: 100%;
420
+ background: #000;
421
+ }
422
+ .sentroy-cropper-background {
423
+ background-color: rgba(0, 0, 0, 0.7);
424
+ }
425
+ .sentroy-cropper .advanced-cropper-stencil-overlay {
426
+ background: rgba(0, 0, 0, 0.55);
427
+ }
428
+ .sentroy-cropper .advanced-cropper-rectangle-stencil__draggable-area {
429
+ border: 1px solid rgba(255, 255, 255, 0.95);
430
+ }
431
+ .sentroy-cropper .advanced-cropper-line-wrapper {
432
+ background: rgba(255, 255, 255, 0.95);
433
+ }
434
+ .sentroy-cropper .advanced-cropper-handler-wrapper--west-north,
435
+ .sentroy-cropper .advanced-cropper-handler-wrapper--north-east,
436
+ .sentroy-cropper .advanced-cropper-handler-wrapper--east-south,
437
+ .sentroy-cropper .advanced-cropper-handler-wrapper--south-west {
438
+ width: 22px;
439
+ height: 22px;
440
+ }
441
+ .sentroy-cropper .advanced-cropper-handler-wrapper__draggable {
442
+ background: #fff;
443
+ border: 2px solid #000;
444
+ width: 12px;
445
+ height: 12px;
446
+ border-radius: 2px;
447
+ }
448
+ `}</style>
448
449
  </motion.div>
449
450
  )}
450
451
  </AnimatePresence>
451
452
  )
452
453
  }
453
454
 
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,
455
+ function Stat({ label, value }: { label: string; value: string }) {
456
+ return (
457
+ <div className="flex items-center justify-between">
458
+ <span>{label}</span>
459
+ <span className="font-mono text-white/80">{value}</span>
460
+ </div>
513
461
  )
514
462
  }
515
463
 
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
- })
464
+ function ToolbarIconButton({
465
+ onClick,
466
+ title,
467
+ ariaLabel,
468
+ children,
469
+ }: {
470
+ onClick: () => void
471
+ title: string
472
+ ariaLabel: string
473
+ children: React.ReactNode
474
+ }) {
475
+ return (
476
+ <button
477
+ type="button"
478
+ onClick={onClick}
479
+ title={title}
480
+ aria-label={ariaLabel}
481
+ className="inline-flex size-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
482
+ >
483
+ {children}
484
+ </button>
485
+ )
552
486
  }
553
487
 
554
488
  function cls(...arr: Array<string | false | null | undefined>): string {
@@ -564,7 +498,7 @@ function RotateLeftIcon() {
564
498
  strokeWidth="2"
565
499
  strokeLinecap="round"
566
500
  strokeLinejoin="round"
567
- className="size-3.5"
501
+ className="size-4"
568
502
  aria-hidden="true"
569
503
  >
570
504
  <path d="M3 12a9 9 0 1 0 3-6.7" />
@@ -582,7 +516,7 @@ function RotateRightIcon() {
582
516
  strokeWidth="2"
583
517
  strokeLinecap="round"
584
518
  strokeLinejoin="round"
585
- className="size-3.5"
519
+ className="size-4"
586
520
  aria-hidden="true"
587
521
  >
588
522
  <path d="M21 12a9 9 0 1 1-3-6.7" />
@@ -590,3 +524,41 @@ function RotateRightIcon() {
590
524
  </svg>
591
525
  )
592
526
  }
527
+
528
+ function FlipHorizontalIcon() {
529
+ return (
530
+ <svg
531
+ viewBox="0 0 24 24"
532
+ fill="none"
533
+ stroke="currentColor"
534
+ strokeWidth="2"
535
+ strokeLinecap="round"
536
+ strokeLinejoin="round"
537
+ className="size-4"
538
+ aria-hidden="true"
539
+ >
540
+ <path d="M12 3v18" />
541
+ <path d="M16 7l4 5-4 5" />
542
+ <path d="M8 7l-4 5 4 5" />
543
+ </svg>
544
+ )
545
+ }
546
+
547
+ function FlipVerticalIcon() {
548
+ return (
549
+ <svg
550
+ viewBox="0 0 24 24"
551
+ fill="none"
552
+ stroke="currentColor"
553
+ strokeWidth="2"
554
+ strokeLinecap="round"
555
+ strokeLinejoin="round"
556
+ className="size-4"
557
+ aria-hidden="true"
558
+ >
559
+ <path d="M3 12h18" />
560
+ <path d="M7 8l5-4 5 4" />
561
+ <path d="M7 16l5 4 5-4" />
562
+ </svg>
563
+ )
564
+ }