@claude this is what gpt gave me: use this as an example!
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { applySmartCrop } from "@/app/actions/smart-crop";
import {
computeCropRect,
getAdjustmentRange,
POSTCARD_ASPECT_RATIO,
type CropHints,
type CropRect,
type CropOptions,
} from "@/lib/smart-crop";
/**
- SmartCropPreview — robust, accessible, and pointer-friendly crop previewer
- Improvements vs. original:
-
- Pointer Events (mouse + touch + pen) with setPointerCapture for smooth drag
-
- No global window keydown listener; focusable container handles arrows
-
- Stable drag math based on initial values (no cumulative drift)
-
- ResizeObserver to keep scale in sync when the container resizes
-
- Abort stale async crop analyses; ignores late responses
-
- Defensive clamps + NaN guards; fewer re-renders via useMemo/useCallback
-
- Optional thirds grid overlay + double-click to reset adjustments
-
- ARIA: reduced-motion, roles, labels, better error messaging
*/
interface SmartCropPreviewProps {
imageUrl: string;
onCropChange?: (crop: CropRect) => void;
aspectRatio?: number; // default: POSTCARD_ASPECT_RATIO (3:2)
className?: string;
}
export default function SmartCropPreview({
imageUrl,
onCropChange,
aspectRatio = POSTCARD_ASPECT_RATIO,
className,
}: SmartCropPreviewProps) {
const [hints, setHints] = useState<CropHints | null>(null);
const [cropRect, setCropRect] = useState<CropRect | null>(null);
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAIOverlay, setShowAIOverlay] = useState(false);
const [showThirds, setShowThirds] = useState(true);
const [manualAdjust, setManualAdjust] = useState({ manualAdjustX: 0, manualAdjustY: 0 });
// Drag state
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const manualStartRef = useRef({ manualAdjustX: 0, manualAdjustY: 0 });
// DOM refs
const imgRef = useRef(null);
const containerRef = useRef(null);
// Keep an AbortController / request token for stale async protection
const requestIdRef = useRef(0);
// Track container size for scale calculation
const [containerWidth, setContainerWidth] = useState(0);
// ResizeObserver hook
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const cr = entry.contentRect;
if (cr.width > 0) setContainerWidth(cr.width);
}
});
ro.observe(el);
// Initial width
setContainerWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
// Load image dimensions when URL changes
useEffect(() => {
setHints(null);
setCropRect(null);
setError(null);
setManualAdjust({ manualAdjustX: 0, manualAdjustY: 0 });
if (!imageUrl) return;
const img = new Image();
img.decoding = "async";
img.onload = () => setImageDimensions({ width: img.width, height: img.height });
img.onerror = () => setError("Failed to load image.");
img.src = imageUrl;
}, [imageUrl]);
// Compute new crop when manual adjustment OR hints changes
useEffect(() => {
if (!hints) return;
if (imageDimensions.width <= 0 || imageDimensions.height <= 0) return;
const next = computeCropRect(
imageDimensions.width,
imageDimensions.height,
hints.focalPoint,
manualAdjust,
aspectRatio
);
setCropRect(next);
onCropChange?.(next);
}, [manualAdjust, hints, imageDimensions, aspectRatio, onCropChange]);
// Analyze (server action) once we know dimensions
useEffect(() => {
if (!imageUrl) return;
if (imageDimensions.width <= 0) return;
const myId = ++requestIdRef.current;
const run = async () => {
setLoading(true);
setError(null);
try {
const result = await applySmartCrop(
imageUrl,
imageDimensions.width,
imageDimensions.height,
{ ...manualAdjust },
aspectRatio
);
if (requestIdRef.current !== myId) return; // stale response
setHints(result.hints);
setCropRect(result.cropRect);
onCropChange?.(result.cropRect);
} catch (err: unknown) {
if (requestIdRef.current !== myId) return; // ignore late error
const msg = err instanceof Error ? err.message : "Failed to analyze image.";
setError(msg);
} finally {
if (requestIdRef.current === myId) setLoading(false);
}
};
run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageUrl, imageDimensions.width, imageDimensions.height, aspectRatio]);
// Handy values
const adjustmentRange = useMemo(
() => getAdjustmentRange(imageDimensions.width, imageDimensions.height, aspectRatio),
[imageDimensions, aspectRatio]
);
// Scale to render original pixels so that cropRect fits container
const scale = useMemo(() => {
if (!cropRect || containerWidth <= 0) return 1;
return containerWidth / cropRect.w;
}, [cropRect, containerWidth]);
// --- Pointer handlers (mouse/touch/pen) ---
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (!containerRef.current) return;
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
setIsDragging(true);
const rect = containerRef.current.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
manualStartRef.current = { ...manualAdjust };
}, [manualAdjust]);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging || !containerRef.current || !dragStartRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const dx = currentX - dragStartRef.current.x;
const dy = currentY - dragStartRef.current.y;
// Convert to normalized [-1, 1] offsets relative to container
const nx = (dx / rect.width) * 2;
const ny = (dy / rect.height) * 2;
setManualAdjust((prev) => {
const base = manualStartRef.current;
let nextX = base.manualAdjustX ?? 0;
let nextY = base.manualAdjustY ?? 0;
if (adjustmentRange.horizontal) nextX = clamp((base.manualAdjustX ?? 0) + nx, -1, 1);
if (adjustmentRange.vertical) nextY = clamp((base.manualAdjustY ?? 0) + ny, -1, 1);
if (nextX === (prev.manualAdjustX ?? 0) && nextY === (prev.manualAdjustY ?? 0)) return prev;
return { manualAdjustX: nextX, manualAdjustY: nextY };
});
},
[isDragging, adjustmentRange]
);
const endDrag = useCallback((e?: React.PointerEvent) => {
setIsDragging(false);
if (e) (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
dragStartRef.current = null;
}, []);
// Keyboard arrows on focused container
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = 0.05; // 5% per keypress
const key = e.key;
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "0"].includes(key)) return;
e.preventDefault();
setManualAdjust((prev) => {
let x = prev.manualAdjustX ?? 0;
let y = prev.manualAdjustY ?? 0;
if (key === "ArrowLeft" && adjustmentRange.horizontal) x = clamp(x - step, -1, 1);
if (key === "ArrowRight" && adjustmentRange.horizontal) x = clamp(x + step, -1, 1);
if (key === "ArrowUp" && adjustmentRange.vertical) y = clamp(y - step, -1, 1);
if (key === "ArrowDown" && adjustmentRange.vertical) y = clamp(y + step, -1, 1);
if (key === "Home" || key === "0") { x = 0; y = 0; }
if (key === "End") { x = adjustmentRange.horizontal ? Math.sign(x) : 0; y = adjustmentRange.vertical ? Math.sign(y) : 0; }
return { manualAdjustX: x, manualAdjustY: y };
});
},
[adjustmentRange]
);
const resetAdjustments = useCallback(() => setManualAdjust({ manualAdjustX: 0, manualAdjustY: 0 }), []);
const onDoubleClick = useCallback(() => resetAdjustments(), [resetAdjustments]);
// Derived object-position and transforms for the img element
const imgStyle = useMemo<React.CSSProperties>(() => {
if (!cropRect || imageDimensions.width <= 0) return {};
return {
objectFit: "none",
objectPosition: ${-cropRect.x}px ${-cropRect.y}px,
width: ${imageDimensions.width}px,
height: ${imageDimensions.height}px,
maxWidth: "none",
transform: translateZ(0) scale(${Number.isFinite(scale) && scale > 0 ? scale : 1}),
willChange: isDragging ? ("transform, object-position" as any) : undefined,
} as React.CSSProperties;
}, [cropRect, imageDimensions, scale, isDragging]);
// Utility values for UI
const offsetLabel = useMemo(() => {
const x = (manualAdjust.manualAdjustX ?? 0) * 100;
const y = (manualAdjust.manualAdjustY ?? 0) * 100;
return X: ${x.toFixed(0)}%, Y: ${y.toFixed(0)}%;
}, [manualAdjust]);
return (
<div className={"space-y-4 " + (className ?? "")}
aria-busy={loading}
aria-live="polite"
>
Smart Crop Preview ({aspectRatio.toFixed(2)} aspect)
{(adjustmentRange.horizontal || adjustmentRange.vertical) && (
Reset Position
)}
<button
onClick={() => setShowThirds((v) => !v)}
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md"
>
{showThirds ? "Hide" : "Show"} Thirds Grid
<button
onClick={() => setShowAIOverlay((v) => !v)}
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md"
>
{showAIOverlay ? "Hide" : "Show"} AI Detection
<button
onClick={() => {
// Re-run analysis with current adjustments (useful after manual nudges)
const id = ++requestIdRef.current;
(async () => {
setLoading(true);
setError(null);
try {
const result = await applySmartCrop(
imageUrl,
imageDimensions.width,
imageDimensions.height,
{ ...manualAdjust },
aspectRatio
);
if (requestIdRef.current !== id) return;
setHints(result.hints);
setCropRect(result.cropRect);
onCropChange?.(result.cropRect);
} catch (err: unknown) {
if (requestIdRef.current !== id) return;
const msg = err instanceof Error ? err.message : "Failed to analyze image.";
setError(msg);
} finally {
if (requestIdRef.current === id) setLoading(false);
}
})();
}}
disabled={loading}
className="px-3 py-1 text-sm bg-blue-500 text-white hover:bg-blue-600 rounded-md disabled:opacity-50"
>
{loading ? "Analyzing…" : "Re-analyze"}
{error && (
<div role="alert" className="p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm">
{error}
</div>
)}
<div
ref={containerRef}
className={`relative overflow-hidden rounded-lg border-2 border-gray-300 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
style={{ aspectRatio: `${aspectRatio}` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={onDoubleClick}
// Keyboard support
tabIndex={0}
onKeyDown={onKeyDown}
role="application"
aria-label="Smart crop interactive area"
>
{/* Render the original image scaled so that cropRect fills the frame */}
<img
ref={imgRef}
src={imageUrl}
alt="Crop preview"
draggable={false}
className="absolute inset-0 w-full h-full object-cover"
style={imgStyle}
/>
{/* Thirds grid overlay */}
{showThirds && cropRect && (
<svg className="absolute inset-0 w-full h-full pointer-events-none" viewBox={`0 0 ${cropRect.w} ${cropRect.h}`}>
{[1, 2].map((i) => (
<line key={`v${i}`} x1={(cropRect.w * i) / 3} y1={0} x2={(cropRect.w * i) / 3} y2={cropRect.h} stroke="white" strokeOpacity="0.5" strokeWidth={1} />
))}
{[1, 2].map((i) => (
<line key={`h${i}`} x1={0} y1={(cropRect.h * i) / 3} x2={cropRect.w} y2={(cropRect.h * i) / 3} stroke="white" strokeOpacity="0.5" strokeWidth={1} />
))}
</svg>
)}
{/* AI overlay */}
{showAIOverlay && hints && cropRect && (
<svg className="absolute inset-0 w-full h-full pointer-events-none" viewBox={`0 0 ${cropRect.w} ${cropRect.h}`}>
{/* Focal point */}
<circle
cx={hints.focalPoint.x * imageDimensions.width - cropRect.x}
cy={hints.focalPoint.y * imageDimensions.height - cropRect.y}
r={8}
fill="red"
fillOpacity={0.7}
stroke="white"
strokeWidth={2}
/>
{/* Primary subject */}
<rect
x={hints.primarySubject.box.x * imageDimensions.width - cropRect.x}
y={hints.primarySubject.box.y * imageDimensions.height - cropRect.y}
width={hints.primarySubject.box.w * imageDimensions.width}
height={hints.primarySubject.box.h * imageDimensions.height}
fill="none"
stroke="yellow"
strokeWidth={3}
strokeDasharray="5,5"
/>
{/* Other regions */}
{hints.regions.map((region, idx) => (
<g key={idx}>
<rect
x={region.box.x * imageDimensions.width - cropRect.x}
y={region.box.y * imageDimensions.height - cropRect.y}
width={region.box.w * imageDimensions.width}
height={region.box.h * imageDimensions.height}
fill="none"
stroke={region.importance > 7 ? "lime" : region.importance > 4 ? "cyan" : "gray"}
strokeWidth={2}
opacity={0.7}
/>
<text
x={region.box.x * imageDimensions.width - cropRect.x + 5}
y={region.box.y * imageDimensions.height - cropRect.y + 15}
fill="white"
fontSize={12}
fontWeight="bold"
style={{ textShadow: "0 0 3px rgba(0,0,0,0.8)" }}
>
{region.type} ({region.importance})
</text>
</g>
))}
</svg>
)}
{/* Loading shimmer */}
{loading && (
<div className="absolute inset-0 animate-pulse bg-gradient-to-r from-transparent via-white/20 to-transparent" aria-hidden />
)}
</div>
{(adjustmentRange.horizontal || adjustmentRange.vertical) && (
<div className="text-sm text-gray-600 space-y-1">
<p className="font-semibold">Manual Adjustment</p>
{adjustmentRange.horizontal && <p>• Drag or use ← → to pan horizontally</p>}
{adjustmentRange.vertical && <p>• Drag or use ↑ ↓ to pan vertically</p>}
<p className="text-xs text-gray-500 mt-2">Current offset: {offsetLabel}</p>
</div>
)}
{hints && (
<div className="text-sm text-gray-700 space-y-1 border-t pt-3">
<p className="font-semibold">AI Detection</p>
<p>• Primary subject: {hints.primarySubject.type} (confidence: {(hints.primarySubject.confidence * 100).toFixed(0)}%)</p>
<p>• Detected {hints.regions.length} regions</p>
<div className="flex flex-wrap gap-2 mt-2">
{hints.regions.slice(0, 6).map((r, i) => (
<span key={i} className={`px-2 py-1 text-xs rounded-full ${r.importance > 7 ? "bg-green-100 text-green-700" : r.importance > 4 ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}>
{r.label}
</span>
))}
</div>
</div>
)}
</div>
);
}
function clamp(n: number, min: number, max: number) {
if (Number.isNaN(n)) return 0;
return Math.min(max, Math.max(min, n));
}
@claude this is what gpt gave me: use this as an example!
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { applySmartCrop } from "@/app/actions/smart-crop";
import {
computeCropRect,
getAdjustmentRange,
POSTCARD_ASPECT_RATIO,
type CropHints,
type CropRect,
type CropOptions,
} from "@/lib/smart-crop";
/**
*/
interface SmartCropPreviewProps {
imageUrl: string;
onCropChange?: (crop: CropRect) => void;
aspectRatio?: number; // default: POSTCARD_ASPECT_RATIO (3:2)
className?: string;
}
export default function SmartCropPreview({
imageUrl,
onCropChange,
aspectRatio = POSTCARD_ASPECT_RATIO,
className,
}: SmartCropPreviewProps) {
const [hints, setHints] = useState<CropHints | null>(null);
const [cropRect, setCropRect] = useState<CropRect | null>(null);
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAIOverlay, setShowAIOverlay] = useState(false);
const [showThirds, setShowThirds] = useState(true);
const [manualAdjust, setManualAdjust] = useState({ manualAdjustX: 0, manualAdjustY: 0 });
// Drag state
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const manualStartRef = useRef({ manualAdjustX: 0, manualAdjustY: 0 });
// DOM refs
const imgRef = useRef(null);
const containerRef = useRef(null);
// Keep an AbortController / request token for stale async protection
const requestIdRef = useRef(0);
// Track container size for scale calculation
const [containerWidth, setContainerWidth] = useState(0);
// ResizeObserver hook
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const cr = entry.contentRect;
if (cr.width > 0) setContainerWidth(cr.width);
}
});
ro.observe(el);
// Initial width
setContainerWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
// Load image dimensions when URL changes
useEffect(() => {
setHints(null);
setCropRect(null);
setError(null);
setManualAdjust({ manualAdjustX: 0, manualAdjustY: 0 });
}, [imageUrl]);
// Compute new crop when manual adjustment OR hints changes
useEffect(() => {
if (!hints) return;
if (imageDimensions.width <= 0 || imageDimensions.height <= 0) return;
}, [manualAdjust, hints, imageDimensions, aspectRatio, onCropChange]);
// Analyze (server action) once we know dimensions
useEffect(() => {
if (!imageUrl) return;
if (imageDimensions.width <= 0) return;
}, [imageUrl, imageDimensions.width, imageDimensions.height, aspectRatio]);
// Handy values
const adjustmentRange = useMemo(
() => getAdjustmentRange(imageDimensions.width, imageDimensions.height, aspectRatio),
[imageDimensions, aspectRatio]
);
// Scale to render original pixels so that cropRect fits container
const scale = useMemo(() => {
if (!cropRect || containerWidth <= 0) return 1;
return containerWidth / cropRect.w;
}, [cropRect, containerWidth]);
// --- Pointer handlers (mouse/touch/pen) ---
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (!containerRef.current) return;
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
setIsDragging(true);
}, [manualAdjust]);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging || !containerRef.current || !dragStartRef.current) return;
);
const endDrag = useCallback((e?: React.PointerEvent) => {
setIsDragging(false);
if (e) (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
dragStartRef.current = null;
}, []);
// Keyboard arrows on focused container
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = 0.05; // 5% per keypress
const key = e.key;
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "0"].includes(key)) return;
e.preventDefault();
);
const resetAdjustments = useCallback(() => setManualAdjust({ manualAdjustX: 0, manualAdjustY: 0 }), []);
const onDoubleClick = useCallback(() => resetAdjustments(), [resetAdjustments]);
// Derived object-position and transforms for the img element
const imgStyle = useMemo<React.CSSProperties>(() => {
if (!cropRect || imageDimensions.width <= 0) return {};
return {
objectFit: "none",
objectPosition:
${-cropRect.x}px ${-cropRect.y}px,width:
${imageDimensions.width}px,height:
${imageDimensions.height}px,maxWidth: "none",
transform:
translateZ(0) scale(${Number.isFinite(scale) && scale > 0 ? scale : 1}),willChange: isDragging ? ("transform, object-position" as any) : undefined,
} as React.CSSProperties;
}, [cropRect, imageDimensions, scale, isDragging]);
// Utility values for UI
const offsetLabel = useMemo(() => {
const x = (manualAdjust.manualAdjustX ?? 0) * 100;
const y = (manualAdjust.manualAdjustY ?? 0) * 100;
return
X: ${x.toFixed(0)}%, Y: ${y.toFixed(0)}%;}, [manualAdjust]);
return (
<div className={"space-y-4 " + (className ?? "")}
aria-busy={loading}
aria-live="polite"
>
Smart Crop Preview ({aspectRatio.toFixed(2)} aspect)
{(adjustmentRange.horizontal || adjustmentRange.vertical) && (
Reset Position
)}
<button
onClick={() => setShowThirds((v) => !v)}
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md"
>
{showThirds ? "Hide" : "Show"} Thirds Grid
<button
onClick={() => setShowAIOverlay((v) => !v)}
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md"
>
{showAIOverlay ? "Hide" : "Show"} AI Detection
<button
onClick={() => {
// Re-run analysis with current adjustments (useful after manual nudges)
const id = ++requestIdRef.current;
(async () => {
setLoading(true);
setError(null);
try {
const result = await applySmartCrop(
imageUrl,
imageDimensions.width,
imageDimensions.height,
{ ...manualAdjust },
aspectRatio
);
if (requestIdRef.current !== id) return;
setHints(result.hints);
setCropRect(result.cropRect);
onCropChange?.(result.cropRect);
} catch (err: unknown) {
if (requestIdRef.current !== id) return;
const msg = err instanceof Error ? err.message : "Failed to analyze image.";
setError(msg);
} finally {
if (requestIdRef.current === id) setLoading(false);
}
})();
}}
disabled={loading}
className="px-3 py-1 text-sm bg-blue-500 text-white hover:bg-blue-600 rounded-md disabled:opacity-50"
>
{loading ? "Analyzing…" : "Re-analyze"}
);
}
function clamp(n: number, min: number, max: number) {
if (Number.isNaN(n)) return 0;
return Math.min(max, Math.max(min, n));
}