@kasuvia/sdk 0.0.8 → 0.0.9

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 (176) hide show
  1. package/README.md +42 -15
  2. package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
  3. package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
  4. package/dist/components/index.d.mts +497 -0
  5. package/dist/components/index.d.ts +497 -0
  6. package/dist/components/index.js +4217 -0
  7. package/dist/components/index.js.map +1 -0
  8. package/dist/components/index.mjs +4194 -0
  9. package/dist/components/index.mjs.map +1 -0
  10. package/dist/config/server.d.mts +26 -0
  11. package/dist/config/server.d.ts +26 -0
  12. package/dist/config/server.js +38 -0
  13. package/dist/config/server.js.map +1 -0
  14. package/dist/config/server.mjs +13 -0
  15. package/dist/config/server.mjs.map +1 -0
  16. package/dist/hooks/index.d.mts +393 -160
  17. package/dist/hooks/index.d.ts +393 -160
  18. package/dist/hooks/index.js +2792 -2137
  19. package/dist/hooks/index.js.map +1 -1
  20. package/dist/hooks/index.mjs +2734 -2121
  21. package/dist/hooks/index.mjs.map +1 -1
  22. package/dist/index-CNW_LINy.d.mts +578 -0
  23. package/dist/index-CNW_LINy.d.ts +578 -0
  24. package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
  25. package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
  26. package/dist/index-NkI10J_E.d.mts +40 -0
  27. package/dist/index-NkI10J_E.d.ts +40 -0
  28. package/dist/index.d.mts +20437 -103
  29. package/dist/index.d.ts +20437 -103
  30. package/dist/index.js +2111 -1802
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +2006 -1786
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
  35. package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
  36. package/dist/services/index.d.mts +908 -2
  37. package/dist/services/index.d.ts +908 -2
  38. package/dist/services/index.js +1492 -1686
  39. package/dist/services/index.js.map +1 -1
  40. package/dist/services/index.mjs +1431 -1670
  41. package/dist/services/index.mjs.map +1 -1
  42. package/package.json +46 -6
  43. package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
  44. package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
  45. package/src/components/bespoke/index.ts +3 -0
  46. package/src/components/booking/SlotPicker.tsx +97 -0
  47. package/src/components/booking/index.ts +1 -0
  48. package/src/components/cart/CartItem.tsx +81 -0
  49. package/src/components/cart/CartSidebar.tsx +115 -0
  50. package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
  51. package/src/components/cart/index.ts +3 -0
  52. package/src/components/index.ts +8 -0
  53. package/src/components/orders/DeliverableReviewer.tsx +139 -0
  54. package/src/components/orders/index.ts +1 -0
  55. package/src/components/product/ProductCard.tsx +350 -0
  56. package/src/components/product/ProductGrid.tsx +84 -0
  57. package/src/components/product/index.ts +2 -0
  58. package/src/components/shared/AddressAutocomplete.tsx +212 -0
  59. package/src/components/shared/AnalyticsScripts.tsx +56 -0
  60. package/src/components/shared/CookieConsentBanner.tsx +98 -0
  61. package/src/components/shared/ImageEditorModal.tsx +1066 -0
  62. package/src/components/shared/ImageViewerModal.tsx +598 -0
  63. package/src/components/shared/LucideReactIcon.tsx +52 -0
  64. package/src/components/shared/MapPinDrop.tsx +101 -0
  65. package/src/components/shared/MultiSelectPicker.tsx +193 -0
  66. package/src/components/shared/OfflineBanner.tsx +46 -0
  67. package/src/components/shared/SearchableInput.tsx +153 -0
  68. package/src/components/shared/SearchablePopover.tsx +166 -0
  69. package/src/components/shared/SmartImage.tsx +311 -0
  70. package/src/components/shared/TruncatedValue.tsx +27 -0
  71. package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
  72. package/src/components/shared/index.ts +15 -0
  73. package/src/components/skeletons/CardSkeleton.tsx +26 -0
  74. package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
  75. package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
  76. package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
  77. package/src/components/skeletons/TableSkeleton.tsx +42 -0
  78. package/src/components/skeletons/index.ts +5 -0
  79. package/src/config/index.ts +22 -4
  80. package/src/config/server.ts +45 -0
  81. package/src/context/sdk-context.tsx +5 -2
  82. package/src/generated/auth-openapi.ts +2935 -0
  83. package/src/generated/cart-openapi.ts +652 -0
  84. package/src/generated/cms-openapi.ts +1668 -0
  85. package/src/generated/crm-openapi.ts +986 -0
  86. package/src/generated/graphql.ts +126 -0
  87. package/src/generated/index.ts +11 -0
  88. package/src/generated/media-openapi.ts +613 -0
  89. package/src/generated/notification-openapi.ts +862 -0
  90. package/src/generated/payment-openapi.ts +2713 -0
  91. package/src/generated/store-openapi.ts +9510 -0
  92. package/src/graphql/operations/collections.graphql +32 -0
  93. package/src/graphql/operations/products.graphql +197 -0
  94. package/src/graphql/operations/wishlist.graphql +25 -0
  95. package/src/hooks/index.ts +9 -2
  96. package/src/hooks/use-auth.ts +163 -69
  97. package/src/hooks/use-bespoke-availability.ts +39 -22
  98. package/src/hooks/use-business-config.ts +51 -0
  99. package/src/hooks/use-cart.ts +310 -238
  100. package/src/hooks/use-cms.ts +107 -127
  101. package/src/hooks/use-collections.ts +35 -55
  102. package/src/hooks/use-consultations.ts +9 -136
  103. package/src/hooks/use-countries.ts +19 -28
  104. package/src/hooks/use-currencies.ts +8 -20
  105. package/src/hooks/use-customer-address.ts +64 -0
  106. package/src/hooks/use-feedback.ts +34 -0
  107. package/src/hooks/use-languages.ts +26 -39
  108. package/src/hooks/use-media-upload.ts +23 -0
  109. package/src/hooks/use-notification-stream.ts +88 -0
  110. package/src/hooks/use-notifications.ts +163 -0
  111. package/src/hooks/use-order-stream.ts +29 -29
  112. package/src/hooks/use-orders.ts +91 -73
  113. package/src/hooks/use-payment.ts +116 -84
  114. package/src/hooks/use-products.ts +65 -269
  115. package/src/hooks/use-push-notifications.ts +122 -0
  116. package/src/hooks/use-reviews.ts +175 -0
  117. package/src/hooks/use-service-booking.ts +109 -132
  118. package/src/hooks/use-states.ts +16 -30
  119. package/src/hooks/use-wishlist.ts +44 -69
  120. package/src/index.ts +15 -11
  121. package/src/services/auth.service.ts +422 -132
  122. package/src/services/bespoke.service.ts +69 -10
  123. package/src/services/cart.service.ts +155 -186
  124. package/src/services/cms.service.ts +41 -50
  125. package/src/services/collection.service.ts +25 -63
  126. package/src/services/config.service.ts +10 -21
  127. package/src/services/consultation.service.ts +36 -93
  128. package/src/services/feedback.service.ts +76 -0
  129. package/src/services/index.ts +7 -1
  130. package/src/services/legal.service.ts +62 -0
  131. package/src/services/logistics.service.ts +235 -53
  132. package/src/services/media.service.ts +115 -0
  133. package/src/services/notifications.service.ts +204 -20
  134. package/src/services/order.service.ts +498 -127
  135. package/src/services/payment.service.ts +149 -100
  136. package/src/services/product.service.ts +157 -814
  137. package/src/services/review.service.ts +211 -0
  138. package/src/services/service-booking.service.ts +198 -99
  139. package/src/services/wishlist.service.ts +27 -74
  140. package/src/types/auth/index.ts +161 -18
  141. package/src/types/business/index.ts +9 -1
  142. package/src/types/cms/index.ts +91 -6
  143. package/src/types/cms/legal.ts +18 -0
  144. package/src/types/consultations/index.ts +60 -68
  145. package/src/types/index.ts +5 -3
  146. package/src/types/media/index.ts +35 -3
  147. package/src/types/payments/index.ts +80 -16
  148. package/src/types/reviews/index.ts +116 -0
  149. package/src/types/services/index.ts +113 -38
  150. package/src/types/store/checkout.ts +53 -0
  151. package/src/types/store/index.ts +273 -52
  152. package/src/types/store/invoice.ts +31 -0
  153. package/src/types/store/shipping.ts +84 -3
  154. package/src/types/ui/index.ts +1 -1
  155. package/src/utils/checkout.ts +19 -3
  156. package/src/utils/cookieConsent.ts +34 -0
  157. package/src/utils/format.ts +408 -0
  158. package/src/utils/geolocation.ts +134 -0
  159. package/src/utils/graphql-url.ts +47 -0
  160. package/src/utils/guest-session.ts +59 -0
  161. package/src/utils/http-client.ts +147 -0
  162. package/src/utils/index.ts +11 -4
  163. package/src/utils/status-colors.ts +79 -0
  164. package/src/utils/token-refresh.ts +115 -0
  165. package/src/utils/validators.ts +13 -0
  166. package/src/utils/youtube.ts +18 -0
  167. package/dist/index-C5YO9gTl.d.mts +0 -507
  168. package/dist/index-C5YO9gTl.d.ts +0 -507
  169. package/dist/index-CefG7_xq.d.mts +0 -793
  170. package/dist/index-DeHCf3zI.d.ts +0 -793
  171. package/src/graphql/collections.graphql.ts +0 -48
  172. package/src/graphql/index.ts +0 -7
  173. package/src/graphql/products.graphql.ts +0 -860
  174. package/src/graphql/wishlist.graphql.ts +0 -34
  175. package/src/hooks/use-consultation-stream.ts +0 -142
  176. package/src/types/bespoke/index.ts +0 -39
