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 ? ( + Profile Image + ) : ( + + )} +
+ +
+
+ {!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 && ( = ({