fixed code

This commit is contained in:
SlimusMinus
2026-08-11 00:24:07 +03:00
parent c50b75eece
commit 7f59ee1f38
95 changed files with 3623 additions and 2830 deletions

View File

@@ -1,147 +1,90 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "../styles/index.css";
import { API_URL, getImageUrl } from "../config/authConstants";
import { getImageUrl } from "../config/authConstants";
import Sidebar from "../components/Sidebar";
import RightSidebar from "../components/RightSidebar";
import PostOnFeed from "../components/PostOnFeed";
import CreateUpdatePost from "../components/CreateUpdatePost";
import CreateUpdatePost from "./CreateUpdatePost";
import ImageViewer from "../components/ImageViewer";
import CityFilter from "../components/CityFilter";
import MenuIcon from "../components/icons/MenuIcon";
import ProfileAvatar from "../components/profile/ProfileAvatar";
import { ProfileModal } from "../components/profile";
import AuthModal from "../components/AuthModal";
import RulesModal from "../components/RulesModal";
import FeedTopBar from "../components/feed/FeedTopBar";
import FeedSearchBar from "../components/feed/FeedSearchBar";
import TypeFilterBar from "../components/feed/TypeFilterBar";
import FeedContent from "../components/feed/FeedContent";
import { useAuthContext } from "../context/AuthContext";
import { useDeletePost } from "../hooks/useDeletePost";
import { useFavorites } from "../hooks/useFavorites";
import { usePostStatus } from "../hooks/usePostStatus";
import { useCities } from "../hooks/useCities";
import { useSearchPosts } from "../hooks/useSearchPosts";
import { useFeedPosts } from "../hooks/useFeedPosts";
import { useMyPosts } from "../hooks/useMyPosts";
import { useCityFeed } from "../hooks/useCityFeed";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
import { useModals } from "../hooks/useModals";
import { useFeedActions } from "../hooks/useFeedActions";
import { useFavorites } from "../hooks/useFavorites";
import { useTranslation } from 'react-i18next';
/**
* MainFeed — корневой компонент приложения.
* Управляет состоянием ленты, модалок и сайдбара;
* остальные компоненты получают данные через пропсы.
* Композиция хуков состояния + презентационных компонентов ленты.
*/
export default function MainFeed() {
const navigate = useNavigate();
const location = useLocation();
const { user, token, isAuthenticated } = useAuthContext();
const { deletePost } = useDeletePost();
const { toggleFavorite, loadFavorites } = useFavorites();
const { markAsFound } = usePostStatus();
const { cities } = useCities();
const { loading: cityLoading, searchPosts } = useSearchPosts();
const { loadFavorites } = useFavorites();
const { t } = useTranslation();
const modals = useModals();
// Открыт ли левый сайдбар (мобильный слайд-аут)
const [sidebarOpen, setSidebarOpen] = useState(false);
// Активный пункт навигации в сайдбаре
const [activeNav, setActiveNav] = useState("home");
// Фильтр по типу объявления: null (все) | "loss" | "found"
const [typeFilter, setTypeFilter] = useState(null);
// Выбранные города для фильтра: [] (все) | ["Город", ...]
const [activeNav, setActiveNav] = useState("home");
const [typeFilter, setTypeFilter] = useState(null);
const [selectedCities, setSelectedCities] = useState([]);
// Результаты поиска по выбранным городам (бэк /posts/search)
const [cityAds, setCityAds] = useState([]);
// Поисковый запрос (фильтр по title / description)
const [searchQuery, setSearchQuery] = useState("");
// Открыта ли модалка создания нового объявления
const [modalOpen, setModalOpen] = useState(false);
// Объявление, которое сейчас редактируется (null — модалка закрыта)
const [editAd, setEditAd] = useState(null);
// Открыто ли модальное окно редактирования профиля
const [profileModalOpen, setProfileModalOpen] = useState(false);
// Открыто ли модальное окно аутентификации (login / register)
const [authModalOpen, setAuthModalOpen] = useState(false);
// Открыто ли модальное окно правил площадки
const [rulesModalOpen, setRulesModalOpen] = useState(false);
// Просмотр изображений: null — закрыто, { images, index } — открыто
const [viewerData, setViewerData] = useState(null);
// Массив объявлений из API (основная лента)
const [ads, setAds] = useState([]);
// Объявления текущего пользователя (загружаются отдельно по userId)
const [myAds, setMyAds] = useState([]);
// Set id объявлений, которые пользователь лайкнул (только локально)
const [liked, setLiked] = useState(new Set());
// Состояния загрузки и ошибки для ленты
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Пагинация ленты
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [liked, setLiked] = useState(new Set());
const PAGE_SIZE = 20;
const feed = useFeedPosts();
const myPosts = useMyPosts(user?.id);
const cityFeed = useCityFeed(selectedCities);
// Данные профиля — из контекста авторизации (или пустой объект для гостя)
const profileData = useMemo(() => {
if (!user) return {};
// Если бэк присылает имя одним полем "name" — разбиваем на firstName/lastName
// Если бэк присылает отдельные поля firstName/lastName — используем их напрямую
const nameParts = (user.name || "").trim().split(/\s+/);
const firstName = user.firstName || nameParts[0] || "";
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
const avatar = getImageUrl(user.avatar);
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
return {
login: user.login || "",
login: user.login || "",
firstName,
lastName,
email: user.email || "",
phone: user.phone || "",
avatar,
id: user.id,
email: user.email || "",
phone: user.phone || "",
avatar: getImageUrl(user.avatar),
id: user.id,
};
}, [user]);
/** Загружает ленту объявлений с бэкенда. */
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
if (pageNum === 0) {
setLoading(true);
const refreshCurrentList = useCallback(() => {
if (selectedCities.length > 0) {
cityFeed.searchCities(selectedCities); // triggers internal effect via selectedCities dep isn't needed here
} else {
setLoadingMore(true);
feed.loadFeed();
}
setError("");
try {
const params = new URLSearchParams({
page: pageNum,
size: PAGE_SIZE,
});
if (search) params.set("search", search);
}, [selectedCities, cityFeed, feed]);
const res = await fetch(`${API_URL}/api/v1/posts?${params}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const content = data.content ?? [];
const normalizedAds = content.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setAds(prev => append ? [...prev, ...normalizedAds] : normalizedAds);
setHasMore(!data.last); // data.last — стандартное поле Spring Page
setPage(pageNum);
} catch (err) {
setError(err.message || "Failed to load ads");
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
const actions = useFeedActions({
refresh: refreshCurrentList,
loadMyPosts: myPosts.loadMyPosts,
onAuthRequired: () => modals.open("auth"),
isAuthenticated,
t,
});
// Загружаем лайки пользователя при авторизации
useEffect(() => {
if (isAuthenticated && token) {
loadFavorites(token).then(favoriteIds => {
setLiked(new Set(favoriteIds));
});
loadFavorites().then(favoriteIds => setLiked(new Set(favoriteIds)));
} else {
setLiked(new Set());
}
@@ -150,454 +93,166 @@ export default function MainFeed() {
// Открываем модалку аутентификации если пришли с лендинга
useEffect(() => {
if (location.state?.openAuth) {
setAuthModalOpen(true);
modals.open("auth");
window.history.replaceState({}, document.title);
}
}, [location.state]);
}, [location.state, modals]);
// Первичная загрузка при монтировании компонента.
// При активном фильтре по городам ленту не грузим — показываем /posts/search
// Первичная загрузка ленты (дебаунс поиска), только когда нет фильтра по городам
useEffect(() => {
if (selectedCities.length > 0) return;
const timer = setTimeout(() => {
loadFeed(0, searchQuery, false);
feed.loadFeed(0, searchQuery, false);
}, 400);
return () => clearTimeout(timer);
}, [searchQuery, loadFeed, selectedCities]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedCities]);
/** Загружает объявления текущего пользователя по userId с бэкенда. */
const loadMyPosts = useCallback(async () => {
if (!user?.id) return;
setLoading(true);
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/posts/${user.id}`);
if (res.status === 404) {
// У пользователя пока нет объявлений — это нормальная ситуация
setMyAds([]);
return;
}
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
const posts = Array.isArray(data) ? data : (data.items ?? []);
const normalizedPosts = posts.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setMyAds(normalizedPosts);
} catch (err) {
setError(err.message || "Failed to load your ads");
} finally {
setLoading(false);
}
}, [user?.id]);
/** Ищет объявления сразу по нескольким городам и объединяет результаты (без дублей). */
const searchCities = useCallback((citiesToSearch) => {
if (!citiesToSearch.length) return Promise.resolve([]);
return Promise.all(citiesToSearch.map(c => searchPosts(c)))
.then(results => {
const seen = new Set();
const merged = [];
results.flat().forEach(ad => {
if (!seen.has(ad.id)) {
seen.add(ad.id);
merged.push(ad);
}
});
return merged;
});
}, [searchPosts]);
/** Перезагружает текущий список: городской поиск либо обычную ленту. */
const refreshCurrentList = useCallback(() => {
if (selectedCities.length > 0) {
searchCities(selectedCities).then(result => setCityAds(result));
} else {
loadFeed();
}
}, [selectedCities, searchCities, loadFeed]);
// Поиск объявлений по выбранным городам через /posts/search
useEffect(() => {
if (selectedCities.length === 0) {
setCityAds([]);
return;
}
let cancelled = false;
searchCities(selectedCities).then(result => {
if (!cancelled) setCityAds(result);
});
return () => { cancelled = true; };
}, [selectedCities, searchCities]);
/**
* Переключает лайк на объявлении через API.
*/
const toggleLike = useCallback(async (id) => {
if (!isAuthenticated || !token) {
setAuthModalOpen(true);
return;
}
const success = await toggleFavorite(id, token);
if (success) {
// Перезагружаем ленту для получения обновлённых данных
refreshCurrentList();
if (activeNav === "myads") {
loadMyPosts();
}
}
}, [isAuthenticated, token, toggleFavorite, refreshCurrentList, loadMyPosts, activeNav]);
/** Удаляет объявление и перезагружает ленту */
const handleDelete = useCallback(async (postId) => {
if (!window.confirm(t('feed.confirmDelete'))) {
return;
}
const success = await deletePost(postId);
if (success) {
refreshCurrentList();
loadMyPosts();
}
}, [t, deletePost, refreshCurrentList, loadMyPosts]);
/** Отмечает объявление как "Вещь найдена" и перезагружает ленту */
const handleMarkFound = useCallback(async (postId) => {
if (!window.confirm(t('feed.confirmMarkFound'))) {
return;
}
const success = await markAsFound(postId, token);
if (success) {
refreshCurrentList();
loadMyPosts();
}
}, [t, markAsFound, token, refreshCurrentList, loadMyPosts]);
// После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
const handleEditSave = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) {
setViewerData({ images, index, ad });
}
}, []);
/**
* Обработчик нажатия «Разместить объявление».
* Если пользователь не авторизован — открывает модалку входа.
*/
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setModalOpen(true);
}
}, [isAuthenticated]);
/**
* Обработчик редактирования — разрешён только автору объявления.
*/
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setEditAd(ad);
}
}, [isAuthenticated]);
// Сентинел для бесконечного скролла — подгружает следующую страницу
const observerRef = useRef(null);
const sentinelRef = useCallback(node => {
if (loadingMore) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadFeed(page + 1, searchQuery, true);
}
}, { rootMargin: "200px" });
if (node) observerRef.current.observe(node);
}, [loadingMore, hasMore, loading, page, searchQuery, loadFeed]);
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
useEffect(() => {
if (activeNav === "myads" && isAuthenticated && user?.id) {
loadMyPosts();
myPosts.loadMyPosts();
}
}, [activeNav, isAuthenticated, user?.id, loadMyPosts]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNav, isAuthenticated, user?.id]);
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
const filteredAds = (() => {
// База: мои объявления / результаты поиска по городу / обычная лента
const sentinelRef = useInfiniteScroll(
() => feed.loadFeed(feed.page + 1, searchQuery, true),
activeNav === "home" && selectedCities.length === 0 && feed.hasMore && !feed.loading && !feed.loadingMore
);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) modals.open("viewer", { images, index, ad });
}, [modals]);
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) modals.open("auth");
else modals.open("create");
}, [isAuthenticated, modals]);
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) modals.open("auth");
else modals.open("edit", ad);
}, [isAuthenticated, modals]);
const filteredAds = useMemo(() => {
let result;
if (activeNav === "myads") {
// Используем объявления, загруженные с бэкенда по userId
result = myAds;
result = myPosts.myAds;
} else if (selectedCities.length > 0) {
// Выбраны города — используем результаты /posts/search
result = cityAds;
result = cityFeed.cityAds;
} else {
result = ads;
result = feed.ads;
}
// Фильтр по категории из навигации (Вещи / Питомцы)
if (activeNav === "items") {
result = result.filter(ad =>
["Вещь", "Другое"].includes(ad.category)
);
result = result.filter(ad => ["Вещь", "Другое"].includes(ad.category));
} else if (activeNav === "pets") {
result = result.filter(ad => ad.category === "Питомец");
}
// Применяем фильтр по типу объявления
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
return result;
})();
}, [activeNav, myPosts.myAds, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
/**
* Рендерит содержимое ленты в зависимости от текущего состояния:
* — спиннер во время загрузки
* — сообщение об ошибке
* — заглушка при пустой ленте
* — список карточек объявлений
*/
const renderContent = () => {
if (loading || (selectedCities.length > 0 && cityLoading)) {
return (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
{t('feed.loading')}
</div>
</div>
);
}
if (error) {
return (
<div className="f-center">
<div className="f-center-icon">⚠️</div>
<div className="f-center-title">{t('feed.loadError')}</div>
<div className="f-center-sub">{error}</div>
</div>
);
}
if (!filteredAds.length) {
const emptyMsg = activeNav === "myads"
? { title: t('feed.emptyMyAdsTitle'), sub: t('feed.emptyMyAdsSub') }
: selectedCities.length > 0
? { title: t('feed.emptyCityTitle'), sub: t('feed.emptyCitySub') }
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
return (
<div className="f-center">
<div className="f-center-icon">🔍</div>
<div className="f-center-title">{emptyMsg.title}</div>
<div className="f-center-sub"
dangerouslySetInnerHTML={{ __html: emptyMsg.sub }}
/>
</div>
);
}
return filteredAds.map(ad => (
<PostOnFeed
key={ad.id}
ad={ad}
liked={liked.has(ad.id)}
onLike={toggleLike}
onEdit={activeNav === "myads" ? handleEdit : undefined}
onDelete={activeNav === "myads" ? handleDelete : undefined}
onMarkFound={activeNav === "myads" ? handleMarkFound : undefined}
isOwner={activeNav === "myads"}
onImageClick={handleImageClick}
onAuthRequired={() => setAuthModalOpen(true)}
/>
));
};
const listLoading = activeNav === "myads" ? myPosts.loading : feed.loading;
const listError = activeNav === "myads" ? myPosts.error : feed.error;
return (
<div className="f-app">
{/* Левый сайдбар с навигацией */}
<Sidebar
open={sidebarOpen}
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
onNavClick={setActiveNav}
isAuthenticated={isAuthenticated}
onLoginClick={() => setAuthModalOpen(true)}
onRulesClick={() => setRulesModalOpen(true)}
onLoginClick={() => modals.open("auth")}
onRulesClick={() => modals.open("rules")}
onReviewsClick={() => navigate("/reviews")}
/>
<div className="f-main">
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
<div className="f-topbar">
<button className="f-menu-btn"
onClick={() => setSidebarOpen(v => !v)}>
<MenuIcon/>
</button>
<div className="f-topbar-center">
<button className="f-post-btn" onClick={handleCreatePost}>
<span style={{
fontSize: "1.15rem",
fontWeight: 900,
lineHeight: 1
}}>+</span>
{t('feed.createPost')}
</button>
</div>
{/* Иконка профиля в правом углу */}
{isAuthenticated ? (
<div className="f-topbar-profile"
onClick={() => setProfileModalOpen(true)}>
<ProfileAvatar avatar={profileData.avatar}
name={`${profileData.firstName
|| ""} ${profileData.lastName || ""}`}/>
</div>
) : (
<button className="f-login-btn"
onClick={() => setAuthModalOpen(true)}>
{t('feed.login')}
</button>
)}
</div>
<FeedTopBar
onMenuClick={() => setSidebarOpen(v => !v)}
onCreatePost={handleCreatePost}
isAuthenticated={isAuthenticated}
profileData={profileData}
onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")}
t={t}
/>
{/* Строка поиска */}
<div className="f-search-bar">
<svg className="f-search-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input
className="f-search-input"
type="text"
placeholder={t('feed.searchPlaceholder')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button className="f-search-clear"
onClick={() => setSearchQuery("")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" width="18" height="18">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
)}
</div>
<FeedSearchBar value={searchQuery} onChange={setSearchQuery} t={t} />
{/* Фильтр по городам */}
{activeNav === "home" && (
<div className="f-city-filter-wrap">
<CityFilter
cities={cities}
value={selectedCities}
onChange={setSelectedCities}
/>
<CityFilter cities={cities} value={selectedCities} onChange={setSelectedCities} />
</div>
)}
{/* Фильтр по типу объявления */}
<div className="f-filter-bar">
<button
className={`f-filter-btn${typeFilter === "loss" ? " active"
: ""}`}
onClick={() => setTypeFilter("loss")}
>
{t('feed.filterLoss')}
</button>
<button
className={`f-filter-btn${typeFilter === null ? " active"
: ""}`}
onClick={() => setTypeFilter(null)}
>
{t('feed.filterAll')}
</button>
<button
className={`f-filter-btn${typeFilter === "found" ? " active"
: ""}`}
onClick={() => setTypeFilter("found")}
>
{t('feed.filterFound')}
</button>
</div>
<TypeFilterBar value={typeFilter} onChange={setTypeFilter} t={t} />
{/* Основная область: лента + правый сайдбар */}
<div className="f-wrap">
<div className="f-feed">
{renderContent()}
{activeNav === "home" && selectedCities.length === 0 && !loading && (
<div ref={sentinelRef} style={{height: "1px"}}/>
<FeedContent
loading={listLoading}
cityLoading={cityFeed.cityLoading}
citySearchActive={selectedCities.length > 0}
error={listError}
filteredAds={filteredAds}
activeNav={activeNav}
liked={liked}
onLike={actions.toggleLike}
onEdit={handleEdit}
onDelete={actions.handleDelete}
onMarkFound={actions.handleMarkFound}
onImageClick={handleImageClick}
onAuthRequired={() => modals.open("auth")}
t={t}
/>
{activeNav === "home" && selectedCities.length === 0 && !feed.loading && (
<div ref={sentinelRef} style={{ height: "1px" }} />
)}
{loadingMore && (
<div className="f-center" style={{padding: "1rem"}}>
<div className="f-spinner"/>
{feed.loadingMore && (
<div className="f-center" style={{ padding: "1rem" }}>
<div className="f-spinner" />
</div>
)}
</div>
{/* Правый сайдбар: промо-блоки / фильтры */}
<RightSidebar/>
<RightSidebar />
</div>
</div>
{/* Модалка создания нового объявления (только для авторизованных) */}
{modalOpen && isAuthenticated && (
{modals.isOpen("create") && isAuthenticated && (
<CreateUpdatePost onClose={modals.close} onSuccess={actions.handleNewAd} />
)}
{modals.isOpen("edit") && isAuthenticated && (
<CreateUpdatePost
onClose={() => setModalOpen(false)}
onSuccess={handleNewAd}
initialData={modals.modal.payload}
onClose={modals.close}
onSuccess={actions.handleEditSave}
/>
)}
{/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && isAuthenticated && (
<CreateUpdatePost
initialData={editAd}
onClose={() => setEditAd(null)}
onSuccess={handleEditSave}
/>
{modals.isOpen("profile") && isAuthenticated && (
<ProfileModal onClose={modals.close} initialData={profileData} />
)}
{/* Модальное окно редактирования профиля */}
{profileModalOpen && isAuthenticated && (
<ProfileModal
onClose={() => setProfileModalOpen(false)}
initialData={profileData}
/>
)}
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
{/* Модальное окно входа / регистрации */}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
/>
)}
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
{/* Модальное окно правил площадки */}
{rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} />
)}
{/* Просмотр изображений */}
{viewerData && (
{modals.isOpen("viewer") && (
<ImageViewer
images={viewerData.images}
initialIndex={viewerData.index}
onClose={() => setViewerData(null)}
postData={viewerData.ad}
images={modals.modal.payload.images}
initialIndex={modals.modal.payload.index}
onClose={modals.close}
postData={modals.modal.payload.ad}
/>
)}
</div>
);
}
}