From 6cb79585e6b2443af5befa1150619abfa27495b3 Mon Sep 17 00:00:00 2001 From: YoungMame Date: Thu, 27 Nov 2025 17:12:51 +0100 Subject: [PATCH 01/13] add:(user edit profile page) --- fastify/assets/srcs/models/User/index.ts | 1 + fastify/assets/srcs/routes/private/index.ts | 2 +- .../srcs/routes/private/user/me/index.ts | 4 +- .../srcs/routes/private/user/me/profile.ts | 4 +- .../srcs/routes/private/user/view/index.ts | 2 +- fastify/assets/srcs/services/UserService.ts | 7 +- nextjs/matcha/.gitignore | 2 + nextjs/matcha/next.config.ts | 3 + nextjs/matcha/public/default-profile.svg | Bin 0 -> 64285 bytes nextjs/matcha/src/app/(logged)/layout.tsx | 75 +++----- nextjs/matcha/src/app/(logged)/me/page.tsx | 130 +++++++++++++ .../components/browsing/ConversationItem.tsx | 2 +- .../src/components/browsing/LeftDrawer.tsx | 31 +++- .../src/components/browsing/MatchCard.tsx | 2 +- .../src/components/browsing/ProfileCard.tsx | 1 + .../src/components/browsing/UserHeader.tsx | 58 +++--- .../src/components/homepage/SignUpModal.tsx | 3 - nextjs/matcha/src/components/me/Settings.tsx | 9 + .../src/components/profile/ProfileView.tsx | 6 +- nextjs/matcha/src/constants/onboarding.ts | 12 +- nextjs/matcha/src/contexts/MeContext.tsx | 175 ++++++++++++++++++ nextjs/matcha/src/hooks/useOnboarding.ts | 36 ++-- nextjs/matcha/src/mocks/browsing_mocks.ts | 30 +-- nextjs/matcha/src/types/location.ts | 6 + 24 files changed, 475 insertions(+), 126 deletions(-) create mode 100644 nextjs/matcha/public/default-profile.svg create mode 100644 nextjs/matcha/src/app/(logged)/me/page.tsx create mode 100644 nextjs/matcha/src/components/me/Settings.tsx create mode 100644 nextjs/matcha/src/contexts/MeContext.tsx create mode 100644 nextjs/matcha/src/types/location.ts diff --git a/fastify/assets/srcs/models/User/index.ts b/fastify/assets/srcs/models/User/index.ts index af5b0bd..5a04278 100644 --- a/fastify/assets/srcs/models/User/index.ts +++ b/fastify/assets/srcs/models/User/index.ts @@ -132,6 +132,7 @@ export default class UserModel { } update = async (id: number, user: UserProfile, location?: UserLocation) => { + console.log("Updating user ID:", id, "with data:", user, "and location:", location); user = this.fixPropertiesCase(user); if (user.bornAt instanceof Date) { user.bornAt = user.bornAt.toISOString(); diff --git a/fastify/assets/srcs/routes/private/index.ts b/fastify/assets/srcs/routes/private/index.ts index cdd9a94..7fbf3c5 100644 --- a/fastify/assets/srcs/routes/private/index.ts +++ b/fastify/assets/srcs/routes/private/index.ts @@ -25,6 +25,6 @@ export default async function privateRoutes(fastify: FastifyInstance, options: F fastify.register(statics, { root: path.join(__dirname, '../../../uploads'), prefix: '/uploads', // optional: default '/' - constraints: { host: process.env.HOST || 'localhost' }, // optional: default {} + // constraints: { host: process.env.HOST || 'localhost' }, // optional: default {} }); } diff --git a/fastify/assets/srcs/routes/private/user/me/index.ts b/fastify/assets/srcs/routes/private/user/me/index.ts index c107537..31e1e8d 100644 --- a/fastify/assets/srcs/routes/private/user/me/index.ts +++ b/fastify/assets/srcs/routes/private/user/me/index.ts @@ -10,9 +10,9 @@ const meRoutes = async (fastify: FastifyInstance) => { 'GET /profile', 'PUT /profile', 'DELETE /profile', ]}; }); - fastify.register(profilePictureRoutes, { prefix: '/profile-picture', preHandler: fastify.checkIsCompleted }); + fastify.register(profilePictureRoutes, { prefix: '/profile-picture' }); fastify.register(profileRoutes, { prefix: '/profile', preHandler: fastify.checkIsCompleted }); - fastify.register(completeProfileRoutes, { prefix: '/complete-profile', preHandler: fastify.checkIsVerified }); + fastify.register(completeProfileRoutes, { prefix: '/complete-profile'}); } export default meRoutes; \ No newline at end of file diff --git a/fastify/assets/srcs/routes/private/user/me/profile.ts b/fastify/assets/srcs/routes/private/user/me/profile.ts index d2abbc8..43c14a9 100644 --- a/fastify/assets/srcs/routes/private/user/me/profile.ts +++ b/fastify/assets/srcs/routes/private/user/me/profile.ts @@ -59,12 +59,14 @@ const profileRoutes = async (fastify: FastifyInstance) => { id: { type: 'integer' }, email: { type: 'string', format: 'email' }, username: { type: 'string', minLength: 2, maxLength: 100 }, + firstName: { type: 'string', maxLength: 50 }, + lastName: { type: 'string', maxLength: 50 }, profilePictureIndex: { type: 'integer' }, profilePictures: { type: 'array', items: { type: 'string', format: 'uri' } }, bio: { type: 'string', maxLength: 100 }, tags: { type: 'array', items: { type: 'string' } }, bornAt: { type: 'string', format: 'date-time' }, - gender: { type: 'string', enum: ['male', 'female'] }, + gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual'] }, isVerified: { type: 'boolean' }, isProfileCompleted: { type: 'boolean' }, diff --git a/fastify/assets/srcs/routes/private/user/view/index.ts b/fastify/assets/srcs/routes/private/user/view/index.ts index 7833381..f1776f0 100644 --- a/fastify/assets/srcs/routes/private/user/view/index.ts +++ b/fastify/assets/srcs/routes/private/user/view/index.ts @@ -23,7 +23,7 @@ const viewRoutes = async (fastify: FastifyInstance) => { bio: { type: 'string', maxLength: 100 }, tags: { type: 'array', items: { type: 'string' } }, bornAt: { type: 'string', format: 'date-time' }, - gender: { type: 'string', enum: ['male', 'female'] }, + gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual'] }, fameRate: { type: 'number' }, location: { diff --git a/fastify/assets/srcs/services/UserService.ts b/fastify/assets/srcs/services/UserService.ts index 77e3861..1e5d30a 100644 --- a/fastify/assets/srcs/services/UserService.ts +++ b/fastify/assets/srcs/services/UserService.ts @@ -150,6 +150,8 @@ class UserService { id: number; email: string; username: string; + firstName: string; + lastName: string; profilePictureIndex: number | undefined; profilePictures: string[]; bio: string; @@ -164,12 +166,15 @@ class UserService { createdAt: Date; }> { const user = await this.getUser(id); + console.log("Retrieved user:", user); if (!user || !user.isProfileCompleted) throw new NotFoundError(); return { id: user.id, email: user.email, username: user.username, + firstName: user.firstName as string, + lastName: user.lastName as string, profilePictureIndex: user.profilePictureIndex, profilePictures: user.profilePictures || [], bio: user.bio || '', @@ -204,8 +209,6 @@ class UserService { throw new NotFoundError(); if (user.isProfileCompleted) throw new BadRequestError('Profile already completed'); - if (user.isVerified === false) - throw new BadRequestError('Email not verified'); await this.userModel.update(id, { ...profile, isProfileCompleted: true diff --git a/nextjs/matcha/.gitignore b/nextjs/matcha/.gitignore index 5ef6a52..2629d47 100644 --- a/nextjs/matcha/.gitignore +++ b/nextjs/matcha/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +package-lock.json diff --git a/nextjs/matcha/next.config.ts b/nextjs/matcha/next.config.ts index e9ffa30..54d45cb 100644 --- a/nextjs/matcha/next.config.ts +++ b/nextjs/matcha/next.config.ts @@ -2,6 +2,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + images: { + domains: ["localhost", "matcha.fr", "mduvey.matcha.fr"] + } }; export default nextConfig; diff --git a/nextjs/matcha/public/default-profile.svg b/nextjs/matcha/public/default-profile.svg new file mode 100644 index 0000000000000000000000000000000000000000..f8b690afbaae79528dd873242c878c5301e817dc GIT binary patch literal 64285 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 z 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 literal 0 HcmV?d00001 diff --git a/nextjs/matcha/src/app/(logged)/layout.tsx b/nextjs/matcha/src/app/(logged)/layout.tsx index 4a9fc2f..33d44fa 100644 --- a/nextjs/matcha/src/app/(logged)/layout.tsx +++ b/nextjs/matcha/src/app/(logged)/layout.tsx @@ -8,6 +8,7 @@ import ChatInterface from "@/components/browsing/ChatInterface"; import ChatProfilePanel from "@/components/browsing/ChatProfilePanel"; import { mockMatches, mockConversations, mockMessages, generateMockProfilesWithMetadata } from "@/mocks/browsing_mocks"; import { BrowsingProvider, useBrowsing } from "@/contexts/BrowsingContext"; +import { MeProvider, useMe } from "@/contexts/MeContext"; import { Message } from "@/types/message"; type Tab = "matches" | "messages"; @@ -16,6 +17,9 @@ function LayoutContent({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const { openMatchModal, openConversation, closeConversation, selectedConversationId } = useBrowsing(); + + // useMe here — MeProvider is above LayoutContent in the tree + const { id, username, profilePictureUrls, profilePictureIndex, isLoading: meLoading, error: meError, refresh } = useMe(); const [activeTab, setActiveTab] = useState("matches"); const [allMessages, setAllMessages] = useState>(mockMessages); @@ -28,63 +32,34 @@ function LayoutContent({ children }: { children: React.ReactNode }) { const handleTabChange = useCallback((tab: Tab) => { setActiveTab(tab); - if (tab === "messages") { - // Open first conversation when switching to messages - if (mockConversations.length > 0) { - openConversation(mockConversations[0].id); - } + if (mockConversations.length > 0) openConversation(mockConversations[0].id); } else { - // Close conversation when switching to matches closeConversation(); } }, [openConversation, closeConversation, pathname, router]); const handleMatchClick = useCallback((userId: string) => { openMatchModal(userId); - // Don't navigate - let each page handle the modal display }, [openMatchModal]); const handleSendMessage = useCallback((content: string) => { if (!selectedConversationId) return; - - const newMessage: Message = { - id: `msg-${Date.now()}`, - senderId: "current-user", - content, - timestamp: new Date(), - }; - - setAllMessages((prev) => ({ - ...prev, - [selectedConversationId]: [ - ...(prev[selectedConversationId] || []), - newMessage, - ], - })); + const newMessage: Message = { id: `msg-${Date.now()}`, senderId: "current-user", content, timestamp: new Date() }; + setAllMessages((prev) => ({ ...prev, [selectedConversationId]: [ ...(prev[selectedConversationId] || []), newMessage ] })); }, [selectedConversationId]); - // Get the current conversation data - const selectedConversation = selectedConversationId - ? mockConversations.find((conv) => conv.id === selectedConversationId) - : null; - - const selectedChatUser = - selectedConversation && selectedConversation.userId - ? allProfiles.find((u) => u.id === selectedConversation.userId) - : null; - - const conversationMessages = selectedConversationId - ? allMessages[selectedConversationId as keyof typeof allMessages] || [] - : []; + const selectedConversation = selectedConversationId ? mockConversations.find((conv) => conv.id === selectedConversationId) : null; + const selectedChatUser = selectedConversation && selectedConversation.userId ? allProfiles.find((u) => u.id === selectedConversation.userId) : null; + const conversationMessages = selectedConversationId ? allMessages[selectedConversationId as keyof typeof allMessages] || [] : []; return ( - {/* Left Drawer */} + {/* LeftDrawer receives loading/error state and can render skeleton or error locally */} - {/* Main Content - Show ChatInterface when messages tab is active and conversation is selected */} + {/* Main Content - don't block whole layout; child components handle meLoading/meError as needed */} {activeTab === "messages" && selectedConversationId && selectedChatUser ? ( @@ -117,14 +95,13 @@ function LayoutContent({ children }: { children: React.ReactNode }) { ); } -export default function LoggedLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function LoggedLayout({ children }: Readonly<{ children: React.ReactNode }>) { + // MeProvider wraps the whole area that may need user data, but we don't block rendering return ( - - {children} - + + + {children} + + ); } diff --git a/nextjs/matcha/src/app/(logged)/me/page.tsx b/nextjs/matcha/src/app/(logged)/me/page.tsx new file mode 100644 index 0000000..3b980ed --- /dev/null +++ b/nextjs/matcha/src/app/(logged)/me/page.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useMe } from "@/contexts/MeContext"; +import Stack from "@/components/common/Stack"; +import TextField from "@/components/common/TextField"; +import ProfileCard from "@/components/browsing/ProfileCard"; +import { calculateAge } from "@/lib/searchUtils"; +import { GENDER_OPTIONS , ORIENTATION_OPTIONS } from "@/constants/onboarding"; +import { useState, useEffect } from "react"; + +function onChange(field: string, value: any) { + console.log(`Field ${field} changed to`, value); +} + +export default function MePage() { + const me = useMe(); + + const [firstNameError, setFirstNameError] = useState(undefined); + const [lastNameError, setLastNameError] = useState(undefined); + const [genderError, setGenderError] = useState(undefined); + const [birthdateError, setBirthdateError] = useState(undefined); + const [orientationError, setOrientationError] = useState(undefined); + + useEffect(() => { + const trimmedFirstName = me.firstName.trim() + if (trimmedFirstName === '') { + setFirstNameError('First name cannot be empty'); + } else if (trimmedFirstName.length > 50) { + setFirstNameError('First name cannot be longer than 50 characters'); + } else { + setFirstNameError(undefined); + } + + const trimmedLastName = me.lastName.trim() + if (trimmedLastName.length > 50) { + setLastNameError('Last name cannot be longer than 50 characters'); + } else if (trimmedLastName === '') { + setLastNameError('Last name cannot be empty'); + } else { + setLastNameError(undefined); + } + + if (!me.birthdate || isNaN(me.birthdate.getTime())) { + setBirthdateError('Invalid birthdate'); + } else { + setBirthdateError(undefined); + } + + const today = new Date(); + const heightensAgo = today.setFullYear(today.getFullYear() - 18); + + if (heightensAgo - (me.birthdate as Date).getTime() < 0) + { + setBirthdateError('You must be at least 18 years old'); + } + + }, [me.firstName, me.lastName, me.birthdate, me.orientation]); + + return ( +
+ +

My Profile

+ {me.isLoading &&

Loading...

} + {me.error && ( +
+

Error loading profile: {me.error}

+ +
+ )} + {!me.isLoading && !me.error && ( <> + + + + me.setFirstName(e.target.value)} /> + me.setLastName(e.target.value)} /> + me.setBirthdate(new Date(e.target.value))} /> + + + )} +
+ { !me.isLoading && !me.error && ( +
+
+ {}} key={String(me.id)} id={String(me.id)} name={me.firstName} age={calculateAge(me.birthdate?.toDateString() as string)} pictureUrl={(me.profilePictureUrls?.length > 0 && me.profilePictureIndex !== null) ? me.profilePictureUrls[me.profilePictureIndex] : '/default-profile.svg'}> +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/nextjs/matcha/src/components/browsing/ConversationItem.tsx b/nextjs/matcha/src/components/browsing/ConversationItem.tsx index 5313d32..e7416b0 100644 --- a/nextjs/matcha/src/components/browsing/ConversationItem.tsx +++ b/nextjs/matcha/src/components/browsing/ConversationItem.tsx @@ -23,7 +23,7 @@ export default function ConversationItem({ >
- {name} + {name}
diff --git a/nextjs/matcha/src/components/browsing/LeftDrawer.tsx b/nextjs/matcha/src/components/browsing/LeftDrawer.tsx index 5d78efb..fb4c6ca 100644 --- a/nextjs/matcha/src/components/browsing/LeftDrawer.tsx +++ b/nextjs/matcha/src/components/browsing/LeftDrawer.tsx @@ -12,7 +12,7 @@ type Tab = "matches" | "messages"; interface Match { id: string; name: string; - pictureUrl: string; + pictureUrl: string | null; } interface Conversation { @@ -33,6 +33,9 @@ interface LeftDrawerProps { onConversationClick?: (conversationId: string) => void; activeTab?: Tab; onTabChange?: (tab: Tab) => void; + meLoading: boolean; + meError: string | null; + onRetryMe: () => void; } export default function LeftDrawer({ @@ -43,6 +46,9 @@ export default function LeftDrawer({ onConversationClick, activeTab: controlledActiveTab, onTabChange, + meLoading, + meError, + onRetryMe, }: LeftDrawerProps) { const [internalActiveTab, setInternalActiveTab] = useState("matches"); @@ -72,7 +78,28 @@ export default function LeftDrawer({ + { + meError ? ( + + + {meError} + + + + ) : null} {/* Tabs */} onMatchClick?.(match.id)} /> )) diff --git a/nextjs/matcha/src/components/browsing/MatchCard.tsx b/nextjs/matcha/src/components/browsing/MatchCard.tsx index 68d1cec..d47a619 100644 --- a/nextjs/matcha/src/components/browsing/MatchCard.tsx +++ b/nextjs/matcha/src/components/browsing/MatchCard.tsx @@ -21,7 +21,7 @@ export default function MatchCard({ >
- {name} + {name}
{name} diff --git a/nextjs/matcha/src/components/browsing/ProfileCard.tsx b/nextjs/matcha/src/components/browsing/ProfileCard.tsx index c80ee41..487e9a1 100644 --- a/nextjs/matcha/src/components/browsing/ProfileCard.tsx +++ b/nextjs/matcha/src/components/browsing/ProfileCard.tsx @@ -24,6 +24,7 @@ export default function ProfileCard({ {/* Image */}
{name}
- Vous + { isLoading ? "Loading..." : error ? `Error` : `Vous: ${username}`}
- - + - - - - + + + + + +
); } diff --git a/nextjs/matcha/src/components/homepage/SignUpModal.tsx b/nextjs/matcha/src/components/homepage/SignUpModal.tsx index 3e59b74..621872e 100644 --- a/nextjs/matcha/src/components/homepage/SignUpModal.tsx +++ b/nextjs/matcha/src/components/homepage/SignUpModal.tsx @@ -60,9 +60,6 @@ export default function SignInModal({ isOpen, onClose }: SignInModalProps) { email, username, password, - bornAt: "1990-01-01", // Default date of birth - orientation: "bisexual" as const, // Default orientation - gender: "men" as const, // Default gender }; const response = await fetch("/api/auth/signup", { diff --git a/nextjs/matcha/src/components/me/Settings.tsx b/nextjs/matcha/src/components/me/Settings.tsx new file mode 100644 index 0000000..9c9dd99 --- /dev/null +++ b/nextjs/matcha/src/components/me/Settings.tsx @@ -0,0 +1,9 @@ +'use client' + +export default function Settings() { + return ( +
+

Settings Page

+
+ ) +} \ No newline at end of file diff --git a/nextjs/matcha/src/components/profile/ProfileView.tsx b/nextjs/matcha/src/components/profile/ProfileView.tsx index da47cb2..5f3ebc3 100644 --- a/nextjs/matcha/src/components/profile/ProfileView.tsx +++ b/nextjs/matcha/src/components/profile/ProfileView.tsx @@ -102,7 +102,7 @@ export default function ProfileView({
- {profile.gender === "male" ? "Homme" : profile.gender === "female" ? "Femme" : "Autre"} + {profile.gender === "men" ? "Homme" : profile.gender === "women" ? "Femme" : "Autre"} {profile.firstName} {age > 0 ? age : "?"} @@ -185,7 +185,7 @@ export default function ProfileView({ Genre - {profile.gender === "male" ? "Homme" : profile.gender === "female" ? "Femme" : "Autre"} + {profile.gender === "men" ? "Homme" : profile.gender === "women" ? "Femme" : "Autre"}
@@ -217,7 +217,7 @@ export default function ProfileView({
{profile.interestedInGenders.map((gender, idx) => ( - {gender === "male" ? "Homme" : gender === "female" ? "Femme" : "Autre"} + {gender === "men" ? "Homme" : gender === "women" ? "Femme" : "Autre"} {idx < profile.interestedInGenders.length - 1 && ", "} ))} diff --git a/nextjs/matcha/src/constants/onboarding.ts b/nextjs/matcha/src/constants/onboarding.ts index 8627d62..e393be4 100644 --- a/nextjs/matcha/src/constants/onboarding.ts +++ b/nextjs/matcha/src/constants/onboarding.ts @@ -59,11 +59,15 @@ export const AVAILABLE_INTERESTS = [ ]; export const GENDER_OPTIONS = [ - { value: "male", label: "Homme" }, - { value: "female", label: "Femme" }, - { value: "non-binary", label: "Non-binaire" }, - { value: "other", label: "Autre" }, + { value: "men", label: "Homme" }, + { value: "women", label: "Femme" }, ]; +export const ORIENTATION_OPTIONS = [ + { value: "heterosexual", label: "Hétérosexuel" }, + { value: "homosexual", label: "Homosexuel" }, + { value: "bisexual", label: "Bisexuel" }, +] + export const MIN_INTERESTS = 3; export const MAX_ADDITIONAL_PICTURES = 4; diff --git a/nextjs/matcha/src/contexts/MeContext.tsx b/nextjs/matcha/src/contexts/MeContext.tsx new file mode 100644 index 0000000..94367d3 --- /dev/null +++ b/nextjs/matcha/src/contexts/MeContext.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { createContext, useContext, ReactNode, useState, useEffect } from "react"; +import { Location } from "../types/location"; +import axios, { Axios } from "axios"; +import { useQuery } from "@tanstack/react-query"; + +interface MeContextType { + id: number; + setId: (id: number) => void; + profilePictureUrls: string[]; + setProfilePictureUrls: (urls: string[]) => void; + profilePictureIndex: number | null; + setProfilePictureIndex: (index: number | null) => void; + bio: string; + setBio: (bio: string) => void; + tags: string[]; + setTags: (tags: string[]) => void; + email: string; + setEmail: (email: string) => void; + username: string; + setUsername: (username: string) => void; + firstName: string; + setFirstName: (firstName: string) => void; + lastName: string; + setLastName: (lastName: string) => void; + birthdate: Date | null; + setBirthdate: (birthdate: Date | null) => void; + location: Location | null; + setLocation: (location: Location | null) => void; + orientation: string; + setOrientation: (orientation: string) => void; + gender: string; + setGender: (gender: string) => void; + isVerified: boolean; + setIsVerified: (isVerified: boolean) => void; + isProfileCompleted: boolean; + setIsProfileCompleted: (isProfileCompleted: boolean) => void; + fameRate: number; + setFameRate: (fameRate: number) => void; + + error: string | null; + isLoading: boolean; + refresh: () => void; +} + +const MeContext = createContext(undefined); + +type Me = { + id: number; + email: string; + username: string; + firstName: string; + lastName: string; + profilePictures: string[]; + profilePictureIndex: number | null; + bio: string; + tags: string[]; + bornAt: string; + gender: string; + orientation: string; + isVerified: boolean; + isProfileCompleted: boolean; + fameRate: number; + location: Location | null; + createdAt: string; +} + +function usePosts() { + const query = useQuery({ + queryKey: ['me'], + queryFn: async (): Promise => { + const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/private/user/me/profile`, { + withCredentials: true, + }); + return response.data; + }, + }); + if (query.error) { + query.error = new Error("Failed to fetch user profile"); + } + return query; +} + +export function MeProvider({ children }: { children: ReactNode }) { + const [id, setId] = useState(0); + const [profilePictureUrls, setProfilePictureUrls] = useState([]); + const [profilePictureIndex, setProfilePictureIndex] = useState(null); + const [bio, setBio] = useState(""); + const [tags, setTags] = useState([]); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [birthdate, setBirthdate] = useState(null); + const [location, setLocation] = useState(null); + const [orientation, setOrientation] = useState(""); + const [gender, setGender] = useState(""); + const [isVerified, setIsVerified] = useState(false); + const [isProfileCompleted, setIsProfileCompleted] = useState(false); + const [fameRate, setFameRate] = useState(0); + + const { data, error, isLoading, refetch } = usePosts(); + + useEffect(() => { + if (data) { + setId(data.id); + setEmail(data.email); + setUsername(data.username); + setFirstName(data.firstName); + setLastName(data.lastName); + setProfilePictureUrls(data.profilePictures); + setProfilePictureIndex(data.profilePictureIndex); + setBio(data.bio); + setTags(data.tags); + setBirthdate(new Date(data.bornAt)); + setLocation(data.location); + setOrientation(data.orientation); + setGender(data.gender); + } + }, [data]); + + return ( + + {children} + + ); +} + +export function useMe() { + const context = useContext(MeContext); + if (context === undefined) { + throw new Error("useMe must be used within a MeProvider"); + } + return context; +} diff --git a/nextjs/matcha/src/hooks/useOnboarding.ts b/nextjs/matcha/src/hooks/useOnboarding.ts index efb4a8f..e526f95 100644 --- a/nextjs/matcha/src/hooks/useOnboarding.ts +++ b/nextjs/matcha/src/hooks/useOnboarding.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { OnboardingData, OnboardingStep } from '@/types/onboarding'; import { STEPS, MIN_INTERESTS } from '@/constants/onboarding'; +import { Fira_Sans_Extra_Condensed } from 'next/font/google'; const initialData: OnboardingData = { firstName: '', @@ -80,7 +81,6 @@ export const useOnboarding = () => { const completedStepsCount = STEPS.filter((step) => isStepValid(step.id)).length; const submitOnboarding = async () => { - console.log("Submitting onboarding with data:", data); try { // Step 1: Upload profile picture (required) if (data.profilePicture) { @@ -93,29 +93,32 @@ export const useOnboarding = () => { }); } - // Step 2: Upload additional pictures - // for (const picture of data.additionalPictures) { - // if (picture) { - // const formData = new FormData(); - // formData.append('file', picture); + for (const picture of data.additionalPictures) { + if (picture) { + const formData = new FormData(); + formData.append('file', picture); - // await fetch('/api/private/user/me/profile-picture', { - // method: 'POST', - // body: formData, - // }); - // } - // } + await fetch('/api/private/user/me/profile-picture', { + method: 'POST', + body: formData, + }); + } + } // Map gender from UI values to backend enum ("men"/"women") const genderMap: Record = { 'Men': 'men', 'Women': 'women', 'male': 'men', - 'female': 'women' + 'female': 'women', + 'men': 'men', + 'women': 'women', }; // Step 3: Update profile with onboarding data const profileData = { + firstName: data.firstName, + lastName: data.lastName, bio: data.biography, tags: data.interests, gender: genderMap[data.gender] || data.gender, @@ -125,14 +128,17 @@ export const useOnboarding = () => { ? 'homosexual' : 'heterosexual', bornAt: new Date(data.birthday).toISOString(), - }; const response = await fetch('/api/private/user/me/profile', { - method: 'PUT', + }; + console.log('Submitting profile data:', profileData); + const response = await fetch('/api/private/user/me/complete-profile', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profileData), }); if (!response.ok) { const error = await response.json(); + console.error('Failed to submit onboarding:', error); throw new Error(error.message || 'Failed to submit onboarding'); } diff --git a/nextjs/matcha/src/mocks/browsing_mocks.ts b/nextjs/matcha/src/mocks/browsing_mocks.ts index 38b116f..dc5c8c9 100644 --- a/nextjs/matcha/src/mocks/browsing_mocks.ts +++ b/nextjs/matcha/src/mocks/browsing_mocks.ts @@ -3,7 +3,7 @@ import { FilterOptions } from "@/components/browsing/FilterBar/types"; import { filterProfiles } from "@/lib/searchUtils"; // Available profile pictures -const FEMALE_PICTURES = [ +const female_PICTURES = [ "/mock_pictures/femme1.jpg", "/mock_pictures/femme2.jpg", "/mock_pictures/femme3.jpg", @@ -11,7 +11,7 @@ const FEMALE_PICTURES = [ "/mock_pictures/femme5.jpg", ]; -const MALE_PICTURES = [ +const men_PICTURES = [ "/mock_pictures/homme1.jpg", "/mock_pictures/homme2.jpg", "/mock_pictures/homme3.jpg", @@ -29,7 +29,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Passionnée de voyages et de photographie. J'adore découvrir de nouvelles cultures et partager des moments authentiques.", interests: ["Voyages", "Photographie", "Cuisine", "Yoga"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme1.jpg", additionalPictures: ["/mock_pictures/femme2.jpg", "/mock_pictures/femme3.jpg", null, null], fame: 85, @@ -43,7 +43,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Architecte le jour, artiste la nuit. J'aime créer et explorer l'art sous toutes ses formes.", interests: ["Architecture", "Art", "Musique", "Randonnée"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme2.jpg", additionalPictures: ["/mock_pictures/femme1.jpg", null, null, null], fame: 62, @@ -57,7 +57,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Étudiante en médecine et amoureuse des animaux. Je cherche quelqu'un avec qui partager de bons moments.", interests: ["Médecine", "Animaux", "Lecture", "Sport"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme3.jpg", additionalPictures: ["/mock_pictures/femme4.jpg", "/mock_pictures/femme5.jpg", "/mock_pictures/femme1.jpg", null], fame: 45, @@ -71,7 +71,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Développeuse web passionnée par les nouvelles technologies et l'innovation.", interests: ["Technologie", "Gaming", "Cinéma", "Running"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme4.jpg", additionalPictures: ["/mock_pictures/femme5.jpg", null, null, null], fame: 15, @@ -85,7 +85,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Professeure de danse et amoureuse de la vie. Toujours prête pour de nouvelles aventures !", interests: ["Danse", "Musique", "Fitness", "Mode"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme5.jpg", additionalPictures: ["/mock_pictures/femme1.jpg", "/mock_pictures/femme2.jpg", null, null], fame: 0, @@ -99,7 +99,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Étudiante en communication, passionnée par les réseaux sociaux et le marketing digital.", interests: ["Marketing", "Réseaux sociaux", "Café", "Voyage"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme1.jpg", additionalPictures: [null, null, null, null], fame: 0, @@ -113,7 +113,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Chef cuisinière qui aime expérimenter de nouvelles saveurs. Gourmande assumée !", interests: ["Cuisine", "Gastronomie", "Vin", "Pâtisserie"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme2.jpg", additionalPictures: ["/mock_pictures/femme3.jpg", "/mock_pictures/femme4.jpg", "/mock_pictures/femme5.jpg", "/mock_pictures/femme1.jpg"], fame: 0, @@ -127,7 +127,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Graphiste freelance et amoureuse de la nature. Je cherche quelqu'un de créatif et authentique.", interests: ["Design", "Nature", "Randonnée", "Photographie"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme3.jpg", additionalPictures: ["/mock_pictures/femme2.jpg", null, null, null], fame: 0, @@ -141,7 +141,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Infirmière dévouée le jour, aventurière le week-end. J'adore les activités en plein air.", interests: ["Sport", "Nature", "Camping", "Vélo"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme4.jpg", additionalPictures: ["/mock_pictures/femme5.jpg", "/mock_pictures/femme1.jpg", null, null], fame: 0, @@ -155,7 +155,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Musicienne et compositrice. La musique est ma vie et je cherche quelqu'un qui partage cette passion.", interests: ["Musique", "Concert", "Guitare", "Chant"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme5.jpg", additionalPictures: ["/mock_pictures/femme3.jpg", null, null, null], fame: 0, @@ -367,11 +367,11 @@ export function generateMockUserProfiles(n: number): UserProfile[] { bioTemplates[Math.floor(Math.random() * bioTemplates.length)]; // Random gender and preferences - const gender = Math.random() > 0.5 ? "female" : "male"; - const interestedInGenders = gender === "female" ? ["male"] : ["female"]; + const gender = Math.random() > 0.5 ? "female" : "men"; + const interestedInGenders = gender === "female" ? ["men"] : ["female"]; // Select pictures based on gender - const picturePool = gender === "female" ? FEMALE_PICTURES : MALE_PICTURES; + const picturePool = gender === "female" ? female_PICTURES : men_PICTURES; const profilePicture = picturePool[Math.floor(Math.random() * picturePool.length)]; // Generate random additional pictures (0-4 pictures, some can be null) diff --git a/nextjs/matcha/src/types/location.ts b/nextjs/matcha/src/types/location.ts new file mode 100644 index 0000000..0f52c7b --- /dev/null +++ b/nextjs/matcha/src/types/location.ts @@ -0,0 +1,6 @@ +export type Location = { + latitude: number; + longitude: number; + city: string; + country: string; +}; \ No newline at end of file From 4987c14b678b9a7d235959934947b634df356a3d Mon Sep 17 00:00:00 2001 From: YoungMame Date: Fri, 28 Nov 2025 08:24:35 +0100 Subject: [PATCH 02/13] i react cropper component --- nextjs/matcha/package.json | 3 ++- nextjs/matcha/pnpm-lock.yaml | 12 ++++++++++++ .../src/components/onboarding/steps/PicturesStep.tsx | 12 +++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/nextjs/matcha/package.json b/nextjs/matcha/package.json index a936332..6ba249b 100644 --- a/nextjs/matcha/package.json +++ b/nextjs/matcha/package.json @@ -15,7 +15,8 @@ "framer-motion": "^12.23.24", "next": "16.0.0", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "react-image-crop": "^11.0.10" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/nextjs/matcha/pnpm-lock.yaml b/nextjs/matcha/pnpm-lock.yaml index a4aa384..c703958 100644 --- a/nextjs/matcha/pnpm-lock.yaml +++ b/nextjs/matcha/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: react-dom: specifier: 19.2.0 version: 19.2.0(react@19.2.0) + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@19.2.0) devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -1700,6 +1703,11 @@ packages: peerDependencies: react: ^19.2.0 + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3737,6 +3745,10 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-image-crop@11.0.10(react@19.2.0): + dependencies: + react: 19.2.0 + react-is@16.13.1: {} react@19.2.0: {} diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index 1341b4b..1925e7d 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -4,6 +4,8 @@ import { useRef, useState } from "react"; import Typography from "@/components/common/Typography"; import ErrorModal from "@/components/common/ErrorModal"; import { MAX_ADDITIONAL_PICTURES } from "@/constants/onboarding"; +import ReactCrop, { type Crop } from 'react-image-crop' +import 'react-image-crop/dist/ReactCrop.css'; interface PicturesStepProps { profilePicture: File | null; @@ -158,6 +160,9 @@ export default function PicturesStep({ const hasError = showValidation && !profilePicture; + const [crop, setCrop] = useState() + + return (
{/* Profile Picture */} @@ -174,7 +179,7 @@ export default function PicturesStep({ )} -
+
)}
+ {profilePicture && ( + setCrop(c)}> + + ) + }
From aef87abde8cd39f9395b3c161ba6c517679dba9f Mon Sep 17 00:00:00 2001 From: YoungMame Date: Fri, 28 Nov 2025 14:16:40 +0100 Subject: [PATCH 03/13] add:(simple image crop in onboarding) --- nextjs/matcha/package.json | 1 + nextjs/matcha/pnpm-lock.yaml | 21 ++++ .../onboarding/steps/PicturesStep.tsx | 110 ++++++++++++++-- nextjs/matcha/src/utils/cropImage.ts | 117 ++++++++++++++++++ 4 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 nextjs/matcha/src/utils/cropImage.ts diff --git a/nextjs/matcha/package.json b/nextjs/matcha/package.json index 6ba249b..dac7959 100644 --- a/nextjs/matcha/package.json +++ b/nextjs/matcha/package.json @@ -16,6 +16,7 @@ "next": "16.0.0", "react": "19.2.0", "react-dom": "19.2.0", + "react-easy-crop": "^5.5.6", "react-image-crop": "^11.0.10" }, "devDependencies": { diff --git a/nextjs/matcha/pnpm-lock.yaml b/nextjs/matcha/pnpm-lock.yaml index c703958..d1e70a0 100644 --- a/nextjs/matcha/pnpm-lock.yaml +++ b/nextjs/matcha/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: react-dom: specifier: 19.2.0 version: 19.2.0(react@19.2.0) + react-easy-crop: + specifier: ^5.5.6 + version: 5.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@19.2.0) @@ -1595,6 +1598,9 @@ packages: node-releases@2.0.26: resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} + normalize-wheel@1.0.1: + resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1703,6 +1709,12 @@ packages: peerDependencies: react: ^19.2.0 + react-easy-crop@5.5.6: + resolution: {integrity: sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==} + peerDependencies: + react: '>=16.4.0' + react-dom: '>=16.4.0' + react-image-crop@11.0.10: resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} peerDependencies: @@ -3631,6 +3643,8 @@ snapshots: node-releases@2.0.26: {} + normalize-wheel@1.0.1: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -3745,6 +3759,13 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-easy-crop@5.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + normalize-wheel: 1.0.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + react-image-crop@11.0.10(react@19.2.0): dependencies: react: 19.2.0 diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index 1925e7d..3d0f7af 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -4,8 +4,33 @@ import { useRef, useState } from "react"; import Typography from "@/components/common/Typography"; import ErrorModal from "@/components/common/ErrorModal"; import { MAX_ADDITIONAL_PICTURES } from "@/constants/onboarding"; -import ReactCrop, { type Crop } from 'react-image-crop' -import 'react-image-crop/dist/ReactCrop.css'; +import Cropper from 'react-easy-crop' +import getCroppedImg from '@/utils/cropImage'; +import RangeSlider from "@/components/common/RangeSlider"; + +// declare type Size = { +// width: number; +// height: number; +// }; +// declare type MediaSize = { +// width: number; +// height: number; +// naturalWidth: number; +// naturalHeight: number; +// }; + +declare type Point = { + x: number; + y: number; +}; +declare type Area = { + width: number; + height: number; + x: number; + y: number; +}; + +const CROP_AREA_ASPECT = 9 / 16; interface PicturesStepProps { profilePicture: File | null; @@ -17,6 +42,32 @@ interface PicturesStepProps { showValidation?: boolean; } +const Output = ({ croppedArea}: { croppedArea: Area }) => { + const scale = 100 / croppedArea.width; + const transform = { + x: `${-croppedArea.x * scale}%`, + y: `${-croppedArea.y * scale}%`, + scale, + width: "calc(100% + 0.5px)", + height: "auto" + }; + + const imageStyle = { + transform: `translate3d(${transform.x}, ${transform.y}, 0) scale3d(${transform.scale},${transform.scale},1)`, + width: transform.width, + height: transform.height + }; + + return ( +
+ +
+ ); +}; + export default function PicturesStep({ profilePicture, additionalPictures, @@ -160,8 +211,33 @@ export default function PicturesStep({ const hasError = showValidation && !profilePicture; - const [crop, setCrop] = useState() - + const [crop, setCrop] = useState({ x: 0, y: 0 }) + const [rotation, setRotation] = useState(0) + const [zoom, setZoom] = useState(1) + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) + const [croppedImage, setCroppedImage] = useState(null) + + const onCropComplete = (croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels) + } + + const showCroppedImage = async () => { + try { + const croppedImage = await getCroppedImg( + profilePicture ? getImageUrl(profilePicture)! : "", + croppedAreaPixels as Area, + rotation + ) + console.log('donee', { croppedImage }) + setCroppedImage(croppedImage as string) + } catch (e) { + console.error(e) + } + } + + const onClose = () => { + setCroppedImage(null) + } return (
@@ -252,11 +328,27 @@ export default function PicturesStep({ )}
- {profilePicture && ( - setCrop(c)}> - - ) - } + {profilePicture && (<> +
+ + {/* TODO create slider component */} + + + +
+
+ +
+ + )}
diff --git a/nextjs/matcha/src/utils/cropImage.ts b/nextjs/matcha/src/utils/cropImage.ts new file mode 100644 index 0000000..97a4250 --- /dev/null +++ b/nextjs/matcha/src/utils/cropImage.ts @@ -0,0 +1,117 @@ +type Area = { + x: number + y: number + width: number + height: number +} + +type Point = { + x: number + y: number +} + +export const createImage = (url: string) => + new Promise((resolve, reject) => { + const image = new Image() + image.addEventListener('load', () => resolve(image)) + image.addEventListener('error', (error) => reject(error)) + image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox + image.src = url + }) + +export function getRadianAngle(degreeValue: number) { + return (degreeValue * Math.PI) / 180 +} + +/** + * Returns the new bounding area of a rotated rectangle. + */ +export function rotateSize(width: number, height: number, rotation: number) { + const rotRad = getRadianAngle(rotation) + + return { + width: + Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: + Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + } +} + +/** + * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop + */ +export default async function getCroppedImg( + imageSrc: string, + pixelCrop: Area, + rotation = 0, + flip = { horizontal: false, vertical: false } +) { + const image = await createImage(imageSrc) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + if (!ctx) { + return null + } + + const rotRad = getRadianAngle(rotation) + + // calculate bounding box of the rotated image + const { width: bBoxWidth, height: bBoxHeight } = rotateSize( + image.width as number, + image.height as number, + rotation + ) + + // set canvas size to match the bounding box + canvas.width = bBoxWidth + canvas.height = bBoxHeight + + // translate canvas context to a central location to allow rotating and flipping around the center + ctx.translate(bBoxWidth / 2, bBoxHeight / 2) + ctx.rotate(rotRad) + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1) + ctx.translate(-image.width / 2, -image.height / 2) + + // draw rotated image + ctx.drawImage(image, 0, 0) + + const croppedCanvas = document.createElement('canvas') + + const croppedCtx = croppedCanvas.getContext('2d') + + if (!croppedCtx) { + return null + } + + // Set the size of the cropped canvas + croppedCanvas.width = pixelCrop.width + croppedCanvas.height = pixelCrop.height + + // Draw the cropped image onto the new canvas + croppedCtx.drawImage( + canvas, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ) + + // As Base64 string + // return croppedCanvas.toDataURL('image/jpeg'); + + // As a blob + return new Promise((resolve, reject) => { + croppedCanvas.toBlob((file: Blob | null) => { + if (file) { + resolve(URL.createObjectURL(file)) + } else { + reject(new Error('Canvas is empty')) + } + }, 'image/jpeg') + }) +} From 5f7611889dbc619f50b94f83c6a72b57195fb8a6 Mon Sep 17 00:00:00 2001 From: YoungMame Date: Fri, 28 Nov 2025 17:11:03 +0100 Subject: [PATCH 04/13] add:(serveur image croping) --- fastify/assets/package.json | 1 + .../controllers/private/me/profilePictures.ts | 20 ++- .../routes/private/user/me/profilePicture.ts | 8 ++ .../onboarding/steps/PicturesStep.tsx | 123 +++++++++--------- 4 files changed, 89 insertions(+), 63 deletions(-) diff --git a/fastify/assets/package.json b/fastify/assets/package.json index 3215fe4..dce433a 100644 --- a/fastify/assets/package.json +++ b/fastify/assets/package.json @@ -19,6 +19,7 @@ "image-dimensions": "^2.5.0", "pg": "^8.16.3", "pump": "^3.0.3", + "sharp": "^0.34.5", "text-case": "^1.2.9" }, "devDependencies": { diff --git a/fastify/assets/srcs/controllers/private/me/profilePictures.ts b/fastify/assets/srcs/controllers/private/me/profilePictures.ts index 84536fe..e328497 100644 --- a/fastify/assets/srcs/controllers/private/me/profilePictures.ts +++ b/fastify/assets/srcs/controllers/private/me/profilePictures.ts @@ -2,6 +2,14 @@ import { FastifyRequest, FastifyReply, FastifyRequestUser } from 'fastify'; import { AppError, UnauthorizedError, ForbiddenError, NotFoundError, BadRequestError } from '../../../utils/error'; import path from 'path'; import fs from 'fs'; +import sharp from 'sharp'; + +type Scrop = { + x: number; + y: number; + width: number; + height: number; +} export const setProfilePictureIndexHandler = async ( request: FastifyRequest, @@ -35,6 +43,8 @@ export const addProfilePictureHandler = async ( if (!userId) throw new UnauthorizedError(); + const { scrop, rotation } = request.body as { scrop?: Scrop, rotation?: number }; + const file = request.fileMeta; if (!file) throw (new BadRequestError()); @@ -48,7 +58,15 @@ export const addProfilePictureHandler = async ( const newFilePath = path.join(picturesDir, newFileName); const newFileURL = `https://${process.env.DOMAIN || 'localhost'}/api/private/uploads/${userId}/${newFileName}`; const dest = fs.createWriteStream(newFilePath); - dest.write(request.fileBuffer); + + const newBuffer = await sharp(request.fileBuffer).extract({ + left: Math.round(scrop?.x || 0), + top: Math.round(scrop?.y || 0), + width: Math.round(scrop?.width || 0), + height: Math.round(scrop?.height || 0) + }).rotate(rotation || 0).toBuffer(); + + dest.write(newBuffer); dest.end(); // dest.on('finish', () => { diff --git a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts index b0b9451..c2be896 100644 --- a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts +++ b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts @@ -77,6 +77,14 @@ const profilePictureRoutes = async (fastify: FastifyInstance) => { }); fastify.post('/', { schema: { + request: { + properties: { + scrop: { type: 'object', properties: { + x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number'} + }, required: ['x', 'y', 'width', 'height']}, + rotation: { type: 'number' } + } + }, response: { 200: { type: 'object', diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index 3d0f7af..c82a889 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -30,11 +30,18 @@ declare type Area = { y: number; }; +declare type ImageSettings = { + rotation: number; + crop: Area; +} + const CROP_AREA_ASPECT = 9 / 16; interface PicturesStepProps { profilePicture: File | null; additionalPictures: (File | null)[]; + profilePictureSettings: ImageSettings; + additionalPicturesSettings: ImageSettings[]; onChange: ( field: "profilePicture" | "additionalPictures", value: File | null | (File | null)[] @@ -42,35 +49,11 @@ interface PicturesStepProps { showValidation?: boolean; } -const Output = ({ croppedArea}: { croppedArea: Area }) => { - const scale = 100 / croppedArea.width; - const transform = { - x: `${-croppedArea.x * scale}%`, - y: `${-croppedArea.y * scale}%`, - scale, - width: "calc(100% + 0.5px)", - height: "auto" - }; - - const imageStyle = { - transform: `translate3d(${transform.x}, ${transform.y}, 0) scale3d(${transform.scale},${transform.scale},1)`, - width: transform.width, - height: transform.height - }; - - return ( -
- -
- ); -}; - export default function PicturesStep({ profilePicture, additionalPictures, + profilePictureSettings, + additionalPicturesSettings, onChange, showValidation = false, }: PicturesStepProps) { @@ -211,33 +194,52 @@ export default function PicturesStep({ const hasError = showValidation && !profilePicture; - const [crop, setCrop] = useState({ x: 0, y: 0 }) - const [rotation, setRotation] = useState(0) - const [zoom, setZoom] = useState(1) - const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) - const [croppedImage, setCroppedImage] = useState(null) - - const onCropComplete = (croppedArea: Area, croppedAreaPixels: Area) => { - setCroppedAreaPixels(croppedAreaPixels) - } - - const showCroppedImage = async () => { - try { - const croppedImage = await getCroppedImg( - profilePicture ? getImageUrl(profilePicture)! : "", - croppedAreaPixels as Area, - rotation - ) - console.log('donee', { croppedImage }) - setCroppedImage(croppedImage as string) - } catch (e) { - console.error(e) - } - } - - const onClose = () => { - setCroppedImage(null) - } + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [rotation, setRotation] = useState(0); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const [croppedImages, setCroppedImages] = useState([]); + const [currentCroppingIndex, setCurrentCroppingIndex] = useState(null); + + const onCropComplete = (croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels) + } + + const submitImage = async () => { + try { + if (currentCroppingIndex === null || croppedAreaPixels === null) return; + + const picture = currentCroppingIndex == 0 ? profilePicture : additionalPictures[currentCroppingIndex - 1]; + if (picture == null) return; + + const pictureURL = getImageUrl(picture); + if (pictureURL == null) return; + + const croppedImage = await getCroppedImg( + pictureURL, + croppedAreaPixels as Area, + rotation + ) + console.log('donee', { croppedImage }) + const newCroppedImages = [...croppedImages]; + newCroppedImages[currentCroppingIndex] = croppedImage as string; + setCroppedImages(newCroppedImages); + setCurrentCroppingIndex(null); + onChange( + currentCroppingIndex == 0 ? "profilePicture" : "additionalPictures", + currentCroppingIndex == 0 + ? croppedImage as File + : (() => { + const newAdditionalPictures = [...additionalPictures]; + newAdditionalPictures[currentCroppingIndex - 1] = croppedImage as File; + return newAdditionalPictures; + })() + ); + } catch (e) { + console.error(e) + } + } return (
@@ -258,7 +260,7 @@ export default function PicturesStep({
Profile @@ -328,10 +330,10 @@ export default function PicturesStep({ )}
- {profilePicture && (<> + {currentCroppingIndex !== null && (<>
setRotation((rotation + 90) % 360)}>ROTATE DROITE - -
-
- +
)} @@ -375,7 +374,7 @@ export default function PicturesStep({ {picture ? ( <> {`Additional From bc982aa5657ae70265b8bafc57efc8cf98480c73 Mon Sep 17 00:00:00 2001 From: YoungMame Date: Fri, 28 Nov 2025 22:33:46 +0100 Subject: [PATCH 05/13] add:(image settings to unboarding data) --- fastify/assets/srcs/app.ts | 3 ++- .../srcs/routes/private/user/me/profilePicture.ts | 8 -------- nextjs/matcha/src/app/onboarding/page.tsx | 2 ++ .../src/components/onboarding/steps/PicturesStep.tsx | 12 ++++++------ nextjs/matcha/src/hooks/useOnboarding.ts | 9 +++++++++ nextjs/matcha/src/types/onboarding.ts | 8 ++++++++ 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/fastify/assets/srcs/app.ts b/fastify/assets/srcs/app.ts index 8a1b7be..154cdc8 100644 --- a/fastify/assets/srcs/app.ts +++ b/fastify/assets/srcs/app.ts @@ -46,7 +46,8 @@ export const buildApp = () => { files: 1, headerPairs: 2000, parts: 1000 - } + }, + attachFieldsToBody: true }); app.register(websocket, { diff --git a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts index c2be896..b0b9451 100644 --- a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts +++ b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts @@ -77,14 +77,6 @@ const profilePictureRoutes = async (fastify: FastifyInstance) => { }); fastify.post('/', { schema: { - request: { - properties: { - scrop: { type: 'object', properties: { - x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number'} - }, required: ['x', 'y', 'width', 'height']}, - rotation: { type: 'number' } - } - }, response: { 200: { type: 'object', diff --git a/nextjs/matcha/src/app/onboarding/page.tsx b/nextjs/matcha/src/app/onboarding/page.tsx index 33fe908..25c7c84 100644 --- a/nextjs/matcha/src/app/onboarding/page.tsx +++ b/nextjs/matcha/src/app/onboarding/page.tsx @@ -121,6 +121,8 @@ export default function OnboardingPage() { { updateData({ [field]: value }); if (showValidation) { diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index c82a889..9e36b92 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -43,8 +43,8 @@ interface PicturesStepProps { profilePictureSettings: ImageSettings; additionalPicturesSettings: ImageSettings[]; onChange: ( - field: "profilePicture" | "additionalPictures", - value: File | null | (File | null)[] + field: "profilePicture" | "additionalPictures" | "profilePictureSettings" | "additionalPicturesSettings", + value: File | null | (File | null)[] | ImageSettings | ImageSettings[] ) => void; showValidation?: boolean; } @@ -227,12 +227,12 @@ export default function PicturesStep({ setCroppedImages(newCroppedImages); setCurrentCroppingIndex(null); onChange( - currentCroppingIndex == 0 ? "profilePicture" : "additionalPictures", + currentCroppingIndex == 0 ? "profilePictureSettings" : "additionalPicturesSettings", currentCroppingIndex == 0 - ? croppedImage as File + ? { rotation, crop: croppedAreaPixels as Area } : (() => { - const newAdditionalPictures = [...additionalPictures]; - newAdditionalPictures[currentCroppingIndex - 1] = croppedImage as File; + const newAdditionalPictures = [...additionalPicturesSettings]; + newAdditionalPictures[currentCroppingIndex - 1] = { rotation, crop: croppedAreaPixels as Area }; return newAdditionalPictures; })() ); diff --git a/nextjs/matcha/src/hooks/useOnboarding.ts b/nextjs/matcha/src/hooks/useOnboarding.ts index e526f95..d5e5162 100644 --- a/nextjs/matcha/src/hooks/useOnboarding.ts +++ b/nextjs/matcha/src/hooks/useOnboarding.ts @@ -13,6 +13,11 @@ const initialData: OnboardingData = { interestedInGenders: [], profilePicture: null, additionalPictures: [null, null, null, null], + profilePictureSettings: { + rotation: 0, + crop: { x: 0, y: 0, width: 0, height: 0 }, + }, + additionalPicturesSettings: [], }; export const useOnboarding = () => { @@ -86,6 +91,8 @@ export const useOnboarding = () => { if (data.profilePicture) { const formData = new FormData(); formData.append('file', data.profilePicture); + formData.append('rotation', data.profilePictureSettings.rotation.toString()); + formData.append('scrop', JSON.stringify(data.profilePictureSettings.crop)); await fetch('/api/private/user/me/profile-picture', { method: 'POST', @@ -97,6 +104,8 @@ export const useOnboarding = () => { if (picture) { const formData = new FormData(); formData.append('file', picture); + formData.append('rotation', data.additionalPicturesSettings[data.additionalPictures.indexOf(picture)].rotation.toString()); + formData.append('scrop', JSON.stringify(data.additionalPicturesSettings[data.additionalPictures.indexOf(picture)].crop)); await fetch('/api/private/user/me/profile-picture', { method: 'POST', diff --git a/nextjs/matcha/src/types/onboarding.ts b/nextjs/matcha/src/types/onboarding.ts index e5673d3..c01f79a 100644 --- a/nextjs/matcha/src/types/onboarding.ts +++ b/nextjs/matcha/src/types/onboarding.ts @@ -15,6 +15,14 @@ export type OnboardingData = { // Step 4: Pictures profilePicture: File | null; additionalPictures: (File | null)[]; + profilePictureSettings: { + rotation: number; + crop: { x: number; y: number; width: number; height: number }; + }; + additionalPicturesSettings: { + rotation: number; + crop: { x: number; y: number; width: number; height: number }; + }[]; }; export type OnboardingStep = From caf5ea1c0b0d1119f6dc4c616d0e1733e9e33f80 Mon Sep 17 00:00:00 2001 From: YoungMame Date: Mon, 1 Dec 2025 13:46:03 +0100 Subject: [PATCH 06/13] fix:(image crop on unboarding) --- .../controllers/private/me/profilePictures.ts | 45 +++++++++--- .../srcs/plugins/checkImageConformity.ts | 59 +++++++++++---- .../routes/private/user/me/profilePicture.ts | 1 + .../public/rotate-option-svgrepo-com.svg | 7 ++ .../src/components/homepage/FloatingCard.tsx | 1 - .../onboarding/steps/PicturesStep.tsx | 59 +++++++++------ nextjs/matcha/src/hooks/useOnboarding.ts | 4 +- nextjs/matcha/src/utils/cropImage.ts | 71 ++++++++----------- 8 files changed, 158 insertions(+), 89 deletions(-) create mode 100644 nextjs/matcha/public/rotate-option-svgrepo-com.svg diff --git a/fastify/assets/srcs/controllers/private/me/profilePictures.ts b/fastify/assets/srcs/controllers/private/me/profilePictures.ts index e328497..330ecc5 100644 --- a/fastify/assets/srcs/controllers/private/me/profilePictures.ts +++ b/fastify/assets/srcs/controllers/private/me/profilePictures.ts @@ -3,8 +3,9 @@ import { AppError, UnauthorizedError, ForbiddenError, NotFoundError, BadRequestE import path from 'path'; import fs from 'fs'; import sharp from 'sharp'; +import { imageDimensionsFromData } from 'image-dimensions'; -type Scrop = { +type Crop = { x: number; y: number; width: number; @@ -41,14 +42,30 @@ export const addProfilePictureHandler = async ( const user = request.user; const userId: number = (user as any)?.id; if (!userId) - throw new UnauthorizedError(); + throw new UnauthorizedError(); + + const rotation = Number((request.body as { rotation?: { value: string } }).rotation?.value) || 0; + const rawCrop = (request.body as { crop?: { value: string } })?.crop?.value; + let crop: Crop | undefined; + if (typeof rawCrop === 'string') { + try { + const parsed = JSON.parse(rawCrop); + crop = parsed && typeof parsed === 'object' ? parsed : undefined; + } catch (e) { + throw new BadRequestError('Invalid crop JSON'); + } + } else if (rawCrop && typeof rawCrop === 'object') { + crop = rawCrop as Crop; + } - const { scrop, rotation } = request.body as { scrop?: Scrop, rotation?: number }; const file = request.fileMeta; if (!file) throw (new BadRequestError()); + if (!request.fileBuffer) + throw (new BadRequestError()); + const picturesDir = path.join(__dirname, '..', '..', '..', '..', 'uploads', userId.toString()); if (!fs.existsSync(picturesDir)) { fs.mkdirSync(picturesDir, { recursive: true }); @@ -59,13 +76,23 @@ export const addProfilePictureHandler = async ( const newFileURL = `https://${process.env.DOMAIN || 'localhost'}/api/private/uploads/${userId}/${newFileName}`; const dest = fs.createWriteStream(newFilePath); - const newBuffer = await sharp(request.fileBuffer).extract({ - left: Math.round(scrop?.x || 0), - top: Math.round(scrop?.y || 0), - width: Math.round(scrop?.width || 0), - height: Math.round(scrop?.height || 0) - }).rotate(rotation || 0).toBuffer(); + const currentSize = await imageDimensionsFromData(request.fileBuffer); + if (!currentSize) + throw new BadRequestError('Unrecognized file format'); + + let newBuffer; + try { + newBuffer = await sharp(request.fileBuffer).rotate(rotation || 0).extract({ + left: Math.round(crop?.x || 0), + top: Math.round(crop?.y || 0), + width: Math.round(crop?.width || currentSize.width), + height: Math.round(crop?.height || currentSize.height) + }).toBuffer(); + } catch (error) { + throw new BadRequestError('Failed to process image with given crop/rotation'); + } + console.log('Saving new profile picture to', newFilePath); dest.write(newBuffer); dest.end(); diff --git a/fastify/assets/srcs/plugins/checkImageConformity.ts b/fastify/assets/srcs/plugins/checkImageConformity.ts index 08827d9..fcc6ba7 100644 --- a/fastify/assets/srcs/plugins/checkImageConformity.ts +++ b/fastify/assets/srcs/plugins/checkImageConformity.ts @@ -1,11 +1,12 @@ import fp from 'fastify-plugin' import { FastifyRequest, FastifyReply } from 'fastify' import { imageDimensionsFromData } from 'image-dimensions' +import { MultipartFile } from '@fastify/multipart' export default fp(async function(fastify, opts) { fastify.decorate("checkImageConformity", async function(request: FastifyRequest, reply: FastifyReply) { try { - const file = await request.file(); + const { file } = request.body as { file?: MultipartFile }; if (!file) throw (new Error('No file uploaded')); @@ -15,32 +16,66 @@ export default fp(async function(fastify, opts) { // read once into a Buffer and reuse const buffer = await file.toBuffer(); + if (!buffer || buffer.length === 0) + throw (new Error('Failed to read uploaded file')); const maxSizeInBytes = 50 * 1024 * 1024; // 50 MB if (buffer.length > maxSizeInBytes) throw (new Error('File size exceeds limit')); + const ratiox = 9; const ratioy = 16; const minWidth = 150; const maxWidth = 1080; - const currentSize = await imageDimensionsFromData(buffer); - if (!currentSize) - throw new Error('Unrecognized file format'); - - const ratio = (currentSize.width / currentSize.height).toPrecision(3); - const expectedRatio = (ratiox / ratioy).toPrecision(3); - if (currentSize.width > maxWidth || currentSize.width < minWidth || ratio != expectedRatio) - throw new Error('Wrong file dimensions'); - - // attach buffer + meta to the request so later handlers can reuse it + const currentSize = imageDimensionsFromData(buffer); request.fileBuffer = buffer; request.fileMeta = { filename: file.filename, mimetype: file.mimetype, - fields: file.fields // if you need multipart fields + fields: file.fields, // if you need multipart fields }; + + const rawCrop = (request.body as { crop?: { value: string } })?.crop?.value; + let crop: { width?: number; height?: number; x?: number; y?: number } | undefined; + if (typeof rawCrop === 'string') { + try { + const parsed = JSON.parse(rawCrop); + crop = parsed && typeof parsed === 'object' ? parsed : undefined; + } catch (e) { + throw new Error('Invalid crop JSON'); + } + } else if (rawCrop && typeof rawCrop === 'object') { + crop = rawCrop; + } + + const width = crop?.width != null ? Number(crop.width) : undefined; + const height = crop?.height != null ? Number(crop.height) : undefined; + if (width && height) { + let rotation = Number((request.body as { rotation?: { value: string } }).rotation?.value) || 0; + if (rotation > 180) rotation = 180; + if (rotation < -180) rotation = -180; + const ratio = (width / height).toPrecision(2); + const expectedRatio = (ratiox / ratioy).toPrecision(2); + // console.log('width', width); + // console.log('width > maxWidth', width > maxWidth); + // console.log('width < minWidth', width < minWidth); + // console.log(ratio) + // console.log(expectedRatio) + + if (width > maxWidth || width < minWidth || ratio != expectedRatio) + throw new Error('Wrong file dimensions after crop/rotation'); + return; // Skip other checks + } + + if (!currentSize) + throw new Error('Unrecognized file format'); + + const ratio = (currentSize.width / currentSize.height).toPrecision(2); + const expectedRatio = (ratiox / ratioy).toPrecision(2); + if (currentSize.width > maxWidth || currentSize.width < minWidth || ratio != expectedRatio) + throw new Error('Wrong file dimensions'); } catch (err) { if (err instanceof Error && err.message) return reply.status(400).send({ error: err.message }); diff --git a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts index b0b9451..91e61b1 100644 --- a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts +++ b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts @@ -77,6 +77,7 @@ const profilePictureRoutes = async (fastify: FastifyInstance) => { }); fastify.post('/', { schema: { + consumes: ['multipart/form-data'], response: { 200: { type: 'object', diff --git a/nextjs/matcha/public/rotate-option-svgrepo-com.svg b/nextjs/matcha/public/rotate-option-svgrepo-com.svg new file mode 100644 index 0000000..cfe45c3 --- /dev/null +++ b/nextjs/matcha/public/rotate-option-svgrepo-com.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/nextjs/matcha/src/components/homepage/FloatingCard.tsx b/nextjs/matcha/src/components/homepage/FloatingCard.tsx index c15bcb4..f12a450 100644 --- a/nextjs/matcha/src/components/homepage/FloatingCard.tsx +++ b/nextjs/matcha/src/components/homepage/FloatingCard.tsx @@ -76,7 +76,6 @@ export default function FloatingCard({ // Check collision with other cards using AABB (Axis-Aligned Bounding Box) let collisionDetected = false; const otherCards = getOtherCards(); - console.log("otherCards:", otherCards); otherCards.forEach((other) => { // Rectangle collision detection diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index 9e36b92..438549d 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -6,23 +6,9 @@ import ErrorModal from "@/components/common/ErrorModal"; import { MAX_ADDITIONAL_PICTURES } from "@/constants/onboarding"; import Cropper from 'react-easy-crop' import getCroppedImg from '@/utils/cropImage'; -import RangeSlider from "@/components/common/RangeSlider"; - -// declare type Size = { -// width: number; -// height: number; -// }; -// declare type MediaSize = { -// width: number; -// height: number; -// naturalWidth: number; -// naturalHeight: number; -// }; - -declare type Point = { - x: number; - y: number; -}; +import Button from '../../common/Button' +import IconButton from "@/components/common/IconButton"; + declare type Area = { width: number; height: number; @@ -147,6 +133,7 @@ export default function PicturesStep({ } onChange("profilePicture", file); + setCurrentCroppingIndex(0); }; const handleAdditionalPictureChange = (index: number, file: File | null) => { @@ -172,6 +159,7 @@ export default function PicturesStep({ const newPictures = [...additionalPictures]; newPictures[index] = file; onChange("additionalPictures", newPictures); + setCurrentCroppingIndex(index + 1); }; const removeProfilePicture = () => { @@ -216,6 +204,11 @@ export default function PicturesStep({ const pictureURL = getImageUrl(picture); if (pictureURL == null) return; + if (croppedAreaPixels.width < 150) + return showError("La largeur minimale est de 150 pixels"); + if (croppedAreaPixels.width > 1080) + return showError("La largeur maximale est de 1080 pixels"); + const croppedImage = await getCroppedImg( pictureURL, croppedAreaPixels as Area, @@ -236,6 +229,8 @@ export default function PicturesStep({ return newAdditionalPictures; })() ); + setRotation(0); + setZoom(1); } catch (e) { console.error(e) } @@ -330,23 +325,41 @@ export default function PicturesStep({ )}
- {currentCroppingIndex !== null && (<> + {currentCroppingIndex !== null && (
- {/* TODO create slider component */} - - -
- +
+ setRotation((rotation + 90) % 180)} + size="small" + aria-label="Rotate right" + > +
\-
+
+ setRotation((rotation + 90) % 180)} + size="small" + aria-label="Rotate right" + > +
-/
+
+
+
+ + +
+
)}
diff --git a/nextjs/matcha/src/hooks/useOnboarding.ts b/nextjs/matcha/src/hooks/useOnboarding.ts index d5e5162..8a834d9 100644 --- a/nextjs/matcha/src/hooks/useOnboarding.ts +++ b/nextjs/matcha/src/hooks/useOnboarding.ts @@ -92,7 +92,7 @@ export const useOnboarding = () => { const formData = new FormData(); formData.append('file', data.profilePicture); formData.append('rotation', data.profilePictureSettings.rotation.toString()); - formData.append('scrop', JSON.stringify(data.profilePictureSettings.crop)); + formData.append('crop', JSON.stringify(data.profilePictureSettings.crop)); await fetch('/api/private/user/me/profile-picture', { method: 'POST', @@ -105,7 +105,7 @@ export const useOnboarding = () => { const formData = new FormData(); formData.append('file', picture); formData.append('rotation', data.additionalPicturesSettings[data.additionalPictures.indexOf(picture)].rotation.toString()); - formData.append('scrop', JSON.stringify(data.additionalPicturesSettings[data.additionalPictures.indexOf(picture)].crop)); + formData.append('crop', JSON.stringify(data.additionalPicturesSettings[data.additionalPictures.indexOf(picture)].crop)); await fetch('/api/private/user/me/profile-picture', { method: 'POST', diff --git a/nextjs/matcha/src/utils/cropImage.ts b/nextjs/matcha/src/utils/cropImage.ts index 97a4250..d20f200 100644 --- a/nextjs/matcha/src/utils/cropImage.ts +++ b/nextjs/matcha/src/utils/cropImage.ts @@ -12,34 +12,28 @@ type Point = { export const createImage = (url: string) => new Promise((resolve, reject) => { - const image = new Image() - image.addEventListener('load', () => resolve(image)) - image.addEventListener('error', (error) => reject(error)) - image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox - image.src = url - }) + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox + image.src = url; + }); export function getRadianAngle(degreeValue: number) { return (degreeValue * Math.PI) / 180 } -/** - * Returns the new bounding area of a rotated rectangle. - */ export function rotateSize(width: number, height: number, rotation: number) { - const rotRad = getRadianAngle(rotation) + const rotRad = getRadianAngle(rotation); return { width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), - } + }; } -/** - * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop - */ export default async function getCroppedImg( imageSrc: string, pixelCrop: Area, @@ -51,44 +45,39 @@ export default async function getCroppedImg( const ctx = canvas.getContext('2d') if (!ctx) { - return null + return null; } - const rotRad = getRadianAngle(rotation) + const rotRad = getRadianAngle(rotation); + console.log('rotRad', rotRad); - // calculate bounding box of the rotated image const { width: bBoxWidth, height: bBoxHeight } = rotateSize( image.width as number, image.height as number, rotation - ) + ); - // set canvas size to match the bounding box - canvas.width = bBoxWidth - canvas.height = bBoxHeight + canvas.width = bBoxWidth; + canvas.height = bBoxHeight; - // translate canvas context to a central location to allow rotating and flipping around the center - ctx.translate(bBoxWidth / 2, bBoxHeight / 2) - ctx.rotate(rotRad) - ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1) - ctx.translate(-image.width / 2, -image.height / 2) + ctx.translate(bBoxWidth / 2, bBoxHeight / 2); + ctx.rotate(rotRad);; + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); + ctx.translate(-image.width / 2, -image.height / 2); - // draw rotated image - ctx.drawImage(image, 0, 0) + ctx.drawImage(image, 0, 0); - const croppedCanvas = document.createElement('canvas') + const croppedCanvas = document.createElement('canvas'); - const croppedCtx = croppedCanvas.getContext('2d') + const croppedCtx = croppedCanvas.getContext('2d'); if (!croppedCtx) { - return null + return null; } - // Set the size of the cropped canvas - croppedCanvas.width = pixelCrop.width - croppedCanvas.height = pixelCrop.height + croppedCanvas.width = pixelCrop.width; + croppedCanvas.height = pixelCrop.height; - // Draw the cropped image onto the new canvas croppedCtx.drawImage( canvas, pixelCrop.x, @@ -99,19 +88,17 @@ export default async function getCroppedImg( 0, pixelCrop.width, pixelCrop.height - ) + ); - // As Base64 string // return croppedCanvas.toDataURL('image/jpeg'); - // As a blob return new Promise((resolve, reject) => { croppedCanvas.toBlob((file: Blob | null) => { if (file) { - resolve(URL.createObjectURL(file)) + resolve(URL.createObjectURL(file)); } else { - reject(new Error('Canvas is empty')) + reject(new Error('Canvas is empty')); } - }, 'image/jpeg') - }) + }, 'image/jpeg'); + }); } From 9415c2e1d7ebe495664fe4a1d7f185672a7ac60b Mon Sep 17 00:00:00 2001 From: YoungMame Date: Mon, 1 Dec 2025 13:47:05 +0100 Subject: [PATCH 07/13] remove:(rotate svg) --- nextjs/matcha/public/rotate-option-svgrepo-com.svg | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 nextjs/matcha/public/rotate-option-svgrepo-com.svg diff --git a/nextjs/matcha/public/rotate-option-svgrepo-com.svg b/nextjs/matcha/public/rotate-option-svgrepo-com.svg deleted file mode 100644 index cfe45c3..0000000 --- a/nextjs/matcha/public/rotate-option-svgrepo-com.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file From 0aaec9942458c811414125a35acd0096af923d8a Mon Sep 17 00:00:00 2001 From: YoungMame Date: Mon, 1 Dec 2025 14:09:55 +0100 Subject: [PATCH 08/13] add:(refacto in scropper in component) --- .../src/components/common/ImageCropper.tsx | 67 +++++++++++++++++++ .../onboarding/steps/PicturesStep.tsx | 55 +++++---------- nextjs/matcha/src/utils/getImageUrl.ts | 3 + 3 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 nextjs/matcha/src/components/common/ImageCropper.tsx create mode 100644 nextjs/matcha/src/utils/getImageUrl.ts diff --git a/nextjs/matcha/src/components/common/ImageCropper.tsx b/nextjs/matcha/src/components/common/ImageCropper.tsx new file mode 100644 index 0000000..1421f09 --- /dev/null +++ b/nextjs/matcha/src/components/common/ImageCropper.tsx @@ -0,0 +1,67 @@ +import Cropper from "react-easy-crop"; +import IconButton from "@/components/common/IconButton"; +import Button from "@/components/common/Button"; +import getImageUrl from '@/utils/getImageUrl'; + +const CROP_AREA_ASPECT = 9 / 16; + +export default function ImageCropper({ + profilePicture, + additionalPictures, + currentCroppingIndex, + onCropComplete, + submitImage, + zoom, + setZoom, + rotation, + setRotation, + crop, setCrop +}: { + profilePicture: File | null; + additionalPictures: (File | null)[]; + currentCroppingIndex: number; + onCropComplete: (croppedArea: any, croppedAreaPixels: any) => void; + submitImage: () => void; + zoom: number; + setZoom: (zoom: number) => void; + rotation: number; + setRotation: (rotation: number) => void; + crop: { x: number; y: number }; + setCrop: (crop: { x: number; y: number }) => void; +}) { + return (
+
+ +
+
+ setRotation((rotation + 90) % 180)} + size="small" + aria-label="Rotate right" + > +
\-
+
+ setRotation((rotation + 90) % 180)} + size="small" + aria-label="Rotate right" + > +
-/
+
+
+
+ + +
+
); +} \ No newline at end of file diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index 438549d..6eff3ba 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -6,8 +6,10 @@ import ErrorModal from "@/components/common/ErrorModal"; import { MAX_ADDITIONAL_PICTURES } from "@/constants/onboarding"; import Cropper from 'react-easy-crop' import getCroppedImg from '@/utils/cropImage'; +import getImageUrl from '@/utils/getImageUrl'; import Button from '../../common/Button' import IconButton from "@/components/common/IconButton"; +import ImageCropper from "@/components/common/ImageCropper"; declare type Area = { width: number; @@ -176,10 +178,6 @@ export default function PicturesStep({ } }; - const getImageUrl = (file: File | null): string | null => { - return file ? URL.createObjectURL(file) : null; - }; - const hasError = showValidation && !profilePicture; const [crop, setCrop] = useState({ x: 0, y: 0 }); @@ -325,42 +323,19 @@ export default function PicturesStep({ )} - {currentCroppingIndex !== null && (
-
- -
-
- setRotation((rotation + 90) % 180)} - size="small" - aria-label="Rotate right" - > -
\-
-
- setRotation((rotation + 90) % 180)} - size="small" - aria-label="Rotate right" - > -
-/
-
-
-
- - -
-
- )} + {currentCroppingIndex !== null && } diff --git a/nextjs/matcha/src/utils/getImageUrl.ts b/nextjs/matcha/src/utils/getImageUrl.ts new file mode 100644 index 0000000..2164bc6 --- /dev/null +++ b/nextjs/matcha/src/utils/getImageUrl.ts @@ -0,0 +1,3 @@ +export default function getImageUrl(file: File | null): string | null { + return file ? URL.createObjectURL(file) : null; +}; \ No newline at end of file From 4afdee020e61db134dd195f5e6167e7bdb0ec47f Mon Sep 17 00:00:00 2001 From: ValentinMalassigne Date: Thu, 18 Dec 2025 07:07:49 +0100 Subject: [PATCH 09/13] fix useHeader --- .gitignore | 5 ++++- nextjs/matcha/src/components/browsing/UserHeader.tsx | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7431b26..890bec9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ # production /nextjs/matcha/build -.github \ No newline at end of file +.github + +node_modules +.DS_Store \ No newline at end of file diff --git a/nextjs/matcha/src/components/browsing/UserHeader.tsx b/nextjs/matcha/src/components/browsing/UserHeader.tsx index 6d4da15..7469e5e 100644 --- a/nextjs/matcha/src/components/browsing/UserHeader.tsx +++ b/nextjs/matcha/src/components/browsing/UserHeader.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import Typography from "@/components/common/Typography"; import Stack from "@/components/common/Stack"; import IconButton from "@/components/common/IconButton"; -import Link from "next/dist/client/link"; interface UserHeaderProps { username: string; From ece69cba255e953bd4f954b72f4241fd960d5213 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 07:21:03 +0100 Subject: [PATCH 10/13] Fix image rotation to use 360 degrees instead of 180 (#61) * Initial plan * Fix image rotation to use 360 degrees instead of 180 Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com> * Remove package-lock.json from tracking Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com> * Address code review feedback: use strict equality, proper types, and rotation icons Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com> * Remove npm package-lock.json (project uses pnpm) Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com> * Extract image selection logic and add configurable output format Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com> * Restore original package-lock.json from main branch Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com> Co-authored-by: ValentinMalassigne --- nextjs/matcha/package.json | 2 +- .../src/components/common/ImageCropper.tsx | 33 +++- nextjs/matcha/src/utils/cropImage.ts | 187 +++++++++--------- nextjs/matcha/src/utils/getImageUrl.ts | 5 +- 4 files changed, 124 insertions(+), 103 deletions(-) diff --git a/nextjs/matcha/package.json b/nextjs/matcha/package.json index dac7959..80fa505 100644 --- a/nextjs/matcha/package.json +++ b/nextjs/matcha/package.json @@ -17,7 +17,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-easy-crop": "^5.5.6", - "react-image-crop": "^11.0.10" + "react-image-crop": "^11.0.10", }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/nextjs/matcha/src/components/common/ImageCropper.tsx b/nextjs/matcha/src/components/common/ImageCropper.tsx index 1421f09..e3a4b11 100644 --- a/nextjs/matcha/src/components/common/ImageCropper.tsx +++ b/nextjs/matcha/src/components/common/ImageCropper.tsx @@ -1,7 +1,10 @@ +"use client"; + import Cropper from "react-easy-crop"; import IconButton from "@/components/common/IconButton"; import Button from "@/components/common/Button"; import getImageUrl from '@/utils/getImageUrl'; +import type { Area } from '@/utils/cropImage'; const CROP_AREA_ASPECT = 9 / 16; @@ -20,7 +23,7 @@ export default function ImageCropper({ profilePicture: File | null; additionalPictures: (File | null)[]; currentCroppingIndex: number; - onCropComplete: (croppedArea: any, croppedAreaPixels: any) => void; + onCropComplete: (croppedArea: Area, croppedAreaPixels: Area) => void; submitImage: () => void; zoom: number; setZoom: (zoom: number) => void; @@ -29,10 +32,20 @@ export default function ImageCropper({ crop: { x: number; y: number }; setCrop: (crop: { x: number; y: number }) => void; }) { + // Get the current image based on index (0 = profile picture, 1+ = additional pictures) + const getCurrentImage = (): File | null => { + if (currentCroppingIndex === 0) { + return profilePicture; + } + return additionalPictures[currentCroppingIndex - 1] || null; + }; + + const imageUrl = getImageUrl(getCurrentImage()) || "none"; + return (
setRotation((rotation + 90) % 180)} + onClick={() => setRotation((rotation - 90 + 360) % 360)} size="small" - aria-label="Rotate right" + aria-label="Rotate left" > -
\-
+ + +
setRotation((rotation + 90) % 180)} + onClick={() => setRotation((rotation + 90) % 360)} size="small" aria-label="Rotate right" > -
-/
+ + +
@@ -64,4 +81,4 @@ export default function ImageCropper({
); -} \ No newline at end of file +} diff --git a/nextjs/matcha/src/utils/cropImage.ts b/nextjs/matcha/src/utils/cropImage.ts index d20f200..66bdd5e 100644 --- a/nextjs/matcha/src/utils/cropImage.ts +++ b/nextjs/matcha/src/utils/cropImage.ts @@ -1,104 +1,105 @@ -type Area = { - x: number - y: number - width: number - height: number +/** + * Create a cropped image from a source image URL + */ +export const createImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export function getRadianAngle(degreeValue: number): number { + return (degreeValue * Math.PI) / 180; } -type Point = { - x: number - y: number +export function rotateSize(width: number, height: number, rotation: number): { width: number; height: number } { + const rotRad = getRadianAngle(rotation); + return { + width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + }; } -export const createImage = (url: string) => - new Promise((resolve, reject) => { - const image = new Image(); - image.addEventListener('load', () => resolve(image)); - image.addEventListener('error', (error) => reject(error)); - image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox - image.src = url; - }); - -export function getRadianAngle(degreeValue: number) { - return (degreeValue * Math.PI) / 180 +export interface Area { + width: number; + height: number; + x: number; + y: number; } -export function rotateSize(width: number, height: number, rotation: number) { - const rotRad = getRadianAngle(rotation); +export type ImageFormat = 'image/jpeg' | 'image/png' | 'image/webp'; - return { - width: - Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), - height: - Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), - }; +export interface CropOptions { + format?: ImageFormat; + quality?: number; } +/** + * Returns a cropped image as a data URL + * @param imageSrc - Source image URL + * @param pixelCrop - Crop area in pixels + * @param rotation - Rotation in degrees (0-360) + * @param flip - Flip options + * @param options - Output format and quality options + */ export default async function getCroppedImg( - imageSrc: string, - pixelCrop: Area, - rotation = 0, - flip = { horizontal: false, vertical: false } -) { - const image = await createImage(imageSrc) - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - - if (!ctx) { - return null; - } - - const rotRad = getRadianAngle(rotation); - console.log('rotRad', rotRad); - - const { width: bBoxWidth, height: bBoxHeight } = rotateSize( - image.width as number, - image.height as number, - rotation - ); - - canvas.width = bBoxWidth; - canvas.height = bBoxHeight; - - ctx.translate(bBoxWidth / 2, bBoxHeight / 2); - ctx.rotate(rotRad);; - ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); - ctx.translate(-image.width / 2, -image.height / 2); - - ctx.drawImage(image, 0, 0); - - const croppedCanvas = document.createElement('canvas'); - - const croppedCtx = croppedCanvas.getContext('2d'); - - if (!croppedCtx) { - return null; - } - - croppedCanvas.width = pixelCrop.width; - croppedCanvas.height = pixelCrop.height; - - croppedCtx.drawImage( - canvas, - pixelCrop.x, - pixelCrop.y, - pixelCrop.width, - pixelCrop.height, - 0, - 0, - pixelCrop.width, - pixelCrop.height - ); - - // return croppedCanvas.toDataURL('image/jpeg'); - - return new Promise((resolve, reject) => { - croppedCanvas.toBlob((file: Blob | null) => { - if (file) { - resolve(URL.createObjectURL(file)); - } else { - reject(new Error('Canvas is empty')); - } - }, 'image/jpeg'); - }); + imageSrc: string, + pixelCrop: Area, + rotation = 0, + flip = { horizontal: false, vertical: false }, + options: CropOptions = {} +): Promise { + const { format = 'image/jpeg', quality = 0.9 } = options; + + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return null; + } + + const rotRad = getRadianAngle(rotation); + + const { width: bBoxWidth, height: bBoxHeight } = rotateSize( + image.width, + image.height, + rotation + ); + + canvas.width = bBoxWidth; + canvas.height = bBoxHeight; + + ctx.translate(bBoxWidth / 2, bBoxHeight / 2); + ctx.rotate(rotRad); + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); + ctx.translate(-image.width / 2, -image.height / 2); + + ctx.drawImage(image, 0, 0); + + const croppedCanvas = document.createElement('canvas'); + const croppedCtx = croppedCanvas.getContext('2d'); + + if (!croppedCtx) { + return null; + } + + croppedCanvas.width = pixelCrop.width; + croppedCanvas.height = pixelCrop.height; + + croppedCtx.drawImage( + canvas, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + return croppedCanvas.toDataURL(format, quality); } diff --git a/nextjs/matcha/src/utils/getImageUrl.ts b/nextjs/matcha/src/utils/getImageUrl.ts index 2164bc6..d598760 100644 --- a/nextjs/matcha/src/utils/getImageUrl.ts +++ b/nextjs/matcha/src/utils/getImageUrl.ts @@ -1,3 +1,6 @@ +/** + * Convert a File object to a data URL for display + */ export default function getImageUrl(file: File | null): string | null { return file ? URL.createObjectURL(file) : null; -}; \ No newline at end of file +} From b1ea979f18aecba932f04b02286927b69fb3a7f0 Mon Sep 17 00:00:00 2001 From: ValentinMalassigne Date: Thu, 18 Dec 2025 07:34:36 +0100 Subject: [PATCH 11/13] remove trailing comma in package.json --- nextjs/matcha/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextjs/matcha/package.json b/nextjs/matcha/package.json index 80fa505..dac7959 100644 --- a/nextjs/matcha/package.json +++ b/nextjs/matcha/package.json @@ -17,7 +17,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-easy-crop": "^5.5.6", - "react-image-crop": "^11.0.10", + "react-image-crop": "^11.0.10" }, "devDependencies": { "@eslint/eslintrc": "^3", From 36cf9748a8292bef3f47ac9f706f42caefa95f8f Mon Sep 17 00:00:00 2001 From: YoungMame Date: Thu, 18 Dec 2025 14:42:27 +0100 Subject: [PATCH 12/13] add:(some client side data validation) --- .../routes/private/user/me/completeProfile.ts | 8 +-- .../srcs/routes/private/user/me/profile.ts | 15 +++--- nextjs/matcha/src/app/(logged)/me/page.tsx | 51 +++++++++++++++++-- .../onboarding/steps/IdentityStep.tsx | 6 ++- nextjs/matcha/src/hooks/useOnboarding.ts | 6 ++- 5 files changed, 67 insertions(+), 19 deletions(-) diff --git a/fastify/assets/srcs/routes/private/user/me/completeProfile.ts b/fastify/assets/srcs/routes/private/user/me/completeProfile.ts index 766af0d..cfab1b8 100644 --- a/fastify/assets/srcs/routes/private/user/me/completeProfile.ts +++ b/fastify/assets/srcs/routes/private/user/me/completeProfile.ts @@ -29,10 +29,10 @@ const completeProfileRoutes = async (fastify: FastifyInstance) => { body: { type: 'object', properties: { - firstName: { type: 'string', minLength: 2, maxLength: 50 }, - lastName: { type: 'string', minLength: 2, maxLength: 50 }, - bio: { type: 'string', minLength: 2, maxLength: 100 }, - tags: { type: 'array', items: { type: 'string' }, minItems: 1 }, + firstName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, + lastName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, + bio: { type: 'string', minLength: 50, maxLength: 500 }, + tags: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 30, pattern: '[a-zA-Z_]' }, minItems: 3 }, gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual', 'other'] }, bornAt: { type: 'string', format: 'date-time' }, diff --git a/fastify/assets/srcs/routes/private/user/me/profile.ts b/fastify/assets/srcs/routes/private/user/me/profile.ts index fa31577..75fee9b 100644 --- a/fastify/assets/srcs/routes/private/user/me/profile.ts +++ b/fastify/assets/srcs/routes/private/user/me/profile.ts @@ -4,23 +4,22 @@ import { setProfileHandler, getProfileHandler } from "../../../../controllers/pr const profileRoutes = async (fastify: FastifyInstance) => { fastify.put('/', { schema: { - body: { type: 'object', properties: { - firstName: { type: 'string' }, - lastName: { type: 'string' }, + firstName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, + lastName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, email: { type: 'string', format: 'email' }, - bio: { type: 'string', minLength: 50, maxLength: 100 }, - tags: { type: 'array', items: { type: 'string' }, minItems: 1 }, + bio: { type: 'string', minLength: 50, maxLength: 500 }, + tags: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 30, pattern: '[a-zA-Z_]' }, minItems: 3 }, gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual', 'other'] }, bornAt: { type: 'string', format: 'date-time' }, location: { type: 'object', properties: { - latitude: { type: 'number' }, - longitude: { type: 'number' } + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 } }, additionalProperties: false } @@ -64,8 +63,6 @@ const profileRoutes = async (fastify: FastifyInstance) => { firstName: { type: 'string' }, lastName: { type: 'string' }, username: { type: 'string', minLength: 2, maxLength: 100 }, - firstName: { type: 'string', maxLength: 50 }, - lastName: { type: 'string', maxLength: 50 }, profilePictureIndex: { type: 'integer' }, profilePictures: { type: 'array', items: { type: 'string', format: 'uri' } }, bio: { type: 'string', maxLength: 100 }, diff --git a/nextjs/matcha/src/app/(logged)/me/page.tsx b/nextjs/matcha/src/app/(logged)/me/page.tsx index fb4c54b..3829e7e 100644 --- a/nextjs/matcha/src/app/(logged)/me/page.tsx +++ b/nextjs/matcha/src/app/(logged)/me/page.tsx @@ -145,7 +145,7 @@ import InterestsStep from "@/components/onboarding/steps/InterestsStep"; export default function MyProfilePage() { const router = useRouter(); const queryClient = useQueryClient(); - const { data: profile, isLoading, error } = useMyProfile(); + const { data: profile, isLoading, error, refetch } = useMyProfile(); const [isUpdating, setIsUpdating] = useState(false); const [isUploading, setIsUploading] = useState(false); @@ -167,6 +167,8 @@ export default function MyProfilePage() { useEffect(() => { if (profile) { + console.log('Profile data loaded into form:', profile) + console.log('Form data before set:', formData) setFormData({ firstName: profile.firstName || "", lastName: profile.lastName || "", @@ -183,8 +185,8 @@ export default function MyProfilePage() { const handleSave = () => { setValidationError(null); - if (formData.tags.length < 3) { - setValidationError("Vous devez sélectionner au moins 3 centres d'intérêt."); + if (formData.tags.length < 1) { + setValidationError("Vous devez sélectionner au moins 1 centre d'intérêt."); return; } @@ -192,12 +194,55 @@ export default function MyProfilePage() { setValidationError("Votre bio doit contenir au moins 50 caractères."); return; } + if (formData.bio.trim().length > 500) { + setValidationError("Votre bio doit contenir au maximum 500 caractères."); + return; + } if (!formData.firstName.trim() || !formData.lastName.trim() || !formData.email.trim()) { setValidationError("Veuillez remplir tous les champs obligatoires (Prénom, Nom, Email)."); return; } + if (!formData.firstName.trim().match(/^[a-zA-Z-\' ]+$/)) { + setValidationError("Le prénom contient des caractères invalides."); + return; + } + + if (formData.firstName.trim().length > 50 || formData.firstName.trim().length < 1) { + setValidationError("Le prénom doit contenir entre 1 et 50 caractères."); + return; + } + + if (!formData.lastName.trim().match(/^[a-zA-Z-\' ]+$/)) { + setValidationError("Le nom contient des caractères invalides."); + return; + } + + if (formData.lastName.trim().length > 50 || formData.lastName.trim().length < 1) { + setValidationError("Le nom doit contenir entre 1 et 50 caractères."); + return; + } + + if (!formData.email.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) { + setValidationError("L'email est invalide."); + return; + } + + if (formData.bornAt) { + const bornDate = new Date(formData.bornAt); + if (isNaN(bornDate.getTime())) { + setValidationError("La date de naissance est invalide."); + return; + } + const today = new Date(); + const eighteenYearsAgo = today.setFullYear(today.getFullYear() - 18); + if (eighteenYearsAgo - bornDate.getTime() < 0) { + setValidationError("Vous devez avoir au moins 18 ans."); + return; + } + } + const updateData = async () => { setIsUpdating(true); try { diff --git a/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx b/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx index 0e0559c..f31e568 100644 --- a/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx @@ -33,6 +33,8 @@ export default function IdentityStep({ const hasFirstNameError = showValidation && !firstName.trim(); const hasLastNameError = showValidation && !lastName.trim(); + const lastNameRegexError = showValidation && !lastName.trim().match(/^[a-zA-Z-']+$/); + const firstNameRegexError = showValidation && !firstName.trim().match(/^[a-zA-Z-']+$/); const hasBirthdayError = showValidation && !birthday; const hasBiographyError = showValidation && (!biography.trim() || biography.trim().length < 50); @@ -46,7 +48,7 @@ export default function IdentityStep({ onChange={(e) => onChange("firstName", e.target.value)} placeholder="Entrez votre prénom" required - error={hasFirstNameError ? "Le prénom est requis" : undefined} + error={(firstNameRegexError ? "Le prénom contient des caractères invalides" : undefined) || (hasFirstNameError ? "Le prénom est requis" : undefined)} /> onChange("lastName", e.target.value)} placeholder="Entrez votre nom de famille" required - error={hasLastNameError ? "Le nom de famille est requis" : undefined} + error={(lastNameRegexError ? "Le nom de famille contient des caractères invalides" : undefined) || (hasLastNameError ? "Le nom de famille est requis" : undefined)} />
diff --git a/nextjs/matcha/src/hooks/useOnboarding.ts b/nextjs/matcha/src/hooks/useOnboarding.ts index 308b6a7..febbace 100644 --- a/nextjs/matcha/src/hooks/useOnboarding.ts +++ b/nextjs/matcha/src/hooks/useOnboarding.ts @@ -56,9 +56,13 @@ export const useOnboarding = () => { return !!( data.firstName.trim() && data.lastName.trim() && + data.firstName.trim().length >= 1 && data.firstName.trim().length <= 50 && + data.lastName.trim().length >= 1 && data.lastName.trim().length <= 50 && data.birthday && data.biography.trim() && - data.biography.trim().length >= 50 + data.biography.trim().length >= 50 && data.biography.trim().length <= 500 && + data.firstName.trim().match(/^[a-zA-Z-\']+$/) && + data.lastName.trim().match(/^[a-zA-Z-\']+$/) ); case 'interests': return data.interests.length >= MIN_INTERESTS; From 7655afab8a7d1db85e56fec2e3f845264cdd2750 Mon Sep 17 00:00:00 2001 From: YoungMame Date: Thu, 18 Dec 2025 14:44:52 +0100 Subject: [PATCH 13/13] add:(clean comments) --- nextjs/matcha/src/app/(logged)/me/page.tsx | 2 -- nextjs/matcha/src/app/onboarding/page.tsx | 1 - nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx | 1 - nextjs/matcha/src/hooks/useOnboarding.ts | 1 - nextjs/matcha/src/lib/api/browsing.ts | 1 - 5 files changed, 6 deletions(-) diff --git a/nextjs/matcha/src/app/(logged)/me/page.tsx b/nextjs/matcha/src/app/(logged)/me/page.tsx index 3829e7e..96da1a2 100644 --- a/nextjs/matcha/src/app/(logged)/me/page.tsx +++ b/nextjs/matcha/src/app/(logged)/me/page.tsx @@ -167,8 +167,6 @@ export default function MyProfilePage() { useEffect(() => { if (profile) { - console.log('Profile data loaded into form:', profile) - console.log('Form data before set:', formData) setFormData({ firstName: profile.firstName || "", lastName: profile.lastName || "", diff --git a/nextjs/matcha/src/app/onboarding/page.tsx b/nextjs/matcha/src/app/onboarding/page.tsx index f8a9d20..ead4d31 100644 --- a/nextjs/matcha/src/app/onboarding/page.tsx +++ b/nextjs/matcha/src/app/onboarding/page.tsx @@ -68,7 +68,6 @@ export default function OnboardingPage() { try { await submitOnboarding(); - console.log("Onboarding submitted successfully"); router.push("/browsing"); } catch (error: any) { console.error("Failed to submit onboarding:", error); diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index 6eff3ba..ab9bab5 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -212,7 +212,6 @@ export default function PicturesStep({ croppedAreaPixels as Area, rotation ) - console.log('donee', { croppedImage }) const newCroppedImages = [...croppedImages]; newCroppedImages[currentCroppingIndex] = croppedImage as string; setCroppedImages(newCroppedImages); diff --git a/nextjs/matcha/src/hooks/useOnboarding.ts b/nextjs/matcha/src/hooks/useOnboarding.ts index febbace..fa7b8c3 100644 --- a/nextjs/matcha/src/hooks/useOnboarding.ts +++ b/nextjs/matcha/src/hooks/useOnboarding.ts @@ -145,7 +145,6 @@ export const useOnboarding = () => { : 'heterosexual' as const, bornAt: new Date(data.birthday).toISOString(), }; - console.log('Submitting profile data:', profileData); const response = await profileApi.completeProfile(profileData); await profileApi.updateProfile({ diff --git a/nextjs/matcha/src/lib/api/browsing.ts b/nextjs/matcha/src/lib/api/browsing.ts index 4a999fc..c86bdb2 100644 --- a/nextjs/matcha/src/lib/api/browsing.ts +++ b/nextjs/matcha/src/lib/api/browsing.ts @@ -25,7 +25,6 @@ export const browsingApi = { let userProfile; try { userProfile = await profileApi.getMyProfile(); - console.log("Fetched user profile for defaults:", userProfile); } catch (error) { console.error('Failed to fetch user profile for defaults', error); }