@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.
- package/README.md +42 -15
- package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
- package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
- package/dist/components/index.d.mts +497 -0
- package/dist/components/index.d.ts +497 -0
- package/dist/components/index.js +4217 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +4194 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/config/server.d.mts +26 -0
- package/dist/config/server.d.ts +26 -0
- package/dist/config/server.js +38 -0
- package/dist/config/server.js.map +1 -0
- package/dist/config/server.mjs +13 -0
- package/dist/config/server.mjs.map +1 -0
- package/dist/hooks/index.d.mts +393 -160
- package/dist/hooks/index.d.ts +393 -160
- package/dist/hooks/index.js +2792 -2137
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/index.mjs +2734 -2121
- package/dist/hooks/index.mjs.map +1 -1
- package/dist/index-CNW_LINy.d.mts +578 -0
- package/dist/index-CNW_LINy.d.ts +578 -0
- package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
- package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
- package/dist/index-NkI10J_E.d.mts +40 -0
- package/dist/index-NkI10J_E.d.ts +40 -0
- package/dist/index.d.mts +20437 -103
- package/dist/index.d.ts +20437 -103
- package/dist/index.js +2111 -1802
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2006 -1786
- package/dist/index.mjs.map +1 -1
- package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
- package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
- package/dist/services/index.d.mts +908 -2
- package/dist/services/index.d.ts +908 -2
- package/dist/services/index.js +1492 -1686
- package/dist/services/index.js.map +1 -1
- package/dist/services/index.mjs +1431 -1670
- package/dist/services/index.mjs.map +1 -1
- package/package.json +46 -6
- package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
- package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
- package/src/components/bespoke/index.ts +3 -0
- package/src/components/booking/SlotPicker.tsx +97 -0
- package/src/components/booking/index.ts +1 -0
- package/src/components/cart/CartItem.tsx +81 -0
- package/src/components/cart/CartSidebar.tsx +115 -0
- package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
- package/src/components/cart/index.ts +3 -0
- package/src/components/index.ts +8 -0
- package/src/components/orders/DeliverableReviewer.tsx +139 -0
- package/src/components/orders/index.ts +1 -0
- package/src/components/product/ProductCard.tsx +350 -0
- package/src/components/product/ProductGrid.tsx +84 -0
- package/src/components/product/index.ts +2 -0
- package/src/components/shared/AddressAutocomplete.tsx +212 -0
- package/src/components/shared/AnalyticsScripts.tsx +56 -0
- package/src/components/shared/CookieConsentBanner.tsx +98 -0
- package/src/components/shared/ImageEditorModal.tsx +1066 -0
- package/src/components/shared/ImageViewerModal.tsx +598 -0
- package/src/components/shared/LucideReactIcon.tsx +52 -0
- package/src/components/shared/MapPinDrop.tsx +101 -0
- package/src/components/shared/MultiSelectPicker.tsx +193 -0
- package/src/components/shared/OfflineBanner.tsx +46 -0
- package/src/components/shared/SearchableInput.tsx +153 -0
- package/src/components/shared/SearchablePopover.tsx +166 -0
- package/src/components/shared/SmartImage.tsx +311 -0
- package/src/components/shared/TruncatedValue.tsx +27 -0
- package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
- package/src/components/shared/index.ts +15 -0
- package/src/components/skeletons/CardSkeleton.tsx +26 -0
- package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
- package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
- package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
- package/src/components/skeletons/TableSkeleton.tsx +42 -0
- package/src/components/skeletons/index.ts +5 -0
- package/src/config/index.ts +22 -4
- package/src/config/server.ts +45 -0
- package/src/context/sdk-context.tsx +5 -2
- package/src/generated/auth-openapi.ts +2935 -0
- package/src/generated/cart-openapi.ts +652 -0
- package/src/generated/cms-openapi.ts +1668 -0
- package/src/generated/crm-openapi.ts +986 -0
- package/src/generated/graphql.ts +126 -0
- package/src/generated/index.ts +11 -0
- package/src/generated/media-openapi.ts +613 -0
- package/src/generated/notification-openapi.ts +862 -0
- package/src/generated/payment-openapi.ts +2713 -0
- package/src/generated/store-openapi.ts +9510 -0
- package/src/graphql/operations/collections.graphql +32 -0
- package/src/graphql/operations/products.graphql +197 -0
- package/src/graphql/operations/wishlist.graphql +25 -0
- package/src/hooks/index.ts +9 -2
- package/src/hooks/use-auth.ts +163 -69
- package/src/hooks/use-bespoke-availability.ts +39 -22
- package/src/hooks/use-business-config.ts +51 -0
- package/src/hooks/use-cart.ts +310 -238
- package/src/hooks/use-cms.ts +107 -127
- package/src/hooks/use-collections.ts +35 -55
- package/src/hooks/use-consultations.ts +9 -136
- package/src/hooks/use-countries.ts +19 -28
- package/src/hooks/use-currencies.ts +8 -20
- package/src/hooks/use-customer-address.ts +64 -0
- package/src/hooks/use-feedback.ts +34 -0
- package/src/hooks/use-languages.ts +26 -39
- package/src/hooks/use-media-upload.ts +23 -0
- package/src/hooks/use-notification-stream.ts +88 -0
- package/src/hooks/use-notifications.ts +163 -0
- package/src/hooks/use-order-stream.ts +29 -29
- package/src/hooks/use-orders.ts +91 -73
- package/src/hooks/use-payment.ts +116 -84
- package/src/hooks/use-products.ts +65 -269
- package/src/hooks/use-push-notifications.ts +122 -0
- package/src/hooks/use-reviews.ts +175 -0
- package/src/hooks/use-service-booking.ts +109 -132
- package/src/hooks/use-states.ts +16 -30
- package/src/hooks/use-wishlist.ts +44 -69
- package/src/index.ts +15 -11
- package/src/services/auth.service.ts +422 -132
- package/src/services/bespoke.service.ts +69 -10
- package/src/services/cart.service.ts +155 -186
- package/src/services/cms.service.ts +41 -50
- package/src/services/collection.service.ts +25 -63
- package/src/services/config.service.ts +10 -21
- package/src/services/consultation.service.ts +36 -93
- package/src/services/feedback.service.ts +76 -0
- package/src/services/index.ts +7 -1
- package/src/services/legal.service.ts +62 -0
- package/src/services/logistics.service.ts +235 -53
- package/src/services/media.service.ts +115 -0
- package/src/services/notifications.service.ts +204 -20
- package/src/services/order.service.ts +498 -127
- package/src/services/payment.service.ts +149 -100
- package/src/services/product.service.ts +157 -814
- package/src/services/review.service.ts +211 -0
- package/src/services/service-booking.service.ts +198 -99
- package/src/services/wishlist.service.ts +27 -74
- package/src/types/auth/index.ts +161 -18
- package/src/types/business/index.ts +9 -1
- package/src/types/cms/index.ts +91 -6
- package/src/types/cms/legal.ts +18 -0
- package/src/types/consultations/index.ts +60 -68
- package/src/types/index.ts +5 -3
- package/src/types/media/index.ts +35 -3
- package/src/types/payments/index.ts +80 -16
- package/src/types/reviews/index.ts +116 -0
- package/src/types/services/index.ts +113 -38
- package/src/types/store/checkout.ts +53 -0
- package/src/types/store/index.ts +273 -52
- package/src/types/store/invoice.ts +31 -0
- package/src/types/store/shipping.ts +84 -3
- package/src/types/ui/index.ts +1 -1
- package/src/utils/checkout.ts +19 -3
- package/src/utils/cookieConsent.ts +34 -0
- package/src/utils/format.ts +408 -0
- package/src/utils/geolocation.ts +134 -0
- package/src/utils/graphql-url.ts +47 -0
- package/src/utils/guest-session.ts +59 -0
- package/src/utils/http-client.ts +147 -0
- package/src/utils/index.ts +11 -4
- package/src/utils/status-colors.ts +79 -0
- package/src/utils/token-refresh.ts +115 -0
- package/src/utils/validators.ts +13 -0
- package/src/utils/youtube.ts +18 -0
- package/dist/index-C5YO9gTl.d.mts +0 -507
- package/dist/index-C5YO9gTl.d.ts +0 -507
- package/dist/index-CefG7_xq.d.mts +0 -793
- package/dist/index-DeHCf3zI.d.ts +0 -793
- package/src/graphql/collections.graphql.ts +0 -48
- package/src/graphql/index.ts +0 -7
- package/src/graphql/products.graphql.ts +0 -860
- package/src/graphql/wishlist.graphql.ts +0 -34
- package/src/hooks/use-consultation-stream.ts +0 -142
- 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;
|