new web ract
This commit is contained in:
16
.env.example
16
.env.example
@@ -10,5 +10,21 @@ MINIO_PUBLIC_URL=http://localhost:9000
|
||||
DOCS_USERNAME=admin
|
||||
DOCS_PASSWORD=admin
|
||||
|
||||
# CORS: разрешённые домены через запятую
|
||||
ALLOWED_ORIGINS=http://localhost,http://localhost:80
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# VK импорт (необязательно)
|
||||
# Сервисный токен приложения: vk.com/apps → Настройки → Сервисный ключ доступа
|
||||
VK_ACCESS_TOKEN=
|
||||
# ID групп через запятую (без минуса): 12345,67890
|
||||
VK_GROUP_IDS=
|
||||
# Статус импортированных постов: published или draft
|
||||
VK_IMPORT_STATUS=published
|
||||
# Сколько последних постов проверять за раз
|
||||
VK_POSTS_PER_RUN=20
|
||||
# Время запуска импорта (UTC)
|
||||
VK_IMPORT_HOUR=6
|
||||
VK_IMPORT_MINUTE=0
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -17,10 +17,14 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
uv.lock
|
||||
|
||||
# ── Node / React editor UI ─────────────────────────────────────────────────
|
||||
# ── Node / React ───────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
web/editor-ui/node_modules/
|
||||
web/editor-ui/dist/
|
||||
web/editor-ui/.vite/
|
||||
web-react/node_modules/
|
||||
web-react/dist/
|
||||
web-react/.vite/
|
||||
|
||||
# ── IDE / OS ───────────────────────────────────────────────────────────────
|
||||
.DS_Store
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Table, Column, Integer, Enum as SAEnum
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Table, Column, Integer, Boolean, Enum as SAEnum
|
||||
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import enum
|
||||
@@ -44,6 +44,7 @@ class Article(Base):
|
||||
excerpt: Mapped[str] = mapped_column(String(1000), default="")
|
||||
cover_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
font_family: Mapped[str] = mapped_column(String(100), default="Merriweather")
|
||||
source_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
status: Mapped[ArticleStatus] = mapped_column(SAEnum(ArticleStatus), default=ArticleStatus.draft)
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@@ -71,3 +72,13 @@ class Media(Base):
|
||||
media_type: Mapped[str] = mapped_column(String(20)) # image | video
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class VkSource(Base):
|
||||
__tablename__ = "vk_sources"
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
group_id: Mapped[str] = mapped_column(String(50), unique=True) # числовой ID без минуса
|
||||
group_name: Mapped[str] = mapped_column(String(200)) # станет названием категории
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
29
api/main.py
29
api/main.py
@@ -4,6 +4,11 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from rate_limiter import limiter
|
||||
|
||||
from bd.database import wait_for_db, engine, Base
|
||||
from route.public import router as public_router
|
||||
@@ -11,12 +16,14 @@ from route.admin_auth import router as auth_router, ensure_default_admin
|
||||
from route.admin_articles import router as articles_router
|
||||
from route.admin_categories import router as categories_router
|
||||
from route.admin_media import router as media_router
|
||||
from route.admin_vk import router as vk_router
|
||||
import scheduler
|
||||
|
||||
DOCS_USER = os.getenv("DOCS_USERNAME", "admin")
|
||||
DOCS_PASS = os.getenv("DOCS_PASSWORD", "admin")
|
||||
_basic = HTTPBasic()
|
||||
|
||||
|
||||
# Rate limiter — ключ по IP-адресу
|
||||
def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
|
||||
ok_u = secrets.compare_digest(creds.username.encode(), DOCS_USER.encode())
|
||||
ok_p = secrets.compare_digest(creds.password.encode(), DOCS_PASS.encode())
|
||||
@@ -31,7 +38,9 @@ async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await ensure_default_admin()
|
||||
scheduler.start()
|
||||
yield
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -43,17 +52,18 @@ app = FastAPI(
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# CORS: разрешаем только наш домен (меняй на продакшн-домен)
|
||||
_ALLOWED_ORIGINS = [o.strip() for o in os.getenv("ALLOWED_ORIGINS", "http://localhost,http://localhost:80").split(",") if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=_ALLOWED_ORIGINS,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Docs protected by HTTP Basic Auth
|
||||
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def docs(username: str = Depends(_verify_docs)):
|
||||
@@ -78,8 +88,9 @@ async def health():
|
||||
app.include_router(public_router, prefix="/news", tags=["public"])
|
||||
app.include_router(auth_router, prefix="/admin", tags=["admin-auth"])
|
||||
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"])
|
||||
app.include_router(categories_router, prefix="/admin/categories", tags=["admin-categories"])
|
||||
app.include_router(categories_router,prefix="/admin/categories",tags=["admin-categories"])
|
||||
app.include_router(media_router, prefix="/admin/media", tags=["admin-media"])
|
||||
app.include_router(vk_router, prefix="/admin/vk", tags=["admin-vk"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
4
api/rate_limiter.py
Normal file
4
api/rate_limiter.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -10,3 +10,11 @@ python-multipart==0.0.12
|
||||
minio==7.2.11
|
||||
python-slugify==8.0.4
|
||||
pillow==11.0.0
|
||||
httpx==0.27.2
|
||||
apscheduler==3.10.4
|
||||
bleach==6.1.0
|
||||
tinycss2==1.4.0
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.3.0
|
||||
slowapi==0.1.9
|
||||
yt-dlp>=2024.12.3
|
||||
|
||||
@@ -21,6 +21,7 @@ class ArticleIn(BaseModel):
|
||||
content: str = ""
|
||||
excerpt: str = ""
|
||||
cover_url: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
font_family: str = "Merriweather"
|
||||
category_id: Optional[str] = None
|
||||
tag_names: list[str] = []
|
||||
@@ -35,6 +36,7 @@ def _article_dict(a: Article) -> dict:
|
||||
"content": a.content,
|
||||
"excerpt": a.excerpt,
|
||||
"cover_url": a.cover_url,
|
||||
"source_url": a.source_url,
|
||||
"font_family": a.font_family,
|
||||
"status": a.status.value,
|
||||
"view_count": a.view_count,
|
||||
@@ -73,14 +75,19 @@ async def list_articles(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
status: Optional[ArticleStatus] = Query(None),
|
||||
q: Optional[str] = Query(None, max_length=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
|
||||
from sqlalchemy import or_
|
||||
stmt = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
|
||||
if status:
|
||||
q = q.where(Article.status == status)
|
||||
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
|
||||
articles = (await db.execute(q.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
stmt = stmt.where(Article.status == status)
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
stmt = stmt.where(or_(Article.title.ilike(pattern), Article.excerpt.ilike(pattern)))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
articles = (await db.execute(stmt.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
@@ -115,6 +122,7 @@ async def create_article(
|
||||
content=data.content,
|
||||
excerpt=data.excerpt,
|
||||
cover_url=data.cover_url,
|
||||
source_url=data.source_url,
|
||||
font_family=data.font_family,
|
||||
status=data.status,
|
||||
category=cat,
|
||||
@@ -174,6 +182,7 @@ async def update_article(
|
||||
article.content = data.content
|
||||
article.excerpt = data.excerpt
|
||||
article.cover_url = data.cover_url
|
||||
article.source_url = data.source_url
|
||||
article.font_family = data.font_family
|
||||
article.status = data.status
|
||||
article.category = cat
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -9,17 +9,24 @@ from passlib.context import CryptContext
|
||||
|
||||
from bd.database import get_db, AsyncSessionLocal
|
||||
from bd.models import Admin
|
||||
from rate_limiter import limiter
|
||||
|
||||
router = APIRouter()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
|
||||
_raw_secret = os.getenv("JWT_SECRET", "")
|
||||
if not _raw_secret:
|
||||
import secrets as _s
|
||||
_raw_secret = _s.token_hex(32)
|
||||
print("[WARN] JWT_SECRET не задан — сгенерирован временный секрет. Установи JWT_SECRET в .env!")
|
||||
|
||||
JWT_SECRET = _raw_secret
|
||||
JWT_ALGO = "HS256"
|
||||
JWT_EXP_HOURS = 24
|
||||
JWT_EXP_HOURS = 8
|
||||
|
||||
|
||||
def _make_token(username: str) -> str:
|
||||
exp = datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS)
|
||||
exp = datetime.now(timezone.utc) + timedelta(hours=JWT_EXP_HOURS)
|
||||
return jwt.encode({"sub": username, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGO)
|
||||
|
||||
|
||||
@@ -40,7 +47,8 @@ class LoginRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
@limiter.limit("5/minute")
|
||||
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
admin = (await db.execute(select(Admin).where(Admin.username == data.username))).scalar_one_or_none()
|
||||
if not admin or not pwd_context.verify(data.password, admin.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль")
|
||||
@@ -48,12 +56,7 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from route.deps import require_admin
|
||||
# handled with deps in main
|
||||
async def change_password(data: dict, db: AsyncSession = Depends(get_db)):
|
||||
admin = (await db.execute(select(Admin).where(Admin.username == data["username"]))).scalar_one_or_none()
|
||||
if not admin or not pwd_context.verify(data["current_password"], admin.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Неверный текущий пароль")
|
||||
|
||||
@@ -99,20 +99,25 @@ async def upload_media(
|
||||
@router.get("")
|
||||
async def list_media(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
limit: int = Query(40, ge=1, le=200),
|
||||
media_type: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
q = select(Media)
|
||||
from sqlalchemy import func
|
||||
base = select(Media)
|
||||
if media_type:
|
||||
q = q.where(Media.media_type == media_type)
|
||||
items = (await db.execute(q.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
return [
|
||||
base = base.where(Media.media_type == media_type)
|
||||
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
|
||||
items = (await db.execute(base.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"items": [
|
||||
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
|
||||
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
|
||||
for m in items
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{media_id}")
|
||||
|
||||
113
api/route/admin_vk.py
Normal file
113
api/route/admin_vk.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bd.database import get_db
|
||||
from bd.models import VkSource
|
||||
from route.deps import require_admin
|
||||
from vk_parser import run_import, run_history_import, VK_TOKEN
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SourceIn(BaseModel):
|
||||
group_id: str
|
||||
group_name: str
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
def _source_dict(s: VkSource) -> dict:
|
||||
return {
|
||||
"id": str(s.id),
|
||||
"group_id": s.group_id,
|
||||
"group_name": s.group_name,
|
||||
"enabled": s.enabled,
|
||||
"last_run": s.last_run.isoformat() if s.last_run else None,
|
||||
"created_at": s.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def vk_status(_: str = Depends(require_admin)):
|
||||
return {"configured": bool(VK_TOKEN)}
|
||||
|
||||
|
||||
# ── CRUD источников ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/sources")
|
||||
async def list_sources(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
rows = (await db.execute(select(VkSource).order_by(VkSource.created_at))).scalars().all()
|
||||
return [_source_dict(r) for r in rows]
|
||||
|
||||
|
||||
def _clean_group_id(raw: str) -> str:
|
||||
"""Из строки вида '-95503797', '95503797_6512', 'wall-95503797_12' вытащить только числовой ID группы."""
|
||||
import re
|
||||
raw = raw.strip().lstrip("-")
|
||||
# Берём только первое число (до '_' если есть)
|
||||
m = re.search(r"(\d+)", raw)
|
||||
return m.group(1) if m else raw
|
||||
|
||||
|
||||
@router.post("/sources", status_code=201)
|
||||
async def add_source(data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
group_id = _clean_group_id(data.group_id)
|
||||
exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть")
|
||||
source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled)
|
||||
db.add(source)
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
return _source_dict(source)
|
||||
|
||||
|
||||
@router.patch("/sources/{source_id}")
|
||||
async def update_source(source_id: str, data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
source.group_id = _clean_group_id(data.group_id)
|
||||
source.group_name = data.group_name
|
||||
source.enabled = data.enabled
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
return _source_dict(source)
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", status_code=204)
|
||||
async def delete_source(source_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Ручной запуск импорта ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/import")
|
||||
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
||||
background_tasks.add_task(run_import)
|
||||
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
|
||||
|
||||
|
||||
@router.post("/import/history")
|
||||
async def vk_history_import_all(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
||||
background_tasks.add_task(run_history_import)
|
||||
return {"ok": True, "message": "Исторический импорт всех групп запущен в фоне (до 5 мин)"}
|
||||
|
||||
|
||||
@router.post("/import/history/{source_id}")
|
||||
async def vk_history_import_one(
|
||||
source_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
background_tasks.add_task(run_history_import, group_id=source.group_id)
|
||||
return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"}
|
||||
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from bd.database import get_db, get_redis
|
||||
from bd.models import Article, Category, Tag, ArticleStatus
|
||||
from bd.models import Article, Category, Tag, ArticleStatus, article_tag
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +23,7 @@ def _article_card(a: Article) -> dict:
|
||||
"slug": a.slug,
|
||||
"excerpt": a.excerpt,
|
||||
"cover_url": a.cover_url,
|
||||
"source_url": a.source_url,
|
||||
"published_at": a.published_at.isoformat() if a.published_at else None,
|
||||
"view_count": a.view_count,
|
||||
"category": _cat_dict(a.category) if a.category else None,
|
||||
@@ -42,10 +44,13 @@ async def list_news(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
category: str | None = Query(None),
|
||||
tag: str | None = Query(None),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
q: str | None = Query(None, max_length=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}"
|
||||
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}:{date_from or ''}:{date_to or ''}:{q or ''}"
|
||||
cached = await redis.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
@@ -68,6 +73,29 @@ async def list_news(
|
||||
if t:
|
||||
base = base.where(Article.tags.any(Tag.id == t.id))
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
base = base.where(Article.published_at >= datetime.fromisoformat(date_from))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
base = base.where(Article.published_at <= datetime.fromisoformat(date_to))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
base = base.where(
|
||||
or_(
|
||||
Article.title.ilike(pattern),
|
||||
Article.excerpt.ilike(pattern),
|
||||
Article.content.ilike(pattern),
|
||||
Article.tags.any(Tag.name.ilike(pattern)),
|
||||
)
|
||||
)
|
||||
|
||||
count_q = select(func.count()).select_from(base.subquery())
|
||||
total: int = (await db.execute(count_q)).scalar_one()
|
||||
|
||||
@@ -82,7 +110,7 @@ async def list_news(
|
||||
"has_more": (offset + limit) < total,
|
||||
"items": [_article_card(a) for a in articles],
|
||||
}
|
||||
await redis.setex(cache_key, 30, json.dumps(result, default=str))
|
||||
await redis.setex(cache_key, 30 if q else 120, json.dumps(result, default=str))
|
||||
return result
|
||||
|
||||
|
||||
@@ -96,7 +124,36 @@ async def list_categories(
|
||||
return json.loads(cached)
|
||||
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
|
||||
result = [_cat_dict(c) for c in cats]
|
||||
await redis.setex("news:categories", 60, json.dumps(result))
|
||||
await redis.setex("news:categories", 300, json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def list_tags(
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
cache_key = f"news:tags:{limit}"
|
||||
cached = await redis.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
q = (
|
||||
select(Tag, func.count(article_tag.c.article_id).label("cnt"))
|
||||
.join(article_tag, Tag.id == article_tag.c.tag_id)
|
||||
.join(Article, article_tag.c.article_id == Article.id)
|
||||
.where(Article.status == ArticleStatus.published)
|
||||
.group_by(Tag.id)
|
||||
.order_by(func.count(article_tag.c.article_id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(q)).all()
|
||||
result = [
|
||||
{"id": str(r.Tag.id), "name": r.Tag.name, "slug": r.Tag.slug, "count": r.cnt}
|
||||
for r in rows
|
||||
]
|
||||
await redis.setex(cache_key, 300, json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
26
api/scheduler.py
Normal file
26
api/scheduler.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
_scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
|
||||
VK_HOUR = int(os.getenv("VK_IMPORT_HOUR", "6"))
|
||||
VK_MINUTE = int(os.getenv("VK_IMPORT_MINUTE", "0"))
|
||||
|
||||
|
||||
def start():
|
||||
from vk_parser import run_import
|
||||
_scheduler.add_job(
|
||||
run_import,
|
||||
trigger="cron",
|
||||
hour=VK_HOUR,
|
||||
minute=VK_MINUTE,
|
||||
id="vk_daily_import",
|
||||
replace_existing=True,
|
||||
)
|
||||
_scheduler.start()
|
||||
print(f"[Scheduler] VK-импорт запланирован на {VK_HOUR:02d}:{VK_MINUTE:02d} UTC каждый день")
|
||||
|
||||
|
||||
def shutdown():
|
||||
if _scheduler.running:
|
||||
_scheduler.shutdown(wait=False)
|
||||
725
api/vk_parser.py
Normal file
725
api/vk_parser.py
Normal file
@@ -0,0 +1,725 @@
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncio
|
||||
import bleach
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
import httpx
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
from slugify import slugify
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bd.database import AsyncSessionLocal
|
||||
from bd.models import Article, ArticleStatus, Category, Tag, VkSource
|
||||
|
||||
VK_API = "https://api.vk.com/method"
|
||||
VK_VERSION = "5.199"
|
||||
VK_TOKEN = os.getenv("VK_ACCESS_TOKEN", "")
|
||||
VK_IMPORT_AS = os.getenv("VK_IMPORT_STATUS", "published") # published | draft
|
||||
VK_PER_RUN = int(os.getenv("VK_POSTS_PER_RUN", "100")) # макс 100 за запрос
|
||||
|
||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
||||
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
|
||||
|
||||
_URL_RE = re.compile(r"(https?://[^\s<>\"']+)")
|
||||
|
||||
# Разрешённые HTML-теги и атрибуты после санитизации
|
||||
_ALLOWED_TAGS = [
|
||||
"p", "br", "strong", "em", "a", "img",
|
||||
"ul", "ol", "li", "blockquote",
|
||||
"div", "iframe", "span", "video",
|
||||
]
|
||||
_ALLOWED_ATTRS = {
|
||||
"a": ["href", "target", "rel"],
|
||||
"img": ["src", "style", "alt"],
|
||||
"iframe": ["src", "style", "frameborder", "allowfullscreen"],
|
||||
"video": ["src", "controls", "style", "poster"],
|
||||
"div": ["style"],
|
||||
"span": ["style"],
|
||||
}
|
||||
|
||||
|
||||
_CSS = CSSSanitizer(allowed_css_properties=[
|
||||
"max-width", "width", "height", "position", "top", "left",
|
||||
"padding-bottom", "overflow", "margin", "border-radius",
|
||||
])
|
||||
|
||||
|
||||
def _sanitize(html: str) -> str:
|
||||
return bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS,
|
||||
css_sanitizer=_CSS, strip=True)
|
||||
|
||||
# ── Теги по ключевым словам ───────────────────────────────────────────────────
|
||||
# Ключ = название тега, значение = список подстрок (регистронезависимо)
|
||||
TAG_KEYWORDS: dict[str, list[str]] = {
|
||||
# ── Учёба и поступление ───────────────────────────────────────────────────
|
||||
"экзамены": ["экзамен", "зачёт", "зачет", "сессия", "огэ", "егэ",
|
||||
"гиа", "защит", "диплом", "аттестац", "промежуточн"],
|
||||
"абитуриентам": ["абитуриент", "поступ", "приёмн", "приемн", "зачислен",
|
||||
"набор студент", "подай документ", "приглашаем поступ",
|
||||
"бюджетн мест", "целевой приём", "контрольные цифры"],
|
||||
"день открытых дверей": ["день открытых дверей", "открытые двери", "день открытых",
|
||||
"экскурси", "познакомиться с колледж"],
|
||||
"практика": ["практик", "стажировк", "производственн", "учебно-производ",
|
||||
"на предприяти", "работодател", "наставник"],
|
||||
"достижения": ["победител", "призёр", "призер", "награда", "грамот",
|
||||
"диплом лауреат", "1 место", "2 место", "3 место",
|
||||
"лучший студент", "гордост", "поздравляем", "медал"],
|
||||
"студенческая жизнь": ["студсовет", "студенческ жизн", "общежити", "капустник",
|
||||
"квн", "студенч", "первокурсник", "посвящение",
|
||||
"студент год", "актив"],
|
||||
"профессионалитет": ["профессионалитет", "фп профессионалитет",
|
||||
"кластер", "федеральн проект"],
|
||||
|
||||
# ── Направления колледжей ─────────────────────────────────────────────────
|
||||
"медицина": ["медицин", "сестринск", "фельдшер", "фармацевт", "лечебн",
|
||||
"анатоми", "патологи", "здравоохранен", "санитар",
|
||||
"первая помощ", "реанимац", "клиническ"],
|
||||
"педагогика": ["педагог", "воспитател", "учител", "начальн класс",
|
||||
"дошкольн", "детский сад", "логопед", "коррекцион",
|
||||
"инклюзив", "тьютор"],
|
||||
"юриспруденция": ["юрист", "юридическ", "правоохранительн", "право",
|
||||
"законодательств", "суд", "прокурат", "полиц",
|
||||
"юриспруденц", "правовед"],
|
||||
"IT и технологии": ["программирован", "it-", "ит-", "информационн технолог",
|
||||
"цифров", "кибербезопасн", "веб-разраб", "1с",
|
||||
"компьютерн", "разработчик", "python", "frontend",
|
||||
"backend", "хакатон", "ворлдскиллс"],
|
||||
"кулинария и торговля": ["кулинар", "повар", "кондитер", "гастроном", "блюд",
|
||||
"рецепт", "ресторан", "кафе", "торговл", "продавец",
|
||||
"мерчандайзинг", "товаровед", "общепит"],
|
||||
"строительство": ["строительств", "архитектур", "проектирован", "чертёж",
|
||||
"чертеж", "монтаж", "сварк", "электромонтаж",
|
||||
"сантехник", "отделочн"],
|
||||
"техника и механика": ["механик", "двигател", "автомобил", "станок",
|
||||
"металлообраб", "токар", "слесар", "техническ обслуж",
|
||||
"ремонт оборудован", "машиностроен", "технолог производ"],
|
||||
"сельское хозяйство": ["агроном", "агроинженер", "сельск хоз", "животновод",
|
||||
"агротехник", "землепользован", "агро"],
|
||||
"экономика и бухучёт": ["бухгалтер", "экономик", "финансов", "налог",
|
||||
"аудит", "бизнес", "предпринимател", "менеджмент",
|
||||
"маркетинг", "логистик"],
|
||||
"социальная работа": ["социальн работ", "соцработник", "психолог",
|
||||
"реабилитац", "инвалид", "ограниченн возможн",
|
||||
"социальн помощ", "опека"],
|
||||
|
||||
# ── Общественные события ──────────────────────────────────────────────────
|
||||
"спорт": ["спорт", "соревнован", "олимпиад", "футбол", "баскетбол",
|
||||
"волейбол", "чемпион", "кубок", "турнир", "атлет",
|
||||
"фитнес", "зарядк", "ворлдскиллс хайскул"],
|
||||
"военка": ["военн", "армия", "призыв", "нво", "сво", "оборон",
|
||||
"патриот", "защитник", "военно-патриот", "стрельб",
|
||||
"зарниц", "юнармия", "допризывн"],
|
||||
"воздушная опасность": ["воздушн тревог", "воздушн опасност", "ракет",
|
||||
"бпла", "дрон", "обстрел", "укрытие", "сирен",
|
||||
"эвакуац", "бомбоубежищ"],
|
||||
"общественная деятельность": ["волонтёр", "волонтер", "благотвор", "субботник",
|
||||
"экологическ акц", "посадк дерев", "уборк территор",
|
||||
"донорств", "гуманитарн", "помощ фронт"],
|
||||
"праздники и события": ["праздник", "концерт", "фестиваль", "выставк",
|
||||
"торжеств", "церемони", "день колледж", "юбилей",
|
||||
"8 марта", "23 февраля", "новый год", "масленица",
|
||||
"день знаний", "последний звонок"],
|
||||
"конкурсы и олимпиады": ["конкурс", "олимпиад", "чемпионат профессий",
|
||||
"worldskills", "ворлдскиллс", "хакатон", "викторин",
|
||||
"интеллектуальн", "акселератор"],
|
||||
}
|
||||
|
||||
|
||||
def _detect_tags(text: str) -> list[str]:
|
||||
lower = text.lower()
|
||||
found = []
|
||||
for tag_name, keywords in TAG_KEYWORDS.items():
|
||||
if any(kw in lower for kw in keywords):
|
||||
found.append(tag_name)
|
||||
return found
|
||||
|
||||
|
||||
# ── MinIO helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _minio_client() -> Minio:
|
||||
return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
|
||||
|
||||
|
||||
def _ensure_bucket(mc: Minio):
|
||||
try:
|
||||
if not mc.bucket_exists(MINIO_BUCKET):
|
||||
mc.make_bucket(MINIO_BUCKET)
|
||||
policy = (
|
||||
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*",'
|
||||
f'"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::{MINIO_BUCKET}/*"]'
|
||||
'}]}'
|
||||
)
|
||||
mc.set_bucket_policy(MINIO_BUCKET, policy)
|
||||
except S3Error:
|
||||
pass
|
||||
|
||||
|
||||
async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
r = await client.get(url)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.content
|
||||
ct = r.headers.get("content-type", "image/jpeg").split(";")[0].strip()
|
||||
ext = ct.split("/")[-1].replace("jpeg", "jpg")
|
||||
object_name = f"vk/images/{uuid.uuid4().hex}.{ext}"
|
||||
mc = _minio_client()
|
||||
_ensure_bucket(mc)
|
||||
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type=ct)
|
||||
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||
if db is not None:
|
||||
from bd.models import Media
|
||||
db.add(Media(
|
||||
filename=object_name.split("/")[-1],
|
||||
url=public_url,
|
||||
media_type="image",
|
||||
size_bytes=len(data),
|
||||
))
|
||||
return public_url
|
||||
except Exception as exc:
|
||||
print(f"[VK] Не удалось загрузить фото {url}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Video download ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _fetch_video_info(owner_id: int, video_id: int) -> dict:
|
||||
"""Получить info о видео через video.get.
|
||||
Возвращает thumb_url (лучший кадр превью).
|
||||
files.mp4_* и player возвращаются только если токен имеет scope=video;
|
||||
без scope они None — в этом случае скачивание идёт через yt-dlp.
|
||||
"""
|
||||
if not VK_TOKEN:
|
||||
return {}
|
||||
params = {
|
||||
"videos": f"{owner_id}_{video_id}",
|
||||
"access_token": VK_TOKEN,
|
||||
"v": VK_VERSION,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(f"{VK_API}/video.get", params=params)
|
||||
data = r.json()
|
||||
if "error" in data:
|
||||
print(f"[VK video.get] {data['error'].get('error_msg')}")
|
||||
return {}
|
||||
items = data.get("response", {}).get("items", [])
|
||||
if not items:
|
||||
return {}
|
||||
v = items[0]
|
||||
result = {}
|
||||
# лучший кадр превью (выше качеством чем в wall.get)
|
||||
for src_key in ("image", "first_frame"):
|
||||
frames = v.get(src_key, [])
|
||||
if frames:
|
||||
best = max(frames, key=lambda f: f.get("width", 0))
|
||||
result["thumb_url"] = best.get("url")
|
||||
break
|
||||
return result
|
||||
except Exception as exc:
|
||||
print(f"[VK] video.get {owner_id}_{video_id}: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def _ytdlp_extract_url(vk_url: str, max_mb: int = 150) -> str | None:
|
||||
"""Синхронно: через yt-dlp достать прямую mp4-ссылку без скачивания файла."""
|
||||
try:
|
||||
import yt_dlp
|
||||
except ImportError:
|
||||
return None
|
||||
max_bytes = max_mb * 1024 * 1024
|
||||
ydl_opts = {
|
||||
"format": "best[ext=mp4][filesize<?{}]/best[ext=mp4]/best".format(max_bytes),
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
}
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(vk_url, download=False)
|
||||
url = info.get("url") if info else None
|
||||
if url:
|
||||
print(f"[VK] yt-dlp: найден mp4 для {vk_url}")
|
||||
return url
|
||||
except Exception as exc:
|
||||
print(f"[VK] yt-dlp: не удалось получить URL {vk_url}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def _download_vk_video(owner_id: int, video_id: int,
|
||||
db: AsyncSession | None = None,
|
||||
max_mb: int = 150) -> str | None:
|
||||
"""Скачать VK-видео в MinIO и вернуть публичный URL.
|
||||
Использует yt-dlp для получения прямой mp4-ссылки.
|
||||
Стриминг — не держит весь файл в памяти.
|
||||
"""
|
||||
vk_url = f"https://vk.com/video{owner_id}_{video_id}"
|
||||
max_bytes = max_mb * 1024 * 1024
|
||||
|
||||
# yt-dlp работает синхронно — запускаем в пуле потоков
|
||||
loop = asyncio.get_event_loop()
|
||||
mp4_url = await loop.run_in_executor(None, _ytdlp_extract_url, vk_url, max_mb)
|
||||
if not mp4_url:
|
||||
return None
|
||||
|
||||
_chrome_ua = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=300,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": _chrome_ua, "Referer": "https://vk.com/"},
|
||||
) as client:
|
||||
async with client.stream("GET", mp4_url) as r:
|
||||
if r.status_code != 200:
|
||||
print(f"[VK] Видео HTTP {r.status_code}: {mp4_url[:80]}")
|
||||
return None
|
||||
ct = r.headers.get("content-type", "video/mp4").split(";")[0].strip()
|
||||
# Если content-type не видео — значит получили HTML, не файл
|
||||
if "video" not in ct and "octet" not in ct:
|
||||
print(f"[VK] Неверный content-type видео: {ct}")
|
||||
return None
|
||||
declared = int(r.headers.get("content-length", 0))
|
||||
if declared and declared > max_bytes:
|
||||
print(f"[VK] Видео слишком большое: {declared/1024/1024:.0f}MB > {max_mb}MB, пропускаем")
|
||||
return None
|
||||
chunks, total = [], 0
|
||||
async for chunk in r.aiter_bytes(1024 * 256):
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
print(f"[VK] Видео превысило {max_mb}MB при скачивании, пропускаем")
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
object_name = f"vk/videos/{uuid.uuid4().hex}.mp4"
|
||||
mc = _minio_client()
|
||||
_ensure_bucket(mc)
|
||||
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type="video/mp4")
|
||||
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||
print(f"[VK] Видео загружено в MinIO: {object_name} ({total/1024/1024:.1f}MB)")
|
||||
if db is not None:
|
||||
from bd.models import Media
|
||||
db.add(Media(
|
||||
filename=object_name.split("/")[-1],
|
||||
url=public_url,
|
||||
media_type="video",
|
||||
size_bytes=len(data),
|
||||
))
|
||||
return public_url
|
||||
except Exception as exc:
|
||||
print(f"[VK] Ошибка скачивания видео {vk_url}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
# ── VK content helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _best_photo(sizes: list) -> str | None:
|
||||
for t in ("w", "z", "y", "x", "m", "s"):
|
||||
for s in sizes:
|
||||
if s.get("type") == t:
|
||||
return s["url"]
|
||||
return sizes[-1]["url"] if sizes else None
|
||||
|
||||
|
||||
def _text_to_html(text: str) -> str:
|
||||
parts = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
line = _URL_RE.sub(r'<a href="\1" target="_blank">\1</a>', line)
|
||||
parts.append(f"<p>{line}</p>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_title(text: str, ts: int) -> str:
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
return line[:200]
|
||||
return "ВКонтакте " + datetime.utcfromtimestamp(ts).strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def _vk_slug(owner_id: int, post_id: int) -> str:
|
||||
return f"vk-{abs(owner_id)}-{post_id}"
|
||||
|
||||
|
||||
# ── DB helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _slug_exists(slug: str, db: AsyncSession) -> bool:
|
||||
r = await db.execute(select(Article.id).where(Article.slug == slug))
|
||||
return r.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _get_or_create_category(name: str, db: AsyncSession) -> Category:
|
||||
s = slugify(name)
|
||||
cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none()
|
||||
if not cat:
|
||||
cat = Category(name=name, slug=s)
|
||||
db.add(cat)
|
||||
await db.flush()
|
||||
return cat
|
||||
|
||||
|
||||
async def _get_or_create_tag(name: str, db: AsyncSession) -> Tag:
|
||||
s = slugify(name)
|
||||
tag = (await db.execute(select(Tag).where(Tag.slug == s))).scalar_one_or_none()
|
||||
if not tag:
|
||||
tag = Tag(name=name, slug=s)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
return tag
|
||||
|
||||
|
||||
# ── Post processing ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _process_post(post: dict, category_name: str, db: AsyncSession) -> bool:
|
||||
owner_id = post["owner_id"]
|
||||
post_id = post["id"]
|
||||
vk_slug = _vk_slug(owner_id, post_id)
|
||||
|
||||
if await _slug_exists(vk_slug, db):
|
||||
return False
|
||||
|
||||
text = post.get("text", "")
|
||||
attachments = post.get("attachments", [])
|
||||
ts = post["date"]
|
||||
|
||||
# VK Clips и некоторые видео-посты: post.text пустой, описание лежит в video.description
|
||||
if not text:
|
||||
for att in attachments:
|
||||
if att.get("type") == "video":
|
||||
desc = att["video"].get("description", "").strip()
|
||||
if desc:
|
||||
text = desc
|
||||
break
|
||||
|
||||
cover_url = None
|
||||
content_parts = []
|
||||
|
||||
if text:
|
||||
content_parts.append(_text_to_html(text))
|
||||
|
||||
for att in attachments:
|
||||
att_type = att.get("type")
|
||||
|
||||
if att_type == "photo":
|
||||
url = _best_photo(att["photo"].get("sizes", []))
|
||||
if url:
|
||||
minio_url = await _upload_from_url(url, db=db)
|
||||
final_url = minio_url or url # fallback на оригинальный URL если MinIO недоступен
|
||||
if cover_url is None:
|
||||
cover_url = final_url
|
||||
else:
|
||||
content_parts.append(
|
||||
f'<p><img src="{final_url}" style="max-width:100%;border-radius:8px;"></p>'
|
||||
)
|
||||
|
||||
elif att_type == "video":
|
||||
vid = att["video"]
|
||||
player = vid.get("player")
|
||||
vtitle = vid.get("title", "Видео")
|
||||
vid_id = vid.get("id")
|
||||
vid_oid = vid.get("owner_id")
|
||||
vk_video_url = (
|
||||
f"https://vk.com/video{vid_oid}_{vid_id}"
|
||||
if vid_id and vid_oid else None
|
||||
)
|
||||
|
||||
# Кадр превью из wall.get (запасной)
|
||||
thumb_url = None
|
||||
for src_key in ("image", "first_frame"):
|
||||
frames = vid.get(src_key, [])
|
||||
if frames:
|
||||
best = max(frames, key=lambda f: f.get("width", 0))
|
||||
thumb_url = best.get("url")
|
||||
break
|
||||
|
||||
# Улучшенный кадр превью из video.get (+ может вернуть player при scope=video)
|
||||
if vid_id and vid_oid:
|
||||
vinfo = await _fetch_video_info(vid_oid, vid_id)
|
||||
if vinfo.get("thumb_url"):
|
||||
thumb_url = vinfo["thumb_url"]
|
||||
|
||||
# Всегда скачиваем превью в MinIO
|
||||
minio_thumb = None
|
||||
if thumb_url:
|
||||
minio_thumb = await _upload_from_url(thumb_url, db=db)
|
||||
final_thumb = minio_thumb or thumb_url
|
||||
if cover_url is None:
|
||||
cover_url = final_thumb
|
||||
else:
|
||||
final_thumb = None
|
||||
|
||||
# Скачиваем само видео через yt-dlp → MinIO
|
||||
minio_video_url = None
|
||||
if vid_id and vid_oid:
|
||||
minio_video_url = await _download_vk_video(vid_oid, vid_id, db=db)
|
||||
|
||||
if minio_video_url:
|
||||
# 1. Видео лежит у нас — нативный <video>
|
||||
poster_attr = f' poster="{final_thumb}"' if final_thumb else ""
|
||||
content_parts.append(
|
||||
f'<div style="margin:1em 0">'
|
||||
f'<video src="{minio_video_url}"{poster_attr} controls '
|
||||
f'style="max-width:100%;border-radius:8px;display:block;">'
|
||||
f'</video>'
|
||||
f'<p><em>{vtitle}</em></p></div>'
|
||||
)
|
||||
|
||||
elif vk_video_url:
|
||||
# 2. Fallback: превью + ссылка на ВК (yt-dlp не смог)
|
||||
if final_thumb:
|
||||
content_parts.append(
|
||||
f'<p><a href="{vk_video_url}" target="_blank" rel="noopener">'
|
||||
f'<img src="{final_thumb}" alt="{vtitle}" '
|
||||
f'style="max-width:100%;border-radius:8px;display:block;">'
|
||||
f'</a></p>'
|
||||
f'<p>▶ <a href="{vk_video_url}" target="_blank" rel="noopener">'
|
||||
f'{vtitle}</a></p>'
|
||||
)
|
||||
else:
|
||||
content_parts.append(
|
||||
f'<p>▶ <a href="{vk_video_url}" target="_blank" rel="noopener">'
|
||||
f'{vtitle}</a></p>'
|
||||
)
|
||||
|
||||
elif att_type == "link":
|
||||
href = att["link"].get("url", "")
|
||||
label = att["link"].get("title") or href
|
||||
if href:
|
||||
content_parts.append(f'<p>🔗 <a href="{href}" target="_blank">{label}</a></p>')
|
||||
|
||||
content = _sanitize("\n".join(content_parts))
|
||||
if not content.strip():
|
||||
return False
|
||||
|
||||
title = _extract_title(text, ts)
|
||||
title_slug = slugify(title)
|
||||
final_slug = title_slug if not await _slug_exists(title_slug, db) else vk_slug
|
||||
|
||||
# Категория = название группы
|
||||
category = await _get_or_create_category(category_name, db)
|
||||
|
||||
# Теги по ключевым словам
|
||||
tag_names = _detect_tags(text)
|
||||
tags = [await _get_or_create_tag(n, db) for n in tag_names]
|
||||
|
||||
status = ArticleStatus.published if VK_IMPORT_AS == "published" else ArticleStatus.draft
|
||||
# Храним naive UTC — колонка TIMESTAMP WITHOUT TIME ZONE
|
||||
created_at = datetime.utcfromtimestamp(ts)
|
||||
published_at = created_at if status == ArticleStatus.published else None
|
||||
|
||||
source_url = f"https://vk.com/wall{owner_id}_{post_id}"
|
||||
|
||||
article = Article(
|
||||
title=title,
|
||||
slug=final_slug,
|
||||
content=content,
|
||||
excerpt=text[:300].strip(),
|
||||
cover_url=cover_url,
|
||||
source_url=source_url,
|
||||
status=status,
|
||||
published_at=published_at,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
category_id=category.id,
|
||||
tags=tags,
|
||||
)
|
||||
db.add(article)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── VK API fetch ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _fetch_posts(group_id: str, count: int) -> list:
|
||||
# Если токен есть — официальный API, иначе — HTML-парсинг
|
||||
if VK_TOKEN:
|
||||
return await _fetch_posts_api(group_id, count)
|
||||
return await _fetch_posts_html(group_id, count)
|
||||
|
||||
|
||||
async def _fetch_posts_api(group_id: str, count: int, offset: int = 0) -> list:
|
||||
owner_id = f"-{group_id.lstrip('-')}"
|
||||
params = {
|
||||
"owner_id": owner_id,
|
||||
"count": count,
|
||||
"offset": offset,
|
||||
"filter": "owner",
|
||||
"access_token": VK_TOKEN,
|
||||
"v": VK_VERSION,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(f"{VK_API}/wall.get", params=params)
|
||||
data = r.json()
|
||||
if "error" in data:
|
||||
print(f"[VK API] Ошибка группы {group_id}: {data['error'].get('error_msg')}")
|
||||
return []
|
||||
items = data.get("response", {}).get("items", [])
|
||||
print(f"[VK API] Группа {group_id} offset={offset}: {len(items)} постов")
|
||||
return items
|
||||
except Exception as exc:
|
||||
print(f"[VK API] Сетевая ошибка группы {group_id}: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
async def _fetch_posts_html(group_id: str, count: int) -> list:
|
||||
from vk_scraper import fetch_group_posts_html
|
||||
posts = await fetch_group_posts_html(group_id, count)
|
||||
print(f"[VK scraper] Группа {group_id}: {len(posts)} постов")
|
||||
return posts
|
||||
|
||||
|
||||
# ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
async def run_import() -> dict:
|
||||
"""Импортирует только новые посты: пагинирует каждую группу и останавливается,
|
||||
когда весь батч состоит из уже сохранённых постов."""
|
||||
mode = "API" if VK_TOKEN else "HTML-scraper"
|
||||
imported = skipped = errors = 0
|
||||
batch = 100
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
sources = (await db.execute(
|
||||
select(VkSource).where(VkSource.enabled == True)
|
||||
)).scalars().all()
|
||||
|
||||
if not sources:
|
||||
print("[VK] Нет активных источников")
|
||||
return {"ok": False, "reason": "Нет активных VK-источников"}
|
||||
|
||||
print(f"[VK] ▶ Импорт новых постов | групп: {len(sources)} | режим: {mode}")
|
||||
|
||||
for i, source in enumerate(sources, 1):
|
||||
src_offset = 0
|
||||
grp_new = grp_skip = grp_err = 0
|
||||
print(f"[VK] [{i}/{len(sources)}] Группа «{source.group_name}» (id={source.group_id})")
|
||||
|
||||
while True:
|
||||
if VK_TOKEN:
|
||||
posts = await _fetch_posts_api(source.group_id, batch, src_offset)
|
||||
else:
|
||||
posts = await _fetch_posts_html(source.group_id, batch)
|
||||
|
||||
if not posts:
|
||||
print(f"[VK] offset={src_offset}: постов нет, завершаем группу")
|
||||
break
|
||||
|
||||
batch_new = 0
|
||||
for post in posts:
|
||||
try:
|
||||
ok = await _process_post(post, source.group_name, db)
|
||||
if ok:
|
||||
imported += 1; grp_new += 1; batch_new += 1
|
||||
else:
|
||||
skipped += 1; grp_skip += 1
|
||||
except Exception as exc:
|
||||
print(f"[VK] ✗ Пост {post.get('id')}: {exc}")
|
||||
errors += 1; grp_err += 1
|
||||
await db.rollback()
|
||||
|
||||
print(f"[VK] offset={src_offset}: получено {len(posts)}, "
|
||||
f"новых +{batch_new}, пропущено {len(posts)-batch_new}")
|
||||
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
if batch_new == 0 or len(posts) < batch or not VK_TOKEN:
|
||||
print(f"[VK] Догнали до уже импортированных, останавливаем группу")
|
||||
break
|
||||
|
||||
src_offset += batch
|
||||
|
||||
print(f"[VK] ✓ «{source.group_name}»: +{grp_new} новых, {grp_skip} пропущено, {grp_err} ошибок")
|
||||
source.last_run = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
print(f"[VK] ■ Импорт завершён: +{imported} новых, {skipped} уже было, {errors} ошибок")
|
||||
return {"ok": True, "imported": imported, "skipped": skipped, "errors": errors, "mode": mode}
|
||||
|
||||
|
||||
async def run_history_import(group_id: str | None = None, since_days: int = 365) -> dict:
|
||||
"""Пагинированный импорт постов за последние since_days дней."""
|
||||
from datetime import timedelta
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=since_days)).timestamp()
|
||||
cutoff_str = datetime.utcfromtimestamp(cutoff).strftime("%d.%m.%Y")
|
||||
mode = "API" if VK_TOKEN else "HTML-scraper"
|
||||
imported = skipped = errors = 0
|
||||
batch = 100
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
q = select(VkSource).where(VkSource.enabled == True)
|
||||
if group_id:
|
||||
q = q.where(VkSource.group_id == group_id)
|
||||
sources = (await db.execute(q)).scalars().all()
|
||||
|
||||
if not sources:
|
||||
print("[VK] Источники не найдены")
|
||||
return {"ok": False, "reason": "Источники не найдены"}
|
||||
|
||||
print(f"[VK] ▶ Исторический импорт | с {cutoff_str} | групп: {len(sources)} | режим: {mode}")
|
||||
|
||||
for i, source in enumerate(sources, 1):
|
||||
src_offset = 0
|
||||
grp_new = grp_skip = grp_err = 0
|
||||
print(f"[VK] [{i}/{len(sources)}] Группа «{source.group_name}» (id={source.group_id})")
|
||||
|
||||
while True:
|
||||
if VK_TOKEN:
|
||||
posts = await _fetch_posts_api(source.group_id, batch, src_offset)
|
||||
else:
|
||||
posts = await _fetch_posts_html(source.group_id, batch)
|
||||
|
||||
if not posts:
|
||||
print(f"[VK] offset={src_offset}: постов нет, завершаем группу")
|
||||
break
|
||||
|
||||
hit_cutoff = False
|
||||
batch_new = 0
|
||||
for post in posts:
|
||||
post_date = datetime.utcfromtimestamp(post.get("date", 0)).strftime("%d.%m.%Y")
|
||||
if post.get("date", 0) < cutoff:
|
||||
hit_cutoff = True
|
||||
continue
|
||||
try:
|
||||
ok = await _process_post(post, source.group_name, db)
|
||||
if ok:
|
||||
imported += 1; grp_new += 1; batch_new += 1
|
||||
else:
|
||||
skipped += 1; grp_skip += 1
|
||||
except Exception as exc:
|
||||
print(f"[VK] ✗ Пост {post.get('id')} ({post_date}): {exc}")
|
||||
errors += 1; grp_err += 1
|
||||
await db.rollback()
|
||||
|
||||
oldest = datetime.utcfromtimestamp(posts[-1].get("date", 0)).strftime("%d.%m.%Y")
|
||||
print(f"[VK] offset={src_offset}: получено {len(posts)}, "
|
||||
f"новых +{batch_new}, пропущено {grp_skip}, самый старый: {oldest}"
|
||||
+ (" ← достигли года" if hit_cutoff else ""))
|
||||
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
if hit_cutoff or len(posts) < batch or not VK_TOKEN:
|
||||
break
|
||||
|
||||
src_offset += batch
|
||||
|
||||
print(f"[VK] ✓ «{source.group_name}»: +{grp_new} новых, {grp_skip} уже было, {grp_err} ошибок")
|
||||
source.last_run = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
print(f"[VK] ■ Исторический импорт завершён: +{imported} новых, {skipped} уже было, {errors} ошибок")
|
||||
return {"ok": True, "imported": imported, "skipped": skipped, "errors": errors, "mode": mode}
|
||||
179
api/vk_scraper.py
Normal file
179
api/vk_scraper.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Парсинг публичных групп ВКонтакте без API-ключа.
|
||||
Использует мобильную версию m.vk.com — простой HTML без JS-рендеринга.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Имитируем мобильный браузер
|
||||
_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
),
|
||||
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
}
|
||||
|
||||
_BASE = "https://m.vk.com"
|
||||
|
||||
# Разбираем русские сокращения дат вида "14 мая в 10:35" или "вчера в 18:00"
|
||||
_MONTHS = {
|
||||
"янв": 1, "фев": 2, "мар": 3, "апр": 4, "май": 5, "маи": 5,
|
||||
"июн": 6, "июл": 7, "авг": 8, "сен": 9, "окт": 10, "ноя": 11, "дек": 12,
|
||||
}
|
||||
|
||||
|
||||
def _parse_vk_date(text: str) -> datetime:
|
||||
"""Конвертирует строку даты VK в datetime UTC."""
|
||||
now = datetime.now(timezone.utc)
|
||||
text = text.strip().lower()
|
||||
|
||||
if "сегодня" in text or "today" in text:
|
||||
time_match = re.search(r"(\d{1,2}):(\d{2})", text)
|
||||
if time_match:
|
||||
return now.replace(hour=int(time_match[1]), minute=int(time_match[2]), second=0, microsecond=0)
|
||||
return now
|
||||
|
||||
if "вчера" in text or "yesterday" in text:
|
||||
yesterday = now - timedelta(days=1)
|
||||
time_match = re.search(r"(\d{1,2}):(\d{2})", text)
|
||||
if time_match:
|
||||
return yesterday.replace(hour=int(time_match[1]), minute=int(time_match[2]), second=0, microsecond=0)
|
||||
return yesterday
|
||||
|
||||
# "14 мая в 10:35" или "14 мая 2023 в 10:35"
|
||||
match = re.search(r"(\d{1,2})\s+([а-яё]+)(?:\s+(\d{4}))?(?:\s+в\s+(\d{1,2}):(\d{2}))?", text)
|
||||
if match:
|
||||
day = int(match[1])
|
||||
month = _MONTHS.get(match[2][:3], 1)
|
||||
year = int(match[3]) if match[3] else now.year
|
||||
hour = int(match[4]) if match[4] else 12
|
||||
minute= int(match[5]) if match[5] else 0
|
||||
try:
|
||||
return datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return now
|
||||
|
||||
|
||||
async def fetch_group_posts_html(group_id: str, count: int = 20) -> list[dict]:
|
||||
"""
|
||||
Скачивает и парсит посты публичной группы ВКонтакте.
|
||||
group_id — числовой ID без минуса (например "12345").
|
||||
Возвращает список словарей совместимых с форматом vk_parser.
|
||||
"""
|
||||
url = f"{_BASE}/wall-{group_id}"
|
||||
posts = []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers=_HEADERS,
|
||||
timeout=20,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
r = await client.get(url)
|
||||
|
||||
if r.status_code != 200:
|
||||
print(f"[VK scraper] HTTP {r.status_code} для группы {group_id}")
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
items = soup.select("div.wall_item, div._post, article.post")
|
||||
|
||||
if not items:
|
||||
# Попробуем запасной селектор
|
||||
items = soup.select("[id^='post-']")
|
||||
|
||||
for item in items[:count]:
|
||||
post = _parse_post(item, group_id)
|
||||
if post:
|
||||
posts.append(post)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[VK scraper] Ошибка группы {group_id}: {exc}")
|
||||
|
||||
return posts
|
||||
|
||||
|
||||
def _parse_post(item, group_id: str) -> dict | None:
|
||||
"""Парсит один пост из HTML-элемента."""
|
||||
# ── ID поста ──────────────────────────────────────────────────────────────
|
||||
post_id = None
|
||||
elem_id = item.get("id", "")
|
||||
id_match = re.search(r"(\d+)_(\d+)$", elem_id)
|
||||
if id_match:
|
||||
post_id = int(id_match[2])
|
||||
else:
|
||||
# Ищем ссылку на пост вида /wall-12345_67890
|
||||
link = item.select_one("a[href*='wall-']")
|
||||
if link:
|
||||
lm = re.search(r"wall-?\d+_(\d+)", link["href"])
|
||||
if lm:
|
||||
post_id = int(lm[1])
|
||||
|
||||
if not post_id:
|
||||
return None
|
||||
|
||||
owner_id = int(group_id)
|
||||
|
||||
# ── Текст ─────────────────────────────────────────────────────────────────
|
||||
text_el = (
|
||||
item.select_one(".pi_text")
|
||||
or item.select_one(".wall_post_text")
|
||||
or item.select_one("._post_content")
|
||||
or item.select_one(".post_text")
|
||||
)
|
||||
text = text_el.get_text(separator="\n").strip() if text_el else ""
|
||||
|
||||
# ── Дата ──────────────────────────────────────────────────────────────────
|
||||
date_el = item.select_one(".pi_date, time, .post_date, ._date")
|
||||
ts = int(datetime.now(timezone.utc).timestamp())
|
||||
if date_el:
|
||||
dt = _parse_vk_date(date_el.get_text())
|
||||
ts = int(dt.timestamp())
|
||||
|
||||
# ── Изображения ───────────────────────────────────────────────────────────
|
||||
attachments = []
|
||||
for img in item.select("img.pi_img, .wall_post_img img, img._photo_img"):
|
||||
src = img.get("src") or img.get("data-src") or ""
|
||||
if src and "userapi.com" in src or "vk.com" in src:
|
||||
attachments.append({"type": "photo", "photo": {"sizes": [{"type": "x", "url": src}]}})
|
||||
|
||||
# ── Видео ─────────────────────────────────────────────────────────────────
|
||||
for video_link in item.select("a[href*='/video']"):
|
||||
href = video_link.get("href", "")
|
||||
vid_match = re.search(r"/video(-?\d+)_(\d+)", href)
|
||||
if vid_match:
|
||||
oid = vid_match[1]
|
||||
vid = vid_match[2]
|
||||
player = f"https://vk.com/video_ext.php?oid={oid}&id={vid}"
|
||||
title = video_link.get_text(strip=True) or "Видео"
|
||||
attachments.append({"type": "video", "video": {"player": player, "title": title}})
|
||||
|
||||
# ── Ссылки ────────────────────────────────────────────────────────────────
|
||||
for a in item.select("a.pi_snippet, a._snippet"):
|
||||
href = a.get("href", "")
|
||||
label = a.select_one(".pi_snippet_title, .snippet_title")
|
||||
if href and not href.startswith("/"):
|
||||
attachments.append({
|
||||
"type": "link",
|
||||
"link": {"url": href, "title": label.get_text(strip=True) if label else href},
|
||||
})
|
||||
|
||||
if not text and not attachments:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": post_id,
|
||||
"owner_id": -owner_id,
|
||||
"date": ts,
|
||||
"text": text,
|
||||
"attachments": attachments,
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
|
||||
# ── FastAPI backend ────────────────────────────────────────────────────────
|
||||
@@ -21,11 +19,18 @@ services:
|
||||
DOCS_PASSWORD: ${DOCS_PASSWORD:-admin}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
|
||||
VK_ACCESS_TOKEN: ${VK_ACCESS_TOKEN:-}
|
||||
VK_IMPORT_STATUS: ${VK_IMPORT_STATUS:-published}
|
||||
VK_POSTS_PER_RUN: ${VK_POSTS_PER_RUN:-100}
|
||||
VK_IMPORT_HOUR: ${VK_IMPORT_HOUR:-6}
|
||||
VK_IMPORT_MINUTE: ${VK_IMPORT_MINUTE:-0}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8000/health | grep ok || exit 1"]
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\" || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -33,25 +38,23 @@ services:
|
||||
networks:
|
||||
- news_net
|
||||
|
||||
# ── Flet SPA + React editor (multi-stage build) ────────────────────────────
|
||||
# ── React SPA + TipTap editor + nginx ─────────────────────────────────────
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: web-react/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:8080"
|
||||
environment:
|
||||
API_URL: http://api:8000
|
||||
- "80:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ > /dev/null 2>&1 || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
start_period: 20s
|
||||
networks:
|
||||
- news_net
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
|
||||
POSTGRES_USER: postgres
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
@@ -21,7 +22,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- news_net
|
||||
news_net:
|
||||
aliases:
|
||||
- db
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
@@ -41,7 +44,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- news_net
|
||||
news_net:
|
||||
aliases:
|
||||
- redis
|
||||
|
||||
networks:
|
||||
news_net:
|
||||
|
||||
@@ -38,7 +38,7 @@ find_container() {
|
||||
echo "$found"
|
||||
return
|
||||
fi
|
||||
error "Контейнер PostgreSQL не найден. Запусти: docker-compose -f docker-compose_db.yml up -d"
|
||||
error "Контейнер PostgreSQL не найден. Запусти: docker compose -f docker-compose_db.yml up -d"
|
||||
}
|
||||
|
||||
psql_cmd() {
|
||||
@@ -153,9 +153,9 @@ main() {
|
||||
create_data_dirs
|
||||
|
||||
# Запуск docker-compose_db.yml если не запущен
|
||||
if ! docker-compose -f docker-compose_db.yml ps --services --filter "status=running" 2>/dev/null | grep -q "db"; then
|
||||
if ! docker compose -f docker-compose_db.yml ps --services --filter "status=running" 2>/dev/null | grep -q "db"; then
|
||||
info "Запуск docker-compose_db.yml..."
|
||||
docker-compose -f docker-compose_db.yml up -d
|
||||
docker compose -f docker-compose_db.yml up -d
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
@@ -173,7 +173,7 @@ main() {
|
||||
echo "══════════════════════════════════════════════"
|
||||
info "Готово! Теперь запускай:"
|
||||
echo ""
|
||||
echo " docker-compose up -d --build"
|
||||
echo " docker compose up -d --build"
|
||||
echo ""
|
||||
echo " Сайт: http://localhost"
|
||||
echo " Админка: http://localhost/admin"
|
||||
|
||||
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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2,7 +2,21 @@ import os
|
||||
import httpx
|
||||
|
||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
_TIMEOUT = 15.0
|
||||
_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
||||
|
||||
# Один клиент на весь процесс — TCP-соединения переиспользуются (keep-alive)
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _get() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(
|
||||
base_url=API_URL,
|
||||
timeout=_TIMEOUT,
|
||||
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _headers(token: str | None = None) -> dict:
|
||||
@@ -19,15 +33,20 @@ async def get_news(
|
||||
limit: int = 20,
|
||||
category: str | None = None,
|
||||
tag: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> dict | None:
|
||||
params = {"offset": offset, "limit": limit}
|
||||
params: dict = {"offset": offset, "limit": limit}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if tag:
|
||||
params["tag"] = tag
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/news", params=params)
|
||||
r = await _get().get("/news", params=params)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -37,8 +56,7 @@ async def get_news(
|
||||
|
||||
async def get_article(slug: str) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/news/{slug}")
|
||||
r = await _get().get(f"/news/{slug}")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -48,8 +66,17 @@ async def get_article(slug: str) -> dict | None:
|
||||
|
||||
async def get_categories() -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/news/categories")
|
||||
r = await _get().get("/news/categories")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_tags(limit: int = 30) -> list:
|
||||
try:
|
||||
r = await _get().get("/news/tags", params={"limit": limit})
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -61,9 +88,8 @@ async def get_categories() -> list:
|
||||
|
||||
async def admin_login(username: str, password: str) -> str | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(
|
||||
f"{API_URL}/admin/login",
|
||||
r = await _get().post(
|
||||
"/admin/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
@@ -76,12 +102,11 @@ async def admin_login(username: str, password: str) -> str | None:
|
||||
# ─── Admin: Articles ──────────────────────────────────────────────────────────
|
||||
|
||||
async def admin_list_articles(token: str, offset: int = 0, limit: int = 50, status: str | None = None) -> dict | None:
|
||||
params = {"offset": offset, "limit": limit}
|
||||
params: dict = {"offset": offset, "limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/articles", params=params, headers=_headers(token))
|
||||
r = await _get().get("/admin/articles", params=params, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -91,8 +116,7 @@ async def admin_list_articles(token: str, offset: int = 0, limit: int = 50, stat
|
||||
|
||||
async def admin_get_article(token: str, article_id: str) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
|
||||
r = await _get().get(f"/admin/articles/{article_id}", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -102,8 +126,7 @@ async def admin_get_article(token: str, article_id: str) -> dict | None:
|
||||
|
||||
async def admin_create_article(token: str, data: dict) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(f"{API_URL}/admin/articles", json=data, headers=_headers(token))
|
||||
r = await _get().post("/admin/articles", json=data, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -113,8 +136,7 @@ async def admin_create_article(token: str, data: dict) -> dict | None:
|
||||
|
||||
async def admin_update_article(token: str, article_id: str, data: dict) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.put(f"{API_URL}/admin/articles/{article_id}", json=data, headers=_headers(token))
|
||||
r = await _get().put(f"/admin/articles/{article_id}", json=data, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -124,8 +146,7 @@ async def admin_update_article(token: str, article_id: str, data: dict) -> dict
|
||||
|
||||
async def admin_delete_article(token: str, article_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.delete(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
|
||||
r = await _get().delete(f"/admin/articles/{article_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
@@ -133,8 +154,7 @@ async def admin_delete_article(token: str, article_id: str) -> bool:
|
||||
|
||||
async def admin_publish_article(token: str, article_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(f"{API_URL}/admin/articles/{article_id}/publish", headers=_headers(token))
|
||||
r = await _get().post(f"/admin/articles/{article_id}/publish", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
@@ -144,8 +164,7 @@ async def admin_publish_article(token: str, article_id: str) -> bool:
|
||||
|
||||
async def admin_list_categories(token: str) -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/categories", headers=_headers(token))
|
||||
r = await _get().get("/admin/categories", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -155,9 +174,8 @@ async def admin_list_categories(token: str) -> list:
|
||||
|
||||
async def admin_create_category(token: str, name: str, slug: str = "") -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(
|
||||
f"{API_URL}/admin/categories",
|
||||
r = await _get().post(
|
||||
"/admin/categories",
|
||||
json={"name": name, "slug": slug or None},
|
||||
headers=_headers(token),
|
||||
)
|
||||
@@ -170,9 +188,8 @@ async def admin_create_category(token: str, name: str, slug: str = "") -> dict |
|
||||
|
||||
async def admin_update_category(token: str, cat_id: str, name: str, slug: str = "") -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.put(
|
||||
f"{API_URL}/admin/categories/{cat_id}",
|
||||
r = await _get().put(
|
||||
f"/admin/categories/{cat_id}",
|
||||
json={"name": name, "slug": slug or None},
|
||||
headers=_headers(token),
|
||||
)
|
||||
@@ -185,8 +202,7 @@ async def admin_update_category(token: str, cat_id: str, name: str, slug: str =
|
||||
|
||||
async def admin_delete_category(token: str, cat_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.delete(f"{API_URL}/admin/categories/{cat_id}", headers=_headers(token))
|
||||
r = await _get().delete(f"/admin/categories/{cat_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
@@ -196,9 +212,8 @@ async def admin_delete_category(token: str, cat_id: str) -> bool:
|
||||
|
||||
async def admin_upload_media(token: str, file_bytes: bytes, filename: str, content_type: str) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
r = await client.post(
|
||||
f"{API_URL}/admin/media/upload",
|
||||
r = await _get().post(
|
||||
"/admin/media/upload",
|
||||
files={"file": (filename, file_bytes, content_type)},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
@@ -210,12 +225,11 @@ async def admin_upload_media(token: str, file_bytes: bytes, filename: str, conte
|
||||
|
||||
|
||||
async def admin_list_media(token: str, offset: int = 0, limit: int = 50, media_type: str | None = None) -> list:
|
||||
params = {"offset": offset, "limit": limit}
|
||||
params: dict = {"offset": offset, "limit": limit}
|
||||
if media_type:
|
||||
params["media_type"] = media_type
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/media", params=params, headers=_headers(token))
|
||||
r = await _get().get("/admin/media", params=params, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
@@ -225,8 +239,88 @@ async def admin_list_media(token: str, offset: int = 0, limit: int = 50, media_t
|
||||
|
||||
async def admin_delete_media(token: str, media_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.delete(f"{API_URL}/admin/media/{media_id}", headers=_headers(token))
|
||||
r = await _get().delete(f"/admin/media/{media_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
# ─── Admin: VK Sources ────────────────────────────────────────────────────────
|
||||
|
||||
async def admin_vk_status(token: str) -> dict:
|
||||
try:
|
||||
r = await _get().get("/admin/vk/status", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return {"configured": False}
|
||||
|
||||
|
||||
async def admin_vk_list_sources(token: str) -> list:
|
||||
try:
|
||||
r = await _get().get("/admin/vk/sources", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def admin_vk_add_source(token: str, group_id: str, group_name: str, enabled: bool = True) -> dict | None:
|
||||
try:
|
||||
r = await _get().post(
|
||||
"/admin/vk/sources",
|
||||
json={"group_id": group_id, "group_name": group_name, "enabled": enabled},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 201:
|
||||
return r.json()
|
||||
return {"error": r.json().get("detail", "Ошибка")}
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
|
||||
async def admin_vk_update_source(token: str, source_id: str, group_id: str, group_name: str, enabled: bool) -> dict | None:
|
||||
try:
|
||||
r = await _get().patch(
|
||||
f"/admin/vk/sources/{source_id}",
|
||||
json={"group_id": group_id, "group_name": group_name, "enabled": enabled},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def admin_vk_delete_source(token: str, source_id: str) -> bool:
|
||||
try:
|
||||
r = await _get().delete(f"/admin/vk/sources/{source_id}", headers=_headers(token))
|
||||
return r.status_code == 204
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
async def admin_vk_import(token: str) -> dict | None:
|
||||
try:
|
||||
r = await _get().post("/admin/vk/import", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def admin_vk_import_history(token: str, source_id: str | None = None) -> dict | None:
|
||||
url = "/admin/vk/import/history"
|
||||
if source_id:
|
||||
url += f"/{source_id}"
|
||||
try:
|
||||
r = await _get().post(url, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -26,18 +26,19 @@ GOOGLE_FONTS_JS = """
|
||||
"""
|
||||
|
||||
# ─── Цвета ───────────────────────────────────────────────────────────────────
|
||||
PRIMARY = "#1565C0"
|
||||
PRIMARY_LIGHT = "#1976D2"
|
||||
PRIMARY_DARK = "#0D47A1"
|
||||
ACCENT = "#FF6D00"
|
||||
SUCCESS = "#2E7D32"
|
||||
WARNING = "#F57F17"
|
||||
ERROR = "#C62828"
|
||||
PRIMARY = "#2787F5" # VK синий
|
||||
PRIMARY_LIGHT = "#5BA4F7"
|
||||
PRIMARY_DARK = "#1565C0"
|
||||
ACCENT = "#FF3F4B" # красный акцент (лайки / важное)
|
||||
SUCCESS = "#3DC47E"
|
||||
WARNING = "#FFA726"
|
||||
ERROR = "#E64646"
|
||||
SURFACE = "#FFFFFF"
|
||||
BACKGROUND = "#F5F5F5"
|
||||
TEXT_PRIMARY = "#212121"
|
||||
TEXT_SECONDARY = "#616161"
|
||||
DIVIDER = "#E0E0E0"
|
||||
BACKGROUND = "#F0F2F5" # VK светло-серый фон
|
||||
CARD_BG = "#FFFFFF"
|
||||
TEXT_PRIMARY = "#050505"
|
||||
TEXT_SECONDARY = "#818C99" # VK серый для мета-текста
|
||||
DIVIDER = "#E5E9EF"
|
||||
ADMIN_SIDEBAR = "#1A237E"
|
||||
ADMIN_SIDEBAR_TEXT = "#E8EAF6"
|
||||
ADMIN_SIDEBAR_ACTIVE = "#283593"
|
||||
|
||||
@@ -52,22 +52,62 @@ const DEFAULT_META = {
|
||||
status: 'draft',
|
||||
}
|
||||
|
||||
/* ── Загрузчик: сначала данные, потом редактор ── */
|
||||
export default function App() {
|
||||
const [meta, setMeta] = useState(DEFAULT_META)
|
||||
const [initContent, setInitContent] = useState(IS_NEW ? '' : null) // null = ещё не загружено
|
||||
const [initMeta, setInitMeta] = useState(DEFAULT_META)
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_NEW) return
|
||||
api.getArticle(ARTICLE_ID, TOKEN)
|
||||
.then(data => {
|
||||
setInitMeta({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
excerpt: data.excerpt || '',
|
||||
cover_url: data.cover_url || null,
|
||||
font_family: data.font_family || 'Merriweather',
|
||||
category_id: data.category?.id || null,
|
||||
tag_names: (data.tags || []).map(t => t.name),
|
||||
status: data.status,
|
||||
})
|
||||
const html = data.content?.startsWith('<')
|
||||
? data.content
|
||||
: marked.parse(data.content || '')
|
||||
setInitContent(html || '')
|
||||
})
|
||||
.catch(() => setInitContent(''))
|
||||
}, [])
|
||||
|
||||
// Пока данные не загружены — показываем лоадер
|
||||
if (initContent === null) {
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', alignItems: 'center', justifyContent: 'center',
|
||||
background: '#f8fafc', color: '#94a3b8', fontSize: 14 }}>
|
||||
Загрузка статьи…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <EditorApp initContent={initContent} initMeta={initMeta} />
|
||||
}
|
||||
|
||||
/* ── Сам редактор — рендерится только когда данные готовы ── */
|
||||
function EditorApp({ initContent, initMeta }) {
|
||||
const [meta, setMeta] = useState(initMeta)
|
||||
const [categories, setCategories] = useState([])
|
||||
const [saveStatus, setSaveStatus] = useState('saved')
|
||||
const [mediaState, setMediaState] = useState(null)
|
||||
|
||||
/* Refs so callbacks always see latest values without re-creating them */
|
||||
const metaRef = useRef(meta)
|
||||
metaRef.current = meta
|
||||
const currentIdRef = useRef(IS_NEW ? null : ARTICLE_ID)
|
||||
const currentIdRef = useRef(initMeta.id ?? (IS_NEW ? null : ARTICLE_ID))
|
||||
const saveTimerRef = useRef(null)
|
||||
const editorRef = useRef(null) // always points to live editor instance
|
||||
const articleLoadedRef = useRef(false)
|
||||
const doSaveRef = useRef(null) // always points to latest doSave
|
||||
const editorRef = useRef(null)
|
||||
const doSaveRef = useRef(null)
|
||||
|
||||
/* ── Editor ── */
|
||||
/* ── Editor создаётся сразу с готовым контентом ── */
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -88,54 +128,22 @@ export default function App() {
|
||||
Placeholder.configure({ placeholder: 'Начните писать статью…' }),
|
||||
CharacterCount,
|
||||
],
|
||||
content: initContent,
|
||||
onUpdate: () => {
|
||||
setSaveStatus('unsaved')
|
||||
clearTimeout(saveTimerRef.current)
|
||||
// Always call the latest doSave via ref — avoids stale closure
|
||||
saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000)
|
||||
},
|
||||
})
|
||||
|
||||
// Keep editorRef current every render so doSave never uses a stale instance
|
||||
editorRef.current = editor
|
||||
|
||||
/* ── Load categories once on mount ── */
|
||||
/* ── Категории ── */
|
||||
useEffect(() => {
|
||||
api.getCategories(TOKEN).then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
|
||||
/* ── Load article once the editor is ready ── */
|
||||
useEffect(() => {
|
||||
if (!editor || IS_NEW || articleLoadedRef.current) return
|
||||
articleLoadedRef.current = true
|
||||
|
||||
api.getArticle(ARTICLE_ID, TOKEN)
|
||||
.then(data => {
|
||||
setMeta({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
excerpt: data.excerpt || '',
|
||||
cover_url: data.cover_url || null,
|
||||
font_family: data.font_family || 'Merriweather',
|
||||
category_id: data.category?.id || null,
|
||||
tag_names: (data.tags || []).map(t => t.name),
|
||||
status: data.status,
|
||||
})
|
||||
const html = data.content?.startsWith('<')
|
||||
? data.content
|
||||
: marked.parse(data.content || '')
|
||||
editor.commands.setContent(html, false)
|
||||
setSaveStatus('saved')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [editor])
|
||||
|
||||
/* ── Save ─────────────────────────────────────────────────────────────────
|
||||
Uses editorRef + metaRef so this callback is stable (no deps that change).
|
||||
doSaveRef is updated every render so the auto-save timer always calls the
|
||||
freshest version.
|
||||
── */
|
||||
/* ── Сохранение ── */
|
||||
const doSave = useCallback(async overrideStatus => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
@@ -165,15 +173,12 @@ export default function App() {
|
||||
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
|
||||
}
|
||||
setSaveStatus('saved')
|
||||
if (overrideStatus) {
|
||||
setMeta(m => ({ ...m, status: overrideStatus }))
|
||||
}
|
||||
if (overrideStatus) setMeta(m => ({ ...m, status: overrideStatus }))
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
}, []) // stable: reads from editorRef + metaRef
|
||||
}, [])
|
||||
|
||||
// Keep ref current so the auto-save timer always calls the latest version
|
||||
doSaveRef.current = doSave
|
||||
|
||||
const handleUploadCover = async file => {
|
||||
@@ -183,7 +188,6 @@ export default function App() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/* ── Render ── */
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||
|
||||
|
||||
@@ -15,13 +15,20 @@ export function Header({ saveStatus, articleStatus, onSave, onPublish }) {
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 h-14 bg-slate-900 border-b border-slate-800 flex-shrink-0 z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="javascript:history.back()"
|
||||
<button
|
||||
onClick={() => {
|
||||
// Если в iframe — говорим родителю закрыть редактор
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'editor-close' }, '*')
|
||||
} else {
|
||||
window.close()
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-slate-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Назад</span>
|
||||
</a>
|
||||
<span>Закрыть</span>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-700" />
|
||||
<span className="text-slate-200 font-semibold text-sm tracking-tight">News CMS</span>
|
||||
</div>
|
||||
|
||||
@@ -35,38 +35,60 @@ function Sep() {
|
||||
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
|
||||
}
|
||||
|
||||
/* ── Hook: позиция дропдауна через fixed (вырывается из overflow-контейнеров) ── */
|
||||
|
||||
function useFixedDropdown() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef(null)
|
||||
|
||||
const toggle = e => {
|
||||
e.preventDefault()
|
||||
if (!open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen(o => !o)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = e => {
|
||||
if (!btnRef.current?.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [open])
|
||||
|
||||
return { open, setOpen, pos, btnRef, toggle }
|
||||
}
|
||||
|
||||
/* ── Font family dropdown ── */
|
||||
|
||||
function FontDropdown({ editor, fonts }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
||||
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт'
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={ref}>
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||
onMouseDown={toggle}
|
||||
className="flex items-center gap-1 px-2 h-[30px] rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors max-w-[130px] cursor-pointer"
|
||||
title="Шрифт"
|
||||
>
|
||||
<span
|
||||
className="truncate leading-none"
|
||||
style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}
|
||||
>
|
||||
<span className="truncate leading-none" style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}>
|
||||
{current}
|
||||
</span>
|
||||
<ChevronDown size={11} className="flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 max-h-72 overflow-y-auto py-1.5">
|
||||
<div
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
|
||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
|
||||
@@ -95,21 +117,15 @@ function FontDropdown({ editor, fonts }) {
|
||||
/* ── Font size dropdown ── */
|
||||
|
||||
function SizeDropdown({ editor }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
||||
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—'
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={ref}>
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||
onMouseDown={toggle}
|
||||
className="flex items-center gap-1 px-1.5 h-[30px] w-14 rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors cursor-pointer"
|
||||
title="Размер шрифта"
|
||||
>
|
||||
@@ -118,7 +134,10 @@ function SizeDropdown({ editor }) {
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-20 max-h-56 overflow-y-auto py-1.5">
|
||||
<div
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 80 }}
|
||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-56 overflow-y-auto py-1.5"
|
||||
>
|
||||
{FONT_SIZES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
@@ -138,14 +157,7 @@ function SizeDropdown({ editor }) {
|
||||
/* ── Table menu ── */
|
||||
|
||||
function TableMenu({ editor }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [])
|
||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
||||
|
||||
const items = [
|
||||
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
@@ -162,17 +174,22 @@ function TableMenu({ editor }) {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={ref}>
|
||||
<div className="flex-shrink-0">
|
||||
<Btn
|
||||
onClick={() => setOpen(o => !o)}
|
||||
onClick={e => toggle({ preventDefault: () => {}, ...e })}
|
||||
active={editor.isActive('table')}
|
||||
title="Таблица"
|
||||
>
|
||||
<Table2 size={14} />
|
||||
</Btn>
|
||||
{/* Используем ref на обёртке для Btn */}
|
||||
<span ref={btnRef} style={{ display: 'none' }} />
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 py-1.5">
|
||||
<div
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
|
||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl py-1.5"
|
||||
>
|
||||
{items.map((item, i) =>
|
||||
item === null ? (
|
||||
<div key={i} className="my-1 border-t border-slate-100" />
|
||||
@@ -181,9 +198,7 @@ function TableMenu({ editor }) {
|
||||
key={item.label}
|
||||
type="button"
|
||||
className={`w-full px-3 py-1.5 text-left text-xs transition-colors
|
||||
${item.danger
|
||||
? 'text-red-500 hover:bg-red-50'
|
||||
: 'text-slate-700 hover:bg-slate-50'}`}
|
||||
${item.danger ? 'text-red-500 hover:bg-red-50' : 'text-slate-700 hover:bg-slate-50'}`}
|
||||
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
|
||||
>
|
||||
{item.label}
|
||||
@@ -210,7 +225,7 @@ export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-wrap min-h-[46px]">
|
||||
<div className="flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-shrink-0 min-h-[46px]" style={{ scrollbarWidth: 'none' }}>
|
||||
{/* History */}
|
||||
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
|
||||
<Undo2 size={14} />
|
||||
|
||||
@@ -8,6 +8,7 @@ from views.articles_list import ArticlesListView
|
||||
from views.article_editor import ArticleEditorView
|
||||
from views.categories import CategoriesView
|
||||
from views.media_library import MediaLibraryView
|
||||
from views.vk_sources import VkSourcesView
|
||||
|
||||
_PUBLIC = {"/", "/admin/login"}
|
||||
|
||||
@@ -45,6 +46,8 @@ def resolve_view(page: ft.Page, route: str):
|
||||
return CategoriesView(page)
|
||||
if sub == "media":
|
||||
return MediaLibraryView(page)
|
||||
if sub == "vk":
|
||||
return VkSourcesView(page)
|
||||
return DashboardView(page)
|
||||
|
||||
return NewsFeedView(page)
|
||||
|
||||
@@ -42,15 +42,18 @@ class DashboardView:
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
import asyncio
|
||||
token = self.page.session.get("token")
|
||||
data = await api.admin_list_articles(token, limit=100)
|
||||
if not data:
|
||||
return
|
||||
published_data, draft_data, recent_data = await asyncio.gather(
|
||||
api.admin_list_articles(token, limit=1, status="published"),
|
||||
api.admin_list_articles(token, limit=1, status="draft"),
|
||||
api.admin_list_articles(token, limit=10),
|
||||
)
|
||||
|
||||
items = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
published = sum(1 for a in items if a.get("status") == "published")
|
||||
drafts = total - published
|
||||
published = (published_data or {}).get("total", 0)
|
||||
drafts = (draft_data or {}).get("total", 0)
|
||||
total = published + drafts
|
||||
items = (recent_data or {}).get("items", [])
|
||||
|
||||
self._stats_row.controls = [
|
||||
d.stat_card("Всего статей", str(total), ft.icons.ARTICLE, d.PRIMARY),
|
||||
@@ -58,7 +61,7 @@ class DashboardView:
|
||||
d.stat_card("Черновики", str(drafts), ft.icons.EDIT_NOTE, d.WARNING),
|
||||
]
|
||||
|
||||
self._recent.controls = [_recent_item(a, self.page) for a in items[:10]]
|
||||
self._recent.controls = [_recent_item(a, self.page) for a in items]
|
||||
self.page.update()
|
||||
|
||||
|
||||
@@ -118,6 +121,7 @@ def _sidebar(page: ft.Page, active: str = "") -> ft.Container:
|
||||
(ft.icons.ARTICLE, "Статьи", "/admin/articles"),
|
||||
(ft.icons.CATEGORY, "Категории", "/admin/categories"),
|
||||
(ft.icons.PERM_MEDIA, "Медиа", "/admin/media"),
|
||||
(ft.icons.RSS_FEED, "ВКонтакте", "/admin/vk"),
|
||||
]
|
||||
|
||||
def nav_item(icon, label, route):
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
import re
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
import theme_manager
|
||||
|
||||
|
||||
def _html_to_markdown(html: str) -> str:
|
||||
"""Convert TipTap HTML output to Markdown for ft.Markdown rendering."""
|
||||
t = html or ""
|
||||
# Headings
|
||||
t = re.sub(r"<h([1-6])[^>]*>(.*?)</h\1>",
|
||||
lambda m: "#" * int(m[1]) + " " + m[2] + "\n\n",
|
||||
t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Bold / italic
|
||||
t = re.sub(r"<(strong|b)[^>]*>(.*?)</\1>", r"**\2**", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
t = re.sub(r"<(em|i)[^>]*>(.*?)</\1>", r"*\2*", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Links
|
||||
t = re.sub(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
|
||||
r"[\2](\1)", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Images
|
||||
t = re.sub(r'<img[^>]+src=["\']([^"\']+)["\'][^>]*>', r"", t, flags=re.IGNORECASE)
|
||||
# Lists
|
||||
t = re.sub(r"<li[^>]*>(.*?)</li>", r"- \1\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
t = re.sub(r"</?[uo]l[^>]*>", "\n", t, flags=re.IGNORECASE)
|
||||
# Paragraphs / line breaks
|
||||
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.IGNORECASE)
|
||||
t = re.sub(r"<p[^>]*>(.*?)</p>", r"\1\n\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Strip remaining tags
|
||||
t = re.sub(r"<[^>]+>", "", t)
|
||||
# HTML entities
|
||||
t = (t.replace(" ", " ").replace("&", "&")
|
||||
.replace("<", "<").replace(">", ">").replace(""", '"'))
|
||||
# Collapse excessive blank lines
|
||||
t = re.sub(r"\n{3,}", "\n\n", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
class NewsDetailView:
|
||||
def __init__(self, page: ft.Page, slug: str):
|
||||
self.page = page
|
||||
@@ -159,7 +191,7 @@ class NewsDetailView:
|
||||
|
||||
controls.append(
|
||||
ft.Markdown(
|
||||
value=a.get("content", ""),
|
||||
value=_html_to_markdown(a.get("content", "")),
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
on_tap_link=lambda e: self.page.launch_url(e.data),
|
||||
@@ -167,6 +199,26 @@ class NewsDetailView:
|
||||
)
|
||||
)
|
||||
|
||||
if a.get("source_url"):
|
||||
source_url = a["source_url"]
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Icon(ft.icons.OPEN_IN_NEW, size=15, color=d.PRIMARY),
|
||||
ft.Text("Источник:", size=13, color=d.TEXT_SECONDARY),
|
||||
ft.TextButton(
|
||||
source_url,
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, url=source_url: self.page.launch_url(url),
|
||||
),
|
||||
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER, wrap=True),
|
||||
bgcolor=ft.colors.with_opacity(0.04, d.PRIMARY),
|
||||
border_radius=8,
|
||||
padding=12,
|
||||
border=ft.border.all(1, ft.colors.with_opacity(0.15, d.PRIMARY)),
|
||||
)
|
||||
)
|
||||
|
||||
controls.append(ft.Divider(height=32, color="transparent"))
|
||||
controls.append(
|
||||
ft.TextButton(
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
|
||||
|
||||
_DATE_PRESETS = [
|
||||
(None, "Всё время"),
|
||||
("week", "Неделя"),
|
||||
("month", "Месяц"),
|
||||
("3months", "3 месяца"),
|
||||
("year", "Год"),
|
||||
]
|
||||
|
||||
|
||||
def _preset_to_date_from(preset: str | None) -> str | None:
|
||||
if preset is None:
|
||||
return None
|
||||
deltas = {
|
||||
"week": timedelta(weeks=1),
|
||||
"month": timedelta(days=30),
|
||||
"3months": timedelta(days=90),
|
||||
"year": timedelta(days=365),
|
||||
}
|
||||
if preset in deltas:
|
||||
return (datetime.now() - deltas[preset]).isoformat()
|
||||
return None
|
||||
|
||||
|
||||
class NewsFeedView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
@@ -12,82 +38,103 @@ class NewsFeedView:
|
||||
self._has_more = True
|
||||
self._loading = False
|
||||
self._category: str | None = None
|
||||
self._tag: str | None = None
|
||||
self._date_preset: str | None = None
|
||||
self._categories: list = []
|
||||
self._tags: list = []
|
||||
|
||||
self._list_col = ft.Column(spacing=16)
|
||||
self._grid = ft.GridView(
|
||||
max_extent=290,
|
||||
child_aspect_ratio=0.72,
|
||||
spacing=12,
|
||||
run_spacing=12,
|
||||
expand=True,
|
||||
padding=0,
|
||||
)
|
||||
self._loader = ft.Container(
|
||||
content=d.loader(36),
|
||||
alignment=ft.alignment.center,
|
||||
padding=20,
|
||||
visible=True,
|
||||
)
|
||||
self._load_more_btn = ft.ElevatedButton(
|
||||
"Загрузить ещё",
|
||||
self._load_more_btn = ft.Container(
|
||||
content=ft.TextButton(
|
||||
"Показать ещё",
|
||||
icon=ft.icons.EXPAND_MORE,
|
||||
on_click=lambda e: self.page.run_task(self._load_more),
|
||||
visible=False,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=d.SURFACE,
|
||||
color=d.PRIMARY,
|
||||
shape=ft.RoundedRectangleBorder(radius=8),
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
),
|
||||
alignment=ft.alignment.center,
|
||||
padding=ft.padding.only(bottom=24),
|
||||
visible=False,
|
||||
)
|
||||
self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8)
|
||||
self._tag_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6)
|
||||
self._date_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6)
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
header = ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=28),
|
||||
ft.Text("Новости", size=20, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
|
||||
], spacing=8),
|
||||
ft.Row([
|
||||
ft.TextButton(
|
||||
"Войти",
|
||||
icon=ft.icons.ADMIN_PANEL_SETTINGS_OUTLINED,
|
||||
style=ft.ButtonStyle(color=ft.colors.WHITE70),
|
||||
ft.Container(
|
||||
content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, color=ft.colors.WHITE, size=22),
|
||||
bgcolor=ft.colors.with_opacity(0.2, ft.colors.WHITE),
|
||||
border_radius=8,
|
||||
padding=6,
|
||||
),
|
||||
ft.Text(
|
||||
"Новости колледжей",
|
||||
size=18,
|
||||
weight=ft.FontWeight.W_700,
|
||||
color=ft.colors.WHITE,
|
||||
font_family=d.FONT_UI,
|
||||
),
|
||||
], spacing=10),
|
||||
ft.IconButton(
|
||||
icon=ft.icons.MANAGE_ACCOUNTS_OUTLINED,
|
||||
icon_color=ft.colors.WHITE70,
|
||||
tooltip="Войти в панель управления",
|
||||
on_click=lambda e: self.page.go("/admin/login"),
|
||||
)
|
||||
]),
|
||||
icon_size=22,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
bgcolor=d.ADMIN_SIDEBAR,
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=14),
|
||||
bgcolor=d.PRIMARY,
|
||||
padding=ft.padding.symmetric(horizontal=20, vertical=12),
|
||||
shadow=ft.BoxShadow(
|
||||
blur_radius=8,
|
||||
offset=ft.Offset(0, 2),
|
||||
color=ft.colors.with_opacity(0.18, ft.colors.BLACK),
|
||||
),
|
||||
)
|
||||
|
||||
cat_section = ft.Container(
|
||||
filters = ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Container(content=self._cat_row, padding=ft.padding.symmetric(horizontal=24, vertical=8)),
|
||||
]),
|
||||
ft.Container(
|
||||
content=self._cat_row,
|
||||
padding=ft.padding.only(left=16, right=16, top=10, bottom=4),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._tag_row,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=4),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._date_row,
|
||||
padding=ft.padding.only(left=16, right=16, top=4, bottom=10),
|
||||
),
|
||||
], spacing=0),
|
||||
bgcolor=d.SURFACE,
|
||||
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||
)
|
||||
|
||||
main_content = ft.Container(
|
||||
feed = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
self._list_col,
|
||||
self._loader,
|
||||
ft.Container(
|
||||
content=self._load_more_btn,
|
||||
alignment=ft.alignment.center,
|
||||
padding=16,
|
||||
[self._grid, self._loader, self._load_more_btn],
|
||||
spacing=0,
|
||||
),
|
||||
],
|
||||
spacing=16,
|
||||
),
|
||||
width=680,
|
||||
),
|
||||
],
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=24),
|
||||
alignment=ft.alignment.top_center,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=16),
|
||||
expand=True,
|
||||
)
|
||||
|
||||
@@ -98,10 +145,9 @@ class NewsFeedView:
|
||||
ft.Column(
|
||||
[
|
||||
header,
|
||||
cat_section,
|
||||
filters,
|
||||
ft.Container(
|
||||
content=main_content,
|
||||
alignment=ft.alignment.top_center,
|
||||
content=feed,
|
||||
expand=True,
|
||||
),
|
||||
],
|
||||
@@ -113,27 +159,39 @@ class NewsFeedView:
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
self._categories = await api.get_categories()
|
||||
self._render_categories()
|
||||
self._categories, self._tags = await _gather(
|
||||
api.get_categories(),
|
||||
api.get_tags(30),
|
||||
)
|
||||
# Все фильтры — один update вместо трёх
|
||||
self._render_categories(update=False)
|
||||
self._render_tags(update=False)
|
||||
self._render_dates(update=False)
|
||||
self.page.update()
|
||||
await self._load_articles()
|
||||
|
||||
async def _load_more(self):
|
||||
if self._loading or not self._has_more:
|
||||
return
|
||||
self._loading = True
|
||||
self._load_more_btn.disabled = True
|
||||
self._load_more_btn.visible = False
|
||||
self.page.update()
|
||||
self._offset += self._limit
|
||||
await self._load_articles()
|
||||
self._loading = False
|
||||
self._load_more_btn.disabled = False
|
||||
self.page.update()
|
||||
|
||||
async def _load_articles(self):
|
||||
self._loader.visible = True
|
||||
self.page.update()
|
||||
|
||||
data = await api.get_news(offset=self._offset, limit=self._limit, category=self._category)
|
||||
date_from = _preset_to_date_from(self._date_preset)
|
||||
data = await api.get_news(
|
||||
offset=self._offset,
|
||||
limit=self._limit,
|
||||
category=self._category,
|
||||
tag=self._tag,
|
||||
date_from=date_from,
|
||||
)
|
||||
self._loader.visible = False
|
||||
|
||||
if not data:
|
||||
@@ -145,120 +203,250 @@ class NewsFeedView:
|
||||
self._has_more = data.get("has_more", False)
|
||||
|
||||
for a in items:
|
||||
self._list_col.controls.append(self._build_card(a))
|
||||
self._grid.controls.append(self._build_card(a))
|
||||
|
||||
self._load_more_btn.visible = self._has_more
|
||||
self.page.update()
|
||||
|
||||
def _render_categories(self):
|
||||
def _reset_feed(self):
|
||||
self._articles = []
|
||||
self._offset = 0
|
||||
self._has_more = True
|
||||
self._grid.controls.clear()
|
||||
|
||||
# ── Category filter ────────────────────────────────────────────────────────
|
||||
|
||||
def _render_categories(self, update: bool = True):
|
||||
self._cat_row.controls = [
|
||||
self._cat_chip(None, "Все")
|
||||
] + [
|
||||
self._cat_chip(c["slug"], c["name"]) for c in self._categories
|
||||
]
|
||||
if update:
|
||||
self.page.update()
|
||||
|
||||
def _cat_chip(self, slug: str | None, label: str) -> ft.Chip:
|
||||
is_active = self._category == slug
|
||||
return ft.Chip(
|
||||
label=ft.Text(label, size=13),
|
||||
label=ft.Text(label, size=12, font_family=d.FONT_UI),
|
||||
bgcolor=d.PRIMARY if is_active else d.SURFACE,
|
||||
label_style=ft.TextStyle(color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY),
|
||||
label_style=ft.TextStyle(
|
||||
color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY,
|
||||
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
|
||||
),
|
||||
on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s),
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=2),
|
||||
border_side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||
elevation=0,
|
||||
)
|
||||
|
||||
async def _filter_category(self, slug: str | None):
|
||||
self._category = slug
|
||||
self._articles = []
|
||||
self._offset = 0
|
||||
self._has_more = True
|
||||
self._list_col.controls.clear()
|
||||
self._reset_feed()
|
||||
self._render_categories()
|
||||
await self._load_articles()
|
||||
|
||||
def _build_card(self, article: dict) -> ft.Container:
|
||||
pub_date = ""
|
||||
if article.get("published_at"):
|
||||
pub_date = article["published_at"][:10]
|
||||
# ── Tag filter ─────────────────────────────────────────────────────────────
|
||||
|
||||
slug = article["slug"]
|
||||
def _render_tags(self, update: bool = True):
|
||||
if not self._tags:
|
||||
self._tag_row.controls = []
|
||||
else:
|
||||
self._tag_row.controls = [
|
||||
ft.Container(
|
||||
content=ft.Text("Теги:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
padding=ft.padding.only(right=4),
|
||||
)
|
||||
] + [self._tag_chip(t["slug"], t["name"]) for t in self._tags]
|
||||
if update:
|
||||
self.page.update()
|
||||
|
||||
cat = article.get("category")
|
||||
meta_row_controls = []
|
||||
if cat:
|
||||
meta_row_controls.append(ft.Container(
|
||||
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_600),
|
||||
bgcolor=d.PRIMARY,
|
||||
def _tag_chip(self, slug: str, label: str) -> ft.Container:
|
||||
is_active = self._tag == slug
|
||||
return ft.Container(
|
||||
content=ft.Text(
|
||||
f"#{label}",
|
||||
size=11,
|
||||
color=ft.colors.WHITE if is_active else d.PRIMARY,
|
||||
font_family=d.FONT_UI,
|
||||
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
|
||||
),
|
||||
bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.08, d.PRIMARY),
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
))
|
||||
if pub_date:
|
||||
meta_row_controls.append(ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY))
|
||||
on_click=lambda e, s=slug: self.page.run_task(self._filter_tag, s),
|
||||
ink=True,
|
||||
)
|
||||
|
||||
cover_section = ft.Container(
|
||||
async def _filter_tag(self, slug: str):
|
||||
self._tag = None if self._tag == slug else slug
|
||||
self._reset_feed()
|
||||
self._render_tags()
|
||||
await self._load_articles()
|
||||
|
||||
# ── Date filter ────────────────────────────────────────────────────────────
|
||||
|
||||
def _render_dates(self, update: bool = True):
|
||||
self._date_row.controls = [
|
||||
ft.Container(
|
||||
content=ft.Text("Период:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
padding=ft.padding.only(right=4),
|
||||
)
|
||||
] + [self._date_btn(preset, label) for preset, label in _DATE_PRESETS]
|
||||
if update:
|
||||
self.page.update()
|
||||
|
||||
def _date_btn(self, preset: str | None, label: str) -> ft.Container:
|
||||
is_active = self._date_preset == preset
|
||||
return ft.Container(
|
||||
content=ft.Text(
|
||||
label,
|
||||
size=11,
|
||||
color=ft.colors.WHITE if is_active else d.TEXT_SECONDARY,
|
||||
font_family=d.FONT_UI,
|
||||
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
|
||||
),
|
||||
bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.06, ft.colors.BLACK),
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
border=ft.border.all(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||
on_click=lambda e, p=preset: self.page.run_task(self._filter_date, p),
|
||||
ink=True,
|
||||
)
|
||||
|
||||
async def _filter_date(self, preset: str | None):
|
||||
self._date_preset = preset
|
||||
self._reset_feed()
|
||||
self._render_dates()
|
||||
await self._load_articles()
|
||||
|
||||
# ── Card ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_card(self, article: dict) -> ft.Container:
|
||||
slug = article["slug"]
|
||||
source_url = article.get("source_url", "") or ""
|
||||
|
||||
src_domain = ""
|
||||
if source_url:
|
||||
try:
|
||||
src_domain = urlparse(source_url).netloc.replace("www.", "")
|
||||
except Exception:
|
||||
src_domain = "vk.com"
|
||||
|
||||
pub_date = ""
|
||||
if article.get("published_at"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(article["published_at"].replace("Z", "+00:00"))
|
||||
pub_date = dt.strftime("%d.%m.%Y")
|
||||
except Exception:
|
||||
pub_date = article["published_at"][:10]
|
||||
|
||||
cat = article.get("category")
|
||||
|
||||
# Cover
|
||||
if article.get("cover_url"):
|
||||
cover = ft.Container(
|
||||
content=ft.Image(
|
||||
src=article["cover_url"],
|
||||
width=float("inf"),
|
||||
height=200,
|
||||
fit=ft.ImageFit.COVER,
|
||||
width=float("inf"),
|
||||
height=170,
|
||||
error_content=ft.Container(
|
||||
bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY),
|
||||
content=ft.Icon(ft.icons.IMAGE_NOT_SUPPORTED_OUTLINED, size=28, color=d.DIVIDER),
|
||||
alignment=ft.alignment.center,
|
||||
height=170,
|
||||
),
|
||||
height=200,
|
||||
),
|
||||
height=170,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
border_radius=ft.BorderRadius(12, 12, 0, 0),
|
||||
) if article.get("cover_url") else ft.Container(visible=False)
|
||||
)
|
||||
else:
|
||||
cover = ft.Container(
|
||||
height=120,
|
||||
bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY),
|
||||
content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, size=32, color=ft.colors.with_opacity(0.25, d.PRIMARY)),
|
||||
alignment=ft.alignment.center,
|
||||
)
|
||||
|
||||
body = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(meta_row_controls, spacing=8) if meta_row_controls else ft.Container(visible=False),
|
||||
# Category + date row
|
||||
meta_row = ft.Row([
|
||||
ft.Text(
|
||||
cat["name"] if cat else "",
|
||||
size=10,
|
||||
color=d.PRIMARY,
|
||||
weight=ft.FontWeight.W_600,
|
||||
font_family=d.FONT_UI,
|
||||
max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
expand=True,
|
||||
),
|
||||
ft.Text(pub_date, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
], spacing=4, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
title = ft.Text(
|
||||
article["title"],
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Text(
|
||||
article.get("excerpt", ""),
|
||||
size=13,
|
||||
color=d.TEXT_SECONDARY,
|
||||
max_lines=3,
|
||||
weight=ft.FontWeight.W_700,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
) if article.get("excerpt") else ft.Container(visible=False),
|
||||
ft.Row(
|
||||
font_family=d.FONT_UI,
|
||||
)
|
||||
|
||||
tags = article.get("tags", [])
|
||||
tag_row = ft.Row(
|
||||
[
|
||||
ft.Container(
|
||||
content=ft.Text(f"#{t['name']}", size=10, color=d.PRIMARY, font_family=d.FONT_UI),
|
||||
bgcolor=ft.colors.with_opacity(0.08, d.PRIMARY),
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=6, vertical=2),
|
||||
)
|
||||
for t in tags[:3]
|
||||
],
|
||||
spacing=4,
|
||||
wrap=True,
|
||||
) if tags else ft.Container(height=2)
|
||||
|
||||
# Footer: source domain + "Читать →"
|
||||
footer = ft.Row([
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||
], spacing=4),
|
||||
ft.TextButton(
|
||||
"Читать →",
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, s=slug: self.page.launch_url(
|
||||
f"/article/{s}", web_window_name="_self"
|
||||
),
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=20, vertical=16),
|
||||
ft.Icon(ft.icons.LINK, size=11, color=d.TEXT_SECONDARY),
|
||||
ft.Text(src_domain, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
], spacing=2) if src_domain else ft.Container(),
|
||||
ft.Container(expand=True),
|
||||
ft.Text("Читать →", size=11, color=d.PRIMARY, weight=ft.FontWeight.W_600, font_family=d.FONT_UI),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
content_col = ft.Column(
|
||||
[meta_row, title, tag_row, ft.Container(expand=True), footer],
|
||||
spacing=6,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([cover_section, body], spacing=0),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=12,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.with_opacity(0.07, ft.colors.BLACK)),
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
on_click=lambda e, s=slug: self.page.launch_url(
|
||||
f"/article/{s}", web_window_name="_self"
|
||||
content=ft.Column([
|
||||
cover,
|
||||
ft.Container(
|
||||
content=content_col,
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=8),
|
||||
expand=True,
|
||||
),
|
||||
], spacing=0, expand=True),
|
||||
bgcolor=d.CARD_BG,
|
||||
border_radius=12,
|
||||
shadow=ft.BoxShadow(
|
||||
blur_radius=6,
|
||||
offset=ft.Offset(0, 1),
|
||||
color=ft.colors.with_opacity(0.08, ft.colors.BLACK),
|
||||
),
|
||||
on_click=lambda e, s=slug: self.page.go(f"/news/{s}"),
|
||||
ink=True,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
)
|
||||
|
||||
|
||||
async def _gather(*coros):
|
||||
import asyncio
|
||||
return await asyncio.gather(*coros)
|
||||
|
||||
359
web/views/vk_sources.py
Normal file
359
web/views/vk_sources.py
Normal file
@@ -0,0 +1,359 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
from views.dashboard import _admin_appbar, _sidebar
|
||||
|
||||
|
||||
class VkSourcesView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._sources: list = []
|
||||
self._api_ok = False
|
||||
self._editing_id: str | None = None # ID источника в режиме редактирования
|
||||
|
||||
# Статус бейдж
|
||||
self._status_row = ft.Row(spacing=6)
|
||||
|
||||
# Уведомление (добавляется в page.overlay при load)
|
||||
self._snack = ft.SnackBar(content=ft.Text(""), open=False)
|
||||
|
||||
# Таблица источников
|
||||
self._table = ft.DataTable(
|
||||
columns=[
|
||||
ft.DataColumn(ft.Text("Группа", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("ID", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("Последний импорт", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("Статус", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("Действия", weight=ft.FontWeight.W_600, size=13)),
|
||||
],
|
||||
rows=[],
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
border_radius=8,
|
||||
vertical_lines=ft.BorderSide(1, d.DIVIDER),
|
||||
heading_row_color=ft.colors.with_opacity(0.04, ft.colors.BLACK),
|
||||
data_row_max_height=56,
|
||||
column_spacing=16,
|
||||
)
|
||||
|
||||
# Форма добавления / редактирования
|
||||
self._form_gid = ft.TextField(
|
||||
label="ID группы (числовой)",
|
||||
hint_text="95503797",
|
||||
width=220,
|
||||
dense=True,
|
||||
)
|
||||
self._form_gname = ft.TextField(
|
||||
label="Название группы",
|
||||
hint_text="ЮУрГК",
|
||||
width=240,
|
||||
dense=True,
|
||||
)
|
||||
self._form_enabled = ft.Checkbox(label="Активна", value=True)
|
||||
self._form_save_btn = ft.ElevatedButton(
|
||||
"Добавить",
|
||||
icon=ft.icons.ADD,
|
||||
bgcolor=d.PRIMARY,
|
||||
color=ft.colors.WHITE,
|
||||
on_click=lambda e: self.page.run_task(self._save_source),
|
||||
)
|
||||
self._form_cancel_btn = ft.TextButton(
|
||||
"Отмена",
|
||||
visible=False,
|
||||
on_click=lambda e: self._cancel_edit(),
|
||||
)
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
token_info = ft.Container(
|
||||
content=ft.Column([
|
||||
self._status_row,
|
||||
ft.Text(
|
||||
"Без токена VK API — HTML-парсинг не работает (ВК перешёл на JS-рендеринг).\n"
|
||||
"Получи бесплатный сервисный ключ: vk.com/dev → Мои приложения → Создать → "
|
||||
"Standalone → вкладка «Настройки» → «Сервисный ключ доступа».\n"
|
||||
"Затем добавь в .env: VK_ACCESS_TOKEN=<ключ>",
|
||||
size=12,
|
||||
color=d.TEXT_SECONDARY,
|
||||
selectable=True,
|
||||
),
|
||||
], spacing=6),
|
||||
bgcolor=ft.colors.with_opacity(0.04, ft.colors.BLACK),
|
||||
border_radius=8,
|
||||
padding=14,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
)
|
||||
|
||||
form = ft.Container(
|
||||
content=ft.Column([
|
||||
d.headline("Добавить / редактировать группу", size=15),
|
||||
ft.Row(
|
||||
[self._form_gid, self._form_gname, self._form_enabled],
|
||||
spacing=10, wrap=True,
|
||||
),
|
||||
ft.Row(
|
||||
[self._form_save_btn, self._form_cancel_btn],
|
||||
spacing=10,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
], spacing=10),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=10,
|
||||
padding=16,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
)
|
||||
|
||||
import_btns = ft.Row([
|
||||
ft.ElevatedButton(
|
||||
"Импорт новых постов",
|
||||
icon=ft.icons.SYNC,
|
||||
bgcolor=d.PRIMARY,
|
||||
color=ft.colors.WHITE,
|
||||
tooltip="Загрузить новые посты со всех активных групп",
|
||||
on_click=lambda e: self.page.run_task(self._import_recent),
|
||||
),
|
||||
ft.ElevatedButton(
|
||||
"Импорт за год (все)",
|
||||
icon=ft.icons.HISTORY,
|
||||
bgcolor=d.WARNING,
|
||||
color=ft.colors.WHITE,
|
||||
tooltip="Пагинированный импорт за 365 дней. Запускается в фоне.",
|
||||
on_click=lambda e: self.page.run_task(self._import_history_all),
|
||||
),
|
||||
], spacing=10, wrap=True)
|
||||
|
||||
content = ft.Column([
|
||||
d.headline("ВКонтакте — источники", size=22),
|
||||
token_info,
|
||||
import_btns,
|
||||
ft.Divider(height=4, color="transparent"),
|
||||
form,
|
||||
ft.Divider(height=4, color="transparent"),
|
||||
d.headline("Группы в базе", size=15),
|
||||
ft.Container(
|
||||
content=self._table,
|
||||
border_radius=8,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
),
|
||||
], scroll=ft.ScrollMode.AUTO, spacing=12, expand=True)
|
||||
|
||||
return ft.View(
|
||||
route="/admin/vk",
|
||||
bgcolor=d.BACKGROUND,
|
||||
appbar=_admin_appbar(self.page, "ВКонтакте"),
|
||||
controls=[
|
||||
ft.Row([
|
||||
_sidebar(self.page, active="/admin/vk"),
|
||||
ft.Container(content=content, expand=True, padding=24),
|
||||
], expand=True, spacing=0),
|
||||
],
|
||||
)
|
||||
|
||||
# ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async def load(self):
|
||||
self.page.overlay.append(self._snack)
|
||||
token = self.page.session.get("token")
|
||||
status = await api.admin_vk_status(token)
|
||||
self._api_ok = status.get("configured", False)
|
||||
self._status_row.controls = [
|
||||
ft.Icon(
|
||||
ft.icons.CHECK_CIRCLE if self._api_ok else ft.icons.WARNING_AMBER_ROUNDED,
|
||||
color=d.SUCCESS if self._api_ok else d.WARNING,
|
||||
size=18,
|
||||
),
|
||||
ft.Text(
|
||||
"API-токен подключён — VK API активен" if self._api_ok
|
||||
else "Токен не задан — парсинг недоступен",
|
||||
size=13,
|
||||
weight=ft.FontWeight.W_600,
|
||||
color=d.SUCCESS if self._api_ok else d.WARNING,
|
||||
),
|
||||
]
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
# ── Table ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _render_table(self):
|
||||
if not self._sources:
|
||||
self._table.rows = [
|
||||
ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Text("Групп пока нет", color=d.TEXT_SECONDARY, italic=True)),
|
||||
ft.DataCell(ft.Text("")),
|
||||
ft.DataCell(ft.Text("")),
|
||||
ft.DataCell(ft.Text("")),
|
||||
ft.DataCell(ft.Text("")),
|
||||
])
|
||||
]
|
||||
return
|
||||
|
||||
self._table.rows = [self._build_row(s) for s in self._sources]
|
||||
|
||||
def _build_row(self, src: dict) -> ft.DataRow:
|
||||
last_run = src.get("last_run")
|
||||
if last_run:
|
||||
try:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(last_run.replace("Z", "+00:00"))
|
||||
last_run_str = dt.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
last_run_str = last_run[:16]
|
||||
else:
|
||||
last_run_str = "никогда"
|
||||
|
||||
enabled = src.get("enabled", True)
|
||||
|
||||
return ft.DataRow(
|
||||
cells=[
|
||||
ft.DataCell(ft.Text(src["group_name"], size=13, weight=ft.FontWeight.W_500)),
|
||||
ft.DataCell(ft.Text(src["group_id"], size=13, color=d.TEXT_SECONDARY, selectable=True)),
|
||||
ft.DataCell(ft.Text(last_run_str, size=12, color=d.TEXT_SECONDARY)),
|
||||
ft.DataCell(
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"Активна" if enabled else "Отключена",
|
||||
size=11,
|
||||
color=ft.colors.WHITE,
|
||||
),
|
||||
bgcolor=d.SUCCESS if enabled else d.TEXT_SECONDARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
)
|
||||
),
|
||||
ft.DataCell(
|
||||
ft.Row([
|
||||
ft.IconButton(
|
||||
ft.icons.EDIT_OUTLINED,
|
||||
icon_size=18,
|
||||
icon_color=d.PRIMARY,
|
||||
tooltip="Редактировать",
|
||||
on_click=lambda e, s=src: self._start_edit(s),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.TOGGLE_ON if enabled else ft.icons.TOGGLE_OFF,
|
||||
icon_size=22,
|
||||
icon_color=d.SUCCESS if enabled else d.TEXT_SECONDARY,
|
||||
tooltip="Вкл / Выкл",
|
||||
on_click=lambda e, s=src: self.page.run_task(self._toggle, s),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.HISTORY,
|
||||
icon_size=18,
|
||||
icon_color=d.WARNING,
|
||||
tooltip="Импорт за год для этой группы",
|
||||
on_click=lambda e, s=src: self.page.run_task(
|
||||
self._import_history_one, s["id"], s["group_name"]
|
||||
),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.DELETE_OUTLINE,
|
||||
icon_size=18,
|
||||
icon_color=d.ERROR,
|
||||
tooltip="Удалить группу",
|
||||
on_click=lambda e, s=src: self.page.run_task(self._delete, s["id"]),
|
||||
),
|
||||
], spacing=0),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# ── Edit form helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _start_edit(self, src: dict):
|
||||
self._editing_id = src["id"]
|
||||
self._form_gid.value = src["group_id"]
|
||||
self._form_gname.value = src["group_name"]
|
||||
self._form_enabled.value = src.get("enabled", True)
|
||||
self._form_save_btn.text = "Сохранить"
|
||||
self._form_save_btn.icon = ft.icons.SAVE
|
||||
self._form_cancel_btn.visible = True
|
||||
self.page.update()
|
||||
|
||||
def _cancel_edit(self):
|
||||
self._editing_id = None
|
||||
self._form_gid.value = ""
|
||||
self._form_gname.value = ""
|
||||
self._form_enabled.value = True
|
||||
self._form_save_btn.text = "Добавить"
|
||||
self._form_save_btn.icon = ft.icons.ADD
|
||||
self._form_cancel_btn.visible = False
|
||||
self.page.update()
|
||||
|
||||
# ── CRUD actions ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _save_source(self):
|
||||
gid = self._form_gid.value.strip()
|
||||
gname = self._form_gname.value.strip()
|
||||
if not gid or not gname:
|
||||
self._notify("Заполни ID и название", d.ERROR)
|
||||
return
|
||||
|
||||
token = self.page.session.get("token")
|
||||
|
||||
if self._editing_id:
|
||||
result = await api.admin_vk_update_source(
|
||||
token, self._editing_id, gid, gname, self._form_enabled.value
|
||||
)
|
||||
self._notify("Группа обновлена" if result else "Ошибка обновления",
|
||||
d.SUCCESS if result else d.ERROR)
|
||||
else:
|
||||
result = await api.admin_vk_add_source(token, gid, gname, self._form_enabled.value)
|
||||
if result and "error" not in result:
|
||||
self._notify(f"Группа «{gname}» добавлена", d.SUCCESS)
|
||||
else:
|
||||
err = result.get("error") if result else "Ошибка запроса"
|
||||
self._notify(str(err), d.ERROR)
|
||||
return
|
||||
|
||||
self._cancel_edit()
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
async def _toggle(self, src: dict):
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_update_source(
|
||||
token, src["id"], src["group_id"], src["group_name"], not src["enabled"]
|
||||
)
|
||||
if result:
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
async def _delete(self, source_id: str):
|
||||
token = self.page.session.get("token")
|
||||
ok = await api.admin_vk_delete_source(token, source_id)
|
||||
self._notify("Группа удалена" if ok else "Ошибка удаления",
|
||||
d.TEXT_SECONDARY if ok else d.ERROR)
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
async def _import_recent(self):
|
||||
self._notify("Запускаю импорт новых постов…", d.PRIMARY)
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_import(token)
|
||||
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||
|
||||
async def _import_history_all(self):
|
||||
self._notify("Запускаю исторический импорт в фоне…", d.WARNING)
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_import_history(token)
|
||||
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||
|
||||
async def _import_history_one(self, source_id: str, name: str):
|
||||
self._notify(f"Запускаю год для «{name}»…", d.WARNING)
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_import_history(token, source_id=source_id)
|
||||
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||
|
||||
def _notify(self, text: str, color: str = d.SUCCESS):
|
||||
self._snack.content = ft.Text(text, color=ft.colors.WHITE)
|
||||
self._snack.bgcolor = color
|
||||
self._snack.open = True
|
||||
self.page.update()
|
||||
Reference in New Issue
Block a user