Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 4 additions & 2 deletions apps/web/actions/organization/upload-organization-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { revalidatePath } from "next/cache";
import { sanitizeFile } from "@/lib/sanitizeFile";
import { runPromise } from "@/lib/server";

const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1MB

export async function uploadOrganizationIcon(
formData: FormData,
organizationId: Organisation.OrganisationId,
Expand Down Expand Up @@ -47,8 +49,8 @@ export async function uploadOrganizationIcon(
}

// Validate file size (limit to 2MB)
if (file.size > 2 * 1024 * 1024) {
throw new Error("File size must be less than 2MB");
if (file.size > MAX_FILE_SIZE_BYTES) {
throw new Error("File size must be less than 1MB");
Comment thread
ameer2468 marked this conversation as resolved.
}

// Create a unique file key
Expand Down
41 changes: 21 additions & 20 deletions apps/web/app/(org)/dashboard/settings/account/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { useEffect, useId, useState } from "react";
import { toast } from "sonner";
import { removeProfileImage } from "@/actions/account/remove-profile-image";
import { uploadProfileImage } from "@/actions/account/upload-profile-image";
import { FileInput } from "@/components/FileInput";
import { useDashboardContext } from "../../Contexts";
import { ProfileImage } from "./components/ProfileImage";
import { patchAccountSettings } from "./server";

export const Settings = ({
Expand All @@ -32,7 +32,9 @@ export const Settings = ({
const [defaultOrgId, setDefaultOrgId] = useState<
Organisation.OrganisationId | undefined
>(user?.defaultOrgId || undefined);
const avatarInputId = useId();
const firstNameId = useId();
const lastNameId = useId();
const contactEmailId = useId();
const initialProfileImage = user?.image ?? null;
const [profileImageOverride, setProfileImageOverride] = useState<
string | null | undefined
Expand Down Expand Up @@ -163,39 +165,38 @@ export const Settings = ({
}}
>
<div className="grid gap-6 w-full md:grid-cols-2">
<Card className="flex flex-col gap-4">
<Card className="space-y-4">
<div className="space-y-1">
<CardTitle>Profile image</CardTitle>
<CardDescription>
This image appears in your profile, comments, and shared caps.
</CardDescription>
</div>
<FileInput
id={avatarInputId}
name="profileImage"
height={120}
previewIconSize={28}
<ProfileImage
initialPreviewUrl={profileImagePreviewUrl}
onChange={handleProfileImageChange}
onRemove={handleProfileImageRemove}
disabled={isProfileImageMutating}
isLoading={isProfileImageMutating}
isUploading={isUploadingProfileImage}
isRemoving={isRemovingProfileImage}
/>
</Card>
<Card className="space-y-1">
<CardTitle>Your name</CardTitle>
<CardDescription>
Changing your name below will update how your name appears when
sharing a Cap, and in your profile.
</CardDescription>
<div className="flex flex-col flex-wrap gap-5 pt-4 w-full md:flex-row">
<div className="flex-1 space-y-2">
<Card className="space-y-4">
<div className="space-y-1">
<CardTitle>Your name</CardTitle>
<CardDescription>
Changing your name below will update how your name appears when
sharing a Cap, and in your profile.
</CardDescription>
</div>
<div className="flex flex-col flex-wrap gap-3 w-full">
<div className="flex-1">
<Input
type="text"
placeholder="First name"
onChange={(e) => setFirstName(e.target.value)}
defaultValue={firstName as string}
id="firstName"
id={firstNameId}
name="firstName"
/>
</div>
Expand All @@ -205,7 +206,7 @@ export const Settings = ({
placeholder="Last name"
onChange={(e) => setLastName(e.target.value)}
defaultValue={lastName as string}
id="lastName"
id={lastNameId}
name="lastName"
/>
</div>
Expand All @@ -221,7 +222,7 @@ export const Settings = ({
<Input
type="email"
value={user?.email as string}
id="contactEmail"
id={contactEmailId}
name="contactEmail"
disabled
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { Button } from "@cap/ui";
import { faImage } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";

interface ProfileImageProps {
initialPreviewUrl?: string | null;
onChange?: (file: File | null) => void;
onRemove?: () => void;
disabled?: boolean;
isUploading?: boolean;
isRemoving?: boolean;
}

export function ProfileImage({
initialPreviewUrl,
onChange,
onRemove,
disabled = false,
isUploading = false,
isRemoving = false,
}: ProfileImageProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(
initialPreviewUrl || null,
);
const fileInputRef = useRef<HTMLInputElement>(null);

// Reset isRemoving when the parent confirms the operation completed
useEffect(() => {
if (initialPreviewUrl !== undefined) {
setPreviewUrl(initialPreviewUrl);
}
}, [initialPreviewUrl]);
Comment thread
ameer2468 marked this conversation as resolved.

const handleFileChange = () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
const sizeLimit = 1024 * 1024 * 1;
if (file.size > sizeLimit) {
toast.error("File size must be 1MB or less");
return;
}
if (file && file.size <= sizeLimit) {
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
onChange?.(file);
}
};
Comment thread
ameer2468 marked this conversation as resolved.
Comment thread
ameer2468 marked this conversation as resolved.

const handleRemove = () => {
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onRemove?.();
};
Comment on lines +57 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Memory leak: Object URL not revoked on remove.

When removing an image, if previewUrl is a local blob URL, it must be revoked before setting to null, otherwise it leaks memory.

Apply this diff:

 	const handleRemove = () => {
+		// Revoke local object URL before removing
+		if (previewUrl && previewUrl.startsWith("blob:")) {
+			URL.revokeObjectURL(previewUrl);
+		}
 		setPreviewUrl(null);
 		if (fileInputRef.current) {
 			fileInputRef.current.value = "";
 		}
 		onRemove?.();
 	};
🤖 Prompt for AI Agents
In apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx
around lines 57 to 63, the handleRemove function currently sets previewUrl to
null without revoking a blob URL, causing a memory leak; update handleRemove to
check if previewUrl is non-null and appears to be an object URL (e.g., starts
with "blob:"), call URL.revokeObjectURL(previewUrl) before clearing it, then
proceed to clear fileInputRef.current.value and call onRemove, and handle any
exceptions from revokeObjectURL safely (try/catch) so removal still proceeds.


const handleUploadClick = () => {
if (!disabled && !isUploading && !isRemoving) {
fileInputRef.current?.click();
}
};

const isLoading = isUploading || isRemoving;

return (
<div className="rounded-xl border border-dashed bg-gray-2 h-fit border-gray-4">
<div className="flex gap-5 p-5">
<div
className={clsx(
"flex justify-center items-center rounded-full border size-14 bg-gray-3 border-gray-6",
previewUrl ? "border-solid" : "border-dashed",
)}
>
{previewUrl ? (
<Image
src={previewUrl}
alt="Profile Image"
width={56}
className="object-cover rounded-full size-14"
height={56}
/>
) : (
<FontAwesomeIcon icon={faImage} className="size-4 text-gray-9" />
)}
</div>
<input
type="file"
className="hidden h-0"
accept="image/jpeg, image/jpg, image/png, image/svg+xml"
ref={fileInputRef}
onChange={handleFileChange}
disabled={disabled || isLoading}
/>
<div className="space-y-3">
<div className="flex gap-2">
{!isRemoving && (
<Button
type="button"
variant="gray"
disabled={disabled || isLoading}
size="xs"
onClick={handleUploadClick}
spinner={isUploading}
>
{isUploading ? "Uploading..." : "Upload Image"}
</Button>
)}
{(previewUrl || isRemoving) && (
<Button
type="button"
variant="outline"
disabled={disabled || isLoading}
size="xs"
onClick={handleRemove}
spinner={isRemoving}
>
{isRemoving ? "Removing image..." : "Remove"}
</Button>
)}
</div>
<p className="text-xs text-gray-10">Recommended size: 120x120</p>
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const OrganizationIcon = () => {
toast.success("Organization icon updated successfully");
}
} catch (error) {
console.error("Error uploading organization icon:", error);
toast.error(
error instanceof Error ? error.message : "Failed to upload icon",
);
Expand Down Expand Up @@ -75,6 +74,7 @@ export const OrganizationIcon = () => {
isLoading={isUploading}
initialPreviewUrl={existingIconUrl || null}
onRemove={handleRemoveIcon}
maxFileSizeBytes={1 * 1024 * 1024} // 1MB
/>
</div>
);
Expand Down
30 changes: 18 additions & 12 deletions apps/web/components/FileInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,28 @@ import { toast } from "sonner";
import { Tooltip } from "./Tooltip";

const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]);
const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024;
const DEFAULT_MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = Array.from(ALLOWED_IMAGE_TYPES).join(",");

export interface FileInputProps {
onChange?: (file: File | null) => void;
disabled?: boolean;
id?: string;
name?: string;
containerStyle?: React.CSSProperties;
className?: string;
notDraggingClassName?: string;
initialPreviewUrl?: string | null;
onRemove?: () => void;
isLoading?: boolean;
height?: string | number;
previewIconSize?: string | number;
maxFileSizeBytes?: number;
}

export const FileInput: React.FC<FileInputProps> = ({
onChange,
containerStyle,
disabled = false,
id = "file",
name = "file",
Expand All @@ -42,8 +45,9 @@ export const FileInput: React.FC<FileInputProps> = ({
initialPreviewUrl = null,
onRemove,
isLoading = false,
height = "44px",
height = 44,
previewIconSize = 20,
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_BYTES,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
Expand Down Expand Up @@ -130,9 +134,10 @@ export const FileInput: React.FC<FileInputProps> = ({
return;
}

// Validate file size (limit to 3MB)
if (file.size > MAX_FILE_SIZE_BYTES) {
toast.error("File size must be 3MB or less");
// Validate file size
if (file.size > maxFileSizeBytes) {
const maxSizeMB = maxFileSizeBytes / (1024 * 1024);
toast.error(`File size must be ${maxSizeMB}MB or less`);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
Expand Down Expand Up @@ -185,14 +190,15 @@ export const FileInput: React.FC<FileInputProps> = ({
<div
style={{
height,
...containerStyle,
}}
>
{/* Fixed height container to prevent resizing */}
{previewUrl ? (
<div className="flex h-full items-center gap-2 rounded-xl border border-dashed border-gray-4 bg-gray-1 p-1.5">
<div className="flex h-full items-center gap-2 rounded-xl border border-dashed border-gray-4 bg-gray-1 px-4 py-1.5">
<div className="flex flex-1 items-center gap-1.5">
<div className="flex flex-1 items-center gap-1">
<div className="flex items-center gap-2 px-2">
<div className="flex flex-1 gap-1 items-center">
<div className="flex gap-2 items-center">
<p className="text-xs font-medium text-gray-12">
Current icon:{" "}
</p>
Expand All @@ -201,7 +207,7 @@ export const FileInput: React.FC<FileInputProps> = ({
width: previewIconSize,
height: previewIconSize,
}}
className="relative flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full"
className="flex overflow-hidden relative flex-shrink-0 justify-center items-center rounded-full"
>
{previewUrl && (
<Image
Expand All @@ -220,7 +226,7 @@ export const FileInput: React.FC<FileInputProps> = ({
<Button
variant="outline"
size="xs"
className="!p-0 size-7 group mr-2"
className="!p-0 size-7 group"
disabled={isLoading || disabled}
onClick={handleRemove}
>
Expand All @@ -239,7 +245,7 @@ export const FileInput: React.FC<FileInputProps> = ({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={clsx(
"flex h-full w-full cursor-pointer items-center justify-center gap-3 rounded-xl border border-dashed px-4 transition-all duration-300",
"flex gap-3 justify-center items-center px-4 w-full h-full rounded-xl border border-dashed transition-all duration-300 cursor-pointer",
isDragging
? "border-blue-500 bg-gray-5"
: `border-gray-5 hover:bg-gray-2 ${notDraggingClassName}`,
Expand All @@ -248,7 +254,7 @@ export const FileInput: React.FC<FileInputProps> = ({
>
{isLoading ? (
<FontAwesomeIcon
className="size-4 animate-spin text-gray-10"
className="animate-spin size-4 text-gray-10"
icon={faSpinner}
/>
) : (
Expand Down
Loading