infinity serch
This commit is contained in:
@@ -19,12 +19,18 @@ function presetToDateFrom(preset) {
|
||||
|
||||
export default function NewsFeed() {
|
||||
const { dark, toggle } = useTheme()
|
||||
const [articles, setArticles] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [articles, setArticles] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [categories, setCategories] = useState([])
|
||||
const [tags, setTags] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '', page: 1 })
|
||||
const [tags, setTags] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '' })
|
||||
|
||||
const offsetRef = useRef(0)
|
||||
const busyRef = useRef(false)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
// Поиск с debounce
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
@@ -35,19 +41,14 @@ export default function NewsFeed() {
|
||||
const val = e.target.value
|
||||
setSearchInput(val)
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearchQuery(val.trim())
|
||||
setFilters(f => ({ ...f, page: 1 }))
|
||||
}, 400)
|
||||
debounceRef.current = setTimeout(() => setSearchQuery(val.trim()), 400)
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchInput('')
|
||||
setSearchQuery('')
|
||||
setFilters(f => ({ ...f, page: 1 }))
|
||||
}
|
||||
|
||||
// Модальное окно
|
||||
const [modalArticle, setModalArticle] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,30 +58,71 @@ export default function NewsFeed() {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadNews = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = {
|
||||
limit: PAGE_SIZE,
|
||||
offset: (filters.page - 1) * PAGE_SIZE,
|
||||
}
|
||||
if (filters.category) params.category = filters.category
|
||||
if (filters.tag) params.tag = filters.tag
|
||||
const df = presetToDateFrom(filters.datePreset)
|
||||
if (df) params.date_from = df
|
||||
if (searchQuery) params.q = searchQuery
|
||||
|
||||
const data = await getNews(params)
|
||||
setArticles(data.items ?? [])
|
||||
setTotal(data.total ?? 0)
|
||||
} catch {
|
||||
setArticles([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
const buildParams = useCallback((offset) => {
|
||||
const params = { limit: PAGE_SIZE, offset }
|
||||
if (filters.category) params.category = filters.category
|
||||
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])
|
||||
|
||||
useEffect(() => { loadNews() }, [loadNews])
|
||||
// Сброс и первая загрузка при смене фильтров/поиска
|
||||
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 })
|
||||
@@ -92,8 +134,6 @@ export default function NewsFeed() {
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="public-header">
|
||||
@@ -104,7 +144,6 @@ export default function NewsFeed() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Строка поиска */}
|
||||
<div className="search-bar-wrap">
|
||||
<div className="search-bar">
|
||||
<svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -129,10 +168,9 @@ export default function NewsFeed() {
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onChange={f => setFilters(f)}
|
||||
/>
|
||||
|
||||
{/* Счётчик результатов при поиске */}
|
||||
{searchQuery && !loading && (
|
||||
<p className="search-results-hint">
|
||||
{total > 0
|
||||
@@ -142,48 +180,47 @@ export default function NewsFeed() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <Loader />
|
||||
: articles.length === 0
|
||||
? <p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
|
||||
Новостей не найдено
|
||||
</p>
|
||||
: <div className="news-list">
|
||||
{articles.map(a => (
|
||||
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={filters.page <= 1}
|
||||
onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}
|
||||
>← Назад</button>
|
||||
<span className="pagination__info">{filters.page} / {totalPages}</span>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={filters.page >= totalPages}
|
||||
onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
|
||||
>Вперёд →</button>
|
||||
{loading ? (
|
||||
<Loader />
|
||||
) : articles.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
|
||||
Новостей не найдено
|
||||
</p>
|
||||
) : (
|
||||
<div className="news-list">
|
||||
{articles.map(a => (
|
||||
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sentinel для IntersectionObserver */}
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
|
||||
{loadingMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '1.5rem 0' }}>
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasMore && articles.length > 0 && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--c-text-2)', fontSize: '0.85rem', padding: '1.5rem 0' }}>
|
||||
Все новости загружены · {total}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Модальное окно статьи */}
|
||||
{modalArticle && (
|
||||
modalArticle._loading
|
||||
? (
|
||||
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
|
||||
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<Loader />
|
||||
</div>
|
||||
modalArticle._loading ? (
|
||||
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
|
||||
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<Loader />
|
||||
</div>
|
||||
)
|
||||
: <ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
||||
</div>
|
||||
) : (
|
||||
<ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user