diff --git a/frontend/src/components/profile/EditProfileDialog.tsx b/frontend/src/components/profile/EditProfileDialog.tsx new file mode 100644 index 0000000..e8149c6 --- /dev/null +++ b/frontend/src/components/profile/EditProfileDialog.tsx @@ -0,0 +1,128 @@ +import type { ChangeEvent, RefObject } from "react"; +import { X } from "lucide-react"; +import { profileActionClass as actionClass } from "./profileStyles"; + +type EditProfileDialogProps = { + dialogRef: RefObject; + coverInputRef: RefObject; + profileInputRef: RefObject; + coverImage: string | null; + profileImage: string | null; + busy: boolean; + imageError?: string; + onClose: () => void; + onRemoveCoverImage: () => void; + onRemoveProfileImage: () => void; + onCoverImageChange: (event: ChangeEvent) => void; + onProfileImageChange: (event: ChangeEvent) => void; +}; + +export default function EditProfileDialog({ + dialogRef, + coverInputRef, + profileInputRef, + coverImage, + profileImage, + busy, + imageError, + onClose, + onRemoveCoverImage, + onRemoveProfileImage, + onCoverImageChange, + onProfileImageChange, +}: EditProfileDialogProps) { + return ( + +
+

+ Edit profile +

+ +
+ {(["profile", "cover"] as const).map((type) => { + const image = type === "profile" ? profileImage : coverImage; + const input = type === "profile" ? profileInputRef : coverInputRef; + return ( +
+ + {type === "profile" ? "Profile image" : "Cover image"} + +
+ + {image && ( + + )} +
+
+ ); + })} + {imageError && ( +

+ {imageError} +

+ )} +
+ +
+ + +
+ ); +} diff --git a/frontend/src/components/profile/ProfileHeader.tsx b/frontend/src/components/profile/ProfileHeader.tsx index 16d968f..700c79d 100644 --- a/frontend/src/components/profile/ProfileHeader.tsx +++ b/frontend/src/components/profile/ProfileHeader.tsx @@ -1,5 +1,7 @@ -import { useEffect, useRef, useState, type ChangeEvent } from "react"; -import { Ellipsis, LogOut, Pencil, UserRound, X } from "lucide-react"; +import { useRef, type ChangeEvent } from "react"; +import { Pencil, UserRound } from "lucide-react"; +import EditProfileDialog from "./EditProfileDialog"; +import ProfileOptionsMenu from "./ProfileOptionsMenu"; type ProfileHeaderProps = { username: string; @@ -16,9 +18,6 @@ type ProfileHeaderProps = { onProfileImageChange: (event: ChangeEvent) => void; }; -const actionClass = - "cursor-pointer rounded-md px-3 py-2 text-sm text-[#514943] transition-colors hover:bg-[#f4f1ee] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#80766f] disabled:opacity-50 dark:text-[#e6dfd6] dark:hover:bg-[#302e2a]"; - export default function ProfileHeader({ username, coverColor, @@ -36,21 +35,9 @@ export default function ProfileHeader({ const coverInputRef = useRef(null); const profileInputRef = useRef(null); const editDialogRef = useRef(null); - const optionsRef = useRef(null); const optionsButtonRef = useRef(null); - const [optionsOpen, setOptionsOpen] = useState(false); const busy = isUpdatingImages || isLoadingImages; - useEffect(() => { - if (!optionsOpen) return; - function dismiss(event: PointerEvent) { - if (!optionsRef.current?.contains(event.target as Node)) - setOptionsOpen(false); - } - document.addEventListener("pointerdown", dismiss); - return () => document.removeEventListener("pointerdown", dismiss); - }, [optionsOpen]); - return ( <>
{username} -
{ - if (!event.currentTarget.contains(event.relatedTarget)) - setOptionsOpen(false); - }} - onKeyDown={(event) => { - if (event.key === "Escape") { - setOptionsOpen(false); - optionsButtonRef.current?.focus(); - } - }} - > - - {optionsOpen && ( -
- - -
- )} -
+ editDialogRef.current?.showModal()} + onLogout={onLogout} + />
- optionsButtonRef.current?.focus()} - className="m-auto w-[calc(100%-2rem)] max-w-md rounded-2xl bg-white p-6 text-[#292624] shadow-xl backdrop:bg-black/40 dark:bg-[#242321] dark:text-[#f3f0ea]" - > -
-

- Edit profile -

- -
- {(["profile", "cover"] as const).map((type) => { - const image = type === "profile" ? profileImage : coverImage; - const input = type === "profile" ? profileInputRef : coverInputRef; - return ( -
- - {type === "profile" ? "Profile image" : "Cover image"} - -
- - {image && ( - - )} -
-
- ); - })} - {imageError && ( -

- {imageError} -

- )} -
- -
- - -
+ onRemoveCoverImage={onRemoveCoverImage} + onRemoveProfileImage={onRemoveProfileImage} + onCoverImageChange={onCoverImageChange} + onProfileImageChange={onProfileImageChange} + /> ); } diff --git a/frontend/src/components/profile/ProfileOptionsMenu.tsx b/frontend/src/components/profile/ProfileOptionsMenu.tsx new file mode 100644 index 0000000..4e8c92f --- /dev/null +++ b/frontend/src/components/profile/ProfileOptionsMenu.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState, type RefObject } from "react"; +import { Ellipsis, LogOut, Pencil } from "lucide-react"; +import { profileActionClass as actionClass } from "./profileStyles"; + +type ProfileOptionsMenuProps = { + buttonRef: RefObject; + onEdit: () => void; + onLogout: () => void; +}; + +export default function ProfileOptionsMenu({ + buttonRef, + onEdit, + onLogout, +}: ProfileOptionsMenuProps) { + const optionsRef = useRef(null); + const [optionsOpen, setOptionsOpen] = useState(false); + + useEffect(() => { + if (!optionsOpen) return; + function dismiss(event: PointerEvent) { + if (!optionsRef.current?.contains(event.target as Node)) + setOptionsOpen(false); + } + document.addEventListener("pointerdown", dismiss); + return () => document.removeEventListener("pointerdown", dismiss); + }, [optionsOpen]); + + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) + setOptionsOpen(false); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + setOptionsOpen(false); + buttonRef.current?.focus(); + } + }} + > + + {optionsOpen && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/profile/profileStyles.ts b/frontend/src/components/profile/profileStyles.ts new file mode 100644 index 0000000..9f0ec26 --- /dev/null +++ b/frontend/src/components/profile/profileStyles.ts @@ -0,0 +1,2 @@ +export const profileActionClass = + "cursor-pointer rounded-md px-3 py-2 text-sm text-[#514943] transition-colors hover:bg-[#f4f1ee] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#80766f] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[#e6dfd6] dark:hover:bg-[#302e2a]"; diff --git a/frontend/src/hooks/profile/profileImageMutations.ts b/frontend/src/hooks/profile/profileImageMutations.ts new file mode 100644 index 0000000..13977dc --- /dev/null +++ b/frontend/src/hooks/profile/profileImageMutations.ts @@ -0,0 +1,39 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; + +type UpdateProfileImageData = { + updateProfileImage: { + profileImage: string | null; + } | null; +}; + +type UpdateCoverImageData = { + updateCoverImage: { + coverImage: string | null; + } | null; +}; + +type ImageVariables = { + image: string | null; +}; + +export const UPDATE_PROFILE_IMAGE: TypedDocumentNode< + UpdateProfileImageData, + ImageVariables +> = gql` + mutation UpdateProfileImage($image: String) { + updateProfileImage(image: $image) { + profileImage + } + } +`; + +export const UPDATE_COVER_IMAGE: TypedDocumentNode< + UpdateCoverImageData, + ImageVariables +> = gql` + mutation UpdateCoverImage($image: String) { + updateCoverImage(image: $image) { + coverImage + } + } +`; diff --git a/frontend/src/hooks/profile/useProfileImageActions.ts b/frontend/src/hooks/profile/useProfileImageActions.ts new file mode 100644 index 0000000..0fe2c5f --- /dev/null +++ b/frontend/src/hooks/profile/useProfileImageActions.ts @@ -0,0 +1,88 @@ +import { useMutation } from "@apollo/client/react"; +import { useState } from "react"; +import { + UPDATE_PROFILE_IMAGE, + UPDATE_COVER_IMAGE, +} from "./profileImageMutations"; +import { + readImageAsDataUrl, + validateProfileImage, +} from "../../utils/profileImages"; + +type ImageType = "profile" | "cover"; +const mutationOptions = { + refetchQueries: ["GetProfileImages"], + awaitRefetchQueries: true, +}; + +// Saves or removes profile images and refreshes the cached profile query. +export function useProfileImageActions(token: string | null) { + const [isUpdating, setIsUpdating] = useState(false); + const [removalError, setRemovalError] = useState(""); + const [updateProfileImage] = useMutation( + UPDATE_PROFILE_IMAGE, + mutationOptions, + ); + const [updateCoverImage] = useMutation(UPDATE_COVER_IMAGE, mutationOptions); + + async function saveImage( + type: ImageType, + image: string | null, + ): Promise { + if (!token) throw new Error("You must be logged in"); + let savedImage: string | null | undefined; + if (type === "profile") { + const { data } = await updateProfileImage({ variables: { image } }); + savedImage = data?.updateProfileImage?.profileImage; + } else { + const { data } = await updateCoverImage({ variables: { image } }); + savedImage = data?.updateCoverImage?.coverImage; + } + if (image === null) { + if (savedImage !== null) throw new Error("The image was not removed"); + return null; + } + if (!savedImage) throw new Error("The image was not saved"); + return savedImage; + } + + async function uploadImage( + file: File, + type: ImageType, + ): Promise { + setRemovalError(""); + const validationError = validateProfileImage(file); + if (validationError) { + alert(validationError); + return null; + } + setIsUpdating(true); + try { + // Check before reading the file as well as before saving it. + if (!token) throw new Error("You must be logged in"); + return await saveImage(type, await readImageAsDataUrl(file)); + } catch (error) { + console.error(`Error uploading ${type} image:`, error); + alert(`Could not save ${type} image`); + return null; + } finally { + setIsUpdating(false); + } + } + + async function removeImage(type: ImageType): Promise { + setRemovalError(""); + setIsUpdating(true); + try { + await saveImage(type, null); + return true; + } catch { + setRemovalError(`Could not remove ${type} image. Please try again.`); + return false; + } finally { + setIsUpdating(false); + } + } + + return { uploadImage, removeImage, isUpdating, removalError }; +} diff --git a/frontend/src/hooks/profile/useUploadProfileImage.ts b/frontend/src/hooks/profile/useUploadProfileImage.ts deleted file mode 100644 index a5001c8..0000000 --- a/frontend/src/hooks/profile/useUploadProfileImage.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { gql, type TypedDocumentNode } from "@apollo/client"; -import { useMutation } from "@apollo/client/react"; -import { useState, type ChangeEvent } from "react"; - -type ImageType = "profile" | "cover"; - -type UpdateProfileImageData = { - updateProfileImage: { - profileImage: string | null; - } | null; -}; - -type UpdateCoverImageData = { - updateCoverImage: { - coverImage: string | null; - } | null; -}; - -type ImageVariables = { - image: string | null; -}; - -const UPDATE_PROFILE_IMAGE: TypedDocumentNode< - UpdateProfileImageData, - ImageVariables -> = gql` - mutation UpdateProfileImage($image: String) { - updateProfileImage(image: $image) { - profileImage - } - } -`; - -const UPDATE_COVER_IMAGE: TypedDocumentNode< - UpdateCoverImageData, - ImageVariables -> = gql` - mutation UpdateCoverImage($image: String) { - updateCoverImage(image: $image) { - coverImage - } - } -`; - -// Validates and uploads profile or cover images -export function useUploadProfileImage(token: string | null) { - const [isUploading, setIsUploading] = useState(false); - const [removalError, setRemovalError] = useState(""); - - const [updateProfileImage] = useMutation(UPDATE_PROFILE_IMAGE, { - refetchQueries: ["GetProfileImages"], - awaitRefetchQueries: true, - }); - - const [updateCoverImage] = useMutation(UPDATE_COVER_IMAGE, { - refetchQueries: ["GetProfileImages"], - awaitRefetchQueries: true, - }); - - async function uploadImage( - event: ChangeEvent, - type: ImageType, - ): Promise { - const file = event.target.files?.[0]; - event.target.value = ""; - if (!file) return null; - - if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { - alert("Please select a JPEG, PNG or WebP image"); - return null; - } - - if (file.size > 2_000_000) { - alert("Image must be smaller than 2 MB"); - return null; - } - - setIsUploading(true); - - try { - if (!token) { - throw new Error("You must be logged in"); - } - - const image = await new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - if (typeof reader.result === "string") { - resolve(reader.result); - } else { - reject(new Error("Could not read image")); - } - }; - - reader.onerror = () => reject(new Error("Could not read image")); - - reader.readAsDataURL(file); - }); - - let imageUrl: string | null | undefined; - - if (type === "profile") { - const { data } = await updateProfileImage({ - variables: { image }, - }); - - imageUrl = data?.updateProfileImage?.profileImage; - } else { - const { data } = await updateCoverImage({ - variables: { image }, - }); - - imageUrl = data?.updateCoverImage?.coverImage; - } - - if (!imageUrl) { - throw new Error("The server did not return an image."); - } - - return imageUrl; - } catch (error) { - console.error(`Error uploading ${type} image:`, error); - alert(`Could not save ${type} image`); - return null; - } finally { - setIsUploading(false); - } - } - - async function removeImage(type: ImageType): Promise { - setRemovalError(""); - setIsUploading(true); - - try { - if (!token) throw new Error("You must be logged in"); - - if (type === "profile") { - const { data } = await updateProfileImage({ - variables: { image: null }, - }); - if ( - !data?.updateProfileImage || - data.updateProfileImage.profileImage !== null - ) { - throw new Error("Profile image was not removed"); - } - } else { - const { data } = await updateCoverImage({ variables: { image: null } }); - if ( - !data?.updateCoverImage || - data.updateCoverImage.coverImage !== null - ) { - throw new Error("Cover image was not removed"); - } - } - return true; - } catch { - setRemovalError(`Could not remove ${type} image. Please try again.`); - return false; - } finally { - setIsUploading(false); - } - } - - return { uploadImage, removeImage, isUploading, removalError }; -} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 41bac47..6d087a5 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -10,7 +10,7 @@ import ProfileHeader from "../components/profile/ProfileHeader"; // Hooks import { useGalleryCoverImages } from "../hooks/gallery/useGalleryCoverImages"; import { useProfileImages } from "../hooks/profile/useProfileImages"; -import { useUploadProfileImage } from "../hooks/profile/useUploadProfileImage"; +import { useProfileImageActions } from "../hooks/profile/useProfileImageActions"; import { useMyGalleries } from "../hooks/gallery/useMyGallery"; import { useSuccessMessage } from "../hooks/common/useSuccessMessage"; @@ -63,8 +63,8 @@ export default function ProfilePage() { const galleryCoverImages = useGalleryCoverImages(galleries); // Profile image hooks - const { uploadImage, removeImage, isUploading, removalError } = - useUploadProfileImage(token); + const { uploadImage, removeImage, isUpdating, removalError } = + useProfileImageActions(token); const { profileImage, coverImage, isLoadingImages } = useProfileImages(token); @@ -72,13 +72,17 @@ export default function ProfilePage() { async function handleCoverImageChange( event: React.ChangeEvent, ) { - await uploadImage(event, "cover"); + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) await uploadImage(file, "cover"); } async function handleProfileImageChange( event: React.ChangeEvent, ) { - await uploadImage(event, "profile"); + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) await uploadImage(file, "profile"); } // Logout @@ -116,7 +120,7 @@ export default function ProfilePage() { coverImage={coverImage} profileImage={profileImage} isLoadingImages={isLoadingImages} - isUpdatingImages={isUploading} + isUpdatingImages={isUpdating} onRemoveCoverImage={() => void removeImage("cover")} onRemoveProfileImage={() => void removeImage("profile")} onCoverImageChange={handleCoverImageChange} diff --git a/frontend/src/test/profileImageRemoval.test.tsx b/frontend/src/test/profileImageRemoval.test.tsx index c1ea389..925a26e 100644 --- a/frontend/src/test/profileImageRemoval.test.tsx +++ b/frontend/src/test/profileImageRemoval.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { apolloClient } from "../lib/apolloClient"; import { useAuthStore } from "../store/authStore"; import { useProfileImages } from "../hooks/profile/useProfileImages"; -import { useUploadProfileImage } from "../hooks/profile/useUploadProfileImage"; +import { useProfileImageActions } from "../hooks/profile/useProfileImageActions"; import ProfileHeader from "../components/profile/ProfileHeader"; const logout = vi.fn(); @@ -19,8 +19,8 @@ async function editProfile() { function Profile() { const images = useProfileImages("token"); - const { removeImage, isUploading, removalError } = - useUploadProfileImage("token"); + const { uploadImage, removeImage, isUpdating, removalError } = + useProfileImageActions("token"); return ( <> {}} - onProfileImageChange={() => {}} + isUpdatingImages={isUpdating} + onCoverImageChange={(event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void uploadImage(file, "cover"); + }} + onProfileImageChange={(event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void uploadImage(file, "profile"); + }} onRemoveCoverImage={() => void removeImage("cover")} onRemoveProfileImage={() => void removeImage("profile")} /> @@ -65,13 +73,21 @@ describe("Profile image removal", () => { body = { data: { me: { ...saved } } }; else if (fail) body = { errors: [{ message: "Removal failed" }] }; else { - expect(variables.image).toBeNull(); + expect( + variables.image === null || typeof variables.image === "string", + ).toBe(true); if (operationName === "UpdateProfileImage") { - saved.profileImage = null; - body = { data: { updateProfileImage: { profileImage: null } } }; + saved.profileImage = variables.image; + body = { + data: { + updateProfileImage: { profileImage: saved.profileImage }, + }, + }; } else if (operationName === "UpdateCoverImage") { - saved.coverImage = null; - body = { data: { updateCoverImage: { coverImage: null } } }; + saved.coverImage = variables.image; + body = { + data: { updateCoverImage: { coverImage: saved.coverImage } }, + }; } else throw new Error(`Unexpected operation ${operationName}`); } return new Response(JSON.stringify(body), { @@ -86,6 +102,7 @@ describe("Profile image removal", () => { useAuthStore.getState().logout(); sessionStorage.clear(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it("keeps profile actions hidden until opened and logs out from the options", async () => { @@ -160,6 +177,88 @@ describe("Profile image removal", () => { }, ); + it.each(["profile", "cover"] as const)( + "uploads a %s image and refreshes its displayed value", + async (type) => { + const user = userEvent.setup(); + renderProfile(); + await editProfile(); + await screen.findByRole("button", { name: `Remove ${type} image` }); + const input = screen.getByLabelText( + type === "profile" ? "Profile image file" : "Cover image file", + ); + await user.upload( + input, + new File(["new image"], "new.png", { type: "image/png" }), + ); + const expectedImage = "data:image/png;base64,bmV3IGltYWdl"; + await waitFor(() => + expect(saved[type === "profile" ? "profileImage" : "coverImage"]).toBe( + expectedImage, + ), + ); + const displayedImage = () => + type === "profile" + ? screen + .getByRole("button", { name: "Change profile image" }) + .querySelector("img") + : screen.getByAltText("Cover"); + await waitFor(() => + expect(displayedImage()).toHaveAttribute("src", expectedImage), + ); + expect(saved[type === "profile" ? "coverImage" : "profileImage"]).toBe( + type === "profile" ? "cover.png" : "profile.png", + ); + expect(input).toHaveValue(""); + }, + ); + + it("clears a previous removal error when uploading a new image", async () => { + const user = userEvent.setup(); + fail = true; + renderProfile(); + await editProfile(); + await user.click( + await screen.findByRole("button", { name: "Remove profile image" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not remove profile image", + ); + + fail = false; + await user.upload( + screen.getByLabelText("Profile image file"), + new File(["new image"], "new.png", { type: "image/png" }), + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen + .getByRole("button", { name: "Change profile image" }) + .querySelector("img"), + ).toHaveAttribute("src", "data:image/png;base64,bmV3IGltYWdl"), + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("rejects invalid image files without updating saved images", async () => { + const alert = vi.spyOn(window, "alert").mockImplementation(() => {}); + renderProfile(); + await editProfile(); + await screen.findByRole("button", { name: "Remove profile image" }); + const user = userEvent.setup({ applyAccept: false }); + await user.upload( + screen.getByLabelText("Profile image file"), + new File(["invalid"], "bad.txt", { type: "text/plain" }), + ); + expect(alert).toHaveBeenCalledWith( + "Please select a JPEG, PNG or WebP image", + ); + expect(saved.profileImage).toBe("profile.png"); + expect( + screen.getByRole("button", { name: "Remove profile image" }), + ).toBeEnabled(); + }); it("shows an error and retains the image when removal fails", async () => { fail = true; renderProfile(); diff --git a/frontend/src/utils/profileImages.ts b/frontend/src/utils/profileImages.ts new file mode 100644 index 0000000..fd7791c --- /dev/null +++ b/frontend/src/utils/profileImages.ts @@ -0,0 +1,19 @@ +export function validateProfileImage(file: File): string | null { + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + return "Please select a JPEG, PNG or WebP image"; + } + if (file.size > 2_000_000) return "Image must be smaller than 2 MB"; + return null; +} + +export function readImageAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") resolve(reader.result); + else reject(new Error("Could not read image")); + }; + reader.onerror = () => reject(new Error("Could not read image")); + reader.readAsDataURL(file); + }); +}