Skip to content

Commit 47a59db

Browse files
authored
Merge pull request #40 from asteriau/croppable-profile-elements
2 parents 7436c24 + 3fc8bc3 commit 47a59db

14 files changed

Lines changed: 560 additions & 66 deletions

File tree

app/(website)/settings/components/UploadImageForm.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,18 @@ type UploadImageFormProps = {
1818
export default function UploadImageForm({ type }: UploadImageFormProps) {
1919
const t = useT("pages.settings.components.uploadImage");
2020
const [file, setFile] = useState<File | null>(null);
21+
const [hasChanged, setHasChanged] = useState(false);
2122
const [isFileUploading, setIsFileUploading] = useState(false);
2223

2324
const { self } = useSelf();
24-
2525
const { trigger: triggerUserUpload } = useUserUpload();
26+
const { toast } = useToast();
27+
28+
const handleFileChange = (f: File | null) => {
29+
setFile(f);
30+
if (f)
31+
setHasChanged(true);
32+
};
2633

2734
const localizedType = t(`types.${type}`);
2835

@@ -33,13 +40,20 @@ export default function UploadImageForm({ type }: UploadImageFormProps) {
3340
const urlToFetch = type === "avatar" ? self.avatar_url : self.banner_url;
3441

3542
fetch(urlToFetch).then(async (res) => {
36-
const file = await res.blob();
37-
setFile(new File([file], "file.png"));
43+
const blob = await res.blob();
44+
const ext
45+
= blob.type === "image/gif"
46+
? "gif"
47+
: blob.type === "image/png"
48+
? "png"
49+
: blob.type === "image/webp"
50+
? "webp"
51+
: "jpg";
52+
53+
setFile(new File([blob], `file.${ext}`, { type: blob.type }));
3854
});
3955
}, [file, self, type]);
4056

41-
const { toast } = useToast();
42-
4357
const uploadFile = async () => {
4458
if (file === null)
4559
return;
@@ -52,15 +66,16 @@ export default function UploadImageForm({ type }: UploadImageFormProps) {
5266
type,
5367
},
5468
{
55-
onSuccess(_data, _key, _config) {
69+
onSuccess() {
5670
toast({
5771
title: t("toast.success", { type: localizedType }),
5872
variant: "success",
5973
className: "capitalize",
6074
});
6175
setIsFileUploading(false);
76+
setHasChanged(false);
6277
},
63-
onError(err, _key, _config) {
78+
onError(err) {
6479
toast({
6580
title: err?.message ?? t("toast.error"),
6681
variant: "destructive",
@@ -74,16 +89,18 @@ export default function UploadImageForm({ type }: UploadImageFormProps) {
7489
return (
7590
<>
7691
<ImageSelect
77-
setFile={setFile}
92+
setFile={handleFileChange}
7893
file={file}
7994
isWide={type === "banner"}
8095
maxFileSizeBytes={5 * 1024 * 1024}
96+
enableCrop
97+
type={type}
8198
/>
8299
<Button
83100
isLoading={isFileUploading}
84101
onClick={uploadFile}
85-
className="mt-2 w-40 text-sm"
86-
variant="secondary"
102+
className={`mt-2 w-40 text-sm${hasChanged ? " text-black" : ""}`}
103+
variant={hasChanged ? "default" : "secondary"}
87104
>
88105
<CloudUpload />
89106
{t("button", { type: localizedType })}
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"use client";
2+
3+
import { Grid, Image as ImageIcon, RotateCcw } from "lucide-react";
4+
import { useCallback, useEffect, useMemo, useState } from "react";
5+
import type { Area } from "react-easy-crop";
6+
import Cropper from "react-easy-crop";
7+
8+
import { Button } from "@/components/ui/button";
9+
import {
10+
Dialog,
11+
DialogContent,
12+
DialogDescription,
13+
DialogFooter,
14+
DialogHeader,
15+
DialogTitle,
16+
} from "@/components/ui/dialog";
17+
import { useT } from "@/lib/i18n/utils";
18+
import { cn } from "@/lib/utils";
19+
20+
type ImageCropDialogProps = {
21+
file: File | null;
22+
type: "avatar" | "banner";
23+
open: boolean;
24+
onOpenChange: (open: boolean) => void;
25+
onCropped: (file: File) => void;
26+
};
27+
28+
function readFileAsDataUrl(file: File): Promise<string> {
29+
return new Promise((resolve, reject) => {
30+
const reader = new FileReader();
31+
reader.addEventListener("load", () => {
32+
if (typeof reader.result === "string") {
33+
resolve(reader.result);
34+
}
35+
else {
36+
reject(new Error("Failed to read file"));
37+
}
38+
});
39+
reader.addEventListener("error", () => {
40+
reject(reader.error ?? new Error("Failed to read file"));
41+
});
42+
reader.readAsDataURL(file);
43+
});
44+
}
45+
46+
async function loadImage(src: string): Promise<HTMLImageElement> {
47+
return await new Promise((resolve, reject) => {
48+
const img = new window.Image();
49+
img.addEventListener("load", () => resolve(img));
50+
img.addEventListener("error", () => reject(new Error("Failed to load image")));
51+
img.src = src;
52+
});
53+
}
54+
55+
async function getCroppedImage(
56+
imageSrc: string,
57+
cropAreaPixels: Area,
58+
type: "avatar" | "banner",
59+
): Promise<Blob> {
60+
const image = await loadImage(imageSrc);
61+
const canvas = document.createElement("canvas");
62+
const ctx = canvas.getContext("2d");
63+
64+
if (!ctx) {
65+
throw new Error("Failed to get canvas context");
66+
}
67+
68+
const targetWidth = type === "avatar" ? 512 : 2048;
69+
const targetHeight = type === "avatar" ? 512 : 512;
70+
71+
canvas.width = targetWidth;
72+
canvas.height = targetHeight;
73+
74+
ctx.imageSmoothingQuality = "high";
75+
76+
ctx.drawImage(
77+
image,
78+
cropAreaPixels.x,
79+
cropAreaPixels.y,
80+
cropAreaPixels.width,
81+
cropAreaPixels.height,
82+
0,
83+
0,
84+
targetWidth,
85+
targetHeight,
86+
);
87+
88+
return await new Promise((resolve, reject) => {
89+
canvas.toBlob((blob) => {
90+
if (blob) {
91+
resolve(blob);
92+
}
93+
else {
94+
reject(new Error("Failed to create blob from canvas"));
95+
}
96+
}, "image/png");
97+
});
98+
}
99+
100+
const MIN_ZOOM = 1;
101+
const MAX_ZOOM = 3;
102+
103+
export default function ImageCropDialog({
104+
file,
105+
type,
106+
open,
107+
onOpenChange,
108+
onCropped,
109+
}: ImageCropDialogProps) {
110+
const t = useT("components.imageCropDialog");
111+
112+
const [imageSrc, setImageSrc] = useState<string | null>(null);
113+
const [crop, setCrop] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
114+
const [zoom, setZoom] = useState(MIN_ZOOM);
115+
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
116+
const [isSaving, setIsSaving] = useState(false);
117+
const [showGrid, setShowGrid] = useState(false);
118+
119+
const aspect = useMemo(() => (type === "avatar" ? 1 / 1 : 4 / 1), [type]);
120+
121+
useEffect(() => {
122+
if (!file || !open)
123+
return;
124+
125+
let active = true;
126+
readFileAsDataUrl(file)
127+
.then((dataUrl) => {
128+
if (active)
129+
setImageSrc(dataUrl);
130+
})
131+
.catch(() => {
132+
if (active)
133+
setImageSrc(null);
134+
});
135+
136+
return () => {
137+
active = false;
138+
};
139+
}, [file, open]);
140+
141+
const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixelsParam: Area) => {
142+
setCroppedAreaPixels(croppedAreaPixelsParam);
143+
}, []);
144+
145+
const handleClose = useCallback(
146+
(nextOpen: boolean) => {
147+
if (!nextOpen) {
148+
setImageSrc(null);
149+
setCrop({ x: 0, y: 0 });
150+
setZoom(MIN_ZOOM);
151+
setCroppedAreaPixels(null);
152+
setIsSaving(false);
153+
setShowGrid(false);
154+
}
155+
onOpenChange(nextOpen);
156+
},
157+
[onOpenChange],
158+
);
159+
160+
const handleSave = useCallback(async () => {
161+
if (!imageSrc || !croppedAreaPixels || !file)
162+
return;
163+
164+
try {
165+
setIsSaving(true);
166+
const blob = await getCroppedImage(imageSrc, croppedAreaPixels, type);
167+
const croppedFile = new File([blob], file.name, { type: "image/png" });
168+
onCropped(croppedFile);
169+
handleClose(false);
170+
}
171+
catch (error) {
172+
console.error("Failed to crop image", error);
173+
setIsSaving(false);
174+
}
175+
}, [croppedAreaPixels, file, handleClose, imageSrc, onCropped, type]);
176+
177+
const handleReset = useCallback(() => {
178+
setZoom(MIN_ZOOM);
179+
setCrop({ x: 0, y: 0 });
180+
}, []);
181+
182+
if (!file)
183+
return null;
184+
185+
return (
186+
<Dialog open={open} onOpenChange={handleClose}>
187+
<DialogContent className="max-w-2xl">
188+
<DialogHeader>
189+
<DialogTitle>{type === "avatar" ? t("titleAvatar") : t("titleBanner")}</DialogTitle>
190+
<DialogDescription>
191+
{type === "avatar" ? t("descriptionAvatar") : t("descriptionBanner")}
192+
</DialogDescription>
193+
</DialogHeader>
194+
195+
<div className="space-y-4">
196+
<div className="relative h-72 w-full overflow-hidden rounded-lg bg-black/60">
197+
{imageSrc && (
198+
<Cropper
199+
image={imageSrc}
200+
crop={crop}
201+
zoom={zoom}
202+
aspect={aspect}
203+
cropShape={type === "avatar" ? "round" : "rect"}
204+
showGrid={showGrid}
205+
restrictPosition
206+
onCropChange={setCrop}
207+
onZoomChange={setZoom}
208+
onCropComplete={onCropComplete}
209+
minZoom={MIN_ZOOM}
210+
maxZoom={MAX_ZOOM}
211+
/>
212+
)}
213+
</div>
214+
215+
<div className="space-y-2">
216+
<div className="flex items-center justify-center gap-2">
217+
<ImageIcon className="size-4 text-muted-foreground" />
218+
<input
219+
type="range"
220+
min={MIN_ZOOM}
221+
max={MAX_ZOOM}
222+
step={0.01}
223+
value={zoom}
224+
onChange={e => setZoom(Number(e.target.value))}
225+
className={cn(
226+
"h-2 w-1/2 cursor-pointer appearance-none rounded-full bg-muted focus:outline-none",
227+
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-sm",
228+
"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:shadow-sm",
229+
)}
230+
/>
231+
<ImageIcon className="size-6 text-muted-foreground" />
232+
</div>
233+
234+
<div className="flex items-center justify-center gap-2 pt-1">
235+
<Button
236+
variant="ghost"
237+
size="sm"
238+
className="gap-1 px-2 text-muted-foreground"
239+
onClick={() => setShowGrid(prev => !prev)}
240+
>
241+
{showGrid ? t("hideGrid") : t("showGrid")}
242+
<Grid className="size-4" />
243+
</Button>
244+
<Button
245+
variant="ghost"
246+
size="sm"
247+
className="gap-1 px-2 text-muted-foreground"
248+
onClick={handleReset}
249+
>
250+
<RotateCcw className="size-4" />
251+
{t("reset")}
252+
</Button>
253+
</div>
254+
</div>
255+
</div>
256+
257+
<DialogFooter className="mt-4">
258+
<Button
259+
variant="outline"
260+
type="button"
261+
onClick={() => handleClose(false)}
262+
disabled={isSaving}
263+
>
264+
{t("cancel")}
265+
</Button>
266+
<Button
267+
type="button"
268+
className="text-black"
269+
onClick={handleSave}
270+
isLoading={isSaving}
271+
disabled={!imageSrc || !croppedAreaPixels}
272+
>
273+
{t("save")}
274+
</Button>
275+
</DialogFooter>
276+
</DialogContent>
277+
</Dialog>
278+
);
279+
}

0 commit comments

Comments
 (0)