new web ract
This commit is contained in:
16
.env.example
16
.env.example
@@ -10,5 +10,21 @@ MINIO_PUBLIC_URL=http://localhost:9000
|
|||||||
DOCS_USERNAME=admin
|
DOCS_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
6
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
27
api/main.py
27
api/main.py
@@ -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)):
|
||||||
@@ -80,6 +90,7 @@ 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
4
api/rate_limiter.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -10,3 +10,11 @@ python-multipart==0.0.12
|
|||||||
minio==7.2.11
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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="Неверный текущий пароль")
|
||||||
|
|||||||
@@ -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
113
api/route/admin_vk.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bd.database import get_db
|
||||||
|
from bd.models import VkSource
|
||||||
|
from route.deps import require_admin
|
||||||
|
from vk_parser import run_import, run_history_import, VK_TOKEN
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SourceIn(BaseModel):
|
||||||
|
group_id: str
|
||||||
|
group_name: str
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _source_dict(s: VkSource) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(s.id),
|
||||||
|
"group_id": s.group_id,
|
||||||
|
"group_name": s.group_name,
|
||||||
|
"enabled": s.enabled,
|
||||||
|
"last_run": s.last_run.isoformat() if s.last_run else None,
|
||||||
|
"created_at": s.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def vk_status(_: str = Depends(require_admin)):
|
||||||
|
return {"configured": bool(VK_TOKEN)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD источников ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/sources")
|
||||||
|
async def list_sources(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||||
|
rows = (await db.execute(select(VkSource).order_by(VkSource.created_at))).scalars().all()
|
||||||
|
return [_source_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_group_id(raw: str) -> str:
|
||||||
|
"""Из строки вида '-95503797', '95503797_6512', 'wall-95503797_12' вытащить только числовой ID группы."""
|
||||||
|
import re
|
||||||
|
raw = raw.strip().lstrip("-")
|
||||||
|
# Берём только первое число (до '_' если есть)
|
||||||
|
m = re.search(r"(\d+)", raw)
|
||||||
|
return m.group(1) if m else raw
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources", status_code=201)
|
||||||
|
async def add_source(data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||||
|
group_id = _clean_group_id(data.group_id)
|
||||||
|
exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none()
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть")
|
||||||
|
source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled)
|
||||||
|
db.add(source)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(source)
|
||||||
|
return _source_dict(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sources/{source_id}")
|
||||||
|
async def update_source(source_id: str, data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||||
|
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||||
|
source.group_id = _clean_group_id(data.group_id)
|
||||||
|
source.group_name = data.group_name
|
||||||
|
source.enabled = data.enabled
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(source)
|
||||||
|
return _source_dict(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sources/{source_id}", status_code=204)
|
||||||
|
async def delete_source(source_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||||
|
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||||
|
await db.delete(source)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ручной запуск импорта ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
||||||
|
background_tasks.add_task(run_import)
|
||||||
|
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/history")
|
||||||
|
async def vk_history_import_all(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
||||||
|
background_tasks.add_task(run_history_import)
|
||||||
|
return {"ok": True, "message": "Исторический импорт всех групп запущен в фоне (до 5 мин)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/history/{source_id}")
|
||||||
|
async def vk_history_import_one(
|
||||||
|
source_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||||
|
background_tasks.add_task(run_history_import, group_id=source.group_id)
|
||||||
|
return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import json
|
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
26
api/scheduler.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
_scheduler = AsyncIOScheduler(timezone="UTC")
|
||||||
|
|
||||||
|
VK_HOUR = int(os.getenv("VK_IMPORT_HOUR", "6"))
|
||||||
|
VK_MINUTE = int(os.getenv("VK_IMPORT_MINUTE", "0"))
|
||||||
|
|
||||||
|
|
||||||
|
def start():
|
||||||
|
from vk_parser import run_import
|
||||||
|
_scheduler.add_job(
|
||||||
|
run_import,
|
||||||
|
trigger="cron",
|
||||||
|
hour=VK_HOUR,
|
||||||
|
minute=VK_MINUTE,
|
||||||
|
id="vk_daily_import",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_scheduler.start()
|
||||||
|
print(f"[Scheduler] VK-импорт запланирован на {VK_HOUR:02d}:{VK_MINUTE:02d} UTC каждый день")
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown():
|
||||||
|
if _scheduler.running:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
725
api/vk_parser.py
Normal file
725
api/vk_parser.py
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import bleach
|
||||||
|
from bleach.css_sanitizer import CSSSanitizer
|
||||||
|
import httpx
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
from slugify import slugify
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bd.database import AsyncSessionLocal
|
||||||
|
from bd.models import Article, ArticleStatus, Category, Tag, VkSource
|
||||||
|
|
||||||
|
VK_API = "https://api.vk.com/method"
|
||||||
|
VK_VERSION = "5.199"
|
||||||
|
VK_TOKEN = os.getenv("VK_ACCESS_TOKEN", "")
|
||||||
|
VK_IMPORT_AS = os.getenv("VK_IMPORT_STATUS", "published") # published | draft
|
||||||
|
VK_PER_RUN = int(os.getenv("VK_POSTS_PER_RUN", "100")) # макс 100 за запрос
|
||||||
|
|
||||||
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
||||||
|
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
|
||||||
|
|
||||||
|
_URL_RE = re.compile(r"(https?://[^\s<>\"']+)")
|
||||||
|
|
||||||
|
# Разрешённые HTML-теги и атрибуты после санитизации
|
||||||
|
_ALLOWED_TAGS = [
|
||||||
|
"p", "br", "strong", "em", "a", "img",
|
||||||
|
"ul", "ol", "li", "blockquote",
|
||||||
|
"div", "iframe", "span", "video",
|
||||||
|
]
|
||||||
|
_ALLOWED_ATTRS = {
|
||||||
|
"a": ["href", "target", "rel"],
|
||||||
|
"img": ["src", "style", "alt"],
|
||||||
|
"iframe": ["src", "style", "frameborder", "allowfullscreen"],
|
||||||
|
"video": ["src", "controls", "style", "poster"],
|
||||||
|
"div": ["style"],
|
||||||
|
"span": ["style"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CSS = CSSSanitizer(allowed_css_properties=[
|
||||||
|
"max-width", "width", "height", "position", "top", "left",
|
||||||
|
"padding-bottom", "overflow", "margin", "border-radius",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(html: str) -> str:
|
||||||
|
return bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS,
|
||||||
|
css_sanitizer=_CSS, strip=True)
|
||||||
|
|
||||||
|
# ── Теги по ключевым словам ───────────────────────────────────────────────────
|
||||||
|
# Ключ = название тега, значение = список подстрок (регистронезависимо)
|
||||||
|
TAG_KEYWORDS: dict[str, list[str]] = {
|
||||||
|
# ── Учёба и поступление ───────────────────────────────────────────────────
|
||||||
|
"экзамены": ["экзамен", "зачёт", "зачет", "сессия", "огэ", "егэ",
|
||||||
|
"гиа", "защит", "диплом", "аттестац", "промежуточн"],
|
||||||
|
"абитуриентам": ["абитуриент", "поступ", "приёмн", "приемн", "зачислен",
|
||||||
|
"набор студент", "подай документ", "приглашаем поступ",
|
||||||
|
"бюджетн мест", "целевой приём", "контрольные цифры"],
|
||||||
|
"день открытых дверей": ["день открытых дверей", "открытые двери", "день открытых",
|
||||||
|
"экскурси", "познакомиться с колледж"],
|
||||||
|
"практика": ["практик", "стажировк", "производственн", "учебно-производ",
|
||||||
|
"на предприяти", "работодател", "наставник"],
|
||||||
|
"достижения": ["победител", "призёр", "призер", "награда", "грамот",
|
||||||
|
"диплом лауреат", "1 место", "2 место", "3 место",
|
||||||
|
"лучший студент", "гордост", "поздравляем", "медал"],
|
||||||
|
"студенческая жизнь": ["студсовет", "студенческ жизн", "общежити", "капустник",
|
||||||
|
"квн", "студенч", "первокурсник", "посвящение",
|
||||||
|
"студент год", "актив"],
|
||||||
|
"профессионалитет": ["профессионалитет", "фп профессионалитет",
|
||||||
|
"кластер", "федеральн проект"],
|
||||||
|
|
||||||
|
# ── Направления колледжей ─────────────────────────────────────────────────
|
||||||
|
"медицина": ["медицин", "сестринск", "фельдшер", "фармацевт", "лечебн",
|
||||||
|
"анатоми", "патологи", "здравоохранен", "санитар",
|
||||||
|
"первая помощ", "реанимац", "клиническ"],
|
||||||
|
"педагогика": ["педагог", "воспитател", "учител", "начальн класс",
|
||||||
|
"дошкольн", "детский сад", "логопед", "коррекцион",
|
||||||
|
"инклюзив", "тьютор"],
|
||||||
|
"юриспруденция": ["юрист", "юридическ", "правоохранительн", "право",
|
||||||
|
"законодательств", "суд", "прокурат", "полиц",
|
||||||
|
"юриспруденц", "правовед"],
|
||||||
|
"IT и технологии": ["программирован", "it-", "ит-", "информационн технолог",
|
||||||
|
"цифров", "кибербезопасн", "веб-разраб", "1с",
|
||||||
|
"компьютерн", "разработчик", "python", "frontend",
|
||||||
|
"backend", "хакатон", "ворлдскиллс"],
|
||||||
|
"кулинария и торговля": ["кулинар", "повар", "кондитер", "гастроном", "блюд",
|
||||||
|
"рецепт", "ресторан", "кафе", "торговл", "продавец",
|
||||||
|
"мерчандайзинг", "товаровед", "общепит"],
|
||||||
|
"строительство": ["строительств", "архитектур", "проектирован", "чертёж",
|
||||||
|
"чертеж", "монтаж", "сварк", "электромонтаж",
|
||||||
|
"сантехник", "отделочн"],
|
||||||
|
"техника и механика": ["механик", "двигател", "автомобил", "станок",
|
||||||
|
"металлообраб", "токар", "слесар", "техническ обслуж",
|
||||||
|
"ремонт оборудован", "машиностроен", "технолог производ"],
|
||||||
|
"сельское хозяйство": ["агроном", "агроинженер", "сельск хоз", "животновод",
|
||||||
|
"агротехник", "землепользован", "агро"],
|
||||||
|
"экономика и бухучёт": ["бухгалтер", "экономик", "финансов", "налог",
|
||||||
|
"аудит", "бизнес", "предпринимател", "менеджмент",
|
||||||
|
"маркетинг", "логистик"],
|
||||||
|
"социальная работа": ["социальн работ", "соцработник", "психолог",
|
||||||
|
"реабилитац", "инвалид", "ограниченн возможн",
|
||||||
|
"социальн помощ", "опека"],
|
||||||
|
|
||||||
|
# ── Общественные события ──────────────────────────────────────────────────
|
||||||
|
"спорт": ["спорт", "соревнован", "олимпиад", "футбол", "баскетбол",
|
||||||
|
"волейбол", "чемпион", "кубок", "турнир", "атлет",
|
||||||
|
"фитнес", "зарядк", "ворлдскиллс хайскул"],
|
||||||
|
"военка": ["военн", "армия", "призыв", "нво", "сво", "оборон",
|
||||||
|
"патриот", "защитник", "военно-патриот", "стрельб",
|
||||||
|
"зарниц", "юнармия", "допризывн"],
|
||||||
|
"воздушная опасность": ["воздушн тревог", "воздушн опасност", "ракет",
|
||||||
|
"бпла", "дрон", "обстрел", "укрытие", "сирен",
|
||||||
|
"эвакуац", "бомбоубежищ"],
|
||||||
|
"общественная деятельность": ["волонтёр", "волонтер", "благотвор", "субботник",
|
||||||
|
"экологическ акц", "посадк дерев", "уборк территор",
|
||||||
|
"донорств", "гуманитарн", "помощ фронт"],
|
||||||
|
"праздники и события": ["праздник", "концерт", "фестиваль", "выставк",
|
||||||
|
"торжеств", "церемони", "день колледж", "юбилей",
|
||||||
|
"8 марта", "23 февраля", "новый год", "масленица",
|
||||||
|
"день знаний", "последний звонок"],
|
||||||
|
"конкурсы и олимпиады": ["конкурс", "олимпиад", "чемпионат профессий",
|
||||||
|
"worldskills", "ворлдскиллс", "хакатон", "викторин",
|
||||||
|
"интеллектуальн", "акселератор"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_tags(text: str) -> list[str]:
|
||||||
|
lower = text.lower()
|
||||||
|
found = []
|
||||||
|
for tag_name, keywords in TAG_KEYWORDS.items():
|
||||||
|
if any(kw in lower for kw in keywords):
|
||||||
|
found.append(tag_name)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# ── MinIO helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _minio_client() -> Minio:
|
||||||
|
return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_bucket(mc: Minio):
|
||||||
|
try:
|
||||||
|
if not mc.bucket_exists(MINIO_BUCKET):
|
||||||
|
mc.make_bucket(MINIO_BUCKET)
|
||||||
|
policy = (
|
||||||
|
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*",'
|
||||||
|
f'"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::{MINIO_BUCKET}/*"]'
|
||||||
|
'}]}'
|
||||||
|
)
|
||||||
|
mc.set_bucket_policy(MINIO_BUCKET, policy)
|
||||||
|
except S3Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = r.content
|
||||||
|
ct = r.headers.get("content-type", "image/jpeg").split(";")[0].strip()
|
||||||
|
ext = ct.split("/")[-1].replace("jpeg", "jpg")
|
||||||
|
object_name = f"vk/images/{uuid.uuid4().hex}.{ext}"
|
||||||
|
mc = _minio_client()
|
||||||
|
_ensure_bucket(mc)
|
||||||
|
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type=ct)
|
||||||
|
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||||
|
if db is not None:
|
||||||
|
from bd.models import Media
|
||||||
|
db.add(Media(
|
||||||
|
filename=object_name.split("/")[-1],
|
||||||
|
url=public_url,
|
||||||
|
media_type="image",
|
||||||
|
size_bytes=len(data),
|
||||||
|
))
|
||||||
|
return public_url
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK] Не удалось загрузить фото {url}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Video download ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _fetch_video_info(owner_id: int, video_id: int) -> dict:
|
||||||
|
"""Получить info о видео через video.get.
|
||||||
|
Возвращает thumb_url (лучший кадр превью).
|
||||||
|
files.mp4_* и player возвращаются только если токен имеет scope=video;
|
||||||
|
без scope они None — в этом случае скачивание идёт через yt-dlp.
|
||||||
|
"""
|
||||||
|
if not VK_TOKEN:
|
||||||
|
return {}
|
||||||
|
params = {
|
||||||
|
"videos": f"{owner_id}_{video_id}",
|
||||||
|
"access_token": VK_TOKEN,
|
||||||
|
"v": VK_VERSION,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.get(f"{VK_API}/video.get", params=params)
|
||||||
|
data = r.json()
|
||||||
|
if "error" in data:
|
||||||
|
print(f"[VK video.get] {data['error'].get('error_msg')}")
|
||||||
|
return {}
|
||||||
|
items = data.get("response", {}).get("items", [])
|
||||||
|
if not items:
|
||||||
|
return {}
|
||||||
|
v = items[0]
|
||||||
|
result = {}
|
||||||
|
# лучший кадр превью (выше качеством чем в wall.get)
|
||||||
|
for src_key in ("image", "first_frame"):
|
||||||
|
frames = v.get(src_key, [])
|
||||||
|
if frames:
|
||||||
|
best = max(frames, key=lambda f: f.get("width", 0))
|
||||||
|
result["thumb_url"] = best.get("url")
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK] video.get {owner_id}_{video_id}: {exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _ytdlp_extract_url(vk_url: str, max_mb: int = 150) -> str | None:
|
||||||
|
"""Синхронно: через yt-dlp достать прямую mp4-ссылку без скачивания файла."""
|
||||||
|
try:
|
||||||
|
import yt_dlp
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
max_bytes = max_mb * 1024 * 1024
|
||||||
|
ydl_opts = {
|
||||||
|
"format": "best[ext=mp4][filesize<?{}]/best[ext=mp4]/best".format(max_bytes),
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(vk_url, download=False)
|
||||||
|
url = info.get("url") if info else None
|
||||||
|
if url:
|
||||||
|
print(f"[VK] yt-dlp: найден mp4 для {vk_url}")
|
||||||
|
return url
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK] yt-dlp: не удалось получить URL {vk_url}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_vk_video(owner_id: int, video_id: int,
|
||||||
|
db: AsyncSession | None = None,
|
||||||
|
max_mb: int = 150) -> str | None:
|
||||||
|
"""Скачать VK-видео в MinIO и вернуть публичный URL.
|
||||||
|
Использует yt-dlp для получения прямой mp4-ссылки.
|
||||||
|
Стриминг — не держит весь файл в памяти.
|
||||||
|
"""
|
||||||
|
vk_url = f"https://vk.com/video{owner_id}_{video_id}"
|
||||||
|
max_bytes = max_mb * 1024 * 1024
|
||||||
|
|
||||||
|
# yt-dlp работает синхронно — запускаем в пуле потоков
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
mp4_url = await loop.run_in_executor(None, _ytdlp_extract_url, vk_url, max_mb)
|
||||||
|
if not mp4_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_chrome_ua = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=300,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": _chrome_ua, "Referer": "https://vk.com/"},
|
||||||
|
) as client:
|
||||||
|
async with client.stream("GET", mp4_url) as r:
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"[VK] Видео HTTP {r.status_code}: {mp4_url[:80]}")
|
||||||
|
return None
|
||||||
|
ct = r.headers.get("content-type", "video/mp4").split(";")[0].strip()
|
||||||
|
# Если content-type не видео — значит получили HTML, не файл
|
||||||
|
if "video" not in ct and "octet" not in ct:
|
||||||
|
print(f"[VK] Неверный content-type видео: {ct}")
|
||||||
|
return None
|
||||||
|
declared = int(r.headers.get("content-length", 0))
|
||||||
|
if declared and declared > max_bytes:
|
||||||
|
print(f"[VK] Видео слишком большое: {declared/1024/1024:.0f}MB > {max_mb}MB, пропускаем")
|
||||||
|
return None
|
||||||
|
chunks, total = [], 0
|
||||||
|
async for chunk in r.aiter_bytes(1024 * 256):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
print(f"[VK] Видео превысило {max_mb}MB при скачивании, пропускаем")
|
||||||
|
return None
|
||||||
|
chunks.append(chunk)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
object_name = f"vk/videos/{uuid.uuid4().hex}.mp4"
|
||||||
|
mc = _minio_client()
|
||||||
|
_ensure_bucket(mc)
|
||||||
|
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type="video/mp4")
|
||||||
|
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||||
|
print(f"[VK] Видео загружено в MinIO: {object_name} ({total/1024/1024:.1f}MB)")
|
||||||
|
if db is not None:
|
||||||
|
from bd.models import Media
|
||||||
|
db.add(Media(
|
||||||
|
filename=object_name.split("/")[-1],
|
||||||
|
url=public_url,
|
||||||
|
media_type="video",
|
||||||
|
size_bytes=len(data),
|
||||||
|
))
|
||||||
|
return public_url
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK] Ошибка скачивания видео {vk_url}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── VK content helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _best_photo(sizes: list) -> str | None:
|
||||||
|
for t in ("w", "z", "y", "x", "m", "s"):
|
||||||
|
for s in sizes:
|
||||||
|
if s.get("type") == t:
|
||||||
|
return s["url"]
|
||||||
|
return sizes[-1]["url"] if sizes else None
|
||||||
|
|
||||||
|
|
||||||
|
def _text_to_html(text: str) -> str:
|
||||||
|
parts = []
|
||||||
|
for line in text.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
line = _URL_RE.sub(r'<a href="\1" target="_blank">\1</a>', line)
|
||||||
|
parts.append(f"<p>{line}</p>")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title(text: str, ts: int) -> str:
|
||||||
|
for line in text.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
return line[:200]
|
||||||
|
return "ВКонтакте " + datetime.utcfromtimestamp(ts).strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def _vk_slug(owner_id: int, post_id: int) -> str:
|
||||||
|
return f"vk-{abs(owner_id)}-{post_id}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _slug_exists(slug: str, db: AsyncSession) -> bool:
|
||||||
|
r = await db.execute(select(Article.id).where(Article.slug == slug))
|
||||||
|
return r.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_category(name: str, db: AsyncSession) -> Category:
|
||||||
|
s = slugify(name)
|
||||||
|
cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none()
|
||||||
|
if not cat:
|
||||||
|
cat = Category(name=name, slug=s)
|
||||||
|
db.add(cat)
|
||||||
|
await db.flush()
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_tag(name: str, db: AsyncSession) -> Tag:
|
||||||
|
s = slugify(name)
|
||||||
|
tag = (await db.execute(select(Tag).where(Tag.slug == s))).scalar_one_or_none()
|
||||||
|
if not tag:
|
||||||
|
tag = Tag(name=name, slug=s)
|
||||||
|
db.add(tag)
|
||||||
|
await db.flush()
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
# ── Post processing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _process_post(post: dict, category_name: str, db: AsyncSession) -> bool:
|
||||||
|
owner_id = post["owner_id"]
|
||||||
|
post_id = post["id"]
|
||||||
|
vk_slug = _vk_slug(owner_id, post_id)
|
||||||
|
|
||||||
|
if await _slug_exists(vk_slug, db):
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = post.get("text", "")
|
||||||
|
attachments = post.get("attachments", [])
|
||||||
|
ts = post["date"]
|
||||||
|
|
||||||
|
# VK Clips и некоторые видео-посты: post.text пустой, описание лежит в video.description
|
||||||
|
if not text:
|
||||||
|
for att in attachments:
|
||||||
|
if att.get("type") == "video":
|
||||||
|
desc = att["video"].get("description", "").strip()
|
||||||
|
if desc:
|
||||||
|
text = desc
|
||||||
|
break
|
||||||
|
|
||||||
|
cover_url = None
|
||||||
|
content_parts = []
|
||||||
|
|
||||||
|
if text:
|
||||||
|
content_parts.append(_text_to_html(text))
|
||||||
|
|
||||||
|
for att in attachments:
|
||||||
|
att_type = att.get("type")
|
||||||
|
|
||||||
|
if att_type == "photo":
|
||||||
|
url = _best_photo(att["photo"].get("sizes", []))
|
||||||
|
if url:
|
||||||
|
minio_url = await _upload_from_url(url, db=db)
|
||||||
|
final_url = minio_url or url # fallback на оригинальный URL если MinIO недоступен
|
||||||
|
if cover_url is None:
|
||||||
|
cover_url = final_url
|
||||||
|
else:
|
||||||
|
content_parts.append(
|
||||||
|
f'<p><img src="{final_url}" style="max-width:100%;border-radius:8px;"></p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif att_type == "video":
|
||||||
|
vid = att["video"]
|
||||||
|
player = vid.get("player")
|
||||||
|
vtitle = vid.get("title", "Видео")
|
||||||
|
vid_id = vid.get("id")
|
||||||
|
vid_oid = vid.get("owner_id")
|
||||||
|
vk_video_url = (
|
||||||
|
f"https://vk.com/video{vid_oid}_{vid_id}"
|
||||||
|
if vid_id and vid_oid else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Кадр превью из wall.get (запасной)
|
||||||
|
thumb_url = None
|
||||||
|
for src_key in ("image", "first_frame"):
|
||||||
|
frames = vid.get(src_key, [])
|
||||||
|
if frames:
|
||||||
|
best = max(frames, key=lambda f: f.get("width", 0))
|
||||||
|
thumb_url = best.get("url")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Улучшенный кадр превью из video.get (+ может вернуть player при scope=video)
|
||||||
|
if vid_id and vid_oid:
|
||||||
|
vinfo = await _fetch_video_info(vid_oid, vid_id)
|
||||||
|
if vinfo.get("thumb_url"):
|
||||||
|
thumb_url = vinfo["thumb_url"]
|
||||||
|
|
||||||
|
# Всегда скачиваем превью в MinIO
|
||||||
|
minio_thumb = None
|
||||||
|
if thumb_url:
|
||||||
|
minio_thumb = await _upload_from_url(thumb_url, db=db)
|
||||||
|
final_thumb = minio_thumb or thumb_url
|
||||||
|
if cover_url is None:
|
||||||
|
cover_url = final_thumb
|
||||||
|
else:
|
||||||
|
final_thumb = None
|
||||||
|
|
||||||
|
# Скачиваем само видео через yt-dlp → MinIO
|
||||||
|
minio_video_url = None
|
||||||
|
if vid_id and vid_oid:
|
||||||
|
minio_video_url = await _download_vk_video(vid_oid, vid_id, db=db)
|
||||||
|
|
||||||
|
if minio_video_url:
|
||||||
|
# 1. Видео лежит у нас — нативный <video>
|
||||||
|
poster_attr = f' poster="{final_thumb}"' if final_thumb else ""
|
||||||
|
content_parts.append(
|
||||||
|
f'<div style="margin:1em 0">'
|
||||||
|
f'<video src="{minio_video_url}"{poster_attr} controls '
|
||||||
|
f'style="max-width:100%;border-radius:8px;display:block;">'
|
||||||
|
f'</video>'
|
||||||
|
f'<p><em>{vtitle}</em></p></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif vk_video_url:
|
||||||
|
# 2. Fallback: превью + ссылка на ВК (yt-dlp не смог)
|
||||||
|
if final_thumb:
|
||||||
|
content_parts.append(
|
||||||
|
f'<p><a href="{vk_video_url}" target="_blank" rel="noopener">'
|
||||||
|
f'<img src="{final_thumb}" alt="{vtitle}" '
|
||||||
|
f'style="max-width:100%;border-radius:8px;display:block;">'
|
||||||
|
f'</a></p>'
|
||||||
|
f'<p>▶ <a href="{vk_video_url}" target="_blank" rel="noopener">'
|
||||||
|
f'{vtitle}</a></p>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content_parts.append(
|
||||||
|
f'<p>▶ <a href="{vk_video_url}" target="_blank" rel="noopener">'
|
||||||
|
f'{vtitle}</a></p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif att_type == "link":
|
||||||
|
href = att["link"].get("url", "")
|
||||||
|
label = att["link"].get("title") or href
|
||||||
|
if href:
|
||||||
|
content_parts.append(f'<p>🔗 <a href="{href}" target="_blank">{label}</a></p>')
|
||||||
|
|
||||||
|
content = _sanitize("\n".join(content_parts))
|
||||||
|
if not content.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
title = _extract_title(text, ts)
|
||||||
|
title_slug = slugify(title)
|
||||||
|
final_slug = title_slug if not await _slug_exists(title_slug, db) else vk_slug
|
||||||
|
|
||||||
|
# Категория = название группы
|
||||||
|
category = await _get_or_create_category(category_name, db)
|
||||||
|
|
||||||
|
# Теги по ключевым словам
|
||||||
|
tag_names = _detect_tags(text)
|
||||||
|
tags = [await _get_or_create_tag(n, db) for n in tag_names]
|
||||||
|
|
||||||
|
status = ArticleStatus.published if VK_IMPORT_AS == "published" else ArticleStatus.draft
|
||||||
|
# Храним naive UTC — колонка TIMESTAMP WITHOUT TIME ZONE
|
||||||
|
created_at = datetime.utcfromtimestamp(ts)
|
||||||
|
published_at = created_at if status == ArticleStatus.published else None
|
||||||
|
|
||||||
|
source_url = f"https://vk.com/wall{owner_id}_{post_id}"
|
||||||
|
|
||||||
|
article = Article(
|
||||||
|
title=title,
|
||||||
|
slug=final_slug,
|
||||||
|
content=content,
|
||||||
|
excerpt=text[:300].strip(),
|
||||||
|
cover_url=cover_url,
|
||||||
|
source_url=source_url,
|
||||||
|
status=status,
|
||||||
|
published_at=published_at,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=created_at,
|
||||||
|
category_id=category.id,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
db.add(article)
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── VK API fetch ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _fetch_posts(group_id: str, count: int) -> list:
|
||||||
|
# Если токен есть — официальный API, иначе — HTML-парсинг
|
||||||
|
if VK_TOKEN:
|
||||||
|
return await _fetch_posts_api(group_id, count)
|
||||||
|
return await _fetch_posts_html(group_id, count)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_posts_api(group_id: str, count: int, offset: int = 0) -> list:
|
||||||
|
owner_id = f"-{group_id.lstrip('-')}"
|
||||||
|
params = {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"count": count,
|
||||||
|
"offset": offset,
|
||||||
|
"filter": "owner",
|
||||||
|
"access_token": VK_TOKEN,
|
||||||
|
"v": VK_VERSION,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.get(f"{VK_API}/wall.get", params=params)
|
||||||
|
data = r.json()
|
||||||
|
if "error" in data:
|
||||||
|
print(f"[VK API] Ошибка группы {group_id}: {data['error'].get('error_msg')}")
|
||||||
|
return []
|
||||||
|
items = data.get("response", {}).get("items", [])
|
||||||
|
print(f"[VK API] Группа {group_id} offset={offset}: {len(items)} постов")
|
||||||
|
return items
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK API] Сетевая ошибка группы {group_id}: {exc}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_posts_html(group_id: str, count: int) -> list:
|
||||||
|
from vk_scraper import fetch_group_posts_html
|
||||||
|
posts = await fetch_group_posts_html(group_id, count)
|
||||||
|
print(f"[VK scraper] Группа {group_id}: {len(posts)} постов")
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_import() -> dict:
|
||||||
|
"""Импортирует только новые посты: пагинирует каждую группу и останавливается,
|
||||||
|
когда весь батч состоит из уже сохранённых постов."""
|
||||||
|
mode = "API" if VK_TOKEN else "HTML-scraper"
|
||||||
|
imported = skipped = errors = 0
|
||||||
|
batch = 100
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
sources = (await db.execute(
|
||||||
|
select(VkSource).where(VkSource.enabled == True)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
if not sources:
|
||||||
|
print("[VK] Нет активных источников")
|
||||||
|
return {"ok": False, "reason": "Нет активных VK-источников"}
|
||||||
|
|
||||||
|
print(f"[VK] ▶ Импорт новых постов | групп: {len(sources)} | режим: {mode}")
|
||||||
|
|
||||||
|
for i, source in enumerate(sources, 1):
|
||||||
|
src_offset = 0
|
||||||
|
grp_new = grp_skip = grp_err = 0
|
||||||
|
print(f"[VK] [{i}/{len(sources)}] Группа «{source.group_name}» (id={source.group_id})")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if VK_TOKEN:
|
||||||
|
posts = await _fetch_posts_api(source.group_id, batch, src_offset)
|
||||||
|
else:
|
||||||
|
posts = await _fetch_posts_html(source.group_id, batch)
|
||||||
|
|
||||||
|
if not posts:
|
||||||
|
print(f"[VK] offset={src_offset}: постов нет, завершаем группу")
|
||||||
|
break
|
||||||
|
|
||||||
|
batch_new = 0
|
||||||
|
for post in posts:
|
||||||
|
try:
|
||||||
|
ok = await _process_post(post, source.group_name, db)
|
||||||
|
if ok:
|
||||||
|
imported += 1; grp_new += 1; batch_new += 1
|
||||||
|
else:
|
||||||
|
skipped += 1; grp_skip += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK] ✗ Пост {post.get('id')}: {exc}")
|
||||||
|
errors += 1; grp_err += 1
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
print(f"[VK] offset={src_offset}: получено {len(posts)}, "
|
||||||
|
f"новых +{batch_new}, пропущено {len(posts)-batch_new}")
|
||||||
|
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
|
||||||
|
if batch_new == 0 or len(posts) < batch or not VK_TOKEN:
|
||||||
|
print(f"[VK] Догнали до уже импортированных, останавливаем группу")
|
||||||
|
break
|
||||||
|
|
||||||
|
src_offset += batch
|
||||||
|
|
||||||
|
print(f"[VK] ✓ «{source.group_name}»: +{grp_new} новых, {grp_skip} пропущено, {grp_err} ошибок")
|
||||||
|
source.last_run = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print(f"[VK] ■ Импорт завершён: +{imported} новых, {skipped} уже было, {errors} ошибок")
|
||||||
|
return {"ok": True, "imported": imported, "skipped": skipped, "errors": errors, "mode": mode}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_history_import(group_id: str | None = None, since_days: int = 365) -> dict:
|
||||||
|
"""Пагинированный импорт постов за последние since_days дней."""
|
||||||
|
from datetime import timedelta
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=since_days)).timestamp()
|
||||||
|
cutoff_str = datetime.utcfromtimestamp(cutoff).strftime("%d.%m.%Y")
|
||||||
|
mode = "API" if VK_TOKEN else "HTML-scraper"
|
||||||
|
imported = skipped = errors = 0
|
||||||
|
batch = 100
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
q = select(VkSource).where(VkSource.enabled == True)
|
||||||
|
if group_id:
|
||||||
|
q = q.where(VkSource.group_id == group_id)
|
||||||
|
sources = (await db.execute(q)).scalars().all()
|
||||||
|
|
||||||
|
if not sources:
|
||||||
|
print("[VK] Источники не найдены")
|
||||||
|
return {"ok": False, "reason": "Источники не найдены"}
|
||||||
|
|
||||||
|
print(f"[VK] ▶ Исторический импорт | с {cutoff_str} | групп: {len(sources)} | режим: {mode}")
|
||||||
|
|
||||||
|
for i, source in enumerate(sources, 1):
|
||||||
|
src_offset = 0
|
||||||
|
grp_new = grp_skip = grp_err = 0
|
||||||
|
print(f"[VK] [{i}/{len(sources)}] Группа «{source.group_name}» (id={source.group_id})")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if VK_TOKEN:
|
||||||
|
posts = await _fetch_posts_api(source.group_id, batch, src_offset)
|
||||||
|
else:
|
||||||
|
posts = await _fetch_posts_html(source.group_id, batch)
|
||||||
|
|
||||||
|
if not posts:
|
||||||
|
print(f"[VK] offset={src_offset}: постов нет, завершаем группу")
|
||||||
|
break
|
||||||
|
|
||||||
|
hit_cutoff = False
|
||||||
|
batch_new = 0
|
||||||
|
for post in posts:
|
||||||
|
post_date = datetime.utcfromtimestamp(post.get("date", 0)).strftime("%d.%m.%Y")
|
||||||
|
if post.get("date", 0) < cutoff:
|
||||||
|
hit_cutoff = True
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ok = await _process_post(post, source.group_name, db)
|
||||||
|
if ok:
|
||||||
|
imported += 1; grp_new += 1; batch_new += 1
|
||||||
|
else:
|
||||||
|
skipped += 1; grp_skip += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK] ✗ Пост {post.get('id')} ({post_date}): {exc}")
|
||||||
|
errors += 1; grp_err += 1
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
oldest = datetime.utcfromtimestamp(posts[-1].get("date", 0)).strftime("%d.%m.%Y")
|
||||||
|
print(f"[VK] offset={src_offset}: получено {len(posts)}, "
|
||||||
|
f"новых +{batch_new}, пропущено {grp_skip}, самый старый: {oldest}"
|
||||||
|
+ (" ← достигли года" if hit_cutoff else ""))
|
||||||
|
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
|
if hit_cutoff or len(posts) < batch or not VK_TOKEN:
|
||||||
|
break
|
||||||
|
|
||||||
|
src_offset += batch
|
||||||
|
|
||||||
|
print(f"[VK] ✓ «{source.group_name}»: +{grp_new} новых, {grp_skip} уже было, {grp_err} ошибок")
|
||||||
|
source.last_run = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print(f"[VK] ■ Исторический импорт завершён: +{imported} новых, {skipped} уже было, {errors} ошибок")
|
||||||
|
return {"ok": True, "imported": imported, "skipped": skipped, "errors": errors, "mode": mode}
|
||||||
179
api/vk_scraper.py
Normal file
179
api/vk_scraper.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Парсинг публичных групп ВКонтакте без API-ключа.
|
||||||
|
Использует мобильную версию m.vk.com — простой HTML без JS-рендеринга.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
# Имитируем мобильный браузер
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Linux; Android 13; Pixel 7) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
}
|
||||||
|
|
||||||
|
_BASE = "https://m.vk.com"
|
||||||
|
|
||||||
|
# Разбираем русские сокращения дат вида "14 мая в 10:35" или "вчера в 18:00"
|
||||||
|
_MONTHS = {
|
||||||
|
"янв": 1, "фев": 2, "мар": 3, "апр": 4, "май": 5, "маи": 5,
|
||||||
|
"июн": 6, "июл": 7, "авг": 8, "сен": 9, "окт": 10, "ноя": 11, "дек": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vk_date(text: str) -> datetime:
|
||||||
|
"""Конвертирует строку даты VK в datetime UTC."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
text = text.strip().lower()
|
||||||
|
|
||||||
|
if "сегодня" in text or "today" in text:
|
||||||
|
time_match = re.search(r"(\d{1,2}):(\d{2})", text)
|
||||||
|
if time_match:
|
||||||
|
return now.replace(hour=int(time_match[1]), minute=int(time_match[2]), second=0, microsecond=0)
|
||||||
|
return now
|
||||||
|
|
||||||
|
if "вчера" in text or "yesterday" in text:
|
||||||
|
yesterday = now - timedelta(days=1)
|
||||||
|
time_match = re.search(r"(\d{1,2}):(\d{2})", text)
|
||||||
|
if time_match:
|
||||||
|
return yesterday.replace(hour=int(time_match[1]), minute=int(time_match[2]), second=0, microsecond=0)
|
||||||
|
return yesterday
|
||||||
|
|
||||||
|
# "14 мая в 10:35" или "14 мая 2023 в 10:35"
|
||||||
|
match = re.search(r"(\d{1,2})\s+([а-яё]+)(?:\s+(\d{4}))?(?:\s+в\s+(\d{1,2}):(\d{2}))?", text)
|
||||||
|
if match:
|
||||||
|
day = int(match[1])
|
||||||
|
month = _MONTHS.get(match[2][:3], 1)
|
||||||
|
year = int(match[3]) if match[3] else now.year
|
||||||
|
hour = int(match[4]) if match[4] else 12
|
||||||
|
minute= int(match[5]) if match[5] else 0
|
||||||
|
try:
|
||||||
|
return datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_group_posts_html(group_id: str, count: int = 20) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Скачивает и парсит посты публичной группы ВКонтакте.
|
||||||
|
group_id — числовой ID без минуса (например "12345").
|
||||||
|
Возвращает список словарей совместимых с форматом vk_parser.
|
||||||
|
"""
|
||||||
|
url = f"{_BASE}/wall-{group_id}"
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
headers=_HEADERS,
|
||||||
|
timeout=20,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"[VK scraper] HTTP {r.status_code} для группы {group_id}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
items = soup.select("div.wall_item, div._post, article.post")
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
# Попробуем запасной селектор
|
||||||
|
items = soup.select("[id^='post-']")
|
||||||
|
|
||||||
|
for item in items[:count]:
|
||||||
|
post = _parse_post(item, group_id)
|
||||||
|
if post:
|
||||||
|
posts.append(post)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[VK scraper] Ошибка группы {group_id}: {exc}")
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_post(item, group_id: str) -> dict | None:
|
||||||
|
"""Парсит один пост из HTML-элемента."""
|
||||||
|
# ── ID поста ──────────────────────────────────────────────────────────────
|
||||||
|
post_id = None
|
||||||
|
elem_id = item.get("id", "")
|
||||||
|
id_match = re.search(r"(\d+)_(\d+)$", elem_id)
|
||||||
|
if id_match:
|
||||||
|
post_id = int(id_match[2])
|
||||||
|
else:
|
||||||
|
# Ищем ссылку на пост вида /wall-12345_67890
|
||||||
|
link = item.select_one("a[href*='wall-']")
|
||||||
|
if link:
|
||||||
|
lm = re.search(r"wall-?\d+_(\d+)", link["href"])
|
||||||
|
if lm:
|
||||||
|
post_id = int(lm[1])
|
||||||
|
|
||||||
|
if not post_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
owner_id = int(group_id)
|
||||||
|
|
||||||
|
# ── Текст ─────────────────────────────────────────────────────────────────
|
||||||
|
text_el = (
|
||||||
|
item.select_one(".pi_text")
|
||||||
|
or item.select_one(".wall_post_text")
|
||||||
|
or item.select_one("._post_content")
|
||||||
|
or item.select_one(".post_text")
|
||||||
|
)
|
||||||
|
text = text_el.get_text(separator="\n").strip() if text_el else ""
|
||||||
|
|
||||||
|
# ── Дата ──────────────────────────────────────────────────────────────────
|
||||||
|
date_el = item.select_one(".pi_date, time, .post_date, ._date")
|
||||||
|
ts = int(datetime.now(timezone.utc).timestamp())
|
||||||
|
if date_el:
|
||||||
|
dt = _parse_vk_date(date_el.get_text())
|
||||||
|
ts = int(dt.timestamp())
|
||||||
|
|
||||||
|
# ── Изображения ───────────────────────────────────────────────────────────
|
||||||
|
attachments = []
|
||||||
|
for img in item.select("img.pi_img, .wall_post_img img, img._photo_img"):
|
||||||
|
src = img.get("src") or img.get("data-src") or ""
|
||||||
|
if src and "userapi.com" in src or "vk.com" in src:
|
||||||
|
attachments.append({"type": "photo", "photo": {"sizes": [{"type": "x", "url": src}]}})
|
||||||
|
|
||||||
|
# ── Видео ─────────────────────────────────────────────────────────────────
|
||||||
|
for video_link in item.select("a[href*='/video']"):
|
||||||
|
href = video_link.get("href", "")
|
||||||
|
vid_match = re.search(r"/video(-?\d+)_(\d+)", href)
|
||||||
|
if vid_match:
|
||||||
|
oid = vid_match[1]
|
||||||
|
vid = vid_match[2]
|
||||||
|
player = f"https://vk.com/video_ext.php?oid={oid}&id={vid}"
|
||||||
|
title = video_link.get_text(strip=True) or "Видео"
|
||||||
|
attachments.append({"type": "video", "video": {"player": player, "title": title}})
|
||||||
|
|
||||||
|
# ── Ссылки ────────────────────────────────────────────────────────────────
|
||||||
|
for a in item.select("a.pi_snippet, a._snippet"):
|
||||||
|
href = a.get("href", "")
|
||||||
|
label = a.select_one(".pi_snippet_title, .snippet_title")
|
||||||
|
if href and not href.startswith("/"):
|
||||||
|
attachments.append({
|
||||||
|
"type": "link",
|
||||||
|
"link": {"url": href, "title": label.get_text(strip=True) if label else href},
|
||||||
|
})
|
||||||
|
|
||||||
|
if not text and not attachments:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": post_id,
|
||||||
|
"owner_id": -owner_id,
|
||||||
|
"date": ts,
|
||||||
|
"text": text,
|
||||||
|
"attachments": attachments,
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
22
web-react/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# ── Шаг 1: сборка основного React-приложения ──────────────────────────────────
|
||||||
|
FROM node:20-alpine AS app-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY web-react/package*.json ./
|
||||||
|
RUN npm ci --prefer-offline
|
||||||
|
COPY web-react/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Шаг 2: сборка редактора статей (TipTap) ───────────────────────────────────
|
||||||
|
FROM node:20-alpine AS editor-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY web/editor-ui/package*.json ./
|
||||||
|
RUN npm ci --prefer-offline
|
||||||
|
COPY web/editor-ui/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Шаг 3: nginx раздаёт оба приложения ───────────────────────────────────────
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=app-build /build/dist /usr/share/nginx/html/app
|
||||||
|
COPY --from=editor-build /build/dist /usr/share/nginx/html/editor
|
||||||
|
COPY web-react/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
15
web-react/index.html
Normal file
15
web-react/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Новости колледжей</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
web-react/nginx.conf
Normal file
32
web-react/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
# Главное React-приложение
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html/app;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Редактор статей (отдельный React-билд)
|
||||||
|
location /editor/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri /editor/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Статические ассеты редактора (Vite base: '/editor-assets/')
|
||||||
|
location /editor-assets/ {
|
||||||
|
alias /usr/share/nginx/html/editor/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проксируем API-запросы на FastAPI
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# Отключаем буферизацию для быстрого ответа
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
}
|
||||||
2667
web-react/package-lock.json
generated
Normal file
2667
web-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web-react/package.json
Normal file
22
web-react/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "news-react",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web-react/postcss.config.js
Normal file
6
web-react/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
42
web-react/src/App.jsx
Normal file
42
web-react/src/App.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from './hooks/useAuth'
|
||||||
|
import { useTheme } from './hooks/useTheme'
|
||||||
|
|
||||||
|
import NewsFeed from './pages/NewsFeed'
|
||||||
|
import NewsDetail from './pages/NewsDetail'
|
||||||
|
import Login from './pages/admin/Login'
|
||||||
|
import Dashboard from './pages/admin/Dashboard'
|
||||||
|
import ArticlesList from './pages/admin/ArticlesList'
|
||||||
|
import ArticleEditor from './pages/admin/ArticleEditor'
|
||||||
|
import Categories from './pages/admin/Categories'
|
||||||
|
import MediaLibrary from './pages/admin/MediaLibrary'
|
||||||
|
import VkSources from './pages/admin/VkSources'
|
||||||
|
|
||||||
|
function PrivateRoute({ children }) {
|
||||||
|
const { isAuth } = useAuth()
|
||||||
|
return isAuth ? children : <Navigate to="/admin/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
useTheme() // инициализация темы при старте
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Публичные */}
|
||||||
|
<Route path="/" element={<NewsFeed />} />
|
||||||
|
<Route path="/news/:slug" element={<NewsDetail />} />
|
||||||
|
|
||||||
|
{/* Админ */}
|
||||||
|
<Route path="/admin/login" element={<Login />} />
|
||||||
|
<Route path="/admin" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
||||||
|
<Route path="/admin/articles" element={<PrivateRoute><ArticlesList /></PrivateRoute>} />
|
||||||
|
<Route path="/admin/articles/:id" element={<PrivateRoute><ArticleEditor /></PrivateRoute>} />
|
||||||
|
<Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
|
||||||
|
<Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} />
|
||||||
|
<Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
web-react/src/api/index.js
Normal file
112
web-react/src/api/index.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Все запросы к FastAPI идут через /api (nginx проксирует на api:8000)
|
||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
// Базовая функция запроса
|
||||||
|
async function req(method, path, token, body, isForm = false) {
|
||||||
|
const headers = {}
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
if (!isForm) headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: isForm ? body : (body !== undefined ? JSON.stringify(body) : undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error(text || res.statusText)
|
||||||
|
}
|
||||||
|
// 204 No Content — не парсим JSON
|
||||||
|
if (res.status === 204) return true
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Публичные эндпоинты ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getNews = (params = {}) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params).forEach(([k, v]) => { if (v != null) q.set(k, v) })
|
||||||
|
return req('GET', `news?${q}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getArticle = slug => req('GET', `news/${slug}`)
|
||||||
|
|
||||||
|
export const getCategories = () => req('GET', 'news/categories')
|
||||||
|
|
||||||
|
export const getTags = (limit = 30) => req('GET', `news/tags?limit=${limit}`)
|
||||||
|
|
||||||
|
// ── Авторизация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const login = (username, password) =>
|
||||||
|
req('POST', 'admin/login', null, { username, password })
|
||||||
|
|
||||||
|
// ── Статьи (админ) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const adminCreateArticle = (token, title = 'Новая статья') =>
|
||||||
|
req('POST', 'admin/articles', token, { title, content: '', status: 'draft' })
|
||||||
|
|
||||||
|
export const adminListArticles = (token, params = {}) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params).forEach(([k, v]) => { if (v != null) q.set(k, v) })
|
||||||
|
return req('GET', `admin/articles?${q}`, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminDeleteArticle = (token, id) =>
|
||||||
|
req('DELETE', `admin/articles/${id}`, token)
|
||||||
|
|
||||||
|
export const adminPublishArticle = (token, id) =>
|
||||||
|
req('POST', `admin/articles/${id}/publish`, token)
|
||||||
|
|
||||||
|
// ── Категории (админ) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const adminListCategories = token => req('GET', 'admin/categories', token)
|
||||||
|
|
||||||
|
export const adminCreateCategory = (token, name) =>
|
||||||
|
req('POST', 'admin/categories', token, { name })
|
||||||
|
|
||||||
|
export const adminUpdateCategory = (token, id, name) =>
|
||||||
|
req('PUT', `admin/categories/${id}`, token, { name })
|
||||||
|
|
||||||
|
export const adminDeleteCategory = (token, id) =>
|
||||||
|
req('DELETE', `admin/categories/${id}`, token)
|
||||||
|
|
||||||
|
// ── Медиа (админ) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const adminListMedia = (token, { type, offset = 0, limit = 40 } = {}) => {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
if (type) p.set('media_type', type)
|
||||||
|
p.set('offset', offset)
|
||||||
|
p.set('limit', limit)
|
||||||
|
return req('GET', `admin/media?${p}`, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminUploadMedia = (token, file) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
return req('POST', 'admin/media/upload', token, fd, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminDeleteMedia = (token, id) =>
|
||||||
|
req('DELETE', `admin/media/${id}`, token)
|
||||||
|
|
||||||
|
// ── VK источники (админ) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const adminVkStatus = token => req('GET', 'admin/vk/status', token)
|
||||||
|
|
||||||
|
export const adminVkListSources = token => req('GET', 'admin/vk/sources', token)
|
||||||
|
|
||||||
|
export const adminVkAddSource = (token, group_id, group_name, enabled = true) =>
|
||||||
|
req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled })
|
||||||
|
|
||||||
|
export const adminVkUpdateSource = (token, id, group_id, group_name, enabled) =>
|
||||||
|
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled })
|
||||||
|
|
||||||
|
export const adminVkDeleteSource = (token, id) =>
|
||||||
|
req('DELETE', `admin/vk/sources/${id}`, token)
|
||||||
|
|
||||||
|
export const adminVkImport = token =>
|
||||||
|
req('POST', 'admin/vk/import', token)
|
||||||
|
|
||||||
|
export const adminVkImportHistory = (token, sourceId) =>
|
||||||
|
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}`, token)
|
||||||
532
web-react/src/app.css
Normal file
532
web-react/src/app.css
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
КОМПОНЕНТЫ — стили зависят от переменных theme.css
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
/* ── Кнопки ── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--r-btn);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity .15s, background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: .88; }
|
||||||
|
.btn:active { opacity: .75; }
|
||||||
|
|
||||||
|
.btn-primary { background: var(--c-primary); color: #fff; }
|
||||||
|
.btn-danger { background: var(--c-error); color: #fff; }
|
||||||
|
.btn-ghost { background: transparent; color: var(--c-text-1); border: 1px solid var(--c-border); }
|
||||||
|
.btn-sm { padding: 5px 11px; font-size: 0.8rem; }
|
||||||
|
.btn-icon { padding: 8px; }
|
||||||
|
|
||||||
|
/* ── Чипы / фильтры ── */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text-2);
|
||||||
|
transition: background .15s, color .15s, border-color .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip--ghost { background: transparent; }
|
||||||
|
.chip--active { background: var(--c-primary); color: #fff; border-color: var(--c-primary); }
|
||||||
|
.chip--primary { background: rgba(var(--c-primary-rgb),.12); color: var(--c-primary); border-color: transparent; }
|
||||||
|
.chip__count { font-size: 0.72rem; opacity: .7; }
|
||||||
|
|
||||||
|
/* ── Спиннер ── */
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 3px solid var(--c-border);
|
||||||
|
border-top-color: var(--c-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Модальное окно ── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Карточка новости ── */
|
||||||
|
.news-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--c-card);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: transform .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
.news-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.14);
|
||||||
|
}
|
||||||
|
.news-card__cover { height: 175px; overflow: hidden; background: var(--c-border); flex-shrink: 0; }
|
||||||
|
.news-card__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.news-card__cover-placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, var(--c-border), var(--c-bg)); }
|
||||||
|
.news-card__body { padding: 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||||
|
.news-card__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.news-card__date { font-size: 0.76rem; color: var(--c-text-2); margin-left: auto; }
|
||||||
|
.news-card__title { font-family: 'Merriweather', serif; font-size: 0.95rem; font-weight: 700; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.news-card__tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.news-card__source { margin-top: auto; display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--c-text-2); }
|
||||||
|
|
||||||
|
/* ── Лента новостей ── */
|
||||||
|
.news-feed { max-width: 680px; margin: 0 auto; padding: 0 1rem 3rem; }
|
||||||
|
|
||||||
|
/* ── VK-стиль: список постов ── */
|
||||||
|
.news-list { display: flex; flex-direction: column; gap: 12px; margin-top: 1rem; }
|
||||||
|
|
||||||
|
.vk-card {
|
||||||
|
background: var(--c-card);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow .18s;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.vk-card:hover { box-shadow: var(--shadow-panel); }
|
||||||
|
|
||||||
|
.vk-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.vk-card__source-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.vk-card__avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--c-primary-rgb), .15);
|
||||||
|
color: var(--c-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vk-card__category { font-weight: 600; font-size: 0.9rem; color: var(--c-text-1); }
|
||||||
|
.vk-card__date { font-size: 0.78rem; color: var(--c-text-2); margin-top: 1px; }
|
||||||
|
.vk-card__source-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--c-text-2);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vk-card__source-link:hover { color: var(--c-primary); }
|
||||||
|
|
||||||
|
.vk-card__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--c-text-1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.vk-card__excerpt {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--c-text-2);
|
||||||
|
line-height: 1.55;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vk-card__img-wrap {
|
||||||
|
margin: 10px -16px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
.vk-card__img-wrap--expanded { max-height: none; }
|
||||||
|
.vk-card__img-wrap img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: zoom-in;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
.vk-card__img-wrap--expanded img { cursor: zoom-out; object-fit: unset; }
|
||||||
|
|
||||||
|
.vk-card__tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
|
||||||
|
.vk-card__footer { display: flex; align-items: center; justify-content: flex-end; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--c-border); }
|
||||||
|
.vk-card__read { font-size: 0.82rem; color: var(--c-primary); font-weight: 500; }
|
||||||
|
|
||||||
|
.public-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
}
|
||||||
|
.public-header__inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.public-header__logo { font-weight: 700; font-size: 1.1rem; color: var(--c-primary); flex: 1; }
|
||||||
|
.public-header__theme { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px; }
|
||||||
|
|
||||||
|
/* Строка поиска в шапке */
|
||||||
|
.search-bar-wrap {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--c-bg);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.search-bar:focus-within {
|
||||||
|
border-color: var(--c-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--c-primary-rgb), .12);
|
||||||
|
}
|
||||||
|
.search-bar__icon { color: var(--c-text-2); flex-shrink: 0; }
|
||||||
|
.search-bar__input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--c-text-1);
|
||||||
|
}
|
||||||
|
.search-bar__input::placeholder { color: var(--c-text-2); }
|
||||||
|
.search-bar__clear {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--c-text-2);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.search-bar__clear:hover { color: var(--c-text-1); }
|
||||||
|
|
||||||
|
.search-results-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--c-text-2);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar { padding: 1rem 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.news-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination { display: flex; align-items: center; gap: 0.75rem; justify-content: center; margin-top: 2rem; }
|
||||||
|
.pagination__info { font-size: 0.85rem; color: var(--c-text-2); }
|
||||||
|
|
||||||
|
/* ── Детальная страница ── */
|
||||||
|
.article-page { max-width: 780px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||||
|
.article-cover { width: 100%; max-height: 420px; object-fit: cover; border-radius: var(--r-card); margin-bottom: 1.5rem; }
|
||||||
|
.article-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 1.25rem; color: var(--c-text-2); font-size: 0.85rem; }
|
||||||
|
.article-title { font-family: 'Merriweather', serif; font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 700; line-height: 1.3; margin-bottom: 1.5rem; }
|
||||||
|
.article-body { line-height: 1.8; font-size: 1.0625rem; }
|
||||||
|
.article-body img { max-width: 100%; border-radius: var(--r-card); }
|
||||||
|
.article-body h2, .article-body h3 { margin: 1.5rem 0 .75rem; font-family: 'Merriweather', serif; }
|
||||||
|
.article-source { display: flex; align-items: center; gap: 8px; padding: 12px 16px; background: rgba(var(--c-primary-rgb),.07); border: 1px solid rgba(var(--c-primary-rgb),.2); border-radius: var(--r-card); font-size: 0.875rem; color: var(--c-primary); margin-top: 2rem; }
|
||||||
|
.article-source a { color: var(--c-primary); text-decoration: underline; word-break: break-all; }
|
||||||
|
.article-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 1.5rem; }
|
||||||
|
.back-btn { display: inline-flex; align-items: center; gap: 6px; color: var(--c-primary); cursor: pointer; background: none; border: none; font-size: 0.875rem; padding: 0; margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
/* ── Админ layout ── */
|
||||||
|
.admin-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--c-sidebar);
|
||||||
|
color: var(--c-sidebar-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar__logo {
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.sidebar__close { display: none; background: none; border: none; color: inherit; cursor: pointer; font-size: 1.1rem; }
|
||||||
|
.sidebar__nav { flex: 1; padding: 0.75rem 0; }
|
||||||
|
.sidebar__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 1rem;
|
||||||
|
color: var(--c-sidebar-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: .8;
|
||||||
|
transition: background .15s, opacity .15s;
|
||||||
|
}
|
||||||
|
.sidebar__link:hover { background: rgba(255,255,255,.07); opacity: 1; }
|
||||||
|
.sidebar__link--active { background: var(--c-sidebar-active); opacity: 1; font-weight: 600; }
|
||||||
|
.sidebar__logout {
|
||||||
|
margin: 0.75rem;
|
||||||
|
padding: 10px 1rem;
|
||||||
|
background: rgba(255,255,255,.07);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-btn);
|
||||||
|
color: var(--c-sidebar-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.sidebar-overlay { display: none; }
|
||||||
|
|
||||||
|
.admin-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.admin-topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
}
|
||||||
|
.topbar__burger { display: none; background: none; border: none; cursor: pointer; font-size: 1.4rem; padding: 4px; color: var(--c-text-1); }
|
||||||
|
.topbar__title { font-size: 1.1rem; font-weight: 600; flex: 1; }
|
||||||
|
.topbar__theme { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px; }
|
||||||
|
.admin-content { padding: 1.5rem; flex: 1; }
|
||||||
|
|
||||||
|
/* ── Таблица ── */
|
||||||
|
.table-wrap { overflow-x: auto; background: var(--c-surface); border-radius: var(--r-card); box-shadow: var(--shadow-card); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||||
|
th { padding: 10px 14px; text-align: left; font-weight: 600; color: var(--c-text-2); border-bottom: 2px solid var(--c-border); white-space: nowrap; }
|
||||||
|
td { padding: 10px 14px; border-bottom: 1px solid var(--c-border); vertical-align: middle; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(var(--c-primary-rgb),.04); }
|
||||||
|
.td-actions { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
/* ── Форма / Input ── */
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.form-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-2); }
|
||||||
|
.form-input, .form-select {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text-1);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.form-input:focus, .form-select:focus { border-color: var(--c-primary); }
|
||||||
|
|
||||||
|
/* ── Карточки статистики (дашборд) ── */
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.stat-card { background: var(--c-surface); border-radius: var(--r-card); padding: 1.25rem; box-shadow: var(--shadow-card); }
|
||||||
|
.stat-card__value { font-size: 2rem; font-weight: 700; color: var(--c-primary); line-height: 1; }
|
||||||
|
.stat-card__label { font-size: 0.8rem; color: var(--c-text-2); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── Страница входа ── */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--c-bg);
|
||||||
|
}
|
||||||
|
.login-box {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.login-box h1 { font-size: 1.5rem; font-weight: 700; text-align: center; color: var(--c-primary); }
|
||||||
|
.error-msg { color: var(--c-error); font-size: 0.85rem; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Алерт ── */
|
||||||
|
.alert { padding: 10px 14px; border-radius: var(--r-card); font-size: 0.875rem; margin-bottom: 1rem; }
|
||||||
|
.alert-success { background: rgba(61,196,126,.12); color: var(--c-success); border: 1px solid var(--c-success); }
|
||||||
|
.alert-error { background: rgba(230,70,70,.10); color: var(--c-error); border: 1px solid var(--c-error); }
|
||||||
|
|
||||||
|
/* ── Медиа галерея ── */
|
||||||
|
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||||
|
.media-item { background: var(--c-surface); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-card); }
|
||||||
|
.media-item img { width: 100%; height: 120px; object-fit: cover; display: block; }
|
||||||
|
.media-item__info { padding: 8px; font-size: 0.75rem; color: var(--c-text-2); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.media-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
|
||||||
|
|
||||||
|
/* ── Toggle-кнопка (активен/неактивен) ── */
|
||||||
|
.toggle-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: transform .15s, opacity .15s;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
.toggle-btn:hover { transform: scale(1.2); opacity: 1; }
|
||||||
|
|
||||||
|
/* ── VK ── */
|
||||||
|
.vk-status-bar { background: var(--c-surface); border-radius: var(--r-card); padding: 1rem 1.25rem; box-shadow: var(--shadow-card); margin-bottom: 1.25rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.vk-status__dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.dot-green { background: var(--c-success); }
|
||||||
|
.dot-red { background: var(--c-error); }
|
||||||
|
.dot-yellow { background: var(--c-warning); }
|
||||||
|
|
||||||
|
/* ── Модалка статьи ── */
|
||||||
|
.article-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.6);
|
||||||
|
z-index: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2vh 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: fadeIn .18s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
|
||||||
|
.article-modal {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 780px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,.35);
|
||||||
|
animation: slideUp .22s ease;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
@keyframes slideUp { from { transform: translateY(24px); opacity: 0 } to { transform: none; opacity: 1 } }
|
||||||
|
|
||||||
|
.article-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0,0,0,.18);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.article-modal__close:hover { background: rgba(0,0,0,.4); }
|
||||||
|
|
||||||
|
.article-modal__cover {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 380px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--r-card) var(--r-card) 0 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-modal__body {
|
||||||
|
padding: 1.75rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Адаптив (мобилки) ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: -260px;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 200;
|
||||||
|
transition: left .25s;
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
.sidebar--open { left: 0; }
|
||||||
|
.sidebar__close { display: block; }
|
||||||
|
.sidebar-overlay { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 199; }
|
||||||
|
.topbar__burger { display: block; }
|
||||||
|
|
||||||
|
.admin-content { padding: 1rem; }
|
||||||
|
.news-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 10px; }
|
||||||
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
61
web-react/src/components/AdminLayout.jsx
Normal file
61
web-react/src/components/AdminLayout.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ to: '/admin', label: 'Дашборд', icon: '🏠', end: true },
|
||||||
|
{ to: '/admin/articles', label: 'Статьи', icon: '📰' },
|
||||||
|
{ to: '/admin/categories', label: 'Категории', icon: '🏷️' },
|
||||||
|
{ to: '/admin/media', label: 'Медиа', icon: '🖼️' },
|
||||||
|
{ to: '/admin/vk', label: 'ВКонтакте', icon: '📥' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminLayout({ children, title }) {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const { dark, toggle } = useTheme()
|
||||||
|
const [sideOpen, setSideOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-layout">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={`sidebar ${sideOpen ? 'sidebar--open' : ''}`}>
|
||||||
|
<div className="sidebar__logo">
|
||||||
|
<span>📋 Новости</span>
|
||||||
|
<button className="sidebar__close" onClick={() => setSideOpen(false)}>✕</button>
|
||||||
|
</div>
|
||||||
|
<nav className="sidebar__nav">
|
||||||
|
{NAV.map(n => (
|
||||||
|
<NavLink
|
||||||
|
key={n.to}
|
||||||
|
to={n.to}
|
||||||
|
end={n.end}
|
||||||
|
className={({ isActive }) => `sidebar__link ${isActive ? 'sidebar__link--active' : ''}`}
|
||||||
|
onClick={() => setSideOpen(false)}
|
||||||
|
>
|
||||||
|
<span>{n.icon}</span> {n.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<button className="sidebar__logout" onClick={logout}>⬅️ Выйти</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Overlay for mobile */}
|
||||||
|
{sideOpen && <div className="sidebar-overlay" onClick={() => setSideOpen(false)} />}
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<div className="admin-main">
|
||||||
|
<header className="admin-topbar">
|
||||||
|
<button className="topbar__burger" onClick={() => setSideOpen(true)}>☰</button>
|
||||||
|
<h1 className="topbar__title">{title}</h1>
|
||||||
|
<button className="topbar__theme" onClick={toggle} title="Сменить тему">
|
||||||
|
{dark ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<main className="admin-content">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
web-react/src/components/ArticleModal.jsx
Normal file
69
web-react/src/components/ArticleModal.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
|
||||||
|
function fmt(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticleModal({ article, onClose }) {
|
||||||
|
// Закрытие по Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = e => { if (e.key === 'Escape') onClose() }
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
// Блокируем скролл страницы под модалкой
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handler)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="article-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="article-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="article-modal__close" onClick={onClose} aria-label="Закрыть">✕</button>
|
||||||
|
|
||||||
|
{article.cover_url && (
|
||||||
|
<img className="article-modal__cover" src={article.cover_url} alt={article.title} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="article-modal__body">
|
||||||
|
<div className="article-meta">
|
||||||
|
{article.category && (
|
||||||
|
<span className="chip chip--primary">{article.category.name}</span>
|
||||||
|
)}
|
||||||
|
<span>{fmt(article.published_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="article-title">{article.title}</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="article-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.content ?? '' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{article.tags?.length > 0 && (
|
||||||
|
<div className="article-tags">
|
||||||
|
{article.tags.map(t => (
|
||||||
|
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{article.source_url && (
|
||||||
|
<div className="article-source">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
|
</svg>
|
||||||
|
<span>Источник:</span>
|
||||||
|
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{article.source_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
web-react/src/components/Confirm.jsx
Normal file
15
web-react/src/components/Confirm.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function Confirm({ message, onOk, onCancel }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
|
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
||||||
|
<p style={{ marginBottom: '1.5rem', color: 'var(--c-text-1)' }}>{message}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-ghost" onClick={onCancel}>Отмена</button>
|
||||||
|
<button className="btn btn-danger" onClick={onOk}>Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
web-react/src/components/FilterBar.jsx
Normal file
56
web-react/src/components/FilterBar.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const DATE_PRESETS = [
|
||||||
|
{ value: '', label: 'Всё время' },
|
||||||
|
{ value: 'week', label: 'Неделя' },
|
||||||
|
{ value: 'month', label: 'Месяц' },
|
||||||
|
{ value: '3months',label: '3 месяца' },
|
||||||
|
{ value: 'year', label: 'Год' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function FilterBar({ categories, tags, filters, onChange }) {
|
||||||
|
const set = key => val => onChange({ ...filters, [key]: val, page: 1 })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-bar">
|
||||||
|
{/* Категории */}
|
||||||
|
<div className="filter-row">
|
||||||
|
<button
|
||||||
|
className={`chip ${!filters.category ? 'chip--active' : 'chip--ghost'}`}
|
||||||
|
onClick={() => set('category')(null)}
|
||||||
|
>Все</button>
|
||||||
|
{categories.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className={`chip ${filters.category === c.slug ? 'chip--active' : 'chip--ghost'}`}
|
||||||
|
onClick={() => set('category')(c.slug)}
|
||||||
|
>{c.name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Теги */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="filter-row">
|
||||||
|
{tags.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`chip ${filters.tag === t.slug ? 'chip--active' : 'chip--ghost'}`}
|
||||||
|
onClick={() => set('tag')(filters.tag === t.slug ? null : t.slug)}
|
||||||
|
>#{t.name} <span className="chip__count">{t.count}</span></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Даты */}
|
||||||
|
<div className="filter-row">
|
||||||
|
{DATE_PRESETS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
className={`chip ${(filters.datePreset ?? '') === p.value ? 'chip--active' : 'chip--ghost'}`}
|
||||||
|
onClick={() => set('datePreset')(p.value)}
|
||||||
|
>{p.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
web-react/src/components/Loader.jsx
Normal file
13
web-react/src/components/Loader.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function Loader({ full = false }) {
|
||||||
|
const style = full
|
||||||
|
? { display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }
|
||||||
|
: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
web-react/src/components/NewsCard.jsx
Normal file
82
web-react/src/components/NewsCard.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
function fmt(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function domain(url) {
|
||||||
|
try { return new URL(url).hostname.replace('www.', '') }
|
||||||
|
catch { return url }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsCard({ article, onClick }) {
|
||||||
|
const { title, cover_url, excerpt, category, published_at, tags = [], source_url } = article
|
||||||
|
const [imgExpanded, setImgExpanded] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="vk-card" onClick={onClick}>
|
||||||
|
{/* Шапка: категория + дата */}
|
||||||
|
<div className="vk-card__header">
|
||||||
|
<div className="vk-card__source-info">
|
||||||
|
<div className="vk-card__avatar">
|
||||||
|
{category?.name?.[0]?.toUpperCase() ?? '📰'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="vk-card__category">{category?.name ?? 'Новости'}</div>
|
||||||
|
<div className="vk-card__date">{fmt(published_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{source_url && (
|
||||||
|
<a
|
||||||
|
className="vk-card__source-link"
|
||||||
|
href={source_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
title="Источник"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
{domain(source_url)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<h3 className="vk-card__title">{title}</h3>
|
||||||
|
|
||||||
|
{/* Превью текста */}
|
||||||
|
{excerpt && <p className="vk-card__excerpt">{excerpt}</p>}
|
||||||
|
|
||||||
|
{/* Обложка */}
|
||||||
|
{cover_url && (
|
||||||
|
<div className={`vk-card__img-wrap ${imgExpanded ? 'vk-card__img-wrap--expanded' : ''}`}>
|
||||||
|
<img
|
||||||
|
src={cover_url}
|
||||||
|
alt={title}
|
||||||
|
loading="lazy"
|
||||||
|
onClick={e => { e.stopPropagation(); setImgExpanded(v => !v) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Теги */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="vk-card__tags" onClick={e => e.stopPropagation()}>
|
||||||
|
{tags.map(t => (
|
||||||
|
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Подвал */}
|
||||||
|
<div className="vk-card__footer">
|
||||||
|
<span className="vk-card__read">Читать полностью →</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
web-react/src/components/StatusBadge.jsx
Normal file
25
web-react/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const MAP = {
|
||||||
|
published: { label: 'Опубликовано', color: 'var(--c-success)' },
|
||||||
|
draft: { label: 'Черновик', color: 'var(--c-warning)' },
|
||||||
|
archived: { label: 'Архив', color: 'var(--c-text-2)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }) {
|
||||||
|
const { label, color } = MAP[status] ?? { label: status, color: 'var(--c-text-2)' }
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: 'var(--r-chip)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
background: color + '22',
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}55`,
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
web-react/src/hooks/useAuth.js
Normal file
21
web-react/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
// Токен хранится в localStorage — сохраняется при перезагрузке
|
||||||
|
export function useAuth() {
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem('token') || '')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const saveToken = t => {
|
||||||
|
localStorage.setItem('token', t)
|
||||||
|
setToken(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
setToken('')
|
||||||
|
navigate('/admin/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, saveToken, logout, isAuth: !!token }
|
||||||
|
}
|
||||||
13
web-react/src/hooks/useTheme.js
Normal file
13
web-react/src/hooks/useTheme.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
// Тёмная тема: переключает атрибут data-theme на <html>
|
||||||
|
export function useTheme() {
|
||||||
|
const [dark, setDark] = useState(() => localStorage.getItem('theme') === 'dark')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||||
|
localStorage.setItem('theme', dark ? 'dark' : 'light')
|
||||||
|
}, [dark])
|
||||||
|
|
||||||
|
return { dark, toggle: () => setDark(d => !d) }
|
||||||
|
}
|
||||||
12
web-react/src/main.jsx
Normal file
12
web-react/src/main.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './theme.css'
|
||||||
|
import './app.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
77
web-react/src/pages/NewsDetail.jsx
Normal file
77
web-react/src/pages/NewsDetail.jsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { getArticle } from '../api/index'
|
||||||
|
import Loader from '../components/Loader'
|
||||||
|
|
||||||
|
function fmt(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsDetail() {
|
||||||
|
const { slug } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [article, setArticle] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getArticle(slug)
|
||||||
|
.then(setArticle)
|
||||||
|
.catch(() => setError('Статья не найдена'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
if (loading) return <Loader full />
|
||||||
|
if (error) return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||||
|
<p style={{ color: 'var(--c-text-2)' }}>{error}</p>
|
||||||
|
<button className="btn btn-ghost" style={{ marginTop: '1rem' }} onClick={() => navigate('/')}>
|
||||||
|
← На главную
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="article-page">
|
||||||
|
<button className="back-btn" onClick={() => navigate(-1)}>← Назад</button>
|
||||||
|
|
||||||
|
{article.cover_url && (
|
||||||
|
<img className="article-cover" src={article.cover_url} alt={article.title} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="article-meta">
|
||||||
|
{article.category && <span className="chip chip--primary">{article.category.name}</span>}
|
||||||
|
<span>{fmt(article.published_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="article-title">{article.title}</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="article-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.body ?? '' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{article.tags?.length > 0 && (
|
||||||
|
<div className="article-tags">
|
||||||
|
{article.tags.map(t => (
|
||||||
|
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{article.source_url && (
|
||||||
|
<div className="article-source">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
|
</svg>
|
||||||
|
<span>Источник:</span>
|
||||||
|
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{article.source_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
web-react/src/pages/NewsFeed.jsx
Normal file
190
web-react/src/pages/NewsFeed.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
import { getNews, getCategories, getTags, getArticle } from '../api/index'
|
||||||
|
import NewsCard from '../components/NewsCard'
|
||||||
|
import FilterBar from '../components/FilterBar'
|
||||||
|
import Loader from '../components/Loader'
|
||||||
|
import ArticleModal from '../components/ArticleModal'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
function presetToDateFrom(preset) {
|
||||||
|
if (!preset) return null
|
||||||
|
const now = new Date()
|
||||||
|
const days = { week: 7, month: 30, '3months': 90, year: 365 }[preset]
|
||||||
|
if (!days) return null
|
||||||
|
now.setDate(now.getDate() - days)
|
||||||
|
return now.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsFeed() {
|
||||||
|
const { dark, toggle } = useTheme()
|
||||||
|
const [articles, setArticles] = useState([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [tags, setTags] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '', page: 1 })
|
||||||
|
|
||||||
|
// Поиск с debounce
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const debounceRef = useRef(null)
|
||||||
|
|
||||||
|
const handleSearch = e => {
|
||||||
|
const val = e.target.value
|
||||||
|
setSearchInput(val)
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setSearchQuery(val.trim())
|
||||||
|
setFilters(f => ({ ...f, page: 1 }))
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchInput('')
|
||||||
|
setSearchQuery('')
|
||||||
|
setFilters(f => ({ ...f, page: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модальное окно
|
||||||
|
const [modalArticle, setModalArticle] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([getCategories(), getTags(30)]).then(([cats, tgs]) => {
|
||||||
|
setCategories(cats)
|
||||||
|
setTags(tgs)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadNews = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: (filters.page - 1) * PAGE_SIZE,
|
||||||
|
}
|
||||||
|
if (filters.category) params.category = filters.category
|
||||||
|
if (filters.tag) params.tag = filters.tag
|
||||||
|
const df = presetToDateFrom(filters.datePreset)
|
||||||
|
if (df) params.date_from = df
|
||||||
|
if (searchQuery) params.q = searchQuery
|
||||||
|
|
||||||
|
const data = await getNews(params)
|
||||||
|
setArticles(data.items ?? [])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
} catch {
|
||||||
|
setArticles([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [filters, searchQuery])
|
||||||
|
|
||||||
|
useEffect(() => { loadNews() }, [loadNews])
|
||||||
|
|
||||||
|
const openArticle = async (slug) => {
|
||||||
|
setModalArticle({ _loading: true })
|
||||||
|
try {
|
||||||
|
const data = await getArticle(slug)
|
||||||
|
setModalArticle(data)
|
||||||
|
} catch {
|
||||||
|
setModalArticle(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="public-header">
|
||||||
|
<div className="public-header__inner">
|
||||||
|
<span className="public-header__logo">📰 Новости колледжей</span>
|
||||||
|
<button className="public-header__theme" onClick={toggle} title="Тема">
|
||||||
|
{dark ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Строка поиска */}
|
||||||
|
<div className="search-bar-wrap">
|
||||||
|
<div className="search-bar">
|
||||||
|
<svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="search-bar__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по заголовку, тексту, тегам..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={handleSearch}
|
||||||
|
/>
|
||||||
|
{searchInput && (
|
||||||
|
<button className="search-bar__clear" onClick={clearSearch}>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="news-feed">
|
||||||
|
<FilterBar
|
||||||
|
categories={categories}
|
||||||
|
tags={tags}
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Счётчик результатов при поиске */}
|
||||||
|
{searchQuery && !loading && (
|
||||||
|
<p className="search-results-hint">
|
||||||
|
{total > 0
|
||||||
|
? `Найдено: ${total} ${total === 1 ? 'новость' : total < 5 ? 'новости' : 'новостей'} по запросу «${searchQuery}»`
|
||||||
|
: `По запросу «${searchQuery}» ничего не найдено`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading
|
||||||
|
? <Loader />
|
||||||
|
: articles.length === 0
|
||||||
|
? <p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
|
||||||
|
Новостей не найдено
|
||||||
|
</p>
|
||||||
|
: <div className="news-list">
|
||||||
|
{articles.map(a => (
|
||||||
|
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{!loading && totalPages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
disabled={filters.page <= 1}
|
||||||
|
onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}
|
||||||
|
>← Назад</button>
|
||||||
|
<span className="pagination__info">{filters.page} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
disabled={filters.page >= totalPages}
|
||||||
|
onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
|
||||||
|
>Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модальное окно статьи */}
|
||||||
|
{modalArticle && (
|
||||||
|
modalArticle._loading
|
||||||
|
? (
|
||||||
|
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
|
||||||
|
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
web-react/src/pages/admin/ArticleEditor.jsx
Normal file
29
web-react/src/pages/admin/ArticleEditor.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
|
||||||
|
export default function ArticleEditor() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { token } = useAuth()
|
||||||
|
|
||||||
|
const editorUrl = `/editor/${id}?t=${token}`
|
||||||
|
|
||||||
|
// Слушаем сообщение «закрыть» из iframe-редактора
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = e => {
|
||||||
|
if (e.data?.type === 'editor-close') navigate('/admin/articles')
|
||||||
|
}
|
||||||
|
window.addEventListener('message', handler)
|
||||||
|
return () => window.removeEventListener('message', handler)
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={editorUrl}
|
||||||
|
style={{ position: 'fixed', inset: 0, width: '100%', height: '100%', border: 'none', zIndex: 300 }}
|
||||||
|
title="Редактор статьи"
|
||||||
|
allow="fullscreen"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
web-react/src/pages/admin/ArticlesList.jsx
Normal file
162
web-react/src/pages/admin/ArticlesList.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { adminListArticles, adminDeleteArticle, adminPublishArticle, adminCreateArticle } from '../../api/index'
|
||||||
|
import AdminLayout from '../../components/AdminLayout'
|
||||||
|
import StatusBadge from '../../components/StatusBadge'
|
||||||
|
import Confirm from '../../components/Confirm'
|
||||||
|
import Loader from '../../components/Loader'
|
||||||
|
|
||||||
|
const PAGE = 20
|
||||||
|
|
||||||
|
function fmt(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticlesList() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [confirm, setConfirm] = useState(null) // { id, title }
|
||||||
|
|
||||||
|
const createNew = async () => {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const article = await adminCreateArticle(token)
|
||||||
|
navigate(`/admin/articles/${article.id}`)
|
||||||
|
} catch {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce поиска 300ms
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => { setSearchQuery(searchInput); setPage(1) }, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [searchInput])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = { limit: PAGE, offset: (page - 1) * PAGE }
|
||||||
|
if (status) params.status = status
|
||||||
|
if (searchQuery) params.q = searchQuery
|
||||||
|
const data = await adminListArticles(token, params)
|
||||||
|
setItems(data?.items ?? [])
|
||||||
|
setTotal(data?.total ?? 0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [token, page, status, searchQuery])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (!confirm) return
|
||||||
|
await adminDeleteArticle(token, confirm.id).catch(() => {})
|
||||||
|
setConfirm(null)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const doPublish = async id => {
|
||||||
|
await adminPublishArticle(token, id).catch(() => {})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Статьи">
|
||||||
|
{confirm && (
|
||||||
|
<Confirm
|
||||||
|
message={`Удалить «${confirm.title}»?`}
|
||||||
|
onOk={doDelete}
|
||||||
|
onCancel={() => setConfirm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Тулбар */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<button className="btn btn-primary" onClick={createNew} disabled={creating}>
|
||||||
|
{creating ? 'Создаю...' : '✏️ Написать новость'}
|
||||||
|
</button>
|
||||||
|
<div style={{ width: 1, height: 28, background: 'var(--c-border)', margin: '0 4px' }} />
|
||||||
|
{['', 'published', 'draft'].map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
className={`btn btn-sm ${status === s ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
onClick={() => { setStatus(s); setPage(1) }}
|
||||||
|
>
|
||||||
|
{s === '' ? 'Все' : s === 'published' ? 'Опубликованные' : 'Черновики'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div style={{ width: 1, height: 28, background: 'var(--c-border)', margin: '0 4px' }} />
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
style={{ width: 220, fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '0.85rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
|
||||||
|
Всего: {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <Loader /> : (
|
||||||
|
<>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Заголовок</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(a => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td style={{ maxWidth: 340, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{a.title}
|
||||||
|
</td>
|
||||||
|
<td><StatusBadge status={a.status} /></td>
|
||||||
|
<td style={{ color: 'var(--c-text-2)', fontSize: '0.82rem' }}>
|
||||||
|
{fmt(a.published_at ?? a.created_at)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="td-actions">
|
||||||
|
<Link to={`/admin/articles/${a.id}`} className="btn btn-ghost btn-sm">✏️</Link>
|
||||||
|
{a.status !== 'published' && (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => doPublish(a.id)}>✅</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ id: a.id, title: a.title })}>🗑</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="pagination" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="btn btn-ghost btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>← Назад</button>
|
||||||
|
<span className="pagination__info">{page} / {totalPages}</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
web-react/src/pages/admin/Categories.jsx
Normal file
126
web-react/src/pages/admin/Categories.jsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { adminListCategories, adminCreateCategory, adminUpdateCategory, adminDeleteCategory } from '../../api/index'
|
||||||
|
import AdminLayout from '../../components/AdminLayout'
|
||||||
|
import Confirm from '../../components/Confirm'
|
||||||
|
import Loader from '../../components/Loader'
|
||||||
|
|
||||||
|
export default function Categories() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [cats, setCats] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [editing, setEditing] = useState(null) // { id, name }
|
||||||
|
const [confirm, setConfirm] = useState(null) // { id, name }
|
||||||
|
const [alert, setAlert] = useState(null)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try { setCats(await adminListCategories(token)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [token])
|
||||||
|
|
||||||
|
const flash = (type, msg) => {
|
||||||
|
setAlert({ type, msg })
|
||||||
|
setTimeout(() => setAlert(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doCreate = async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newName.trim()) return
|
||||||
|
try {
|
||||||
|
await adminCreateCategory(token, newName.trim())
|
||||||
|
setNewName('')
|
||||||
|
flash('success', 'Категория создана')
|
||||||
|
load()
|
||||||
|
} catch { flash('error', 'Ошибка создания') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doUpdate = async () => {
|
||||||
|
if (!editing) return
|
||||||
|
try {
|
||||||
|
await adminUpdateCategory(token, editing.id, editing.name)
|
||||||
|
setEditing(null)
|
||||||
|
flash('success', 'Сохранено')
|
||||||
|
load()
|
||||||
|
} catch { flash('error', 'Ошибка сохранения') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (!confirm) return
|
||||||
|
try {
|
||||||
|
await adminDeleteCategory(token, confirm.id)
|
||||||
|
setConfirm(null)
|
||||||
|
flash('success', 'Удалено')
|
||||||
|
load()
|
||||||
|
} catch { flash('error', 'Ошибка удаления') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Категории">
|
||||||
|
{confirm && (
|
||||||
|
<Confirm
|
||||||
|
message={`Удалить категорию «${confirm.name}»?`}
|
||||||
|
onOk={doDelete}
|
||||||
|
onCancel={() => setConfirm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{alert && <div className={`alert alert-${alert.type}`}>{alert.msg}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={doCreate} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', maxWidth: 480 }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Название новой категории"
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" type="submit" style={{ flexShrink: 0 }}>Добавить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading ? <Loader /> : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Название</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cats.map(c => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>
|
||||||
|
{editing?.id === c.id
|
||||||
|
? <input
|
||||||
|
className="form-input"
|
||||||
|
value={editing.name}
|
||||||
|
onChange={e => setEditing(ed => ({ ...ed, name: e.target.value }))}
|
||||||
|
style={{ maxWidth: 300 }}
|
||||||
|
/>
|
||||||
|
: c.name
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="td-actions">
|
||||||
|
{editing?.id === c.id
|
||||||
|
? <>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={doUpdate}>Сохранить</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(null)}>Отмена</button>
|
||||||
|
</>
|
||||||
|
: <>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setEditing({ id: c.id, name: c.name })}>✏️</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ id: c.id, name: c.name })}>🗑</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
web-react/src/pages/admin/Dashboard.jsx
Normal file
85
web-react/src/pages/admin/Dashboard.jsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { adminListArticles } from '../../api/index'
|
||||||
|
import AdminLayout from '../../components/AdminLayout'
|
||||||
|
import StatusBadge from '../../components/StatusBadge'
|
||||||
|
import Loader from '../../components/Loader'
|
||||||
|
|
||||||
|
function fmt(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [stats, setStats] = useState({ published: 0, drafts: 0, total: 0 })
|
||||||
|
const [recent, setRecent] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
adminListArticles(token, { limit: 1, status: 'published' }),
|
||||||
|
adminListArticles(token, { limit: 1, status: 'draft' }),
|
||||||
|
adminListArticles(token, { limit: 8 }),
|
||||||
|
]).then(([pub, draft, all]) => {
|
||||||
|
setStats({
|
||||||
|
published: pub?.total ?? 0,
|
||||||
|
drafts: draft?.total ?? 0,
|
||||||
|
total: all?.total ?? 0,
|
||||||
|
})
|
||||||
|
setRecent(all?.items ?? [])
|
||||||
|
}).catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Дашборд">
|
||||||
|
{loading ? <Loader /> : (
|
||||||
|
<>
|
||||||
|
<div className="stat-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card__value">{stats.total}</div>
|
||||||
|
<div className="stat-card__label">Всего статей</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card__value">{stats.published}</div>
|
||||||
|
<div className="stat-card__label">Опубликовано</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card__value">{stats.drafts}</div>
|
||||||
|
<div className="stat-card__label">Черновики</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Заголовок</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recent.map(a => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{a.title}
|
||||||
|
</td>
|
||||||
|
<td><StatusBadge status={a.status} /></td>
|
||||||
|
<td style={{ color: 'var(--c-text-2)', fontSize: '0.82rem' }}>{fmt(a.published_at ?? a.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<Link to={`/admin/articles/${a.id}`} className="btn btn-ghost btn-sm">Открыть</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
web-react/src/pages/admin/Login.jsx
Normal file
60
web-react/src/pages/admin/Login.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { login } from '../../api/index'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { saveToken } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const submit = async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await login(username, password)
|
||||||
|
saveToken(data.access_token)
|
||||||
|
navigate('/admin')
|
||||||
|
} catch (err) {
|
||||||
|
setError('Неверный логин или пароль')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<form className="login-box" onSubmit={submit}>
|
||||||
|
<h1>Вход</h1>
|
||||||
|
{error && <p className="error-msg">{error}</p>}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Логин</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Пароль</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading} style={{ width: '100%' }}>
|
||||||
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
185
web-react/src/pages/admin/MediaLibrary.jsx
Normal file
185
web-react/src/pages/admin/MediaLibrary.jsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { adminListMedia, adminUploadMedia, adminDeleteMedia } from '../../api/index'
|
||||||
|
import AdminLayout from '../../components/AdminLayout'
|
||||||
|
import Confirm from '../../components/Confirm'
|
||||||
|
import Loader from '../../components/Loader'
|
||||||
|
|
||||||
|
const PAGE = 40
|
||||||
|
|
||||||
|
function fmtSize(bytes) {
|
||||||
|
if (!bytes) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024**2) return `${(bytes/1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes/1024**2).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoThumb({ url, filename }) {
|
||||||
|
const [err, setErr] = useState(false)
|
||||||
|
if (err) return (
|
||||||
|
<div style={{ height: 140, background: '#1e293b', display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 28 }}>🎬</span>
|
||||||
|
<span style={{ maxWidth: 140, textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block', background: '#0f172a' }}
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
onError={() => setErr(true)}
|
||||||
|
onMouseEnter={e => e.target.play?.()}
|
||||||
|
onMouseLeave={e => { e.target.pause?.(); e.target.currentTime = 0 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaLibrary() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [type, setType] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [confirm, setConfirm] = useState(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [alert, setAlert] = useState(null)
|
||||||
|
const [preview, setPreview] = useState(null)
|
||||||
|
const fileRef = useRef()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminListMedia(token, { type: type || undefined, offset: (page - 1) * PAGE, limit: PAGE })
|
||||||
|
setItems(data.items ?? [])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [token, type, page])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const flash = (t, msg) => { setAlert({ type: t, msg }); setTimeout(() => setAlert(null), 3000) }
|
||||||
|
|
||||||
|
const doUpload = async e => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
await adminUploadMedia(token, file)
|
||||||
|
flash('success', 'Загружено!')
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
} catch { flash('error', 'Ошибка загрузки') }
|
||||||
|
finally { setUploading(false); e.target.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (!confirm) return
|
||||||
|
try { await adminDeleteMedia(token, confirm.id); setConfirm(null); load() }
|
||||||
|
catch { flash('error', 'Ошибка удаления') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Медиатека">
|
||||||
|
{confirm && (
|
||||||
|
<Confirm
|
||||||
|
message="Удалить файл?"
|
||||||
|
onOk={doDelete}
|
||||||
|
onCancel={() => setConfirm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{preview && (
|
||||||
|
<div
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
>
|
||||||
|
{preview.media_type === 'image'
|
||||||
|
? <img src={preview.url} alt={preview.filename}
|
||||||
|
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, boxShadow: '0 8px 48px #000' }} />
|
||||||
|
: <video src={preview.url} controls autoPlay
|
||||||
|
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, boxShadow: '0 8px 48px #000' }}
|
||||||
|
onClick={e => e.stopPropagation()} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{alert && <div className={`alert alert-${alert.type}`} style={{ marginBottom: 12 }}>{alert.msg}</div>}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{[['', 'Все'], ['image', 'Изображения'], ['video', 'Видео']].map(([val, label]) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
className={`btn btn-sm ${type === val ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
onClick={() => { setType(val); setPage(1) }}
|
||||||
|
>{label}</button>
|
||||||
|
))}
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
|
||||||
|
Всего: {total}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? 'Загружаю...' : '⬆️ Загрузить'}
|
||||||
|
</button>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*,video/*" style={{ display: 'none' }} onChange={doUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <Loader /> : items.length === 0
|
||||||
|
? <p style={{ color: 'var(--c-text-2)', textAlign: 'center', padding: '2rem' }}>Файлов нет</p>
|
||||||
|
: (
|
||||||
|
<div className="media-grid">
|
||||||
|
{items.map(m => (
|
||||||
|
<div key={m.id} className="media-item" style={{ cursor: 'pointer' }}>
|
||||||
|
<div onClick={() => setPreview(m)} style={{ position: 'relative' }}>
|
||||||
|
{m.media_type === 'image'
|
||||||
|
? <img src={m.url} alt={m.filename} style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }} />
|
||||||
|
: <VideoThumb url={m.url} filename={m.filename} />
|
||||||
|
}
|
||||||
|
{m.media_type === 'video' && (
|
||||||
|
<div style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.6)',
|
||||||
|
borderRadius: 4, padding: '2px 6px', fontSize: 10, color: '#fff' }}>▶ видео</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="media-item__info">
|
||||||
|
<span className="media-item__name" title={m.filename}>{m.filename}</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--c-text-2)' }}>{fmtSize(m.size_bytes)} · {fmtDate(m.created_at)}</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-icon btn-sm"
|
||||||
|
onClick={() => setConfirm({ id: m.id })}
|
||||||
|
style={{ padding: '2px 6px', color: 'var(--c-error)' }}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="pagination" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="btn btn-ghost btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>← Назад</button>
|
||||||
|
<span className="pagination__info">{page} / {totalPages}</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
web-react/src/pages/admin/VkSources.jsx
Normal file
217
web-react/src/pages/admin/VkSources.jsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import {
|
||||||
|
adminVkStatus, adminVkListSources,
|
||||||
|
adminVkAddSource, adminVkUpdateSource, adminVkDeleteSource,
|
||||||
|
adminVkImport, adminVkImportHistory,
|
||||||
|
} from '../../api/index'
|
||||||
|
import AdminLayout from '../../components/AdminLayout'
|
||||||
|
import Confirm from '../../components/Confirm'
|
||||||
|
import Loader from '../../components/Loader'
|
||||||
|
|
||||||
|
export default function VkSources() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [status, setStatus] = useState(null)
|
||||||
|
const [sources, setSources] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [confirm, setConfirm] = useState(null)
|
||||||
|
const [alert, setAlert] = useState(null)
|
||||||
|
const [importing, setImporting] = useState('')
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true })
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [st, srcs] = await Promise.all([
|
||||||
|
adminVkStatus(token),
|
||||||
|
adminVkListSources(token),
|
||||||
|
])
|
||||||
|
setStatus(st)
|
||||||
|
setSources(srcs)
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [token])
|
||||||
|
|
||||||
|
const flash = (type, msg) => { setAlert({ type, msg }); setTimeout(() => setAlert(null), 5000) }
|
||||||
|
|
||||||
|
const doAdd = async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled)
|
||||||
|
setForm({ group_id: '', group_name: '', enabled: true })
|
||||||
|
flash('success', 'Источник добавлен')
|
||||||
|
load()
|
||||||
|
} catch(err) { flash('error', String(err.message)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doUpdate = async () => {
|
||||||
|
if (!editing) return
|
||||||
|
try {
|
||||||
|
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled)
|
||||||
|
setEditing(null)
|
||||||
|
flash('success', 'Сохранено')
|
||||||
|
load()
|
||||||
|
} catch { flash('error', 'Ошибка') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (!confirm) return
|
||||||
|
try { await adminVkDeleteSource(token, confirm.id); setConfirm(null); load() }
|
||||||
|
catch { flash('error', 'Ошибка удаления') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doImport = async () => {
|
||||||
|
setImporting('new')
|
||||||
|
try {
|
||||||
|
const r = await adminVkImport(token)
|
||||||
|
flash('success', `Импорт завершён: +${r.imported ?? 0} новых`)
|
||||||
|
} catch { flash('error', 'Ошибка импорта') }
|
||||||
|
finally { setImporting('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doHistory = async (sourceId = null) => {
|
||||||
|
setImporting('history')
|
||||||
|
try {
|
||||||
|
const r = await adminVkImportHistory(token, sourceId)
|
||||||
|
flash('success', `История загружена: +${r.imported ?? 0} постов`)
|
||||||
|
} catch { flash('error', 'Ошибка загрузки истории') }
|
||||||
|
finally { setImporting('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusDot = () => {
|
||||||
|
if (!status) return 'dot-yellow'
|
||||||
|
if (status.configured) return 'dot-green'
|
||||||
|
return 'dot-red'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="ВКонтакте">
|
||||||
|
{confirm && <Confirm message="Удалить источник?" onOk={doDelete} onCancel={() => setConfirm(null)} />}
|
||||||
|
|
||||||
|
{alert && <div className={`alert alert-${alert.type}`}>{alert.msg}</div>}
|
||||||
|
|
||||||
|
{/* Статус */}
|
||||||
|
<div className="vk-status-bar">
|
||||||
|
<div className={`vk-status__dot ${statusDot()}`} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
||||||
|
{status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'}
|
||||||
|
</span>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={doImport}
|
||||||
|
disabled={!!importing}
|
||||||
|
>{importing === 'new' ? 'Импортирую...' : '🔄 Новые посты'}</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => doHistory(null)}
|
||||||
|
disabled={!!importing}
|
||||||
|
>{importing === 'history' ? 'Загружаю...' : '📥 История (все)'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Добавить источник */}
|
||||||
|
<form onSubmit={doAdd} style={{ background: 'var(--c-surface)', borderRadius: 'var(--r-card)', padding: '1rem', marginBottom: '1.5rem', boxShadow: 'var(--shadow-card)' }}>
|
||||||
|
<h3 style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.95rem' }}>Добавить источник</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<div className="form-group" style={{ flex: '1 1 160px' }}>
|
||||||
|
<label className="form-label">ID группы</label>
|
||||||
|
<input className="form-input" placeholder="131748668" value={form.group_id}
|
||||||
|
onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: '2 1 220px' }}>
|
||||||
|
<label className="form-label">Название</label>
|
||||||
|
<input className="form-input" placeholder="Колледж..." value={form.group_name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, group_name: e.target.value }))} required />
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.875rem', paddingBottom: 4 }}>
|
||||||
|
<input type="checkbox" checked={form.enabled}
|
||||||
|
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
|
||||||
|
Активен
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading ? <Loader /> : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID группы</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Активен</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sources.map(s => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||||
|
{editing?.id === s.id
|
||||||
|
? <input className="form-input" value={editing.group_id}
|
||||||
|
onChange={e => setEditing(ed => ({ ...ed, group_id: e.target.value }))}
|
||||||
|
style={{ width: 130 }} />
|
||||||
|
: s.group_id
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editing?.id === s.id
|
||||||
|
? <input className="form-input" value={editing.group_name}
|
||||||
|
onChange={e => setEditing(ed => ({ ...ed, group_name: e.target.value }))} />
|
||||||
|
: s.group_name
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editing?.id === s.id
|
||||||
|
? <input type="checkbox" checked={editing.enabled}
|
||||||
|
onChange={e => setEditing(ed => ({ ...ed, enabled: e.target.checked }))} />
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className="toggle-btn"
|
||||||
|
data-on={s.enabled}
|
||||||
|
title={s.enabled ? 'Отключить' : 'Включить'}
|
||||||
|
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled)
|
||||||
|
.then(load).catch(() => flash('error', 'Ошибка'))}
|
||||||
|
>
|
||||||
|
{s.enabled ? '✅' : '❌'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="td-actions">
|
||||||
|
{editing?.id === s.id
|
||||||
|
? <>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={doUpdate}>Сохранить</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(null)}>Отмена</button>
|
||||||
|
</>
|
||||||
|
: <>
|
||||||
|
<button className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setEditing({ ...s })}>✏️</button>
|
||||||
|
<button className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => doHistory(s.id)} disabled={!!importing}
|
||||||
|
title="Загрузить историю этого источника">📥</button>
|
||||||
|
<button className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => setConfirm({ id: s.id })}>🗑</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sources.length === 0 && (
|
||||||
|
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
|
||||||
|
Источников нет
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
web-react/src/theme.css
Normal file
87
web-react/src/theme.css
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
ДИЗАЙН-КОНФИГ — меняй только здесь, всё остальное обновится само
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
/* ── Шрифты Google ── */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&display=swap');
|
||||||
|
|
||||||
|
/* ── Светлая тема (по умолчанию) ── */
|
||||||
|
:root {
|
||||||
|
/* Основной цвет (синий ВК-стиль) */
|
||||||
|
--c-primary: #2787F5;
|
||||||
|
--c-primary-dark: #1565C0;
|
||||||
|
--c-primary-rgb: 39, 135, 245; /* для rgba() */
|
||||||
|
|
||||||
|
/* Фоны */
|
||||||
|
--c-bg: #F0F2F5; /* страница */
|
||||||
|
--c-surface: #FFFFFF; /* панели, шапки */
|
||||||
|
--c-card: #FFFFFF; /* карточки новостей */
|
||||||
|
|
||||||
|
/* Текст */
|
||||||
|
--c-text-1: #050505; /* основной */
|
||||||
|
--c-text-2: #818C99; /* вторичный (дата, метки) */
|
||||||
|
|
||||||
|
/* Границы и разделители */
|
||||||
|
--c-border: #E5E9EF;
|
||||||
|
|
||||||
|
/* Статусы */
|
||||||
|
--c-success: #3DC47E;
|
||||||
|
--c-warning: #FFA726;
|
||||||
|
--c-error: #E64646;
|
||||||
|
|
||||||
|
/* Боковая панель администратора */
|
||||||
|
--c-sidebar: #1A237E;
|
||||||
|
--c-sidebar-text: #E8EAF6;
|
||||||
|
--c-sidebar-active: #283593;
|
||||||
|
|
||||||
|
/* Скругления */
|
||||||
|
--r-card: 12px;
|
||||||
|
--r-chip: 6px;
|
||||||
|
--r-btn: 8px;
|
||||||
|
--r-input: 8px;
|
||||||
|
|
||||||
|
/* Тени */
|
||||||
|
--shadow-card: 0 1px 6px rgba(0,0,0,.08);
|
||||||
|
--shadow-panel: 0 2px 8px rgba(0,0,0,.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Тёмная тема ── */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--c-bg: #0F0F1A;
|
||||||
|
--c-surface: #1A1A2E;
|
||||||
|
--c-card: #22223A;
|
||||||
|
|
||||||
|
--c-text-1: #E8EAF6;
|
||||||
|
--c-text-2: #9FA8C4;
|
||||||
|
|
||||||
|
--c-border: #2D2D50;
|
||||||
|
|
||||||
|
--c-sidebar: #0D0D1F;
|
||||||
|
--c-sidebar-text: #C5CAE9;
|
||||||
|
--c-sidebar-active:#1A237E;
|
||||||
|
|
||||||
|
--shadow-card: 0 1px 6px rgba(0,0,0,.3);
|
||||||
|
--shadow-panel: 0 2px 8px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Базовые стили ── */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text-1);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
/* Плавное переключение тёмной темы */
|
||||||
|
*, *::before, *::after {
|
||||||
|
transition: background-color .2s, border-color .2s, color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скроллбар */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--c-border); border-radius: 3px; }
|
||||||
35
web-react/tailwind.config.js
Normal file
35
web-react/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
// Тёмная тема включается атрибутом data-theme="dark" на <html>
|
||||||
|
darkMode: ['selector', '[data-theme="dark"]'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
// Все цвета берутся из CSS-переменных в theme.css
|
||||||
|
// Менять цвета нужно только там
|
||||||
|
colors: {
|
||||||
|
primary: 'var(--c-primary)',
|
||||||
|
'primary-d':'var(--c-primary-dark)',
|
||||||
|
bg: 'var(--c-bg)',
|
||||||
|
surface: 'var(--c-surface)',
|
||||||
|
card: 'var(--c-card)',
|
||||||
|
't1': 'var(--c-text-1)',
|
||||||
|
't2': 'var(--c-text-2)',
|
||||||
|
border: 'var(--c-border)',
|
||||||
|
success: 'var(--c-success)',
|
||||||
|
warning: 'var(--c-warning)',
|
||||||
|
error: 'var(--c-error)',
|
||||||
|
sidebar: 'var(--c-sidebar)',
|
||||||
|
'sidebar-t':'var(--c-sidebar-text)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
ui: ['Inter', 'sans-serif'],
|
||||||
|
display: ['Merriweather', 'serif'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
card: '12px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
15
web-react/vite.config.js
Normal file
15
web-react/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
// В dev-режиме проксируем API запросы на FastAPI
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
rewrite: path => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -2,7 +2,21 @@ import os
|
|||||||
import httpx
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"", t, flags=re.IGNORECASE)
|
||||||
|
# Lists
|
||||||
|
t = re.sub(r"<li[^>]*>(.*?)</li>", r"- \1\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
t = re.sub(r"</?[uo]l[^>]*>", "\n", t, flags=re.IGNORECASE)
|
||||||
|
# Paragraphs / line breaks
|
||||||
|
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.IGNORECASE)
|
||||||
|
t = re.sub(r"<p[^>]*>(.*?)</p>", r"\1\n\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
# Strip remaining tags
|
||||||
|
t = re.sub(r"<[^>]+>", "", t)
|
||||||
|
# HTML entities
|
||||||
|
t = (t.replace(" ", " ").replace("&", "&")
|
||||||
|
.replace("<", "<").replace(">", ">").replace(""", '"'))
|
||||||
|
# Collapse excessive blank lines
|
||||||
|
t = re.sub(r"\n{3,}", "\n\n", t)
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
class NewsDetailView:
|
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(
|
||||||
|
|||||||
@@ -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
359
web/views/vk_sources.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
from views.dashboard import _admin_appbar, _sidebar
|
||||||
|
|
||||||
|
|
||||||
|
class VkSourcesView:
|
||||||
|
def __init__(self, page: ft.Page):
|
||||||
|
self.page = page
|
||||||
|
self._sources: list = []
|
||||||
|
self._api_ok = False
|
||||||
|
self._editing_id: str | None = None # ID источника в режиме редактирования
|
||||||
|
|
||||||
|
# Статус бейдж
|
||||||
|
self._status_row = ft.Row(spacing=6)
|
||||||
|
|
||||||
|
# Уведомление (добавляется в page.overlay при load)
|
||||||
|
self._snack = ft.SnackBar(content=ft.Text(""), open=False)
|
||||||
|
|
||||||
|
# Таблица источников
|
||||||
|
self._table = ft.DataTable(
|
||||||
|
columns=[
|
||||||
|
ft.DataColumn(ft.Text("Группа", weight=ft.FontWeight.W_600, size=13)),
|
||||||
|
ft.DataColumn(ft.Text("ID", weight=ft.FontWeight.W_600, size=13)),
|
||||||
|
ft.DataColumn(ft.Text("Последний импорт", weight=ft.FontWeight.W_600, size=13)),
|
||||||
|
ft.DataColumn(ft.Text("Статус", weight=ft.FontWeight.W_600, size=13)),
|
||||||
|
ft.DataColumn(ft.Text("Действия", weight=ft.FontWeight.W_600, size=13)),
|
||||||
|
],
|
||||||
|
rows=[],
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
border_radius=8,
|
||||||
|
vertical_lines=ft.BorderSide(1, d.DIVIDER),
|
||||||
|
heading_row_color=ft.colors.with_opacity(0.04, ft.colors.BLACK),
|
||||||
|
data_row_max_height=56,
|
||||||
|
column_spacing=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Форма добавления / редактирования
|
||||||
|
self._form_gid = ft.TextField(
|
||||||
|
label="ID группы (числовой)",
|
||||||
|
hint_text="95503797",
|
||||||
|
width=220,
|
||||||
|
dense=True,
|
||||||
|
)
|
||||||
|
self._form_gname = ft.TextField(
|
||||||
|
label="Название группы",
|
||||||
|
hint_text="ЮУрГК",
|
||||||
|
width=240,
|
||||||
|
dense=True,
|
||||||
|
)
|
||||||
|
self._form_enabled = ft.Checkbox(label="Активна", value=True)
|
||||||
|
self._form_save_btn = ft.ElevatedButton(
|
||||||
|
"Добавить",
|
||||||
|
icon=ft.icons.ADD,
|
||||||
|
bgcolor=d.PRIMARY,
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
on_click=lambda e: self.page.run_task(self._save_source),
|
||||||
|
)
|
||||||
|
self._form_cancel_btn = ft.TextButton(
|
||||||
|
"Отмена",
|
||||||
|
visible=False,
|
||||||
|
on_click=lambda e: self._cancel_edit(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
token_info = ft.Container(
|
||||||
|
content=ft.Column([
|
||||||
|
self._status_row,
|
||||||
|
ft.Text(
|
||||||
|
"Без токена VK API — HTML-парсинг не работает (ВК перешёл на JS-рендеринг).\n"
|
||||||
|
"Получи бесплатный сервисный ключ: vk.com/dev → Мои приложения → Создать → "
|
||||||
|
"Standalone → вкладка «Настройки» → «Сервисный ключ доступа».\n"
|
||||||
|
"Затем добавь в .env: VK_ACCESS_TOKEN=<ключ>",
|
||||||
|
size=12,
|
||||||
|
color=d.TEXT_SECONDARY,
|
||||||
|
selectable=True,
|
||||||
|
),
|
||||||
|
], spacing=6),
|
||||||
|
bgcolor=ft.colors.with_opacity(0.04, ft.colors.BLACK),
|
||||||
|
border_radius=8,
|
||||||
|
padding=14,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
)
|
||||||
|
|
||||||
|
form = ft.Container(
|
||||||
|
content=ft.Column([
|
||||||
|
d.headline("Добавить / редактировать группу", size=15),
|
||||||
|
ft.Row(
|
||||||
|
[self._form_gid, self._form_gname, self._form_enabled],
|
||||||
|
spacing=10, wrap=True,
|
||||||
|
),
|
||||||
|
ft.Row(
|
||||||
|
[self._form_save_btn, self._form_cancel_btn],
|
||||||
|
spacing=10,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
], spacing=10),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border_radius=10,
|
||||||
|
padding=16,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
)
|
||||||
|
|
||||||
|
import_btns = ft.Row([
|
||||||
|
ft.ElevatedButton(
|
||||||
|
"Импорт новых постов",
|
||||||
|
icon=ft.icons.SYNC,
|
||||||
|
bgcolor=d.PRIMARY,
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
tooltip="Загрузить новые посты со всех активных групп",
|
||||||
|
on_click=lambda e: self.page.run_task(self._import_recent),
|
||||||
|
),
|
||||||
|
ft.ElevatedButton(
|
||||||
|
"Импорт за год (все)",
|
||||||
|
icon=ft.icons.HISTORY,
|
||||||
|
bgcolor=d.WARNING,
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
tooltip="Пагинированный импорт за 365 дней. Запускается в фоне.",
|
||||||
|
on_click=lambda e: self.page.run_task(self._import_history_all),
|
||||||
|
),
|
||||||
|
], spacing=10, wrap=True)
|
||||||
|
|
||||||
|
content = ft.Column([
|
||||||
|
d.headline("ВКонтакте — источники", size=22),
|
||||||
|
token_info,
|
||||||
|
import_btns,
|
||||||
|
ft.Divider(height=4, color="transparent"),
|
||||||
|
form,
|
||||||
|
ft.Divider(height=4, color="transparent"),
|
||||||
|
d.headline("Группы в базе", size=15),
|
||||||
|
ft.Container(
|
||||||
|
content=self._table,
|
||||||
|
border_radius=8,
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
),
|
||||||
|
], scroll=ft.ScrollMode.AUTO, spacing=12, expand=True)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/admin/vk",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
appbar=_admin_appbar(self.page, "ВКонтакте"),
|
||||||
|
controls=[
|
||||||
|
ft.Row([
|
||||||
|
_sidebar(self.page, active="/admin/vk"),
|
||||||
|
ft.Container(content=content, expand=True, padding=24),
|
||||||
|
], expand=True, spacing=0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Load ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
self.page.overlay.append(self._snack)
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
status = await api.admin_vk_status(token)
|
||||||
|
self._api_ok = status.get("configured", False)
|
||||||
|
self._status_row.controls = [
|
||||||
|
ft.Icon(
|
||||||
|
ft.icons.CHECK_CIRCLE if self._api_ok else ft.icons.WARNING_AMBER_ROUNDED,
|
||||||
|
color=d.SUCCESS if self._api_ok else d.WARNING,
|
||||||
|
size=18,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
"API-токен подключён — VK API активен" if self._api_ok
|
||||||
|
else "Токен не задан — парсинг недоступен",
|
||||||
|
size=13,
|
||||||
|
weight=ft.FontWeight.W_600,
|
||||||
|
color=d.SUCCESS if self._api_ok else d.WARNING,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self._sources = await api.admin_vk_list_sources(token)
|
||||||
|
self._render_table()
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
# ── Table ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _render_table(self):
|
||||||
|
if not self._sources:
|
||||||
|
self._table.rows = [
|
||||||
|
ft.DataRow(cells=[
|
||||||
|
ft.DataCell(ft.Text("Групп пока нет", color=d.TEXT_SECONDARY, italic=True)),
|
||||||
|
ft.DataCell(ft.Text("")),
|
||||||
|
ft.DataCell(ft.Text("")),
|
||||||
|
ft.DataCell(ft.Text("")),
|
||||||
|
ft.DataCell(ft.Text("")),
|
||||||
|
])
|
||||||
|
]
|
||||||
|
return
|
||||||
|
|
||||||
|
self._table.rows = [self._build_row(s) for s in self._sources]
|
||||||
|
|
||||||
|
def _build_row(self, src: dict) -> ft.DataRow:
|
||||||
|
last_run = src.get("last_run")
|
||||||
|
if last_run:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
dt = datetime.fromisoformat(last_run.replace("Z", "+00:00"))
|
||||||
|
last_run_str = dt.strftime("%d.%m.%Y %H:%M")
|
||||||
|
except Exception:
|
||||||
|
last_run_str = last_run[:16]
|
||||||
|
else:
|
||||||
|
last_run_str = "никогда"
|
||||||
|
|
||||||
|
enabled = src.get("enabled", True)
|
||||||
|
|
||||||
|
return ft.DataRow(
|
||||||
|
cells=[
|
||||||
|
ft.DataCell(ft.Text(src["group_name"], size=13, weight=ft.FontWeight.W_500)),
|
||||||
|
ft.DataCell(ft.Text(src["group_id"], size=13, color=d.TEXT_SECONDARY, selectable=True)),
|
||||||
|
ft.DataCell(ft.Text(last_run_str, size=12, color=d.TEXT_SECONDARY)),
|
||||||
|
ft.DataCell(
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text(
|
||||||
|
"Активна" if enabled else "Отключена",
|
||||||
|
size=11,
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
),
|
||||||
|
bgcolor=d.SUCCESS if enabled else d.TEXT_SECONDARY,
|
||||||
|
border_radius=4,
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ft.DataCell(
|
||||||
|
ft.Row([
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.EDIT_OUTLINED,
|
||||||
|
icon_size=18,
|
||||||
|
icon_color=d.PRIMARY,
|
||||||
|
tooltip="Редактировать",
|
||||||
|
on_click=lambda e, s=src: self._start_edit(s),
|
||||||
|
),
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.TOGGLE_ON if enabled else ft.icons.TOGGLE_OFF,
|
||||||
|
icon_size=22,
|
||||||
|
icon_color=d.SUCCESS if enabled else d.TEXT_SECONDARY,
|
||||||
|
tooltip="Вкл / Выкл",
|
||||||
|
on_click=lambda e, s=src: self.page.run_task(self._toggle, s),
|
||||||
|
),
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.HISTORY,
|
||||||
|
icon_size=18,
|
||||||
|
icon_color=d.WARNING,
|
||||||
|
tooltip="Импорт за год для этой группы",
|
||||||
|
on_click=lambda e, s=src: self.page.run_task(
|
||||||
|
self._import_history_one, s["id"], s["group_name"]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.DELETE_OUTLINE,
|
||||||
|
icon_size=18,
|
||||||
|
icon_color=d.ERROR,
|
||||||
|
tooltip="Удалить группу",
|
||||||
|
on_click=lambda e, s=src: self.page.run_task(self._delete, s["id"]),
|
||||||
|
),
|
||||||
|
], spacing=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Edit form helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_edit(self, src: dict):
|
||||||
|
self._editing_id = src["id"]
|
||||||
|
self._form_gid.value = src["group_id"]
|
||||||
|
self._form_gname.value = src["group_name"]
|
||||||
|
self._form_enabled.value = src.get("enabled", True)
|
||||||
|
self._form_save_btn.text = "Сохранить"
|
||||||
|
self._form_save_btn.icon = ft.icons.SAVE
|
||||||
|
self._form_cancel_btn.visible = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _cancel_edit(self):
|
||||||
|
self._editing_id = None
|
||||||
|
self._form_gid.value = ""
|
||||||
|
self._form_gname.value = ""
|
||||||
|
self._form_enabled.value = True
|
||||||
|
self._form_save_btn.text = "Добавить"
|
||||||
|
self._form_save_btn.icon = ft.icons.ADD
|
||||||
|
self._form_cancel_btn.visible = False
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
# ── CRUD actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _save_source(self):
|
||||||
|
gid = self._form_gid.value.strip()
|
||||||
|
gname = self._form_gname.value.strip()
|
||||||
|
if not gid or not gname:
|
||||||
|
self._notify("Заполни ID и название", d.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
|
||||||
|
if self._editing_id:
|
||||||
|
result = await api.admin_vk_update_source(
|
||||||
|
token, self._editing_id, gid, gname, self._form_enabled.value
|
||||||
|
)
|
||||||
|
self._notify("Группа обновлена" if result else "Ошибка обновления",
|
||||||
|
d.SUCCESS if result else d.ERROR)
|
||||||
|
else:
|
||||||
|
result = await api.admin_vk_add_source(token, gid, gname, self._form_enabled.value)
|
||||||
|
if result and "error" not in result:
|
||||||
|
self._notify(f"Группа «{gname}» добавлена", d.SUCCESS)
|
||||||
|
else:
|
||||||
|
err = result.get("error") if result else "Ошибка запроса"
|
||||||
|
self._notify(str(err), d.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._cancel_edit()
|
||||||
|
self._sources = await api.admin_vk_list_sources(token)
|
||||||
|
self._render_table()
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _toggle(self, src: dict):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
result = await api.admin_vk_update_source(
|
||||||
|
token, src["id"], src["group_id"], src["group_name"], not src["enabled"]
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
self._sources = await api.admin_vk_list_sources(token)
|
||||||
|
self._render_table()
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _delete(self, source_id: str):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
ok = await api.admin_vk_delete_source(token, source_id)
|
||||||
|
self._notify("Группа удалена" if ok else "Ошибка удаления",
|
||||||
|
d.TEXT_SECONDARY if ok else d.ERROR)
|
||||||
|
self._sources = await api.admin_vk_list_sources(token)
|
||||||
|
self._render_table()
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _import_recent(self):
|
||||||
|
self._notify("Запускаю импорт новых постов…", d.PRIMARY)
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
result = await api.admin_vk_import(token)
|
||||||
|
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||||
|
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||||
|
|
||||||
|
async def _import_history_all(self):
|
||||||
|
self._notify("Запускаю исторический импорт в фоне…", d.WARNING)
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
result = await api.admin_vk_import_history(token)
|
||||||
|
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||||
|
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||||
|
|
||||||
|
async def _import_history_one(self, source_id: str, name: str):
|
||||||
|
self._notify(f"Запускаю год для «{name}»…", d.WARNING)
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
result = await api.admin_vk_import_history(token, source_id=source_id)
|
||||||
|
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||||
|
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||||
|
|
||||||
|
def _notify(self, text: str, color: str = d.SUCCESS):
|
||||||
|
self._snack.content = ft.Text(text, color=ft.colors.WHITE)
|
||||||
|
self._snack.bgcolor = color
|
||||||
|
self._snack.open = True
|
||||||
|
self.page.update()
|
||||||
Reference in New Issue
Block a user