Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"react-dropzone": "^14.3.8",
"react-email": "^4.0.16",
"react-hotkeys-hook": "^5.1.0",
"react-image-crop": "^11.0.10",
"react-intersection-observer": "^9.16.0",
"react-notion-x": "^7.3.0",
"react-pdf": "^8.0.2",
Expand Down
223 changes: 208 additions & 15 deletions pages/branding.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { useRouter } from "next/router";

import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, useRef } from "react";
import ReactCrop, {
centerCrop,
makeAspectCrop,
Crop,
PixelCrop,
convertToPixelCrop,
} from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";

import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import { Check, CircleHelpIcon, PlusIcon } from "lucide-react";
import { Check, CircleHelpIcon, PlusIcon, X } from "lucide-react";
import { HexColorInput, HexColorPicker } from "react-colorful";
import { toast } from "sonner";
import { mutate } from "swr";
Expand Down Expand Up @@ -43,6 +51,82 @@ export default function Branding() {
const [fileError, setFileError] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);

// Image cropping states
const [showCropper, setShowCropper] = useState(false);
const [imageToCrop, setImageToCrop] = useState<string>("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const imgRef = useRef<HTMLImageElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);

// Utility functions for cropping
const centerAspectCrop = useCallback((mediaWidth: number, mediaHeight: number, aspect: number) => {
return centerCrop(
makeAspectCrop(
{
unit: "%",
width: 90,
},
aspect,
mediaWidth,
mediaHeight,
),
mediaWidth,
mediaHeight,
);
}, []);

const canvasPreview = useCallback(async (
image: HTMLImageElement,
canvas: HTMLCanvasElement,
crop: PixelCrop,
) => {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("No 2d context");
}

const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const pixelRatio = window.devicePixelRatio || 1;

canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);

ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = "high";

const cropX = crop.x * scaleX;
const cropY = crop.y * scaleY;

ctx.save();
ctx.translate(-cropX, -cropY);
ctx.drawImage(
image,
0,
0,
image.naturalWidth,
image.naturalHeight,
0,
0,
image.naturalWidth,
image.naturalHeight,
);
ctx.restore();
}, []);

// Handle image crop completion
useEffect(() => {
if (
completedCrop?.width &&
completedCrop?.height &&
imgRef.current &&
previewCanvasRef.current
) {
canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop);
}
}, [completedCrop, canvasPreview]);

const onChangeLogo = useCallback(
(e: any) => {
setFileError(null);
Expand All @@ -56,19 +140,58 @@ export default function Branding() {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
setLogo(dataUrl);
// create a blob url for preview
const blob = convertDataUrlToFile({ dataUrl });
const blobUrl = URL.createObjectURL(blob);
setBlobUrl(blobUrl);
setImageToCrop(dataUrl);
setShowCropper(true);
};
reader.readAsDataURL(file);
}
}
// Reset the input value so the same file can be selected again
e.target.value = '';
},
[setLogo],
[],
);

const handleImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const { width, height } = e.currentTarget;
setCrop(centerAspectCrop(width, height, 1)); // Square aspect ratio for logo
}, [centerAspectCrop]);

const handleCropComplete = useCallback(() => {
if (!previewCanvasRef.current) {
return;
}

previewCanvasRef.current.toBlob((blob) => {
if (!blob) {
toast.error("Failed to process image");
return;
}

const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
setLogo(dataUrl);
// create a blob url for preview
const blob = convertDataUrlToFile({ dataUrl });
const blobUrl = URL.createObjectURL(blob);
setBlobUrl(blobUrl);
setShowCropper(false);
setImageToCrop("");
setCrop(undefined);
setCompletedCrop(undefined);
};
reader.readAsDataURL(blob);
});
}, []);
Comment on lines +160 to +186
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential memory leak with blob URLs

When creating a new blob URL at line 178, the previous blobUrl should be revoked to prevent memory leaks.

Add URL revocation before setting the new blob URL:

 const reader = new FileReader();
 reader.onload = () => {
   const dataUrl = reader.result as string;
   setLogo(dataUrl);
   // create a blob url for preview
   const blob = convertDataUrlToFile({ dataUrl });
+  // Revoke previous blob URL to prevent memory leak
+  if (blobUrl) {
+    URL.revokeObjectURL(blobUrl);
+  }
   const blobUrl = URL.createObjectURL(blob);
   setBlobUrl(blobUrl);
   setShowCropper(false);
   setImageToCrop("");
   setCrop(undefined);
   setCompletedCrop(undefined);
 };

Also consider adding cleanup in a useEffect:

useEffect(() => {
  return () => {
    if (blobUrl) {
      URL.revokeObjectURL(blobUrl);
    }
  };
}, [blobUrl]);
🤖 Prompt for AI Agents
In pages/branding.tsx around lines 160 to 186, the code creates a new blob URL
without revoking the previous one, which can cause memory leaks. To fix this,
before setting a new blob URL with setBlobUrl, check if there is an existing
blobUrl and call URL.revokeObjectURL on it to release the old URL. Additionally,
add a useEffect hook that cleans up by revoking the current blobUrl when the
component unmounts or when blobUrl changes, ensuring no blob URLs remain
allocated unnecessarily.


const handleCropCancel = useCallback(() => {
setShowCropper(false);
setImageToCrop("");
setCrop(undefined);
setCompletedCrop(undefined);
}, []);

useEffect(() => {
if (brand) {
setBrandColor(brand.brandColor || "#000000");
Expand Down Expand Up @@ -257,13 +380,8 @@ export default function Branding() {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
setLogo(dataUrl);
// create a blob url for preview
const blob = convertDataUrlToFile({
dataUrl,
});
const blobUrl = URL.createObjectURL(blob);
setBlobUrl(blobUrl);
setImageToCrop(dataUrl);
setShowCropper(true);
};
reader.readAsDataURL(file);
}
Expand Down Expand Up @@ -594,6 +712,81 @@ export default function Branding() {
</div>
</div>
</main>

{/* Image Cropper Modal */}
{showCropper && imageToCrop && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<Card className="w-full max-w-4xl max-h-[90vh] overflow-auto">
<CardContent className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Crop Logo</h3>
<Button variant="ghost" size="sm" onClick={handleCropCancel}>
<X className="h-4 w-4" />
</Button>
</div>

<div className="space-y-4">
<div className="flex justify-center">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => {
if (imgRef.current) {
setCompletedCrop(
convertToPixelCrop(c, imgRef.current.width, imgRef.current.height)
);
}
}}
aspect={1} // Square aspect ratio for logos
minWidth={100}
minHeight={100}
className="max-w-full"
>
<img
ref={imgRef}
alt="Crop me"
src={imageToCrop}
onLoad={handleImageLoad}
style={{
maxWidth: "100%",
maxHeight: "60vh",
}}
/>
</ReactCrop>
</div>

{completedCrop && (
<div className="space-y-4">
<div className="flex justify-center">
<div className="text-center">
<h4 className="text-sm font-medium mb-2">Preview:</h4>
<canvas
ref={previewCanvasRef}
className="border rounded max-w-full h-auto mx-auto"
style={{
objectFit: "contain",
width: Math.min(completedCrop.width, 200),
height: Math.min(completedCrop.height, 200),
}}
/>
</div>
</div>

<div className="flex gap-2 justify-center">
<Button onClick={handleCropComplete}>
Apply Crop
</Button>
<Button variant="outline" onClick={handleCropCancel}>
Cancel
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</AppLayout>
);
}
Loading