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 (
<>
{total > 0 ? `Найдено: ${total} ${total === 1 ? 'новость' : total < 5 ? 'новости' : 'новостей'} по запросу «${searchQuery}»` : `По запросу «${searchQuery}» ничего не найдено` }
)} {loading ? (Новостей не найдено
) : (Все новости загружены · {total}
)}