new web ract

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

View File

@@ -10,5 +10,21 @@ MINIO_PUBLIC_URL=http://localhost:9000
DOCS_USERNAME=admin DOCS_USERNAME=admin
DOCS_PASSWORD=admin DOCS_PASSWORD=admin
# CORS: разрешённые домены через запятую
ALLOWED_ORIGINS=http://localhost,http://localhost:80
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme 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
View File

@@ -17,10 +17,14 @@ __pycache__/
.ruff_cache/ .ruff_cache/
uv.lock uv.lock
# ── Node / React editor UI ───────────────────────────────────────────────── # ── Node / React ───────────────────────────────────────────────────────────
node_modules/
web/editor-ui/node_modules/ web/editor-ui/node_modules/
web/editor-ui/dist/ web/editor-ui/dist/
web/editor-ui/.vite/ web/editor-ui/.vite/
web-react/node_modules/
web-react/dist/
web-react/.vite/
# ── IDE / OS ─────────────────────────────────────────────────────────────── # ── IDE / OS ───────────────────────────────────────────────────────────────
.DS_Store .DS_Store

View File

@@ -1,6 +1,6 @@
import uuid import uuid
from datetime import datetime 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.orm import mapped_column, Mapped, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
import enum import enum
@@ -44,6 +44,7 @@ class Article(Base):
excerpt: Mapped[str] = mapped_column(String(1000), default="") excerpt: Mapped[str] = mapped_column(String(1000), default="")
cover_url: Mapped[str | None] = mapped_column(String(1000), nullable=True) cover_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
font_family: Mapped[str] = mapped_column(String(100), default="Merriweather") 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) status: Mapped[ArticleStatus] = mapped_column(SAEnum(ArticleStatus), default=ArticleStatus.draft)
view_count: Mapped[int] = mapped_column(Integer, default=0) view_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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 media_type: Mapped[str] = mapped_column(String(20)) # image | video
size_bytes: Mapped[int] = mapped_column(Integer, default=0) size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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)

View File

