import React, { useState, useEffect, useCallback, useRef } from 'react' import { useSearchParams } from 'react-router-dom' import { useTheme } from '../hooks/useTheme' import { getNews, getCategories, getTags, getArticle } from '../api/index' import NewsCard from '../components/NewsCard' import FilterBar from '../components/FilterBar' import Loader from '../components/Loader' import ArticleModal from '../components/ArticleModal' const PAGE_SIZE = 20 function presetToDateFrom(preset) { if (!preset) return null const now = new Date() const days = { week: 7, month: 30, '3months': 90, year: 365 }[preset] if (!days) return null now.setDate(now.getDate() - days) return now.toISOString() } export default function NewsFeed() { const { dark, toggle } = useTheme() const [searchParams, setSearchParams] = useSearchParams() const [articles, setArticles] = useState([]) const [total, setTotal] = useState(0) const [categories, setCategories] = useState([]) const [tags, setTags] = useState([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [hasMore, setHasMore] = useState(false) // Начальное состояние фильтров читаем из URL — ссылка открывает ленту с этими колледжами const [filters, setFilters] = useState(() => ({ // Чистим slug'и: CMS иногда дописывает свой путь (/page/...) в хвост ссылки — // берём часть до первого «/» (в слагах слэша нет) и убираем дубли. category: [...new Set( (searchParams.get('category') || '') .split(',') .map(s => s.split('/')[0].trim()) .filter(Boolean) )], tag: (searchParams.get('tag') || '').split('/')[0] || null, datePreset: searchParams.get('period') || '', })) const offsetRef = useRef(0) const busyRef = useRef(false) const sentinelRef = useRef(null) // Поиск с debounce (тоже инициализируется из URL) const [searchInput, setSearchInput] = useState(() => searchParams.get('q') || '') const [searchQuery, setSearchQuery] = useState(() => searchParams.get('q') || '') const debounceRef = useRef(null) const handleSearch = e => { const val = e.target.value setSearchInput(val) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => setSearchQuery(val.trim()), 400) } const clearSearch = () => { setSearchInput('') setSearchQuery('') } const [modalArticle, setModalArticle] = useState(null) useEffect(() => { Promise.all([getCategories(), getTags(30)]).then(([cats, tgs]) => { setCategories(cats) setTags(tgs) }).catch(() => {}) }, []) const buildParams = useCallback((offset) => { const params = { limit: PAGE_SIZE, offset } if (filters.category?.length) params.category = filters.category.join(',') if (filters.tag) params.tag = filters.tag const df = presetToDateFrom(filters.datePreset) if (df) params.date_from = df if (searchQuery) params.q = searchQuery return params }, [filters, searchQuery]) // Синхронизируем фильтры/поиск в URL, чтобы адрес можно было скопировать и расшарить useEffect(() => { const p = {} if (filters.category.length) p.category = filters.category.join(',') if (filters.tag) p.tag = filters.tag if (filters.datePreset) p.period = filters.datePreset if (searchQuery) p.q = searchQuery setSearchParams(p, { replace: true }) }, [filters, searchQuery, setSearchParams]) // Сброс и первая загрузка при смене фильтров/поиска useEffect(() => { let cancelled = false offsetRef.current = 0 busyRef.current = true setLoading(true) setArticles([]) setHasMore(false) getNews(buildParams(0)).then(data => { if (cancelled) return setArticles(data.items ?? []) setTotal(data.total ?? 0) setHasMore(data.has_more ?? false) offsetRef.current = PAGE_SIZE }).catch(() => { if (!cancelled) setArticles([]) }).finally(() => { if (!cancelled) { setLoading(false) busyRef.current = false } }) return () => { cancelled = true } }, [buildParams]) // Подгрузка следующего батча const loadMore = useCallback(async () => { if (busyRef.current || !hasMore) return busyRef.current = true setLoadingMore(true) try { const data = await getNews(buildParams(offsetRef.current)) setArticles(prev => [...prev, ...(data.items ?? [])]) setHasMore(data.has_more ?? false) offsetRef.current += PAGE_SIZE } catch {} finally { setLoadingMore(false) busyRef.current = false } }, [hasMore, buildParams]) // IntersectionObserver на sentinel-div внизу списка useEffect(() => { const el = sentinelRef.current if (!el) return const observer = new IntersectionObserver( entries => { if (entries[0].isIntersecting) loadMore() }, { rootMargin: '300px' } ) observer.observe(el) return () => observer.disconnect() }, [loadMore]) const openArticle = async (slug) => { setModalArticle({ _loading: true }) try { const data = await getArticle(slug) setModalArticle(data) } catch { setModalArticle(null) } } return ( <>
📰 Новости колледжей
{searchInput && ( )}
setFilters(f)} /> {searchQuery && !loading && (

{total > 0 ? `Найдено: ${total} ${total === 1 ? 'новость' : total < 5 ? 'новости' : 'новостей'} по запросу «${searchQuery}»` : `По запросу «${searchQuery}» ничего не найдено` }

)} {loading ? ( ) : articles.length === 0 ? (

Новостей не найдено

) : (
{articles.map(a => ( openArticle(a.slug)} /> ))}
)} {/* Sentinel для IntersectionObserver */}
{loadingMore && (
)} {!loading && !hasMore && articles.length > 0 && (

Все новости загружены · {total}

)}
{modalArticle && ( modalArticle._loading ? (
setModalArticle(null)}>
e.stopPropagation()}>
) : ( setModalArticle(null)} /> ) )} ) }