added city filter
This commit is contained in:
@@ -7,6 +7,7 @@ import RightSidebar from "../components/RightSidebar";
|
||||
import PostOnFeed from "../components/PostOnFeed";
|
||||
import CreateUpdatePost from "../components/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";
|
||||
@@ -16,6 +17,8 @@ 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 { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
@@ -30,6 +33,8 @@ export default function MainFeed() {
|
||||
const { deletePost } = useDeletePost();
|
||||
const { toggleFavorite, loadFavorites } = useFavorites();
|
||||
const { markAsFound } = usePostStatus();
|
||||
const { cities } = useCities();
|
||||
const { loading: cityLoading, searchPosts } = useSearchPosts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Открыт ли левый сайдбар (мобильный слайд-аут)
|
||||
@@ -38,6 +43,10 @@ export default function MainFeed() {
|
||||
const [activeNav, setActiveNav] = useState("home");
|
||||
// Фильтр по типу объявления: null (все) | "loss" | "found"
|
||||
const [typeFilter, setTypeFilter] = useState(null);
|
||||
// Выбранные города для фильтра: [] (все) | ["Город", ...]
|
||||
const [selectedCities, setSelectedCities] = useState([]);
|
||||
// Результаты поиска по выбранным городам (бэк /posts/search)
|
||||
const [cityAds, setCityAds] = useState([]);
|
||||
// Поисковый запрос (фильтр по title / description)
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Открыта ли модалка создания нового объявления
|
||||
@@ -146,13 +155,15 @@ export default function MainFeed() {
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
// Первичная загрузка при монтировании компонента
|
||||
// Первичная загрузка при монтировании компонента.
|
||||
// При активном фильтре по городам ленту не грузим — показываем /posts/search
|
||||
useEffect(() => {
|
||||
if (selectedCities.length > 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
loadFeed(0, searchQuery, false);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, loadFeed]);
|
||||
}, [searchQuery, loadFeed, selectedCities]);
|
||||
|
||||
|
||||
/** Загружает объявления текущего пользователя по userId с бэкенда. */
|
||||
@@ -182,6 +193,45 @@ export default function MainFeed() {
|
||||
}
|
||||
}, [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.
|
||||
*/
|
||||
@@ -194,12 +244,12 @@ export default function MainFeed() {
|
||||
const success = await toggleFavorite(id, token);
|
||||
if (success) {
|
||||
// Перезагружаем ленту для получения обновлённых данных
|
||||
loadFeed();
|
||||
refreshCurrentList();
|
||||
if (activeNav === "myads") {
|
||||
loadMyPosts();
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, token, toggleFavorite, loadFeed, loadMyPosts, activeNav]);
|
||||
}, [isAuthenticated, token, toggleFavorite, refreshCurrentList, loadMyPosts, activeNav]);
|
||||
|
||||
/** Удаляет объявление и перезагружает ленту */
|
||||
const handleDelete = useCallback(async (postId) => {
|
||||
@@ -208,10 +258,10 @@ export default function MainFeed() {
|
||||
}
|
||||
const success = await deletePost(postId);
|
||||
if (success) {
|
||||
loadFeed();
|
||||
refreshCurrentList();
|
||||
loadMyPosts();
|
||||
}
|
||||
}, [t, deletePost, loadFeed, loadMyPosts]);
|
||||
}, [t, deletePost, refreshCurrentList, loadMyPosts]);
|
||||
|
||||
/** Отмечает объявление как "Вещь найдена" и перезагружает ленту */
|
||||
const handleMarkFound = useCallback(async (postId) => {
|
||||
@@ -221,15 +271,15 @@ export default function MainFeed() {
|
||||
|
||||
const success = await markAsFound(postId, token);
|
||||
if (success) {
|
||||
loadFeed();
|
||||
refreshCurrentList();
|
||||
loadMyPosts();
|
||||
}
|
||||
}, [t, markAsFound, token, loadFeed, loadMyPosts]);
|
||||
}, [t, markAsFound, token, refreshCurrentList, loadMyPosts]);
|
||||
|
||||
// После создания или редактирования объявления перезагружаем ленту,
|
||||
// чтобы получить актуальные данные (включая изображения) из БД
|
||||
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
||||
const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
||||
const handleNewAd = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
|
||||
const handleEditSave = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
|
||||
|
||||
const handleImageClick = useCallback((images, index, ad) => {
|
||||
if (images?.length) {
|
||||
@@ -285,26 +335,26 @@ export default function MainFeed() {
|
||||
|
||||
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
|
||||
const filteredAds = (() => {
|
||||
// База: мои объявления / результаты поиска по городу / обычная лента
|
||||
let result;
|
||||
|
||||
if (activeNav === "myads") {
|
||||
// Используем объявления, загруженные с бэкенда по userId
|
||||
result = myAds;
|
||||
} else if (activeNav === "home") {
|
||||
result = ads;
|
||||
} else if (selectedCities.length > 0) {
|
||||
// Выбраны города — используем результаты /posts/search
|
||||
result = cityAds;
|
||||
} else {
|
||||
result = ads.filter(ad =>
|
||||
activeNav === "items"
|
||||
? ["Вещь", "Другое"].includes(ad.category)
|
||||
: activeNav === "pets"
|
||||
? ad.category === "Питомец"
|
||||
: false
|
||||
);
|
||||
result = ads;
|
||||
}
|
||||
|
||||
// Применяем поисковый фильтр по title / description
|
||||
if (typeFilter) {
|
||||
result = result.filter(ad => ad.type === typeFilter);
|
||||
// Фильтр по категории из навигации (Вещи / Питомцы)
|
||||
if (activeNav === "items") {
|
||||
result = result.filter(ad =>
|
||||
["Вещь", "Другое"].includes(ad.category)
|
||||
);
|
||||
} else if (activeNav === "pets") {
|
||||
result = result.filter(ad => ad.category === "Питомец");
|
||||
}
|
||||
|
||||
// Применяем фильтр по типу объявления
|
||||
@@ -322,7 +372,7 @@ export default function MainFeed() {
|
||||
* — список карточек объявлений
|
||||
*/
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
if (loading || (selectedCities.length > 0 && cityLoading)) {
|
||||
return (
|
||||
<div className="f-center">
|
||||
<div className="f-spinner-wrap">
|
||||
@@ -344,6 +394,8 @@ export default function MainFeed() {
|
||||
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 (
|
||||
@@ -446,6 +498,17 @@ export default function MainFeed() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Фильтр по городам */}
|
||||
{activeNav === "home" && (
|
||||
<div className="f-city-filter-wrap">
|
||||
<CityFilter
|
||||
cities={cities}
|
||||
value={selectedCities}
|
||||
onChange={setSelectedCities}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Фильтр по типу объявления */}
|
||||
<div className="f-filter-bar">
|
||||
<button
|
||||
@@ -475,7 +538,7 @@ export default function MainFeed() {
|
||||
<div className="f-wrap">
|
||||
<div className="f-feed">
|
||||
{renderContent()}
|
||||
{activeNav === "home" && !loading && (
|
||||
{activeNav === "home" && selectedCities.length === 0 && !loading && (
|
||||
<div ref={sentinelRef} style={{height: "1px"}}/>
|
||||
)}
|
||||
{loadingMore && (
|
||||
|
||||
@@ -122,6 +122,127 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── City Multiselect ── */
|
||||
.f-city-filter-wrap {
|
||||
position: relative; z-index: 20;
|
||||
display: flex; align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-filter); backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.f-city-multiselect {
|
||||
position: relative; flex: 1;
|
||||
width: 100%; max-width: 440px; margin: 0 auto;
|
||||
}
|
||||
|
||||
.f-city-ms-control {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
min-height: 44px;
|
||||
cursor: text;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.f-city-ms-control.open {
|
||||
border-color: rgba(var(--teal-rgb), .5);
|
||||
box-shadow: 0 0 0 3px rgba(var(--teal-rgb), .12);
|
||||
}
|
||||
|
||||
.f-city-ms-icon {
|
||||
width: 18px; height: 18px; flex-shrink: 0; color: var(--muted);
|
||||
}
|
||||
|
||||
.f-city-ms-tags {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
flex: 1; min-width: 0; align-items: center;
|
||||
}
|
||||
|
||||
.f-city-ms-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: rgba(var(--teal-rgb), .15);
|
||||
border: 1px solid rgba(var(--teal-rgb), .4);
|
||||
color: var(--teal);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.f-city-ms-tag-remove {
|
||||
background: none; border: none; color: inherit; cursor: pointer;
|
||||
padding: 0; display: flex; align-items: center; line-height: 0;
|
||||
opacity: .7; transition: opacity .15s;
|
||||
}
|
||||
.f-city-ms-tag-remove:hover { opacity: 1; }
|
||||
|
||||
.f-city-ms-input {
|
||||
flex: 1; min-width: 90px;
|
||||
border: none; outline: none; background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font); font-size: 0.9rem;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.f-city-ms-input::placeholder { color: var(--muted); }
|
||||
|
||||
.f-city-ms-clear {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--muted); padding: 4px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; transition: color .15s, background .15s;
|
||||
}
|
||||
.f-city-ms-clear:hover { color: var(--text-primary); background: var(--hover-bg); }
|
||||
|
||||
.f-city-ms-chevron {
|
||||
width: 16px; height: 16px; flex-shrink: 0; color: var(--muted);
|
||||
transition: transform .2s;
|
||||
}
|
||||
.f-city-ms-control.open .f-city-ms-chevron { transform: rotate(180deg); }
|
||||
|
||||
.f-city-ms-dropdown {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; right: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, .4);
|
||||
max-height: 260px; overflow-y: auto;
|
||||
z-index: 30; padding: 6px;
|
||||
}
|
||||
.f-city-ms-dropdown::-webkit-scrollbar { width: 4px; }
|
||||
.f-city-ms-dropdown::-webkit-scrollbar-track { background: transparent; }
|
||||
.f-city-ms-dropdown::-webkit-scrollbar-thumb { background: var(--muted2); border-radius: 2px; }
|
||||
|
||||
.f-city-ms-option {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
width: 100%; text-align: left;
|
||||
padding: 9px 10px; border: none; border-radius: 8px;
|
||||
background: none; color: var(--text-primary);
|
||||
font-family: var(--font); font-size: 0.9rem; cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.f-city-ms-option:hover { background: var(--hover-bg); }
|
||||
.f-city-ms-option.selected { color: var(--teal); font-weight: 600; }
|
||||
.f-city-ms-option.f-city-ms-add { color: var(--teal); font-weight: 600; }
|
||||
|
||||
.f-city-ms-check {
|
||||
width: 18px; height: 18px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1.5px solid var(--border); border-radius: 5px;
|
||||
font-size: 0.8rem; color: var(--teal);
|
||||
}
|
||||
.f-city-ms-option.selected .f-city-ms-check {
|
||||
border-color: var(--teal);
|
||||
background: rgba(var(--teal-rgb), .15);
|
||||
}
|
||||
.f-city-ms-option.f-city-ms-add .f-city-ms-check {
|
||||
border-color: rgba(var(--teal-rgb), .5);
|
||||
}
|
||||
|
||||
.f-city-ms-empty {
|
||||
padding: 10px; text-align: center; color: var(--muted); font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Feed Wrap + Scroll ── */
|
||||
.f-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }
|
||||
|
||||
@@ -165,6 +286,12 @@
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Городской фильтр компактнее */
|
||||
.f-city-filter-wrap { padding: 8px 12px; }
|
||||
.f-city-ms-control { padding: 6px 10px; min-height: 40px; }
|
||||
.f-city-ms-input { font-size: 0.85rem; }
|
||||
.f-city-ms-tag { font-size: 0.75rem; padding: 3px 8px; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
Reference in New Issue
Block a user