Merge pull request #37 from SlimusMinus/add-city_filter

added city filter
This commit is contained in:
SlimusMinus
2026-08-05 00:41:19 +03:00
committed by GitHub
2 changed files with 215 additions and 25 deletions

View File

@@ -7,6 +7,7 @@ import RightSidebar from "../components/RightSidebar";
import PostOnFeed from "../components/PostOnFeed"; import PostOnFeed from "../components/PostOnFeed";
import CreateUpdatePost from "../components/CreateUpdatePost"; import CreateUpdatePost from "../components/CreateUpdatePost";
import ImageViewer from "../components/ImageViewer"; import ImageViewer from "../components/ImageViewer";
import CityFilter from "../components/CityFilter";
import MenuIcon from "../components/icons/MenuIcon"; import MenuIcon from "../components/icons/MenuIcon";
import ProfileAvatar from "../components/profile/ProfileAvatar"; import ProfileAvatar from "../components/profile/ProfileAvatar";
import { ProfileModal } from "../components/profile"; import { ProfileModal } from "../components/profile";
@@ -16,6 +17,8 @@ import { useAuthContext } from "../context/AuthContext";
import { useDeletePost } from "../hooks/useDeletePost"; import { useDeletePost } from "../hooks/useDeletePost";
import { useFavorites } from "../hooks/useFavorites"; import { useFavorites } from "../hooks/useFavorites";
import { usePostStatus } from "../hooks/usePostStatus"; import { usePostStatus } from "../hooks/usePostStatus";
import { useCities } from "../hooks/useCities";
import { useSearchPosts } from "../hooks/useSearchPosts";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
/** /**
@@ -30,6 +33,8 @@ export default function MainFeed() {
const { deletePost } = useDeletePost(); const { deletePost } = useDeletePost();
const { toggleFavorite, loadFavorites } = useFavorites(); const { toggleFavorite, loadFavorites } = useFavorites();
const { markAsFound } = usePostStatus(); const { markAsFound } = usePostStatus();
const { cities } = useCities();
const { loading: cityLoading, searchPosts } = useSearchPosts();
const { t } = useTranslation(); const { t } = useTranslation();
// Открыт ли левый сайдбар (мобильный слайд-аут) // Открыт ли левый сайдбар (мобильный слайд-аут)
@@ -38,6 +43,10 @@ export default function MainFeed() {
const [activeNav, setActiveNav] = useState("home"); const [activeNav, setActiveNav] = useState("home");
// Фильтр по типу объявления: null (все) | "loss" | "found" // Фильтр по типу объявления: null (все) | "loss" | "found"
const [typeFilter, setTypeFilter] = useState(null); const [typeFilter, setTypeFilter] = useState(null);
// Выбранные города для фильтра: [] (все) | ["Город", ...]
const [selectedCities, setSelectedCities] = useState([]);
// Результаты поиска по выбранным городам (бэк /posts/search)
const [cityAds, setCityAds] = useState([]);
// Поисковый запрос (фильтр по title / description) // Поисковый запрос (фильтр по title / description)
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// Открыта ли модалка создания нового объявления // Открыта ли модалка создания нового объявления
@@ -146,13 +155,15 @@ export default function MainFeed() {
} }
}, [location.state]); }, [location.state]);
// Первичная загрузка при монтировании компонента // Первичная загрузка при монтировании компонента.
// При активном фильтре по городам ленту не грузим — показываем /posts/search
useEffect(() => { useEffect(() => {
if (selectedCities.length > 0) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
loadFeed(0, searchQuery, false); loadFeed(0, searchQuery, false);
}, 400); }, 400);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, loadFeed]); }, [searchQuery, loadFeed, selectedCities]);
/** Загружает объявления текущего пользователя по userId с бэкенда. */ /** Загружает объявления текущего пользователя по userId с бэкенда. */
@@ -182,6 +193,45 @@ export default function MainFeed() {
} }
}, [user?.id]); }, [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. * Переключает лайк на объявлении через API.
*/ */
@@ -194,12 +244,12 @@ export default function MainFeed() {
const success = await toggleFavorite(id, token); const success = await toggleFavorite(id, token);
if (success) { if (success) {
// Перезагружаем ленту для получения обновлённых данных // Перезагружаем ленту для получения обновлённых данных
loadFeed(); refreshCurrentList();
if (activeNav === "myads") { if (activeNav === "myads") {
loadMyPosts(); loadMyPosts();
} }
} }
}, [isAuthenticated, token, toggleFavorite, loadFeed, loadMyPosts, activeNav]); }, [isAuthenticated, token, toggleFavorite, refreshCurrentList, loadMyPosts, activeNav]);
/** Удаляет объявление и перезагружает ленту */ /** Удаляет объявление и перезагружает ленту */
const handleDelete = useCallback(async (postId) => { const handleDelete = useCallback(async (postId) => {
@@ -208,10 +258,10 @@ export default function MainFeed() {
} }
const success = await deletePost(postId); const success = await deletePost(postId);
if (success) { if (success) {
loadFeed(); refreshCurrentList();
loadMyPosts(); loadMyPosts();
} }
}, [t, deletePost, loadFeed, loadMyPosts]); }, [t, deletePost, refreshCurrentList, loadMyPosts]);
/** Отмечает объявление как "Вещь найдена" и перезагружает ленту */ /** Отмечает объявление как "Вещь найдена" и перезагружает ленту */
const handleMarkFound = useCallback(async (postId) => { const handleMarkFound = useCallback(async (postId) => {
@@ -221,15 +271,15 @@ export default function MainFeed() {
const success = await markAsFound(postId, token); const success = await markAsFound(postId, token);
if (success) { if (success) {
loadFeed(); refreshCurrentList();
loadMyPosts(); loadMyPosts();
} }
}, [t, markAsFound, token, loadFeed, loadMyPosts]); }, [t, markAsFound, token, refreshCurrentList, loadMyPosts]);
// После создания или редактирования объявления перезагружаем ленту, // После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД // чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]); const handleNewAd = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]); const handleEditSave = useCallback(() => { refreshCurrentList(); loadMyPosts(); }, [refreshCurrentList, loadMyPosts]);
const handleImageClick = useCallback((images, index, ad) => { const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) { if (images?.length) {
@@ -285,26 +335,26 @@ export default function MainFeed() {
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа // Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
const filteredAds = (() => { const filteredAds = (() => {
// База: мои объявления / результаты поиска по городу / обычная лента
let result; let result;
if (activeNav === "myads") { if (activeNav === "myads") {
// Используем объявления, загруженные с бэкенда по userId // Используем объявления, загруженные с бэкенда по userId
result = myAds; result = myAds;
} else if (activeNav === "home") { } else if (selectedCities.length > 0) {
result = ads; // Выбраны города — используем результаты /posts/search
result = cityAds;
} else { } else {
result = ads.filter(ad => result = ads;
activeNav === "items"
? ["Вещь", "Другое"].includes(ad.category)
: activeNav === "pets"
? ad.category === "Питомец"
: false
);
} }
// Применяем поисковый фильтр по title / description // Фильтр по категории из навигации (Вещи / Питомцы)
if (typeFilter) { if (activeNav === "items") {
result = result.filter(ad => ad.type === typeFilter); 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 = () => { const renderContent = () => {
if (loading) { if (loading || (selectedCities.length > 0 && cityLoading)) {
return ( return (
<div className="f-center"> <div className="f-center">
<div className="f-spinner-wrap"> <div className="f-spinner-wrap">
@@ -344,7 +394,9 @@ export default function MainFeed() {
if (!filteredAds.length) { if (!filteredAds.length) {
const emptyMsg = activeNav === "myads" const emptyMsg = activeNav === "myads"
? { title: t('feed.emptyMyAdsTitle'), sub: t('feed.emptyMyAdsSub') } ? { title: t('feed.emptyMyAdsTitle'), sub: t('feed.emptyMyAdsSub') }
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') }; : selectedCities.length > 0
? { title: t('feed.emptyCityTitle'), sub: t('feed.emptyCitySub') }
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
return ( return (
<div className="f-center"> <div className="f-center">
@@ -446,6 +498,17 @@ export default function MainFeed() {
)} )}
</div> </div>
{/* Фильтр по городам */}
{activeNav === "home" && (
<div className="f-city-filter-wrap">
<CityFilter
cities={cities}
value={selectedCities}
onChange={setSelectedCities}
/>
</div>
)}
{/* Фильтр по типу объявления */} {/* Фильтр по типу объявления */}
<div className="f-filter-bar"> <div className="f-filter-bar">
<button <button
@@ -475,7 +538,7 @@ export default function MainFeed() {
<div className="f-wrap"> <div className="f-wrap">
<div className="f-feed"> <div className="f-feed">
{renderContent()} {renderContent()}
{activeNav === "home" && !loading && ( {activeNav === "home" && selectedCities.length === 0 && !loading && (
<div ref={sentinelRef} style={{height: "1px"}}/> <div ref={sentinelRef} style={{height: "1px"}}/>
)} )}
{loadingMore && ( {loadingMore && (

View File

@@ -122,6 +122,127 @@
font-weight: 700; 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 ── */ /* ── Feed Wrap + Scroll ── */
.f-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; } .f-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }
@@ -165,6 +286,12 @@
padding: 8px 14px; padding: 8px 14px;
font-size: 0.85rem; 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) { @media (max-width: 600px) {