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

@@ -13,6 +13,7 @@ import ArticleEditor from './pages/admin/ArticleEditor'
import Categories from './pages/admin/Categories'
import MediaLibrary from './pages/admin/MediaLibrary'
import VkSources from './pages/admin/VkSources'
import SystemStatus from './pages/admin/SystemStatus'
function PrivateRoute({ children }) {
const { isAuth } = useAuth()
@@ -36,6 +37,7 @@ export default function App() {
<Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
<Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} />
<Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} />
<Route path="/admin/system" element={<PrivateRoute><SystemStatus /></PrivateRoute>} />
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -97,11 +97,11 @@ export const adminVkStatus = token => req('GET', 'admin/vk/status', token)
export const adminVkListSources = token => req('GET', 'admin/vk/sources', token)
export const adminVkAddSource = (token, group_id, group_name, enabled = true) =>
req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled })
export const adminVkAddSource = (token, group_id, group_name, enabled = true, strict_filter = true) =>
req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled, strict_filter })
export const adminVkUpdateSource = (token, id, group_id, group_name, enabled) =>
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled })
export const adminVkUpdateSource = (token, id, group_id, group_name, enabled, strict_filter = true) =>
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled, strict_filter })
export const adminVkDeleteSource = (token, id) =>
req('DELETE', `admin/vk/sources/${id}`, token)
@@ -109,5 +109,8 @@ export const adminVkDeleteSource = (token, id) =>
export const adminVkImport = token =>
req('POST', 'admin/vk/import', token)
export const adminVkImportHistory = (token, sourceId) =>
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}`, token)
export const adminVkImportHistory = (token, sourceId, sinceDays = 365) =>
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}?since_days=${sinceDays}`, token)
export const adminSystemStatus = token =>
req('GET', 'admin/system/status', token)

View File

