test ferst

This commit is contained in:
jze9
2026-05-13 19:05:07 +05:00
commit 0ac1c8a862
33 changed files with 3702 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.claude/
data/
.env
.env.example

6
api/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

0
api/bd/__init__.py Normal file
View File

44
api/bd/database.py Normal file
View File

@@ -0,0 +1,44 @@
import os
import asyncio
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/news")
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=20, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
yield session
async def wait_for_db():
for attempt in range(30):
try:
async with engine.connect() as conn:
await conn.execute(sqlalchemy.text("SELECT 1"))
return
except Exception:
if attempt == 29:
raise
await asyncio.sleep(2)
import redis.asyncio as aioredis
_redis: aioredis.Redis | None = None
async def get_redis() -> aioredis.Redis:
global _redis
if _redis is None:
_redis = aioredis.from_url(REDIS_URL, encoding="utf-8", decode_responses=True)
return _redis

73
api/bd/models.py Normal file
View File

@@ -0,0 +1,73 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Table, Column, Integer, Enum as SAEnum
from sqlalchemy.orm import mapped_column, Mapped, relationship
from sqlalchemy.dialects.postgresql import UUID
import enum
from .database import Base
class ArticleStatus(str, enum.Enum):
draft = "draft"
published = "published"
article_tag = Table(
"article_tag",
Base.metadata,
Column("article_id", UUID(as_uuid=True), ForeignKey("articles.id", ondelete="CASCADE")),
Column("tag_id", UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE")),
)
class Category(Base):
__tablename__ = "categories"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), unique=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
articles: Mapped[list["Article"]] = relationship(back_populates="category", lazy="noload")
class Tag(Base):
__tablename__ = "tags"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), unique=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
class Article(Base):
__tablename__ = "articles"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(500))
slug: Mapped[str] = mapped_column(String(500), unique=True, index=True)
content: Mapped[str] = mapped_column(Text, default="")
excerpt: Mapped[str] = mapped_column(String(1000), default="")
cover_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
font_family: Mapped[str] = mapped_column(String(100), default="Merriweather")
status: Mapped[ArticleStatus] = mapped_column(SAEnum(ArticleStatus), default=ArticleStatus.draft)
view_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
category_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
category: Mapped["Category | None"] = relationship(back_populates="articles", lazy="selectin")
tags: Mapped[list["Tag"]] = relationship(secondary=article_tag, lazy="selectin")
class Admin(Base):
__tablename__ = "admins"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(100), unique=True)
password_hash: Mapped[str] = mapped_column(String(300))
class Media(Base):
__tablename__ = "media"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename: Mapped[str] = mapped_column(String(500))
url: Mapped[str] = mapped_column(String(1000))
media_type: Mapped[str] = mapped_column(String(20)) # image | video
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

82
api/main.py Normal file
View File

@@ -0,0 +1,82 @@
import os
import secrets
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.middleware.cors import CORSMiddleware
from bd.database import wait_for_db, engine, Base
from route.public import router as public_router
from route.admin_auth import router as auth_router, ensure_default_admin
from route.admin_articles import router as articles_router
from route.admin_categories import router as categories_router
from route.admin_media import router as media_router
DOCS_USER = os.getenv("DOCS_USERNAME", "admin")
DOCS_PASS = os.getenv("DOCS_PASSWORD", "admin")
_basic = HTTPBasic()
def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
ok_u = secrets.compare_digest(creds.username.encode(), DOCS_USER.encode())
ok_p = secrets.compare_digest(creds.password.encode(), DOCS_PASS.encode())
if not (ok_u and ok_p):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Basic"})
return creds.username
@asynccontextmanager
async def lifespan(app: FastAPI):
await wait_for_db()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await ensure_default_admin()
yield
app = FastAPI(
title="News API",
version="1.0.0",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Docs protected by HTTP Basic Auth
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
from fastapi.responses import JSONResponse
@app.get("/docs", include_in_schema=False)
async def docs(username: str = Depends(_verify_docs)):
return get_swagger_ui_html(openapi_url="/openapi.json", title="News API Docs")
@app.get("/redoc", include_in_schema=False)
async def redoc(username: str = Depends(_verify_docs)):
return get_redoc_html(openapi_url="/openapi.json", title="News API Redoc")
@app.get("/openapi.json", include_in_schema=False)
async def openapi(username: str = Depends(_verify_docs)):
return JSONResponse(app.openapi())
app.include_router(public_router, prefix="/news", tags=["public"])
app.include_router(auth_router, prefix="/admin", tags=["admin-auth"])
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"])
app.include_router(categories_router, prefix="/admin/categories", tags=["admin-categories"])
app.include_router(media_router, prefix="/admin/media", tags=["admin-media"])
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)

12
api/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
redis[asyncio]==5.2.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==3.2.2
python-multipart==0.0.12
minio==7.2.11
python-slugify==8.0.4
pillow==11.0.0

0
api/route/__init__.py Normal file
View File

224
api/route/admin_articles.py Normal file
View File

@@ -0,0 +1,224 @@
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import redis.asyncio as aioredis
from bd.database import get_db, get_redis
from bd.models import Article, Category, Tag, ArticleStatus
from route.deps import require_admin
router = APIRouter()
class ArticleIn(BaseModel):
title: str
slug: Optional[str] = None
content: str = ""
excerpt: str = ""
cover_url: Optional[str] = None
font_family: str = "Merriweather"
category_id: Optional[str] = None
tag_names: list[str] = []
status: ArticleStatus = ArticleStatus.draft
def _article_dict(a: Article) -> dict:
return {
"id": str(a.id),
"title": a.title,
"slug": a.slug,
"content": a.content,
"excerpt": a.excerpt,
"cover_url": a.cover_url,
"font_family": a.font_family,
"status": a.status.value,
"view_count": a.view_count,
"created_at": a.created_at.isoformat(),
"updated_at": a.updated_at.isoformat(),
"published_at": a.published_at.isoformat() if a.published_at else None,
"category": {"id": str(a.category.id), "name": a.category.name, "slug": a.category.slug} if a.category else None,
"tags": [{"id": str(t.id), "name": t.name, "slug": t.slug} for t in a.tags],
}
async def _resolve_tags(tag_names: list[str], db: AsyncSession) -> list[Tag]:
from slugify import slugify
tags = []
for name in tag_names:
name = name.strip()
if not name:
continue
slug = slugify(name)
tag = (await db.execute(select(Tag).where(Tag.slug == slug))).scalar_one_or_none()
if not tag:
tag = Tag(name=name, slug=slug)
db.add(tag)
tags.append(tag)
return tags
async def _invalidate_cache(redis: aioredis.Redis):
keys = await redis.keys("news:*")
if keys:
await redis.delete(*keys)
@router.get("")
async def list_articles(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: Optional[ArticleStatus] = Query(None),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
if status:
q = q.where(Article.status == status)
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
articles = (await db.execute(q.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
return {
"total": total,
"offset": offset,
"limit": limit,
"has_more": (offset + limit) < total,
"items": [_article_dict(a) for a in articles],
}
@router.post("")
async def create_article(
data: ArticleIn,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
_: str = Depends(require_admin),
):
from slugify import slugify
slug = data.slug or slugify(data.title)
existing = (await db.execute(select(Article).where(Article.slug == slug))).scalar_one_or_none()
if existing:
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
cat = None
if data.category_id:
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(data.category_id)))).scalar_one_or_none()
tags = await _resolve_tags(data.tag_names, db)
article = Article(
title=data.title,
slug=slug,
content=data.content,
excerpt=data.excerpt,
cover_url=data.cover_url,
font_family=data.font_family,
status=data.status,
category=cat,
tags=tags,
published_at=datetime.utcnow() if data.status == ArticleStatus.published else None,
)
db.add(article)
await db.commit()
await db.refresh(article)
await _invalidate_cache(redis)
return _article_dict(article)
@router.get("/{article_id}")
async def get_article(
article_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags)).where(Article.id == uuid.UUID(article_id))
article = (await db.execute(q)).scalar_one_or_none()
if not article:
raise HTTPException(status_code=404, detail="Not found")
return _article_dict(article)
@router.put("/{article_id}")
async def update_article(
article_id: str,
data: ArticleIn,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
_: str = Depends(require_admin),
):
from slugify import slugify
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags)).where(Article.id == uuid.UUID(article_id))
article = (await db.execute(q)).scalar_one_or_none()
if not article:
raise HTTPException(status_code=404, detail="Not found")
slug = data.slug or slugify(data.title)
conflict = (await db.execute(
select(Article).where(Article.slug == slug, Article.id != article.id)
)).scalar_one_or_none()
if conflict:
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
was_draft = article.status == ArticleStatus.draft
cat = None
if data.category_id:
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(data.category_id)))).scalar_one_or_none()
tags = await _resolve_tags(data.tag_names, db)
article.title = data.title
article.slug = slug
article.content = data.content
article.excerpt = data.excerpt
article.cover_url = data.cover_url
article.font_family = data.font_family
article.status = data.status
article.category = cat
article.tags = tags
article.updated_at = datetime.utcnow()
if was_draft and data.status == ArticleStatus.published and not article.published_at:
article.published_at = datetime.utcnow()
await db.commit()
await db.refresh(article)
await _invalidate_cache(redis)
return _article_dict(article)
@router.delete("/{article_id}")
async def delete_article(
article_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
_: str = Depends(require_admin),
):
article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none()
if not article:
raise HTTPException(status_code=404, detail="Not found")
await db.delete(article)
await db.commit()
await _invalidate_cache(redis)
return {"ok": True}
@router.post("/{article_id}/publish")
async def publish_article(
article_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
_: str = Depends(require_admin),
):
article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none()
if not article:
raise HTTPException(status_code=404, detail="Not found")
article.status = ArticleStatus.published
if not article.published_at:
article.published_at = datetime.utcnow()
article.updated_at = datetime.utcnow()
await db.commit()
await _invalidate_cache(redis)
return {"ok": True, "published_at": article.published_at.isoformat()}

62
api/route/admin_auth.py Normal file
View File

@@ -0,0 +1,62 @@
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from jose import jwt
from passlib.context import CryptContext
from bd.database import get_db, AsyncSessionLocal
from bd.models import Admin
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
JWT_ALGO = "HS256"
JWT_EXP_HOURS = 24
def _make_token(username: str) -> str:
exp = datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS)
return jwt.encode({"sub": username, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGO)
async def ensure_default_admin():
username = os.getenv("ADMIN_USERNAME", "admin")
password = os.getenv("ADMIN_PASSWORD", "changeme")
async with AsyncSessionLocal() as db:
existing = (await db.execute(select(Admin))).first()
if not existing:
admin = Admin(username=username, password_hash=pwd_context.hash(password))
db.add(admin)
await db.commit()
class LoginRequest(BaseModel):
username: str
password: str
@router.post("/login")
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
admin = (await db.execute(select(Admin).where(Admin.username == data.username))).scalar_one_or_none()
if not admin or not pwd_context.verify(data.password, admin.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль")
return {"access_token": _make_token(admin.username), "token_type": "bearer"}
@router.post("/change-password")
async def change_password(
data: dict,
db: AsyncSession = Depends(get_db),
):
from route.deps import require_admin
# handled with deps in main
admin = (await db.execute(select(Admin).where(Admin.username == data["username"]))).scalar_one_or_none()
if not admin or not pwd_context.verify(data["current_password"], admin.password_hash):
raise HTTPException(status_code=400, detail="Неверный текущий пароль")
admin.password_hash = pwd_context.hash(data["new_password"])
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,78 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import redis.asyncio as aioredis
from bd.database import get_db, get_redis
from bd.models import Category
from route.deps import require_admin
router = APIRouter()
class CategoryIn(BaseModel):
name: str
slug: str | None = None
@router.get("")
async def list_categories(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
return [{"id": str(c.id), "name": c.name, "slug": c.slug} for c in cats]
@router.post("")
async def create_category(
data: CategoryIn,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
_: str = Depends(require_admin),
):
from slugify import slugify
slug = data.slug or slugify(data.name)
existing = (await db.execute(select(Category).where(Category.slug == slug))).scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Категория с таким slug уже существует")
cat = Category(name=data.name, slug=slug)
db.add(cat)
await db.commit()
await db.refresh(cat)
await redis.delete("news:categories")
return {"id": str(cat.id), "name": cat.name, "slug": cat.slug}
@router.put("/{cat_id}")
async def update_category(
cat_id: str,
data: CategoryIn,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
_: str = Depends(require_admin),
):
from slugify import slugify
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(cat_id)))).scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Not found")
cat.name = data.name
cat.slug = data.slug or slugify(data.name)
await db.commit()
await redis.delete("news:categories")
return {"id": str(cat.id), "name": cat.name, "slug": cat.slug}
@router.delete("/{cat_id}")
async def delete_category(
cat_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
_: str = Depends(require_admin),
):
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(cat_id)))).scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Not found")
await db.delete(cat)
await db.commit()
await redis.delete("news:categories")
return {"ok": True}

