infinity serch
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
144
web-react/src/pages/admin/SystemStatus.jsx
Normal file
144
web-react/src/pages/admin/SystemStatus.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user