@@ -9,6 +9,7 @@ const NAV = [
{ to: '/admin/categories', label: 'Категории', icon: '🏷️' },
{ to: '/admin/media', label: 'Медиа', icon: '🖼️' },
{ to: '/admin/vk', label: 'ВКонтакте', icon: '📥' },
{ to: '/admin/system', label: 'Сервисы', icon: '🖥️' },
]
export default function AdminLayout({ children, title }) {

View File

@@ -9,7 +9,7 @@ const DATE_PRESETS = [
]
export default function FilterBar({ categories, tags, filters, onChange }) {
const set = key => val => onChange({ ...filters, [key]: val, page: 1 })
const set = key => val => onChange({ ...filters, [key]: val })
return (
<div className="filter-bar">

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

View File

@@ -0,0 +1,144 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../../hooks/useAuth'
import { adminSystemStatus } from '../../api/index'
import AdminLayout from '../../components/AdminLayout'
import Loader from '../../components/Loader'
function StatusBadge({ ok }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 10px', borderRadius: 99, fontSize: '0.8rem', fontWeight: 600,
background: ok ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
color: ok ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
}}>
<span style={{ fontSize: '0.65rem' }}>{ok ? '●' : '●'}</span>
{ok ? 'OK' : 'Ошибка'}
</span>
)
}
function ServiceCard({ title, icon, data }) {
if (!data) return null
const rows = Object.entries(data).filter(([k]) => !['ok', 'error'].includes(k))
return (
<div style={{
background: 'var(--c-surface)',
borderRadius: 'var(--r-card)',
boxShadow: 'var(--shadow-card)',
padding: '1.25rem 1.5rem',
borderLeft: `4px solid ${data.ok ? 'var(--c-success, #10b981)' : 'var(--c-danger, #ef4444)'}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.4rem' }}>{icon}</span>
<span style={{ fontWeight: 700, fontSize: '1rem' }}>{title}</span>
</div>
<StatusBadge ok={data.ok} />
</div>
{data.error && (
<div style={{
background: 'var(--c-danger-bg, #fee2e2)',
color: 'var(--c-danger, #991b1b)',
borderRadius: 6, padding: '0.5rem 0.75rem',
fontSize: '0.8rem', fontFamily: 'monospace',
marginBottom: '0.75rem', wordBreak: 'break-all',
}}>
{data.error}
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<tbody>
{rows.map(([key, value]) => (
<tr key={key} style={{ borderBottom: '1px solid var(--c-border, #e5e7eb)' }}>
<td style={{ padding: '0.4rem 0', color: 'var(--c-text-2)', width: '40%', fontWeight: 500 }}>
{LABELS[key] ?? key}
</td>
<td style={{ padding: '0.4rem 0', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{value === null ? <span style={{ color: 'var(--c-text-2)' }}></span>
: value === true ? '✅ да'
: value === false ? '❌ нет'
: String(value)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const LABELS = {
host: 'Хост',
port: 'Порт',
database: 'База данных',
user: 'Пользователь',
version: 'Версия',
latency_ms: 'Задержка (мс)',
db: 'Номер БД',
endpoint: 'Эндпоинт',
bucket: 'Бакет',
public_url: 'Публичный URL',
bucket_exists: 'Бакет существует',
}
export default function SystemStatus() {
const { token } = useAuth()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [lastUpdated, setLastUpdated] = useState(null)
const load = useCallback(async () => {
setLoading(true)
try {
const result = await adminSystemStatus(token)
setData(result)
setLastUpdated(new Date())
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}, [token])
useEffect(() => { load() }, [load])
const allOk = data && Object.values(data).every(s => s.ok)
return (
<AdminLayout title="Состояние сервисов">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{data && (
<span style={{
padding: '4px 14px', borderRadius: 99, fontWeight: 700, fontSize: '0.85rem',
background: allOk ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
color: allOk ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
}}>
{allOk ? '✅ Все сервисы работают' : '⚠️ Есть проблемы'}
</span>
)}
{lastUpdated && (
<span style={{ fontSize: '0.8rem', color: 'var(--c-text-2)' }}>
Обновлено: {lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
<button className="btn btn-ghost btn-sm" onClick={load} disabled={loading}>
{loading ? 'Проверяю...' : '🔄 Обновить'}
</button>
</div>
{loading && !data ? <Loader /> : (
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))' }}>
<ServiceCard title="PostgreSQL" icon="🐘" data={data?.postgres} />
<ServiceCard title="Redis" icon="⚡" data={data?.redis} />
<ServiceCard title="MinIO" icon="🗄️" data={data?.minio} />
</div>
)}
</AdminLayout>
)
}

View File

@@ -17,8 +17,9 @@ export default function VkSources() {
const [confirm, setConfirm] = useState(null)
const [alert, setAlert] = useState(null)
const [importing, setImporting] = useState('')
const [sinceDays, setSinceDays] = useState(365)
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true })
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true, strict_filter: true })
const [editing, setEditing] = useState(null)
const load = async () => {
@@ -40,8 +41,8 @@ export default function VkSources() {
const doAdd = async e => {
e.preventDefault()
try {
await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled)
setForm({ group_id: '', group_name: '', enabled: true })
await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled, form.strict_filter)
setForm({ group_id: '', group_name: '', enabled: true, strict_filter: true })
flash('success', 'Источник добавлен')
load()
} catch(err) { flash('error', String(err.message)) }
@@ -50,7 +51,7 @@ export default function VkSources() {
const doUpdate = async () => {
if (!editing) return
try {
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled)
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled, editing.strict_filter)
setEditing(null)
flash('success', 'Сохранено')
load()
@@ -79,7 +80,7 @@ export default function VkSources() {
const doHistory = async (sourceId = null) => {
setImporting('history')
try {
const r = await adminVkImportHistory(token, sourceId)
const r = await adminVkImportHistory(token, sourceId, sinceDays)
flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`)
} catch { flash('error', 'Ошибка загрузки истории') }
finally { setImporting('') }
@@ -103,7 +104,17 @@ export default function VkSources() {
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
{status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.85rem' }}>
<span style={{ color: 'var(--c-text-2)' }}>Глубина:</span>
<input
type="number" min={1} max={3650} value={sinceDays}
onChange={e => setSinceDays(Number(e.target.value))}
className="form-input"
style={{ width: 70, padding: '3px 6px', fontSize: '0.85rem' }}
/>
<span style={{ color: 'var(--c-text-2)' }}>дн.</span>
</label>
<button
className="btn btn-primary btn-sm"
onClick={doImport}
@@ -136,6 +147,11 @@ export default function VkSources() {
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
Активен
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.875rem', paddingBottom: 4 }}>
<input type="checkbox" checked={form.strict_filter}
onChange={e => setForm(f => ({ ...f, strict_filter: e.target.checked }))} />
Строгий фильтр
</label>
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
</div>
</form>
@@ -148,6 +164,7 @@ export default function VkSources() {
<th>ID группы</th>
<th>Название</th>
<th>Активен</th>
<th title="Строгий фильтр: только посты с тегами. Выкл — все посты паблика">Фильтр тегов</th>
<th>Действия</th>
</tr>
</thead>
@@ -178,7 +195,7 @@ export default function VkSources() {
className="toggle-btn"
data-on={s.enabled}
title={s.enabled ? 'Отключить' : 'Включить'}
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled)
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled, s.strict_filter)
.then(load).catch(() => flash('error', 'Ошибка'))}
>
{s.enabled ? '✅' : '❌'}
@@ -186,6 +203,23 @@ export default function VkSources() {
)
}
</td>
<td>
{editing?.id === s.id
? <input type="checkbox" checked={editing.strict_filter}
onChange={e => setEditing(ed => ({ ...ed, strict_filter: e.target.checked }))} />
: (
<button
className="toggle-btn"
data-on={s.strict_filter}
title={s.strict_filter ? 'Строгий фильтр: только посты с тегами. Нажмите чтобы отключить' : 'Фильтр выключен: импортируются все посты. Нажмите чтобы включить'}
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, s.enabled, !s.strict_filter)
.then(load).catch(() => flash('error', 'Ошибка'))}
>
{s.strict_filter ? '🔒' : '🔓'}
</button>
)
}
</td>
<td>
<div className="td-actions">
{editing?.id === s.id
@@ -208,7 +242,7 @@ export default function VkSources() {
</tr>
))}
{sources.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
Источников нет
</td></tr>
)}