220 lines
8.9 KiB
JavaScript
220 lines
8.9 KiB
JavaScript
import { useState, useEffect, useCallback } from "react";
|
||
import "../styles/index.css";
|
||
import { API_URL } from "../config/authConstants";
|
||
import Sidebar from "./Sidebar";
|
||
import RightSidebar from "./RightSidebar";
|
||
import PostOnFeed from "./PostOnFeed";
|
||
import CreateUpdatePost from "./CreateUpdatePost";
|
||
import MenuIcon from "./icons/MenuIcon";
|
||
|
||
/**
|
||
* MainFeed — корневой компонент приложения.
|
||
* Управляет состоянием ленты, модалок и сайдбара;
|
||
* остальные компоненты получают данные через пропсы.
|
||
*/
|
||
export default function MainFeed() {
|
||
// Открыт ли левый сайдбар (мобильный слайд-аут)
|
||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
// Активный пункт навигации в сайдбаре
|
||
const [activeNav, setActiveNav] = useState("Главная");
|
||
// Фильтр по типу объявления: null (все) | "loss" | "found"
|
||
const [typeFilter, setTypeFilter] = useState(null);
|
||
// Открыта ли модалка создания нового объявления
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
// Объявление, которое сейчас редактируется (null — модалка закрыта)
|
||
const [editAd, setEditAd] = useState(null);
|
||
// Массив объявлений из API
|
||
const [ads, setAds] = useState([]);
|
||
// Set id объявлений, которые пользователь лайкнул (только локально)
|
||
const [liked, setLiked] = useState(new Set());
|
||
// Состояния загрузки и ошибки для ленты
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
|
||
/** Загружает ленту объявлений с бэкенда. */
|
||
const loadFeed = useCallback(async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`${API_URL}/api/v1/posts`);
|
||
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
|
||
const data = await res.json();
|
||
// API может вернуть массив напрямую или объект { items: [] }
|
||
const ads = Array.isArray(data) ? data : (data.items ?? []);
|
||
// Нормализуем type: enum LOSS/FOUND → "loss"/"found"
|
||
const normalizedAds = ads.map(ad => ({
|
||
...ad,
|
||
type: ad.type ? ad.type.toLowerCase() : "loss"
|
||
}));
|
||
setAds(normalizedAds);
|
||
} catch (err) {
|
||
setError(err.message || "Не удалось загрузить объявления");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Первичная загрузка при монтировании компонента
|
||
useEffect(() => { loadFeed(); }, [loadFeed]);
|
||
|
||
/**
|
||
* Переключает лайк на объявлении.
|
||
* Состояние хранится только на клиенте — без запроса к API.
|
||
*/
|
||
const toggleLike = useCallback((id) => {
|
||
setLiked(prev => {
|
||
const next = new Set(prev);
|
||
next.has(id) ? next.delete(id) : next.add(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// После создания или редактирования объявления перезагружаем ленту,
|
||
// чтобы получить актуальные данные (включая изображения) из БД
|
||
const handleNewAd = useCallback(() => loadFeed(), [loadFeed]);
|
||
const handleEditSave = useCallback(() => loadFeed(), [loadFeed]);
|
||
|
||
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
|
||
const filteredAds = (() => {
|
||
let result = activeNav === "Главная"
|
||
? ads
|
||
: ads.filter(ad =>
|
||
activeNav === "Вещи"
|
||
? ["Вещь", "Другое"].includes(ad.category)
|
||
: activeNav === "Питомцы"
|
||
? ad.category === "Питомец"
|
||
: false
|
||
);
|
||
// Применяем фильтр по типу объявления
|
||
if (typeFilter) {
|
||
result = result.filter(ad => ad.type === typeFilter);
|
||
}
|
||
return result;
|
||
})();
|
||
|
||
/**
|
||
* Рендерит содержимое ленты в зависимости от текущего состояния:
|
||
* — спиннер во время загрузки
|
||
* — сообщение об ошибке
|
||
* — заглушка при пустой ленте
|
||
* — список карточек объявлений
|
||
*/
|
||
const renderContent = () => {
|
||
if (loading) {
|
||
return (
|
||
<div className="f-center">
|
||
<div className="f-spinner-wrap">
|
||
<div className="f-spinner" />
|
||
Загружаем объявления...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (error) {
|
||
return (
|
||
<div className="f-center">
|
||
<div className="f-center-icon">⚠️</div>
|
||
<div className="f-center-title">Не удалось загрузить ленту</div>
|
||
<div className="f-center-sub">{error}</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (!filteredAds.length) {
|
||
return (
|
||
<div className="f-center">
|
||
<div className="f-center-icon">🔍</div>
|
||
<div className="f-center-title">Объявлений пока нет</div>
|
||
<div className="f-center-sub">
|
||
Будьте первым — разместите объявление<br />о потере или находке
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return filteredAds.map(ad => (
|
||
<PostOnFeed
|
||
key={ad.id}
|
||
ad={ad}
|
||
liked={liked.has(ad.id)} // передаём локальное состояние лайка
|
||
onLike={toggleLike} // колбэк для переключения лайка
|
||
onEdit={setEditAd} // колбэк открывает модалку редактирования
|
||
/>
|
||
));
|
||
};
|
||
|
||
return (
|
||
<div className="f-app">
|
||
{/* Левый сайдбар с навигацией */}
|
||
<Sidebar
|
||
open={sidebarOpen}
|
||
activeNav={activeNav}
|
||
onClose={() => setSidebarOpen(false)}
|
||
onNavClick={setActiveNav}
|
||
/>
|
||
|
||
<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={() => setModalOpen(true)}>
|
||
<span style={{ fontSize: "1.15rem", fontWeight: 900, lineHeight: 1 }}>+</span>
|
||
Разместить объявление
|
||
</button>
|
||
</div>
|
||
{/* Пустой спейсер симметрично уравновешивает бургер слева */}
|
||
<div className="f-topbar-spacer" />
|
||
</div>
|
||
|
||
{/* Фильтр по типу объявления */}
|
||
<div className="f-filter-bar">
|
||
<button
|
||
className={`f-filter-btn${typeFilter === null ? " active" : ""}`}
|
||
onClick={() => setTypeFilter(null)}
|
||
>
|
||
Все объявления
|
||
</button>
|
||
<button
|
||
className={`f-filter-btn${typeFilter === "loss" ? " active" : ""}`}
|
||
onClick={() => setTypeFilter("loss")}
|
||
>
|
||
🔴 Потери
|
||
</button>
|
||
<button
|
||
className={`f-filter-btn${typeFilter === "found" ? " active" : ""}`}
|
||
onClick={() => setTypeFilter("found")}
|
||
>
|
||
🟢 Находки
|
||
</button>
|
||
</div>
|
||
|
||
{/* Основная область: лента + правый сайдбар */}
|
||
<div className="f-wrap">
|
||
<div className="f-feed">
|
||
{renderContent()}
|
||
</div>
|
||
{/* Правый сайдбар: промо-блоки / фильтры */}
|
||
<RightSidebar />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Модалка создания нового объявления */}
|
||
{modalOpen && (
|
||
<CreateUpdatePost
|
||
onClose={() => setModalOpen(false)}
|
||
onSuccess={handleNewAd}
|
||
/>
|
||
)}
|
||
|
||
{/* Модалка редактирования — открывается когда editAd !== null */}
|
||
{editAd && (
|
||
<CreateUpdatePost
|
||
initialData={editAd}
|
||
onClose={() => setEditAd(null)}
|
||
onSuccess={handleEditSave}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
} |