infinity serch

This commit is contained in:
jze9
2026-05-19 17:48:35 +05:00
parent 57ac3fc317
commit eb4e71ee77
17 changed files with 694 additions and 150 deletions

View File

@@ -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)} />
)
)}
</>
)