Merge pull request #37 from SlimusMinus/add-city_filter
added city filter
This commit is contained in:
@@ -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,6 +394,8 @@ 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') }
|
||||||
|
: selectedCities.length > 0
|
||||||
|
? { title: t('feed.emptyCityTitle'), sub: t('feed.emptyCitySub') }
|
||||||
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
|
: { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user