fixed get profile avatar and get user from back

This commit is contained in:
SlimusMinus
2026-09-03 00:29:19 +03:00
parent a5ecf3feb3
commit 4b0cb4e5a9
9 changed files with 262 additions and 14 deletions

View File

@@ -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;
@@ -60,13 +61,19 @@ export default function ProfileModal({ onClose, initialData = {} }) {
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);

131
src/hooks/useGeolocation.js Normal file
View File

@@ -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 };
}

View File

@@ -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 || []);

View File

@@ -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}`, {

View File

@@ -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",

View File

@@ -270,7 +270,10 @@
"validatePhoneInvalid": "Некорректный номер телефона",
"updateError": "Не удалось сохранить профиль",
"updateErrorWithStatus": "Не удалось сохранить профиль (ошибка {{status}})",
"updateMissingUserId": "Не удалось определить пользователя"
"updateMissingUserId": "Не удалось определить пользователя",
"fetchMissingUserId": "Не удалось определить пользователя",
"fetchError": "Не удалось загрузить профиль",
"fetchErrorWithStatus": "Не удалось загрузить профиль (ошибка {{status}})"
},
"validation": {
"titleRequired": "Введите название",

View File

@@ -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()) {

View File

@@ -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 (
<div className="f-app">
<Sidebar

View File

@@ -103,6 +103,43 @@ select.f-m-field::selection { background: transparent; }
.f-img-upload-label:hover { border-color: var(--teal); background: rgba(var(--teal-rgb), .05); color: var(--teal); }
.f-img-input { display: none; }
/* Profile Photo Preview */
.f-img-preview-wrap {
position: relative;
width: 120px;
height: 120px;
border-radius: 12px;
overflow: hidden;
}
.f-img-preview {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.f-img-preview-wrap .f-img-remove {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
background: rgba(0,0,0,0.55);
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
padding: 0;
transition: background .2s;
}
.f-img-preview-wrap .f-img-remove:hover { background: rgba(0,0,0,0.8); }
.f-img-preview-wrap .f-img-remove svg {
width: 12px;
height: 12px;
}
/* Multi-image Grid */
.f-img-grid {
display: grid;