Files
news_all_spo/web-react/src/pages/NewsFeed.jsx
jze9 02a21a8e45 fix: устойчивый разбор category из URL (отрезаем мусор /page/... от CMS)
CMS copp74.ru дописывает в хвост ссылки путь публикации (/page/<uuid>),
из-за чего последний slug ломался. Теперь при чтении URL берём часть
slug'а до первого «/» и убираем дубли — покорёженные ссылки работают.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:59:40 +05:00

252 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)} />
)
)}
</>
)
}