138
api/route/admin_media.py Normal file
View File

@@ -0,0 +1,138 @@
import os
import io
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from minio import Minio
from minio.error import S3Error
from bd.database import get_db
from bd.models import Media
from route.deps import require_admin
router = APIRouter()
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")
ALLOWED_IMAGE = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
ALLOWED_VIDEO = {"video/mp4", "video/webm", "video/ogg", "video/quicktime"}
MAX_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
def get_minio() -> Minio:
return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
def ensure_bucket(client: Minio):
try:
if not client.bucket_exists(MINIO_BUCKET):
client.make_bucket(MINIO_BUCKET)
policy = f"""{{
"Version":"2012-10-17",
"Statement":[{{
"Effect":"Allow",
"Principal":"*",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::{MINIO_BUCKET}/*"]
}}]
}}"""
client.set_bucket_policy(MINIO_BUCKET, policy)
except S3Error:
pass
@router.post("/upload")
async def upload_media(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
content_type = file.content_type or ""
if content_type not in ALLOWED_IMAGE and content_type not in ALLOWED_VIDEO:
raise HTTPException(status_code=400, detail=f"Тип файла не поддерживается: {content_type}")
data = await file.read()
if len(data) > MAX_SIZE_BYTES:
raise HTTPException(status_code=400, detail="Файл слишком большой (макс. 100 МБ)")
media_type = "image" if content_type in ALLOWED_IMAGE else "video"
ext = (file.filename or "file").rsplit(".", 1)[-1].lower()
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
client = get_minio()
ensure_bucket(client)
client.put_object(
MINIO_BUCKET,
object_name,
io.BytesIO(data),
length=len(data),
content_type=content_type,
)
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
media = Media(
filename=file.filename or object_name,
url=public_url,
media_type=media_type,
size_bytes=len(data),
)
db.add(media)
await db.commit()
await db.refresh(media)
return {
"id": str(media.id),
"url": public_url,
"filename": media.filename,
"media_type": media_type,
"size_bytes": media.size_bytes,
}
@router.get("")
async def list_media(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
media_type: str | None = Query(None),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
q = select(Media)
if media_type:
q = q.where(Media.media_type == media_type)
items = (await db.execute(q.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
return [
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
for m in items
]
@router.delete("/{media_id}")
async def delete_media(
media_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
import uuid as uuid_lib
media = (await db.execute(select(Media).where(Media.id == uuid_lib.UUID(media_id)))).scalar_one_or_none()
if not media:
raise HTTPException(status_code=404, detail="Not found")
object_name = media.url.split(f"/{MINIO_BUCKET}/", 1)[-1]
try:
client = get_minio()
client.remove_object(MINIO_BUCKET, object_name)
except Exception:
pass
await db.delete(media)
await db.commit()
return {"ok": True}

17
api/route/deps.py Normal file
View File

@@ -0,0 +1,17 @@
import os
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
JWT_ALGO = "HS256"
bearer = HTTPBearer()
async def require_admin(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
try:
payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGO])
return payload["sub"]
except (JWTError, KeyError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")

132
api/route/public.py Normal file
View File

@@ -0,0 +1,132 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import redis.asyncio as aioredis
from bd.database import get_db, get_redis
from bd.models import Article, Category, Tag, ArticleStatus
router = APIRouter()
def _cat_dict(c: Category) -> dict:
return {"id": str(c.id), "name": c.name, "slug": c.slug}
def _article_card(a: Article) -> dict:
return {
"id": str(a.id),
"title": a.title,
"slug": a.slug,
"excerpt": a.excerpt,
"cover_url": a.cover_url,
"published_at": a.published_at.isoformat() if a.published_at else None,
"view_count": a.view_count,
"category": _cat_dict(a.category) if a.category else None,
"tags": [{"id": str(t.id), "name": t.name, "slug": t.slug} for t in a.tags],
}
def _article_full(a: Article) -> dict:
d = _article_card(a)
d["content"] = a.content
d["font_family"] = a.font_family
return d
@router.get("")
async def list_news(
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
category: str | None = Query(None),
tag: str | None = Query(None),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
base = (
select(Article)
.options(selectinload(Article.category), selectinload(Article.tags))
.where(Article.status == ArticleStatus.published)
)
if category:
cat = (await db.execute(select(Category).where(Category.slug == category))).scalar_one_or_none()
if cat:
base = base.where(Article.category_id == cat.id)
else:
return {"total": 0, "offset": offset, "limit": limit, "has_more": False, "items": []}
if tag:
t = (await db.execute(select(Tag).where(Tag.slug == tag))).scalar_one_or_none()
if t:
base = base.where(Article.tags.any(Tag.id == t.id))
count_q = select(func.count()).select_from(base.subquery())
total: int = (await db.execute(count_q)).scalar_one()
articles = (
await db.execute(base.order_by(Article.published_at.desc()).offset(offset).limit(limit))
).scalars().all()
result = {
"total": total,
"offset": offset,
"limit": limit,
"has_more": (offset + limit) < total,
"items": [_article_card(a) for a in articles],
}
await redis.setex(cache_key, 30, json.dumps(result, default=str))
return result
@router.get("/categories")
async def list_categories(
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
cached = await redis.get("news:categories")
if cached:
return json.loads(cached)
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
result = [_cat_dict(c) for c in cats]
await redis.setex("news:categories", 60, json.dumps(result))
return result
@router.get("/{slug}")
async def get_article(
slug: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
cache_key = f"news:article:{slug}"
cached = await redis.get(cache_key)
q = (
select(Article)
.options(selectinload(Article.category), selectinload(Article.tags))
.where(Article.slug == slug, Article.status == ArticleStatus.published)
)
article = (await db.execute(q)).scalar_one_or_none()
if not article:
raise HTTPException(status_code=404, detail="Article not found")
article.view_count += 1
await db.commit()
if cached:
data = json.loads(cached)
data["view_count"] = article.view_count
return data
await db.refresh(article)
result = _article_full(article)
await redis.setex(cache_key, 60, json.dumps(result, default=str))
return result

65
docker-compose.yml Normal file
View File

@@ -0,0 +1,65 @@
version: "3.9"
services:
api:
build:
context: ./api
dockerfile: Dockerfile
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
JWT_SECRET: ${JWT_SECRET}
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
MINIO_BUCKET: ${MINIO_BUCKET:-news-media}
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
DOCS_USERNAME: ${DOCS_USERNAME:-admin}
DOCS_PASSWORD: ${DOCS_PASSWORD:-admin}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
depends_on:
minio:
condition: service_healthy
networks:
- news_net
web:
build:
context: ./web
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "80:8080"
environment:
API_URL: http://api:8000
depends_on:
- api
networks:
- news_net
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./data/minio:/data
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
networks:
- news_net
networks:
news_net:
external: true
name: news_net

50
docker-compose_db.yml Normal file
View File

@@ -0,0 +1,50 @@
version: "3.9"
# Локальные базы данных для разработки и тестирования.
# Запуск: docker-compose -f docker-compose_db.yml up -d
# После запуска выполни: ./init_db.sh
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
POSTGRES_USER: postgres
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- ./data/postgres:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
networks:
- news_net
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD:-RedisNewsMk2026}
--appendonly yes
--appendfsync everysec
volumes:
- ./data/redis:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-RedisNewsMk2026}", "ping"]
interval: 5s
timeout: 5s
retries: 10
networks:
- news_net
networks:
news_net:
name: news_net
driver: bridge

185
init_db.sh Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env bash
set -euo pipefail
# ─── Загрузка переменных из .env ─────────────────────────────────────────────
if [ -f .env ]; then
export $(grep -v '^#' .env | grep -v '^$' | xargs)
fi
POSTGRES_ROOT_PASSWORD=${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
NEWS_DB_PASSWORD=${NEWS_DB_PASSWORD:-NewsAdmPass2026Xk}
DB_USER="news_admin"
DB_NAME="news"
DB_CONTAINER="news_flet-db-1" # имя контейнера по умолчанию (docker compose project name)
# ─── Цвета вывода ────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
# ─── Поиск контейнера postgres ────────────────────────────────────────────────
find_container() {
# Пробуем стандартные варианты имён контейнера
for name in "news_flet-db-1" "news_flet_db_1" "db"; do
if docker inspect "$name" &>/dev/null 2>&1; then
echo "$name"
return
fi
done
# Ищем по образу
local found
found=$(docker ps --filter "ancestor=postgres" --format "{{.Names}}" | head -1)
if [ -n "$found" ]; then
echo "$found"
return
fi
error "Контейнер PostgreSQL не найден. Запусти: docker-compose -f docker-compose_db.yml up -d"
}
psql_cmd() {
docker exec -i "$DB_CONTAINER" psql -U postgres "$@"
}
# ─── Ожидание готовности PostgreSQL ──────────────────────────────────────────
wait_for_postgres() {
info "Ожидание готовности PostgreSQL..."
local retries=30
until docker exec "$DB_CONTAINER" pg_isready -U postgres -q 2>/dev/null; do
retries=$((retries - 1))
if [ $retries -le 0 ]; then
error "PostgreSQL не стал доступен за 60 секунд."
fi
echo -n "."
sleep 2
done
echo ""
info "PostgreSQL готов."
}
# ─── Создание пользователя ────────────────────────────────────────────────────
create_user() {
info "Проверка пользователя '$DB_USER'..."
local exists
exists=$(psql_cmd -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER';" 2>/dev/null || echo "")
if [ "$exists" = "1" ]; then
warn "Пользователь '$DB_USER' уже существует — обновляю пароль."
psql_cmd -c "ALTER USER $DB_USER WITH PASSWORD '$NEWS_DB_PASSWORD';" > /dev/null
else
psql_cmd -c "CREATE USER $DB_USER WITH PASSWORD '$NEWS_DB_PASSWORD';" > /dev/null
info "Пользователь '$DB_USER' создан."
fi
}
# ─── Создание базы данных ─────────────────────────────────────────────────────
create_database() {
info "Проверка базы данных '$DB_NAME'..."
local exists
exists=$(psql_cmd -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME';" 2>/dev/null || echo "")
if [ "$exists" = "1" ]; then
warn "База данных '$DB_NAME' уже существует — пропускаю создание."
else
psql_cmd -c "CREATE DATABASE $DB_NAME OWNER $DB_USER ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0;" > /dev/null
info "База данных '$DB_NAME' создана."
fi
}
# ─── Настройка прав ───────────────────────────────────────────────────────────
grant_privileges() {
info "Настройка привилегий..."
psql_cmd -d "$DB_NAME" << 'SQL' > /dev/null
GRANT ALL PRIVILEGES ON DATABASE news TO news_admin;
GRANT ALL ON SCHEMA public TO news_admin;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO news_admin;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO news_admin;
SQL
info "Привилегии настроены."
}
# ─── Проверка подключения ─────────────────────────────────────────────────────
verify_connection() {
info "Проверка подключения от '$DB_USER' к '$DB_NAME'..."
if docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" > /dev/null 2>&1; then
info "Подключение успешно."
else
error "Не удалось подключиться от '$DB_USER' к '$DB_NAME'."
fi
}
# ─── Ожидание Redis ───────────────────────────────────────────────────────────
wait_for_redis() {
info "Ожидание готовности Redis..."
local redis_container
redis_container=$(docker ps --filter "ancestor=redis" --format "{{.Names}}" | head -1)
if [ -z "$redis_container" ]; then
warn "Контейнер Redis не найден — пропускаю проверку."
return
fi
local retries=15
local redis_pass=${REDIS_PASSWORD:-RedisNewsMk2026}
until docker exec "$redis_container" redis-cli -a "$redis_pass" ping 2>/dev/null | grep -q PONG; do
retries=$((retries - 1))
if [ $retries -le 0 ]; then
warn "Redis не ответил за 30 секунд — продолжаю без него."
return
fi
echo -n "."
sleep 2
done
echo ""
info "Redis готов."
}
# ─── Создание директорий для данных ──────────────────────────────────────────
create_data_dirs() {
info "Создание директорий для хранения данных..."
mkdir -p ./data/postgres ./data/redis ./data/minio
info "Директории: ./data/postgres ./data/redis ./data/minio"
}
# ─── Главная функция ──────────────────────────────────────────────────────────
main() {
echo ""
echo "══════════════════════════════════════════════"
echo " Инициализация баз данных — News CMS"
echo "══════════════════════════════════════════════"
echo ""
create_data_dirs
# Запуск docker-compose_db.yml если не запущен
if ! docker-compose -f docker-compose_db.yml ps --services --filter "status=running" 2>/dev/null | grep -q "db"; then
info "Запуск docker-compose_db.yml..."
docker-compose -f docker-compose_db.yml up -d
sleep 3
fi
DB_CONTAINER=$(find_container)
info "Контейнер PostgreSQL: $DB_CONTAINER"
wait_for_postgres
create_user
create_database
grant_privileges
verify_connection
wait_for_redis
echo ""
echo "══════════════════════════════════════════════"
info "Готово! Теперь запускай:"
echo ""
echo " docker-compose up -d --build"
echo ""
echo " Сайт: http://localhost"
echo " Админка: http://localhost/admin"
echo " MinIO консоль: http://localhost:9001"
echo "══════════════════════════════════════════════"
echo ""
}
main "$@"

6
web/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

232
web/api_client.py Normal file
View File

@@ -0,0 +1,232 @@
import os
import httpx
API_URL = os.getenv("API_URL", "http://api:8000")
_TIMEOUT = 15.0
def _headers(token: str | None = None) -> dict:
h = {"Content-Type": "application/json"}
if token:
h["Authorization"] = f"Bearer {token}"
return h
# ─── Public ──────────────────────────────────────────────────────────────────
async def get_news(
offset: int = 0,
limit: int = 20,
category: str | None = None,
tag: str | None = None,
) -> dict | None:
params = {"offset": offset, "limit": limit}
if category:
params["category"] = category
if tag:
params["tag"] = tag
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{API_URL}/news", params=params)
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def get_article(slug: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{API_URL}/news/{slug}")
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def get_categories() -> list:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{API_URL}/news/categories")
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return []
# ─── Admin: Auth ─────────────────────────────────────────────────────────────
async def admin_login(username: str, password: str) -> str | None:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(
f"{API_URL}/admin/login",
json={"username": username, "password": password},
)
if r.status_code == 200:
return r.json().get("access_token")
return None
except httpx.RequestError:
return None
# ─── Admin: Articles ──────────────────────────────────────────────────────────
async def admin_list_articles(token: str, offset: int = 0, limit: int = 50, status: str | None = None) -> dict | None:
params = {"offset": offset, "limit": limit}
if status:
params["status"] = status
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{API_URL}/admin/articles", params=params, headers=_headers(token))
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def admin_get_article(token: str, article_id: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def admin_create_article(token: str, data: dict) -> dict | None:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{API_URL}/admin/articles", json=data, headers=_headers(token))
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def admin_update_article(token: str, article_id: str, data: dict) -> dict | None:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.put(f"{API_URL}/admin/articles/{article_id}", json=data, headers=_headers(token))
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def admin_delete_article(token: str, article_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.delete(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
return r.status_code == 200
except httpx.RequestError:
return False
async def admin_publish_article(token: str, article_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{API_URL}/admin/articles/{article_id}/publish", headers=_headers(token))
return r.status_code == 200
except httpx.RequestError:
return False
# ─── Admin: Categories ────────────────────────────────────────────────────────
async def admin_list_categories(token: str) -> list:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{API_URL}/admin/categories", headers=_headers(token))
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return []
async def admin_create_category(token: str, name: str, slug: str = "") -> dict | None:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(
f"{API_URL}/admin/categories",
json={"name": name, "slug": slug or None},
headers=_headers(token),
)
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def admin_update_category(token: str, cat_id: str, name: str, slug: str = "") -> dict | None:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.put(
f"{API_URL}/admin/categories/{cat_id}",
json={"name": name, "slug": slug or None},
headers=_headers(token),
)
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def admin_delete_category(token: str, cat_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.delete(f"{API_URL}/admin/categories/{cat_id}", headers=_headers(token))
return r.status_code == 200
except httpx.RequestError:
return False
# ─── Admin: Media ─────────────────────────────────────────────────────────────
async def admin_upload_media(token: str, file_bytes: bytes, filename: str, content_type: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.post(
f"{API_URL}/admin/media/upload",
files={"file": (filename, file_bytes, content_type)},
headers={"Authorization": f"Bearer {token}"},
)
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return None
async def admin_list_media(token: str, offset: int = 0, limit: int = 50, media_type: str | None = None) -> list:
params = {"offset": offset, "limit": limit}
if media_type:
params["media_type"] = media_type
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{API_URL}/admin/media", params=params, headers=_headers(token))
if r.status_code == 200:
return r.json()
except httpx.RequestError:
pass
return []
async def admin_delete_media(token: str, media_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.delete(f"{API_URL}/admin/media/{media_id}", headers=_headers(token))
return r.status_code == 200
except httpx.RequestError:
return False

252
web/designer.py Normal file
View File

@@ -0,0 +1,252 @@
import flet as ft
# ─── Шрифты ──────────────────────────────────────────────────────────────────
FONT_UI = "Inter"
FONT_SERIF = "Merriweather"
FONT_DISPLAY = "Playfair Display"
FONT_MONO = "JetBrains Mono"
ARTICLE_FONTS: dict[str, str] = {
"Merriweather": "Merriweather — для чтения (засечки)",
"Inter": "Inter — современный (без засечек)",
"Playfair Display": "Playfair Display — газетный",
"Roboto": "Roboto — нейтральный",
"JetBrains Mono": "JetBrains Mono — моноширинный",
}
GOOGLE_FONTS_JS = """
(function(){
if(document.getElementById('gfonts-news')) return;
var l=document.createElement('link');
l.id='gfonts-news';
l.rel='stylesheet';
l.href='https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&family=Roboto:wght@400;500;700&display=swap';
document.head.appendChild(l);
})();
"""
# ─── Цвета ───────────────────────────────────────────────────────────────────
PRIMARY = "#1565C0"
PRIMARY_LIGHT = "#1976D2"
PRIMARY_DARK = "#0D47A1"
ACCENT = "#FF6D00"
SUCCESS = "#2E7D32"
WARNING = "#F57F17"
ERROR = "#C62828"
SURFACE = "#FFFFFF"
BACKGROUND = "#F5F5F5"
TEXT_PRIMARY = "#212121"
TEXT_SECONDARY = "#616161"
DIVIDER = "#E0E0E0"
ADMIN_SIDEBAR = "#1A237E"
ADMIN_SIDEBAR_TEXT = "#E8EAF6"
ADMIN_SIDEBAR_ACTIVE = "#283593"
# ─── Типографика ─────────────────────────────────────────────────────────────
def headline(text: str, size: int = 28, color: str = TEXT_PRIMARY, weight=ft.FontWeight.BOLD, font: str = FONT_UI) -> ft.Text:
return ft.Text(text, size=size, color=color, weight=weight, font_family=font)
def body(text: str, size: int = 14, color: str = TEXT_PRIMARY, font: str = FONT_UI) -> ft.Text:
return ft.Text(text, size=size, color=color, font_family=font)
def caption(text: str, color: str = TEXT_SECONDARY) -> ft.Text:
return ft.Text(text, size=12, color=color, font_family=FONT_UI)
# ─── Кнопки ──────────────────────────────────────────────────────────────────
def btn_primary(text: str, on_click=None, icon=None, width: int | None = None) -> ft.ElevatedButton:
return ft.ElevatedButton(
text=text,
icon=icon,
on_click=on_click,
width=width,
style=ft.ButtonStyle(
bgcolor=PRIMARY,
color=ft.colors.WHITE,
shape=ft.RoundedRectangleBorder(radius=8),
padding=ft.padding.symmetric(horizontal=20, vertical=12),
),
)
def btn_outlined(text: str, on_click=None, icon=None, color: str = PRIMARY) -> ft.OutlinedButton:
return ft.OutlinedButton(
text=text,
icon=icon,
on_click=on_click,
style=ft.ButtonStyle(
color=color,
side=ft.BorderSide(1, color),
shape=ft.RoundedRectangleBorder(radius=8),
padding=ft.padding.symmetric(horizontal=16, vertical=10),
),
)
def btn_danger(text: str, on_click=None, icon=None) -> ft.ElevatedButton:
return ft.ElevatedButton(
text=text,
icon=icon,
on_click=on_click,
style=ft.ButtonStyle(
bgcolor=ERROR,
color=ft.colors.WHITE,
shape=ft.RoundedRectangleBorder(radius=8),
),
)
def btn_icon(icon, tooltip: str = "", on_click=None, color: str = TEXT_SECONDARY) -> ft.IconButton:
return ft.IconButton(icon=icon, tooltip=tooltip, on_click=on_click, icon_color=color, icon_size=20)
# ─── Поля ввода ──────────────────────────────────────────────────────────────
def text_field(
label: str,
value: str = "",
on_change=None,
multiline: bool = False,
min_lines: int = 1,
max_lines: int | None = 1,
hint: str = "",
password: bool = False,
expand: bool = False,
read_only: bool = False,
prefix_icon=None,
) -> ft.TextField:
return ft.TextField(
label=label,
value=value,
on_change=on_change,
multiline=multiline,
min_lines=min_lines,
max_lines=max_lines,
hint_text=hint,
password=password,
can_reveal_password=password,
expand=expand,
read_only=read_only,
prefix_icon=prefix_icon,
border_color=DIVIDER,
focused_border_color=PRIMARY,
label_style=ft.TextStyle(color=TEXT_SECONDARY, size=13, font_family=FONT_UI),
text_style=ft.TextStyle(color=TEXT_PRIMARY, size=14, font_family=FONT_UI),
border_radius=8,
content_padding=ft.padding.symmetric(horizontal=12, vertical=10),
)
def dropdown(label: str, options: list[ft.dropdown.Option], value: str | None = None, on_change=None) -> ft.Dropdown:
return ft.Dropdown(
label=label,
value=value,
options=options,
on_change=on_change,
border_color=DIVIDER,
focused_border_color=PRIMARY,
label_style=ft.TextStyle(color=TEXT_SECONDARY, size=13),
border_radius=8,
)
# ─── Карточки ────────────────────────────────────────────────────────────────
def card(content: ft.Control, padding: int = 16, elevation: int = 1) -> ft.Card:
return ft.Card(
content=ft.Container(content=content, padding=padding),
elevation=elevation,
surface_tint_color=SURFACE,
)
def stat_card(title: str, value: str, icon, color: str = PRIMARY) -> ft.Container:
return ft.Container(
content=ft.Column(
[
ft.Row([ft.Icon(icon, color=color, size=28), body(title, color=TEXT_SECONDARY)], spacing=8),
ft.Text(value, size=32, weight=ft.FontWeight.BOLD, color=color),
],
spacing=8,
),
bgcolor=SURFACE,
border_radius=12,
padding=20,
shadow=ft.BoxShadow(spread_radius=0, blur_radius=8, color=ft.colors.with_opacity(0.08, ft.colors.BLACK)),
width=200,
)
# ─── Статус-бейджи ────────────────────────────────────────────────────────────
def status_badge(status: str) -> ft.Container:
cfg = {
"published": (SUCCESS, "Опубликовано"),
"draft": (TEXT_SECONDARY, "Черновик"),
}
color, label = cfg.get(status, (TEXT_SECONDARY, status))
return ft.Container(
content=ft.Text(label, size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_500),
bgcolor=color,
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3),
)
# ─── Разделитель ─────────────────────────────────────────────────────────────
def divider() -> ft.Divider:
return ft.Divider(height=1, color=DIVIDER)
# ─── Загрузчик ───────────────────────────────────────────────────────────────
def loader(size: int = 40) -> ft.ProgressRing:
return ft.ProgressRing(width=size, height=size, stroke_width=3, color=PRIMARY)
# ─── Снэкбар ─────────────────────────────────────────────────────────────────
def snack(page: ft.Page, message: str, error: bool = False):
page.snack_bar = ft.SnackBar(
content=ft.Text(message, color=ft.colors.WHITE),
bgcolor=ERROR if error else SUCCESS,
duration=3000,
)
page.snack_bar.open = True
page.update()
# ─── Диалог подтверждения ─────────────────────────────────────────────────────
def confirm_dialog(page: ft.Page, title: str, message: str, on_confirm) -> ft.AlertDialog:
dlg = ft.AlertDialog(
modal=True,
title=ft.Text(title, weight=ft.FontWeight.BOLD),
content=ft.Text(message),
actions=[
ft.TextButton("Отмена", on_click=lambda e: _close_dlg(page, dlg)),
btn_danger("Удалить", on_click=lambda e: (_close_dlg(page, dlg), on_confirm())),
],
)
return dlg
def _close_dlg(page: ft.Page, dlg: ft.AlertDialog):
dlg.open = False
page.update()
# ─── Пустое состояние ────────────────────────────────────────────────────────
def empty_state(icon, message: str) -> ft.Column:
return ft.Column(
[ft.Icon(icon, size=64, color=DIVIDER), body(message, color=TEXT_SECONDARY)],
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=12,
)

41
web/main.py Normal file
View File

@@ -0,0 +1,41 @@
import flet as ft
import flet.fastapi as flet_fastapi
import designer as d
import theme_manager
from router import handle_route
async def main(page: ft.Page):
page.title = "Новости"
page.padding = 0
page.spacing = 0
page.window.width = 1280
page.window.min_width = 360
page.fonts = {
"Merriweather": "https://cdn.jsdelivr.net/npm/@fontsource/merriweather@latest/files/merriweather-latin-400-normal.woff2",
"Inter": "https://cdn.jsdelivr.net/npm/@fontsource/inter@latest/files/inter-latin-400-normal.woff2",
"Playfair Display": "https://cdn.jsdelivr.net/npm/@fontsource/playfair-display@latest/files/playfair-display-latin-400-normal.woff2",
"JetBrains Mono": "https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@latest/files/jetbrains-mono-latin-400-normal.woff2",
"Roboto": "https://cdn.jsdelivr.net/npm/@fontsource/roboto@latest/files/roboto-latin-400-normal.woff2",
}
theme_manager.apply(page, "light")
async def on_route_change(e: ft.RouteChangeEvent):
theme_manager.reset_font(page)
await handle_route(page)
def on_view_pop(e: ft.ViewPopEvent):
page.views.pop()
if page.views:
page.go(page.views[-1].route)
page.on_route_change = on_route_change
page.on_view_pop = on_view_pop
await handle_route(page)
app = flet_fastapi.app(main)

3
web/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
flet==0.24.1
uvicorn[standard]==0.30.6
httpx==0.27.2

70
web/router.py Normal file
View File

@@ -0,0 +1,70 @@
import flet as ft
from views.news_feed import NewsFeedView
from views.news_detail import NewsDetailView
from views.login import LoginView
from views.dashboard import DashboardView
from views.articles_list import ArticlesListView
from views.article_editor import ArticleEditorView
from views.categories import CategoriesView
from views.media_library import MediaLibraryView
_PUBLIC = {"/", "/admin/login"}
def _is_admin(route: str) -> bool:
return route.startswith("/admin") and route != "/admin/login"
def _parts(route: str) -> list[str]:
return [p for p in route.strip("/").split("/") if p]
def resolve_view(page: ft.Page, route: str):
parts = _parts(route)
if not parts or route == "/":
return NewsFeedView(page)
if parts[0] == "news" and len(parts) >= 2:
return NewsDetailView(page, slug=parts[1])
if parts[0] == "admin":
if len(parts) == 1:
return DashboardView(page)
sub = parts[1]
if sub == "login":
return LoginView(page)
if sub == "articles":
if len(parts) == 2:
return ArticlesListView(page)
if len(parts) >= 3:
aid = parts[2]
return ArticleEditorView(page, article_id=None if aid == "new" else aid)
if sub == "categories":
return CategoriesView(page)
if sub == "media":
return MediaLibraryView(page)
return DashboardView(page)
return NewsFeedView(page)
async def handle_route(page: ft.Page):
route = page.route or "/"
if _is_admin(route):
token = page.session.get("token")
if not token:
page.go("/admin/login")
return
view = resolve_view(page, route)
flet_view = view.build_page_view()
page.views.clear()
page.views.append(flet_view)
page.update()
if hasattr(view, "load"):
page.run_task(view.load)

69
web/theme_manager.py Normal file
View File

@@ -0,0 +1,69 @@
import flet as ft
import designer as d
_ui_text = ft.TextTheme(
body_medium=ft.TextStyle(font_family=d.FONT_UI),
body_large=ft.TextStyle(font_family=d.FONT_UI),
body_small=ft.TextStyle(font_family=d.FONT_UI),
label_large=ft.TextStyle(font_family=d.FONT_UI),
label_medium=ft.TextStyle(font_family=d.FONT_UI),
label_small=ft.TextStyle(font_family=d.FONT_UI),
title_large=ft.TextStyle(font_family=d.FONT_UI, weight=ft.FontWeight.W_600),
title_medium=ft.TextStyle(font_family=d.FONT_UI, weight=ft.FontWeight.W_500),
title_small=ft.TextStyle(font_family=d.FONT_UI),
)
THEMES = {
"light": ft.Theme(
color_scheme_seed=d.PRIMARY,
color_scheme=ft.ColorScheme(
primary=d.PRIMARY,
secondary=d.ACCENT,
background=d.BACKGROUND,
surface=d.SURFACE,
),
text_theme=_ui_text,
),
"dark": ft.Theme(
color_scheme_seed=d.PRIMARY,
color_scheme=ft.ColorScheme(
primary=d.PRIMARY_LIGHT,
secondary=d.ACCENT,
background="#121212",
surface="#1E1E1E",
on_surface=ft.colors.WHITE,
),
text_theme=_ui_text,
),
}
def apply(page: ft.Page, theme_id: str = "light"):
theme = THEMES.get(theme_id, THEMES["light"])
if theme_id == "dark":
page.theme_mode = ft.ThemeMode.DARK
page.dark_theme = theme
else:
page.theme_mode = ft.ThemeMode.LIGHT
page.theme = theme
def apply_article_font(page: ft.Page, font_family: str):
"""Temporarily override body text font for article reading view."""
article_text = ft.TextTheme(
body_medium=ft.TextStyle(font_family=font_family, size=16, height=1.7),
body_large=ft.TextStyle(font_family=font_family, size=18, height=1.7),
body_small=ft.TextStyle(font_family=font_family),
)
if page.theme:
page.theme.text_theme = article_text
else:
page.theme = ft.Theme(text_theme=article_text)
page.update()
def reset_font(page: ft.Page):
"""Restore default UI font after leaving article view."""
if page.theme:
page.theme.text_theme = _ui_text
page.update()

0
web/views/__init__.py Normal file
View File

615
web/views/article_editor.py Normal file
View File

@@ -0,0 +1,615 @@
import flet as ft
import designer as d
import api_client as api
from views.dashboard import _admin_appbar, _sidebar
def _js_font_span(font_name: str) -> str:
fn = font_name.replace("'", "\\'")
return f"""
(function(){{
var sel=window.__editorSel;
if(!sel||!sel.el){{var tas=document.querySelectorAll('textarea');if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};else return;}}
var ta=sel.el,val=ta.value,st=sel.start,en=sel.end;
var selected=val.substring(st,en)||'текст';
var before=val.substring(0,st),after=val.substring(en);
var ins='<span style="font-family:\\'{fn}\\'">' + selected + '</span>';
ta.value=before+ins+after;
var newSt=st+ins.length-selected.length-7;
ta.selectionStart=newSt; ta.selectionEnd=newSt+selected.length;
ta.focus();
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
window.__editorSel={{el:ta,start:ta.selectionStart,end:ta.selectionEnd}};
return ta.value;
}})();
"""
# JS: inject once on view mount — tracks last focused textarea + selection
_TRACK_JS = """
window.__editorSel = {start:0, end:0, el:null};
document.addEventListener('mousedown', function(ev){
var t = document.activeElement;
if(t && t.tagName==='TEXTAREA'){
window.__editorSel = {start:t.selectionStart||0, end:t.selectionEnd||0, el:t};
}
});
document.addEventListener('keyup', function(ev){
var t = document.activeElement;
if(t && t.tagName==='TEXTAREA'){
window.__editorSel = {start:t.selectionStart||0, end:t.selectionEnd||0, el:t};
}
});
"""
# JS: insert markdown syntax around selection (called from toolbar buttons)
def _js_insert(prefix: str, suffix: str = "", placeholder: str = "текст") -> str:
p = prefix.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
s = suffix.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
ph = placeholder.replace("'", "\\'")
return f"""
(function(){{
var sel = window.__editorSel;
if(!sel||!sel.el){{
var tas=document.querySelectorAll('textarea');
if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};
else return;
}}
var ta=sel.el, val=ta.value;
var st=sel.start, en=sel.end;
var selected=val.substring(st,en)||'{ph}';
var before=val.substring(0,st), after=val.substring(en);
var inserted='{p}'+selected+'{s}';
ta.value=before+inserted+after;
ta.selectionStart=st+{len(prefix)};
ta.selectionEnd=st+{len(prefix)}+selected.length;
ta.focus();
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
window.__editorSel={{el:ta,start:ta.selectionStart,end:ta.selectionEnd}};
return ta.value;
}})();
"""
def _js_prepend_line(prefix: str) -> str:
p = prefix.replace("'", "\\'")
return f"""
(function(){{
var sel=window.__editorSel;
if(!sel||!sel.el){{
var tas=document.querySelectorAll('textarea');
if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};
else return;
}}
var ta=sel.el, val=ta.value, pos=sel.start;
var lineStart=val.lastIndexOf('\\n',pos-1)+1;
var line=val.substring(lineStart);
var lineEnd=line.indexOf('\\n'); if(lineEnd===-1)lineEnd=line.length;
line=line.substring(0,lineEnd);
var newLine=line.startsWith('{p}')?line.substring({len(prefix)}):'{p}'+line;
ta.value=val.substring(0,lineStart)+newLine+val.substring(lineStart+lineEnd);
ta.selectionStart=ta.selectionEnd=lineStart+newLine.length;
ta.focus();
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
return ta.value;
}})();
"""
def _js_append(text: str) -> str:
t = text.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
return f"""
(function(){{
var sel=window.__editorSel;
var ta=sel&&sel.el;
if(!ta){{var tas=document.querySelectorAll('textarea');if(tas.length>0)ta=tas[tas.length-1];else return;}}
ta.value=(ta.value?ta.value+'\\n':'')+'{t}';
ta.selectionStart=ta.selectionEnd=ta.value.length;
ta.focus();
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
return ta.value;
}})();
"""
class ArticleEditorView:
def __init__(self, page: ft.Page, article_id: str | None = None):
self.page = page
self.article_id = article_id
self._article: dict | None = None
self._categories: list = []
# Fields
self._title = d.text_field("Заголовок *", on_change=self._on_title_change)
self._slug = d.text_field("Slug (URL)", hint="генерируется автоматически")
self._excerpt = d.text_field("Краткое описание", multiline=True, min_lines=2, max_lines=4)
self._cover_url = d.text_field("URL обложки", on_change=self._on_cover_change)
self._tags = d.text_field("Теги", hint="через запятую: Python, новости, технологии")
self._cat_dd = ft.Dropdown(
label="Категория",
options=[ft.dropdown.Option("", "— без категории —")],
value="",
border_color=d.DIVIDER,
focused_border_color=d.PRIMARY,
border_radius=8,
)
self._status_sw = ft.Switch(label="Опубликовать сразу", value=False, active_color=d.SUCCESS)
self._font_dd = ft.Dropdown(
label="Шрифт статьи",
value="Merriweather",
options=[ft.dropdown.Option(k, v) for k, v in d.ARTICLE_FONTS.items()],
border_color=d.DIVIDER,
focused_border_color=d.PRIMARY,
border_radius=8,
on_change=self._on_article_font_change,
)
self._inline_font_dd = ft.Dropdown(
label="Шрифт фрагмента",
hint_text="Выбери и примени к выделению",
width=200,
options=[ft.dropdown.Option(k, k) for k in d.ARTICLE_FONTS],
border_color=d.DIVIDER,
focused_border_color=d.PRIMARY,
border_radius=8,
on_change=self._on_inline_font_change,
)
# Content editor
self._content_field = ft.TextField(
multiline=True,
min_lines=25,
max_lines=None,
expand=True,
hint_text="Пишите в Markdown...",
text_style=ft.TextStyle(font_family="monospace", size=14, color=d.TEXT_PRIMARY),
border_color=d.DIVIDER,
focused_border_color=d.PRIMARY,
border_radius=8,
content_padding=ft.padding.all(12),
on_change=self._on_content_change,
)
self._preview = ft.Markdown(
value="",
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
on_tap_link=lambda e: self.page.launch_url(e.data),
expand=True,
)
self._cover_preview = ft.Image(
src="",
width=200,
height=120,
fit=ft.ImageFit.COVER,
border_radius=8,
visible=False,
)
# File pickers
self._img_picker = ft.FilePicker(on_result=self._on_image_picked)
self._vid_picker = ft.FilePicker(on_result=self._on_video_picked)
self._cover_picker = ft.FilePicker(on_result=self._on_cover_picked)
# Save button
self._save_btn = d.btn_primary("Сохранить", on_click=lambda e: self.page.run_task(self._save), icon=ft.icons.SAVE)
self._loading = ft.ProgressRing(width=20, height=20, stroke_width=2, visible=False, color=d.PRIMARY)
def build_page_view(self) -> ft.View:
title_text = "Редактировать статью" if self.article_id else "Новая статья"
toolbar = self._build_toolbar()
editor_area = ft.Row(
[
ft.Container(
content=ft.Column(
[
ft.Text("Редактор", size=12, color=d.TEXT_SECONDARY, weight=ft.FontWeight.W_500),
self._content_field,
],
spacing=4,
expand=True,
),
expand=True,
padding=ft.padding.only(right=8),
),
ft.Container(
content=ft.Column(
[
ft.Text("Превью", size=12, color=d.TEXT_SECONDARY, weight=ft.FontWeight.W_500),
ft.Container(
content=self._preview,
expand=True,
bgcolor=d.SURFACE,
border=ft.border.all(1, d.DIVIDER),
border_radius=8,
padding=16,
),
],
spacing=4,
expand=True,
),
expand=True,
padding=ft.padding.only(left=8),
),
],
expand=True,
spacing=0,
)
meta_panel = ft.Container(
content=ft.Column(
[
d.headline("Настройки", size=16),
d.divider(),
self._cat_dd,
self._tags,
d.divider(),
d.headline("Обложка", size=14),
self._cover_url,
ft.Row([
d.btn_outlined(
"Загрузить",
icon=ft.icons.UPLOAD,
on_click=lambda e: self._cover_picker.pick_files(
allowed_extensions=["jpg", "jpeg", "png", "webp", "gif"],
dialog_title="Выберите обложку",
),
)
]),
self._cover_preview,
d.divider(),
self._font_dd,
d.divider(),
self._status_sw,
ft.Row(
[self._save_btn, self._loading],
vertical_alignment=ft.CrossAxisAlignment.CENTER,
spacing=12,
),
],
spacing=12,
width=280,
scroll=ft.ScrollMode.AUTO,
),
bgcolor=d.SURFACE,
border=ft.border.all(1, d.DIVIDER),
border_radius=12,
padding=16,
width=280,
)
content_area = ft.Column(
[
self._title,
self._slug,
self._excerpt,
ft.Container(
content=ft.Column([toolbar, editor_area], spacing=0),
expand=True,
bgcolor=d.SURFACE,
border=ft.border.all(1, d.DIVIDER),
border_radius=12,
padding=16,
),
],
expand=True,
spacing=12,
)
return ft.View(
route=f"/admin/articles/{self.article_id or 'new'}",
bgcolor=d.BACKGROUND,
appbar=_admin_appbar(self.page, title_text),
controls=[
ft.Row(
[
_sidebar(self.page, active="/admin/articles"),
ft.Container(
content=ft.Row(
[
ft.Container(content=content_area, expand=True, padding=ft.padding.only(right=16)),
meta_panel,
],
expand=True,
spacing=0,
),
expand=True,
padding=24,
),
],
expand=True,
spacing=0,
),
self._img_picker,
self._vid_picker,
self._cover_picker,
],
scroll=ft.ScrollMode.AUTO,
)
def _build_toolbar(self) -> ft.Container:
def tb_btn(icon, tooltip: str, js_code: str) -> ft.IconButton:
return ft.IconButton(
icon=icon,
tooltip=tooltip,
icon_size=18,
icon_color=d.TEXT_PRIMARY,
on_click=lambda e, js=js_code: self.page.run_task(self._exec_js, js),
style=ft.ButtonStyle(
shape=ft.RoundedRectangleBorder(radius=4),
padding=ft.padding.all(6),
),
)
buttons = [
tb_btn(ft.icons.FORMAT_BOLD, "Жирный (Ctrl+B)", _js_insert("**", "**", "жирный текст")),
tb_btn(ft.icons.FORMAT_ITALIC, "Курсив (Ctrl+I)", _js_insert("*", "*", "курсив")),
tb_btn(ft.icons.FORMAT_STRIKETHROUGH, "Зачёркнутый", _js_insert("~~", "~~", "текст")),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.TITLE, "Заголовок H1", _js_prepend_line("# ")),
ft.Container(
content=ft.Text("H2", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
on_click=lambda e: self.page.run_task(self._exec_js, _js_prepend_line("## ")),
ink=True,
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=6),
tooltip="Заголовок H2",
),
ft.Container(
content=ft.Text("H3", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
on_click=lambda e: self.page.run_task(self._exec_js, _js_prepend_line("### ")),
ink=True,
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=6),
tooltip="Заголовок H3",
),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.FORMAT_LIST_BULLETED, "Список", _js_prepend_line("- ")),
tb_btn(ft.icons.FORMAT_LIST_NUMBERED, "Нумерованный список", _js_prepend_line("1. ")),
tb_btn(ft.icons.FORMAT_QUOTE, "Цитата", _js_prepend_line("> ")),
tb_btn(ft.icons.CODE, "Код", _js_insert("`", "`", "код")),
tb_btn(ft.icons.DATA_OBJECT, "Блок кода", _js_insert("```\n", "\n```", "код")),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.LINK, "Ссылка", _js_insert("[", "](https://)", "текст ссылки")),
ft.IconButton(
icon=ft.icons.IMAGE,
tooltip="Вставить картинку",
icon_size=18,
icon_color=d.PRIMARY,
on_click=lambda e: self._img_picker.pick_files(
allowed_extensions=["jpg", "jpeg", "png", "gif", "webp"],
dialog_title="Выберите изображение",
),
),
ft.IconButton(
icon=ft.icons.VIDEOCAM,
tooltip="Вставить видео",
icon_size=18,
icon_color=d.PRIMARY,
on_click=lambda e: self._vid_picker.pick_files(
allowed_extensions=["mp4", "webm", "ogg", "mov"],
dialog_title="Выберите видео",
),
),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", _js_append("\n---\n")),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
ft.Container(content=self._inline_font_dd, width=210),
]
return ft.Container(
content=ft.Row(buttons, spacing=0, wrap=True),
bgcolor=d.BACKGROUND,
border=ft.border.all(1, d.DIVIDER),
border_radius=ft.BorderRadius(8, 8, 0, 0),
padding=ft.padding.symmetric(horizontal=8, vertical=4),
)
# ─── Events ──────────────────────────────────────────────────────────────
async def _exec_js(self, js: str):
try:
new_val = self.page.run_javascript(js, wait_for_result=True)
if isinstance(new_val, str):
self._content_field.value = new_val
self._preview.value = new_val
self.page.update()
except Exception:
self.page.run_javascript(js)
def _on_article_font_change(self, e):
font = e.control.value or "Merriweather"
self._content_field.text_style = ft.TextStyle(font_family=font, size=14, color=d.TEXT_PRIMARY)
self._preview.value = self._content_field.value or ""
self.page.update()
def _on_inline_font_change(self, e):
font = e.control.value
if font:
self.page.run_task(self._exec_js, _js_font_span(font))
e.control.value = None
self.page.update()
def _on_title_change(self, e):
if not self.article_id:
self._slug.value = _slugify(e.control.value)
self.page.update()
def _on_content_change(self, e):
self._preview.value = e.control.value or ""
self.page.update()
def _on_cover_change(self, e):
url = e.control.value.strip()
self._cover_preview.src = url
self._cover_preview.visible = bool(url)
self.page.update()
async def _on_image_picked(self, e: ft.FilePickerResultEvent):
if not e.files:
return
f = e.files[0]
await self._upload_and_insert(f, "image")
async def _on_video_picked(self, e: ft.FilePickerResultEvent):
if not e.files:
return
f = e.files[0]
await self._upload_and_insert(f, "video")
async def _on_cover_picked(self, e: ft.FilePickerResultEvent):
if not e.files:
return
f = e.files[0]
token = self.page.session.get("token")
file_bytes = await _read_file(f)
if file_bytes is None:
d.snack(self.page, "Не удалось прочитать файл", error=True)
return
ext = (f.name or "").rsplit(".", 1)[-1].lower()
ct = _content_type(ext, "image")
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
if result:
self._cover_url.value = result["url"]
self._cover_preview.src = result["url"]
self._cover_preview.visible = True
self.page.update()
d.snack(self.page, "Обложка загружена")
else:
d.snack(self.page, "Ошибка загрузки обложки", error=True)
async def _upload_and_insert(self, f, media_kind: str):
token = self.page.session.get("token")
d.snack(self.page, f"Загрузка {f.name}...")
file_bytes = await _read_file(f)
if file_bytes is None:
d.snack(self.page, "Не удалось прочитать файл", error=True)
return
ext = (f.name or "").rsplit(".", 1)[-1].lower()
ct = _content_type(ext, media_kind)
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
if result:
url = result["url"]
if media_kind == "image":
md = f"![{f.name}]({url})"
else:
md = (
f'<video controls style="max-width:100%">\n'
f' <source src="{url}" type="{ct}">\n'
f'</video>'
)
self.page.run_javascript(_js_append(md))
self._content_field.value = (self._content_field.value or "") + "\n" + md
self._preview.value = self._content_field.value
self.page.update()
d.snack(self.page, f"Файл загружен: {f.name}")
else:
d.snack(self.page, "Ошибка загрузки файла", error=True)
# ─── Load & Save ─────────────────────────────────────────────────────────
async def load(self):
token = self.page.session.get("token")
self._categories = await api.admin_list_categories(token)
self._cat_dd.options = [ft.dropdown.Option("", "— без категории —")] + [
ft.dropdown.Option(str(c["id"]), c["name"]) for c in self._categories
]
if self.article_id:
self._article = await api.admin_get_article(token, self.article_id)
if self._article:
self._fill_form(self._article)
self.page.run_javascript(_TRACK_JS)
self.page.update()
def _fill_form(self, a: dict):
self._title.value = a.get("title", "")
self._slug.value = a.get("slug", "")
self._excerpt.value = a.get("excerpt", "")
self._cover_url.value = a.get("cover_url", "") or ""
self._content_field.value = a.get("content", "")
self._preview.value = a.get("content", "")
self._tags.value = ", ".join(t["name"] for t in (a.get("tags") or []))
cat = a.get("category")
self._cat_dd.value = str(cat["id"]) if cat else ""
self._status_sw.value = a.get("status") == "published"
font = a.get("font_family", "Merriweather")
self._font_dd.value = font
self._content_field.text_style = ft.TextStyle(font_family=font, size=14, color=d.TEXT_PRIMARY)
if a.get("cover_url"):
self._cover_preview.src = a["cover_url"]
self._cover_preview.visible = True
async def _save(self):
self._save_btn.disabled = True
self._loading.visible = True
self.page.update()
token = self.page.session.get("token")
tag_names = [t.strip() for t in (self._tags.value or "").split(",") if t.strip()]
payload = {
"title": (self._title.value or "").strip(),
"slug": (self._slug.value or "").strip() or None,
"content": self._content_field.value or "",
"excerpt": (self._excerpt.value or "").strip(),
"cover_url": (self._cover_url.value or "").strip() or None,
"font_family": self._font_dd.value or "Merriweather",
"category_id": self._cat_dd.value or None,
"tag_names": tag_names,
"status": "published" if self._status_sw.value else "draft",
}
if not payload["title"]:
d.snack(self.page, "Заголовок обязателен", error=True)
self._save_btn.disabled = False
self._loading.visible = False
self.page.update()
return
if self.article_id:
result = await api.admin_update_article(token, self.article_id, payload)
else:
result = await api.admin_create_article(token, payload)
self._save_btn.disabled = False
self._loading.visible = False
if result:
d.snack(self.page, "Сохранено")
if not self.article_id:
self.article_id = result["id"]
self.page.go(f"/admin/articles/{self.article_id}")
else:
self._fill_form(result)
self.page.update()
else:
d.snack(self.page, "Ошибка сохранения", error=True)
self.page.update()
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _slugify(text: str) -> str:
import re
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[\s_-]+", "-", text)
return text[:100]
def _content_type(ext: str, kind: str) -> str:
image_map = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
"gif": "image/gif", "webp": "image/webp", "svg": "image/svg+xml"}
video_map = {"mp4": "video/mp4", "webm": "video/webm", "ogg": "video/ogg", "mov": "video/quicktime"}
if kind == "image":
return image_map.get(ext, "image/jpeg")
return video_map.get(ext, "video/mp4")
async def _read_file(f) -> bytes | None:
try:
if hasattr(f, "path") and f.path:
with open(f.path, "rb") as fh:
return fh.read()
except Exception:
pass
return None

207
web/views/articles_list.py Normal file
View File

@@ -0,0 +1,207 @@
import flet as ft
import designer as d
import api_client as api
from views.dashboard import _admin_appbar, _sidebar
class ArticlesListView:
def __init__(self, page: ft.Page):
self.page = page
self._table_col = ft.Column(spacing=0, expand=True)
self._filter = "all"
def build_page_view(self) -> ft.View:
filter_tabs = ft.Tabs(
selected_index=0,
on_change=self._on_filter,
tabs=[
ft.Tab(text="Все"),
ft.Tab(text="Опубликовано"),
ft.Tab(text="Черновики"),
],
)
return ft.View(
route="/admin/articles",
bgcolor=d.BACKGROUND,
appbar=_admin_appbar(self.page, "Статьи"),
controls=[
ft.Row(
[
_sidebar(self.page, active="/admin/articles"),
ft.Container(
content=ft.Column(
[
ft.Row(
[
d.headline("Статьи", size=22),
ft.Row(
[
d.btn_primary(
"Новая статья",
icon=ft.icons.ADD,
on_click=lambda e: self.page.go("/admin/articles/new"),
)
]
),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
filter_tabs,
self._table_col,
],
expand=True,
scroll=ft.ScrollMode.AUTO,
spacing=16,
),
expand=True,
padding=24,
),
],
expand=True,
spacing=0,
)
],
)
async def load(self):
await self._load_articles()
async def _on_filter(self, e):
idx = e.control.selected_index
self._filter = ["all", "published", "draft"][idx]
await self._load_articles()
async def _load_articles(self):
token = self.page.session.get("token")
status = None if self._filter == "all" else self._filter
data = await api.admin_list_articles(token, limit=200, status=status)
items = data.get("items", []) if data else []
self._render(items)
def _render(self, items: list):
if not items:
self._table_col.controls = [
ft.Container(
content=d.empty_state(ft.icons.ARTICLE_OUTLINED, "Статей пока нет"),
alignment=ft.alignment.center,
padding=40,
)
]
self.page.update()
return
header = ft.Container(
content=ft.Row(
[
ft.Text("Заголовок", size=12, color=d.TEXT_SECONDARY, expand=3),
ft.Text("Категория", size=12, color=d.TEXT_SECONDARY, expand=1),
ft.Text("Статус", size=12, color=d.TEXT_SECONDARY, width=110),
ft.Text("Дата", size=12, color=d.TEXT_SECONDARY, width=110),
ft.Text("Действия", size=12, color=d.TEXT_SECONDARY, width=130),
],
spacing=8,
),
bgcolor=d.BACKGROUND,
padding=ft.padding.symmetric(horizontal=16, vertical=10),
border_radius=ft.BorderRadius(8, 8, 0, 0),
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
)
rows = [header] + [self._build_row(a) for a in items]
self._table_col.controls = [
ft.Container(
content=ft.Column(rows, spacing=0),
bgcolor=d.SURFACE,
border_radius=8,
border=ft.border.all(1, d.DIVIDER),
)
]
self.page.update()
def _build_row(self, article: dict) -> ft.Container:
pub_date = ""
if article.get("published_at"):
pub_date = article["published_at"][:10]
elif article.get("created_at"):
pub_date = article["created_at"][:10]
cat_name = article.get("category", {})
cat_name = (cat_name or {}).get("name", "")
return ft.Container(
content=ft.Row(
[
ft.Text(
article["title"],
size=14,
expand=3,
max_lines=2,
overflow=ft.TextOverflow.ELLIPSIS,
),
ft.Text(cat_name, size=13, color=d.TEXT_SECONDARY, expand=1),
ft.Container(content=d.status_badge(article.get("status", "draft")), width=110),
ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY, width=110),
ft.Row(
[
d.btn_icon(
ft.icons.EDIT_OUTLINED,
"Редактировать",
on_click=lambda e, aid=article["id"]: self.page.go(f"/admin/articles/{aid}"),
color=d.PRIMARY,
),
*([
d.btn_icon(
ft.icons.PUBLISH,
"Опубликовать",
on_click=lambda e, aid=article["id"]: self.page.run_task(self._publish, aid),
color=d.SUCCESS,
)
] if article.get("status") == "draft" else []),
d.btn_icon(
ft.icons.DELETE_OUTLINE,
"Удалить",
on_click=lambda e, a=article: self._confirm_delete(a),
color=d.ERROR,
),
],
spacing=0,
width=130,
),
],
spacing=8,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
padding=ft.padding.symmetric(horizontal=16, vertical=12),
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
ink=True,
)
def _confirm_delete(self, article: dict):
dlg = d.confirm_dialog(
self.page,
"Удалить статью?",
f'"{article["title"]}" будет удалена навсегда.',
lambda: self.page.run_task(self._delete, article["id"]),
)
self.page.dialog = dlg
dlg.open = True
self.page.update()
async def _delete(self, article_id: str):
token = self.page.session.get("token")
ok = await api.admin_delete_article(token, article_id)
if ok:
d.snack(self.page, "Статья удалена")
await self._load_articles()
else:
d.snack(self.page, "Ошибка удаления", error=True)
async def _publish(self, article_id: str):
token = self.page.session.get("token")
ok = await api.admin_publish_article(token, article_id)
if ok:
d.snack(self.page, "Статья опубликована")
await self._load_articles()
else:
d.snack(self.page, "Ошибка публикации", error=True)

165
web/views/categories.py Normal file
View File

@@ -0,0 +1,165 @@
import flet as ft
import designer as d
import api_client as api
from views.dashboard import _admin_appbar, _sidebar
class CategoriesView:
def __init__(self, page: ft.Page):
self.page = page
self._list_col = ft.Column(spacing=0)
self._name_field = d.text_field("Название категории")
self._slug_field = d.text_field("Slug (необязательно)", hint="автогенерация из названия")
self._edit_id: str | None = None
def build_page_view(self) -> ft.View:
form = ft.Container(
content=ft.Column(
[
d.headline("Категории", size=22),
ft.Divider(height=8, color="transparent"),
ft.Container(
content=ft.Column(
[
d.headline("Добавить категорию", size=16),
ft.Divider(height=4, color="transparent"),
self._name_field,
self._slug_field,
ft.Row(
[
d.btn_primary("Сохранить", on_click=lambda e: self.page.run_task(self._save)),
d.btn_outlined("Сбросить", on_click=self._reset, color=d.TEXT_SECONDARY),
],
spacing=12,
),
],
spacing=12,
),
bgcolor=d.SURFACE,
border_radius=12,
padding=20,
border=ft.border.all(1, d.DIVIDER),
),
ft.Divider(height=16, color="transparent"),
d.headline("Список категорий", size=16),
self._list_col,
],
scroll=ft.ScrollMode.AUTO,
spacing=8,
),
expand=True,
padding=24,
)
return ft.View(
route="/admin/categories",
bgcolor=d.BACKGROUND,
appbar=_admin_appbar(self.page, "Категории"),
controls=[
ft.Row(
[_sidebar(self.page, active="/admin/categories"), form],
expand=True,
spacing=0,
)
],
)
async def load(self):
await self._reload()
async def _reload(self):
token = self.page.session.get("token")
cats = await api.admin_list_categories(token)
self._list_col.controls = [self._build_row(c) for c in cats] if cats else [
ft.Container(
content=d.empty_state(ft.icons.CATEGORY_OUTLINED, "Категорий пока нет"),
padding=30,
alignment=ft.alignment.center,
)
]
self.page.update()
def _build_row(self, cat: dict) -> ft.Container:
return ft.Container(
content=ft.Row(
[
ft.Column(
[
ft.Text(cat["name"], size=14, weight=ft.FontWeight.W_500),
ft.Text(cat["slug"], size=12, color=d.TEXT_SECONDARY),
],
spacing=2,
expand=True,
),
d.btn_icon(
ft.icons.EDIT_OUTLINED, "Редактировать",
on_click=lambda e, c=cat: self._start_edit(c),
color=d.PRIMARY,
),
d.btn_icon(
ft.icons.DELETE_OUTLINE, "Удалить",
on_click=lambda e, c=cat: self._confirm_delete(c),
color=d.ERROR,
),
],
spacing=8,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
bgcolor=d.SURFACE,
border_radius=8,
padding=ft.padding.symmetric(horizontal=16, vertical=12),
border=ft.border.all(1, d.DIVIDER),
margin=ft.margin.only(bottom=8),
)
def _start_edit(self, cat: dict):
self._edit_id = cat["id"]
self._name_field.value = cat["name"]
self._slug_field.value = cat["slug"]
self.page.update()
def _reset(self, e=None):
self._edit_id = None
self._name_field.value = ""
self._slug_field.value = ""
self.page.update()
async def _save(self):
token = self.page.session.get("token")
name = (self._name_field.value or "").strip()
slug = (self._slug_field.value or "").strip()
if not name:
d.snack(self.page, "Введите название", error=True)
return
if self._edit_id:
result = await api.admin_update_category(token, self._edit_id, name, slug)
else:
result = await api.admin_create_category(token, name, slug)
if result:
d.snack(self.page, "Сохранено")
self._reset()
await self._reload()
else:
d.snack(self.page, "Ошибка сохранения", error=True)
def _confirm_delete(self, cat: dict):
dlg = d.confirm_dialog(
self.page,
"Удалить категорию?",
f'"{cat["name"]}" будет удалена. Статьи останутся без категории.',
lambda: self.page.run_task(self._delete, cat["id"]),
)
self.page.dialog = dlg
dlg.open = True
self.page.update()
async def _delete(self, cat_id: str):
token = self.page.session.get("token")
ok = await api.admin_delete_category(token, cat_id)
if ok:
d.snack(self.page, "Категория удалена")
await self._reload()
else:
d.snack(self.page, "Ошибка удаления", error=True)

148
web/views/dashboard.py Normal file
View File

@@ -0,0 +1,148 @@
import flet as ft
import designer as d
import api_client as api
class DashboardView:
def __init__(self, page: ft.Page):
self.page = page
self._stats_row = ft.Row(wrap=True, spacing=16, run_spacing=16)
self._recent = ft.Column(spacing=0)
def build_page_view(self) -> ft.View:
return ft.View(
route="/admin",
bgcolor=d.BACKGROUND,
appbar=_admin_appbar(self.page, "Главная"),
controls=[
ft.Row(
[
_sidebar(self.page, active="/admin"),
ft.Container(
content=ft.Column(
[
d.headline("Обзор", size=22),
ft.Divider(height=8, color="transparent"),
self._stats_row,
ft.Divider(height=16, color="transparent"),
d.headline("Последние статьи", size=18),
ft.Divider(height=8, color="transparent"),
self._recent,
],
scroll=ft.ScrollMode.AUTO,
),
expand=True,
padding=24,
),
],
expand=True,
spacing=0,
)
],
)
async def load(self):
token = self.page.session.get("token")
data = await api.admin_list_articles(token, limit=100)
if not data:
return
items = data.get("items", [])
total = data.get("total", 0)
published = sum(1 for a in items if a.get("status") == "published")
drafts = total - published
self._stats_row.controls = [
d.stat_card("Всего статей", str(total), ft.icons.ARTICLE, d.PRIMARY),
d.stat_card("Опубликовано", str(published), ft.icons.CHECK_CIRCLE, d.SUCCESS),
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.page.update()
def _recent_item(article: dict, page: ft.Page) -> ft.Container:
return ft.Container(
content=ft.Row(
[
d.status_badge(article.get("status", "draft")),
ft.Text(article["title"], size=14, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
ft.TextButton(
"Редактировать",
on_click=lambda e, aid=article["id"]: page.go(f"/admin/articles/{aid}"),
),
],
spacing=12,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
padding=ft.padding.symmetric(vertical=10, horizontal=4),
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
)
# ─── Общие admin-компоненты ─────────────────────────────────────────────────
def _admin_appbar(page: ft.Page, title: str) -> ft.AppBar:
return ft.AppBar(
title=ft.Text(title, size=18, weight=ft.FontWeight.W_600, color=ft.colors.WHITE),
bgcolor=d.ADMIN_SIDEBAR,
color=ft.colors.WHITE,
actions=[
ft.TextButton(
"На сайт",
icon=ft.icons.OPEN_IN_NEW,
style=ft.ButtonStyle(color=ft.colors.WHITE70),
on_click=lambda e: page.launch_url("/"),
),
ft.IconButton(
ft.icons.LOGOUT,
tooltip="Выйти",
icon_color=ft.colors.WHITE70,
on_click=lambda e: _logout(page),
),
],
)
def _logout(page: ft.Page):
page.session.remove("token")
page.session.remove("username")
page.go("/admin/login")
def _sidebar(page: ft.Page, active: str = "") -> ft.Container:
items = [
(ft.icons.DASHBOARD, "Главная", "/admin"),
(ft.icons.ARTICLE, "Статьи", "/admin/articles"),
(ft.icons.CATEGORY, "Категории", "/admin/categories"),
(ft.icons.PERM_MEDIA, "Медиа", "/admin/media"),
]
def nav_item(icon, label, route):
is_active = active == route
return ft.Container(
content=ft.Row(
[
ft.Icon(icon, color=d.ADMIN_SIDEBAR_TEXT if not is_active else d.ACCENT, size=20),
ft.Text(label, color=d.ADMIN_SIDEBAR_TEXT, size=14, weight=ft.FontWeight.W_500 if is_active else ft.FontWeight.NORMAL),
],
spacing=12,
),
bgcolor=d.ADMIN_SIDEBAR_ACTIVE if is_active else None,
border_radius=8,
padding=ft.padding.symmetric(horizontal=16, vertical=12),
on_click=lambda e, r=route: page.go(r),
ink=True,
)
return ft.Container(
content=ft.Column(
[ft.Container(height=16)] + [nav_item(i, l, r) for i, l, r in items],
spacing=2,
),
bgcolor=d.ADMIN_SIDEBAR,
width=220,
padding=ft.padding.symmetric(horizontal=8, vertical=8),
)

76
web/views/login.py Normal file
View File

@@ -0,0 +1,76 @@
import flet as ft
import designer as d
import api_client as api
class LoginView:
def __init__(self, page: ft.Page):
self.page = page
self._username = d.text_field("Логин", prefix_icon=ft.icons.PERSON_OUTLINE)
self._password = d.text_field("Пароль", password=True, prefix_icon=ft.icons.LOCK_OUTLINE)
self._error = ft.Text("", color=d.ERROR, size=13)
self._btn = d.btn_primary("Войти", on_click=self._on_login, width=280)
def build_page_view(self) -> ft.View:
self._username.on_submit = self._on_login
self._password.on_submit = self._on_login
form = ft.Container(
content=ft.Column(
[
ft.Icon(ft.icons.NEWSPAPER, size=56, color=d.PRIMARY),
d.headline("Новостная CMS", size=24),
d.caption("Войдите в панель управления", color=d.TEXT_SECONDARY),
ft.Divider(height=20, color="transparent"),
self._username,
self._password,
self._error,
ft.Divider(height=8, color="transparent"),
self._btn,
],
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=14,
width=320,
),
bgcolor=d.SURFACE,
border_radius=16,
padding=40,
shadow=ft.BoxShadow(blur_radius=24, color=ft.colors.with_opacity(0.10, ft.colors.BLACK)),
)
return ft.View(
route="/admin/login",
bgcolor=d.BACKGROUND,
controls=[
ft.Container(
content=form,
alignment=ft.alignment.center,
expand=True,
)
],
vertical_alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
)
async def _on_login(self, e=None):
self._error.value = ""
username = self._username.value.strip()
password = self._password.value.strip()
if not username or not password:
self._error.value = "Заполните все поля"
self.page.update()
return
self._btn.disabled = True
self.page.update()
token = await api.admin_login(username, password)
self._btn.disabled = False
if token:
self.page.session.set("token", token)
self.page.session.set("username", username)
self.page.go("/admin")
else:
self._error.value = "Неверный логин или пароль"
self.page.update()

217
web/views/media_library.py Normal file
View File

@@ -0,0 +1,217 @@
import flet as ft
import designer as d
import api_client as api
from views.dashboard import _admin_appbar, _sidebar
class MediaLibraryView:
def __init__(self, page: ft.Page):
self.page = page
self._grid = ft.GridView(
expand=True,
runs_count=4,
max_extent=200,
child_aspect_ratio=1.0,
spacing=12,
run_spacing=12,
)
self._filter = None
self._picker = ft.FilePicker(on_result=self._on_files_picked)
def build_page_view(self) -> ft.View:
filter_tabs = ft.Tabs(
selected_index=0,
on_change=self._on_filter,
tabs=[ft.Tab(text="Все"), ft.Tab(text="Изображения"), ft.Tab(text="Видео")],
)
return ft.View(
route="/admin/media",
bgcolor=d.BACKGROUND,
appbar=_admin_appbar(self.page, "Медиа"),
controls=[
ft.Row(
[
_sidebar(self.page, active="/admin/media"),
ft.Container(
content=ft.Column(
[
ft.Row(
[
d.headline("Медиатека", size=22),
d.btn_primary(
"Загрузить файлы",
icon=ft.icons.UPLOAD,
on_click=lambda e: self._picker.pick_files(
allow_multiple=True,
dialog_title="Выберите изображения или видео",
),
),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
filter_tabs,
self._grid,
],
expand=True,
spacing=16,
),
expand=True,
padding=24,
),
],
expand=True,
spacing=0,
),
self._picker,
],
)
async def load(self):
await self._reload()
async def _on_filter(self, e):
self._filter = [None, "image", "video"][e.control.selected_index]
await self._reload()
async def _reload(self):
token = self.page.session.get("token")
items = await api.admin_list_media(token, limit=200, media_type=self._filter)
self._render(items)
def _render(self, items: list):
if not items:
self._grid.controls = [
d.empty_state(ft.icons.PERM_MEDIA_OUTLINED, "Медиафайлов пока нет")
]
self.page.update()
return
self._grid.controls = [self._build_card(m) for m in items]
self.page.update()
def _build_card(self, m: dict) -> ft.Container:
is_image = m.get("media_type") == "image"
content = (
ft.Image(src=m["url"], fit=ft.ImageFit.COVER, expand=True, error_content=ft.Icon(ft.icons.BROKEN_IMAGE))
if is_image
else ft.Column(
[ft.Icon(ft.icons.PLAY_CIRCLE_OUTLINED, size=40, color=d.TEXT_SECONDARY),
ft.Text("Видео", size=11, color=d.TEXT_SECONDARY)],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
expand=True,
)
)
size_kb = round(m.get("size_bytes", 0) / 1024, 1)
name = m.get("filename", "")[:20]
return ft.Container(
content=ft.Stack(
[
ft.Container(content=content, expand=True, bgcolor="#F0F0F0", border_radius=8),
ft.Container(
content=ft.Row(
[
ft.Container(
content=ft.Column(
[
ft.Text(name, size=10, color=ft.colors.WHITE, max_lines=1,
overflow=ft.TextOverflow.ELLIPSIS),
ft.Text(f"{size_kb} КБ", size=9, color=ft.colors.WHITE70),
],
spacing=2,
expand=True,
),
expand=True,
),
ft.Row(
[
ft.IconButton(
ft.icons.COPY,
icon_size=14,
icon_color=ft.colors.WHITE,
tooltip="Копировать URL",
on_click=lambda e, url=m["url"]: self._copy_url(url),
),
ft.IconButton(
ft.icons.DELETE_OUTLINE,
icon_size=14,
icon_color=ft.colors.WHITE,
tooltip="Удалить",
on_click=lambda e, mid=m["id"]: self._confirm_delete(mid, m.get("filename", "")),
),
],
spacing=0,
),
],
spacing=0,
),
bgcolor=ft.colors.with_opacity(0.6, ft.colors.BLACK),
border_radius=ft.BorderRadius(0, 0, 8, 8),
padding=ft.padding.symmetric(horizontal=8, vertical=6),
bottom=0,
left=0,
right=0,
),
],
expand=True,
),
height=180,
border_radius=8,
border=ft.border.all(1, d.DIVIDER),
clip_behavior=ft.ClipBehavior.HARD_EDGE,
)
def _copy_url(self, url: str):
self.page.set_clipboard(url)
d.snack(self.page, "URL скопирован в буфер обмена")
def _confirm_delete(self, media_id: str, filename: str):
dlg = d.confirm_dialog(
self.page,
"Удалить файл?",
f'"{filename}" будет удалён с сервера.',
lambda: self.page.run_task(self._delete, media_id),
)
self.page.dialog = dlg
dlg.open = True
self.page.update()
async def _delete(self, media_id: str):
token = self.page.session.get("token")
ok = await api.admin_delete_media(token, media_id)
if ok:
d.snack(self.page, "Файл удалён")
await self._reload()
else:
d.snack(self.page, "Ошибка удаления", error=True)
async def _on_files_picked(self, e: ft.FilePickerResultEvent):
if not e.files:
return
token = self.page.session.get("token")
uploaded = 0
for f in e.files:
ext = (f.name or "").rsplit(".", 1)[-1].lower()
video_exts = {"mp4", "webm", "ogg", "mov"}
kind = "video" if ext in video_exts else "image"
ct_map = {
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
"gif": "image/gif", "webp": "image/webp",
"mp4": "video/mp4", "webm": "video/webm", "ogg": "video/ogg", "mov": "video/quicktime",
}
ct = ct_map.get(ext, "application/octet-stream")
try:
if hasattr(f, "path") and f.path:
with open(f.path, "rb") as fh:
data = fh.read()
result = await api.admin_upload_media(token, data, f.name, ct)
if result:
uploaded += 1
except Exception:
pass
d.snack(self.page, f"Загружено файлов: {uploaded}")
await self._reload()

189
web/views/news_detail.py Normal file
View File

@@ -0,0 +1,189 @@
import flet as ft
import designer as d
import api_client as api
import theme_manager
class NewsDetailView:
def __init__(self, page: ft.Page, slug: str):
self.page = page
self.slug = slug
self._content_col = ft.Column(
[ft.Container(content=d.loader(), alignment=ft.alignment.center, padding=40)],
expand=True,
)
def build_page_view(self) -> ft.View:
header = ft.Container(
content=ft.Row(
[
ft.IconButton(
ft.icons.ARROW_BACK,
icon_color=ft.colors.WHITE,
tooltip="Назад",
on_click=lambda e: self.page.go("/"),
),
ft.Row([
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=24),
ft.Text("Новости", size=18, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
], spacing=8),
ft.Container(width=40),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
bgcolor=d.ADMIN_SIDEBAR,
padding=ft.padding.symmetric(horizontal=16, vertical=12),
)
return ft.View(
route=f"/news/{self.slug}",
bgcolor=d.BACKGROUND,
controls=[
ft.Column(
[
header,
ft.Container(
content=self._content_col,
alignment=ft.alignment.top_center,
expand=True,
padding=ft.padding.symmetric(horizontal=24, vertical=20),
),
],
spacing=0,
expand=True,
scroll=ft.ScrollMode.AUTO,
)
],
)
async def load(self):
article = await api.get_article(self.slug)
if not article:
theme_manager.reset_font(self.page)
self._content_col.controls = [
ft.Container(
content=ft.Column(
[
ft.Icon(ft.icons.ARTICLE_OUTLINED, size=64, color=d.DIVIDER),
d.headline("Статья не найдена", size=20),
d.btn_outlined("На главную", on_click=lambda e: self.page.go("/")),
],
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=16,
),
alignment=ft.alignment.center,
padding=60,
)
]
self.page.update()
return
font = article.get("font_family", "Merriweather")
theme_manager.apply_article_font(self.page, font)
self._render(article)
def _render(self, a: dict):
font = a.get("font_family", "Merriweather")
cat = a.get("category")
tags = a.get("tags", [])
pub_date = a.get("published_at", "")[:10] if a.get("published_at") else ""
meta_chips = ft.Row(wrap=True, spacing=8, run_spacing=8)
if cat:
meta_chips.controls.append(
ft.Container(
content=ft.Text(cat["name"], size=12, color=ft.colors.WHITE),
bgcolor=d.PRIMARY,
border_radius=4,
padding=ft.padding.symmetric(horizontal=10, vertical=4),
)
)
for tag in tags:
meta_chips.controls.append(
ft.Container(
content=ft.Text(f"#{tag['name']}", size=12, color=d.PRIMARY),
bgcolor=ft.colors.with_opacity(0.1, d.PRIMARY),
border_radius=4,
padding=ft.padding.symmetric(horizontal=10, vertical=4),
)
)
controls = [
d.headline(a["title"], size=30, font=d.FONT_DISPLAY),
ft.Row(
[
meta_chips,
ft.Row([
ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY),
ft.Row([
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
ft.Text(str(a.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
], spacing=4),
], spacing=16),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
wrap=True,
),
]
if a.get("cover_url"):
controls.append(
ft.Container(
content=ft.Image(
src=a["cover_url"],
width=float("inf"),
height=400,
fit=ft.ImageFit.COVER,
border_radius=12,
),
border_radius=12,
clip_behavior=ft.ClipBehavior.HARD_EDGE,
)
)
if a.get("excerpt"):
controls.append(
ft.Container(
content=ft.Text(
a["excerpt"],
size=16,
color=d.TEXT_SECONDARY,
italic=True,
),
border=ft.Border(left=ft.BorderSide(4, d.PRIMARY)),
padding=ft.padding.only(left=16, top=8, bottom=8),
)
)
controls.append(d.divider())
controls.append(
ft.Markdown(
value=a.get("content", ""),
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
on_tap_link=lambda e: self.page.launch_url(e.data),
auto_follow_links=False,
)
)
controls.append(ft.Divider(height=32, color="transparent"))
controls.append(
ft.TextButton(
"← Назад к новостям",
icon=ft.icons.ARROW_BACK,
style=ft.ButtonStyle(color=d.PRIMARY),
on_click=lambda e: self.page.go("/"),
)
)
self._content_col.controls = [
ft.Container(
content=ft.Column(controls, spacing=16),
bgcolor=d.SURFACE,
border_radius=12,
padding=32,
border=ft.border.all(1, d.DIVIDER),
)
]
self.page.update()

240
web/views/news_feed.py Normal file
View File

@@ -0,0 +1,240 @@
import flet as ft
import designer as d
import api_client as api
class NewsFeedView:
def __init__(self, page: ft.Page):
self.page = page
self._articles: list = []
self._offset = 0
self._limit = 20
self._has_more = True
self._loading = False
self._category: str | None = None
self._categories: list = []
self._list_col = ft.Column(spacing=16)
self._loader = ft.Container(
content=d.loader(36),
alignment=ft.alignment.center,
padding=20,
visible=True,
)
self._load_more_btn = ft.ElevatedButton(
"Загрузить ещё",
icon=ft.icons.EXPAND_MORE,
on_click=lambda e: self.page.run_task(self._load_more),
visible=False,
style=ft.ButtonStyle(
bgcolor=d.SURFACE,
color=d.PRIMARY,
shape=ft.RoundedRectangleBorder(radius=8),
),
)
self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8)
def build_page_view(self) -> ft.View:
header = ft.Container(
content=ft.Row(
[
ft.Row([
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=28),
ft.Text("Новости", size=20, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
], spacing=8),
ft.Row([
ft.TextButton(
"Войти",
icon=ft.icons.ADMIN_PANEL_SETTINGS_OUTLINED,
style=ft.ButtonStyle(color=ft.colors.WHITE70),
on_click=lambda e: self.page.go("/admin/login"),
)
]),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
bgcolor=d.ADMIN_SIDEBAR,
padding=ft.padding.symmetric(horizontal=24, vertical=14),
)
cat_section = ft.Container(
content=ft.Column([
ft.Container(content=self._cat_row, padding=ft.padding.symmetric(horizontal=24, vertical=8)),
]),
bgcolor=d.SURFACE,
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
)
main_content = ft.Container(
content=ft.Column(
[
self._list_col,
self._loader,
ft.Container(
content=self._load_more_btn,
alignment=ft.alignment.center,
padding=16,
),
],
spacing=0,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
alignment=ft.alignment.top_center,
)
return ft.View(
route="/",
bgcolor=d.BACKGROUND,
controls=[
ft.Column(
[
header,
cat_section,
ft.Container(
content=main_content,
alignment=ft.alignment.top_center,
expand=True,
),
],
spacing=0,
expand=True,
scroll=ft.ScrollMode.AUTO,
)
],
)
async def load(self):
self._categories = await api.get_categories()
self._render_categories()
await self._load_articles()
async def _load_more(self):
if self._loading or not self._has_more:
return
self._loading = True
self._load_more_btn.disabled = True
self.page.update()
self._offset += self._limit
await self._load_articles()
self._loading = False
self._load_more_btn.disabled = False
self.page.update()
async def _load_articles(self):
self._loader.visible = True
self.page.update()
data = await api.get_news(offset=self._offset, limit=self._limit, category=self._category)
self._loader.visible = False
if not data:
self.page.update()
return
items = data.get("items", [])
self._articles.extend(items)
self._has_more = data.get("has_more", False)
for a in items:
self._list_col.controls.append(self._build_card(a))
self._load_more_btn.visible = self._has_more
self.page.update()
def _render_categories(self):
self._cat_row.controls = [
self._cat_chip(None, "Все")
] + [
self._cat_chip(c["slug"], c["name"]) for c in self._categories
]
self.page.update()
def _cat_chip(self, slug: str | None, label: str) -> ft.Chip:
is_active = self._category == slug
return ft.Chip(
label=ft.Text(label, size=13),
bgcolor=d.PRIMARY if is_active else d.SURFACE,
label_style=ft.TextStyle(color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY),
on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s),
padding=ft.padding.symmetric(horizontal=12, vertical=6),
side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
)
async def _filter_category(self, slug: str | None):
self._category = slug
self._articles = []
self._offset = 0
self._has_more = True
self._list_col.controls.clear()
self._render_categories()
await self._load_articles()
def _build_card(self, article: dict) -> ft.Container:
pub_date = ""
if article.get("published_at"):
pub_date = article["published_at"][:10]
cat = article.get("category")
cat_chip = ft.Container(
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE),
bgcolor=d.PRIMARY,
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3),
visible=bool(cat),
) if cat else ft.Container(visible=False)
cover = ft.Image(
src=article["cover_url"],
width=260,
height=160,
fit=ft.ImageFit.COVER,
border_radius=ft.BorderRadius(0, 8, 8, 0),
) if article.get("cover_url") else ft.Container(visible=False)
text_part = ft.Column(
[
ft.Row([cat_chip, ft.Text(pub_date, size=11, color=d.TEXT_SECONDARY)], spacing=8),
ft.Text(
article["title"],
size=18,
weight=ft.FontWeight.BOLD,
color=d.TEXT_PRIMARY,
max_lines=3,
overflow=ft.TextOverflow.ELLIPSIS,
),
ft.Text(
article.get("excerpt", ""),
size=13,
color=d.TEXT_SECONDARY,
max_lines=3,
overflow=ft.TextOverflow.ELLIPSIS,
),
ft.Row(
[
ft.Row([
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
], spacing=4),
ft.TextButton(
"Читать →",
style=ft.ButtonStyle(color=d.PRIMARY),
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
],
spacing=8,
expand=True,
)
return ft.Container(
content=ft.Row([ft.Container(content=text_part, expand=True, padding=20), cover], spacing=0),
bgcolor=d.SURFACE,
border_radius=12,
border=ft.border.all(1, d.DIVIDER),
shadow=ft.BoxShadow(blur_radius=6, color=ft.colors.with_opacity(0.05, ft.colors.BLACK)),
clip_behavior=ft.ClipBehavior.HARD_EDGE,
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
ink=True,
)