Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Cropper, { type Area } from "react-easy-crop";
|
||||
import { getCroppedImageFile } from "@/lib/cropImage";
|
||||
import styles from "./ImageCropDialog.module.css";
|
||||
|
||||
const MIN_ZOOM = 0.2;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_STEP = 0.05;
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
imageSrc: string | null;
|
||||
title: string;
|
||||
aspect: number;
|
||||
/** Prefer PNG for logos with transparency */
|
||||
outputType?: "image/jpeg" | "image/png";
|
||||
fileName?: string;
|
||||
onCancel: () => void;
|
||||
onComplete: (file: File, previewUrl: string) => void;
|
||||
};
|
||||
|
||||
export default function ImageCropDialog({
|
||||
open,
|
||||
imageSrc,
|
||||
title,
|
||||
aspect,
|
||||
outputType = "image/jpeg",
|
||||
fileName = "image",
|
||||
onCancel,
|
||||
onComplete,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const transparentOutput = outputType === "image/png";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
setBusy(false);
|
||||
setError("");
|
||||
}, [open, imageSrc]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||
setCroppedAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
function clampZoom(value: number) {
|
||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
|
||||
}
|
||||
|
||||
if (!open || !imageSrc) return null;
|
||||
|
||||
async function apply() {
|
||||
if (!croppedAreaPixels || !imageSrc) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const file = await getCroppedImageFile(
|
||||
imageSrc,
|
||||
croppedAreaPixels,
|
||||
fileName,
|
||||
outputType,
|
||||
);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
onComplete(file, previewUrl);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not crop image.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} role="dialog" aria-modal aria-label={title}>
|
||||
<div className={styles.dialog}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.hint}>
|
||||
Drag to move · zoom out to add padding around the image
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
transparentOutput
|
||||
? `${styles.stage} ${styles.stageLight}`
|
||||
: styles.stage
|
||||
}
|
||||
>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={(value) => setZoom(clampZoom(value))}
|
||||
onCropComplete={onCropComplete}
|
||||
showGrid={false}
|
||||
objectFit="contain"
|
||||
restrictPosition={false}
|
||||
style={{
|
||||
containerStyle: {
|
||||
background: "transparent",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.zoomRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
disabled={busy || zoom <= MIN_ZOOM}
|
||||
aria-label="Zoom out"
|
||||
onClick={() => setZoom((value) => clampZoom(value - ZOOM_STEP))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
className={styles.zoomRange}
|
||||
type="range"
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
disabled={busy}
|
||||
aria-label="Zoom"
|
||||
onChange={(event) => setZoom(clampZoom(Number(event.target.value)))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
disabled={busy || zoom >= MAX_ZOOM}
|
||||
aria-label="Zoom in"
|
||||
onClick={() => setZoom((value) => clampZoom(value + ZOOM_STEP))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className={styles.zoomValue}>{Math.round(zoom * 100)}%</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.cancelBtn}`}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setError("");
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.applyBtn}`}
|
||||
disabled={busy || !croppedAreaPixels}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{busy ? "Applying…" : "Apply crop"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user