diff --git a/client/src/components/forms/addNewProfile/AddNewProfile.tsx b/client/src/components/forms/addNewProfile/AddNewProfile.tsx index 745d9ea..7eb2ead 100644 --- a/client/src/components/forms/addNewProfile/AddNewProfile.tsx +++ b/client/src/components/forms/addNewProfile/AddNewProfile.tsx @@ -14,7 +14,6 @@ import { Label } from "@/components/ui/label"; import { generateRandomUsername } from "@/lib/GenerateRandomUsername"; import { useProfiles } from "./useProfiles"; import { useEffect, useState } from "react"; -import SlimeArt from "../../../assets/SlimeArt.png"; interface AddNewProfileProps { open: boolean; @@ -54,7 +53,7 @@ export function AddNewProfile({ open, onOpenChange }: AddNewProfileProps) { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - const [profilePicture, setProfilePicture] = useState(SlimeArt); + const [profilePicture, setProfilePicture] = useState(null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -84,11 +83,15 @@ export function AddNewProfile({ open, onOpenChange }: AddNewProfileProps) { try { setIsSubmitting(true); setError(null); - await addProfile({ name: normalizedUsername, avatar: profilePicture }); + await addProfile({ + name: normalizedUsername, + avatar: "", + avatarFile: profilePicture, + }); onOpenChange(false); // Prepare a fresh suggestion for the next time the dialog opens. setUsername(""); - setProfilePicture(SlimeArt); + setProfilePicture(null); } catch (err) { console.error("Failed to add profile:", err); setError("Failed to save profile. Please try again."); @@ -141,7 +144,7 @@ export function AddNewProfile({ open, onOpenChange }: AddNewProfileProps) { onChange={(e) => { if (e.target.files) { const file = e.target.files[0]; - setProfilePicture(URL.createObjectURL(file)); + setProfilePicture(file ?? null); } }} /> diff --git a/client/src/components/forms/addNewProfile/ProfilesContext.tsx b/client/src/components/forms/addNewProfile/ProfilesContext.tsx index 8aef11a..c21782f 100644 --- a/client/src/components/forms/addNewProfile/ProfilesContext.tsx +++ b/client/src/components/forms/addNewProfile/ProfilesContext.tsx @@ -10,7 +10,6 @@ import { AuthContext } from "@/context/AuthContext"; import { useProfilesQuery, useAddProfileMutation, - useUpdateProfileMutation, useDeleteProfileMutation, type ProfileResponse, } from "@/hooks/useQueryHooks"; @@ -37,7 +36,6 @@ export function ProfileProvider({ children }: { children: React.ReactNode }) { const { data: fetchedProfiles = [] } = useProfilesQuery(numUserId); const addProfileMutation = useAddProfileMutation(); - const updateProfileMutation = useUpdateProfileMutation(); const deleteProfileMutation = useDeleteProfileMutation(); const profiles = useMemo( @@ -45,7 +43,7 @@ export function ProfileProvider({ children }: { children: React.ReactNode }) { fetchedProfiles.map((p: ProfileResponse) => ({ id: p.id, name: p.display_name, - avatar: "", + avatar: p.avatar_url ?? `/api/profiles/${p.id}/pfp`, coins: p.coins, })), [fetchedProfiles], @@ -63,9 +61,6 @@ export function ProfileProvider({ children }: { children: React.ReactNode }) { return profiles.find((p) => p.id === selectedProfileId) ?? profiles[0]; }, [profiles, selectedProfileId]); - console.warn("User: ", userId); - console.warn("Profiles: ", profiles); - // useEffect(() => { // fetchProfiles(); // }, [fetchProfiles]); @@ -79,6 +74,7 @@ export function ProfileProvider({ children }: { children: React.ReactNode }) { await addProfileMutation.mutateAsync({ display_name: profile.name, user_id: numUserId, + profile_picture: profile.avatarFile ?? null, }); // No return needed - mutations handle cache invalidation @@ -86,26 +82,13 @@ export function ProfileProvider({ children }: { children: React.ReactNode }) { const removeProfile = async (name: string) => { const profile = profiles.find((p) => p.name === name); - if (!profile || !profile.id) return; + if (!profile || !profile.id || !numUserId) return; try { - // Rename to tombstone first (without optimistic rename in UI), - // then delete optimistically so tombstone text never flashes. - const tombstoneName = - `del_${profile.id}_${Date.now().toString(36)}`.slice(0, 20); - - await updateProfileMutation.mutateAsync({ + await deleteProfileMutation.mutateAsync({ profileId: profile.id, - payload: { - id: profile.id, - display_name: tombstoneName, - coins: profile.coins ?? 0, - }, - optimistic: false, - invalidateAfterSuccess: false, + userId: numUserId, }); - - await deleteProfileMutation.mutateAsync(profile.id); } catch (error) { console.error("Error deleting profile:", error); throw error; diff --git a/client/src/components/forms/addNewProfile/ProfilesTypes.ts b/client/src/components/forms/addNewProfile/ProfilesTypes.ts index c02f58d..df347dd 100644 --- a/client/src/components/forms/addNewProfile/ProfilesTypes.ts +++ b/client/src/components/forms/addNewProfile/ProfilesTypes.ts @@ -2,6 +2,7 @@ export interface Profile { id?: number; name: string; avatar: string; + avatarFile?: File | null; coins?: number; } diff --git a/client/src/components/pages/profileDependents/profile/ProfilePageConent.tsx b/client/src/components/pages/profileDependents/profile/ProfilePageConent.tsx index 9cf3c23..4423b94 100644 --- a/client/src/components/pages/profileDependents/profile/ProfilePageConent.tsx +++ b/client/src/components/pages/profileDependents/profile/ProfilePageConent.tsx @@ -14,10 +14,12 @@ import { getTextColor, } from "@/lib/utils"; import { useProfiles } from "@/components/forms/addNewProfile/useProfiles"; +import { useUploadProfilePictureMutation } from "@/hooks/useQueryHooks"; export function ProfilePageContent() { const pfpinputRef = useRef(null); const { selectedProfile } = useProfiles(); + const uploadProfilePictureMutation = useUploadProfilePictureMutation(); // local avatarSrc is only used when the user picks a local file (blob) // otherwise we display the selectedProfile.avatar const [avatarSrc, setAvatarSrc] = useState(null); @@ -31,14 +33,28 @@ export function ProfilePageContent() { }; }, [avatarSrc]); - const onFileChange = (e: React.ChangeEvent) => { + const onFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; + + if (!selectedProfile?.id) { + return; + } + const url = URL.createObjectURL(file); setAvatarSrc((prev) => { if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev); return url; }); + + try { + await uploadProfilePictureMutation.mutateAsync({ + profileId: selectedProfile.id, + file, + }); + } catch (error) { + console.error("Failed to upload profile picture:", error); + } }; const bgClass = getBackgroundClasses( diff --git a/client/src/hooks/useQueryHooks.ts b/client/src/hooks/useQueryHooks.ts index 1d2f871..d2458ff 100644 --- a/client/src/hooks/useQueryHooks.ts +++ b/client/src/hooks/useQueryHooks.ts @@ -38,6 +38,7 @@ export interface SignupPayload { export interface AddProfilePayload { display_name: string; user_id: number; + profile_picture?: File | null; } export interface AddProfileResponse { @@ -58,6 +59,18 @@ export interface ProfileResponse { display_name: string; coins: number; last_login: string; + avatar_url?: string; +} + +function getProfilePictureUrl(profileId: number, versionSeed: number) { + return `/api/profiles/${profileId}/pfp?v=${versionSeed}`; +} + +async function uploadProfilePicture(profileId: number, file: File) { + const formData = new FormData(); + formData.append("profilePicture", file); + + await apiClient.post(`/profiles/${profileId}/pfp`, formData); } function clampDisplayName(name: string) { @@ -72,13 +85,6 @@ function withUniqueSuffix(base: string) { return `${safeBase}-${seed}`; } -interface ProfileRollbackContext { - snapshots: Array<{ - queryKey: readonly unknown[]; - previousData: ProfileResponse[] | undefined; - }>; -} - // ─── Auth Mutations ────────────────────────────────────────────────────────── export function useLoginMutation() { @@ -128,7 +134,12 @@ export function useProfilesQuery(userId: number | null) { const { data } = await apiClient.get( `/users/${userId}/profiles`, ); - return data; + + const versionSeed = Date.now(); + return data.map((profile) => ({ + ...profile, + avatar_url: getProfilePictureUrl(profile.id, versionSeed), + })); }, enabled: !!userId, staleTime: 5 * 60 * 1000, // 5 minutes @@ -143,7 +154,7 @@ export function useAddProfileMutation() { return useMutation({ mutationFn: async (payload) => { - const { user_id, ...body } = payload; + const { user_id, profile_picture, ...body } = payload; let candidateName = clampDisplayName(body.display_name); for (let attempt = 0; attempt < 4; attempt++) { @@ -152,6 +163,18 @@ export function useAddProfileMutation() { `/users/${user_id}/profiles`, { display_name: candidateName }, ); + + if (profile_picture) { + try { + await uploadProfilePicture(data.id, profile_picture); + } catch (uploadError) { + console.error( + "Profile created but picture upload failed:", + uploadError, + ); + } + } + return data; } catch (error) { const axiosError = error as AxiosError; @@ -168,60 +191,40 @@ export function useAddProfileMutation() { throw new Error("Failed to create profile"); }, - onMutate: async (variables) => { - // Optimistically add the profile to the cache for instant UI feedback + onSuccess: async (_data, variables) => { const requestedUserId = variables.user_id; if (!requestedUserId) { - return undefined; + return; } - await queryClient.cancelQueries({ - queryKey: queryKeys.profiles.byUserId(requestedUserId), - }); - - const previousProfiles = queryClient.getQueryData( - queryKeys.profiles.byUserId(requestedUserId), - ); - - // Create a temporary profile with the new data - const tempProfile: ProfileResponse = { - id: Date.now(), // Temporary ID - display_name: variables.display_name, - coins: 0, - last_login: new Date().toISOString(), - }; - - queryClient.setQueryData( - queryKeys.profiles.byUserId(requestedUserId), - (old: ProfileResponse[] | undefined) => [...(old || []), tempProfile], - ); - - return { previousProfiles, userId: requestedUserId }; - }, - onError: (_error, _variables, context) => { - // Rollback on error - const rollbackContext = context as - | { previousProfiles: ProfileResponse[]; userId: number } - | undefined; - if (rollbackContext?.previousProfiles && rollbackContext.userId) { - queryClient.setQueryData( - queryKeys.profiles.byUserId(rollbackContext.userId), - rollbackContext.previousProfiles, - ); - } - }, - onSuccess: async (_data, variables) => { - // Immediately refetch profiles list after adding - const requestedUserId = variables.user_id; - if (requestedUserId) { + // Always refetch after adding a profile to ensure fresh list + try { await queryClient.refetchQueries({ queryKey: queryKeys.profiles.byUserId(requestedUserId), + type: "active", }); + } catch (err) { + console.error("Failed to refetch profiles after add:", err); } }, }); } +export function useUploadProfilePictureMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId, file }) => { + await uploadProfilePicture(profileId, file); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.profiles.all, + }); + }, + }); +} + export function useUpdateProfileMutation() { const queryClient = useQueryClient(); @@ -271,47 +274,54 @@ export function useUpdateProfileMutation() { export function useDeleteProfileMutation() { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (profileId) => { + return useMutation({ + mutationFn: async ({ profileId }) => { await apiClient.delete(`/profiles/${profileId}`); }, - onMutate: async (profileId) => { - // Optimistically remove the profile from cache immediately - // so the tombstone name never appears in the UI - const snapshots: ProfileRollbackContext["snapshots"] = []; - const profileQueries = queryClient - .getQueryCache() - .findAll({ queryKey: queryKeys.profiles.all }); - - profileQueries.forEach((query) => { - const previousData = queryClient.getQueryData( - query.queryKey, - ); - snapshots.push({ - queryKey: query.queryKey, - previousData, - }); + onMutate: async ({ profileId, userId }) => { + // Cancel any in-flight queries + await queryClient.cancelQueries({ + queryKey: queryKeys.profiles.byUserId(userId), + }); + + // Snapshot previous data + const previousData = queryClient.getQueryData( + queryKeys.profiles.byUserId(userId), + ); - queryClient.setQueryData(query.queryKey, (old) => { + // Optimistically remove from cache + queryClient.setQueryData( + queryKeys.profiles.byUserId(userId), + (old) => { if (!old) return old; return old.filter((p) => p.id !== profileId); - }); - }); + }, + ); - return { snapshots }; + return { previousData, userId }; }, onError: (_error, _variables, context) => { - if (!context?.snapshots) return; - - context.snapshots.forEach(({ queryKey, previousData }) => { - queryClient.setQueryData(queryKey, previousData); - }); + // Rollback on error + const rollbackContext = context as + | { previousData: ProfileResponse[] | undefined; userId: number } + | undefined; + if (rollbackContext?.previousData !== undefined) { + queryClient.setQueryData( + queryKeys.profiles.byUserId(rollbackContext.userId), + rollbackContext.previousData, + ); + } }, - onSettled: () => { - // Invalidate all profile queries to ensure consistency - queryClient.invalidateQueries({ - queryKey: queryKeys.profiles.all, - }); + onSuccess: async (_data, variables) => { + // Refetch after deleting to ensure fresh list + try { + await queryClient.refetchQueries({ + queryKey: queryKeys.profiles.byUserId(variables.userId), + type: "active", + }); + } catch (err) { + console.error("Failed to refetch profiles after delete:", err); + } }, }); }