new web ract
This commit is contained in:
22
web-react/Dockerfile
Normal file
22
web-react/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# ── Шаг 1: сборка основного React-приложения ──────────────────────────────────
|
||||
FROM node:20-alpine AS app-build
|
||||
WORKDIR /build
|
||||
COPY web-react/package*.json ./
|
||||
RUN npm ci --prefer-offline
|
||||
COPY web-react/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Шаг 2: сборка редактора статей (TipTap) ───────────────────────────────────
|
||||
FROM node:20-alpine AS editor-build
|
||||
WORKDIR /build
|
||||
COPY web/editor-ui/package*.json ./
|
||||
RUN npm ci --prefer-offline
|
||||
COPY web/editor-ui/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Шаг 3: nginx раздаёт оба приложения ───────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
COPY --from=app-build /build/dist /usr/share/nginx/html/app
|
||||
COPY --from=editor-build /build/dist /usr/share/nginx/html/editor
|
||||
COPY web-react/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
15
web-react/index.html
Normal file
15
web-react/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Новости колледжей</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
web-react/nginx.conf
Normal file
32
web-react/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Главное React-приложение
|
||||
location / {
|
||||
root /usr/share/nginx/html/app;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# Редактор статей (отдельный React-билд)
|
||||
location /editor/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri /editor/index.html;
|
||||
}
|
||||
|
||||
# Статические ассеты редактора (Vite base: '/editor-assets/')
|
||||
location /editor-assets/ {
|
||||
alias /usr/share/nginx/html/editor/;
|
||||
}
|
||||
|
||||
# Проксируем API-запросы на FastAPI
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Отключаем буферизацию для быстрого ответа
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
}
|
||||
2667
web-react/package-lock.json
generated
Normal file
2667
web-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web-react/package.json
Normal file
22
web-react/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "news-react",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
6
web-react/postcss.config.js
Normal file
6
web-react/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
42
web-react/src/App.jsx
Normal file
42
web-react/src/App.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
|
||||
import NewsFeed from './pages/NewsFeed'
|
||||
import NewsDetail from './pages/NewsDetail'
|
||||
import Login from './pages/admin/Login'
|
||||
import Dashboard from './pages/admin/Dashboard'
|
||||
import ArticlesList from './pages/admin/ArticlesList'
|
||||
import ArticleEditor from './pages/admin/ArticleEditor'
|
||||
import Categories from './pages/admin/Categories'
|
||||
import MediaLibrary from './pages/admin/MediaLibrary'
|
||||
import VkSources from './pages/admin/VkSources'
|
||||
|
||||
function PrivateRoute({ children }) {
|
||||
const { isAuth } = useAuth()
|
||||
return isAuth ? children : <Navigate to="/admin/login" replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useTheme() // инициализация темы при старте
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Публичные */}
|
||||
<Route path="/" element={<NewsFeed />} />
|
||||
<Route path="/news/:slug" element={<NewsDetail />} />
|
||||
|
||||
{/* Админ */}
|
||||
<Route path="/admin/login" element={<Login />} />
|
||||
<Route path="/admin" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
||||
<Route path="/admin/articles" element={<PrivateRoute><ArticlesList /></PrivateRoute>} />
|
||||
<Route path="/admin/articles/:id" element={<PrivateRoute><ArticleEditor /></PrivateRoute>} />
|
||||
<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="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
112
web-react/src/api/index.js
Normal file
112
web-react/src/api/index.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// Все запросы к FastAPI идут через /api (nginx проксирует на api:8000)
|
||||
const BASE = '/api'
|
||||
|
||||
// Базовая функция запроса
|
||||
async function req(method, path, token, body, isForm = false) {
|
||||
const headers = {}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
if (!isForm) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(`${BASE}/${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: isForm ? body : (body !== undefined ? JSON.stringify(body) : undefined),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || res.statusText)
|
||||
}
|
||||
// 204 No Content — не парсим JSON
|
||||
if (res.status === 204) return true
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Публичные эндпоинты ────────────────────────────────────────────────────
|
||||
|
||||
export const getNews = (params = {}) => {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => { if (v != null) q.set(k, v) })
|
||||
return req('GET', `news?${q}`)
|
||||
}
|
||||
|
||||
export const getArticle = slug => req('GET', `news/${slug}`)
|
||||
|
||||
export const getCategories = () => req('GET', 'news/categories')
|
||||
|
||||
export const getTags = (limit = 30) => req('GET', `news/tags?limit=${limit}`)
|
||||
|
||||
// ── Авторизация ────────────────────────────────────────────────────────────
|
||||
|
||||
export const login = (username, password) =>
|
||||
req('POST', 'admin/login', null, { username, password })
|
||||
|
||||
// ── Статьи (админ) ─────────────────────────────────────────────────────────
|
||||
|
||||
export const adminCreateArticle = (token, title = 'Новая статья') =>
|
||||
req('POST', 'admin/articles', token, { title, content: '', status: 'draft' })
|
||||
|
||||
export const adminListArticles = (token, params = {}) => {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => { if (v != null) q.set(k, v) })
|
||||
return req('GET', `admin/articles?${q}`, token)
|
||||
}
|
||||
|
||||
export const adminDeleteArticle = (token, id) =>
|
||||
req('DELETE', `admin/articles/${id}`, token)
|
||||
|
||||
export const adminPublishArticle = (token, id) =>
|
||||
req('POST', `admin/articles/${id}/publish`, token)
|
||||
|
||||
// ── Категории (админ) ──────────────────────────────────────────────────────
|
||||
|
||||
export const adminListCategories = token => req('GET', 'admin/categories', token)
|
||||
|
||||
export const adminCreateCategory = (token, name) =>
|
||||
req('POST', 'admin/categories', token, { name })
|
||||
|
||||
export const adminUpdateCategory = (token, id, name) =>
|
||||
req('PUT', `admin/categories/${id}`, token, { name })
|
||||
|
||||
export const adminDeleteCategory = (token, id) =>
|
||||
req('DELETE', `admin/categories/${id}`, token)
|
||||
|
||||
// ── Медиа (админ) ──────────────────────────────────────────────────────────
|
||||
|
||||
export const adminListMedia = (token, { type, offset = 0, limit = 40 } = {}) => {
|
||||
const p = new URLSearchParams()
|
||||
if (type) p.set('media_type', type)
|
||||
p.set('offset', offset)
|
||||
p.set('limit', limit)
|
||||
return req('GET', `admin/media?${p}`, token)
|
||||
}
|
||||
|
||||
export const adminUploadMedia = (token, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return req('POST', 'admin/media/upload', token, fd, true)
|
||||
}
|
||||
|
||||
export const adminDeleteMedia = (token, id) =>
|
||||
req('DELETE', `admin/media/${id}`, token)
|
||||
|
||||
// ── VK источники (админ) ───────────────────────────────────────────────────
|
||||
|
||||
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 adminVkUpdateSource = (token, id, group_id, group_name, enabled) =>
|
||||
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled })
|
||||
|
||||
export const adminVkDeleteSource = (token, id) =>
|
||||
req('DELETE', `admin/vk/sources/${id}`, token)
|
||||
|
||||
export const adminVkImport = token =>
|
||||
req('POST', 'admin/vk/import', token)
|
||||
|
||||
export const adminVkImportHistory = (token, sourceId) =>
|
||||
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}`, token)
|
||||
532
web-react/src/app.css
Normal file
532
web-react/src/app.css
Normal file
@@ -0,0 +1,532 @@
|
||||
/* =====================================================================
|
||||
КОМПОНЕНТЫ — стили зависят от переменных theme.css
|
||||
===================================================================== */
|
||||
|
||||
/* ── Кнопки ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--r-btn);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s, background .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { opacity: .88; }
|
||||
.btn:active { opacity: .75; }
|
||||
|
||||
.btn-primary { background: var(--c-primary); color: #fff; }
|
||||
.btn-danger { background: var(--c-error); color: #fff; }
|
||||
.btn-ghost { background: transparent; color: var(--c-text-1); border: 1px solid var(--c-border); }
|
||||
.btn-sm { padding: 5px 11px; font-size: 0.8rem; }
|
||||
.btn-icon { padding: 8px; }
|
||||
|
||||
/* ── Чипы / фильтры ── */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text-2);
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip--ghost { background: transparent; }
|
||||
.chip--active { background: var(--c-primary); color: #fff; border-color: var(--c-primary); }
|
||||
.chip--primary { background: rgba(var(--c-primary-rgb),.12); color: var(--c-primary); border-color: transparent; }
|
||||
.chip__count { font-size: 0.72rem; opacity: .7; }
|
||||
|
||||
/* ── Спиннер ── */
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--c-border);
|
||||
border-top-color: var(--c-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Модальное окно ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--c-surface);
|
||||
border-radius: var(--r-card);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
/* ── Карточка новости ── */
|
||||
.news-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--c-card);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
}
|
||||
.news-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.14);
|
||||
}
|
||||
.news-card__cover { height: 175px; overflow: hidden; background: var(--c-border); flex-shrink: 0; }
|
||||
.news-card__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.news-card__cover-placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, var(--c-border), var(--c-bg)); }
|
||||
.news-card__body { padding: 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.news-card__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.news-card__date { font-size: 0.76rem; color: var(--c-text-2); margin-left: auto; }
|
||||
.news-card__title { font-family: 'Merriweather', serif; font-size: 0.95rem; font-weight: 700; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.news-card__tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.news-card__source { margin-top: auto; display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--c-text-2); }
|
||||
|
||||
/* ── Лента новостей ── */
|
||||
.news-feed { max-width: 680px; margin: 0 auto; padding: 0 1rem 3rem; }
|
||||
|
||||
/* ── VK-стиль: список постов ── */
|
||||
.news-list { display: flex; flex-direction: column; gap: 12px; margin-top: 1rem; }
|
||||
|
||||
.vk-card {
|
||||
background: var(--c-card);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .18s;
|
||||
border: 1px solid var(--c-border);
|
||||
}
|
||||
.vk-card:hover { box-shadow: var(--shadow-panel); }
|
||||
|
||||
.vk-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vk-card__source-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.vk-card__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--c-primary-rgb), .15);
|
||||
color: var(--c-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vk-card__category { font-weight: 600; font-size: 0.9rem; color: var(--c-text-1); }
|
||||
.vk-card__date { font-size: 0.78rem; color: var(--c-text-2); margin-top: 1px; }
|
||||
.vk-card__source-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--c-text-2);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vk-card__source-link:hover { color: var(--c-primary); }
|
||||
|
||||
.vk-card__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: var(--c-text-1);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.vk-card__excerpt {
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-text-2);
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vk-card__img-wrap {
|
||||
margin: 10px -16px;
|
||||
overflow: hidden;
|
||||
max-height: 420px;
|
||||
}
|
||||
.vk-card__img-wrap--expanded { max-height: none; }
|
||||
.vk-card__img-wrap img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
cursor: zoom-in;
|
||||
transition: transform .2s;
|
||||
}
|
||||
.vk-card__img-wrap--expanded img { cursor: zoom-out; object-fit: unset; }
|
||||
|
||||
.vk-card__tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
|
||||
.vk-card__footer { display: flex; align-items: center; justify-content: flex-end; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--c-border); }
|
||||
.vk-card__read { font-size: 0.82rem; color: var(--c-primary); font-weight: 500; }
|
||||
|
||||
.public-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
.public-header__inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.public-header__logo { font-weight: 700; font-size: 1.1rem; color: var(--c-primary); flex: 1; }
|
||||
.public-header__theme { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px; }
|
||||
|
||||
/* Строка поиска в шапке */
|
||||
.search-bar-wrap {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--c-bg);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 24px;
|
||||
padding: 8px 14px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.search-bar:focus-within {
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--c-primary-rgb), .12);
|
||||
}
|
||||
.search-bar__icon { color: var(--c-text-2); flex-shrink: 0; }
|
||||
.search-bar__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-text-1);
|
||||
}
|
||||
.search-bar__input::placeholder { color: var(--c-text-2); }
|
||||
.search-bar__clear {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--c-text-2);
|
||||
font-size: 0.85rem;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.search-bar__clear:hover { color: var(--c-text-1); }
|
||||
|
||||
.search-results-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.filter-bar { padding: 1rem 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pagination { display: flex; align-items: center; gap: 0.75rem; justify-content: center; margin-top: 2rem; }
|
||||
.pagination__info { font-size: 0.85rem; color: var(--c-text-2); }
|
||||
|
||||
/* ── Детальная страница ── */
|
||||
.article-page { max-width: 780px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||
.article-cover { width: 100%; max-height: 420px; object-fit: cover; border-radius: var(--r-card); margin-bottom: 1.5rem; }
|
||||
.article-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 1.25rem; color: var(--c-text-2); font-size: 0.85rem; }
|
||||
.article-title { font-family: 'Merriweather', serif; font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 700; line-height: 1.3; margin-bottom: 1.5rem; }
|
||||
.article-body { line-height: 1.8; font-size: 1.0625rem; }
|
||||
.article-body img { max-width: 100%; border-radius: var(--r-card); }
|
||||
.article-body h2, .article-body h3 { margin: 1.5rem 0 .75rem; font-family: 'Merriweather', serif; }
|
||||
.article-source { display: flex; align-items: center; gap: 8px; padding: 12px 16px; background: rgba(var(--c-primary-rgb),.07); border: 1px solid rgba(var(--c-primary-rgb),.2); border-radius: var(--r-card); font-size: 0.875rem; color: var(--c-primary); margin-top: 2rem; }
|
||||
.article-source a { color: var(--c-primary); text-decoration: underline; word-break: break-all; }
|
||||
.article-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 1.5rem; }
|
||||
.back-btn { display: inline-flex; align-items: center; gap: 6px; color: var(--c-primary); cursor: pointer; background: none; border: none; font-size: 0.875rem; padding: 0; margin-bottom: 1.5rem; }
|
||||
|
||||
/* ── Админ layout ── */
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--c-sidebar);
|
||||
color: var(--c-sidebar-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar__logo {
|
||||
padding: 1.25rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sidebar__close { display: none; background: none; border: none; color: inherit; cursor: pointer; font-size: 1.1rem; }
|
||||
.sidebar__nav { flex: 1; padding: 0.75rem 0; }
|
||||
.sidebar__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 1rem;
|
||||
color: var(--c-sidebar-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
opacity: .8;
|
||||
transition: background .15s, opacity .15s;
|
||||
}
|
||||
.sidebar__link:hover { background: rgba(255,255,255,.07); opacity: 1; }
|
||||
.sidebar__link--active { background: var(--c-sidebar-active); opacity: 1; font-weight: 600; }
|
||||
.sidebar__logout {
|
||||
margin: 0.75rem;
|
||||
padding: 10px 1rem;
|
||||
background: rgba(255,255,255,.07);
|
||||
border: none;
|
||||
border-radius: var(--r-btn);
|
||||
color: var(--c-sidebar-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
}
|
||||
.sidebar-overlay { display: none; }
|
||||
|
||||
.admin-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.admin-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding: 0 1.5rem;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
.topbar__burger { display: none; background: none; border: none; cursor: pointer; font-size: 1.4rem; padding: 4px; color: var(--c-text-1); }
|
||||
.topbar__title { font-size: 1.1rem; font-weight: 600; flex: 1; }
|
||||
.topbar__theme { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px; }
|
||||
.admin-content { padding: 1.5rem; flex: 1; }
|
||||
|
||||
/* ── Таблица ── */
|
||||
.table-wrap { overflow-x: auto; background: var(--c-surface); border-radius: var(--r-card); box-shadow: var(--shadow-card); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
th { padding: 10px 14px; text-align: left; font-weight: 600; color: var(--c-text-2); border-bottom: 2px solid var(--c-border); white-space: nowrap; }
|
||||
td { padding: 10px 14px; border-bottom: 1px solid var(--c-border); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(var(--c-primary-rgb),.04); }
|
||||
.td-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Форма / Input ── */
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-2); }
|
||||
.form-input, .form-select {
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--r-input);
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text-1);
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
width: 100%;
|
||||
}
|
||||
.form-input:focus, .form-select:focus { border-color: var(--c-primary); }
|
||||
|
||||
/* ── Карточки статистики (дашборд) ── */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.stat-card { background: var(--c-surface); border-radius: var(--r-card); padding: 1.25rem; box-shadow: var(--shadow-card); }
|
||||
.stat-card__value { font-size: 2rem; font-weight: 700; color: var(--c-primary); line-height: 1; }
|
||||
.stat-card__label { font-size: 0.8rem; color: var(--c-text-2); margin-top: 4px; }
|
||||
|
||||
/* ── Страница входа ── */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--c-bg);
|
||||
}
|
||||
.login-box {
|
||||
background: var(--c-surface);
|
||||
border-radius: var(--r-card);
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: var(--shadow-panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.login-box h1 { font-size: 1.5rem; font-weight: 700; text-align: center; color: var(--c-primary); }
|
||||
.error-msg { color: var(--c-error); font-size: 0.85rem; text-align: center; }
|
||||
|
||||
/* ── Алерт ── */
|
||||
.alert { padding: 10px 14px; border-radius: var(--r-card); font-size: 0.875rem; margin-bottom: 1rem; }
|
||||
.alert-success { background: rgba(61,196,126,.12); color: var(--c-success); border: 1px solid var(--c-success); }
|
||||
.alert-error { background: rgba(230,70,70,.10); color: var(--c-error); border: 1px solid var(--c-error); }
|
||||
|
||||
/* ── Медиа галерея ── */
|
||||
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||
.media-item { background: var(--c-surface); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-card); }
|
||||
.media-item img { width: 100%; height: 120px; object-fit: cover; display: block; }
|
||||
.media-item__info { padding: 8px; font-size: 0.75rem; color: var(--c-text-2); display: flex; justify-content: space-between; align-items: center; }
|
||||
.media-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
|
||||
|
||||
/* ── Toggle-кнопка (активен/неактивен) ── */
|
||||
.toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
transition: transform .15s, opacity .15s;
|
||||
opacity: .85;
|
||||
}
|
||||
.toggle-btn:hover { transform: scale(1.2); opacity: 1; }
|
||||
|
||||
/* ── VK ── */
|
||||
.vk-status-bar { background: var(--c-surface); border-radius: var(--r-card); padding: 1rem 1.25rem; box-shadow: var(--shadow-card); margin-bottom: 1.25rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.vk-status__dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-green { background: var(--c-success); }
|
||||
.dot-red { background: var(--c-error); }
|
||||
.dot-yellow { background: var(--c-warning); }
|
||||
|
||||
/* ── Модалка статьи ── */
|
||||
.article-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.6);
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 2vh 1rem;
|
||||
overflow-y: auto;
|
||||
animation: fadeIn .18s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
.article-modal {
|
||||
background: var(--c-surface);
|
||||
border-radius: var(--r-card);
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
position: relative;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,.35);
|
||||
animation: slideUp .22s ease;
|
||||
margin: auto;
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(24px); opacity: 0 } to { transform: none; opacity: 1 } }
|
||||
|
||||
.article-modal__close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0,0,0,.18);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
transition: background .15s;
|
||||
}
|
||||
.article-modal__close:hover { background: rgba(0,0,0,.4); }
|
||||
|
||||
.article-modal__cover {
|
||||
width: 100%;
|
||||
max-height: 380px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--r-card) var(--r-card) 0 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.article-modal__body {
|
||||
padding: 1.75rem 2rem 2rem;
|
||||
}
|
||||
|
||||
|
||||
/* ── Адаптив (мобилки) ── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: -260px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 200;
|
||||
transition: left .25s;
|
||||
width: 240px;
|
||||
}
|
||||
.sidebar--open { left: 0; }
|
||||
.sidebar__close { display: block; }
|
||||
.sidebar-overlay { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 199; }
|
||||
.topbar__burger { display: block; }
|
||||
|
||||
.admin-content { padding: 1rem; }
|
||||
.news-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 10px; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
61
web-react/src/components/AdminLayout.jsx
Normal file
61
web-react/src/components/AdminLayout.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useState } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin', label: 'Дашборд', icon: '🏠', end: true },
|
||||
{ to: '/admin/articles', label: 'Статьи', icon: '📰' },
|
||||
{ to: '/admin/categories', label: 'Категории', icon: '🏷️' },
|
||||
{ to: '/admin/media', label: 'Медиа', icon: '🖼️' },
|
||||
{ to: '/admin/vk', label: 'ВКонтакте', icon: '📥' },
|
||||
]
|
||||
|
||||
export default function AdminLayout({ children, title }) {
|
||||
const { logout } = useAuth()
|
||||
const { dark, toggle } = useTheme()
|
||||
const [sideOpen, setSideOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
{/* Sidebar */}
|
||||
<aside className={`sidebar ${sideOpen ? 'sidebar--open' : ''}`}>
|
||||
<div className="sidebar__logo">
|
||||
<span>📋 Новости</span>
|
||||
<button className="sidebar__close" onClick={() => setSideOpen(false)}>✕</button>
|
||||
</div>
|
||||
<nav className="sidebar__nav">
|
||||
{NAV.map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className={({ isActive }) => `sidebar__link ${isActive ? 'sidebar__link--active' : ''}`}
|
||||
onClick={() => setSideOpen(false)}
|
||||
>
|
||||
<span>{n.icon}</span> {n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<button className="sidebar__logout" onClick={logout}>⬅️ Выйти</button>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sideOpen && <div className="sidebar-overlay" onClick={() => setSideOpen(false)} />}
|
||||
|
||||
{/* Main */}
|
||||
<div className="admin-main">
|
||||
<header className="admin-topbar">
|
||||
<button className="topbar__burger" onClick={() => setSideOpen(true)}>☰</button>
|
||||
<h1 className="topbar__title">{title}</h1>
|
||||
<button className="topbar__theme" onClick={toggle} title="Сменить тему">
|
||||
{dark ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</header>
|
||||
<main className="admin-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
web-react/src/components/ArticleModal.jsx
Normal file
69
web-react/src/components/ArticleModal.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function ArticleModal({ article, onClose }) {
|
||||
// Закрытие по Escape
|
||||
useEffect(() => {
|
||||
const handler = e => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
// Блокируем скролл страницы под модалкой
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="article-modal-overlay" onClick={onClose}>
|
||||
<div className="article-modal" onClick={e => e.stopPropagation()}>
|
||||
<button className="article-modal__close" onClick={onClose} aria-label="Закрыть">✕</button>
|
||||
|
||||
{article.cover_url && (
|
||||
<img className="article-modal__cover" src={article.cover_url} alt={article.title} />
|
||||
)}
|
||||
|
||||
<div className="article-modal__body">
|
||||
<div className="article-meta">
|
||||
{article.category && (
|
||||
<span className="chip chip--primary">{article.category.name}</span>
|
||||
)}
|
||||
<span>{fmt(article.published_at)}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="article-title">{article.title}</h1>
|
||||
|
||||
<div
|
||||
className="article-body"
|
||||
dangerouslySetInnerHTML={{ __html: article.content ?? '' }}
|
||||
/>
|
||||
|
||||
{article.tags?.length > 0 && (
|
||||
<div className="article-tags">
|
||||
{article.tags.map(t => (
|
||||
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{article.source_url && (
|
||||
<div className="article-source">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
<span>Источник:</span>
|
||||
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
|
||||
{article.source_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
web-react/src/components/Confirm.jsx
Normal file
15
web-react/src/components/Confirm.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Confirm({ message, onOk, onCancel }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
||||
<p style={{ marginBottom: '1.5rem', color: 'var(--c-text-1)' }}>{message}</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel}>Отмена</button>
|
||||
<button className="btn btn-danger" onClick={onOk}>Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
web-react/src/components/FilterBar.jsx
Normal file
56
web-react/src/components/FilterBar.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
|
||||
const DATE_PRESETS = [
|
||||
{ value: '', label: 'Всё время' },
|
||||
{ value: 'week', label: 'Неделя' },
|
||||
{ value: 'month', label: 'Месяц' },
|
||||
{ value: '3months',label: '3 месяца' },
|
||||
{ value: 'year', label: 'Год' },
|
||||
]
|
||||
|
||||
export default function FilterBar({ categories, tags, filters, onChange }) {
|
||||
const set = key => val => onChange({ ...filters, [key]: val, page: 1 })
|
||||
|
||||
return (
|
||||
<div className="filter-bar">
|
||||
{/* Категории */}
|
||||
<div className="filter-row">
|
||||
<button
|
||||
className={`chip ${!filters.category ? 'chip--active' : 'chip--ghost'}`}
|
||||
onClick={() => set('category')(null)}
|
||||
>Все</button>
|
||||
{categories.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`chip ${filters.category === c.slug ? 'chip--active' : 'chip--ghost'}`}
|
||||
onClick={() => set('category')(c.slug)}
|
||||
>{c.name}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Теги */}
|
||||
{tags.length > 0 && (
|
||||
<div className="filter-row">
|
||||
{tags.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`chip ${filters.tag === t.slug ? 'chip--active' : 'chip--ghost'}`}
|
||||
onClick={() => set('tag')(filters.tag === t.slug ? null : t.slug)}
|
||||
>#{t.name} <span className="chip__count">{t.count}</span></button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Даты */}
|
||||
<div className="filter-row">
|
||||
{DATE_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`chip ${(filters.datePreset ?? '') === p.value ? 'chip--active' : 'chip--ghost'}`}
|
||||
onClick={() => set('datePreset')(p.value)}
|
||||
>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
web-react/src/components/Loader.jsx
Normal file
13
web-react/src/components/Loader.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Loader({ full = false }) {
|
||||
const style = full
|
||||
? { display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }
|
||||
: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem' }
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
web-react/src/components/NewsCard.jsx
Normal file
82
web-react/src/components/NewsCard.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
function domain(url) {
|
||||
try { return new URL(url).hostname.replace('www.', '') }
|
||||
catch { return url }
|
||||
}
|
||||
|
||||
export default function NewsCard({ article, onClick }) {
|
||||
const { title, cover_url, excerpt, category, published_at, tags = [], source_url } = article
|
||||
const [imgExpanded, setImgExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<article className="vk-card" onClick={onClick}>
|
||||
{/* Шапка: категория + дата */}
|
||||
<div className="vk-card__header">
|
||||
<div className="vk-card__source-info">
|
||||
<div className="vk-card__avatar">
|
||||
{category?.name?.[0]?.toUpperCase() ?? '📰'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="vk-card__category">{category?.name ?? 'Новости'}</div>
|
||||
<div className="vk-card__date">{fmt(published_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{source_url && (
|
||||
<a
|
||||
className="vk-card__source-link"
|
||||
href={source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
title="Источник"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
{domain(source_url)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<h3 className="vk-card__title">{title}</h3>
|
||||
|
||||
{/* Превью текста */}
|
||||
{excerpt && <p className="vk-card__excerpt">{excerpt}</p>}
|
||||
|
||||
{/* Обложка */}
|
||||
{cover_url && (
|
||||
<div className={`vk-card__img-wrap ${imgExpanded ? 'vk-card__img-wrap--expanded' : ''}`}>
|
||||
<img
|
||||
src={cover_url}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
onClick={e => { e.stopPropagation(); setImgExpanded(v => !v) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Теги */}
|
||||
{tags.length > 0 && (
|
||||
<div className="vk-card__tags" onClick={e => e.stopPropagation()}>
|
||||
{tags.map(t => (
|
||||
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подвал */}
|
||||
<div className="vk-card__footer">
|
||||
<span className="vk-card__read">Читать полностью →</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
25
web-react/src/components/StatusBadge.jsx
Normal file
25
web-react/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
const MAP = {
|
||||
published: { label: 'Опубликовано', color: 'var(--c-success)' },
|
||||
draft: { label: 'Черновик', color: 'var(--c-warning)' },
|
||||
archived: { label: 'Архив', color: 'var(--c-text-2)' },
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
const { label, color } = MAP[status] ?? { label: status, color: 'var(--c-text-2)' }
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: 'var(--r-chip)',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
background: color + '22',
|
||||
color,
|
||||
border: `1px solid ${color}55`,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
21
web-react/src/hooks/useAuth.js
Normal file
21
web-react/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Токен хранится в localStorage — сохраняется при перезагрузке
|
||||
export function useAuth() {
|
||||
const [token, setToken] = useState(() => localStorage.getItem('token') || '')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const saveToken = t => {
|
||||
localStorage.setItem('token', t)
|
||||
setToken(t)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
setToken('')
|
||||
navigate('/admin/login')
|
||||
}
|
||||
|
||||
return { token, saveToken, logout, isAuth: !!token }
|
||||
}
|
||||
13
web-react/src/hooks/useTheme.js
Normal file
13
web-react/src/hooks/useTheme.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Тёмная тема: переключает атрибут data-theme на <html>
|
||||
export function useTheme() {
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('theme') === 'dark')
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
localStorage.setItem('theme', dark ? 'dark' : 'light')
|
||||
}, [dark])
|
||||
|
||||
return { dark, toggle: () => setDark(d => !d) }
|
||||
}
|
||||
12
web-react/src/main.jsx
Normal file
12
web-react/src/main.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './theme.css'
|
||||
import './app.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
)
|
||||
77
web-react/src/pages/NewsDetail.jsx
Normal file
77
web-react/src/pages/NewsDetail.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { getArticle } from '../api/index'
|
||||
import Loader from '../components/Loader'
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function NewsDetail() {
|
||||
const { slug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [article, setArticle] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getArticle(slug)
|
||||
.then(setArticle)
|
||||
.catch(() => setError('Статья не найдена'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
if (loading) return <Loader full />
|
||||
if (error) return (
|
||||
<div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||
<p style={{ color: 'var(--c-text-2)' }}>{error}</p>
|
||||
<button className="btn btn-ghost" style={{ marginTop: '1rem' }} onClick={() => navigate('/')}>
|
||||
← На главную
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="article-page">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>← Назад</button>
|
||||
|
||||
{article.cover_url && (
|
||||
<img className="article-cover" src={article.cover_url} alt={article.title} />
|
||||
)}
|
||||
|
||||
<div className="article-meta">
|
||||
{article.category && <span className="chip chip--primary">{article.category.name}</span>}
|
||||
<span>{fmt(article.published_at)}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="article-title">{article.title}</h1>
|
||||
|
||||
<div
|
||||
className="article-body"
|
||||
dangerouslySetInnerHTML={{ __html: article.body ?? '' }}
|
||||
/>
|
||||
|
||||
{article.tags?.length > 0 && (
|
||||
<div className="article-tags">
|
||||
{article.tags.map(t => (
|
||||
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{article.source_url && (
|
||||
<div className="article-source">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
<span>Источник:</span>
|
||||
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
|
||||
{article.source_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
web-react/src/pages/NewsFeed.jsx
Normal file
190
web-react/src/pages/NewsFeed.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
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 [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 })
|
||||
|
||||
// Поиск с debounce
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
const handleSearch = e => {
|
||||
const val = e.target.value
|
||||
setSearchInput(val)
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearchQuery(val.trim())
|
||||
setFilters(f => ({ ...f, page: 1 }))
|
||||
}, 400)
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchInput('')
|
||||
setSearchQuery('')
|
||||
setFilters(f => ({ ...f, page: 1 }))
|
||||
}
|
||||
|
||||
// Модальное окно
|
||||
const [modalArticle, setModalArticle] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getCategories(), getTags(30)]).then(([cats, tgs]) => {
|
||||
setCategories(cats)
|
||||
setTags(tgs)
|
||||
}).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)
|
||||
}
|
||||
}, [filters, searchQuery])
|
||||
|
||||
useEffect(() => { loadNews() }, [loadNews])
|
||||
|
||||
const openArticle = async (slug) => {
|
||||
setModalArticle({ _loading: true })
|
||||
try {
|
||||
const data = await getArticle(slug)
|
||||
setModalArticle(data)
|
||||
} catch {
|
||||
setModalArticle(null)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
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={setFilters}
|
||||
/>
|
||||
|
||||
{/* Счётчик результатов при поиске */}
|
||||
{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>
|
||||
}
|
||||
|
||||
{!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>
|
||||
</div>
|
||||
)}
|
||||
</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)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
29
web-react/src/pages/admin/ArticleEditor.jsx
Normal file
29
web-react/src/pages/admin/ArticleEditor.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
|
||||
export default function ArticleEditor() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { token } = useAuth()
|
||||
|
||||
const editorUrl = `/editor/${id}?t=${token}`
|
||||
|
||||
// Слушаем сообщение «закрыть» из iframe-редактора
|
||||
React.useEffect(() => {
|
||||
const handler = e => {
|
||||
if (e.data?.type === 'editor-close') navigate('/admin/articles')
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
return () => window.removeEventListener('message', handler)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={editorUrl}
|
||||
style={{ position: 'fixed', inset: 0, width: '100%', height: '100%', border: 'none', zIndex: 300 }}
|
||||
title="Редактор статьи"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
)
|
||||
}
|
||||
162
web-react/src/pages/admin/ArticlesList.jsx
Normal file
162
web-react/src/pages/admin/ArticlesList.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import { adminListArticles, adminDeleteArticle, adminPublishArticle, adminCreateArticle } from '../../api/index'
|
||||
import AdminLayout from '../../components/AdminLayout'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
import Confirm from '../../components/Confirm'
|
||||
import Loader from '../../components/Loader'
|
||||
|
||||
const PAGE = 20
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function ArticlesList() {
|
||||
const { token } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [items, setItems] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [status, setStatus] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [confirm, setConfirm] = useState(null) // { id, title }
|
||||
|
||||
const createNew = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
const article = await adminCreateArticle(token)
|
||||
navigate(`/admin/articles/${article.id}`)
|
||||
} catch {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce поиска 300ms
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setSearchQuery(searchInput); setPage(1) }, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [searchInput])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = { limit: PAGE, offset: (page - 1) * PAGE }
|
||||
if (status) params.status = status
|
||||
if (searchQuery) params.q = searchQuery
|
||||
const data = await adminListArticles(token, params)
|
||||
setItems(data?.items ?? [])
|
||||
setTotal(data?.total ?? 0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token, page, status, searchQuery])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return
|
||||
await adminDeleteArticle(token, confirm.id).catch(() => {})
|
||||
setConfirm(null)
|
||||
load()
|
||||
}
|
||||
|
||||
const doPublish = async id => {
|
||||
await adminPublishArticle(token, id).catch(() => {})
|
||||
load()
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE))
|
||||
|
||||
return (
|
||||
<AdminLayout title="Статьи">
|
||||
{confirm && (
|
||||
<Confirm
|
||||
message={`Удалить «${confirm.title}»?`}
|
||||
onOk={doDelete}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Тулбар */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={createNew} disabled={creating}>
|
||||
{creating ? 'Создаю...' : '✏️ Написать новость'}
|
||||
</button>
|
||||
<div style={{ width: 1, height: 28, background: 'var(--c-border)', margin: '0 4px' }} />
|
||||
{['', 'published', 'draft'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
className={`btn btn-sm ${status === s ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => { setStatus(s); setPage(1) }}
|
||||
>
|
||||
{s === '' ? 'Все' : s === 'published' ? 'Опубликованные' : 'Черновики'}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ width: 1, height: 28, background: 'var(--c-border)', margin: '0 4px' }} />
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Поиск по названию..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
style={{ width: 220, fontSize: '0.85rem' }}
|
||||
/>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.85rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
|
||||
Всего: {total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? <Loader /> : (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Заголовок</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td style={{ maxWidth: 340, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{a.title}
|
||||
</td>
|
||||
<td><StatusBadge status={a.status} /></td>
|
||||
<td style={{ color: 'var(--c-text-2)', fontSize: '0.82rem' }}>
|
||||
{fmt(a.published_at ?? a.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="td-actions">
|
||||
<Link to={`/admin/articles/${a.id}`} className="btn btn-ghost btn-sm">✏️</Link>
|
||||
{a.status !== 'published' && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => doPublish(a.id)}>✅</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ id: a.id, title: a.title })}>🗑</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination" style={{ marginTop: '1rem' }}>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>← Назад</button>
|
||||
<span className="pagination__info">{page} / {totalPages}</span>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Вперёд →</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
126
web-react/src/pages/admin/Categories.jsx
Normal file
126
web-react/src/pages/admin/Categories.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import { adminListCategories, adminCreateCategory, adminUpdateCategory, adminDeleteCategory } from '../../api/index'
|
||||
import AdminLayout from '../../components/AdminLayout'
|
||||
import Confirm from '../../components/Confirm'
|
||||
import Loader from '../../components/Loader'
|
||||
|
||||
export default function Categories() {
|
||||
const { token } = useAuth()
|
||||
const [cats, setCats] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [editing, setEditing] = useState(null) // { id, name }
|
||||
const [confirm, setConfirm] = useState(null) // { id, name }
|
||||
const [alert, setAlert] = useState(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try { setCats(await adminListCategories(token)) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [token])
|
||||
|
||||
const flash = (type, msg) => {
|
||||
setAlert({ type, msg })
|
||||
setTimeout(() => setAlert(null), 3000)
|
||||
}
|
||||
|
||||
const doCreate = async e => {
|
||||
e.preventDefault()
|
||||
if (!newName.trim()) return
|
||||
try {
|
||||
await adminCreateCategory(token, newName.trim())
|
||||
setNewName('')
|
||||
flash('success', 'Категория создана')
|
||||
load()
|
||||
} catch { flash('error', 'Ошибка создания') }
|
||||
}
|
||||
|
||||
const doUpdate = async () => {
|
||||
if (!editing) return
|
||||
try {
|
||||
await adminUpdateCategory(token, editing.id, editing.name)
|
||||
setEditing(null)
|
||||
flash('success', 'Сохранено')
|
||||
load()
|
||||
} catch { flash('error', 'Ошибка сохранения') }
|
||||
}
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return
|
||||
try {
|
||||
await adminDeleteCategory(token, confirm.id)
|
||||
setConfirm(null)
|
||||
flash('success', 'Удалено')
|
||||
load()
|
||||
} catch { flash('error', 'Ошибка удаления') }
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Категории">
|
||||
{confirm && (
|
||||
<Confirm
|
||||
message={`Удалить категорию «${confirm.name}»?`}
|
||||
onOk={doDelete}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{alert && <div className={`alert alert-${alert.type}`}>{alert.msg}</div>}
|
||||
|
||||
<form onSubmit={doCreate} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', maxWidth: 480 }}>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Название новой категории"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="btn btn-primary" type="submit" style={{ flexShrink: 0 }}>Добавить</button>
|
||||
</form>
|
||||
|
||||
{loading ? <Loader /> : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Название</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cats.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
{editing?.id === c.id
|
||||
? <input
|
||||
className="form-input"
|
||||
value={editing.name}
|
||||
onChange={e => setEditing(ed => ({ ...ed, name: e.target.value }))}
|
||||
style={{ maxWidth: 300 }}
|
||||
/>
|
||||
: c.name
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div className="td-actions">
|
||||
{editing?.id === c.id
|
||||
? <>
|
||||
<button className="btn btn-primary btn-sm" onClick={doUpdate}>Сохранить</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(null)}>Отмена</button>
|
||||
</>
|
||||
: <>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setEditing({ id: c.id, name: c.name })}>✏️</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ id: c.id, name: c.name })}>🗑</button>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
85
web-react/src/pages/admin/Dashboard.jsx
Normal file
85
web-react/src/pages/admin/Dashboard.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import { adminListArticles } from '../../api/index'
|
||||
import AdminLayout from '../../components/AdminLayout'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
import Loader from '../../components/Loader'
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { token } = useAuth()
|
||||
const [stats, setStats] = useState({ published: 0, drafts: 0, total: 0 })
|
||||
const [recent, setRecent] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
adminListArticles(token, { limit: 1, status: 'published' }),
|
||||
adminListArticles(token, { limit: 1, status: 'draft' }),
|
||||
adminListArticles(token, { limit: 8 }),
|
||||
]).then(([pub, draft, all]) => {
|
||||
setStats({
|
||||
published: pub?.total ?? 0,
|
||||
drafts: draft?.total ?? 0,
|
||||
total: all?.total ?? 0,
|
||||
})
|
||||
setRecent(all?.items ?? [])
|
||||
}).catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<AdminLayout title="Дашборд">
|
||||
{loading ? <Loader /> : (
|
||||
<>
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__value">{stats.total}</div>
|
||||
<div className="stat-card__label">Всего статей</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__value">{stats.published}</div>
|
||||
<div className="stat-card__label">Опубликовано</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__value">{stats.drafts}</div>
|
||||
<div className="stat-card__label">Черновики</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Заголовок</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{a.title}
|
||||
</td>
|
||||
<td><StatusBadge status={a.status} /></td>
|
||||
<td style={{ color: 'var(--c-text-2)', fontSize: '0.82rem' }}>{fmt(a.published_at ?? a.created_at)}</td>
|
||||
<td>
|
||||
<Link to={`/admin/articles/${a.id}`} className="btn btn-ghost btn-sm">Открыть</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
60
web-react/src/pages/admin/Login.jsx
Normal file
60
web-react/src/pages/admin/Login.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import { login } from '../../api/index'
|
||||
|
||||
export default function Login() {
|
||||
const { saveToken } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async e => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await login(username, password)
|
||||
saveToken(data.access_token)
|
||||
navigate('/admin')
|
||||
} catch (err) {
|
||||
setError('Неверный логин или пароль')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-box" onSubmit={submit}>
|
||||
<h1>Вход</h1>
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Логин</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Пароль</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={loading} style={{ width: '100%' }}>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
web-react/src/pages/admin/MediaLibrary.jsx
Normal file
185
web-react/src/pages/admin/MediaLibrary.jsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import { adminListMedia, adminUploadMedia, adminDeleteMedia } from '../../api/index'
|
||||
import AdminLayout from '../../components/AdminLayout'
|
||||
import Confirm from '../../components/Confirm'
|
||||
import Loader from '../../components/Loader'
|
||||
|
||||
const PAGE = 40
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024**2) return `${(bytes/1024).toFixed(1)} KB`
|
||||
return `${(bytes/1024**2).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function VideoThumb({ url, filename }) {
|
||||
const [err, setErr] = useState(false)
|
||||
if (err) return (
|
||||
<div style={{ height: 140, background: '#1e293b', display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, gap: 6 }}>
|
||||
<span style={{ fontSize: 28 }}>🎬</span>
|
||||
<span style={{ maxWidth: 140, textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block', background: '#0f172a' }}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
onError={() => setErr(true)}
|
||||
onMouseEnter={e => e.target.play?.()}
|
||||
onMouseLeave={e => { e.target.pause?.(); e.target.currentTime = 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MediaLibrary() {
|
||||
const { token } = useAuth()
|
||||
const [items, setItems] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [type, setType] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [confirm, setConfirm] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [alert, setAlert] = useState(null)
|
||||
const [preview, setPreview] = useState(null)
|
||||
const fileRef = useRef()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminListMedia(token, { type: type || undefined, offset: (page - 1) * PAGE, limit: PAGE })
|
||||
setItems(data.items ?? [])
|
||||
setTotal(data.total ?? 0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token, type, page])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const flash = (t, msg) => { setAlert({ type: t, msg }); setTimeout(() => setAlert(null), 3000) }
|
||||
|
||||
const doUpload = async e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
await adminUploadMedia(token, file)
|
||||
flash('success', 'Загружено!')
|
||||
setPage(1)
|
||||
load()
|
||||
} catch { flash('error', 'Ошибка загрузки') }
|
||||
finally { setUploading(false); e.target.value = '' }
|
||||
}
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return
|
||||
try { await adminDeleteMedia(token, confirm.id); setConfirm(null); load() }
|
||||
catch { flash('error', 'Ошибка удаления') }
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE))
|
||||
|
||||
return (
|
||||
<AdminLayout title="Медиатека">
|
||||
{confirm && (
|
||||
<Confirm
|
||||
message="Удалить файл?"
|
||||
onOk={doDelete}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{preview && (
|
||||
<div
|
||||
onClick={() => setPreview(null)}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
{preview.media_type === 'image'
|
||||
? <img src={preview.url} alt={preview.filename}
|
||||
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, boxShadow: '0 8px 48px #000' }} />
|
||||
: <video src={preview.url} controls autoPlay
|
||||
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, boxShadow: '0 8px 48px #000' }}
|
||||
onClick={e => e.stopPropagation()} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alert && <div className={`alert alert-${alert.type}`} style={{ marginBottom: 12 }}>{alert.msg}</div>}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{[['', 'Все'], ['image', 'Изображения'], ['video', 'Видео']].map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
className={`btn btn-sm ${type === val ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => { setType(val); setPage(1) }}
|
||||
>{label}</button>
|
||||
))}
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
|
||||
Всего: {total}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? 'Загружаю...' : '⬆️ Загрузить'}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*,video/*" style={{ display: 'none' }} onChange={doUpload} />
|
||||
</div>
|
||||
|
||||
{loading ? <Loader /> : items.length === 0
|
||||
? <p style={{ color: 'var(--c-text-2)', textAlign: 'center', padding: '2rem' }}>Файлов нет</p>
|
||||
: (
|
||||
<div className="media-grid">
|
||||
{items.map(m => (
|
||||
<div key={m.id} className="media-item" style={{ cursor: 'pointer' }}>
|
||||
<div onClick={() => setPreview(m)} style={{ position: 'relative' }}>
|
||||
{m.media_type === 'image'
|
||||
? <img src={m.url} alt={m.filename} style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }} />
|
||||
: <VideoThumb url={m.url} filename={m.filename} />
|
||||
}
|
||||
{m.media_type === 'video' && (
|
||||
<div style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: 4, padding: '2px 6px', fontSize: 10, color: '#fff' }}>▶ видео</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="media-item__info">
|
||||
<span className="media-item__name" title={m.filename}>{m.filename}</span>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--c-text-2)' }}>{fmtSize(m.size_bytes)} · {fmtDate(m.created_at)}</span>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => setConfirm({ id: m.id })}
|
||||
style={{ padding: '2px 6px', color: 'var(--c-error)' }}
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination" style={{ marginTop: '1rem' }}>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>← Назад</button>
|
||||
<span className="pagination__info">{page} / {totalPages}</span>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Вперёд →</button>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
217
web-react/src/pages/admin/VkSources.jsx
Normal file
217
web-react/src/pages/admin/VkSources.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import {
|
||||
adminVkStatus, adminVkListSources,
|
||||
adminVkAddSource, adminVkUpdateSource, adminVkDeleteSource,
|
||||
adminVkImport, adminVkImportHistory,
|
||||
} from '../../api/index'
|
||||
import AdminLayout from '../../components/AdminLayout'
|
||||
import Confirm from '../../components/Confirm'
|
||||
import Loader from '../../components/Loader'
|
||||
|
||||
export default function VkSources() {
|
||||
const { token } = useAuth()
|
||||
const [status, setStatus] = useState(null)
|
||||
const [sources, setSources] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [confirm, setConfirm] = useState(null)
|
||||
const [alert, setAlert] = useState(null)
|
||||
const [importing, setImporting] = useState('')
|
||||
|
||||
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true })
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [st, srcs] = await Promise.all([
|
||||
adminVkStatus(token),
|
||||
adminVkListSources(token),
|
||||
])
|
||||
setStatus(st)
|
||||
setSources(srcs)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [token])
|
||||
|
||||
const flash = (type, msg) => { setAlert({ type, msg }); setTimeout(() => setAlert(null), 5000) }
|
||||
|
||||
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 })
|
||||
flash('success', 'Источник добавлен')
|
||||
load()
|
||||
} catch(err) { flash('error', String(err.message)) }
|
||||
}
|
||||
|
||||
const doUpdate = async () => {
|
||||
if (!editing) return
|
||||
try {
|
||||
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled)
|
||||
setEditing(null)
|
||||
flash('success', 'Сохранено')
|
||||
load()
|
||||
} catch { flash('error', 'Ошибка') }
|
||||
}
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!confirm) return
|
||||
try { await adminVkDeleteSource(token, confirm.id); setConfirm(null); load() }
|
||||
catch { flash('error', 'Ошибка удаления') }
|
||||
}
|
||||
|
||||
const doImport = async () => {
|
||||
setImporting('new')
|
||||
try {
|
||||
const r = await adminVkImport(token)
|
||||
flash('success', `Импорт завершён: +${r.imported ?? 0} новых`)
|
||||
} catch { flash('error', 'Ошибка импорта') }
|
||||
finally { setImporting('') }
|
||||
}
|
||||
|
||||
const doHistory = async (sourceId = null) => {
|
||||
setImporting('history')
|
||||
try {
|
||||
const r = await adminVkImportHistory(token, sourceId)
|
||||
flash('success', `История загружена: +${r.imported ?? 0} постов`)
|
||||
} catch { flash('error', 'Ошибка загрузки истории') }
|
||||
finally { setImporting('') }
|
||||
}
|
||||
|
||||
const statusDot = () => {
|
||||
if (!status) return 'dot-yellow'
|
||||
if (status.configured) return 'dot-green'
|
||||
return 'dot-red'
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="ВКонтакте">
|
||||
{confirm && <Confirm message="Удалить источник?" onOk={doDelete} onCancel={() => setConfirm(null)} />}
|
||||
|
||||
{alert && <div className={`alert alert-${alert.type}`}>{alert.msg}</div>}
|
||||
|
||||
{/* Статус */}
|
||||
<div className="vk-status-bar">
|
||||
<div className={`vk-status__dot ${statusDot()}`} />
|
||||
<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' }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={doImport}
|
||||
disabled={!!importing}
|
||||
>{importing === 'new' ? 'Импортирую...' : '🔄 Новые посты'}</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => doHistory(null)}
|
||||
disabled={!!importing}
|
||||
>{importing === 'history' ? 'Загружаю...' : '📥 История (все)'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Добавить источник */}
|
||||
<form onSubmit={doAdd} style={{ background: 'var(--c-surface)', borderRadius: 'var(--r-card)', padding: '1rem', marginBottom: '1.5rem', boxShadow: 'var(--shadow-card)' }}>
|
||||
<h3 style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.95rem' }}>Добавить источник</h3>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div className="form-group" style={{ flex: '1 1 160px' }}>
|
||||
<label className="form-label">ID группы</label>
|
||||
<input className="form-input" placeholder="131748668" value={form.group_id}
|
||||
onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: '2 1 220px' }}>
|
||||
<label className="form-label">Название</label>
|
||||
<input className="form-input" placeholder="Колледж..." value={form.group_name}
|
||||
onChange={e => setForm(f => ({ ...f, group_name: e.target.value }))} required />
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.875rem', paddingBottom: 4 }}>
|
||||
<input type="checkbox" checked={form.enabled}
|
||||
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
|
||||
Активен
|
||||
</label>
|
||||
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loading ? <Loader /> : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID группы</th>
|
||||
<th>Название</th>
|
||||
<th>Активен</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{editing?.id === s.id
|
||||
? <input className="form-input" value={editing.group_id}
|
||||
onChange={e => setEditing(ed => ({ ...ed, group_id: e.target.value }))}
|
||||
style={{ width: 130 }} />
|
||||
: s.group_id
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{editing?.id === s.id
|
||||
? <input className="form-input" value={editing.group_name}
|
||||
onChange={e => setEditing(ed => ({ ...ed, group_name: e.target.value }))} />
|
||||
: s.group_name
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{editing?.id === s.id
|
||||
? <input type="checkbox" checked={editing.enabled}
|
||||
onChange={e => setEditing(ed => ({ ...ed, enabled: e.target.checked }))} />
|
||||
: (
|
||||
<button
|
||||
className="toggle-btn"
|
||||
data-on={s.enabled}
|
||||
title={s.enabled ? 'Отключить' : 'Включить'}
|
||||
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled)
|
||||
.then(load).catch(() => flash('error', 'Ошибка'))}
|
||||
>
|
||||
{s.enabled ? '✅' : '❌'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div className="td-actions">
|
||||
{editing?.id === s.id
|
||||
? <>
|
||||
<button className="btn btn-primary btn-sm" onClick={doUpdate}>Сохранить</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(null)}>Отмена</button>
|
||||
</>
|
||||
: <>
|
||||
<button className="btn btn-ghost btn-sm"
|
||||
onClick={() => setEditing({ ...s })}>✏️</button>
|
||||
<button className="btn btn-ghost btn-sm"
|
||||
onClick={() => doHistory(s.id)} disabled={!!importing}
|
||||
title="Загрузить историю этого источника">📥</button>
|
||||
<button className="btn btn-danger btn-sm"
|
||||
onClick={() => setConfirm({ id: s.id })}>🗑</button>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sources.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
|
||||
Источников нет
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
87
web-react/src/theme.css
Normal file
87
web-react/src/theme.css
Normal file
@@ -0,0 +1,87 @@
|
||||
/* =====================================================================
|
||||
ДИЗАЙН-КОНФИГ — меняй только здесь, всё остальное обновится само
|
||||
===================================================================== */
|
||||
|
||||
/* ── Шрифты Google ── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&display=swap');
|
||||
|
||||
/* ── Светлая тема (по умолчанию) ── */
|
||||
:root {
|
||||
/* Основной цвет (синий ВК-стиль) */
|
||||
--c-primary: #2787F5;
|
||||
--c-primary-dark: #1565C0;
|
||||
--c-primary-rgb: 39, 135, 245; /* для rgba() */
|
||||
|
||||
/* Фоны */
|
||||
--c-bg: #F0F2F5; /* страница */
|
||||
--c-surface: #FFFFFF; /* панели, шапки */
|
||||
--c-card: #FFFFFF; /* карточки новостей */
|
||||
|
||||
/* Текст */
|
||||
--c-text-1: #050505; /* основной */
|
||||
--c-text-2: #818C99; /* вторичный (дата, метки) */
|
||||
|
||||
/* Границы и разделители */
|
||||
--c-border: #E5E9EF;
|
||||
|
||||
/* Статусы */
|
||||
--c-success: #3DC47E;
|
||||
--c-warning: #FFA726;
|
||||
--c-error: #E64646;
|
||||
|
||||
/* Боковая панель администратора */
|
||||
--c-sidebar: #1A237E;
|
||||
--c-sidebar-text: #E8EAF6;
|
||||
--c-sidebar-active: #283593;
|
||||
|
||||
/* Скругления */
|
||||
--r-card: 12px;
|
||||
--r-chip: 6px;
|
||||
--r-btn: 8px;
|
||||
--r-input: 8px;
|
||||
|
||||
/* Тени */
|
||||
--shadow-card: 0 1px 6px rgba(0,0,0,.08);
|
||||
--shadow-panel: 0 2px 8px rgba(0,0,0,.10);
|
||||
}
|
||||
|
||||
/* ── Тёмная тема ── */
|
||||
[data-theme="dark"] {
|
||||
--c-bg: #0F0F1A;
|
||||
--c-surface: #1A1A2E;
|
||||
--c-card: #22223A;
|
||||
|
||||
--c-text-1: #E8EAF6;
|
||||
--c-text-2: #9FA8C4;
|
||||
|
||||
--c-border: #2D2D50;
|
||||
|
||||
--c-sidebar: #0D0D1F;
|
||||
--c-sidebar-text: #C5CAE9;
|
||||
--c-sidebar-active:#1A237E;
|
||||
|
||||
--shadow-card: 0 1px 6px rgba(0,0,0,.3);
|
||||
--shadow-panel: 0 2px 8px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
/* ── Базовые стили ── */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text-1);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* Плавное переключение тёмной темы */
|
||||
*, *::before, *::after {
|
||||
transition: background-color .2s, border-color .2s, color .15s;
|
||||
}
|
||||
|
||||
/* Скроллбар */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--c-border); border-radius: 3px; }
|
||||
35
web-react/tailwind.config.js
Normal file
35
web-react/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
// Тёмная тема включается атрибутом data-theme="dark" на <html>
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
// Все цвета берутся из CSS-переменных в theme.css
|
||||
// Менять цвета нужно только там
|
||||
colors: {
|
||||
primary: 'var(--c-primary)',
|
||||
'primary-d':'var(--c-primary-dark)',
|
||||
bg: 'var(--c-bg)',
|
||||
surface: 'var(--c-surface)',
|
||||
card: 'var(--c-card)',
|
||||
't1': 'var(--c-text-1)',
|
||||
't2': 'var(--c-text-2)',
|
||||
border: 'var(--c-border)',
|
||||
success: 'var(--c-success)',
|
||||
warning: 'var(--c-warning)',
|
||||
error: 'var(--c-error)',
|
||||
sidebar: 'var(--c-sidebar)',
|
||||
'sidebar-t':'var(--c-sidebar-text)',
|
||||
},
|
||||
fontFamily: {
|
||||
ui: ['Inter', 'sans-serif'],
|
||||
display: ['Merriweather', 'serif'],
|
||||
},
|
||||
borderRadius: {
|
||||
card: '12px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
15
web-react/vite.config.js
Normal file
15
web-react/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// В dev-режиме проксируем API запросы на FastAPI
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user