fixed get profile avatar and get user from back
This commit is contained in:
@@ -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
131
src/hooks/useGeolocation.js
Normal 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 };
|
||||
}
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -270,7 +270,10 @@
|
||||
"validatePhoneInvalid": "Некорректный номер телефона",
|
||||
"updateError": "Не удалось сохранить профиль",
|
||||
"updateErrorWithStatus": "Не удалось сохранить профиль (ошибка {{status}})",
|
||||
"updateMissingUserId": "Не удалось определить пользователя"
|
||||
"updateMissingUserId": "Не удалось определить пользователя",
|
||||
"fetchMissingUserId": "Не удалось определить пользователя",
|
||||
"fetchError": "Не удалось загрузить профиль",
|
||||
"fetchErrorWithStatus": "Не удалось загрузить профиль (ошибка {{status}})"
|
||||
},
|
||||
"validation": {
|
||||
"titleRequired": "Введите название",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user