diff --git a/apps/web/actions/organization/upload-organization-icon.ts b/apps/web/actions/organization/upload-organization-icon.ts
index 72fec848e0..83c535e31f 100644
--- a/apps/web/actions/organization/upload-organization-icon.ts
+++ b/apps/web/actions/organization/upload-organization-icon.ts
@@ -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,
@@ -46,9 +48,8 @@ export async function uploadOrganizationIcon(
throw new Error("File must be an image");
}
- // 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");
}
// Create a unique file key
diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx
index 463e58a1a0..114e02d5a3 100644
--- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx
+++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx
@@ -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 = ({
@@ -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
@@ -87,10 +89,7 @@ export const Settings = ({
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [hasChanges]);
- const {
- mutate: uploadProfileImageMutation,
- isPending: isUploadingProfileImage,
- } = useMutation({
+ const uploadProfileImageMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("image", file);
@@ -114,10 +113,7 @@ export const Settings = ({
},
});
- const {
- mutate: removeProfileImageMutation,
- isPending: isRemovingProfileImage,
- } = useMutation({
+ const removeProfileImageMutation = useMutation({
mutationFn: removeProfileImage,
onSuccess: (result) => {
if (result.success) {
@@ -138,13 +134,14 @@ export const Settings = ({
});
const isProfileImageMutating =
- isUploadingProfileImage || isRemovingProfileImage;
+ uploadProfileImageMutation.isPending ||
+ removeProfileImageMutation.isPending;
const handleProfileImageChange = (file: File | null) => {
if (!file || isProfileImageMutating) {
return;
}
- uploadProfileImageMutation(file);
+ uploadProfileImageMutation.mutate(file);
};
const handleProfileImageRemove = () => {
@@ -152,7 +149,7 @@ export const Settings = ({
return;
}
setProfileImageOverride(null);
- removeProfileImageMutation();
+ removeProfileImageMutation.mutate();
};
return (
@@ -163,39 +160,38 @@ export const Settings = ({
}}
>
-
+
Profile image
This image appears in your profile, comments, and shared caps.
-
-
- Your name
-
- Changing your name below will update how your name appears when
- sharing a Cap, and in your profile.
-
-
-
+
+
+ Your name
+
+ Changing your name below will update how your name appears when
+ sharing a Cap, and in your profile.
+
+
+
+
setFirstName(e.target.value)}
defaultValue={firstName as string}
- id="firstName"
+ id={firstNameId}
name="firstName"
/>
@@ -205,7 +201,7 @@ export const Settings = ({
placeholder="Last name"
onChange={(e) => setLastName(e.target.value)}
defaultValue={lastName as string}
- id="lastName"
+ id={lastNameId}
name="lastName"
/>
@@ -221,7 +217,7 @@ export const Settings = ({
diff --git a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx
new file mode 100644
index 0000000000..e401c3a68b
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import { faImage, faTrash } 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";
+import { Tooltip } from "@/components/Tooltip";
+
+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(
+ initialPreviewUrl || null,
+ );
+ const fileInputRef = useRef(null);
+
+ // Reset isRemoving when the parent confirms the operation completed
+ useEffect(() => {
+ if (initialPreviewUrl !== undefined) {
+ setPreviewUrl(initialPreviewUrl);
+ }
+ }, [initialPreviewUrl]);
+
+ 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 (previewUrl && previewUrl !== initialPreviewUrl) {
+ URL.revokeObjectURL(previewUrl);
+ }
+ const objectUrl = URL.createObjectURL(file);
+ setPreviewUrl(objectUrl);
+ onChange?.(file);
+ };
+
+ const handleRemove = () => {
+ setPreviewUrl(null);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ onRemove?.();
+ };
+
+ const handleUploadClick = () => {
+ if (!disabled && !isUploading && !isRemoving) {
+ fileInputRef.current?.click();
+ }
+ };
+
+ const isLoading = isUploading || isRemoving;
+
+ return (
+
+
+
+ {previewUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {!isRemoving && (
+
+ )}
+ {(previewUrl || isRemoving) && (
+
+
+
+ )}
+
+
Recommended size: 120x120
+
+
+
+ );
+}
diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx
index dc046d28d2..ad4dcc9bc8 100644
--- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx
+++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx
@@ -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",
);
@@ -75,6 +74,7 @@ export const OrganizationIcon = () => {
isLoading={isUploading}
initialPreviewUrl={existingIconUrl || null}
onRemove={handleRemoveIcon}
+ maxFileSizeBytes={1 * 1024 * 1024} // 1MB
/>
);
diff --git a/apps/web/components/FileInput.tsx b/apps/web/components/FileInput.tsx
index cf0f4e8610..ecac31dce4 100644
--- a/apps/web/components/FileInput.tsx
+++ b/apps/web/components/FileInput.tsx
@@ -15,7 +15,7 @@ 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 {
@@ -23,6 +23,7 @@ export interface FileInputProps {
disabled?: boolean;
id?: string;
name?: string;
+ containerStyle?: React.CSSProperties;
className?: string;
notDraggingClassName?: string;
initialPreviewUrl?: string | null;
@@ -30,10 +31,12 @@ export interface FileInputProps {
isLoading?: boolean;
height?: string | number;
previewIconSize?: string | number;
+ maxFileSizeBytes?: number;
}
export const FileInput: React.FC
= ({
onChange,
+ containerStyle,
disabled = false,
id = "file",
name = "file",
@@ -42,8 +45,9 @@ export const FileInput: React.FC = ({
initialPreviewUrl = null,
onRemove,
isLoading = false,
- height = "44px",
+ height = 44,
previewIconSize = 20,
+ maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_BYTES,
}) => {
const fileInputRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
@@ -130,9 +134,10 @@ export const FileInput: React.FC = ({
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 = "";
}
@@ -185,14 +190,15 @@ export const FileInput: React.FC = ({
{/* Fixed height container to prevent resizing */}
{previewUrl ? (
-
+
-
-
+
+
Current icon:{" "}
@@ -201,7 +207,7 @@ export const FileInput: React.FC
= ({
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 && (
= ({