From 4b0cb4e5a97933f48f9941cf1dccfbcbac4f3981 Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Thu, 3 Sep 2026 00:29:19 +0300 Subject: [PATCH] fixed get profile avatar and get user from back --- src/components/profile/ProfileModal.jsx | 15 ++- src/hooks/useGeolocation.js | 131 ++++++++++++++++++++++++ src/hooks/useImageUpload.js | 10 +- src/hooks/useUpdateProfile.js | 13 ++- src/locales/en.json | 5 +- src/locales/ru.json | 5 +- src/pages/MainFeed.jsx | 29 +++++- src/pages/ReviewsPage.jsx | 31 +++++- src/styles/modal.css | 37 +++++++ 9 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useGeolocation.js diff --git a/src/components/profile/ProfileModal.jsx b/src/components/profile/ProfileModal.jsx index 8480866..db7f146 100644 --- a/src/components/profile/ProfileModal.jsx +++ b/src/components/profile/ProfileModal.jsx @@ -22,6 +22,7 @@ export default function ProfileModal({ onClose, initialData = {} }) { const [errors, setErrors] = useState({}); const existingImageUrl = initialData.avatar || null; + const existingImages = existingImageUrl ? [existingImageUrl] : []; const { images, @@ -32,7 +33,7 @@ export default function ProfileModal({ onClose, initialData = {} }) { removeImage, getExistingUrlsToKeep, getFilesForUpload, - } = useImageUpload(existingImageUrl); + } = useImageUpload(existingImages); const currentImage = images[0]; const displayImage = currentImage?.preview ?? null; @@ -58,15 +59,21 @@ export default function ProfileModal({ onClose, initialData = {} }) { return; } - const newAvatars = getFilesForUpload(); - const keptExisting = getExistingUrlsToKeep(); + const newAvatars = getFilesForUpload(); + const keptExisting = getExistingUrlsToKeep(); + + // Пользователь удалил старый аватар и не выбрал новый — + // явно сигналим бэку, что аватар нужно снести. + const removeAvatar = !!existingImageUrl && newAvatars.length === 0 && keptExisting.length === 0; + const profileData = { firstName: form.firstName.trim(), lastName: form.lastName.trim(), login: form.login.trim(), phone: form.phone.trim(), email: form.email.trim(), - avatar: newAvatars.length > 0 ? newAvatars[0] : (keptExisting.length > 0 ? keptExisting[0] : null), + avatar: newAvatars.length > 0 ? newAvatars[0] : null, + removeAvatar, }; const result = await updateProfile(user?.id, profileData); diff --git a/src/hooks/useGeolocation.js b/src/hooks/useGeolocation.js new file mode 100644 index 0000000..e5b9ae4 --- /dev/null +++ b/src/hooks/useGeolocation.js @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export const GEO_STATUS = { + IDLE: "idle", + LOADING: "loading", + SUCCESS: "success", + DENIED: "denied", + UNSUPPORTED: "unsupported", + TIMEOUT: "timeout", +}; + +const DEFAULT_TIMEOUT = 10000; + +/** + * Определяет код ошибки геолокации в понятный статус. + * @param {string|number} code — код из GeolocationPositionError + */ +function codeToStatus(code) { + switch (code) { + case 1: // PERMISSION_DENIED + return GEO_STATUS.DENIED; + case 2: // POSITION_UNAVAILABLE + return GEO_STATUS.UNSUPPORTED; + case 3: // TIMEOUT + return GEO_STATUS.TIMEOUT; + default: + return GEO_STATUS.UNSUPPORTED; + } +} + +/** + * useGeolocation — переиспользуемый хук для запроса геолокации браузера. + * + * Возвращает: + * - coords { latitude, longitude, accuracy } | null + * - error текст ошибки (для логирования/диагностики) | null + * - status GEO_STATUS.* (loading / success / denied / unsupported / timeout) + * - requestLocation () => void — запустить запрос заново + * + * @param {Object} [options] + * @param {boolean} [options.onMount] запросить геолокацию сразу при монтировании + * @param {boolean} [options.enableHighAccuracy] точный режим (по умолчанию true) + * @param {number} [options.timeout] таймаут в мс (по умолчанию 10000) + */ +export function useGeolocation({ + onMount = false, + enableHighAccuracy = true, + timeout = DEFAULT_TIMEOUT, +} = {}) { + const [coords, setCoords] = useState(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(GEO_STATUS.IDLE); + + const timeoutRef = useRef(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + const requestLocation = useCallback(() => { + // Либо API полностью отсутствует, либо у геолокации нет getCurrentPosition. + if (!("geolocation" in navigator) || !navigator.geolocation) { + setCoords(null); + setError("Geolocation is not supported by this browser"); + setStatus(GEO_STATUS.UNSUPPORTED); + return; + } + + setStatus(GEO_STATUS.LOADING); + setError(null); + + // Страховочный таймер: некоторые браузеры (Safari/WebKit) могут + // «зависать» в pending без вызова колбэков — не держим пользователя. + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + if (!mountedRef.current) return; + setCoords(null); + setError("Geolocation request timed out"); + setStatus(GEO_STATUS.TIMEOUT); + }, timeout); + + const finish = (resolvedCoords) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (!mountedRef.current) return; + + setCoords({ + latitude: resolvedCoords.latitude, + longitude: resolvedCoords.longitude, + accuracy: resolvedCoords.accuracy ?? null, + }); + setError(null); + setStatus(GEO_STATUS.SUCCESS); + }; + + const fail = (err) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (!mountedRef.current) return; + + setCoords(null); + setError(err && err.message ? err.message : "Geolocation failed"); + setStatus(codeToStatus(err && err.code)); + }; + + navigator.geolocation.getCurrentPosition( + (pos) => finish(pos.coords), + fail, + { enableHighAccuracy, timeout, maximumAge: 60000 } + ); + }, [enableHighAccuracy, timeout]); + + useEffect(() => { + if (onMount) requestLocation(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { coords, error, status, requestLocation }; +} diff --git a/src/hooks/useImageUpload.js b/src/hooks/useImageUpload.js index db65780..d29bcc4 100644 --- a/src/hooks/useImageUpload.js +++ b/src/hooks/useImageUpload.js @@ -26,9 +26,15 @@ export function useImageUpload(existingUrls = []) { const fileInputRef = useRef(null); const existingUrlList = Array.isArray(existingUrls) ? existingUrls : []; + const existingUrlsKey = existingUrlList.join("\u0000"); + + // Синхронизируем существующие изображения при изменениях извне (например, + // когда свежие данные профиля подтягиваются с бэка уже после монтирования). + // Не перезаписываем стейт, если пользователь успел выбрать новые файлы. + const hasUserSelected = images.some(img => !img.isExisting); useEffect(() => { - if (existingUrlList.length > 0) { + if (existingUrlList.length > 0 && !hasUserSelected) { setImages(existingUrlList.map((url, i) => ({ id: `existing-${i}`, file: null, @@ -38,7 +44,7 @@ export function useImageUpload(existingUrls = []) { }))); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [existingUrlsKey, hasUserSelected]); const handleFileChange = useCallback(async (e) => { const files = Array.from(e.target.files || []); diff --git a/src/hooks/useUpdateProfile.js b/src/hooks/useUpdateProfile.js index ee466f2..dcba838 100644 --- a/src/hooks/useUpdateProfile.js +++ b/src/hooks/useUpdateProfile.js @@ -35,9 +35,16 @@ export function useUpdateProfile() { formData.append("phone", profileData.phone || ""); formData.append("email", profileData.email || ""); - // Если avatar — это File (новый файл), отправляем как часть FormData - if (profileData.avatar instanceof File) { - formData.append("avatar", profileData.avatar); + // Новый аватар: compressImage может вернуть Blob (а не File), поэтому + // проверяем по Blob — File является его подклассом, так что оба случая покрыты. + // Blob может не иметь .name, а Spring ждёт filename в multipart-части — задаём явно. + if (profileData.avatar instanceof Blob) { + formData.append("avatar", profileData.avatar, profileData.avatar.name || "avatar.jpg"); + } + + // Явное удаление аватара без замены новым файлом + if (profileData.removeAvatar) { + formData.append("removeAvatar", "true"); } const res = await apiFetch(`${API_URL}/api/v1/users/${userId}`, { diff --git a/src/locales/en.json b/src/locales/en.json index f9c5f4f..dc6a052 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -269,7 +269,10 @@ "validatePhoneInvalid": "Invalid phone number", "updateError": "Failed to save the profile", "updateErrorWithStatus": "Failed to save profile (error {{status}})", - "updateMissingUserId": "Could not identify the user" + "updateMissingUserId": "Could not identify the user", + "fetchMissingUserId": "Could not identify the user", + "fetchError": "Failed to load the profile", + "fetchErrorWithStatus": "Failed to load the profile (error {{status}})" }, "validation": { "titleRequired": "Enter a title", diff --git a/src/locales/ru.json b/src/locales/ru.json index fb9f1ea..d70b682 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -270,7 +270,10 @@ "validatePhoneInvalid": "Некорректный номер телефона", "updateError": "Не удалось сохранить профиль", "updateErrorWithStatus": "Не удалось сохранить профиль (ошибка {{status}})", - "updateMissingUserId": "Не удалось определить пользователя" + "updateMissingUserId": "Не удалось определить пользователя", + "fetchMissingUserId": "Не удалось определить пользователя", + "fetchError": "Не удалось загрузить профиль", + "fetchErrorWithStatus": "Не удалось загрузить профиль (ошибка {{status}})" }, "validation": { "titleRequired": "Введите название", diff --git a/src/pages/MainFeed.jsx b/src/pages/MainFeed.jsx index 76ec8b9..99e13db 100644 --- a/src/pages/MainFeed.jsx +++ b/src/pages/MainFeed.jsx @@ -27,6 +27,7 @@ import { useModals } from "../hooks/useModals"; import { useFeedActions } from "../hooks/useFeedActions"; import { useFavorites } from "../hooks/useFavorites"; import { useTranslation } from 'react-i18next'; +import { useProfile } from "../hooks/useProfile"; /** * MainFeed — корневой компонент приложения. @@ -35,7 +36,8 @@ import { useTranslation } from 'react-i18next'; export default function MainFeed() { const navigate = useNavigate(); const location = useLocation(); - const { user, token, isAuthenticated } = useAuthContext(); + const { user, token, isAuthenticated, updateUser } = useAuthContext(); + const { fetchProfile } = useProfile(); const { cities } = useCities(); const { loadFavorites, loadFavoritePosts, error: favoritesError } = useFavorites(); const { t } = useTranslation(); @@ -49,6 +51,7 @@ export default function MainFeed() { const [liked, setLiked] = useState(new Set()); const [likeCountAdj, setLikeCountAdj] = useState({}); const [favoritePosts, setFavoritePosts] = useState([]); + const profileFetchedFor = useRef(null); const [favoritesLoading, setFavoritesLoading] = useState(false); const feedRef = useRef(null); @@ -125,6 +128,30 @@ export default function MainFeed() { } }, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps + // При загрузке приложения подтягиваем свежие данные профиля (аватар и т.д.) + // с бэка, чтобы нижняя навигация и профиль отражали изменения с других + // устройств, а не устаревший кэш контекста. Дергаем один раз на пользователя. + useEffect(() => { + if (isAuthenticated && user?.id && profileFetchedFor.current !== user.id) { + profileFetchedFor.current = user.id; + fetchProfile(user.id).then(result => { + if (result.success) updateUser(result.data); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, user?.id]); + + // При открытии профиля подтягиваем свежие данные (аватар/профиль) с бэка, + // чтобы изменения с других устройств отражались, а не брались из кэша контекста + useEffect(() => { + if (modals.isOpen("profile") && isAuthenticated && user?.id) { + fetchProfile(user.id).then(result => { + if (result.success) updateUser(result.data); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modals.isOpen("profile"), isAuthenticated, user?.id]); + // Загрузка основной ленты (не в режиме поиска) useEffect(() => { if (!isSearchView && selectedCities.length === 0 && !searchQuery.trim()) { diff --git a/src/pages/ReviewsPage.jsx b/src/pages/ReviewsPage.jsx index a5ccb77..ae62750 100644 --- a/src/pages/ReviewsPage.jsx +++ b/src/pages/ReviewsPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { getImageUrl } from "../config/authConstants"; import { useAuthContext } from "../context/AuthContext"; @@ -13,16 +13,19 @@ import RatingSummary from "../components/reviews/RatingSummary"; import { useReviews } from "../hooks/useReviews"; import "../styles/reviews.css"; import { useTranslation } from 'react-i18next'; +import { useProfile } from "../hooks/useProfile"; export default function ReviewsPage() { const { t } = useTranslation(); const navigate = useNavigate(); - const { user, token, isAuthenticated } = useAuthContext(); + const { user, token, isAuthenticated, updateUser } = useAuthContext(); + const { fetchProfile } = useProfile(); const [sidebarOpen, setSidebarOpen] = useState(false); const [profileModalOpen, setProfileModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false); const [rulesModalOpen, setRulesModalOpen] = useState(false); + const profileFetchedFor = useRef(null); const profileData = useMemo(() => { if (!user) return {}; @@ -50,6 +53,30 @@ export default function ReviewsPage() { useEffect(() => { loadReviews(); }, [loadReviews]); + // При загрузке страницы подтягиваем свежие данные профиля (аватар и т.д.) + // с бэка, чтобы навигация отражала изменения с других устройств, а не кэш. + // Дергаем один раз на пользователя. + useEffect(() => { + if (isAuthenticated && user?.id && profileFetchedFor.current !== user.id) { + profileFetchedFor.current = user.id; + fetchProfile(user.id).then(result => { + if (result.success) updateUser(result.data); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, user?.id]); + + // При открытии профиля подтягиваем свежие данные (аватар/профиль) с бэка, + // чтобы изменения с других устройств отражались, а не брались из кэша контекста + useEffect(() => { + if (profileModalOpen && isAuthenticated && user?.id) { + fetchProfile(user.id).then(result => { + if (result.success) updateUser(result.data); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [profileModalOpen, isAuthenticated, user?.id]); + return (