From 0ac1c8a8622ed2f94735026cfdfa49b0a483efd4 Mon Sep 17 00:00:00 2001 From: jze9 Date: Wed, 13 May 2026 19:05:07 +0500 Subject: [PATCH] test ferst --- .gitignore | 4 + api/Dockerfile | 6 + api/bd/__init__.py | 0 api/bd/database.py | 44 +++ api/bd/models.py | 73 ++++ api/main.py | 82 +++++ api/requirements.txt | 12 + api/route/__init__.py | 0 api/route/admin_articles.py | 224 +++++++++++++ api/route/admin_auth.py | 62 ++++ api/route/admin_categories.py | 78 +++++ api/route/admin_media.py | 138 ++++++++ api/route/deps.py | 17 + api/route/public.py | 132 ++++++++ docker-compose.yml | 65 ++++ docker-compose_db.yml | 50 +++ init_db.sh | 185 ++++++++++ web/Dockerfile | 6 + web/api_client.py | 232 +++++++++++++ web/designer.py | 252 ++++++++++++++ web/main.py | 41 +++ web/requirements.txt | 3 + web/router.py | 70 ++++ web/theme_manager.py | 69 ++++ web/views/__init__.py | 0 web/views/article_editor.py | 615 ++++++++++++++++++++++++++++++++++ web/views/articles_list.py | 207 ++++++++++++ web/views/categories.py | 165 +++++++++ web/views/dashboard.py | 148 ++++++++ web/views/login.py | 76 +++++ web/views/media_library.py | 217 ++++++++++++ web/views/news_detail.py | 189 +++++++++++ web/views/news_feed.py | 240 +++++++++++++ 33 files changed, 3702 insertions(+) create mode 100644 .gitignore create mode 100644 api/Dockerfile create mode 100644 api/bd/__init__.py create mode 100644 api/bd/database.py create mode 100644 api/bd/models.py create mode 100644 api/main.py create mode 100644 api/requirements.txt create mode 100644 api/route/__init__.py create mode 100644 api/route/admin_articles.py create mode 100644 api/route/admin_auth.py create mode 100644 api/route/admin_categories.py create mode 100644 api/route/admin_media.py create mode 100644 api/route/deps.py create mode 100644 api/route/public.py create mode 100644 docker-compose.yml create mode 100644 docker-compose_db.yml create mode 100755 init_db.sh create mode 100644 web/Dockerfile create mode 100644 web/api_client.py create mode 100644 web/designer.py create mode 100644 web/main.py create mode 100644 web/requirements.txt create mode 100644 web/router.py create mode 100644 web/theme_manager.py create mode 100644 web/views/__init__.py create mode 100644 web/views/article_editor.py create mode 100644 web/views/articles_list.py create mode 100644 web/views/categories.py create mode 100644 web/views/dashboard.py create mode 100644 web/views/login.py create mode 100644 web/views/media_library.py create mode 100644 web/views/news_detail.py create mode 100644 web/views/news_feed.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ca5310 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.claude/ +data/ +.env +.env.example diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..063ef6a --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/bd/__init__.py b/api/bd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/bd/database.py b/api/bd/database.py new file mode 100644 index 0000000..f3785fe --- /dev/null +++ b/api/bd/database.py @@ -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 diff --git a/api/bd/models.py b/api/bd/models.py new file mode 100644 index 0000000..461df0c --- /dev/null +++ b/api/bd/models.py @@ -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) diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..9ada67c --- /dev/null +++ b/api/main.py @@ -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) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..a657c84 --- /dev/null +++ b/api/requirements.txt @@ -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 diff --git a/api/route/__init__.py b/api/route/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/route/admin_articles.py b/api/route/admin_articles.py new file mode 100644 index 0000000..097d111 --- /dev/null +++ b/api/route/admin_articles.py @@ -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()} diff --git a/api/route/admin_auth.py b/api/route/admin_auth.py new file mode 100644 index 0000000..33ad2f6 --- /dev/null +++ b/api/route/admin_auth.py @@ -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} diff --git a/api/route/admin_categories.py b/api/route/admin_categories.py new file mode 100644 index 0000000..161e28a --- /dev/null +++ b/api/route/admin_categories.py @@ -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} diff --git a/api/route/admin_media.py b/api/route/admin_media.py new file mode 100644 index 0000000..3710861 --- /dev/null +++ b/api/route/admin_media.py @@ -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} diff --git a/api/route/deps.py b/api/route/deps.py new file mode 100644 index 0000000..858f530 --- /dev/null +++ b/api/route/deps.py @@ -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") diff --git a/api/route/public.py b/api/route/public.py new file mode 100644 index 0000000..4da6fda --- /dev/null +++ b/api/route/public.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..196c729 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker-compose_db.yml b/docker-compose_db.yml new file mode 100644 index 0000000..4c13ce8 --- /dev/null +++ b/docker-compose_db.yml @@ -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 diff --git a/init_db.sh b/init_db.sh new file mode 100755 index 0000000..51ab448 --- /dev/null +++ b/init_db.sh @@ -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 "$@" diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..a100cb9 --- /dev/null +++ b/web/Dockerfile @@ -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"] diff --git a/web/api_client.py b/web/api_client.py new file mode 100644 index 0000000..ff8bb8a --- /dev/null +++ b/web/api_client.py @@ -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 diff --git a/web/designer.py b/web/designer.py new file mode 100644 index 0000000..d431255 --- /dev/null +++ b/web/designer.py @@ -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, + ) diff --git a/web/main.py b/web/main.py new file mode 100644 index 0000000..4129a7b --- /dev/null +++ b/web/main.py @@ -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) diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..cb12dd7 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,3 @@ +flet==0.24.1 +uvicorn[standard]==0.30.6 +httpx==0.27.2 diff --git a/web/router.py b/web/router.py new file mode 100644 index 0000000..f603f61 --- /dev/null +++ b/web/router.py @@ -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) diff --git a/web/theme_manager.py b/web/theme_manager.py new file mode 100644 index 0000000..c2c8b77 --- /dev/null +++ b/web/theme_manager.py @@ -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() diff --git a/web/views/__init__.py b/web/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/views/article_editor.py b/web/views/article_editor.py new file mode 100644 index 0000000..46ddfb0 --- /dev/null +++ b/web/views/article_editor.py @@ -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='' + selected + ''; + 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'' + ) + 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 diff --git a/web/views/articles_list.py b/web/views/articles_list.py new file mode 100644 index 0000000..c721f12 --- /dev/null +++ b/web/views/articles_list.py @@ -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) diff --git a/web/views/categories.py b/web/views/categories.py new file mode 100644 index 0000000..60657cb --- /dev/null +++ b/web/views/categories.py @@ -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) diff --git a/web/views/dashboard.py b/web/views/dashboard.py new file mode 100644 index 0000000..534dcf9 --- /dev/null +++ b/web/views/dashboard.py @@ -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), + ) diff --git a/web/views/login.py b/web/views/login.py new file mode 100644 index 0000000..ddaa451 --- /dev/null +++ b/web/views/login.py @@ -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() diff --git a/web/views/media_library.py b/web/views/media_library.py new file mode 100644 index 0000000..f3c004d --- /dev/null +++ b/web/views/media_library.py @@ -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() diff --git a/web/views/news_detail.py b/web/views/news_detail.py new file mode 100644 index 0000000..0284724 --- /dev/null +++ b/web/views/news_detail.py @@ -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() diff --git a/web/views/news_feed.py b/web/views/news_feed.py new file mode 100644 index 0000000..fe34e91 --- /dev/null +++ b/web/views/news_feed.py @@ -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, + )