@@ -4,6 +4,11 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.middleware.cors import CORSMiddleware 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 bd.database import wait_for_db, engine, Base
from route.public import router as public_router 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_articles import router as articles_router
from route.admin_categories import router as categories_router from route.admin_categories import router as categories_router
from route.admin_media import router as media_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_USER = os.getenv("DOCS_USERNAME", "admin")
DOCS_PASS = os.getenv("DOCS_PASSWORD", "admin") DOCS_PASS = os.getenv("DOCS_PASSWORD", "admin")
_basic = HTTPBasic() _basic = HTTPBasic()
# Rate limiter — ключ по IP-адресу
def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)): def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
ok_u = secrets.compare_digest(creds.username.encode(), DOCS_USER.encode()) ok_u = secrets.compare_digest(creds.username.encode(), DOCS_USER.encode())
ok_p = secrets.compare_digest(creds.password.encode(), DOCS_PASS.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: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
await ensure_default_admin() await ensure_default_admin()
scheduler.start()
yield yield
scheduler.shutdown()
app = FastAPI( app = FastAPI(
@@ -43,17 +52,18 @@ app = FastAPI(
openapi_url=None, 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_ALLOWED_ORIGINS,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["*"], 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) @app.get("/docs", include_in_schema=False)
async def docs(username: str = Depends(_verify_docs)): 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(public_router, prefix="/news", tags=["public"])
app.include_router(auth_router, prefix="/admin", tags=["admin-auth"]) app.include_router(auth_router, prefix="/admin", tags=["admin-auth"])
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"]) 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(media_router, prefix="/admin/media", tags=["admin-media"])
app.include_router(vk_router, prefix="/admin/vk", tags=["admin-vk"])
if __name__ == "__main__": if __name__ == "__main__":

4
api/rate_limiter.py Normal file
View File

@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)

View File

@@ -10,3 +10,11 @@ python-multipart==0.0.12
minio==7.2.11 minio==7.2.11
python-slugify==8.0.4 python-slugify==8.0.4
pillow==11.0.0 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

View File

@@ -21,6 +21,7 @@ class ArticleIn(BaseModel):
content: str = "" content: str = ""
excerpt: str = "" excerpt: str = ""
cover_url: Optional[str] = None cover_url: Optional[str] = None
source_url: Optional[str] = None
font_family: str = "Merriweather" font_family: str = "Merriweather"
category_id: Optional[str] = None category_id: Optional[str] = None
tag_names: list[str] = [] tag_names: list[str] = []
@@ -35,6 +36,7 @@ def _article_dict(a: Article) -> dict:
"content": a.content, "content": a.content,
"excerpt": a.excerpt, "excerpt": a.excerpt,
"cover_url": a.cover_url, "cover_url": a.cover_url,
"source_url": a.source_url,
"font_family": a.font_family, "font_family": a.font_family,
"status": a.status.value, "status": a.status.value,
"view_count": a.view_count, "view_count": a.view_count,
@@ -73,14 +75,19 @@ async def list_articles(
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
status: Optional[ArticleStatus] = Query(None), status: Optional[ArticleStatus] = Query(None),
q: Optional[str] = Query(None, max_length=200),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin), _: 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: if status:
q = q.where(Article.status == status) stmt = stmt.where(Article.status == status)
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one() if q:
articles = (await db.execute(q.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all() 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 { return {
"total": total, "total": total,
"offset": offset, "offset": offset,
@@ -115,6 +122,7 @@ async def create_article(
content=data.content, content=data.content,
excerpt=data.excerpt, excerpt=data.excerpt,
cover_url=data.cover_url, cover_url=data.cover_url,
source_url=data.source_url,
font_family=data.font_family, font_family=data.font_family,
status=data.status, status=data.status,
category=cat, category=cat,
@@ -174,6 +182,7 @@ async def update_article(
article.content = data.content article.content = data.content
article.excerpt = data.excerpt article.excerpt = data.excerpt
article.cover_url = data.cover_url article.cover_url = data.cover_url
article.source_url = data.source_url
article.font_family = data.font_family article.font_family = data.font_family
article.status = data.status article.status = data.status
article.category = cat article.category = cat

View File

@@ -1,6 +1,6 @@
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -9,17 +9,24 @@ from passlib.context import CryptContext
from bd.database import get_db, AsyncSessionLocal from bd.database import get_db, AsyncSessionLocal
from bd.models import Admin from bd.models import Admin
from rate_limiter import limiter
router = APIRouter() router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 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_ALGO = "HS256"
JWT_EXP_HOURS = 24 JWT_EXP_HOURS = 8
def _make_token(username: str) -> str: 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) return jwt.encode({"sub": username, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGO)
@@ -40,7 +47,8 @@ class LoginRequest(BaseModel):
@router.post("/login") @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() 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): if not admin or not pwd_context.verify(data.password, admin.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль") 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") @router.post("/change-password")
async def change_password( async def change_password(data: dict, db: AsyncSession = Depends(get_db)):
data: dict,
db: AsyncSession = Depends(get_db),
):
from route.deps import require_admin
# handled with deps in main
admin = (await db.execute(select(Admin).where(Admin.username == data["username"]))).scalar_one_or_none() 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): if not admin or not pwd_context.verify(data["current_password"], admin.password_hash):
raise HTTPException(status_code=400, detail="Неверный текущий пароль") raise HTTPException(status_code=400, detail="Неверный текущий пароль")

View File

@@ -99,20 +99,25 @@ async def upload_media(
@router.get("") @router.get("")
async def list_media( async def list_media(
offset: int = Query(0, ge=0), 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), media_type: str | None = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin), _: str = Depends(require_admin),
): ):
q = select(Media) from sqlalchemy import func
base = select(Media)
if media_type: if media_type:
q = q.where(Media.media_type == media_type) base = base.where(Media.media_type == media_type)
items = (await db.execute(q.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all() total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
return [ 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, {"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()} "size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
for m in items for m in items
] ],
}
@router.delete("/{media_id}") @router.delete("/{media_id}")

113
api/route/admin_vk.py Normal file
View 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}» запущен в фоне"}

View File

@@ -1,12 +1,13 @@
import json import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
import redis.asyncio as aioredis import redis.asyncio as aioredis
from bd.database import get_db, get_redis 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() router = APIRouter()
@@ -22,6 +23,7 @@ def _article_card(a: Article) -> dict:
"slug": a.slug, "slug": a.slug,
"excerpt": a.excerpt, "excerpt": a.excerpt,
"cover_url": a.cover_url, "cover_url": a.cover_url,
"source_url": a.source_url,
"published_at": a.published_at.isoformat() if a.published_at else None, "published_at": a.published_at.isoformat() if a.published_at else None,
"view_count": a.view_count, "view_count": a.view_count,
"category": _cat_dict(a.category) if a.category else None, "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), limit: int = Query(20, ge=1, le=100),
category: str | None = Query(None), category: str | None = Query(None),
tag: 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), db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis), 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) cached = await redis.get(cache_key)
if cached: if cached:
return json.loads(cached) return json.loads(cached)
@@ -68,6 +73,29 @@ async def list_news(
if t: if t:
base = base.where(Article.tags.any(Tag.id == t.id)) 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()) count_q = select(func.count()).select_from(base.subquery())
total: int = (await db.execute(count_q)).scalar_one() total: int = (await db.execute(count_q)).scalar_one()
@@ -82,7 +110,7 @@ async def list_news(
"has_more": (offset + limit) < total, "has_more": (offset + limit) < total,
"items": [_article_card(a) for a in articles], "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 return result
@@ -96,7 +124,36 @@ async def list_categories(
return json.loads(cached) return json.loads(cached)
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all() cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
result = [_cat_dict(c) for c in cats] 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 return result

26
api/scheduler.py Normal file
View 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
View 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
View 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,
}

View File

@@ -1,5 +1,3 @@
version: "3.9"
services: services:
# ── FastAPI backend ──────────────────────────────────────────────────────── # ── FastAPI backend ────────────────────────────────────────────────────────
@@ -21,11 +19,18 @@ services:
DOCS_PASSWORD: ${DOCS_PASSWORD:-admin} DOCS_PASSWORD: ${DOCS_PASSWORD:-admin}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} 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: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -33,25 +38,23 @@ services:
networks: networks:
- news_net - news_net
# ── Flet SPA + React editor (multi-stage build) ──────────────────────────── # ── React SPA + TipTap editor + nginx ─────────────────────────────────────
web: web:
build: build:
context: ./web context: .
dockerfile: Dockerfile dockerfile: web-react/Dockerfile
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:8080" - "80:80"
environment:
API_URL: http://api:8000
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 30s start_period: 20s
networks: networks:
- news_net - news_net

View File

@@ -11,6 +11,7 @@ services:
environment: environment:
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz} POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
POSTGRES_USER: postgres POSTGRES_USER: postgres
PGDATA: /var/lib/postgresql/data
volumes: volumes:
- ./data/postgres:/var/lib/postgresql/data - ./data/postgres:/var/lib/postgresql/data
ports: ports:
@@ -21,7 +22,9 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
networks: networks:
- news_net news_net:
aliases:
- db
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -41,7 +44,9 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
networks: networks:
- news_net news_net:
aliases:
- redis
networks: networks:
news_net: news_net:

View File

@@ -38,7 +38,7 @@ find_container() {
echo "$found" echo "$found"
return return
fi 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() { psql_cmd() {
@@ -153,9 +153,9 @@ main() {
create_data_dirs create_data_dirs
# Запуск docker-compose_db.yml если не запущен # Запуск 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..." 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 sleep 3
fi fi
@@ -173,7 +173,7 @@ main() {
echo "══════════════════════════════════════════════" echo "══════════════════════════════════════════════"
info "Готово! Теперь запускай:" info "Готово! Теперь запускай:"
echo "" echo ""
echo " docker-compose up -d --build" echo " docker compose up -d --build"
echo "" echo ""
echo " Сайт: http://localhost" echo " Сайт: http://localhost"
echo " Админка: http://localhost/admin" echo " Админка: http://localhost/admin"

22
web-react/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# ── Шаг 1: сборка основного React-приложения ──────────────────────────────────
FROM node:20-alpine AS app-build
WORKDIR /build
COPY web-react/package*.json ./
RUN npm ci --prefer-offline
COPY web-react/ ./
RUN npm run build
# ── Шаг 2: сборка редактора статей (TipTap) ───────────────────────────────────
FROM node:20-alpine AS editor-build
WORKDIR /build
COPY web/editor-ui/package*.json ./
RUN npm ci --prefer-offline
COPY web/editor-ui/ ./
RUN npm run build
# ── Шаг 3: nginx раздаёт оба приложения ───────────────────────────────────────
FROM nginx:alpine
COPY --from=app-build /build/dist /usr/share/nginx/html/app
COPY --from=editor-build /build/dist /usr/share/nginx/html/editor
COPY web-react/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

15
web-react/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Новости колледжей</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

32
web-react/nginx.conf Normal file
View File

@@ -0,0 +1,32 @@
server {
listen 80;
# Главное React-приложение
location / {
root /usr/share/nginx/html/app;
try_files $uri /index.html;
}
# Редактор статей (отдельный React-билд)
location /editor/ {
root /usr/share/nginx/html;
try_files $uri /editor/index.html;
}
# Статические ассеты редактора (Vite base: '/editor-assets/')
location /editor-assets/ {
alias /usr/share/nginx/html/editor/;
}
# Проксируем API-запросы на FastAPI
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Отключаем буферизацию для быстрого ответа
proxy_buffering off;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}

2667
web-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web-react/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "news-react",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8"
}
}

View File

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

42
web-react/src/App.jsx Normal file
View File

@@ -0,0 +1,42 @@
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './hooks/useAuth'
import { useTheme } from './hooks/useTheme'
import NewsFeed from './pages/NewsFeed'
import NewsDetail from './pages/NewsDetail'
import Login from './pages/admin/Login'
import Dashboard from './pages/admin/Dashboard'
import ArticlesList from './pages/admin/ArticlesList'
import ArticleEditor from './pages/admin/ArticleEditor'
import Categories from './pages/admin/Categories'
import MediaLibrary from './pages/admin/MediaLibrary'
import VkSources from './pages/admin/VkSources'
function PrivateRoute({ children }) {
const { isAuth } = useAuth()
return isAuth ? children : <Navigate to="/admin/login" replace />
}
export default function App() {
useTheme() // инициализация темы при старте
return (
<Routes>
{/* Публичные */}
<Route path="/" element={<NewsFeed />} />
<Route path="/news/:slug" element={<NewsDetail />} />
{/* Админ */}
<Route path="/admin/login" element={<Login />} />
<Route path="/admin" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
<Route path="/admin/articles" element={<PrivateRoute><ArticlesList /></PrivateRoute>} />
<Route path="/admin/articles/:id" element={<PrivateRoute><ArticleEditor /></PrivateRoute>} />
<Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
<Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} />
<Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

112
web-react/src/api/index.js Normal file
View File

@@ -0,0 +1,112 @@
// Все запросы к FastAPI идут через /api (nginx проксирует на api:8000)
const BASE = '/api'
// Базовая функция запроса
async function req(method, path, token, body, isForm = false) {
const headers = {}
if (token) headers['Authorization'] = `Bearer ${token}`
if (!isForm) headers['Content-Type'] = 'application/json'
const res = await fetch(`${BASE}/${path}`, {
method,
headers,
body: isForm ? body : (body !== undefined ? JSON.stringify(body) : undefined),
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || res.statusText)
}
// 204 No Content — не парсим JSON
if (res.status === 204) return true
return res.json()
}
// ── Публичные эндпоинты ────────────────────────────────────────────────────
export const getNews = (params = {}) => {
const q = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => { if (v != null) q.set(k, v) })
return req('GET', `news?${q}`)
}
export const getArticle = slug => req('GET', `news/${slug}`)
export const getCategories = () => req('GET', 'news/categories')
export const getTags = (limit = 30) => req('GET', `news/tags?limit=${limit}`)
// ── Авторизация ────────────────────────────────────────────────────────────
export const login = (username, password) =>
req('POST', 'admin/login', null, { username, password })
// ── Статьи (админ) ─────────────────────────────────────────────────────────
export const adminCreateArticle = (token, title = 'Новая статья') =>
req('POST', 'admin/articles', token, { title, content: '', status: 'draft' })
export const adminListArticles = (token, params = {}) => {
const q = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => { if (v != null) q.set(k, v) })
return req('GET', `admin/articles?${q}`, token)
}
export const adminDeleteArticle = (token, id) =>
req('DELETE', `admin/articles/${id}`, token)
export const adminPublishArticle = (token, id) =>
req('POST', `admin/articles/${id}/publish`, token)
// ── Категории (админ) ──────────────────────────────────────────────────────
export const adminListCategories = token => req('GET', 'admin/categories', token)
export const adminCreateCategory = (token, name) =>
req('POST', 'admin/categories', token, { name })
export const adminUpdateCategory = (token, id, name) =>
req('PUT', `admin/categories/${id}`, token, { name })
export const adminDeleteCategory = (token, id) =>
req('DELETE', `admin/categories/${id}`, token)
// ── Медиа (админ) ──────────────────────────────────────────────────────────
export const adminListMedia = (token, { type, offset = 0, limit = 40 } = {}) => {
const p = new URLSearchParams()
if (type) p.set('media_type', type)
p.set('offset', offset)
p.set('limit', limit)
return req('GET', `admin/media?${p}`, token)
}
export const adminUploadMedia = (token, file) => {
const fd = new FormData()
fd.append('file', file)
return req('POST', 'admin/media/upload', token, fd, true)
}
export const adminDeleteMedia = (token, id) =>
req('DELETE', `admin/media/${id}`, token)
// ── VK источники (админ) ───────────────────────────────────────────────────
export const adminVkStatus = token => req('GET', 'admin/vk/status', token)
export const adminVkListSources = token => req('GET', 'admin/vk/sources', token)
export const adminVkAddSource = (token, group_id, group_name, enabled = true) =>
req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled })
export const adminVkUpdateSource = (token, id, group_id, group_name, enabled) =>
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled })
export const adminVkDeleteSource = (token, id) =>
req('DELETE', `admin/vk/sources/${id}`, token)
export const adminVkImport = token =>
req('POST', 'admin/vk/import', token)
export const adminVkImportHistory = (token, sourceId) =>
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}`, token)

532
web-react/src/app.css Normal file
View File

@@ -0,0 +1,532 @@
/* =====================================================================
КОМПОНЕНТЫ — стили зависят от переменных theme.css
===================================================================== */
/* ── Кнопки ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--r-btn);
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity .15s, background .15s;
white-space: nowrap;
}
.btn:hover { opacity: .88; }
.btn:active { opacity: .75; }
.btn-primary { background: var(--c-primary); color: #fff; }
.btn-danger { background: var(--c-error); color: #fff; }
.btn-ghost { background: transparent; color: var(--c-text-1); border: 1px solid var(--c-border); }
.btn-sm { padding: 5px 11px; font-size: 0.8rem; }
.btn-icon { padding: 8px; }
/* ── Чипы / фильтры ── */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-2);
transition: background .15s, color .15s, border-color .15s;
white-space: nowrap;
}
.chip--ghost { background: transparent; }
.chip--active { background: var(--c-primary); color: #fff; border-color: var(--c-primary); }
.chip--primary { background: rgba(var(--c-primary-rgb),.12); color: var(--c-primary); border-color: transparent; }
.chip__count { font-size: 0.72rem; opacity: .7; }
/* ── Спиннер ── */
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--c-border);
border-top-color: var(--c-primary);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Модальное окно ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-box {
background: var(--c-surface);
border-radius: var(--r-card);
padding: 1.5rem;
width: 100%;
max-width: 420px;
box-shadow: var(--shadow-panel);
}
/* ── Карточка новости ── */
.news-card {
display: flex;
flex-direction: column;
background: var(--c-card);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
overflow: hidden;
text-decoration: none;
color: inherit;
transition: transform .2s, box-shadow .2s;
}
.news-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0,0,0,.14);
}
.news-card__cover { height: 175px; overflow: hidden; background: var(--c-border); flex-shrink: 0; }
.news-card__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.news-card__cover-placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, var(--c-border), var(--c-bg)); }
.news-card__body { padding: 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.news-card__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.news-card__date { font-size: 0.76rem; color: var(--c-text-2); margin-left: auto; }
.news-card__title { font-family: 'Merriweather', serif; font-size: 0.95rem; font-weight: 700; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.news-card__tags { display: flex; flex-wrap: wrap; gap: 4px; }
.news-card__source { margin-top: auto; display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--c-text-2); }
/* ── Лента новостей ── */
.news-feed { max-width: 680px; margin: 0 auto; padding: 0 1rem 3rem; }
/* ── VK-стиль: список постов ── */
.news-list { display: flex; flex-direction: column; gap: 12px; margin-top: 1rem; }
.vk-card {
background: var(--c-card);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
padding: 16px;
cursor: pointer;
transition: box-shadow .18s;
border: 1px solid var(--c-border);
}
.vk-card:hover { box-shadow: var(--shadow-panel); }
.vk-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.vk-card__source-info {
display: flex;
align-items: center;
gap: 10px;
}
.vk-card__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(var(--c-primary-rgb), .15);
color: var(--c-primary);
font-weight: 700;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vk-card__category { font-weight: 600; font-size: 0.9rem; color: var(--c-text-1); }
.vk-card__date { font-size: 0.78rem; color: var(--c-text-2); margin-top: 1px; }
.vk-card__source-link {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.78rem;
color: var(--c-text-2);
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.vk-card__source-link:hover { color: var(--c-primary); }
.vk-card__title {
font-size: 1rem;
font-weight: 700;
line-height: 1.45;
color: var(--c-text-1);
margin-bottom: 8px;
}
.vk-card__excerpt {
font-size: 0.9rem;
color: var(--c-text-2);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 10px;
}
.vk-card__img-wrap {
margin: 10px -16px;
overflow: hidden;
max-height: 420px;
}
.vk-card__img-wrap--expanded { max-height: none; }
.vk-card__img-wrap img {
width: 100%;
display: block;
object-fit: cover;
cursor: zoom-in;
transition: transform .2s;
}
.vk-card__img-wrap--expanded img { cursor: zoom-out; object-fit: unset; }
.vk-card__tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
.vk-card__footer { display: flex; align-items: center; justify-content: flex-end; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--c-border); }
.vk-card__read { font-size: 0.82rem; color: var(--c-primary); font-weight: 500; }
.public-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
box-shadow: var(--shadow-panel);
}
.public-header__inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
height: 56px;
display: flex;
align-items: center;
gap: 1rem;
}
.public-header__logo { font-weight: 700; font-size: 1.1rem; color: var(--c-primary); flex: 1; }
.public-header__theme { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px; }
/* Строка поиска в шапке */
.search-bar-wrap {
max-width: 680px;
margin: 0 auto;
padding: 0 1rem 10px;
width: 100%;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: 24px;
padding: 8px 14px;
transition: border-color .15s, box-shadow .15s;
}
.search-bar:focus-within {
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(var(--c-primary-rgb), .12);
}
.search-bar__icon { color: var(--c-text-2); flex-shrink: 0; }
.search-bar__input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.9rem;
color: var(--c-text-1);
}
.search-bar__input::placeholder { color: var(--c-text-2); }
.search-bar__clear {
background: none;
border: none;
cursor: pointer;
color: var(--c-text-2);
font-size: 0.85rem;
padding: 0 2px;
line-height: 1;
}
.search-bar__clear:hover { color: var(--c-text-1); }
.search-results-hint {
font-size: 0.85rem;
color: var(--c-text-2);
margin-bottom: 0.5rem;
padding: 6px 0;
}
.filter-bar { padding: 1rem 0; display: flex; flex-direction: column; gap: 10px; }
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
.news-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-top: 1.5rem;
}
.pagination { display: flex; align-items: center; gap: 0.75rem; justify-content: center; margin-top: 2rem; }
.pagination__info { font-size: 0.85rem; color: var(--c-text-2); }
/* ── Детальная страница ── */
.article-page { max-width: 780px; margin: 0 auto; padding: 2rem 1rem 4rem; }
.article-cover { width: 100%; max-height: 420px; object-fit: cover; border-radius: var(--r-card); margin-bottom: 1.5rem; }
.article-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 1.25rem; color: var(--c-text-2); font-size: 0.85rem; }
.article-title { font-family: 'Merriweather', serif; font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 700; line-height: 1.3; margin-bottom: 1.5rem; }
.article-body { line-height: 1.8; font-size: 1.0625rem; }
.article-body img { max-width: 100%; border-radius: var(--r-card); }
.article-body h2, .article-body h3 { margin: 1.5rem 0 .75rem; font-family: 'Merriweather', serif; }
.article-source { display: flex; align-items: center; gap: 8px; padding: 12px 16px; background: rgba(var(--c-primary-rgb),.07); border: 1px solid rgba(var(--c-primary-rgb),.2); border-radius: var(--r-card); font-size: 0.875rem; color: var(--c-primary); margin-top: 2rem; }
.article-source a { color: var(--c-primary); text-decoration: underline; word-break: break-all; }
.article-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 1.5rem; }
.back-btn { display: inline-flex; align-items: center; gap: 6px; color: var(--c-primary); cursor: pointer; background: none; border: none; font-size: 0.875rem; padding: 0; margin-bottom: 1.5rem; }
/* ── Админ layout ── */
.admin-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--c-sidebar);
color: var(--c-sidebar-text);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar__logo {
padding: 1.25rem 1rem;
font-size: 1.05rem;
font-weight: 700;
border-bottom: 1px solid rgba(255,255,255,.08);
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar__close { display: none; background: none; border: none; color: inherit; cursor: pointer; font-size: 1.1rem; }
.sidebar__nav { flex: 1; padding: 0.75rem 0; }
.sidebar__link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 1rem;
color: var(--c-sidebar-text);
text-decoration: none;
font-size: 0.9rem;
opacity: .8;
transition: background .15s, opacity .15s;
}
.sidebar__link:hover { background: rgba(255,255,255,.07); opacity: 1; }
.sidebar__link--active { background: var(--c-sidebar-active); opacity: 1; font-weight: 600; }
.sidebar__logout {
margin: 0.75rem;
padding: 10px 1rem;
background: rgba(255,255,255,.07);
border: none;
border-radius: var(--r-btn);
color: var(--c-sidebar-text);
cursor: pointer;
font-size: 0.875rem;
text-align: left;
}
.sidebar-overlay { display: none; }
.admin-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.admin-topbar {
position: sticky;
top: 0;
z-index: 50;
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: 0 1.5rem;
height: 56px;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: var(--shadow-panel);
}
.topbar__burger { display: none; background: none; border: none; cursor: pointer; font-size: 1.4rem; padding: 4px; color: var(--c-text-1); }
.topbar__title { font-size: 1.1rem; font-weight: 600; flex: 1; }
.topbar__theme { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px; }
.admin-content { padding: 1.5rem; flex: 1; }
/* ── Таблица ── */
.table-wrap { overflow-x: auto; background: var(--c-surface); border-radius: var(--r-card); box-shadow: var(--shadow-card); }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { padding: 10px 14px; text-align: left; font-weight: 600; color: var(--c-text-2); border-bottom: 2px solid var(--c-border); white-space: nowrap; }
td { padding: 10px 14px; border-bottom: 1px solid var(--c-border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(var(--c-primary-rgb),.04); }
.td-actions { display: flex; gap: 6px; }
/* ── Форма / Input ── */
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-2); }
.form-input, .form-select {
padding: 9px 12px;
border-radius: var(--r-input);
border: 1px solid var(--c-border);
background: var(--c-bg);
color: var(--c-text-1);
font-size: 0.9375rem;
outline: none;
transition: border-color .15s;
width: 100%;
}
.form-input:focus, .form-select:focus { border-color: var(--c-primary); }
/* ── Карточки статистики (дашборд) ── */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.stat-card { background: var(--c-surface); border-radius: var(--r-card); padding: 1.25rem; box-shadow: var(--shadow-card); }
.stat-card__value { font-size: 2rem; font-weight: 700; color: var(--c-primary); line-height: 1; }
.stat-card__label { font-size: 0.8rem; color: var(--c-text-2); margin-top: 4px; }
/* ── Страница входа ── */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--c-bg);
}
.login-box {
background: var(--c-surface);
border-radius: var(--r-card);
padding: 2.5rem 2rem;
width: 100%;
max-width: 380px;
box-shadow: var(--shadow-panel);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.login-box h1 { font-size: 1.5rem; font-weight: 700; text-align: center; color: var(--c-primary); }
.error-msg { color: var(--c-error); font-size: 0.85rem; text-align: center; }
/* ── Алерт ── */
.alert { padding: 10px 14px; border-radius: var(--r-card); font-size: 0.875rem; margin-bottom: 1rem; }
.alert-success { background: rgba(61,196,126,.12); color: var(--c-success); border: 1px solid var(--c-success); }
.alert-error { background: rgba(230,70,70,.10); color: var(--c-error); border: 1px solid var(--c-error); }
/* ── Медиа галерея ── */
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.media-item { background: var(--c-surface); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-card); }
.media-item img { width: 100%; height: 120px; object-fit: cover; display: block; }
.media-item__info { padding: 8px; font-size: 0.75rem; color: var(--c-text-2); display: flex; justify-content: space-between; align-items: center; }
.media-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
/* ── Toggle-кнопка (активен/неактивен) ── */
.toggle-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 2px 4px;
border-radius: 6px;
transition: transform .15s, opacity .15s;
opacity: .85;
}
.toggle-btn:hover { transform: scale(1.2); opacity: 1; }
/* ── VK ── */
.vk-status-bar { background: var(--c-surface); border-radius: var(--r-card); padding: 1rem 1.25rem; box-shadow: var(--shadow-card); margin-bottom: 1.25rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.vk-status__dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.dot-green { background: var(--c-success); }
.dot-red { background: var(--c-error); }
.dot-yellow { background: var(--c-warning); }
/* ── Модалка статьи ── */
.article-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.6);
z-index: 500;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2vh 1rem;
overflow-y: auto;
animation: fadeIn .18s ease;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
.article-modal {
background: var(--c-surface);
border-radius: var(--r-card);
width: 100%;
max-width: 780px;
position: relative;
box-shadow: 0 8px 40px rgba(0,0,0,.35);
animation: slideUp .22s ease;
margin: auto;
}
@keyframes slideUp { from { transform: translateY(24px); opacity: 0 } to { transform: none; opacity: 1 } }
.article-modal__close {
position: absolute;
top: 14px;
right: 14px;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,.18);
color: #fff;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
transition: background .15s;
}
.article-modal__close:hover { background: rgba(0,0,0,.4); }
.article-modal__cover {
width: 100%;
max-height: 380px;
object-fit: cover;
border-radius: var(--r-card) var(--r-card) 0 0;
display: block;
}
.article-modal__body {
padding: 1.75rem 2rem 2rem;
}
/* ── Адаптив (мобилки) ── */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -260px;
top: 0;
height: 100%;
z-index: 200;
transition: left .25s;
width: 240px;
}
.sidebar--open { left: 0; }
.sidebar__close { display: block; }
.sidebar-overlay { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 199; }
.topbar__burger { display: block; }
.admin-content { padding: 1rem; }
.news-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 10px; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
}

View File

@@ -0,0 +1,61 @@
import React, { useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useTheme } from '../hooks/useTheme'
const NAV = [
{ to: '/admin', label: 'Дашборд', icon: '🏠', end: true },
{ to: '/admin/articles', label: 'Статьи', icon: '📰' },
{ to: '/admin/categories', label: 'Категории', icon: '🏷️' },
{ to: '/admin/media', label: 'Медиа', icon: '🖼️' },
{ to: '/admin/vk', label: 'ВКонтакте', icon: '📥' },
]
export default function AdminLayout({ children, title }) {
const { logout } = useAuth()
const { dark, toggle } = useTheme()
const [sideOpen, setSideOpen] = useState(false)
return (
<div className="admin-layout">
{/* Sidebar */}
<aside className={`sidebar ${sideOpen ? 'sidebar--open' : ''}`}>
<div className="sidebar__logo">
<span>📋 Новости</span>
<button className="sidebar__close" onClick={() => setSideOpen(false)}></button>
</div>
<nav className="sidebar__nav">
{NAV.map(n => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className={({ isActive }) => `sidebar__link ${isActive ? 'sidebar__link--active' : ''}`}
onClick={() => setSideOpen(false)}
>
<span>{n.icon}</span> {n.label}
</NavLink>
))}
</nav>
<button className="sidebar__logout" onClick={logout}> Выйти</button>
</aside>
{/* Overlay for mobile */}
{sideOpen && <div className="sidebar-overlay" onClick={() => setSideOpen(false)} />}
{/* Main */}
<div className="admin-main">
<header className="admin-topbar">
<button className="topbar__burger" onClick={() => setSideOpen(true)}></button>
<h1 className="topbar__title">{title}</h1>
<button className="topbar__theme" onClick={toggle} title="Сменить тему">
{dark ? '☀️' : '🌙'}
</button>
</header>
<main className="admin-content">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import React, { useEffect } from 'react'
function fmt(iso) {
if (!iso) return ''
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
export default function ArticleModal({ article, onClose }) {
// Закрытие по Escape
useEffect(() => {
const handler = e => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
// Блокируем скролл страницы под модалкой
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', handler)
document.body.style.overflow = ''
}
}, [onClose])
return (
<div className="article-modal-overlay" onClick={onClose}>
<div className="article-modal" onClick={e => e.stopPropagation()}>
<button className="article-modal__close" onClick={onClose} aria-label="Закрыть"></button>
{article.cover_url && (
<img className="article-modal__cover" src={article.cover_url} alt={article.title} />
)}
<div className="article-modal__body">
<div className="article-meta">
{article.category && (
<span className="chip chip--primary">{article.category.name}</span>
)}
<span>{fmt(article.published_at)}</span>
</div>
<h1 className="article-title">{article.title}</h1>
<div
className="article-body"
dangerouslySetInnerHTML={{ __html: article.content ?? '' }}
/>
{article.tags?.length > 0 && (
<div className="article-tags">
{article.tags.map(t => (
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
))}
</div>
)}
{article.source_url && (
<div className="article-source">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<span>Источник:</span>
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
{article.source_url}
</a>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import React from 'react'
export default function Confirm({ message, onOk, onCancel }) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<p style={{ marginBottom: '1.5rem', color: 'var(--c-text-1)' }}>{message}</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button className="btn btn-ghost" onClick={onCancel}>Отмена</button>
<button className="btn btn-danger" onClick={onOk}>Удалить</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import React from 'react'
const DATE_PRESETS = [
{ value: '', label: 'Всё время' },
{ value: 'week', label: 'Неделя' },
{ value: 'month', label: 'Месяц' },
{ value: '3months',label: '3 месяца' },
{ value: 'year', label: 'Год' },
]
export default function FilterBar({ categories, tags, filters, onChange }) {
const set = key => val => onChange({ ...filters, [key]: val, page: 1 })
return (
<div className="filter-bar">
{/* Категории */}
<div className="filter-row">
<button
className={`chip ${!filters.category ? 'chip--active' : 'chip--ghost'}`}
onClick={() => set('category')(null)}
>Все</button>
{categories.map(c => (
<button
key={c.id}
className={`chip ${filters.category === c.slug ? 'chip--active' : 'chip--ghost'}`}
onClick={() => set('category')(c.slug)}
>{c.name}</button>
))}
</div>
{/* Теги */}
{tags.length > 0 && (
<div className="filter-row">
{tags.map(t => (
<button
key={t.id}
className={`chip ${filters.tag === t.slug ? 'chip--active' : 'chip--ghost'}`}
onClick={() => set('tag')(filters.tag === t.slug ? null : t.slug)}
>#{t.name} <span className="chip__count">{t.count}</span></button>
))}
</div>
)}
{/* Даты */}
<div className="filter-row">
{DATE_PRESETS.map(p => (
<button
key={p.value}
className={`chip ${(filters.datePreset ?? '') === p.value ? 'chip--active' : 'chip--ghost'}`}
onClick={() => set('datePreset')(p.value)}
>{p.label}</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
export default function Loader({ full = false }) {
const style = full
? { display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }
: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem' }
return (
<div style={style}>
<div className="spinner" />
</div>
)
}

View File

@@ -0,0 +1,82 @@
import React, { useState } from 'react'
function fmt(iso) {
if (!iso) return ''
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
function domain(url) {
try { return new URL(url).hostname.replace('www.', '') }
catch { return url }
}
export default function NewsCard({ article, onClick }) {
const { title, cover_url, excerpt, category, published_at, tags = [], source_url } = article
const [imgExpanded, setImgExpanded] = useState(false)
return (
<article className="vk-card" onClick={onClick}>
{/* Шапка: категория + дата */}
<div className="vk-card__header">
<div className="vk-card__source-info">
<div className="vk-card__avatar">
{category?.name?.[0]?.toUpperCase() ?? '📰'}
</div>
<div>
<div className="vk-card__category">{category?.name ?? 'Новости'}</div>
<div className="vk-card__date">{fmt(published_at)}</div>
</div>
</div>
{source_url && (
<a
className="vk-card__source-link"
href={source_url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
title="Источник"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
{domain(source_url)}
</a>
)}
</div>
{/* Заголовок */}
<h3 className="vk-card__title">{title}</h3>
{/* Превью текста */}
{excerpt && <p className="vk-card__excerpt">{excerpt}</p>}
{/* Обложка */}
{cover_url && (
<div className={`vk-card__img-wrap ${imgExpanded ? 'vk-card__img-wrap--expanded' : ''}`}>
<img
src={cover_url}
alt={title}
loading="lazy"
onClick={e => { e.stopPropagation(); setImgExpanded(v => !v) }}
/>
</div>
)}
{/* Теги */}
{tags.length > 0 && (
<div className="vk-card__tags" onClick={e => e.stopPropagation()}>
{tags.map(t => (
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
))}
</div>
)}
{/* Подвал */}
<div className="vk-card__footer">
<span className="vk-card__read">Читать полностью </span>
</div>
</article>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
const MAP = {
published: { label: 'Опубликовано', color: 'var(--c-success)' },
draft: { label: 'Черновик', color: 'var(--c-warning)' },
archived: { label: 'Архив', color: 'var(--c-text-2)' },
}
export default function StatusBadge({ status }) {
const { label, color } = MAP[status] ?? { label: status, color: 'var(--c-text-2)' }
return (
<span style={{
display: 'inline-block',
padding: '2px 10px',
borderRadius: 'var(--r-chip)',
fontSize: '0.75rem',
fontWeight: 600,
background: color + '22',
color,
border: `1px solid ${color}55`,
}}>
{label}
</span>
)
}

View File

@@ -0,0 +1,21 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
// Токен хранится в localStorage — сохраняется при перезагрузке
export function useAuth() {
const [token, setToken] = useState(() => localStorage.getItem('token') || '')
const navigate = useNavigate()
const saveToken = t => {
localStorage.setItem('token', t)
setToken(t)
}
const logout = () => {
localStorage.removeItem('token')
setToken('')
navigate('/admin/login')
}
return { token, saveToken, logout, isAuth: !!token }
}

View File

@@ -0,0 +1,13 @@
import { useState, useEffect } from 'react'
// Тёмная тема: переключает атрибут data-theme на <html>
export function useTheme() {
const [dark, setDark] = useState(() => localStorage.getItem('theme') === 'dark')
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
localStorage.setItem('theme', dark ? 'dark' : 'light')
}, [dark])
return { dark, toggle: () => setDark(d => !d) }
}

12
web-react/src/main.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './theme.css'
import './app.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<BrowserRouter>
<App />
</BrowserRouter>
)

View File

@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { getArticle } from '../api/index'
import Loader from '../components/Loader'
function fmt(iso) {
if (!iso) return ''
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
export default function NewsDetail() {
const { slug } = useParams()
const navigate = useNavigate()
const [article, setArticle] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
getArticle(slug)
.then(setArticle)
.catch(() => setError('Статья не найдена'))
.finally(() => setLoading(false))
}, [slug])
if (loading) return <Loader full />
if (error) return (
<div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<p style={{ color: 'var(--c-text-2)' }}>{error}</p>
<button className="btn btn-ghost" style={{ marginTop: '1rem' }} onClick={() => navigate('/')}>
На главную
</button>
</div>
)
return (
<div className="article-page">
<button className="back-btn" onClick={() => navigate(-1)}> Назад</button>
{article.cover_url && (
<img className="article-cover" src={article.cover_url} alt={article.title} />
)}
<div className="article-meta">
{article.category && <span className="chip chip--primary">{article.category.name}</span>}
<span>{fmt(article.published_at)}</span>
</div>
<h1 className="article-title">{article.title}</h1>
<div
className="article-body"
dangerouslySetInnerHTML={{ __html: article.body ?? '' }}
/>
{article.tags?.length > 0 && (
<div className="article-tags">
{article.tags.map(t => (
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
))}
</div>
)}
{article.source_url && (
<div className="article-source">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<span>Источник:</span>
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
{article.source_url}
</a>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,190 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTheme } from '../hooks/useTheme'
import { getNews, getCategories, getTags, getArticle } from '../api/index'
import NewsCard from '../components/NewsCard'
import FilterBar from '../components/FilterBar'
import Loader from '../components/Loader'
import ArticleModal from '../components/ArticleModal'
const PAGE_SIZE = 20
function presetToDateFrom(preset) {
if (!preset) return null
const now = new Date()
const days = { week: 7, month: 30, '3months': 90, year: 365 }[preset]
if (!days) return null
now.setDate(now.getDate() - days)
return now.toISOString()
}
export default function NewsFeed() {
const { dark, toggle } = useTheme()
const [articles, setArticles] = useState([])
const [total, setTotal] = useState(0)
const [categories, setCategories] = useState([])
const [tags, setTags] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '', page: 1 })
// Поиск с debounce
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const debounceRef = useRef(null)
const handleSearch = e => {
const val = e.target.value
setSearchInput(val)
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setSearchQuery(val.trim())
setFilters(f => ({ ...f, page: 1 }))
}, 400)
}
const clearSearch = () => {
setSearchInput('')
setSearchQuery('')
setFilters(f => ({ ...f, page: 1 }))
}
// Модальное окно
const [modalArticle, setModalArticle] = useState(null)
useEffect(() => {
Promise.all([getCategories(), getTags(30)]).then(([cats, tgs]) => {
setCategories(cats)
setTags(tgs)
}).catch(() => {})
}, [])
const loadNews = useCallback(async () => {
setLoading(true)
try {
const params = {
limit: PAGE_SIZE,
offset: (filters.page - 1) * PAGE_SIZE,
}
if (filters.category) params.category = filters.category
if (filters.tag) params.tag = filters.tag
const df = presetToDateFrom(filters.datePreset)
if (df) params.date_from = df
if (searchQuery) params.q = searchQuery
const data = await getNews(params)
setArticles(data.items ?? [])
setTotal(data.total ?? 0)
} catch {
setArticles([])
} finally {
setLoading(false)
}
}, [filters, searchQuery])
useEffect(() => { loadNews() }, [loadNews])
const openArticle = async (slug) => {
setModalArticle({ _loading: true })
try {
const data = await getArticle(slug)
setModalArticle(data)
} catch {
setModalArticle(null)
}
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<>
<header className="public-header">
<div className="public-header__inner">
<span className="public-header__logo">📰 Новости колледжей</span>
<button className="public-header__theme" onClick={toggle} title="Тема">
{dark ? '☀️' : '🌙'}
</button>
</div>
{/* Строка поиска */}
<div className="search-bar-wrap">
<div className="search-bar">
<svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
className="search-bar__input"
type="text"
placeholder="Поиск по заголовку, тексту, тегам..."
value={searchInput}
onChange={handleSearch}
/>
{searchInput && (
<button className="search-bar__clear" onClick={clearSearch}></button>
)}
</div>
</div>
</header>
<div className="news-feed">
<FilterBar
categories={categories}
tags={tags}
filters={filters}
onChange={setFilters}
/>
{/* Счётчик результатов при поиске */}
{searchQuery && !loading && (
<p className="search-results-hint">
{total > 0
? `Найдено: ${total} ${total === 1 ? 'новость' : total < 5 ? 'новости' : 'новостей'} по запросу «${searchQuery}»`
: `По запросу «${searchQuery}» ничего не найдено`
}
</p>
)}
{loading
? <Loader />
: articles.length === 0
? <p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
Новостей не найдено
</p>
: <div className="news-list">
{articles.map(a => (
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
))}
</div>
}
{!loading && totalPages > 1 && (
<div className="pagination">
<button
className="btn btn-ghost btn-sm"
disabled={filters.page <= 1}
onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}
> Назад</button>
<span className="pagination__info">{filters.page} / {totalPages}</span>
<button
className="btn btn-ghost btn-sm"
disabled={filters.page >= totalPages}
onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
>Вперёд </button>
</div>
)}
</div>
{/* Модальное окно статьи */}
{modalArticle && (
modalArticle._loading
? (
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
onClick={e => e.stopPropagation()}>
<Loader />
</div>
</div>
)
: <ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
)}
</>
)
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth'
export default function ArticleEditor() {
const { id } = useParams()
const navigate = useNavigate()
const { token } = useAuth()
const editorUrl = `/editor/${id}?t=${token}`
// Слушаем сообщение «закрыть» из iframe-редактора
React.useEffect(() => {
const handler = e => {
if (e.data?.type === 'editor-close') navigate('/admin/articles')
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [navigate])
return (
<iframe
src={editorUrl}
style={{ position: 'fixed', inset: 0, width: '100%', height: '100%', border: 'none', zIndex: 300 }}
title="Редактор статьи"
allow="fullscreen"
/>
)
}

View File

@@ -0,0 +1,162 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth'
import { adminListArticles, adminDeleteArticle, adminPublishArticle, adminCreateArticle } from '../../api/index'
import AdminLayout from '../../components/AdminLayout'
import StatusBadge from '../../components/StatusBadge'
import Confirm from '../../components/Confirm'
import Loader from '../../components/Loader'
const PAGE = 20
function fmt(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default function ArticlesList() {
const { token } = useAuth()
const navigate = useNavigate()
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [status, setStatus] = useState('')
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [confirm, setConfirm] = useState(null) // { id, title }
const createNew = async () => {
setCreating(true)
try {
const article = await adminCreateArticle(token)
navigate(`/admin/articles/${article.id}`)
} catch {
setCreating(false)
}
}
// Debounce поиска 300ms
useEffect(() => {
const t = setTimeout(() => { setSearchQuery(searchInput); setPage(1) }, 300)
return () => clearTimeout(t)
}, [searchInput])
const load = useCallback(async () => {
setLoading(true)
try {
const params = { limit: PAGE, offset: (page - 1) * PAGE }
if (status) params.status = status
if (searchQuery) params.q = searchQuery
const data = await adminListArticles(token, params)
setItems(data?.items ?? [])
setTotal(data?.total ?? 0)
} finally {
setLoading(false)
}
}, [token, page, status, searchQuery])
useEffect(() => { load() }, [load])
const doDelete = async () => {
if (!confirm) return
await adminDeleteArticle(token, confirm.id).catch(() => {})
setConfirm(null)
load()
}
const doPublish = async id => {
await adminPublishArticle(token, id).catch(() => {})
load()
}
const totalPages = Math.max(1, Math.ceil(total / PAGE))
return (
<AdminLayout title="Статьи">
{confirm && (
<Confirm
message={`Удалить «${confirm.title}»?`}
onOk={doDelete}
onCancel={() => setConfirm(null)}
/>
)}
{/* Тулбар */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button className="btn btn-primary" onClick={createNew} disabled={creating}>
{creating ? 'Создаю...' : '✏️ Написать новость'}
</button>
<div style={{ width: 1, height: 28, background: 'var(--c-border)', margin: '0 4px' }} />
{['', 'published', 'draft'].map(s => (
<button
key={s}
className={`btn btn-sm ${status === s ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => { setStatus(s); setPage(1) }}
>
{s === '' ? 'Все' : s === 'published' ? 'Опубликованные' : 'Черновики'}
</button>
))}
<div style={{ width: 1, height: 28, background: 'var(--c-border)', margin: '0 4px' }} />
<input
className="form-input"
placeholder="Поиск по названию..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
style={{ width: 220, fontSize: '0.85rem' }}
/>
<span style={{ marginLeft: 'auto', fontSize: '0.85rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
Всего: {total}
</span>
</div>
{loading ? <Loader /> : (
<>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Заголовок</th>
<th>Статус</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{items.map(a => (
<tr key={a.id}>
<td style={{ maxWidth: 340, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.title}
</td>
<td><StatusBadge status={a.status} /></td>
<td style={{ color: 'var(--c-text-2)', fontSize: '0.82rem' }}>
{fmt(a.published_at ?? a.created_at)}
</td>
<td>
<div className="td-actions">
<Link to={`/admin/articles/${a.id}`} className="btn btn-ghost btn-sm"></Link>
{a.status !== 'published' && (
<button className="btn btn-ghost btn-sm" onClick={() => doPublish(a.id)}></button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ id: a.id, title: a.title })}>🗑</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="pagination" style={{ marginTop: '1rem' }}>
<button className="btn btn-ghost btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}> Назад</button>
<span className="pagination__info">{page} / {totalPages}</span>
<button className="btn btn-ghost btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Вперёд </button>
</div>
)}
</>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from '../../hooks/useAuth'
import { adminListCategories, adminCreateCategory, adminUpdateCategory, adminDeleteCategory } from '../../api/index'
import AdminLayout from '../../components/AdminLayout'
import Confirm from '../../components/Confirm'
import Loader from '../../components/Loader'
export default function Categories() {
const { token } = useAuth()
const [cats, setCats] = useState([])
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [editing, setEditing] = useState(null) // { id, name }
const [confirm, setConfirm] = useState(null) // { id, name }
const [alert, setAlert] = useState(null)
const load = async () => {
setLoading(true)
try { setCats(await adminListCategories(token)) }
finally { setLoading(false) }
}
useEffect(() => { load() }, [token])
const flash = (type, msg) => {
setAlert({ type, msg })
setTimeout(() => setAlert(null), 3000)
}
const doCreate = async e => {
e.preventDefault()
if (!newName.trim()) return
try {
await adminCreateCategory(token, newName.trim())
setNewName('')
flash('success', 'Категория создана')
load()
} catch { flash('error', 'Ошибка создания') }
}
const doUpdate = async () => {
if (!editing) return
try {
await adminUpdateCategory(token, editing.id, editing.name)
setEditing(null)
flash('success', 'Сохранено')
load()
} catch { flash('error', 'Ошибка сохранения') }
}
const doDelete = async () => {
if (!confirm) return
try {
await adminDeleteCategory(token, confirm.id)
setConfirm(null)
flash('success', 'Удалено')
load()
} catch { flash('error', 'Ошибка удаления') }
}
return (
<AdminLayout title="Категории">
{confirm && (
<Confirm
message={`Удалить категорию «${confirm.name}»?`}
onOk={doDelete}
onCancel={() => setConfirm(null)}
/>
)}
{alert && <div className={`alert alert-${alert.type}`}>{alert.msg}</div>}
<form onSubmit={doCreate} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', maxWidth: 480 }}>
<input
className="form-input"
placeholder="Название новой категории"
value={newName}
onChange={e => setNewName(e.target.value)}
required
/>
<button className="btn btn-primary" type="submit" style={{ flexShrink: 0 }}>Добавить</button>
</form>
{loading ? <Loader /> : (
<div className="table-wrap">
<table>
<thead>
<tr><th>Название</th><th>Действия</th></tr>
</thead>
<tbody>
{cats.map(c => (
<tr key={c.id}>
<td>
{editing?.id === c.id
? <input
className="form-input"
value={editing.name}
onChange={e => setEditing(ed => ({ ...ed, name: e.target.value }))}
style={{ maxWidth: 300 }}
/>
: c.name
}
</td>
<td>
<div className="td-actions">
{editing?.id === c.id
? <>
<button className="btn btn-primary btn-sm" onClick={doUpdate}>Сохранить</button>
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(null)}>Отмена</button>
</>
: <>
<button className="btn btn-ghost btn-sm" onClick={() => setEditing({ id: c.id, name: c.name })}></button>
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ id: c.id, name: c.name })}>🗑</button>
</>
}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,85 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth'
import { adminListArticles } from '../../api/index'
import AdminLayout from '../../components/AdminLayout'
import StatusBadge from '../../components/StatusBadge'
import Loader from '../../components/Loader'
function fmt(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default function Dashboard() {
const { token } = useAuth()
const [stats, setStats] = useState({ published: 0, drafts: 0, total: 0 })
const [recent, setRecent] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
adminListArticles(token, { limit: 1, status: 'published' }),
adminListArticles(token, { limit: 1, status: 'draft' }),
adminListArticles(token, { limit: 8 }),
]).then(([pub, draft, all]) => {
setStats({
published: pub?.total ?? 0,
drafts: draft?.total ?? 0,
total: all?.total ?? 0,
})
setRecent(all?.items ?? [])
}).catch(() => {})
.finally(() => setLoading(false))
}, [token])
return (
<AdminLayout title="Дашборд">
{loading ? <Loader /> : (
<>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-card__value">{stats.total}</div>
<div className="stat-card__label">Всего статей</div>
</div>
<div className="stat-card">
<div className="stat-card__value">{stats.published}</div>
<div className="stat-card__label">Опубликовано</div>
</div>
<div className="stat-card">
<div className="stat-card__value">{stats.drafts}</div>
<div className="stat-card__label">Черновики</div>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Заголовок</th>
<th>Статус</th>
<th>Дата</th>
<th></th>
</tr>
</thead>
<tbody>
{recent.map(a => (
<tr key={a.id}>
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.title}
</td>
<td><StatusBadge status={a.status} /></td>
<td style={{ color: 'var(--c-text-2)', fontSize: '0.82rem' }}>{fmt(a.published_at ?? a.created_at)}</td>
<td>
<Link to={`/admin/articles/${a.id}`} className="btn btn-ghost btn-sm">Открыть</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth'
import { login } from '../../api/index'
export default function Login() {
const { saveToken } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const submit = async e => {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await login(username, password)
saveToken(data.access_token)
navigate('/admin')
} catch (err) {
setError('Неверный логин или пароль')
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<form className="login-box" onSubmit={submit}>
<h1>Вход</h1>
{error && <p className="error-msg">{error}</p>}
<div className="form-group">
<label className="form-label">Логин</label>
<input
className="form-input"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
/>
</div>
<div className="form-group">
<label className="form-label">Пароль</label>
<input
className="form-input"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<button className="btn btn-primary" type="submit" disabled={loading} style={{ width: '100%' }}>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useAuth } from '../../hooks/useAuth'
import { adminListMedia, adminUploadMedia, adminDeleteMedia } from '../../api/index'
import AdminLayout from '../../components/AdminLayout'
import Confirm from '../../components/Confirm'
import Loader from '../../components/Loader'
const PAGE = 40
function fmtSize(bytes) {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024**2) return `${(bytes/1024).toFixed(1)} KB`
return `${(bytes/1024**2).toFixed(1)} MB`
}
function fmtDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
}
function VideoThumb({ url, filename }) {
const [err, setErr] = useState(false)
if (err) return (
<div style={{ height: 140, background: '#1e293b', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, gap: 6 }}>
<span style={{ fontSize: 28 }}>🎬</span>
<span style={{ maxWidth: 140, textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
</div>
)
return (
<video
src={url}
style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block', background: '#0f172a' }}
preload="metadata"
muted
playsInline
onError={() => setErr(true)}
onMouseEnter={e => e.target.play?.()}
onMouseLeave={e => { e.target.pause?.(); e.target.currentTime = 0 }}
/>
)
}
export default function MediaLibrary() {
const { token } = useAuth()
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
const [type, setType] = useState('')
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [confirm, setConfirm] = useState(null)
const [uploading, setUploading] = useState(false)
const [alert, setAlert] = useState(null)
const [preview, setPreview] = useState(null)
const fileRef = useRef()
const load = useCallback(async () => {
setLoading(true)
try {
const data = await adminListMedia(token, { type: type || undefined, offset: (page - 1) * PAGE, limit: PAGE })
setItems(data.items ?? [])
setTotal(data.total ?? 0)
} finally {
setLoading(false)
}
}, [token, type, page])
useEffect(() => { load() }, [load])
const flash = (t, msg) => { setAlert({ type: t, msg }); setTimeout(() => setAlert(null), 3000) }
const doUpload = async e => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
try {
await adminUploadMedia(token, file)
flash('success', 'Загружено!')
setPage(1)
load()
} catch { flash('error', 'Ошибка загрузки') }
finally { setUploading(false); e.target.value = '' }
}
const doDelete = async () => {
if (!confirm) return
try { await adminDeleteMedia(token, confirm.id); setConfirm(null); load() }
catch { flash('error', 'Ошибка удаления') }
}
const totalPages = Math.max(1, Math.ceil(total / PAGE))
return (
<AdminLayout title="Медиатека">
{confirm && (
<Confirm
message="Удалить файл?"
onOk={doDelete}
onCancel={() => setConfirm(null)}
/>
)}
{/* Lightbox */}
{preview && (
<div
onClick={() => setPreview(null)}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
{preview.media_type === 'image'
? <img src={preview.url} alt={preview.filename}
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, boxShadow: '0 8px 48px #000' }} />
: <video src={preview.url} controls autoPlay
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, boxShadow: '0 8px 48px #000' }}
onClick={e => e.stopPropagation()} />
}
</div>
)}
{alert && <div className={`alert alert-${alert.type}`} style={{ marginBottom: 12 }}>{alert.msg}</div>}
{/* Toolbar */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap', alignItems: 'center' }}>
{[['', 'Все'], ['image', 'Изображения'], ['video', 'Видео']].map(([val, label]) => (
<button
key={val}
className={`btn btn-sm ${type === val ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => { setType(val); setPage(1) }}
>{label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
Всего: {total}
</span>
<button
className="btn btn-primary btn-sm"
onClick={() => fileRef.current?.click()}
disabled={uploading}
>
{uploading ? 'Загружаю...' : '⬆️ Загрузить'}
</button>
<input ref={fileRef} type="file" accept="image/*,video/*" style={{ display: 'none' }} onChange={doUpload} />
</div>
{loading ? <Loader /> : items.length === 0
? <p style={{ color: 'var(--c-text-2)', textAlign: 'center', padding: '2rem' }}>Файлов нет</p>
: (
<div className="media-grid">
{items.map(m => (
<div key={m.id} className="media-item" style={{ cursor: 'pointer' }}>
<div onClick={() => setPreview(m)} style={{ position: 'relative' }}>
{m.media_type === 'image'
? <img src={m.url} alt={m.filename} style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }} />
: <VideoThumb url={m.url} filename={m.filename} />
}
{m.media_type === 'video' && (
<div style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.6)',
borderRadius: 4, padding: '2px 6px', fontSize: 10, color: '#fff' }}> видео</div>
)}
</div>
<div className="media-item__info">
<span className="media-item__name" title={m.filename}>{m.filename}</span>
<span style={{ fontSize: '0.75rem', color: 'var(--c-text-2)' }}>{fmtSize(m.size_bytes)} · {fmtDate(m.created_at)}</span>
<button
className="btn btn-icon btn-sm"
onClick={() => setConfirm({ id: m.id })}
style={{ padding: '2px 6px', color: 'var(--c-error)' }}
></button>
</div>
</div>
))}
</div>
)
}
{totalPages > 1 && (
<div className="pagination" style={{ marginTop: '1rem' }}>
<button className="btn btn-ghost btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}> Назад</button>
<span className="pagination__info">{page} / {totalPages}</span>
<button className="btn btn-ghost btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Вперёд </button>
</div>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from '../../hooks/useAuth'
import {
adminVkStatus, adminVkListSources,
adminVkAddSource, adminVkUpdateSource, adminVkDeleteSource,
adminVkImport, adminVkImportHistory,
} from '../../api/index'
import AdminLayout from '../../components/AdminLayout'
import Confirm from '../../components/Confirm'
import Loader from '../../components/Loader'
export default function VkSources() {
const { token } = useAuth()
const [status, setStatus] = useState(null)
const [sources, setSources] = useState([])
const [loading, setLoading] = useState(true)
const [confirm, setConfirm] = useState(null)
const [alert, setAlert] = useState(null)
const [importing, setImporting] = useState('')
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true })
const [editing, setEditing] = useState(null)
const load = async () => {
setLoading(true)
try {
const [st, srcs] = await Promise.all([
adminVkStatus(token),
adminVkListSources(token),
])
setStatus(st)
setSources(srcs)
} finally { setLoading(false) }
}
useEffect(() => { load() }, [token])
const flash = (type, msg) => { setAlert({ type, msg }); setTimeout(() => setAlert(null), 5000) }
const doAdd = async e => {
e.preventDefault()
try {
await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled)
setForm({ group_id: '', group_name: '', enabled: true })
flash('success', 'Источник добавлен')
load()
} catch(err) { flash('error', String(err.message)) }
}
const doUpdate = async () => {
if (!editing) return
try {
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled)
setEditing(null)
flash('success', 'Сохранено')
load()
} catch { flash('error', 'Ошибка') }
}
const doDelete = async () => {
if (!confirm) return
try { await adminVkDeleteSource(token, confirm.id); setConfirm(null); load() }
catch { flash('error', 'Ошибка удаления') }
}
const doImport = async () => {
setImporting('new')
try {
const r = await adminVkImport(token)
flash('success', `Импорт завершён: +${r.imported ?? 0} новых`)
} catch { flash('error', 'Ошибка импорта') }
finally { setImporting('') }
}
const doHistory = async (sourceId = null) => {
setImporting('history')
try {
const r = await adminVkImportHistory(token, sourceId)
flash('success', `История загружена: +${r.imported ?? 0} постов`)
} catch { flash('error', 'Ошибка загрузки истории') }
finally { setImporting('') }
}
const statusDot = () => {
if (!status) return 'dot-yellow'
if (status.configured) return 'dot-green'
return 'dot-red'
}
return (
<AdminLayout title="ВКонтакте">
{confirm && <Confirm message="Удалить источник?" onOk={doDelete} onCancel={() => setConfirm(null)} />}
{alert && <div className={`alert alert-${alert.type}`}>{alert.msg}</div>}
{/* Статус */}
<div className="vk-status-bar">
<div className={`vk-status__dot ${statusDot()}`} />
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
{status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
className="btn btn-primary btn-sm"
onClick={doImport}
disabled={!!importing}
>{importing === 'new' ? 'Импортирую...' : '🔄 Новые посты'}</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => doHistory(null)}
disabled={!!importing}
>{importing === 'history' ? 'Загружаю...' : '📥 История (все)'}</button>
</div>
</div>
{/* Добавить источник */}
<form onSubmit={doAdd} style={{ background: 'var(--c-surface)', borderRadius: 'var(--r-card)', padding: '1rem', marginBottom: '1.5rem', boxShadow: 'var(--shadow-card)' }}>
<h3 style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.95rem' }}>Добавить источник</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="form-group" style={{ flex: '1 1 160px' }}>
<label className="form-label">ID группы</label>
<input className="form-input" placeholder="131748668" value={form.group_id}
onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))} required />
</div>
<div className="form-group" style={{ flex: '2 1 220px' }}>
<label className="form-label">Название</label>
<input className="form-input" placeholder="Колледж..." value={form.group_name}
onChange={e => setForm(f => ({ ...f, group_name: e.target.value }))} required />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.875rem', paddingBottom: 4 }}>
<input type="checkbox" checked={form.enabled}
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
Активен
</label>
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
</div>
</form>
{loading ? <Loader /> : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID группы</th>
<th>Название</th>
<th>Активен</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{sources.map(s => (
<tr key={s.id}>
<td style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{editing?.id === s.id
? <input className="form-input" value={editing.group_id}
onChange={e => setEditing(ed => ({ ...ed, group_id: e.target.value }))}
style={{ width: 130 }} />
: s.group_id
}
</td>
<td>
{editing?.id === s.id
? <input className="form-input" value={editing.group_name}
onChange={e => setEditing(ed => ({ ...ed, group_name: e.target.value }))} />
: s.group_name
}
</td>
<td>
{editing?.id === s.id
? <input type="checkbox" checked={editing.enabled}
onChange={e => setEditing(ed => ({ ...ed, enabled: e.target.checked }))} />
: (
<button
className="toggle-btn"
data-on={s.enabled}
title={s.enabled ? 'Отключить' : 'Включить'}
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled)
.then(load).catch(() => flash('error', 'Ошибка'))}
>
{s.enabled ? '✅' : '❌'}
</button>
)
}
</td>
<td>
<div className="td-actions">
{editing?.id === s.id
? <>
<button className="btn btn-primary btn-sm" onClick={doUpdate}>Сохранить</button>
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(null)}>Отмена</button>
</>
: <>
<button className="btn btn-ghost btn-sm"
onClick={() => setEditing({ ...s })}></button>
<button className="btn btn-ghost btn-sm"
onClick={() => doHistory(s.id)} disabled={!!importing}
title="Загрузить историю этого источника">📥</button>
<button className="btn btn-danger btn-sm"
onClick={() => setConfirm({ id: s.id })}>🗑</button>
</>
}
</div>
</td>
</tr>
))}
{sources.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
Источников нет
</td></tr>
)}
</tbody>
</table>
</div>
)}
</AdminLayout>
)
}

87
web-react/src/theme.css Normal file
View File

@@ -0,0 +1,87 @@
/* =====================================================================
ДИЗАЙН-КОНФИГ — меняй только здесь, всё остальное обновится само
===================================================================== */
/* ── Шрифты Google ── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&display=swap');
/* ── Светлая тема (по умолчанию) ── */
:root {
/* Основной цвет (синий ВК-стиль) */
--c-primary: #2787F5;
--c-primary-dark: #1565C0;
--c-primary-rgb: 39, 135, 245; /* для rgba() */
/* Фоны */
--c-bg: #F0F2F5; /* страница */
--c-surface: #FFFFFF; /* панели, шапки */
--c-card: #FFFFFF; /* карточки новостей */
/* Текст */
--c-text-1: #050505; /* основной */
--c-text-2: #818C99; /* вторичный (дата, метки) */
/* Границы и разделители */
--c-border: #E5E9EF;
/* Статусы */
--c-success: #3DC47E;
--c-warning: #FFA726;
--c-error: #E64646;
/* Боковая панель администратора */
--c-sidebar: #1A237E;
--c-sidebar-text: #E8EAF6;
--c-sidebar-active: #283593;
/* Скругления */
--r-card: 12px;
--r-chip: 6px;
--r-btn: 8px;
--r-input: 8px;
/* Тени */
--shadow-card: 0 1px 6px rgba(0,0,0,.08);
--shadow-panel: 0 2px 8px rgba(0,0,0,.10);
}
/* ── Тёмная тема ── */
[data-theme="dark"] {
--c-bg: #0F0F1A;
--c-surface: #1A1A2E;
--c-card: #22223A;
--c-text-1: #E8EAF6;
--c-text-2: #9FA8C4;
--c-border: #2D2D50;
--c-sidebar: #0D0D1F;
--c-sidebar-text: #C5CAE9;
--c-sidebar-active:#1A237E;
--shadow-card: 0 1px 6px rgba(0,0,0,.3);
--shadow-panel: 0 2px 8px rgba(0,0,0,.4);
}
/* ── Базовые стили ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--c-bg);
color: var(--c-text-1);
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
/* Плавное переключение тёмной темы */
*, *::before, *::after {
transition: background-color .2s, border-color .2s, color .15s;
}
/* Скроллбар */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--c-border); border-radius: 3px; }

View File

@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
// Тёмная тема включается атрибутом data-theme="dark" на <html>
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
// Все цвета берутся из CSS-переменных в theme.css
// Менять цвета нужно только там
colors: {
primary: 'var(--c-primary)',
'primary-d':'var(--c-primary-dark)',
bg: 'var(--c-bg)',
surface: 'var(--c-surface)',
card: 'var(--c-card)',
't1': 'var(--c-text-1)',
't2': 'var(--c-text-2)',
border: 'var(--c-border)',
success: 'var(--c-success)',
warning: 'var(--c-warning)',
error: 'var(--c-error)',
sidebar: 'var(--c-sidebar)',
'sidebar-t':'var(--c-sidebar-text)',
},
fontFamily: {
ui: ['Inter', 'sans-serif'],
display: ['Merriweather', 'serif'],
},
borderRadius: {
card: '12px',
},
},
},
plugins: [],
}

15
web-react/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// В dev-режиме проксируем API запросы на FastAPI
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
})

View File

@@ -2,7 +2,21 @@ import os
import httpx import httpx
API_URL = os.getenv("API_URL", "http://api:8000") 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: def _headers(token: str | None = None) -> dict:
@@ -19,15 +33,20 @@ async def get_news(
limit: int = 20, limit: int = 20,
category: str | None = None, category: str | None = None,
tag: str | None = None, tag: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> dict | None: ) -> dict | None:
params = {"offset": offset, "limit": limit} params: dict = {"offset": offset, "limit": limit}
if category: if category:
params["category"] = category params["category"] = category
if tag: if tag:
params["tag"] = tag params["tag"] = tag
if date_from:
params["date_from"] = date_from
if date_to:
params["date_to"] = date_to
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().get("/news", params=params)
r = await client.get(f"{API_URL}/news", params=params)
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: except httpx.RequestError:
@@ -37,8 +56,7 @@ async def get_news(
async def get_article(slug: str) -> dict | None: async def get_article(slug: str) -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().get(f"/news/{slug}")
r = await client.get(f"{API_URL}/news/{slug}")
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: except httpx.RequestError:
@@ -48,8 +66,17 @@ async def get_article(slug: str) -> dict | None:
async def get_categories() -> list: async def get_categories() -> list:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().get("/news/categories")
r = await client.get(f"{API_URL}/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: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: except httpx.RequestError:
@@ -61,9 +88,8 @@ async def get_categories() -> list:
async def admin_login(username: str, password: str) -> str | None: async def admin_login(username: str, password: str) -> str | None:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().post(
r = await client.post( "/admin/login",
f"{API_URL}/admin/login",
json={"username": username, "password": password}, json={"username": username, "password": password},
) )
if r.status_code == 200: if r.status_code == 200:
@@ -76,12 +102,11 @@ async def admin_login(username: str, password: str) -> str | None:
# ─── Admin: Articles ────────────────────────────────────────────────────────── # ─── Admin: Articles ──────────────────────────────────────────────────────────
async def admin_list_articles(token: str, offset: int = 0, limit: int = 50, status: str | None = None) -> dict | None: 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: if status:
params["status"] = status params["status"] = status
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().get("/admin/articles", params=params, headers=_headers(token))
r = await client.get(f"{API_URL}/admin/articles", params=params, headers=_headers(token))
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: 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: async def admin_get_article(token: str, article_id: str) -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().get(f"/admin/articles/{article_id}", headers=_headers(token))
r = await client.get(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: 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: async def admin_create_article(token: str, data: dict) -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().post("/admin/articles", json=data, headers=_headers(token))
r = await client.post(f"{API_URL}/admin/articles", json=data, headers=_headers(token))
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: 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: async def admin_update_article(token: str, article_id: str, data: dict) -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().put(f"/admin/articles/{article_id}", json=data, headers=_headers(token))
r = await client.put(f"{API_URL}/admin/articles/{article_id}", json=data, headers=_headers(token))
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: 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: async def admin_delete_article(token: str, article_id: str) -> bool:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().delete(f"/admin/articles/{article_id}", headers=_headers(token))
r = await client.delete(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
return r.status_code == 200 return r.status_code == 200
except httpx.RequestError: except httpx.RequestError:
return False 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: async def admin_publish_article(token: str, article_id: str) -> bool:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().post(f"/admin/articles/{article_id}/publish", headers=_headers(token))
r = await client.post(f"{API_URL}/admin/articles/{article_id}/publish", headers=_headers(token))
return r.status_code == 200 return r.status_code == 200
except httpx.RequestError: except httpx.RequestError:
return False 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: async def admin_list_categories(token: str) -> list:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().get("/admin/categories", headers=_headers(token))
r = await client.get(f"{API_URL}/admin/categories", headers=_headers(token))
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: 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: async def admin_create_category(token: str, name: str, slug: str = "") -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().post(
r = await client.post( "/admin/categories",
f"{API_URL}/admin/categories",
json={"name": name, "slug": slug or None}, json={"name": name, "slug": slug or None},
headers=_headers(token), 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: async def admin_update_category(token: str, cat_id: str, name: str, slug: str = "") -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().put(
r = await client.put( f"/admin/categories/{cat_id}",
f"{API_URL}/admin/categories/{cat_id}",
json={"name": name, "slug": slug or None}, json={"name": name, "slug": slug or None},
headers=_headers(token), 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: async def admin_delete_category(token: str, cat_id: str) -> bool:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().delete(f"/admin/categories/{cat_id}", headers=_headers(token))
r = await client.delete(f"{API_URL}/admin/categories/{cat_id}", headers=_headers(token))
return r.status_code == 200 return r.status_code == 200
except httpx.RequestError: except httpx.RequestError:
return False 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: async def admin_upload_media(token: str, file_bytes: bytes, filename: str, content_type: str) -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=60.0) as client: r = await _get().post(
r = await client.post( "/admin/media/upload",
f"{API_URL}/admin/media/upload",
files={"file": (filename, file_bytes, content_type)}, files={"file": (filename, file_bytes, content_type)},
headers={"Authorization": f"Bearer {token}"}, 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: 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: if media_type:
params["media_type"] = media_type params["media_type"] = media_type
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().get("/admin/media", params=params, headers=_headers(token))
r = await client.get(f"{API_URL}/admin/media", params=params, headers=_headers(token))
if r.status_code == 200: if r.status_code == 200:
return r.json() return r.json()
except httpx.RequestError: 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: async def admin_delete_media(token: str, media_id: str) -> bool:
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await _get().delete(f"/admin/media/{media_id}", headers=_headers(token))
r = await client.delete(f"{API_URL}/admin/media/{media_id}", headers=_headers(token))
return r.status_code == 200 return r.status_code == 200
except httpx.RequestError: except httpx.RequestError:
return False 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

View File

@@ -26,18 +26,19 @@ GOOGLE_FONTS_JS = """
""" """
# ─── Цвета ─────────────────────────────────────────────────────────────────── # ─── Цвета ───────────────────────────────────────────────────────────────────
PRIMARY = "#1565C0" PRIMARY = "#2787F5" # VK синий
PRIMARY_LIGHT = "#1976D2" PRIMARY_LIGHT = "#5BA4F7"
PRIMARY_DARK = "#0D47A1" PRIMARY_DARK = "#1565C0"
ACCENT = "#FF6D00" ACCENT = "#FF3F4B" # красный акцент (лайки / важное)
SUCCESS = "#2E7D32" SUCCESS = "#3DC47E"
WARNING = "#F57F17" WARNING = "#FFA726"
ERROR = "#C62828" ERROR = "#E64646"
SURFACE = "#FFFFFF" SURFACE = "#FFFFFF"
BACKGROUND = "#F5F5F5" BACKGROUND = "#F0F2F5" # VK светло-серый фон
TEXT_PRIMARY = "#212121" CARD_BG = "#FFFFFF"
TEXT_SECONDARY = "#616161" TEXT_PRIMARY = "#050505"
DIVIDER = "#E0E0E0" TEXT_SECONDARY = "#818C99" # VK серый для мета-текста
DIVIDER = "#E5E9EF"
ADMIN_SIDEBAR = "#1A237E" ADMIN_SIDEBAR = "#1A237E"
ADMIN_SIDEBAR_TEXT = "#E8EAF6" ADMIN_SIDEBAR_TEXT = "#E8EAF6"
ADMIN_SIDEBAR_ACTIVE = "#283593" ADMIN_SIDEBAR_ACTIVE = "#283593"

View File

@@ -52,22 +52,62 @@ const DEFAULT_META = {
status: 'draft', status: 'draft',
} }
/* ── Загрузчик: сначала данные, потом редактор ── */
export default function App() { 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 [categories, setCategories] = useState([])
const [saveStatus, setSaveStatus] = useState('saved') const [saveStatus, setSaveStatus] = useState('saved')
const [mediaState, setMediaState] = useState(null) const [mediaState, setMediaState] = useState(null)
/* Refs so callbacks always see latest values without re-creating them */
const metaRef = useRef(meta) const metaRef = useRef(meta)
metaRef.current = 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 saveTimerRef = useRef(null)
const editorRef = useRef(null) // always points to live editor instance const editorRef = useRef(null)
const articleLoadedRef = useRef(false) const doSaveRef = useRef(null)
const doSaveRef = useRef(null) // always points to latest doSave
/* ── Editor ── */ /* ── Editor создаётся сразу с готовым контентом ── */
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
@@ -88,54 +128,22 @@ export default function App() {
Placeholder.configure({ placeholder: 'Начните писать статью…' }), Placeholder.configure({ placeholder: 'Начните писать статью…' }),
CharacterCount, CharacterCount,
], ],
content: initContent,
onUpdate: () => { onUpdate: () => {
setSaveStatus('unsaved') setSaveStatus('unsaved')
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
// Always call the latest doSave via ref — avoids stale closure
saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000) saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000)
}, },
}) })
// Keep editorRef current every render so doSave never uses a stale instance
editorRef.current = editor editorRef.current = editor
/* ── Load categories once on mount ── */ /* ── Категории ── */
useEffect(() => { useEffect(() => {
api.getCategories(TOKEN).then(setCategories).catch(() => {}) 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 doSave = useCallback(async overrideStatus => {
const editor = editorRef.current const editor = editorRef.current
if (!editor) return if (!editor) return
@@ -165,15 +173,12 @@ export default function App() {
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN) saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
} }
setSaveStatus('saved') setSaveStatus('saved')
if (overrideStatus) { if (overrideStatus) setMeta(m => ({ ...m, status: overrideStatus }))
setMeta(m => ({ ...m, status: overrideStatus }))
}
} catch { } catch {
setSaveStatus('error') setSaveStatus('error')
} }
}, []) // stable: reads from editorRef + metaRef }, [])
// Keep ref current so the auto-save timer always calls the latest version
doSaveRef.current = doSave doSaveRef.current = doSave
const handleUploadCover = async file => { const handleUploadCover = async file => {
@@ -183,7 +188,6 @@ export default function App() {
} catch {} } catch {}
} }
/* ── Render ── */
return ( return (
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}> <div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>

View File

@@ -15,13 +15,20 @@ export function Header({ saveStatus, articleStatus, onSave, onPublish }) {
return ( 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"> <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"> <div className="flex items-center gap-3">
<a <button
href="javascript:history.back()" 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" className="flex items-center gap-1.5 text-slate-400 hover:text-white transition-colors text-sm"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
<span>Назад</span> <span>Закрыть</span>
</a> </button>
<div className="w-px h-5 bg-slate-700" /> <div className="w-px h-5 bg-slate-700" />
<span className="text-slate-200 font-semibold text-sm tracking-tight">News CMS</span> <span className="text-slate-200 font-semibold text-sm tracking-tight">News CMS</span>
</div> </div>

View File

@@ -35,38 +35,60 @@ function Sep() {
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" /> 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 ── */ /* ── Font family dropdown ── */
function FontDropdown({ editor, fonts }) { function FontDropdown({ editor, fonts }) {
const [open, setOpen] = useState(false) const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const ref = useRef(null)
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт' 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 ( return (
<div className="relative flex-shrink-0" ref={ref}> <div className="flex-shrink-0">
<button <button
ref={btnRef}
type="button" 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" 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="Шрифт" title="Шрифт"
> >
<span <span className="truncate leading-none" style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}>
className="truncate leading-none"
style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}
>
{current} {current}
</span> </span>
<ChevronDown size={11} className="flex-shrink-0 opacity-60" /> <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
</button> </button>
{open && ( {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 <button
type="button" type="button"
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors" 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 ── */ /* ── Font size dropdown ── */
function SizeDropdown({ editor }) { function SizeDropdown({ editor }) {
const [open, setOpen] = useState(false) const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const ref = useRef(null)
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—' 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 ( return (
<div className="relative flex-shrink-0" ref={ref}> <div className="flex-shrink-0">
<button <button
ref={btnRef}
type="button" 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" 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="Размер шрифта" title="Размер шрифта"
> >
@@ -118,7 +134,10 @@ function SizeDropdown({ editor }) {
</button> </button>
{open && ( {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 => ( {FONT_SIZES.map(s => (
<button <button
key={s} key={s}
@@ -138,14 +157,7 @@ function SizeDropdown({ editor }) {
/* ── Table menu ── */ /* ── Table menu ── */
function TableMenu({ editor }) { function TableMenu({ editor }) {
const [open, setOpen] = useState(false) const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
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 items = [ const items = [
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, { label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
@@ -162,17 +174,22 @@ function TableMenu({ editor }) {
] ]
return ( return (
<div className="relative flex-shrink-0" ref={ref}> <div className="flex-shrink-0">
<Btn <Btn
onClick={() => setOpen(o => !o)} onClick={e => toggle({ preventDefault: () => {}, ...e })}
active={editor.isActive('table')} active={editor.isActive('table')}
title="Таблица" title="Таблица"
> >
<Table2 size={14} /> <Table2 size={14} />
</Btn> </Btn>
{/* Используем ref на обёртке для Btn */}
<span ref={btnRef} style={{ display: 'none' }} />
{open && ( {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) => {items.map((item, i) =>
item === null ? ( item === null ? (
<div key={i} className="my-1 border-t border-slate-100" /> <div key={i} className="my-1 border-t border-slate-100" />
@@ -181,9 +198,7 @@ function TableMenu({ editor }) {
key={item.label} key={item.label}
type="button" type="button"
className={`w-full px-3 py-1.5 text-left text-xs transition-colors className={`w-full px-3 py-1.5 text-left text-xs transition-colors
${item.danger ${item.danger ? 'text-red-500 hover:bg-red-50' : 'text-slate-700 hover:bg-slate-50'}`}
? 'text-red-500 hover:bg-red-50'
: 'text-slate-700 hover:bg-slate-50'}`}
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }} onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
> >
{item.label} {item.label}
@@ -210,7 +225,7 @@ export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
} }
return ( 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 */} {/* History */}
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)"> <Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
<Undo2 size={14} /> <Undo2 size={14} />

View File

@@ -8,6 +8,7 @@ from views.articles_list import ArticlesListView
from views.article_editor import ArticleEditorView from views.article_editor import ArticleEditorView
from views.categories import CategoriesView from views.categories import CategoriesView
from views.media_library import MediaLibraryView from views.media_library import MediaLibraryView
from views.vk_sources import VkSourcesView
_PUBLIC = {"/", "/admin/login"} _PUBLIC = {"/", "/admin/login"}
@@ -45,6 +46,8 @@ def resolve_view(page: ft.Page, route: str):
return CategoriesView(page) return CategoriesView(page)
if sub == "media": if sub == "media":
return MediaLibraryView(page) return MediaLibraryView(page)
if sub == "vk":
return VkSourcesView(page)
return DashboardView(page) return DashboardView(page)
return NewsFeedView(page) return NewsFeedView(page)

View File

@@ -42,15 +42,18 @@ class DashboardView:
) )
async def load(self): async def load(self):
import asyncio
token = self.page.session.get("token") token = self.page.session.get("token")
data = await api.admin_list_articles(token, limit=100) published_data, draft_data, recent_data = await asyncio.gather(
if not data: api.admin_list_articles(token, limit=1, status="published"),
return api.admin_list_articles(token, limit=1, status="draft"),
api.admin_list_articles(token, limit=10),
)
items = data.get("items", []) published = (published_data or {}).get("total", 0)
total = data.get("total", 0) drafts = (draft_data or {}).get("total", 0)
published = sum(1 for a in items if a.get("status") == "published") total = published + drafts
drafts = total - published items = (recent_data or {}).get("items", [])
self._stats_row.controls = [ self._stats_row.controls = [
d.stat_card("Всего статей", str(total), ft.icons.ARTICLE, d.PRIMARY), 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), 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() self.page.update()
@@ -118,6 +121,7 @@ def _sidebar(page: ft.Page, active: str = "") -> ft.Container:
(ft.icons.ARTICLE, "Статьи", "/admin/articles"), (ft.icons.ARTICLE, "Статьи", "/admin/articles"),
(ft.icons.CATEGORY, "Категории", "/admin/categories"), (ft.icons.CATEGORY, "Категории", "/admin/categories"),
(ft.icons.PERM_MEDIA, "Медиа", "/admin/media"), (ft.icons.PERM_MEDIA, "Медиа", "/admin/media"),
(ft.icons.RSS_FEED, "ВКонтакте", "/admin/vk"),
] ]
def nav_item(icon, label, route): def nav_item(icon, label, route):

View File

@@ -1,9 +1,41 @@
import re
import flet as ft import flet as ft
import designer as d import designer as d
import api_client as api import api_client as api
import theme_manager 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"![](\1)", 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("&nbsp;", " ").replace("&amp;", "&")
.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"'))
# Collapse excessive blank lines
t = re.sub(r"\n{3,}", "\n\n", t)
return t.strip()
class NewsDetailView: class NewsDetailView:
def __init__(self, page: ft.Page, slug: str): def __init__(self, page: ft.Page, slug: str):
self.page = page self.page = page
@@ -159,7 +191,7 @@ class NewsDetailView:
controls.append( controls.append(
ft.Markdown( ft.Markdown(
value=a.get("content", ""), value=_html_to_markdown(a.get("content", "")),
selectable=True, selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
on_tap_link=lambda e: self.page.launch_url(e.data), 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.Divider(height=32, color="transparent"))
controls.append( controls.append(
ft.TextButton( ft.TextButton(

View File

@@ -1,8 +1,34 @@
from datetime import datetime, timedelta
from urllib.parse import urlparse
import flet as ft import flet as ft
import designer as d import designer as d
import api_client as api 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: class NewsFeedView:
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page):
self.page = page self.page = page
@@ -12,82 +38,103 @@ class NewsFeedView:
self._has_more = True self._has_more = True
self._loading = False self._loading = False
self._category: str | None = None self._category: str | None = None
self._tag: str | None = None
self._date_preset: str | None = None
self._categories: list = [] 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( self._loader = ft.Container(
content=d.loader(36), content=d.loader(36),
alignment=ft.alignment.center, alignment=ft.alignment.center,
padding=20, padding=20,
visible=True, visible=True,
) )
self._load_more_btn = ft.ElevatedButton( self._load_more_btn = ft.Container(
"Загрузить ещё", content=ft.TextButton(
"Показать ещё",
icon=ft.icons.EXPAND_MORE, icon=ft.icons.EXPAND_MORE,
on_click=lambda e: self.page.run_task(self._load_more), on_click=lambda e: self.page.run_task(self._load_more),
visible=False, style=ft.ButtonStyle(color=d.PRIMARY),
style=ft.ButtonStyle(
bgcolor=d.SURFACE,
color=d.PRIMARY,
shape=ft.RoundedRectangleBorder(radius=8),
), ),
alignment=ft.alignment.center,
padding=ft.padding.only(bottom=24),
visible=False,
) )
self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8) 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: def build_page_view(self) -> ft.View:
header = ft.Container( header = ft.Container(
content=ft.Row( content=ft.Row(
[ [
ft.Row([ ft.Row([
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=28), ft.Container(
ft.Text("Новости", size=20, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE), content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, color=ft.colors.WHITE, size=22),
], spacing=8), bgcolor=ft.colors.with_opacity(0.2, ft.colors.WHITE),
ft.Row([ border_radius=8,
ft.TextButton( padding=6,
"Войти", ),
icon=ft.icons.ADMIN_PANEL_SETTINGS_OUTLINED, ft.Text(
style=ft.ButtonStyle(color=ft.colors.WHITE70), "Новости колледжей",
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"), on_click=lambda e: self.page.go("/admin/login"),
) icon_size=22,
]), ),
], ],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN, alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
), ),
bgcolor=d.ADMIN_SIDEBAR, bgcolor=d.PRIMARY,
padding=ft.padding.symmetric(horizontal=24, vertical=14), 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([ 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, bgcolor=d.SURFACE,
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)), border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
) )
main_content = ft.Container( feed = ft.Container(
content=ft.Column( content=ft.Column(
[ [self._grid, self._loader, self._load_more_btn],
ft.Container( spacing=0,
content=ft.Column(
[
self._list_col,
self._loader,
ft.Container(
content=self._load_more_btn,
alignment=ft.alignment.center,
padding=16,
), ),
], padding=ft.padding.symmetric(horizontal=12, vertical=16),
spacing=16,
),
width=680,
),
],
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
),
padding=ft.padding.symmetric(horizontal=16, vertical=24),
alignment=ft.alignment.top_center,
expand=True, expand=True,
) )
@@ -98,10 +145,9 @@ class NewsFeedView:
ft.Column( ft.Column(
[ [
header, header,
cat_section, filters,
ft.Container( ft.Container(
content=main_content, content=feed,
alignment=ft.alignment.top_center,
expand=True, expand=True,
), ),
], ],
@@ -113,27 +159,39 @@ class NewsFeedView:
) )
async def load(self): async def load(self):
self._categories = await api.get_categories() self._categories, self._tags = await _gather(
self._render_categories() 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() await self._load_articles()
async def _load_more(self): async def _load_more(self):
if self._loading or not self._has_more: if self._loading or not self._has_more:
return return
self._loading = True self._loading = True
self._load_more_btn.disabled = True self._load_more_btn.visible = False
self.page.update() self.page.update()
self._offset += self._limit self._offset += self._limit
await self._load_articles() await self._load_articles()
self._loading = False self._loading = False
self._load_more_btn.disabled = False
self.page.update()
async def _load_articles(self): async def _load_articles(self):
self._loader.visible = True self._loader.visible = True
self.page.update() 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 self._loader.visible = False
if not data: if not data:
@@ -145,120 +203,250 @@ class NewsFeedView:
self._has_more = data.get("has_more", False) self._has_more = data.get("has_more", False)
for a in items: 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._load_more_btn.visible = self._has_more
self.page.update() 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_row.controls = [
self._cat_chip(None, "Все") self._cat_chip(None, "Все")
] + [ ] + [
self._cat_chip(c["slug"], c["name"]) for c in self._categories self._cat_chip(c["slug"], c["name"]) for c in self._categories
] ]
if update:
self.page.update() self.page.update()
def _cat_chip(self, slug: str | None, label: str) -> ft.Chip: def _cat_chip(self, slug: str | None, label: str) -> ft.Chip:
is_active = self._category == slug is_active = self._category == slug
return ft.Chip( 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, 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), 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), border_side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
elevation=0,
) )
async def _filter_category(self, slug: str | None): async def _filter_category(self, slug: str | None):
self._category = slug self._category = slug
self._articles = [] self._reset_feed()
self._offset = 0
self._has_more = True
self._list_col.controls.clear()
self._render_categories() self._render_categories()
await self._load_articles() await self._load_articles()
def _build_card(self, article: dict) -> ft.Container: # ── Tag filter ─────────────────────────────────────────────────────────────
pub_date = ""
if article.get("published_at"):
pub_date = article["published_at"][:10]
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") def _tag_chip(self, slug: str, label: str) -> ft.Container:
meta_row_controls = [] is_active = self._tag == slug
if cat: return ft.Container(
meta_row_controls.append(ft.Container( content=ft.Text(
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_600), f"#{label}",
bgcolor=d.PRIMARY, 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, border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3), padding=ft.padding.symmetric(horizontal=8, vertical=3),
)) on_click=lambda e, s=slug: self.page.run_task(self._filter_tag, s),
if pub_date: ink=True,
meta_row_controls.append(ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY)) )
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( content=ft.Image(
src=article["cover_url"], src=article["cover_url"],
width=float("inf"),
height=200,
fit=ft.ImageFit.COVER, 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, 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( # Category + date row
content=ft.Column( meta_row = ft.Row([
[
ft.Row(meta_row_controls, spacing=8) if meta_row_controls else ft.Container(visible=False),
ft.Text( 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"], 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, size=13,
color=d.TEXT_SECONDARY, weight=ft.FontWeight.W_700,
max_lines=3, color=d.TEXT_PRIMARY,
max_lines=2,
overflow=ft.TextOverflow.ELLIPSIS, overflow=ft.TextOverflow.ELLIPSIS,
) if article.get("excerpt") else ft.Container(visible=False), font_family=d.FONT_UI,
ft.Row( )
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.Row([
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY), ft.Icon(ft.icons.LINK, size=11, color=d.TEXT_SECONDARY),
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY), ft.Text(src_domain, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
], spacing=4), ], spacing=2) if src_domain else ft.Container(),
ft.TextButton( ft.Container(expand=True),
"Читать →", ft.Text("Читать →", size=11, color=d.PRIMARY, weight=ft.FontWeight.W_600, font_family=d.FONT_UI),
style=ft.ButtonStyle(color=d.PRIMARY), ], vertical_alignment=ft.CrossAxisAlignment.CENTER)
on_click=lambda e, s=slug: self.page.launch_url(
f"/article/{s}", web_window_name="_self" content_col = ft.Column(
), [meta_row, title, tag_row, ft.Container(expand=True), footer],
), spacing=6,
], expand=True,
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
],
spacing=8,
),
padding=ft.padding.symmetric(horizontal=20, vertical=16),
) )
return ft.Container( return ft.Container(
content=ft.Column([cover_section, body], spacing=0), content=ft.Column([
bgcolor=d.SURFACE, cover,
border_radius=12, ft.Container(
border=ft.border.all(1, d.DIVIDER), content=content_col,
shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.with_opacity(0.07, ft.colors.BLACK)), padding=ft.padding.symmetric(horizontal=10, vertical=8),
clip_behavior=ft.ClipBehavior.HARD_EDGE, expand=True,
on_click=lambda e, s=slug: self.page.launch_url(
f"/article/{s}", web_window_name="_self"
), ),
], 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, 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
View 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()