@@ -0,0 +1,1066 @@
1
+ "use client";
2
+
3
+ import {
4
+ useState,
5
+ useRef,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ } from "react";
10
+ import { AnimatePresence, motion } from "framer-motion";
11
+ import {
12
+ Camera,
13
+ Check,
14
+ Contrast as ContrastIcon,
15
+ Crop,
16
+ FlipHorizontal,
17
+ FlipVertical,
18
+ Image as ImageIcon,
19
+ Info,
20
+ Loader2,
21
+ Maximize2,
22
+ Move,
23
+ RectangleHorizontal,
24
+ RectangleVertical,
25
+ RotateCcw,
26
+ RotateCw,
27
+ Sliders,
28
+ Smartphone,
29
+ Sparkles,
30
+ Square,
31
+ Sun,
32
+ Tv,
33
+ Upload,
34
+ Wand2,
35
+ X,
36
+ ZoomIn,
37
+ ZoomOut,
38
+ } from "lucide-react";
39
+ import { toast } from "sonner";
40
+
41
+ // ─── Types ────────────────────────────────────────────────────────────────────
42
+
43
+ export interface ImageEditorModalProps {
44
+ open: boolean;
45
+ onOpenChange: (open: boolean) => void;
46
+ onImageSelect: (processedImage: Blob, fileName: string) => void;
47
+ aspectRatio?: number;
48
+ /** Max file size in bytes. Defaults to 10 MB. */
49
+ maxFileSize?: number;
50
+ acceptedFormats?: string[];
51
+ maxDimension?: number;
52
+ initialFile?: File | null;
53
+ /** Output blob format. Defaults to "image/jpeg". Pass "image/png" for alpha. */
54
+ outputFormat?: "image/jpeg" | "image/png";
55
+ /** JPEG export quality 0–1. */
56
+ outputQuality?: number;
57
+ /** Minimum output dimensions. */
58
+ minWidth?: number;
59
+ minHeight?: number;
60
+ }
61
+
62
+ interface AspectPreset {
63
+ label: string;
64
+ ratio: number | undefined;
65
+ icon: React.ReactNode;
66
+ }
67
+
68
+ interface CropBox {
69
+ x: number; // fraction 0..1 of frame container width
70
+ y: number;
71
+ w: number;
72
+ h: number;
73
+ }
74
+
75
+ type HandleType =
76
+ | "nw" | "n" | "ne"
77
+ | "w" | "e"
78
+ | "sw" | "s" | "se"
79
+ | "body"
80
+ | null;
81
+
82
+ // ─── Constants ────────────────────────────────────────────────────────────────
83
+
84
+ const HANDLE_SIZE = 14; // px
85
+ const DEFAULT_CROP: CropBox = { x: 0.05, y: 0.05, w: 0.9, h: 0.9 };
86
+
87
+ const ASPECT_PRESETS: AspectPreset[] = [
88
+ { label: "Free", ratio: undefined, icon: <Move className="w-3.5 h-3.5" /> },
89
+ { label: "Original", ratio: -1, icon: <ImageIcon className="w-3.5 h-3.5" /> },
90
+ { label: "1 : 1", ratio: 1, icon: <Square className="w-3.5 h-3.5" /> },
91
+ { label: "16 : 9", ratio: 16 / 9, icon: <Tv className="w-3.5 h-3.5" /> },
92
+ { label: "9 : 16", ratio: 9 / 16, icon: <Smartphone className="w-3.5 h-3.5" /> },
93
+ { label: "4 : 3", ratio: 4 / 3, icon: <RectangleHorizontal className="w-3.5 h-3.5" /> },
94
+ { label: "3 : 4", ratio: 3 / 4, icon: <RectangleVertical className="w-3.5 h-3.5" /> },
95
+ { label: "4 : 5", ratio: 4 / 5, icon: <RectangleVertical className="w-3.5 h-3.5" /> },
96
+ { label: "3 : 2", ratio: 3 / 2, icon: <RectangleHorizontal className="w-3.5 h-3.5" /> },
97
+ { label: "2 : 3", ratio: 2 / 3, icon: <RectangleVertical className="w-3.5 h-3.5" /> },
98
+ ];
99
+
100
+ const HANDLE_DEFS = [
101
+ { key: "nw" as HandleType, cursor: "nwse-resize", style: { left: -7, top: -7 } },
102
+ { key: "ne" as HandleType, cursor: "nesw-resize", style: { right: -7, top: -7 } },
103
+ { key: "sw" as HandleType, cursor: "nesw-resize", style: { left: -7, bottom: -7 } },
104
+ { key: "se" as HandleType, cursor: "nwse-resize", style: { right: -7, bottom: -7 } },
105
+ { key: "n" as HandleType, cursor: "ns-resize", style: { left: "calc(50% - 7px)", top: -7, width: 14 } },
106
+ { key: "s" as HandleType, cursor: "ns-resize", style: { left: "calc(50% - 7px)", bottom: -7, width: 14 } },
107
+ { key: "w" as HandleType, cursor: "ew-resize", style: { top: "calc(50% - 7px)", left: -7, height: 14 } },
108
+ { key: "e" as HandleType, cursor: "ew-resize", style: { top: "calc(50% - 7px)", right: -7, height: 14 } },
109
+ ] as const;
110
+
111
+ // ─── Helper Components ────────────────────────────────────────────────────────
112
+
113
+ function InlineSlider({
114
+ value,
115
+ onValueChange,
116
+ min,
117
+ max,
118
+ step = 1,
119
+ className,
120
+ }: {
121
+ value: number[];
122
+ onValueChange: (value: number[]) => void;
123
+ min: number;
124
+ max: number;
125
+ step?: number;
126
+ className?: string;
127
+ }) {
128
+ return (
129
+ <input
130
+ type="range"
131
+ min={min}
132
+ max={max}
133
+ step={step}
134
+ value={value[0]}
135
+ onChange={(e) => onValueChange([parseFloat(e.target.value)])}
136
+ className={`w-full h-1.5 bg-muted rounded-lg appearance-none cursor-pointer accent-primary ${className ?? ""}`}
137
+ />
138
+ );
139
+ }
140
+
141
+ // ─── Main Component ───────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * ImageEditorModal — Studio-grade full-screen image editor.
145
+ *
146
+ * Features:
147
+ * - Fullscreen, theme-aware responsive modal layout
148
+ * - File drop / camera photo capture
149
+ * - Freehand / aspect-locked crop selection box with handles & rule-of-thirds grid
150
+ * - Pan, zoom, rotation slider, ±90° rotate buttons, horizontal & vertical flip
151
+ * - Brightness, contrast, saturation, blur adjustments
152
+ * - Offscreen canvas export slicing exact crop region into JPEG/PNG Blob
153
+ */
154
+ export function ImageEditorModal({
155
+ open,
156
+ onOpenChange,
157
+ onImageSelect,
158
+ aspectRatio: initialAspectRatio,
159
+ maxFileSize = 10 * 1024 * 1024,
160
+ acceptedFormats = ["image/jpeg", "image/png", "image/webp"],
161
+ maxDimension = 4000,
162
+ initialFile = null,
163
+ outputFormat = "image/jpeg",
164
+ outputQuality = 0.92,
165
+ minWidth,
166
+ minHeight,
167
+ }: ImageEditorModalProps) {
168
+ // ── Image source ──────────────────────────────────────────────────────────
169
+ const [imageSrc, setImageSrc] = useState<string | null>(null);
170
+ const [fileName, setFileName] = useState("");
171
+ const [sourceDimensions, setSourceDimensions] = useState<{ width: number; height: number } | null>(null);
172
+
173
+ // ── Active tool panel ──────────────────────────────────────────────────────
174
+ const [activeTab, setActiveTab] = useState<"crop" | "adjust">("crop");
175
+
176
+ // ── Transform state ────────────────────────────────────────────────────────
177
+ const [pan, setPan] = useState({ x: 0, y: 0 });
178
+ const [zoom, setZoom] = useState(1);
179
+ const [rotation, setRotation] = useState(0);
180
+ const [flipX, setFlipX] = useState(false);
181
+ const [flipY, setFlipY] = useState(false);
182
+
183
+ // ── Adjustment state ───────────────────────────────────────────────────────
184
+ const [brightness, setBrightness] = useState(0); // -100..100
185
+ const [contrast, setContrast] = useState(0);
186
+ const [saturation, setSaturation] = useState(0);
187
+ const [blur, setBlur] = useState(0); // 0..10px
188
+
189
+ // ── Crop state ─────────────────────────────────────────────────────────────
190
+ const [activePresetRatio, setActivePresetRatio] = useState<number | undefined>(initialAspectRatio);
191
+ const [cropBox, setCropBox] = useState<CropBox>(DEFAULT_CROP);
192
+ const [isDraggingBox, setIsDraggingBox] = useState(false);
193
+
194
+ // ── UI state ───────────────────────────────────────────────────────────────
195
+ const [isProcessing, setIsProcessing] = useState(false);
196
+ const [showCamera, setShowCamera] = useState(false);
197
+ const [isCameraStarting, setIsCameraStarting] = useState(false);
198
+ const [cameraSupported] = useState(
199
+ () => typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia
200
+ );
201
+
202
+ // ── Refs ───────────────────────────────────────────────────────────────────
203
+ const frameRef = useRef<HTMLDivElement>(null);
204
+ const canvasRef = useRef<HTMLCanvasElement>(null);
205
+ const fileInputRef = useRef<HTMLInputElement>(null);
206
+ const videoRef = useRef<HTMLVideoElement>(null);
207
+ const cameraStreamRef = useRef<MediaStream | null>(null);
208
+ const loadedInitialFileRef = useRef<File | null>(null);
209
+
210
+ const dragStartRef = useRef<{
211
+ handle: HandleType;
212
+ startBox: CropBox;
213
+ startMousePx: { x: number; y: number };
214
+ frameRect: DOMRect;
215
+ } | null>(null);
216
+
217
+ // ── Helpers ────────────────────────────────────────────────────────────────
218
+ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
219
+
220
+ const effectiveAspectRatio = useMemo(() => {
221
+ if (activePresetRatio === -1 && sourceDimensions) {
222
+ return sourceDimensions.width / sourceDimensions.height;
223
+ }
224
+ return activePresetRatio && activePresetRatio > 0 ? activePresetRatio : undefined;
225
+ }, [activePresetRatio, sourceDimensions]);
226
+
227
+ const isSourceBelowMin = useMemo(() => {
228
+ if (!sourceDimensions) return false;
229
+ return (
230
+ (minWidth && sourceDimensions.width < minWidth) ||
231
+ (minHeight && sourceDimensions.height < minHeight)
232
+ );
233
+ }, [sourceDimensions, minWidth, minHeight]);
234
+
235
+ const filterStyle = useMemo(() => {
236
+ const b = 100 + brightness;
237
+ const c = 100 + contrast;
238
+ const s = 100 + saturation;
239
+ return `brightness(${b}%) contrast(${c}%) saturate(${s}%) blur(${blur}px)`;
240
+ }, [brightness, contrast, saturation, blur]);
241
+
242
+ // ── Reset ──────────────────────────────────────────────────────────────────
243
+ const resetEdits = useCallback(() => {
244
+ setPan({ x: 0, y: 0 });
245
+ setZoom(1);
246
+ setRotation(0);
247
+ setFlipX(false);
248
+ setFlipY(false);
249
+ setBrightness(0);
250
+ setContrast(0);
251
+ setSaturation(0);
252
+ setBlur(0);
253
+ setCropBox(DEFAULT_CROP);
254
+ setActivePresetRatio(initialAspectRatio);
255
+ }, [initialAspectRatio]);
256
+
257
+ const resetModal = useCallback(() => {
258
+ setImageSrc(null);
259
+ setSourceDimensions(null);
260
+ setFileName("");
261
+ setShowCamera(false);
262
+ resetEdits();
263
+ if (fileInputRef.current) fileInputRef.current.value = "";
264
+ }, [resetEdits]);
265
+
266
+ const stopCamera = useCallback(() => {
267
+ cameraStreamRef.current?.getTracks().forEach((t) => t.stop());
268
+ cameraStreamRef.current = null;
269
+ }, []);
270
+
271
+ const handleClose = useCallback(() => {
272
+ stopCamera();
273
+ resetModal();
274
+ onOpenChange(false);
275
+ }, [stopCamera, resetModal, onOpenChange]);
276
+
277
+ // ── Keyboard & scroll lock ─────────────────────────────────────────────────
278
+ useEffect(() => {
279
+ if (!open) return;
280
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") handleClose(); };
281
+ window.addEventListener("keydown", onKey);
282
+ return () => window.removeEventListener("keydown", onKey);
283
+ }, [open, handleClose]);
284
+
285
+ useEffect(() => {
286
+ document.body.style.overflow = open ? "hidden" : "";
287
+ return () => { document.body.style.overflow = ""; };
288
+ }, [open]);
289
+
290
+ // ── Camera ─────────────────────────────────────────────────────────────────
291
+ useEffect(() => {
292
+ if (!showCamera) return;
293
+ let cancelled = false;
294
+ (async () => {
295
+ setIsCameraStarting(true);
296
+ try {
297
+ const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
298
+ if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return; }
299
+ cameraStreamRef.current = stream;
300
+ if (videoRef.current) {
301
+ videoRef.current.srcObject = stream;
302
+ await videoRef.current.play();
303
+ }
304
+ } catch (err) {
305
+ if (!cancelled) {
306
+ toast.error("Camera access denied", {
307
+ description: err instanceof Error ? err.message : "Grant camera permission in your browser settings.",
308
+ });
309
+ setShowCamera(false);
310
+ }
311
+ } finally {
312
+ if (!cancelled) setIsCameraStarting(false);
313
+ }
314
+ })();
315
+ return () => { cancelled = true; };
316
+ }, [showCamera]);
317
+
318
+ useEffect(() => () => { cameraStreamRef.current?.getTracks().forEach((t) => t.stop()); }, []);
319
+
320
+ // ── File loading ───────────────────────────────────────────────────────────
321
+ const loadFile = useCallback(
322
+ (file: File) => {
323
+ if (file.size > maxFileSize) {
324
+ toast.error("File too large", { description: `Maximum file size is ${(maxFileSize / 1024 / 1024).toFixed(1)} MB.` });
325
+ return;
326
+ }
327
+ if (!acceptedFormats.includes(file.type)) {
328
+ toast.error("Unsupported file format", {
329
+ description: `Accepted formats: ${acceptedFormats.map((f) => f.split("/")[1]?.toUpperCase()).join(", ")}`,
330
+ });
331
+ return;
332
+ }
333
+ if (file.type === "image/svg+xml") {
334
+ onImageSelect(file, file.name);
335
+ onOpenChange(false);
336
+ toast.success("Image selected");
337
+ return;
338
+ }
339
+ setFileName(file.name);
340
+ const reader = new FileReader();
341
+ reader.onload = (e) => {
342
+ const dataUrl = e.target?.result as string;
343
+ const img = new Image();
344
+ img.onload = () => {
345
+ setSourceDimensions({ width: img.naturalWidth, height: img.naturalHeight });
346
+ setImageSrc(dataUrl);
347
+ setCropBox(DEFAULT_CROP);
348
+ setPan({ x: 0, y: 0 });
349
+ setZoom(1);
350
+ if (
351
+ (minWidth && img.naturalWidth < minWidth) ||
352
+ (minHeight && img.naturalHeight < minHeight)
353
+ ) {
354
+ toast.info("Low-resolution image", {
355
+ description: `Source is ${img.naturalWidth}×${img.naturalHeight}px. It will be upscaled to meet the ${minWidth ?? img.naturalWidth}×${minHeight ?? img.naturalHeight}px target.`,
356
+ });
357
+ }
358
+ };
359
+ img.src = dataUrl;
360
+ };
361
+ reader.readAsDataURL(file);
362
+ },
363
+ [maxFileSize, acceptedFormats, minWidth, minHeight, onImageSelect, onOpenChange]
364
+ );
365
+
366
+ const handleFileChange = useCallback(
367
+ (e: React.ChangeEvent<HTMLInputElement>) => {
368
+ const file = e.target.files?.[0];
369
+ if (file) loadFile(file);
370
+ },
371
+ [loadFile]
372
+ );
373
+
374
+ const capturePhoto = useCallback(() => {
375
+ const video = videoRef.current;
376
+ if (!video || video.readyState < 2) return;
377
+ const canvas = document.createElement("canvas");
378
+ canvas.width = video.videoWidth;
379
+ canvas.height = video.videoHeight;
380
+ const ctx = canvas.getContext("2d");
381
+ if (!ctx) return;
382
+ ctx.drawImage(video, 0, 0);
383
+ canvas.toBlob(
384
+ (blob) => {
385
+ if (!blob) return;
386
+ const file = new File([blob], `capture-${Date.now()}.jpg`, { type: "image/jpeg" });
387
+ stopCamera();
388
+ setShowCamera(false);
389
+ loadFile(file);
390
+ },
391
+ "image/jpeg",
392
+ 0.92
393
+ );
394
+ }, [loadFile, stopCamera]);
395
+
396
+ useEffect(() => {
397
+ if (open && initialFile && loadedInitialFileRef.current !== initialFile) {
398
+ loadedInitialFileRef.current = initialFile;
399
+ loadFile(initialFile);
400
+ }
401
+ if (!open) loadedInitialFileRef.current = null;
402
+ }, [open, initialFile, loadFile]);
403
+
404
+ // ── Aspect-ratio preset ────────────────────────────────────────────────────
405
+ const applyAspectRatioToCropBox = useCallback(
406
+ (ratio: number | undefined) => {
407
+ if (!ratio || ratio <= 0 || !frameRef.current) return;
408
+ const rect = frameRef.current.getBoundingClientRect();
409
+ if (rect.width <= 0 || rect.height <= 0) return;
410
+ const vpAR = rect.width / rect.height;
411
+ const targetFractionAR = ratio / vpAR;
412
+ setCropBox((cur) => {
413
+ let { x, y, w, h } = cur;
414
+ const cx = x + w / 2;
415
+ const cy = y + h / 2;
416
+ if (w / h > targetFractionAR) {
417
+ w = h * targetFractionAR;
418
+ } else {
419
+ h = w / targetFractionAR;
420
+ }
421
+ w = clamp(w, 0.1, 1);
422
+ h = clamp(h, 0.1, 1);
423
+ x = clamp(cx - w / 2, 0, 1 - w);
424
+ y = clamp(cy - h / 2, 0, 1 - h);
425
+ return { x, y, w, h };
426
+ });
427
+ },
428
+ []
429
+ );
430
+
431
+ const handleSelectPreset = useCallback(
432
+ (preset: AspectPreset) => {
433
+ setActivePresetRatio(preset.ratio);
434
+ if (preset.ratio === -1 && sourceDimensions) {
435
+ applyAspectRatioToCropBox(sourceDimensions.width / sourceDimensions.height);
436
+ } else {
437
+ applyAspectRatioToCropBox(preset.ratio);
438
+ }
439
+ },
440
+ [sourceDimensions, applyAspectRatioToCropBox]
441
+ );
442
+
443
+ // ── Crop drag ──────────────────────────────────────────────────────────────
444
+ const startDrag = useCallback(
445
+ (e: React.PointerEvent, handle: HandleType) => {
446
+ e.preventDefault();
447
+ e.stopPropagation();
448
+ if (!frameRef.current) return;
449
+
450
+ const frameRect = frameRef.current.getBoundingClientRect();
451
+ setIsDraggingBox(true);
452
+
453
+ dragStartRef.current = {
454
+ handle,
455
+ startBox: { ...cropBox },
456
+ startMousePx: { x: e.clientX, y: e.clientY },
457
+ frameRect,
458
+ };
459
+
460
+ const onMove = (me: PointerEvent) => {
461
+ const ref = dragStartRef.current;
462
+ if (!ref) return;
463
+ const { handle: h, startBox: sb, startMousePx: sm, frameRect: r } = ref;
464
+ const dx = (me.clientX - sm.x) / r.width;
465
+ const dy = (me.clientY - sm.y) / r.height;
466
+
467
+ setCropBox(() => {
468
+ let { x, y, w, h: _h } = { ...sb };
469
+
470
+ if (h === "body") {
471
+ x = clamp(x + dx, 0, 1 - w);
472
+ y = clamp(y + dy, 0, 1 - _h);
473
+ } else {
474
+ let x1 = x, y1 = y, x2 = x + w, y2 = y + _h;
475
+ if (h === "nw" || h === "w" || h === "sw") x1 = clamp(x + dx, 0, x2 - 0.05);
476
+ if (h === "ne" || h === "e" || h === "se") x2 = clamp(x + w + dx, x1 + 0.05, 1);
477
+ if (h === "nw" || h === "n" || h === "ne") y1 = clamp(y + dy, 0, y2 - 0.05);
478
+ if (h === "sw" || h === "s" || h === "se") y2 = clamp(y + _h + dy, y1 + 0.05, 1);
479
+ w = x2 - x1; _h = y2 - y1;
480
+
481
+ if (effectiveAspectRatio && effectiveAspectRatio > 0) {
482
+ const vpAR = r.width / r.height;
483
+ const tAR = effectiveAspectRatio / vpAR;
484
+ if (h === "n" || h === "s") { w = clamp(_h * tAR, 0.05, 1 - x1); x2 = x1 + w; }
485
+ else { _h = clamp(w / tAR, 0.05, 1 - y1); y2 = y1 + _h; }
486
+ }
487
+
488
+ x = clamp(x1, 0, 1);
489
+ y = clamp(y1, 0, 1);
490
+ w = clamp(x2 - x, 0.05, 1 - x);
491
+ _h = clamp(y2 - y, 0.05, 1 - y);
492
+ }
493
+ return { x, y, w, h: _h };
494
+ });
495
+ };
496
+
497
+ const onUp = () => {
498
+ setIsDraggingBox(false);
499
+ dragStartRef.current = null;
500
+ window.removeEventListener("pointermove", onMove);
501
+ window.removeEventListener("pointerup", onUp);
502
+ window.removeEventListener("pointercancel", onUp);
503
+ };
504
+
505
+ window.addEventListener("pointermove", onMove);
506
+ window.addEventListener("pointerup", onUp);
507
+ window.addEventListener("pointercancel", onUp);
508
+ },
509
+ [cropBox, effectiveAspectRatio]
510
+ );
511
+
512
+ // Pointer-down on canvas background → pan
513
+ const handleFramePointerDown = useCallback(
514
+ (e: React.PointerEvent<HTMLDivElement>) => {
515
+ if ((e.target as HTMLElement).closest("[data-crop-handle]")) return;
516
+ e.preventDefault();
517
+ const sx = e.clientX, sy = e.clientY;
518
+ const spx = pan.x, spy = pan.y;
519
+ const onMove = (me: PointerEvent) => setPan({ x: spx + me.clientX - sx, y: spy + me.clientY - sy });
520
+ const onUp = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); };
521
+ window.addEventListener("pointermove", onMove);
522
+ window.addEventListener("pointerup", onUp);
523
+ },
524
+ [pan]
525
+ );
526
+
527
+ // ── Export / apply ─────────────────────────────────────────────────────────
528
+ const handleApply = useCallback(async () => {
529
+ if (!imageSrc || !canvasRef.current || !frameRef.current) return;
530
+ setIsProcessing(true);
531
+ try {
532
+ const image = new Image();
533
+ image.src = imageSrc;
534
+ await new Promise<void>((res, rej) => { image.onload = () => res(); image.onerror = () => rej(new Error("Image failed to load")); });
535
+
536
+ const origW = image.naturalWidth;
537
+ const origH = image.naturalHeight;
538
+ const rad = (rotation * Math.PI) / 180;
539
+ const absSin = Math.abs(Math.sin(rad));
540
+ const absCos = Math.abs(Math.cos(rad));
541
+ const rotW = origW * absCos + origH * absSin;
542
+ const rotH = origW * absSin + origH * absCos;
543
+
544
+ // Render rotated+flipped+filtered image to offscreen canvas
545
+ const offscreen = document.createElement("canvas");
546
+ offscreen.width = Math.ceil(rotW);
547
+ offscreen.height = Math.ceil(rotH);
548
+ const offCtx = offscreen.getContext("2d")!;
549
+ offCtx.imageSmoothingEnabled = true;
550
+ offCtx.imageSmoothingQuality = "high";
551
+ offCtx.filter = filterStyle;
552
+ offCtx.translate(rotW / 2, rotH / 2);
553
+ offCtx.rotate(rad);
554
+ offCtx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
555
+ offCtx.drawImage(image, -origW / 2, -origH / 2);
556
+
557
+ // Map crop box fractions back to offscreen coordinates
558
+ const vpRect = frameRef.current.getBoundingClientRect();
559
+ const vpW = vpRect.width, vpH = vpRect.height;
560
+ const baseScale = Math.min(vpW / rotW, vpH / rotH);
561
+ const effectiveScale = baseScale * zoom;
562
+ const displayedW = rotW * effectiveScale;
563
+ const displayedH = rotH * effectiveScale;
564
+ const marginX = (vpW - displayedW) / 2 + pan.x;
565
+ const marginY = (vpH - displayedH) / 2 + pan.y;
566
+
567
+ const cropPxX = cropBox.x * vpW, cropPxY = cropBox.y * vpH;
568
+ const cropPxW = cropBox.w * vpW, cropPxH = cropBox.h * vpH;
569
+
570
+ const sx = clamp((cropPxX - marginX) / effectiveScale, 0, rotW);
571
+ const sy = clamp((cropPxY - marginY) / effectiveScale, 0, rotH);
572
+ const sx2 = clamp((cropPxX + cropPxW - marginX) / effectiveScale, 0, rotW);
573
+ const sy2 = clamp((cropPxY + cropPxH - marginY) / effectiveScale, 0, rotH);
574
+ const sw = Math.max(1, sx2 - sx);
575
+ const sh = Math.max(1, sy2 - sy);
576
+
577
+ let outW = sw, outH = sh;
578
+
579
+ const maxSide = Math.max(outW, outH);
580
+ if (maxDimension && maxSide > maxDimension) {
581
+ const ds = maxDimension / maxSide; outW *= ds; outH *= ds;
582
+ }
583
+ if (minWidth && outW < minWidth) { const us = minWidth / outW; outW = minWidth; outH *= us; }
584
+ if (minHeight && outH < minHeight) { const us = minHeight / outH; outH = minHeight; outW *= us; }
585
+ outW = Math.round(Math.max(1, outW));
586
+ outH = Math.round(Math.max(1, outH));
587
+
588
+ const outputCanvas = canvasRef.current;
589
+ outputCanvas.width = outW;
590
+ outputCanvas.height = outH;
591
+ const outCtx = outputCanvas.getContext("2d")!;
592
+ outCtx.imageSmoothingEnabled = true;
593
+ outCtx.imageSmoothingQuality = "high";
594
+ if (outputFormat === "image/jpeg") { outCtx.fillStyle = "#fff"; outCtx.fillRect(0, 0, outW, outH); }
595
+ outCtx.drawImage(offscreen, sx, sy, sw, sh, 0, 0, outW, outH);
596
+
597
+ outputCanvas.toBlob(
598
+ (blob) => {
599
+ if (!blob) { toast.error("Failed to process image. Please try again."); return; }
600
+ const ext = outputFormat === "image/png" ? "png" : "jpg";
601
+ const baseName = fileName.replace(/\.[^./]+$/, "") || "edited-image";
602
+ onImageSelect(blob, `${baseName}.${ext}`);
603
+ resetModal();
604
+ onOpenChange(false);
605
+ toast.success("Image updated successfully");
606
+ },
607
+ outputFormat,
608
+ outputQuality
609
+ );
610
+ } catch (err) {
611
+ toast.error("Failed to process image", {
612
+ description: err instanceof Error ? err.message : "Please try again.",
613
+ });
614
+ } finally {
615
+ setIsProcessing(false);
616
+ }
617
+ }, [
618
+ imageSrc, rotation, flipX, flipY, zoom, pan, cropBox, fileName,
619
+ filterStyle, maxDimension, minWidth, minHeight,
620
+ outputFormat, outputQuality, onImageSelect, onOpenChange, resetModal,
621
+ ]);
622
+
623
+ // ── Early exit when not open ───────────────────────────────────────────────
624
+ if (!open) return null;
625
+
626
+ const btnBase =
627
+ "inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
628
+
629
+ // ─── Render ────────────────────────────────────────────────────────────────
630
+ return (
631
+ <AnimatePresence>
632
+ {open && (
633
+ <div className="fixed inset-0 z-50 select-none flex">
634
+ {/* Backdrop */}
635
+ <div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={handleClose} />
636
+
637
+ {/* Editor — true fullscreen, theme-aware */}
638
+ <motion.div
639
+ initial={{ opacity: 0, scale: 0.98 }}
640
+ animate={{ opacity: 1, scale: 1 }}
641
+ exit={{ opacity: 0, scale: 0.98 }}
642
+ transition={{ duration: 0.15, ease: "easeOut" }}
643
+ className="relative z-10 flex flex-col w-full h-full bg-background text-foreground overflow-hidden"
644
+ role="dialog"
645
+ aria-modal="true"
646
+ aria-label="Studio Image Editor"
647
+ >
648
+
649
+ {/* ── Slim top bar (h-11 = 44px) ─────────────────────────────── */}
650
+ <div className="flex items-center gap-2 px-3 sm:px-4 h-11 border-b border-border bg-card shrink-0">
651
+
652
+ {/* Icon + title */}
653
+ <div className="flex items-center gap-2 min-w-0 flex-1">
654
+ <Wand2 className="w-4 h-4 text-primary shrink-0" />
655
+ <span className="text-sm font-semibold text-foreground leading-none truncate">
656
+ Studio Editor
657
+ </span>
658
+ {sourceDimensions && (
659
+ <span className="text-[11px] text-muted-foreground hidden sm:inline truncate">
660
+ · {sourceDimensions.width}×{sourceDimensions.height}px
661
+ {minWidth && minHeight ? ` (min ${minWidth}×${minHeight})` : ""}
662
+ </span>
663
+ )}
664
+ {isSourceBelowMin && (
665
+ <span className="hidden sm:inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30 shrink-0">
666
+ <Info className="w-3 h-3" />
667
+ Upscale
668
+ </span>
669
+ )}
670
+ </div>
671
+
672
+ {/* Tabs (only when editing) */}
673
+ {imageSrc && (
674
+ <div className="flex items-center bg-muted rounded-md p-0.5 gap-0.5 shrink-0">
675
+ <button
676
+ type="button"
677
+ id="editor-tab-crop"
678
+ onClick={() => setActiveTab("crop")}
679
+ className={[
680
+ "flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-all",
681
+ activeTab === "crop"
682
+ ? "bg-background text-foreground shadow-sm font-semibold"
683
+ : "text-muted-foreground hover:text-foreground",
684
+ ].join(" ")}
685
+ >
686
+ <Crop className="w-3 h-3 text-primary" />
687
+ <span>Crop</span>
688
+ </button>
689
+ <button
690
+ type="button"
691
+ id="editor-tab-adjust"
692
+ onClick={() => setActiveTab("adjust")}
693
+ className={[
694
+ "flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-all",
695
+ activeTab === "adjust"
696
+ ? "bg-background text-foreground shadow-sm font-semibold"
697
+ : "text-muted-foreground hover:text-foreground",
698
+ ].join(" ")}
699
+ >
700
+ <Sliders className="w-3 h-3 text-secondary" />
701
+ <span>Adjust</span>
702
+ </button>
703
+ </div>
704
+ )}
705
+
706
+ {/* Reset + Close */}
707
+ <div className="flex items-center gap-1 shrink-0">
708
+ {imageSrc && (
709
+ <button
710
+ type="button"
711
+ onClick={resetEdits}
712
+ className={`${btnBase} h-7 text-xs px-2.5 text-muted-foreground hover:text-foreground hover:bg-muted border border-transparent rounded-md`}
713
+ >
714
+ <RotateCcw className="w-3 h-3 sm:mr-1" />
715
+ <span className="hidden sm:inline">Reset</span>
716
+ </button>
717
+ )}
718
+ <button
719
+ type="button"
720
+ onClick={handleClose}
721
+ id="editor-close-btn"
722
+ className={`${btnBase} h-7 w-7 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted`}
723
+ aria-label="Close editor"
724
+ >
725
+ <X className="w-3.5 h-3.5" />
726
+ </button>
727
+ </div>
728
+ </div>
729
+
730
+ {/* ── Main body ─────────────────────────────────────────────── */}
731
+ <div className="flex-1 flex flex-col overflow-hidden min-h-0">
732
+ {!imageSrc ? (
733
+ /* ── Upload / Camera screen ─────────────────────────── */
734
+ showCamera ? (
735
+ <div className="flex-1 flex flex-col items-center justify-center p-6 bg-background">
736
+ <div className="w-full max-w-sm space-y-4">
737
+ <div className="relative aspect-video rounded-xl overflow-hidden border border-border bg-muted flex items-center justify-center">
738
+ {isCameraStarting && (
739
+ <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
740
+ <Loader2 className="w-6 h-6 animate-spin text-primary" />
741
+ </div>
742
+ )}
743
+ <video ref={videoRef} autoPlay playsInline muted className="w-full h-full object-cover" />
744
+ </div>
745
+ <div className="flex gap-3">
746
+ <button
747
+ type="button"
748
+ onClick={() => { stopCamera(); setShowCamera(false); }}
749
+ className={`${btnBase} flex-1 bg-muted border border-border text-foreground hover:bg-muted/80 h-9 px-4 rounded-lg`}
750
+ >
751
+ <X className="w-4 h-4 mr-1.5" /> Cancel
752
+ </button>
753
+ <button
754
+ type="button"
755
+ onClick={capturePhoto}
756
+ disabled={isCameraStarting}
757
+ id="camera-capture-btn"
758
+ className={`${btnBase} flex-1 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 rounded-lg font-medium`}
759
+ >
760
+ <Camera className="w-4 h-4 mr-1.5" /> Capture
761
+ </button>
762
+ </div>
763
+ </div>
764
+ </div>
765
+ ) : (
766
+ <div className="flex-1 flex flex-col items-center justify-center p-6 bg-background">
767
+ <div className="w-full max-w-sm border-2 border-dashed border-border hover:border-primary/50 transition-colors rounded-2xl p-8 text-center space-y-5 bg-card/60">
768
+ <div className="w-14 h-14 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto border border-primary/20">
769
+ <Upload className="w-7 h-7" />
770
+ </div>
771
+ <div className="space-y-1">
772
+ <p className="text-base font-semibold text-foreground">Select an Image</p>
773
+ <p className="text-xs text-muted-foreground">
774
+ JPEG, PNG or WebP · up to {(maxFileSize / 1024 / 1024).toFixed(0)} MB
775
+ </p>
776
+ </div>
777
+ <div className="flex flex-wrap items-center justify-center gap-3">
778
+ <button
779
+ type="button"
780
+ id="editor-choose-file-btn"
781
+ onClick={() => fileInputRef.current?.click()}
782
+ className={`${btnBase} bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-6 rounded-xl font-medium shadow-md`}
783
+ >
784
+ Choose File
785
+ </button>
786
+ {cameraSupported && (
787
+ <button
788
+ type="button"
789
+ id="editor-camera-btn"
790
+ onClick={() => setShowCamera(true)}
791
+ className={`${btnBase} bg-card border border-border text-foreground hover:bg-muted h-9 px-5 rounded-xl font-medium`}
792
+ >
793
+ <Camera className="w-4 h-4 mr-1.5" /> Camera
794
+ </button>
795
+ )}
796
+ </div>
797
+ <input ref={fileInputRef} type="file" className="hidden" accept={acceptedFormats.join(",")} onChange={handleFileChange} />
798
+ </div>
799
+ </div>
800
+ )
801
+ ) : (
802
+ /* ── Editing workspace ──────────────────────────────── */
803
+ <>
804
+ {/* Canvas — fills ALL remaining space */}
805
+ <div className="flex-1 relative bg-muted/30 overflow-hidden select-none min-h-0">
806
+ {/* Dot-grid backdrop */}
807
+ <div
808
+ className="absolute inset-0 pointer-events-none opacity-[0.15]"
809
+ style={{
810
+ backgroundImage: "radial-gradient(circle, hsl(var(--muted-foreground)/0.6) 1px, transparent 1px)",
811
+ backgroundSize: "20px 20px",
812
+ }}
813
+ />
814
+
815
+ {/* Zoom pill — floating, top-left, very compact */}
816
+ <div className="absolute top-2 left-2 z-30 flex items-center bg-card/90 border border-border rounded-md shadow-sm backdrop-blur-sm">
817
+ <button
818
+ type="button"
819
+ id="editor-zoom-out"
820
+ onClick={() => setZoom((z) => Math.max(0.2, z - 0.15))}
821
+ className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
822
+ title="Zoom out"
823
+ >
824
+ <ZoomOut className="w-3 h-3" />
825
+ </button>
826
+ <span className="w-9 text-center font-mono text-[10px] text-foreground font-medium">
827
+ {Math.round(zoom * 100)}%
828
+ </span>
829
+ <button
830
+ type="button"
831
+ id="editor-zoom-in"
832
+ onClick={() => setZoom((z) => Math.min(5, z + 0.15))}
833
+ className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
834
+ title="Zoom in"
835
+ >
836
+ <ZoomIn className="w-3 h-3" />
837
+ </button>
838
+ <div className="h-3 w-px bg-border mx-0.5" />
839
+ <button
840
+ type="button"
841
+ id="editor-fit-btn"
842
+ onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}
843
+ className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
844
+ title="Fit to view"
845
+ >
846
+ <Maximize2 className="w-3 h-3" />
847
+ </button>
848
+ </div>
849
+
850
+ {/* Draggable frame */}
851
+ <div
852
+ ref={frameRef}
853
+ onPointerDown={handleFramePointerDown}
854
+ className="absolute inset-3 touch-none cursor-grab active:cursor-grabbing"
855
+ >
856
+ {/* Image */}
857
+ <div className="absolute inset-0 flex items-center justify-center">
858
+ <img
859
+ src={imageSrc}
860
+ alt="Editor preview"
861
+ draggable={false}
862
+ className="max-w-full max-h-full object-contain pointer-events-none select-none block"
863
+ style={{
864
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom * (flipX ? -1 : 1)}, ${zoom * (flipY ? -1 : 1)}) rotate(${rotation}deg)`,
865
+ filter: filterStyle,
866
+ transition: isDraggingBox ? "none" : "transform 0.1s ease-out",
867
+ }}
868
+ />
869
+ </div>
870
+
871
+ {/* Crop overlay */}
872
+ <div className="absolute inset-0 pointer-events-none z-10">
873
+ <div
874
+ className="absolute"
875
+ style={{
876
+ left: `${cropBox.x * 100}%`,
877
+ top: `${cropBox.y * 100}%`,
878
+ width: `${cropBox.w * 100}%`,
879
+ height: `${cropBox.h * 100}%`,
880
+ boxShadow: "0 0 0 9999px rgba(0,0,0,0.55)",
881
+ }}
882
+ >
883
+ {/* Drag body */}
884
+ <div
885
+ data-crop-handle="body"
886
+ onPointerDown={(e) => startDrag(e, "body")}
887
+ className="absolute inset-0 border-2 border-white/90 cursor-move pointer-events-auto group"
888
+ >
889
+ {/* Rule-of-thirds */}
890
+ <div className={`absolute inset-0 grid grid-cols-3 grid-rows-3 transition-opacity ${isDraggingBox ? "opacity-50" : "opacity-0 group-hover:opacity-30"}`}>
891
+ {Array.from({ length: 9 }).map((_, i) => (
892
+ <div key={i} className={[(i % 3 !== 2) ? "border-r border-white/50" : "", (i < 6) ? "border-b border-white/50" : ""].join(" ")} />
893
+ ))}
894
+ </div>
895
+ {/* Corner L-brackets */}
896
+ {[
897
+ "absolute -top-px -left-px border-t-2 border-l-2",
898
+ "absolute -top-px -right-px border-t-2 border-r-2",
899
+ "absolute -bottom-px -left-px border-b-2 border-l-2",
900
+ "absolute -bottom-px -right-px border-b-2 border-r-2",
901
+ ].map((cls, i) => (
902
+ <div key={i} className={`${cls} w-3.5 h-3.5 border-white pointer-events-none`} />
903
+ ))}
904
+ </div>
905
+
906
+ {/* Resize handles */}
907
+ {HANDLE_DEFS.map(({ key, cursor, style }) => (
908
+ <div
909
+ key={key}
910
+ data-crop-handle={key}
911
+ onPointerDown={(e) => key && startDrag(e, key)}
912
+ className="absolute bg-white border border-black/30 rounded-sm shadow-sm pointer-events-auto z-20 hover:scale-110 transition-transform"
913
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE, cursor, ...style }}
914
+ />
915
+ ))}
916
+ </div>
917
+ </div>
918
+ </div>
919
+ </div>
920
+
921
+ {/* ── Compact bottom controls ──────────────────────── */}
922
+ {activeTab === "crop" ? (
923
+ <div className="bg-card border-t border-border shrink-0">
924
+ {/* Row 1: Rotation + flip/rotate buttons */}
925
+ <div className="flex items-center gap-2 px-3 sm:px-4 h-10 border-b border-border/50">
926
+ <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider shrink-0">Rotate</span>
927
+ <div className="flex-1 min-w-0">
928
+ <InlineSlider
929
+ value={[rotation]}
930
+ onValueChange={([v]) => setRotation(v)}
931
+ min={-180}
932
+ max={180}
933
+ step={1}
934
+ />
935
+ </div>
936
+ <span className="font-mono text-[11px] text-foreground w-8 text-right shrink-0">{rotation}°</span>
937
+ <div className="h-4 w-px bg-border mx-1 shrink-0" />
938
+ {/* −90 */}
939
+ <button
940
+ type="button"
941
+ id="editor-rotate-ccw"
942
+ onClick={() => setRotation((r) => r - 90)}
943
+ title="Rotate −90°"
944
+ className="h-7 px-2 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded hover:bg-muted transition-colors shrink-0"
945
+ >
946
+ <RotateCcw className="w-3 h-3" /><span className="hidden sm:inline">−90°</span>
947
+ </button>
948
+ {/* +90 */}
949
+ <button
950
+ type="button"
951
+ id="editor-rotate-cw"
952
+ onClick={() => setRotation((r) => r + 90)}
953
+ title="Rotate +90°"
954
+ className="h-7 px-2 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded hover:bg-muted transition-colors shrink-0"
955
+ >
956
+ <RotateCw className="w-3 h-3" /><span className="hidden sm:inline">+90°</span>
957
+ </button>
958
+ <div className="h-4 w-px bg-border mx-0.5 shrink-0" />
959
+ {/* Flip H */}
960
+ <button
961
+ type="button"
962
+ id="editor-flip-h"
963
+ onClick={() => setFlipX((f) => !f)}
964
+ title="Flip Horizontal"
965
+ className={["h-7 w-7 flex items-center justify-center rounded border transition-colors shrink-0", flipX ? "bg-primary/10 text-primary border-primary/40" : "border-border text-muted-foreground hover:text-foreground hover:bg-muted"].join(" ")}
966
+ >
967
+ <FlipHorizontal className="w-3.5 h-3.5" />
968
+ </button>
969
+ {/* Flip V */}
970
+ <button
971
+ type="button"
972
+ id="editor-flip-v"
973
+ onClick={() => setFlipY((f) => !f)}
974
+ title="Flip Vertical"
975
+ className={["h-7 w-7 flex items-center justify-center rounded border transition-colors shrink-0", flipY ? "bg-primary/10 text-primary border-primary/40" : "border-border text-muted-foreground hover:text-foreground hover:bg-muted"].join(" ")}
976
+ >
977
+ <FlipVertical className="w-3.5 h-3.5" />
978
+ </button>
979
+ </div>
980
+
981
+ {/* Row 2: Aspect ratio presets */}
982
+ <div className="flex items-center gap-1.5 px-3 sm:px-4 h-9 overflow-x-auto scrollbar-hide">
983
+ {ASPECT_PRESETS.map((preset) => {
984
+ const active = activePresetRatio === preset.ratio;
985
+ return (
986
+ <button
987
+ key={preset.label}
988
+ type="button"
989
+ onClick={() => handleSelectPreset(preset)}
990
+ className={[
991
+ "flex items-center gap-1 px-2.5 py-1 rounded text-[11px] font-medium border shrink-0 transition-all whitespace-nowrap",
992
+ active
993
+ ? "bg-primary/10 border-primary/50 text-primary font-semibold"
994
+ : "border-border text-muted-foreground hover:text-foreground hover:bg-muted",
995
+ ].join(" ")}
996
+ >
997
+ {preset.icon}
998
+ <span>{preset.label}</span>
999
+ </button>
1000
+ );
1001
+ })}
1002
+ </div>
1003
+ </div>
1004
+ ) : (
1005
+ /* Adjustments panel — compact 2-col grid */
1006
+ <div className="bg-card border-t border-border px-4 py-3 shrink-0">
1007
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3 max-w-4xl mx-auto">
1008
+ {[
1009
+ { label: "Brightness", icon: <Sun className="w-3 h-3 text-amber-500" />, value: brightness, set: setBrightness, min: -100, max: 100, unit: "%" },
1010
+ { label: "Contrast", icon: <ContrastIcon className="w-3 h-3 text-blue-500" />, value: contrast, set: setContrast, min: -100, max: 100, unit: "%" },
1011
+ { label: "Saturation", icon: <Sparkles className="w-3 h-3 text-violet-500" />, value: saturation, set: setSaturation, min: -100, max: 100, unit: "%" },
1012
+ { label: "Blur", icon: <Sliders className="w-3 h-3 text-emerald-500" />, value: blur, set: setBlur, min: 0, max: 10, step: 0.5, unit: "px" },
1013
+ ].map(({ label, icon, value, set, min, max, step = 1, unit }) => (
1014
+ <div key={label} className="flex items-center gap-2">
1015
+ <span className="flex items-center gap-1 text-[11px] font-medium text-foreground w-20 shrink-0">{icon}{label}</span>
1016
+ <div className="flex-1 min-w-0">
1017
+ <InlineSlider value={[value]} onValueChange={([v]) => set(v)} min={min} max={max} step={step} />
1018
+ </div>
1019
+ <span className="font-mono text-[11px] text-muted-foreground w-9 text-right shrink-0">{value}{unit}</span>
1020
+ </div>
1021
+ ))}
1022
+ </div>
1023
+ </div>
1024
+ )}
1025
+
1026
+ {/* ── Slim footer (h-10 = 40px) ──────────────────────── */}
1027
+ <div className="flex items-center justify-between gap-3 px-3 sm:px-4 h-10 border-t border-border bg-card shrink-0">
1028
+ <button
1029
+ type="button"
1030
+ id="editor-cancel-btn"
1031
+ onClick={resetModal}
1032
+ className={`${btnBase} flex items-center gap-1.5 h-7 px-3 text-xs text-muted-foreground hover:text-foreground border border-border rounded hover:bg-muted transition-colors`}
1033
+ >
1034
+ <X className="w-3 h-3" />
1035
+ Cancel
1036
+ </button>
1037
+
1038
+ <button
1039
+ type="button"
1040
+ id="editor-apply-btn"
1041
+ onClick={handleApply}
1042
+ disabled={isProcessing}
1043
+ className={`${btnBase} flex items-center gap-1.5 h-7 px-5 text-xs font-semibold bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors disabled:opacity-60`}
1044
+ >
1045
+ {isProcessing ? (
1046
+ <Loader2 className="w-3 h-3 animate-spin" />
1047
+ ) : (
1048
+ <Check className="w-3 h-3" />
1049
+ )}
1050
+ {isProcessing ? "Processing…" : "Apply Edits"}
1051
+ </button>
1052
+ </div>
1053
+ </>
1054
+ )}
1055
+ </div>
1056
+ </motion.div>
1057
+
1058
+ {/* Hidden export canvas */}
1059
+ <canvas ref={canvasRef} className="hidden" aria-hidden="true" />
1060
+ </div>
1061
+ )}
1062
+ </AnimatePresence>
1063
+ );
1064
+ }
1065
+
1066
+ export default ImageEditorModal;