new web ract

This commit is contained in:
jze9
2026-05-15 03:31:28 +05:00
parent de78624495
commit 2335497226
58 changed files with 7397 additions and 406 deletions

22
web-react/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
web-react/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

42
web-react/src/App.jsx Normal file
View 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
View 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
View 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); }
}

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

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

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

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

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

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

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

View 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 }
}

View 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
View 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>
)

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

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

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

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

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

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

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

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

View 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
View 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; }

View 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
View 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/, ''),
},
},
},
})