CMS copp74.ru дописывает в хвост ссылки путь публикации (/page/<uuid>), из-за чего последний slug ломался. Теперь при чтении URL берём часть slug'а до первого «/» и убираем дубли — покорёженные ссылки работают. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
8.8 KiB
JavaScript
252 lines
8.8 KiB
JavaScript
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 (
|
||
<>
|
||
<header className="public-header">
|
||
<div className="public-header__inner">
|
||
<span className="public-header__logo">📰 Новости колледжей</span>
|
||
<button className="public-header__theme" onClick={toggle} title="Тема">
|
||
{dark ? '☀️' : '🌙'}
|
||
</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">
|
||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||
</svg>
|
||
<input
|
||
className="search-bar__input"
|
||
type="text"
|
||
placeholder="Поиск по заголовку, тексту, тегам..."
|
||
value={searchInput}
|
||
onChange={handleSearch}
|
||
/>
|
||
{searchInput && (
|
||
<button className="search-bar__clear" onClick={clearSearch}>✕</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="news-feed">
|
||
<FilterBar
|
||
categories={categories}
|
||
tags={tags}
|
||
filters={filters}
|
||
onChange={f => setFilters(f)}
|
||
/>
|
||
|
||
{searchQuery && !loading && (
|
||
<p className="search-results-hint">
|
||
{total > 0
|
||
? `Найдено: ${total} ${total === 1 ? 'новость' : total < 5 ? 'новости' : 'новостей'} по запросу «${searchQuery}»`
|
||
: `По запросу «${searchQuery}» ничего не найдено`
|
||
}
|
||
</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>
|
||
)}
|
||
|
||
{/* 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>
|
||
</div>
|
||
) : (
|
||
<ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
||
)
|
||
)}
|
||
</>
|
||
)
|
||
}
|