test ferst
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.claude/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
6
api/Dockerfile
Normal file
6
api/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
api/bd/__init__.py
Normal file
0
api/bd/__init__.py
Normal file
44
api/bd/database.py
Normal file
44
api/bd/database.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import sqlalchemy
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/news")
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=20, echo=False)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_db():
|
||||||
|
for attempt in range(30):
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(sqlalchemy.text("SELECT 1"))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
if attempt == 29:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
_redis: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
global _redis
|
||||||
|
if _redis is None:
|
||||||
|
_redis = aioredis.from_url(REDIS_URL, encoding="utf-8", decode_responses=True)
|
||||||
|
return _redis
|
||||||
73
api/bd/models.py
Normal file
73
api/bd/models.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Table, Column, Integer, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import enum
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleStatus(str, enum.Enum):
|
||||||
|
draft = "draft"
|
||||||
|
published = "published"
|
||||||
|
|
||||||
|
|
||||||
|
article_tag = Table(
|
||||||
|
"article_tag",
|
||||||
|
Base.metadata,
|
||||||
|
Column("article_id", UUID(as_uuid=True), ForeignKey("articles.id", ondelete="CASCADE")),
|
||||||
|
Column("tag_id", UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = "categories"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
articles: Mapped[list["Article"]] = relationship(back_populates="category", lazy="noload")
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
__tablename__ = "tags"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Article(Base):
|
||||||
|
__tablename__ = "articles"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
title: Mapped[str] = mapped_column(String(500))
|
||||||
|
slug: Mapped[str] = mapped_column(String(500), unique=True, index=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
excerpt: Mapped[str] = mapped_column(String(1000), default="")
|
||||||
|
cover_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
font_family: Mapped[str] = mapped_column(String(100), default="Merriweather")
|
||||||
|
status: Mapped[ArticleStatus] = mapped_column(SAEnum(ArticleStatus), default=ArticleStatus.draft)
|
||||||
|
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
category: Mapped["Category | None"] = relationship(back_populates="articles", lazy="selectin")
|
||||||
|
tags: Mapped[list["Tag"]] = relationship(secondary=article_tag, lazy="selectin")
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(Base):
|
||||||
|
__tablename__ = "admins"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
username: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(300))
|
||||||
|
|
||||||
|
|
||||||
|
class Media(Base):
|
||||||
|
__tablename__ = "media"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
filename: Mapped[str] = mapped_column(String(500))
|
||||||
|
url: Mapped[str] = mapped_column(String(1000))
|
||||||
|
media_type: Mapped[str] = mapped_column(String(20)) # image | video
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
82
api/main.py
Normal file
82
api/main.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from bd.database import wait_for_db, engine, Base
|
||||||
|
from route.public import router as public_router
|
||||||
|
from route.admin_auth import router as auth_router, ensure_default_admin
|
||||||
|
from route.admin_articles import router as articles_router
|
||||||
|
from route.admin_categories import router as categories_router
|
||||||
|
from route.admin_media import router as media_router
|
||||||
|
|
||||||
|
DOCS_USER = os.getenv("DOCS_USERNAME", "admin")
|
||||||
|
DOCS_PASS = os.getenv("DOCS_PASSWORD", "admin")
|
||||||
|
_basic = HTTPBasic()
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
|
||||||
|
ok_u = secrets.compare_digest(creds.username.encode(), DOCS_USER.encode())
|
||||||
|
ok_p = secrets.compare_digest(creds.password.encode(), DOCS_PASS.encode())
|
||||||
|
if not (ok_u and ok_p):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Basic"})
|
||||||
|
return creds.username
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await wait_for_db()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await ensure_default_admin()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="News API",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Docs protected by HTTP Basic Auth
|
||||||
|
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/docs", include_in_schema=False)
|
||||||
|
async def docs(username: str = Depends(_verify_docs)):
|
||||||
|
return get_swagger_ui_html(openapi_url="/openapi.json", title="News API Docs")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/redoc", include_in_schema=False)
|
||||||
|
async def redoc(username: str = Depends(_verify_docs)):
|
||||||
|
return get_redoc_html(openapi_url="/openapi.json", title="News API Redoc")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/openapi.json", include_in_schema=False)
|
||||||
|
async def openapi(username: str = Depends(_verify_docs)):
|
||||||
|
return JSONResponse(app.openapi())
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(public_router, prefix="/news", tags=["public"])
|
||||||
|
app.include_router(auth_router, prefix="/admin", tags=["admin-auth"])
|
||||||
|
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"])
|
||||||
|
app.include_router(categories_router, prefix="/admin/categories", tags=["admin-categories"])
|
||||||
|
app.include_router(media_router, prefix="/admin/media", tags=["admin-media"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
|
||||||
12
api/requirements.txt
Normal file
12
api/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
redis[asyncio]==5.2.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==3.2.2
|
||||||
|
python-multipart==0.0.12
|
||||||
|
minio==7.2.11
|
||||||
|
python-slugify==8.0.4
|
||||||
|
pillow==11.0.0
|
||||||
0
api/route/__init__.py
Normal file
0
api/route/__init__.py
Normal file
224
api/route/admin_articles.py
Normal file
224
api/route/admin_articles.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from bd.database import get_db, get_redis
|
||||||
|
from bd.models import Article, Category, Tag, ArticleStatus
|
||||||
|
from route.deps import require_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleIn(BaseModel):
|
||||||
|
title: str
|
||||||
|
slug: Optional[str] = None
|
||||||
|
content: str = ""
|
||||||
|
excerpt: str = ""
|
||||||
|
cover_url: Optional[str] = None
|
||||||
|
font_family: str = "Merriweather"
|
||||||
|
category_id: Optional[str] = None
|
||||||
|
tag_names: list[str] = []
|
||||||
|
status: ArticleStatus = ArticleStatus.draft
|
||||||
|
|
||||||
|
|
||||||
|
def _article_dict(a: Article) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(a.id),
|
||||||
|
"title": a.title,
|
||||||
|
"slug": a.slug,
|
||||||
|
"content": a.content,
|
||||||
|
"excerpt": a.excerpt,
|
||||||
|
"cover_url": a.cover_url,
|
||||||
|
"font_family": a.font_family,
|
||||||
|
"status": a.status.value,
|
||||||
|
"view_count": a.view_count,
|
||||||
|
"created_at": a.created_at.isoformat(),
|
||||||
|
"updated_at": a.updated_at.isoformat(),
|
||||||
|
"published_at": a.published_at.isoformat() if a.published_at else None,
|
||||||
|
"category": {"id": str(a.category.id), "name": a.category.name, "slug": a.category.slug} if a.category else None,
|
||||||
|
"tags": [{"id": str(t.id), "name": t.name, "slug": t.slug} for t in a.tags],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_tags(tag_names: list[str], db: AsyncSession) -> list[Tag]:
|
||||||
|
from slugify import slugify
|
||||||
|
tags = []
|
||||||
|
for name in tag_names:
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
slug = slugify(name)
|
||||||
|
tag = (await db.execute(select(Tag).where(Tag.slug == slug))).scalar_one_or_none()
|
||||||
|
if not tag:
|
||||||
|
tag = Tag(name=name, slug=slug)
|
||||||
|
db.add(tag)
|
||||||
|
tags.append(tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
async def _invalidate_cache(redis: aioredis.Redis):
|
||||||
|
keys = await redis.keys("news:*")
|
||||||
|
if keys:
|
||||||
|
await redis.delete(*keys)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_articles(
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
status: Optional[ArticleStatus] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
|
||||||
|
if status:
|
||||||
|
q = q.where(Article.status == status)
|
||||||
|
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
|
||||||
|
articles = (await db.execute(q.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": (offset + limit) < total,
|
||||||
|
"items": [_article_dict(a) for a in articles],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_article(
|
||||||
|
data: ArticleIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
from slugify import slugify
|
||||||
|
slug = data.slug or slugify(data.title)
|
||||||
|
existing = (await db.execute(select(Article).where(Article.slug == slug))).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
cat = None
|
||||||
|
if data.category_id:
|
||||||
|
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(data.category_id)))).scalar_one_or_none()
|
||||||
|
|
||||||
|
tags = await _resolve_tags(data.tag_names, db)
|
||||||
|
|
||||||
|
article = Article(
|
||||||
|
title=data.title,
|
||||||
|
slug=slug,
|
||||||
|
content=data.content,
|
||||||
|
excerpt=data.excerpt,
|
||||||
|
cover_url=data.cover_url,
|
||||||
|
font_family=data.font_family,
|
||||||
|
status=data.status,
|
||||||
|
category=cat,
|
||||||
|
tags=tags,
|
||||||
|
published_at=datetime.utcnow() if data.status == ArticleStatus.published else None,
|
||||||
|
)
|
||||||
|
db.add(article)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(article)
|
||||||
|
await _invalidate_cache(redis)
|
||||||
|
return _article_dict(article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{article_id}")
|
||||||
|
async def get_article(
|
||||||
|
article_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags)).where(Article.id == uuid.UUID(article_id))
|
||||||
|
article = (await db.execute(q)).scalar_one_or_none()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return _article_dict(article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{article_id}")
|
||||||
|
async def update_article(
|
||||||
|
article_id: str,
|
||||||
|
data: ArticleIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
from slugify import slugify
|
||||||
|
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags)).where(Article.id == uuid.UUID(article_id))
|
||||||
|
article = (await db.execute(q)).scalar_one_or_none()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
slug = data.slug or slugify(data.title)
|
||||||
|
conflict = (await db.execute(
|
||||||
|
select(Article).where(Article.slug == slug, Article.id != article.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if conflict:
|
||||||
|
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
was_draft = article.status == ArticleStatus.draft
|
||||||
|
cat = None
|
||||||
|
if data.category_id:
|
||||||
|
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(data.category_id)))).scalar_one_or_none()
|
||||||
|
|
||||||
|
tags = await _resolve_tags(data.tag_names, db)
|
||||||
|
|
||||||
|
article.title = data.title
|
||||||
|
article.slug = slug
|
||||||
|
article.content = data.content
|
||||||
|
article.excerpt = data.excerpt
|
||||||
|
article.cover_url = data.cover_url
|
||||||
|
article.font_family = data.font_family
|
||||||
|
article.status = data.status
|
||||||
|
article.category = cat
|
||||||
|
article.tags = tags
|
||||||
|
article.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if was_draft and data.status == ArticleStatus.published and not article.published_at:
|
||||||
|
article.published_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(article)
|
||||||
|
await _invalidate_cache(redis)
|
||||||
|
return _article_dict(article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{article_id}")
|
||||||
|
async def delete_article(
|
||||||
|
article_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
await db.delete(article)
|
||||||
|
await db.commit()
|
||||||
|
await _invalidate_cache(redis)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{article_id}/publish")
|
||||||
|
async def publish_article(
|
||||||
|
article_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
article.status = ArticleStatus.published
|
||||||
|
if not article.published_at:
|
||||||
|
article.published_at = datetime.utcnow()
|
||||||
|
article.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await _invalidate_cache(redis)
|
||||||
|
return {"ok": True, "published_at": article.published_at.isoformat()}
|
||||||
62
api/route/admin_auth.py
Normal file
62
api/route/admin_auth.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from jose import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from bd.database import get_db, AsyncSessionLocal
|
||||||
|
from bd.models import Admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
|
||||||
|
JWT_ALGO = "HS256"
|
||||||
|
JWT_EXP_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def _make_token(username: str) -> str:
|
||||||
|
exp = datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS)
|
||||||
|
return jwt.encode({"sub": username, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGO)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_default_admin():
|
||||||
|
username = os.getenv("ADMIN_USERNAME", "admin")
|
||||||
|
password = os.getenv("ADMIN_PASSWORD", "changeme")
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
existing = (await db.execute(select(Admin))).first()
|
||||||
|
if not existing:
|
||||||
|
admin = Admin(username=username, password_hash=pwd_context.hash(password))
|
||||||
|
db.add(admin)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
admin = (await db.execute(select(Admin).where(Admin.username == data.username))).scalar_one_or_none()
|
||||||
|
if not admin or not pwd_context.verify(data.password, admin.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль")
|
||||||
|
return {"access_token": _make_token(admin.username), "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
data: dict,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from route.deps import require_admin
|
||||||
|
# handled with deps in main
|
||||||
|
admin = (await db.execute(select(Admin).where(Admin.username == data["username"]))).scalar_one_or_none()
|
||||||
|
if not admin or not pwd_context.verify(data["current_password"], admin.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный текущий пароль")
|
||||||
|
admin.password_hash = pwd_context.hash(data["new_password"])
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
78
api/route/admin_categories.py
Normal file
78
api/route/admin_categories.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from bd.database import get_db, get_redis
|
||||||
|
from bd.models import Category
|
||||||
|
from route.deps import require_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_categories(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||||
|
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
|
||||||
|
return [{"id": str(c.id), "name": c.name, "slug": c.slug} for c in cats]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_category(
|
||||||
|
data: CategoryIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
from slugify import slugify
|
||||||
|
slug = data.slug or slugify(data.name)
|
||||||
|
existing = (await db.execute(select(Category).where(Category.slug == slug))).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Категория с таким slug уже существует")
|
||||||
|
cat = Category(name=data.name, slug=slug)
|
||||||
|
db.add(cat)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cat)
|
||||||
|
await redis.delete("news:categories")
|
||||||
|
return {"id": str(cat.id), "name": cat.name, "slug": cat.slug}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{cat_id}")
|
||||||
|
async def update_category(
|
||||||
|
cat_id: str,
|
||||||
|
data: CategoryIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
from slugify import slugify
|
||||||
|
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(cat_id)))).scalar_one_or_none()
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
cat.name = data.name
|
||||||
|
cat.slug = data.slug or slugify(data.name)
|
||||||
|
await db.commit()
|
||||||
|
await redis.delete("news:categories")
|
||||||
|
return {"id": str(cat.id), "name": cat.name, "slug": cat.slug}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{cat_id}")
|
||||||
|
async def delete_category(
|
||||||
|
cat_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(cat_id)))).scalar_one_or_none()
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
await db.delete(cat)
|
||||||
|
await db.commit()
|
||||||
|
await redis.delete("news:categories")
|
||||||
|
return {"ok": True}
|
||||||
138
api/route/admin_media.py
Normal file
138
api/route/admin_media.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import os
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
|
||||||
|
from bd.database import get_db
|
||||||
|
from bd.models import Media
|
||||||
|
from route.deps import require_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
||||||
|
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
|
||||||
|
|
||||||
|
ALLOWED_IMAGE = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||||
|
ALLOWED_VIDEO = {"video/mp4", "video/webm", "video/ogg", "video/quicktime"}
|
||||||
|
MAX_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio() -> Minio:
|
||||||
|
return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bucket(client: Minio):
|
||||||
|
try:
|
||||||
|
if not client.bucket_exists(MINIO_BUCKET):
|
||||||
|
client.make_bucket(MINIO_BUCKET)
|
||||||
|
policy = f"""{{
|
||||||
|
"Version":"2012-10-17",
|
||||||
|
"Statement":[{{
|
||||||
|
"Effect":"Allow",
|
||||||
|
"Principal":"*",
|
||||||
|
"Action":["s3:GetObject"],
|
||||||
|
"Resource":["arn:aws:s3:::{MINIO_BUCKET}/*"]
|
||||||
|
}}]
|
||||||
|
}}"""
|
||||||
|
client.set_bucket_policy(MINIO_BUCKET, policy)
|
||||||
|
except S3Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_media(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
content_type = file.content_type or ""
|
||||||
|
if content_type not in ALLOWED_IMAGE and content_type not in ALLOWED_VIDEO:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Тип файла не поддерживается: {content_type}")
|
||||||
|
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > MAX_SIZE_BYTES:
|
||||||
|
raise HTTPException(status_code=400, detail="Файл слишком большой (макс. 100 МБ)")
|
||||||
|
|
||||||
|
media_type = "image" if content_type in ALLOWED_IMAGE else "video"
|
||||||
|
ext = (file.filename or "file").rsplit(".", 1)[-1].lower()
|
||||||
|
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
|
||||||
|
|
||||||
|
client = get_minio()
|
||||||
|
ensure_bucket(client)
|
||||||
|
client.put_object(
|
||||||
|
MINIO_BUCKET,
|
||||||
|
object_name,
|
||||||
|
io.BytesIO(data),
|
||||||
|
length=len(data),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||||
|
|
||||||
|
media = Media(
|
||||||
|
filename=file.filename or object_name,
|
||||||
|
url=public_url,
|
||||||
|
media_type=media_type,
|
||||||
|
size_bytes=len(data),
|
||||||
|
)
|
||||||
|
db.add(media)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(media)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(media.id),
|
||||||
|
"url": public_url,
|
||||||
|
"filename": media.filename,
|
||||||
|
"media_type": media_type,
|
||||||
|
"size_bytes": media.size_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_media(
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
media_type: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
q = select(Media)
|
||||||
|
if media_type:
|
||||||
|
q = q.where(Media.media_type == media_type)
|
||||||
|
items = (await db.execute(q.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
|
||||||
|
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
|
||||||
|
for m in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{media_id}")
|
||||||
|
async def delete_media(
|
||||||
|
media_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
import uuid as uuid_lib
|
||||||
|
media = (await db.execute(select(Media).where(Media.id == uuid_lib.UUID(media_id)))).scalar_one_or_none()
|
||||||
|
if not media:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
object_name = media.url.split(f"/{MINIO_BUCKET}/", 1)[-1]
|
||||||
|
try:
|
||||||
|
client = get_minio()
|
||||||
|
client.remove_object(MINIO_BUCKET, object_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await db.delete(media)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
17
api/route/deps.py
Normal file
17
api/route/deps.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
|
||||||
|
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
|
||||||
|
JWT_ALGO = "HS256"
|
||||||
|
|
||||||
|
bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGO])
|
||||||
|
return payload["sub"]
|
||||||
|
except (JWTError, KeyError):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
||||||
132
api/route/public.py
Normal file
132
api/route/public.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from bd.database import get_db, get_redis
|
||||||
|
from bd.models import Article, Category, Tag, ArticleStatus
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _cat_dict(c: Category) -> dict:
|
||||||
|
return {"id": str(c.id), "name": c.name, "slug": c.slug}
|
||||||
|
|
||||||
|
|
||||||
|
def _article_card(a: Article) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(a.id),
|
||||||
|
"title": a.title,
|
||||||
|
"slug": a.slug,
|
||||||
|
"excerpt": a.excerpt,
|
||||||
|
"cover_url": a.cover_url,
|
||||||
|
"published_at": a.published_at.isoformat() if a.published_at else None,
|
||||||
|
"view_count": a.view_count,
|
||||||
|
"category": _cat_dict(a.category) if a.category else None,
|
||||||
|
"tags": [{"id": str(t.id), "name": t.name, "slug": t.slug} for t in a.tags],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _article_full(a: Article) -> dict:
|
||||||
|
d = _article_card(a)
|
||||||
|
d["content"] = a.content
|
||||||
|
d["font_family"] = a.font_family
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_news(
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
category: str | None = Query(None),
|
||||||
|
tag: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
base = (
|
||||||
|
select(Article)
|
||||||
|
.options(selectinload(Article.category), selectinload(Article.tags))
|
||||||
|
.where(Article.status == ArticleStatus.published)
|
||||||
|
)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
cat = (await db.execute(select(Category).where(Category.slug == category))).scalar_one_or_none()
|
||||||
|
if cat:
|
||||||
|
base = base.where(Article.category_id == cat.id)
|
||||||
|
else:
|
||||||
|
return {"total": 0, "offset": offset, "limit": limit, "has_more": False, "items": []}
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
t = (await db.execute(select(Tag).where(Tag.slug == tag))).scalar_one_or_none()
|
||||||
|
if t:
|
||||||
|
base = base.where(Article.tags.any(Tag.id == t.id))
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total: int = (await db.execute(count_q)).scalar_one()
|
||||||
|
|
||||||
|
articles = (
|
||||||
|
await db.execute(base.order_by(Article.published_at.desc()).offset(offset).limit(limit))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"total": total,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": (offset + limit) < total,
|
||||||
|
"items": [_article_card(a) for a in articles],
|
||||||
|
}
|
||||||
|
await redis.setex(cache_key, 30, json.dumps(result, default=str))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def list_categories(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
cached = await redis.get("news:categories")
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
|
||||||
|
result = [_cat_dict(c) for c in cats]
|
||||||
|
await redis.setex("news:categories", 60, json.dumps(result))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}")
|
||||||
|
async def get_article(
|
||||||
|
slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
cache_key = f"news:article:{slug}"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
|
||||||
|
q = (
|
||||||
|
select(Article)
|
||||||
|
.options(selectinload(Article.category), selectinload(Article.tags))
|
||||||
|
.where(Article.slug == slug, Article.status == ArticleStatus.published)
|
||||||
|
)
|
||||||
|
article = (await db.execute(q)).scalar_one_or_none()
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
|
||||||
|
article.view_count += 1
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
data = json.loads(cached)
|
||||||
|
data["view_count"] = article.view_count
|
||||||
|
return data
|
||||||
|
|
||||||
|
await db.refresh(article)
|
||||||
|
result = _article_full(article)
|
||||||
|
await redis.setex(cache_key, 60, json.dumps(result, default=str))
|
||||||
|
return result
|
||||||
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
|
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:-news-media}
|
||||||
|
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
||||||
|
DOCS_USERNAME: ${DOCS_USERNAME:-admin}
|
||||||
|
DOCS_PASSWORD: ${DOCS_PASSWORD:-admin}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:8080"
|
||||||
|
environment:
|
||||||
|
API_URL: http://api:8000
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- ./data/minio:/data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
news_net:
|
||||||
|
external: true
|
||||||
|
name: news_net
|
||||||
50
docker-compose_db.yml
Normal file
50
docker-compose_db.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
# Локальные базы данных для разработки и тестирования.
|
||||||
|
# Запуск: docker-compose -f docker-compose_db.yml up -d
|
||||||
|
# После запуска выполни: ./init_db.sh
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--requirepass ${REDIS_PASSWORD:-RedisNewsMk2026}
|
||||||
|
--appendonly yes
|
||||||
|
--appendfsync everysec
|
||||||
|
volumes:
|
||||||
|
- ./data/redis:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-RedisNewsMk2026}", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
news_net:
|
||||||
|
name: news_net
|
||||||
|
driver: bridge
|
||||||
185
init_db.sh
Executable file
185
init_db.sh
Executable file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─── Загрузка переменных из .env ─────────────────────────────────────────────
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -v '^#' .env | grep -v '^$' | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
POSTGRES_ROOT_PASSWORD=${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
|
||||||
|
NEWS_DB_PASSWORD=${NEWS_DB_PASSWORD:-NewsAdmPass2026Xk}
|
||||||
|
DB_USER="news_admin"
|
||||||
|
DB_NAME="news"
|
||||||
|
DB_CONTAINER="news_flet-db-1" # имя контейнера по умолчанию (docker compose project name)
|
||||||
|
|
||||||
|
# ─── Цвета вывода ────────────────────────────────────────────────────────────
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
# ─── Поиск контейнера postgres ────────────────────────────────────────────────
|
||||||
|
find_container() {
|
||||||
|
# Пробуем стандартные варианты имён контейнера
|
||||||
|
for name in "news_flet-db-1" "news_flet_db_1" "db"; do
|
||||||
|
if docker inspect "$name" &>/dev/null 2>&1; then
|
||||||
|
echo "$name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Ищем по образу
|
||||||
|
local found
|
||||||
|
found=$(docker ps --filter "ancestor=postgres" --format "{{.Names}}" | head -1)
|
||||||
|
if [ -n "$found" ]; then
|
||||||
|
echo "$found"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
error "Контейнер PostgreSQL не найден. Запусти: docker-compose -f docker-compose_db.yml up -d"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_cmd() {
|
||||||
|
docker exec -i "$DB_CONTAINER" psql -U postgres "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Ожидание готовности PostgreSQL ──────────────────────────────────────────
|
||||||
|
wait_for_postgres() {
|
||||||
|
info "Ожидание готовности PostgreSQL..."
|
||||||
|
local retries=30
|
||||||
|
until docker exec "$DB_CONTAINER" pg_isready -U postgres -q 2>/dev/null; do
|
||||||
|
retries=$((retries - 1))
|
||||||
|
if [ $retries -le 0 ]; then
|
||||||
|
error "PostgreSQL не стал доступен за 60 секунд."
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
info "PostgreSQL готов."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Создание пользователя ────────────────────────────────────────────────────
|
||||||
|
create_user() {
|
||||||
|
info "Проверка пользователя '$DB_USER'..."
|
||||||
|
local exists
|
||||||
|
exists=$(psql_cmd -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER';" 2>/dev/null || echo "")
|
||||||
|
if [ "$exists" = "1" ]; then
|
||||||
|
warn "Пользователь '$DB_USER' уже существует — обновляю пароль."
|
||||||
|
psql_cmd -c "ALTER USER $DB_USER WITH PASSWORD '$NEWS_DB_PASSWORD';" > /dev/null
|
||||||
|
else
|
||||||
|
psql_cmd -c "CREATE USER $DB_USER WITH PASSWORD '$NEWS_DB_PASSWORD';" > /dev/null
|
||||||
|
info "Пользователь '$DB_USER' создан."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Создание базы данных ─────────────────────────────────────────────────────
|
||||||
|
create_database() {
|
||||||
|
info "Проверка базы данных '$DB_NAME'..."
|
||||||
|
local exists
|
||||||
|
exists=$(psql_cmd -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME';" 2>/dev/null || echo "")
|
||||||
|
if [ "$exists" = "1" ]; then
|
||||||
|
warn "База данных '$DB_NAME' уже существует — пропускаю создание."
|
||||||
|
else
|
||||||
|
psql_cmd -c "CREATE DATABASE $DB_NAME OWNER $DB_USER ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0;" > /dev/null
|
||||||
|
info "База данных '$DB_NAME' создана."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Настройка прав ───────────────────────────────────────────────────────────
|
||||||
|
grant_privileges() {
|
||||||
|
info "Настройка привилегий..."
|
||||||
|
psql_cmd -d "$DB_NAME" << 'SQL' > /dev/null
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE news TO news_admin;
|
||||||
|
GRANT ALL ON SCHEMA public TO news_admin;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO news_admin;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO news_admin;
|
||||||
|
SQL
|
||||||
|
info "Привилегии настроены."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Проверка подключения ─────────────────────────────────────────────────────
|
||||||
|
verify_connection() {
|
||||||
|
info "Проверка подключения от '$DB_USER' к '$DB_NAME'..."
|
||||||
|
if docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" > /dev/null 2>&1; then
|
||||||
|
info "Подключение успешно."
|
||||||
|
else
|
||||||
|
error "Не удалось подключиться от '$DB_USER' к '$DB_NAME'."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Ожидание Redis ───────────────────────────────────────────────────────────
|
||||||
|
wait_for_redis() {
|
||||||
|
info "Ожидание готовности Redis..."
|
||||||
|
local redis_container
|
||||||
|
redis_container=$(docker ps --filter "ancestor=redis" --format "{{.Names}}" | head -1)
|
||||||
|
if [ -z "$redis_container" ]; then
|
||||||
|
warn "Контейнер Redis не найден — пропускаю проверку."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local retries=15
|
||||||
|
local redis_pass=${REDIS_PASSWORD:-RedisNewsMk2026}
|
||||||
|
until docker exec "$redis_container" redis-cli -a "$redis_pass" ping 2>/dev/null | grep -q PONG; do
|
||||||
|
retries=$((retries - 1))
|
||||||
|
if [ $retries -le 0 ]; then
|
||||||
|
warn "Redis не ответил за 30 секунд — продолжаю без него."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
info "Redis готов."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Создание директорий для данных ──────────────────────────────────────────
|
||||||
|
create_data_dirs() {
|
||||||
|
info "Создание директорий для хранения данных..."
|
||||||
|
mkdir -p ./data/postgres ./data/redis ./data/minio
|
||||||
|
info "Директории: ./data/postgres ./data/redis ./data/minio"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Главная функция ──────────────────────────────────────────────────────────
|
||||||
|
main() {
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
echo " Инициализация баз данных — News CMS"
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
create_data_dirs
|
||||||
|
|
||||||
|
# Запуск docker-compose_db.yml если не запущен
|
||||||
|
if ! docker-compose -f docker-compose_db.yml ps --services --filter "status=running" 2>/dev/null | grep -q "db"; then
|
||||||
|
info "Запуск docker-compose_db.yml..."
|
||||||
|
docker-compose -f docker-compose_db.yml up -d
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_CONTAINER=$(find_container)
|
||||||
|
info "Контейнер PostgreSQL: $DB_CONTAINER"
|
||||||
|
|
||||||
|
wait_for_postgres
|
||||||
|
create_user
|
||||||
|
create_database
|
||||||
|
grant_privileges
|
||||||
|
verify_connection
|
||||||
|
wait_for_redis
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
info "Готово! Теперь запускай:"
|
||||||
|
echo ""
|
||||||
|
echo " docker-compose up -d --build"
|
||||||
|
echo ""
|
||||||
|
echo " Сайт: http://localhost"
|
||||||
|
echo " Админка: http://localhost/admin"
|
||||||
|
echo " MinIO консоль: http://localhost:9001"
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
6
web/Dockerfile
Normal file
6
web/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
232
web/api_client.py
Normal file
232
web/api_client.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
|
_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token: str | None = None) -> dict:
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if token:
|
||||||
|
h["Authorization"] = f"Bearer {token}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Public ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_news(
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
category: str | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
params = {"offset": offset, "limit": limit}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
if tag:
|
||||||
|
params["tag"] = tag
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.get(f"{API_URL}/news", params=params)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_article(slug: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.get(f"{API_URL}/news/{slug}")
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_categories() -> list:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.get(f"{API_URL}/news/categories")
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Admin: Auth ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def admin_login(username: str, password: str) -> str | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{API_URL}/admin/login",
|
||||||
|
json={"username": username, "password": password},
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("access_token")
|
||||||
|
return None
|
||||||
|
except httpx.RequestError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Admin: Articles ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def admin_list_articles(token: str, offset: int = 0, limit: int = 50, status: str | None = None) -> dict | None:
|
||||||
|
params = {"offset": offset, "limit": limit}
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.get(f"{API_URL}/admin/articles", params=params, headers=_headers(token))
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_get_article(token: str, article_id: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.get(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_create_article(token: str, data: dict) -> dict | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.post(f"{API_URL}/admin/articles", json=data, headers=_headers(token))
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_update_article(token: str, article_id: str, data: dict) -> dict | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.put(f"{API_URL}/admin/articles/{article_id}", json=data, headers=_headers(token))
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_delete_article(token: str, article_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.delete(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
|
||||||
|
return r.status_code == 200
|
||||||
|
except httpx.RequestError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_publish_article(token: str, article_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.post(f"{API_URL}/admin/articles/{article_id}/publish", headers=_headers(token))
|
||||||
|
return r.status_code == 200
|
||||||
|
except httpx.RequestError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Admin: Categories ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def admin_list_categories(token: str) -> list:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.get(f"{API_URL}/admin/categories", headers=_headers(token))
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_create_category(token: str, name: str, slug: str = "") -> dict | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{API_URL}/admin/categories",
|
||||||
|
json={"name": name, "slug": slug or None},
|
||||||
|
headers=_headers(token),
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_update_category(token: str, cat_id: str, name: str, slug: str = "") -> dict | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.put(
|
||||||
|
f"{API_URL}/admin/categories/{cat_id}",
|
||||||
|
json={"name": name, "slug": slug or None},
|
||||||
|
headers=_headers(token),
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_delete_category(token: str, cat_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.delete(f"{API_URL}/admin/categories/{cat_id}", headers=_headers(token))
|
||||||
|
return r.status_code == 200
|
||||||
|
except httpx.RequestError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Admin: Media ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def admin_upload_media(token: str, file_bytes: bytes, filename: str, content_type: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{API_URL}/admin/media/upload",
|
||||||
|
files={"file": (filename, file_bytes, content_type)},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_list_media(token: str, offset: int = 0, limit: int = 50, media_type: str | None = None) -> list:
|
||||||
|
params = {"offset": offset, "limit": limit}
|
||||||
|
if media_type:
|
||||||
|
params["media_type"] = media_type
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.get(f"{API_URL}/admin/media", params=params, headers=_headers(token))
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_delete_media(token: str, media_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
r = await client.delete(f"{API_URL}/admin/media/{media_id}", headers=_headers(token))
|
||||||
|
return r.status_code == 200
|
||||||
|
except httpx.RequestError:
|
||||||
|
return False
|
||||||
252
web/designer.py
Normal file
252
web/designer.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
# ─── Шрифты ──────────────────────────────────────────────────────────────────
|
||||||
|
FONT_UI = "Inter"
|
||||||
|
FONT_SERIF = "Merriweather"
|
||||||
|
FONT_DISPLAY = "Playfair Display"
|
||||||
|
FONT_MONO = "JetBrains Mono"
|
||||||
|
|
||||||
|
ARTICLE_FONTS: dict[str, str] = {
|
||||||
|
"Merriweather": "Merriweather — для чтения (засечки)",
|
||||||
|
"Inter": "Inter — современный (без засечек)",
|
||||||
|
"Playfair Display": "Playfair Display — газетный",
|
||||||
|
"Roboto": "Roboto — нейтральный",
|
||||||
|
"JetBrains Mono": "JetBrains Mono — моноширинный",
|
||||||
|
}
|
||||||
|
|
||||||
|
GOOGLE_FONTS_JS = """
|
||||||
|
(function(){
|
||||||
|
if(document.getElementById('gfonts-news')) return;
|
||||||
|
var l=document.createElement('link');
|
||||||
|
l.id='gfonts-news';
|
||||||
|
l.rel='stylesheet';
|
||||||
|
l.href='https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&family=Roboto:wght@400;500;700&display=swap';
|
||||||
|
document.head.appendChild(l);
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ─── Цвета ───────────────────────────────────────────────────────────────────
|
||||||
|
PRIMARY = "#1565C0"
|
||||||
|
PRIMARY_LIGHT = "#1976D2"
|
||||||
|
PRIMARY_DARK = "#0D47A1"
|
||||||
|
ACCENT = "#FF6D00"
|
||||||
|
SUCCESS = "#2E7D32"
|
||||||
|
WARNING = "#F57F17"
|
||||||
|
ERROR = "#C62828"
|
||||||
|
SURFACE = "#FFFFFF"
|
||||||
|
BACKGROUND = "#F5F5F5"
|
||||||
|
TEXT_PRIMARY = "#212121"
|
||||||
|
TEXT_SECONDARY = "#616161"
|
||||||
|
DIVIDER = "#E0E0E0"
|
||||||
|
ADMIN_SIDEBAR = "#1A237E"
|
||||||
|
ADMIN_SIDEBAR_TEXT = "#E8EAF6"
|
||||||
|
ADMIN_SIDEBAR_ACTIVE = "#283593"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Типографика ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def headline(text: str, size: int = 28, color: str = TEXT_PRIMARY, weight=ft.FontWeight.BOLD, font: str = FONT_UI) -> ft.Text:
|
||||||
|
return ft.Text(text, size=size, color=color, weight=weight, font_family=font)
|
||||||
|
|
||||||
|
|
||||||
|
def body(text: str, size: int = 14, color: str = TEXT_PRIMARY, font: str = FONT_UI) -> ft.Text:
|
||||||
|
return ft.Text(text, size=size, color=color, font_family=font)
|
||||||
|
|
||||||
|
|
||||||
|
def caption(text: str, color: str = TEXT_SECONDARY) -> ft.Text:
|
||||||
|
return ft.Text(text, size=12, color=color, font_family=FONT_UI)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Кнопки ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def btn_primary(text: str, on_click=None, icon=None, width: int | None = None) -> ft.ElevatedButton:
|
||||||
|
return ft.ElevatedButton(
|
||||||
|
text=text,
|
||||||
|
icon=icon,
|
||||||
|
on_click=on_click,
|
||||||
|
width=width,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
bgcolor=PRIMARY,
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=8),
|
||||||
|
padding=ft.padding.symmetric(horizontal=20, vertical=12),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def btn_outlined(text: str, on_click=None, icon=None, color: str = PRIMARY) -> ft.OutlinedButton:
|
||||||
|
return ft.OutlinedButton(
|
||||||
|
text=text,
|
||||||
|
icon=icon,
|
||||||
|
on_click=on_click,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
color=color,
|
||||||
|
side=ft.BorderSide(1, color),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=8),
|
||||||
|
padding=ft.padding.symmetric(horizontal=16, vertical=10),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def btn_danger(text: str, on_click=None, icon=None) -> ft.ElevatedButton:
|
||||||
|
return ft.ElevatedButton(
|
||||||
|
text=text,
|
||||||
|
icon=icon,
|
||||||
|
on_click=on_click,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
bgcolor=ERROR,
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=8),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def btn_icon(icon, tooltip: str = "", on_click=None, color: str = TEXT_SECONDARY) -> ft.IconButton:
|
||||||
|
return ft.IconButton(icon=icon, tooltip=tooltip, on_click=on_click, icon_color=color, icon_size=20)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Поля ввода ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def text_field(
|
||||||
|
label: str,
|
||||||
|
value: str = "",
|
||||||
|
on_change=None,
|
||||||
|
multiline: bool = False,
|
||||||
|
min_lines: int = 1,
|
||||||
|
max_lines: int | None = 1,
|
||||||
|
hint: str = "",
|
||||||
|
password: bool = False,
|
||||||
|
expand: bool = False,
|
||||||
|
read_only: bool = False,
|
||||||
|
prefix_icon=None,
|
||||||
|
) -> ft.TextField:
|
||||||
|
return ft.TextField(
|
||||||
|
label=label,
|
||||||
|
value=value,
|
||||||
|
on_change=on_change,
|
||||||
|
multiline=multiline,
|
||||||
|
min_lines=min_lines,
|
||||||
|
max_lines=max_lines,
|
||||||
|
hint_text=hint,
|
||||||
|
password=password,
|
||||||
|
can_reveal_password=password,
|
||||||
|
expand=expand,
|
||||||
|
read_only=read_only,
|
||||||
|
prefix_icon=prefix_icon,
|
||||||
|
border_color=DIVIDER,
|
||||||
|
focused_border_color=PRIMARY,
|
||||||
|
label_style=ft.TextStyle(color=TEXT_SECONDARY, size=13, font_family=FONT_UI),
|
||||||
|
text_style=ft.TextStyle(color=TEXT_PRIMARY, size=14, font_family=FONT_UI),
|
||||||
|
border_radius=8,
|
||||||
|
content_padding=ft.padding.symmetric(horizontal=12, vertical=10),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dropdown(label: str, options: list[ft.dropdown.Option], value: str | None = None, on_change=None) -> ft.Dropdown:
|
||||||
|
return ft.Dropdown(
|
||||||
|
label=label,
|
||||||
|
value=value,
|
||||||
|
options=options,
|
||||||
|
on_change=on_change,
|
||||||
|
border_color=DIVIDER,
|
||||||
|
focused_border_color=PRIMARY,
|
||||||
|
label_style=ft.TextStyle(color=TEXT_SECONDARY, size=13),
|
||||||
|
border_radius=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Карточки ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def card(content: ft.Control, padding: int = 16, elevation: int = 1) -> ft.Card:
|
||||||
|
return ft.Card(
|
||||||
|
content=ft.Container(content=content, padding=padding),
|
||||||
|
elevation=elevation,
|
||||||
|
surface_tint_color=SURFACE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stat_card(title: str, value: str, icon, color: str = PRIMARY) -> ft.Container:
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Row([ft.Icon(icon, color=color, size=28), body(title, color=TEXT_SECONDARY)], spacing=8),
|
||||||
|
ft.Text(value, size=32, weight=ft.FontWeight.BOLD, color=color),
|
||||||
|
],
|
||||||
|
spacing=8,
|
||||||
|
),
|
||||||
|
bgcolor=SURFACE,
|
||||||
|
border_radius=12,
|
||||||
|
padding=20,
|
||||||
|
shadow=ft.BoxShadow(spread_radius=0, blur_radius=8, color=ft.colors.with_opacity(0.08, ft.colors.BLACK)),
|
||||||
|
width=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Статус-бейджи ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def status_badge(status: str) -> ft.Container:
|
||||||
|
cfg = {
|
||||||
|
"published": (SUCCESS, "Опубликовано"),
|
||||||
|
"draft": (TEXT_SECONDARY, "Черновик"),
|
||||||
|
}
|
||||||
|
color, label = cfg.get(status, (TEXT_SECONDARY, status))
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Text(label, size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_500),
|
||||||
|
bgcolor=color,
|
||||||
|
border_radius=4,
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Разделитель ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def divider() -> ft.Divider:
|
||||||
|
return ft.Divider(height=1, color=DIVIDER)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Загрузчик ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def loader(size: int = 40) -> ft.ProgressRing:
|
||||||
|
return ft.ProgressRing(width=size, height=size, stroke_width=3, color=PRIMARY)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Снэкбар ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def snack(page: ft.Page, message: str, error: bool = False):
|
||||||
|
page.snack_bar = ft.SnackBar(
|
||||||
|
content=ft.Text(message, color=ft.colors.WHITE),
|
||||||
|
bgcolor=ERROR if error else SUCCESS,
|
||||||
|
duration=3000,
|
||||||
|
)
|
||||||
|
page.snack_bar.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Диалог подтверждения ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def confirm_dialog(page: ft.Page, title: str, message: str, on_confirm) -> ft.AlertDialog:
|
||||||
|
dlg = ft.AlertDialog(
|
||||||
|
modal=True,
|
||||||
|
title=ft.Text(title, weight=ft.FontWeight.BOLD),
|
||||||
|
content=ft.Text(message),
|
||||||
|
actions=[
|
||||||
|
ft.TextButton("Отмена", on_click=lambda e: _close_dlg(page, dlg)),
|
||||||
|
btn_danger("Удалить", on_click=lambda e: (_close_dlg(page, dlg), on_confirm())),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return dlg
|
||||||
|
|
||||||
|
|
||||||
|
def _close_dlg(page: ft.Page, dlg: ft.AlertDialog):
|
||||||
|
dlg.open = False
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Пустое состояние ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def empty_state(icon, message: str) -> ft.Column:
|
||||||
|
return ft.Column(
|
||||||
|
[ft.Icon(icon, size=64, color=DIVIDER), body(message, color=TEXT_SECONDARY)],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=12,
|
||||||
|
)
|
||||||
41
web/main.py
Normal file
41
web/main.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import flet as ft
|
||||||
|
import flet.fastapi as flet_fastapi
|
||||||
|
|
||||||
|
import designer as d
|
||||||
|
import theme_manager
|
||||||
|
from router import handle_route
|
||||||
|
|
||||||
|
|
||||||
|
async def main(page: ft.Page):
|
||||||
|
page.title = "Новости"
|
||||||
|
page.padding = 0
|
||||||
|
page.spacing = 0
|
||||||
|
page.window.width = 1280
|
||||||
|
page.window.min_width = 360
|
||||||
|
|
||||||
|
page.fonts = {
|
||||||
|
"Merriweather": "https://cdn.jsdelivr.net/npm/@fontsource/merriweather@latest/files/merriweather-latin-400-normal.woff2",
|
||||||
|
"Inter": "https://cdn.jsdelivr.net/npm/@fontsource/inter@latest/files/inter-latin-400-normal.woff2",
|
||||||
|
"Playfair Display": "https://cdn.jsdelivr.net/npm/@fontsource/playfair-display@latest/files/playfair-display-latin-400-normal.woff2",
|
||||||
|
"JetBrains Mono": "https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@latest/files/jetbrains-mono-latin-400-normal.woff2",
|
||||||
|
"Roboto": "https://cdn.jsdelivr.net/npm/@fontsource/roboto@latest/files/roboto-latin-400-normal.woff2",
|
||||||
|
}
|
||||||
|
|
||||||
|
theme_manager.apply(page, "light")
|
||||||
|
|
||||||
|
async def on_route_change(e: ft.RouteChangeEvent):
|
||||||
|
theme_manager.reset_font(page)
|
||||||
|
await handle_route(page)
|
||||||
|
|
||||||
|
def on_view_pop(e: ft.ViewPopEvent):
|
||||||
|
page.views.pop()
|
||||||
|
if page.views:
|
||||||
|
page.go(page.views[-1].route)
|
||||||
|
|
||||||
|
page.on_route_change = on_route_change
|
||||||
|
page.on_view_pop = on_view_pop
|
||||||
|
|
||||||
|
await handle_route(page)
|
||||||
|
|
||||||
|
|
||||||
|
app = flet_fastapi.app(main)
|
||||||
3
web/requirements.txt
Normal file
3
web/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flet==0.24.1
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
httpx==0.27.2
|
||||||
70
web/router.py
Normal file
70
web/router.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from views.news_feed import NewsFeedView
|
||||||
|
from views.news_detail import NewsDetailView
|
||||||
|
from views.login import LoginView
|
||||||
|
from views.dashboard import DashboardView
|
||||||
|
from views.articles_list import ArticlesListView
|
||||||
|
from views.article_editor import ArticleEditorView
|
||||||
|
from views.categories import CategoriesView
|
||||||
|
from views.media_library import MediaLibraryView
|
||||||
|
|
||||||
|
_PUBLIC = {"/", "/admin/login"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(route: str) -> bool:
|
||||||
|
return route.startswith("/admin") and route != "/admin/login"
|
||||||
|
|
||||||
|
|
||||||
|
def _parts(route: str) -> list[str]:
|
||||||
|
return [p for p in route.strip("/").split("/") if p]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_view(page: ft.Page, route: str):
|
||||||
|
parts = _parts(route)
|
||||||
|
|
||||||
|
if not parts or route == "/":
|
||||||
|
return NewsFeedView(page)
|
||||||
|
|
||||||
|
if parts[0] == "news" and len(parts) >= 2:
|
||||||
|
return NewsDetailView(page, slug=parts[1])
|
||||||
|
|
||||||
|
if parts[0] == "admin":
|
||||||
|
if len(parts) == 1:
|
||||||
|
return DashboardView(page)
|
||||||
|
sub = parts[1]
|
||||||
|
if sub == "login":
|
||||||
|
return LoginView(page)
|
||||||
|
if sub == "articles":
|
||||||
|
if len(parts) == 2:
|
||||||
|
return ArticlesListView(page)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
aid = parts[2]
|
||||||
|
return ArticleEditorView(page, article_id=None if aid == "new" else aid)
|
||||||
|
if sub == "categories":
|
||||||
|
return CategoriesView(page)
|
||||||
|
if sub == "media":
|
||||||
|
return MediaLibraryView(page)
|
||||||
|
return DashboardView(page)
|
||||||
|
|
||||||
|
return NewsFeedView(page)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_route(page: ft.Page):
|
||||||
|
route = page.route or "/"
|
||||||
|
|
||||||
|
if _is_admin(route):
|
||||||
|
token = page.session.get("token")
|
||||||
|
if not token:
|
||||||
|
page.go("/admin/login")
|
||||||
|
return
|
||||||
|
|
||||||
|
view = resolve_view(page, route)
|
||||||
|
flet_view = view.build_page_view()
|
||||||
|
|
||||||
|
page.views.clear()
|
||||||
|
page.views.append(flet_view)
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
if hasattr(view, "load"):
|
||||||
|
page.run_task(view.load)
|
||||||
69
web/theme_manager.py
Normal file
69
web/theme_manager.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
|
||||||
|
_ui_text = ft.TextTheme(
|
||||||
|
body_medium=ft.TextStyle(font_family=d.FONT_UI),
|
||||||
|
body_large=ft.TextStyle(font_family=d.FONT_UI),
|
||||||
|
body_small=ft.TextStyle(font_family=d.FONT_UI),
|
||||||
|
label_large=ft.TextStyle(font_family=d.FONT_UI),
|
||||||
|
label_medium=ft.TextStyle(font_family=d.FONT_UI),
|
||||||
|
label_small=ft.TextStyle(font_family=d.FONT_UI),
|
||||||
|
title_large=ft.TextStyle(font_family=d.FONT_UI, weight=ft.FontWeight.W_600),
|
||||||
|
title_medium=ft.TextStyle(font_family=d.FONT_UI, weight=ft.FontWeight.W_500),
|
||||||
|
title_small=ft.TextStyle(font_family=d.FONT_UI),
|
||||||
|
)
|
||||||
|
|
||||||
|
THEMES = {
|
||||||
|
"light": ft.Theme(
|
||||||
|
color_scheme_seed=d.PRIMARY,
|
||||||
|
color_scheme=ft.ColorScheme(
|
||||||
|
primary=d.PRIMARY,
|
||||||
|
secondary=d.ACCENT,
|
||||||
|
background=d.BACKGROUND,
|
||||||
|
surface=d.SURFACE,
|
||||||
|
),
|
||||||
|
text_theme=_ui_text,
|
||||||
|
),
|
||||||
|
"dark": ft.Theme(
|
||||||
|
color_scheme_seed=d.PRIMARY,
|
||||||
|
color_scheme=ft.ColorScheme(
|
||||||
|
primary=d.PRIMARY_LIGHT,
|
||||||
|
secondary=d.ACCENT,
|
||||||
|
background="#121212",
|
||||||
|
surface="#1E1E1E",
|
||||||
|
on_surface=ft.colors.WHITE,
|
||||||
|
),
|
||||||
|
text_theme=_ui_text,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply(page: ft.Page, theme_id: str = "light"):
|
||||||
|
theme = THEMES.get(theme_id, THEMES["light"])
|
||||||
|
if theme_id == "dark":
|
||||||
|
page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
page.dark_theme = theme
|
||||||
|
else:
|
||||||
|
page.theme_mode = ft.ThemeMode.LIGHT
|
||||||
|
page.theme = theme
|
||||||
|
|
||||||
|
|
||||||
|
def apply_article_font(page: ft.Page, font_family: str):
|
||||||
|
"""Temporarily override body text font for article reading view."""
|
||||||
|
article_text = ft.TextTheme(
|
||||||
|
body_medium=ft.TextStyle(font_family=font_family, size=16, height=1.7),
|
||||||
|
body_large=ft.TextStyle(font_family=font_family, size=18, height=1.7),
|
||||||
|
body_small=ft.TextStyle(font_family=font_family),
|
||||||
|
)
|
||||||
|
if page.theme:
|
||||||
|
page.theme.text_theme = article_text
|
||||||
|
else:
|
||||||
|
page.theme = ft.Theme(text_theme=article_text)
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_font(page: ft.Page):
|
||||||
|
"""Restore default UI font after leaving article view."""
|
||||||
|
if page.theme:
|
||||||
|
page.theme.text_theme = _ui_text
|
||||||
|
page.update()
|
||||||
0
web/views/__init__.py
Normal file
0
web/views/__init__.py
Normal file
615
web/views/article_editor.py
Normal file
615
web/views/article_editor.py
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
from views.dashboard import _admin_appbar, _sidebar
|
||||||
|
|
||||||
|
|
||||||
|
def _js_font_span(font_name: str) -> str:
|
||||||
|
fn = font_name.replace("'", "\\'")
|
||||||
|
return f"""
|
||||||
|
(function(){{
|
||||||
|
var sel=window.__editorSel;
|
||||||
|
if(!sel||!sel.el){{var tas=document.querySelectorAll('textarea');if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};else return;}}
|
||||||
|
var ta=sel.el,val=ta.value,st=sel.start,en=sel.end;
|
||||||
|
var selected=val.substring(st,en)||'текст';
|
||||||
|
var before=val.substring(0,st),after=val.substring(en);
|
||||||
|
var ins='<span style="font-family:\\'{fn}\\'">' + selected + '</span>';
|
||||||
|
ta.value=before+ins+after;
|
||||||
|
var newSt=st+ins.length-selected.length-7;
|
||||||
|
ta.selectionStart=newSt; ta.selectionEnd=newSt+selected.length;
|
||||||
|
ta.focus();
|
||||||
|
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
||||||
|
window.__editorSel={{el:ta,start:ta.selectionStart,end:ta.selectionEnd}};
|
||||||
|
return ta.value;
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
# JS: inject once on view mount — tracks last focused textarea + selection
|
||||||
|
_TRACK_JS = """
|
||||||
|
window.__editorSel = {start:0, end:0, el:null};
|
||||||
|
document.addEventListener('mousedown', function(ev){
|
||||||
|
var t = document.activeElement;
|
||||||
|
if(t && t.tagName==='TEXTAREA'){
|
||||||
|
window.__editorSel = {start:t.selectionStart||0, end:t.selectionEnd||0, el:t};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', function(ev){
|
||||||
|
var t = document.activeElement;
|
||||||
|
if(t && t.tagName==='TEXTAREA'){
|
||||||
|
window.__editorSel = {start:t.selectionStart||0, end:t.selectionEnd||0, el:t};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
|
||||||
|
# JS: insert markdown syntax around selection (called from toolbar buttons)
|
||||||
|
def _js_insert(prefix: str, suffix: str = "", placeholder: str = "текст") -> str:
|
||||||
|
p = prefix.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
||||||
|
s = suffix.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
||||||
|
ph = placeholder.replace("'", "\\'")
|
||||||
|
return f"""
|
||||||
|
(function(){{
|
||||||
|
var sel = window.__editorSel;
|
||||||
|
if(!sel||!sel.el){{
|
||||||
|
var tas=document.querySelectorAll('textarea');
|
||||||
|
if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};
|
||||||
|
else return;
|
||||||
|
}}
|
||||||
|
var ta=sel.el, val=ta.value;
|
||||||
|
var st=sel.start, en=sel.end;
|
||||||
|
var selected=val.substring(st,en)||'{ph}';
|
||||||
|
var before=val.substring(0,st), after=val.substring(en);
|
||||||
|
var inserted='{p}'+selected+'{s}';
|
||||||
|
ta.value=before+inserted+after;
|
||||||
|
ta.selectionStart=st+{len(prefix)};
|
||||||
|
ta.selectionEnd=st+{len(prefix)}+selected.length;
|
||||||
|
ta.focus();
|
||||||
|
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
||||||
|
window.__editorSel={{el:ta,start:ta.selectionStart,end:ta.selectionEnd}};
|
||||||
|
return ta.value;
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _js_prepend_line(prefix: str) -> str:
|
||||||
|
p = prefix.replace("'", "\\'")
|
||||||
|
return f"""
|
||||||
|
(function(){{
|
||||||
|
var sel=window.__editorSel;
|
||||||
|
if(!sel||!sel.el){{
|
||||||
|
var tas=document.querySelectorAll('textarea');
|
||||||
|
if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};
|
||||||
|
else return;
|
||||||
|
}}
|
||||||
|
var ta=sel.el, val=ta.value, pos=sel.start;
|
||||||
|
var lineStart=val.lastIndexOf('\\n',pos-1)+1;
|
||||||
|
var line=val.substring(lineStart);
|
||||||
|
var lineEnd=line.indexOf('\\n'); if(lineEnd===-1)lineEnd=line.length;
|
||||||
|
line=line.substring(0,lineEnd);
|
||||||
|
var newLine=line.startsWith('{p}')?line.substring({len(prefix)}):'{p}'+line;
|
||||||
|
ta.value=val.substring(0,lineStart)+newLine+val.substring(lineStart+lineEnd);
|
||||||
|
ta.selectionStart=ta.selectionEnd=lineStart+newLine.length;
|
||||||
|
ta.focus();
|
||||||
|
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
||||||
|
return ta.value;
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _js_append(text: str) -> str:
|
||||||
|
t = text.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
||||||
|
return f"""
|
||||||
|
(function(){{
|
||||||
|
var sel=window.__editorSel;
|
||||||
|
var ta=sel&&sel.el;
|
||||||
|
if(!ta){{var tas=document.querySelectorAll('textarea');if(tas.length>0)ta=tas[tas.length-1];else return;}}
|
||||||
|
ta.value=(ta.value?ta.value+'\\n':'')+'{t}';
|
||||||
|
ta.selectionStart=ta.selectionEnd=ta.value.length;
|
||||||
|
ta.focus();
|
||||||
|
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
||||||
|
return ta.value;
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleEditorView:
|
||||||
|
def __init__(self, page: ft.Page, article_id: str | None = None):
|
||||||
|
self.page = page
|
||||||
|
self.article_id = article_id
|
||||||
|
self._article: dict | None = None
|
||||||
|
self._categories: list = []
|
||||||
|
|
||||||
|
# Fields
|
||||||
|
self._title = d.text_field("Заголовок *", on_change=self._on_title_change)
|
||||||
|
self._slug = d.text_field("Slug (URL)", hint="генерируется автоматически")
|
||||||
|
self._excerpt = d.text_field("Краткое описание", multiline=True, min_lines=2, max_lines=4)
|
||||||
|
self._cover_url = d.text_field("URL обложки", on_change=self._on_cover_change)
|
||||||
|
self._tags = d.text_field("Теги", hint="через запятую: Python, новости, технологии")
|
||||||
|
self._cat_dd = ft.Dropdown(
|
||||||
|
label="Категория",
|
||||||
|
options=[ft.dropdown.Option("", "— без категории —")],
|
||||||
|
value="",
|
||||||
|
border_color=d.DIVIDER,
|
||||||
|
focused_border_color=d.PRIMARY,
|
||||||
|
border_radius=8,
|
||||||
|
)
|
||||||
|
self._status_sw = ft.Switch(label="Опубликовать сразу", value=False, active_color=d.SUCCESS)
|
||||||
|
self._font_dd = ft.Dropdown(
|
||||||
|
label="Шрифт статьи",
|
||||||
|
value="Merriweather",
|
||||||
|
options=[ft.dropdown.Option(k, v) for k, v in d.ARTICLE_FONTS.items()],
|
||||||
|
border_color=d.DIVIDER,
|
||||||
|
focused_border_color=d.PRIMARY,
|
||||||
|
border_radius=8,
|
||||||
|
on_change=self._on_article_font_change,
|
||||||
|
)
|
||||||
|
self._inline_font_dd = ft.Dropdown(
|
||||||
|
label="Шрифт фрагмента",
|
||||||
|
hint_text="Выбери и примени к выделению",
|
||||||
|
width=200,
|
||||||
|
options=[ft.dropdown.Option(k, k) for k in d.ARTICLE_FONTS],
|
||||||
|
border_color=d.DIVIDER,
|
||||||
|
focused_border_color=d.PRIMARY,
|
||||||
|
border_radius=8,
|
||||||
|
on_change=self._on_inline_font_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Content editor
|
||||||
|
self._content_field = ft.TextField(
|
||||||
|
multiline=True,
|
||||||
|
min_lines=25,
|
||||||
|
max_lines=None,
|
||||||
|
expand=True,
|
||||||
|
hint_text="Пишите в Markdown...",
|
||||||
|
text_style=ft.TextStyle(font_family="monospace", size=14, color=d.TEXT_PRIMARY),
|
||||||
|
border_color=d.DIVIDER,
|
||||||
|
focused_border_color=d.PRIMARY,
|
||||||
|
border_radius=8,
|
||||||
|
content_padding=ft.padding.all(12),
|
||||||
|
on_change=self._on_content_change,
|
||||||
|
)
|
||||||
|
self._preview = ft.Markdown(
|
||||||
|
value="",
|
||||||
|
selectable=True,
|
||||||
|
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||||
|
on_tap_link=lambda e: self.page.launch_url(e.data),
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
self._cover_preview = ft.Image(
|
||||||
|
src="",
|
||||||
|
width=200,
|
||||||
|
height=120,
|
||||||
|
fit=ft.ImageFit.COVER,
|
||||||
|
border_radius=8,
|
||||||
|
visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# File pickers
|
||||||
|
self._img_picker = ft.FilePicker(on_result=self._on_image_picked)
|
||||||
|
self._vid_picker = ft.FilePicker(on_result=self._on_video_picked)
|
||||||
|
self._cover_picker = ft.FilePicker(on_result=self._on_cover_picked)
|
||||||
|
|
||||||
|
# Save button
|
||||||
|
self._save_btn = d.btn_primary("Сохранить", on_click=lambda e: self.page.run_task(self._save), icon=ft.icons.SAVE)
|
||||||
|
self._loading = ft.ProgressRing(width=20, height=20, stroke_width=2, visible=False, color=d.PRIMARY)
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
title_text = "Редактировать статью" if self.article_id else "Новая статья"
|
||||||
|
|
||||||
|
toolbar = self._build_toolbar()
|
||||||
|
editor_area = ft.Row(
|
||||||
|
[
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Text("Редактор", size=12, color=d.TEXT_SECONDARY, weight=ft.FontWeight.W_500),
|
||||||
|
self._content_field,
|
||||||
|
],
|
||||||
|
spacing=4,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
padding=ft.padding.only(right=8),
|
||||||
|
),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Text("Превью", size=12, color=d.TEXT_SECONDARY, weight=ft.FontWeight.W_500),
|
||||||
|
ft.Container(
|
||||||
|
content=self._preview,
|
||||||
|
expand=True,
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
border_radius=8,
|
||||||
|
padding=16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=4,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
padding=ft.padding.only(left=8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
meta_panel = ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
d.headline("Настройки", size=16),
|
||||||
|
d.divider(),
|
||||||
|
self._cat_dd,
|
||||||
|
self._tags,
|
||||||
|
d.divider(),
|
||||||
|
d.headline("Обложка", size=14),
|
||||||
|
self._cover_url,
|
||||||
|
ft.Row([
|
||||||
|
d.btn_outlined(
|
||||||
|
"Загрузить",
|
||||||
|
icon=ft.icons.UPLOAD,
|
||||||
|
on_click=lambda e: self._cover_picker.pick_files(
|
||||||
|
allowed_extensions=["jpg", "jpeg", "png", "webp", "gif"],
|
||||||
|
dialog_title="Выберите обложку",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
self._cover_preview,
|
||||||
|
d.divider(),
|
||||||
|
self._font_dd,
|
||||||
|
d.divider(),
|
||||||
|
self._status_sw,
|
||||||
|
ft.Row(
|
||||||
|
[self._save_btn, self._loading],
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
width=280,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
border_radius=12,
|
||||||
|
padding=16,
|
||||||
|
width=280,
|
||||||
|
)
|
||||||
|
|
||||||
|
content_area = ft.Column(
|
||||||
|
[
|
||||||
|
self._title,
|
||||||
|
self._slug,
|
||||||
|
self._excerpt,
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column([toolbar, editor_area], spacing=0),
|
||||||
|
expand=True,
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
border_radius=12,
|
||||||
|
padding=16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route=f"/admin/articles/{self.article_id or 'new'}",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
appbar=_admin_appbar(self.page, title_text),
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
_sidebar(self.page, active="/admin/articles"),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Container(content=content_area, expand=True, padding=ft.padding.only(right=16)),
|
||||||
|
meta_panel,
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
padding=24,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
),
|
||||||
|
self._img_picker,
|
||||||
|
self._vid_picker,
|
||||||
|
self._cover_picker,
|
||||||
|
],
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_toolbar(self) -> ft.Container:
|
||||||
|
def tb_btn(icon, tooltip: str, js_code: str) -> ft.IconButton:
|
||||||
|
return ft.IconButton(
|
||||||
|
icon=icon,
|
||||||
|
tooltip=tooltip,
|
||||||
|
icon_size=18,
|
||||||
|
icon_color=d.TEXT_PRIMARY,
|
||||||
|
on_click=lambda e, js=js_code: self.page.run_task(self._exec_js, js),
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=4),
|
||||||
|
padding=ft.padding.all(6),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
buttons = [
|
||||||
|
tb_btn(ft.icons.FORMAT_BOLD, "Жирный (Ctrl+B)", _js_insert("**", "**", "жирный текст")),
|
||||||
|
tb_btn(ft.icons.FORMAT_ITALIC, "Курсив (Ctrl+I)", _js_insert("*", "*", "курсив")),
|
||||||
|
tb_btn(ft.icons.FORMAT_STRIKETHROUGH, "Зачёркнутый", _js_insert("~~", "~~", "текст")),
|
||||||
|
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
||||||
|
tb_btn(ft.icons.TITLE, "Заголовок H1", _js_prepend_line("# ")),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text("H2", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
|
||||||
|
on_click=lambda e: self.page.run_task(self._exec_js, _js_prepend_line("## ")),
|
||||||
|
ink=True,
|
||||||
|
border_radius=4,
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
||||||
|
tooltip="Заголовок H2",
|
||||||
|
),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text("H3", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
|
||||||
|
on_click=lambda e: self.page.run_task(self._exec_js, _js_prepend_line("### ")),
|
||||||
|
ink=True,
|
||||||
|
border_radius=4,
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
||||||
|
tooltip="Заголовок H3",
|
||||||
|
),
|
||||||
|
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
||||||
|
tb_btn(ft.icons.FORMAT_LIST_BULLETED, "Список", _js_prepend_line("- ")),
|
||||||
|
tb_btn(ft.icons.FORMAT_LIST_NUMBERED, "Нумерованный список", _js_prepend_line("1. ")),
|
||||||
|
tb_btn(ft.icons.FORMAT_QUOTE, "Цитата", _js_prepend_line("> ")),
|
||||||
|
tb_btn(ft.icons.CODE, "Код", _js_insert("`", "`", "код")),
|
||||||
|
tb_btn(ft.icons.DATA_OBJECT, "Блок кода", _js_insert("```\n", "\n```", "код")),
|
||||||
|
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
||||||
|
tb_btn(ft.icons.LINK, "Ссылка", _js_insert("[", "](https://)", "текст ссылки")),
|
||||||
|
ft.IconButton(
|
||||||
|
icon=ft.icons.IMAGE,
|
||||||
|
tooltip="Вставить картинку",
|
||||||
|
icon_size=18,
|
||||||
|
icon_color=d.PRIMARY,
|
||||||
|
on_click=lambda e: self._img_picker.pick_files(
|
||||||
|
allowed_extensions=["jpg", "jpeg", "png", "gif", "webp"],
|
||||||
|
dialog_title="Выберите изображение",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ft.IconButton(
|
||||||
|
icon=ft.icons.VIDEOCAM,
|
||||||
|
tooltip="Вставить видео",
|
||||||
|
icon_size=18,
|
||||||
|
icon_color=d.PRIMARY,
|
||||||
|
on_click=lambda e: self._vid_picker.pick_files(
|
||||||
|
allowed_extensions=["mp4", "webm", "ogg", "mov"],
|
||||||
|
dialog_title="Выберите видео",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
||||||
|
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", _js_append("\n---\n")),
|
||||||
|
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
||||||
|
ft.Container(content=self._inline_font_dd, width=210),
|
||||||
|
]
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row(buttons, spacing=0, wrap=True),
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
border_radius=ft.BorderRadius(8, 8, 0, 0),
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=4),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Events ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _exec_js(self, js: str):
|
||||||
|
try:
|
||||||
|
new_val = self.page.run_javascript(js, wait_for_result=True)
|
||||||
|
if isinstance(new_val, str):
|
||||||
|
self._content_field.value = new_val
|
||||||
|
self._preview.value = new_val
|
||||||
|
self.page.update()
|
||||||
|
except Exception:
|
||||||
|
self.page.run_javascript(js)
|
||||||
|
|
||||||
|
def _on_article_font_change(self, e):
|
||||||
|
font = e.control.value or "Merriweather"
|
||||||
|
self._content_field.text_style = ft.TextStyle(font_family=font, size=14, color=d.TEXT_PRIMARY)
|
||||||
|
self._preview.value = self._content_field.value or ""
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _on_inline_font_change(self, e):
|
||||||
|
font = e.control.value
|
||||||
|
if font:
|
||||||
|
self.page.run_task(self._exec_js, _js_font_span(font))
|
||||||
|
e.control.value = None
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _on_title_change(self, e):
|
||||||
|
if not self.article_id:
|
||||||
|
self._slug.value = _slugify(e.control.value)
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _on_content_change(self, e):
|
||||||
|
self._preview.value = e.control.value or ""
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _on_cover_change(self, e):
|
||||||
|
url = e.control.value.strip()
|
||||||
|
self._cover_preview.src = url
|
||||||
|
self._cover_preview.visible = bool(url)
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _on_image_picked(self, e: ft.FilePickerResultEvent):
|
||||||
|
if not e.files:
|
||||||
|
return
|
||||||
|
f = e.files[0]
|
||||||
|
await self._upload_and_insert(f, "image")
|
||||||
|
|
||||||
|
async def _on_video_picked(self, e: ft.FilePickerResultEvent):
|
||||||
|
if not e.files:
|
||||||
|
return
|
||||||
|
f = e.files[0]
|
||||||
|
await self._upload_and_insert(f, "video")
|
||||||
|
|
||||||
|
async def _on_cover_picked(self, e: ft.FilePickerResultEvent):
|
||||||
|
if not e.files:
|
||||||
|
return
|
||||||
|
f = e.files[0]
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
file_bytes = await _read_file(f)
|
||||||
|
if file_bytes is None:
|
||||||
|
d.snack(self.page, "Не удалось прочитать файл", error=True)
|
||||||
|
return
|
||||||
|
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
||||||
|
ct = _content_type(ext, "image")
|
||||||
|
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
|
||||||
|
if result:
|
||||||
|
self._cover_url.value = result["url"]
|
||||||
|
self._cover_preview.src = result["url"]
|
||||||
|
self._cover_preview.visible = True
|
||||||
|
self.page.update()
|
||||||
|
d.snack(self.page, "Обложка загружена")
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка загрузки обложки", error=True)
|
||||||
|
|
||||||
|
async def _upload_and_insert(self, f, media_kind: str):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
d.snack(self.page, f"Загрузка {f.name}...")
|
||||||
|
file_bytes = await _read_file(f)
|
||||||
|
if file_bytes is None:
|
||||||
|
d.snack(self.page, "Не удалось прочитать файл", error=True)
|
||||||
|
return
|
||||||
|
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
||||||
|
ct = _content_type(ext, media_kind)
|
||||||
|
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
|
||||||
|
if result:
|
||||||
|
url = result["url"]
|
||||||
|
if media_kind == "image":
|
||||||
|
md = f""
|
||||||
|
else:
|
||||||
|
md = (
|
||||||
|
f'<video controls style="max-width:100%">\n'
|
||||||
|
f' <source src="{url}" type="{ct}">\n'
|
||||||
|
f'</video>'
|
||||||
|
)
|
||||||
|
self.page.run_javascript(_js_append(md))
|
||||||
|
self._content_field.value = (self._content_field.value or "") + "\n" + md
|
||||||
|
self._preview.value = self._content_field.value
|
||||||
|
self.page.update()
|
||||||
|
d.snack(self.page, f"Файл загружен: {f.name}")
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка загрузки файла", error=True)
|
||||||
|
|
||||||
|
# ─── Load & Save ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
self._categories = await api.admin_list_categories(token)
|
||||||
|
self._cat_dd.options = [ft.dropdown.Option("", "— без категории —")] + [
|
||||||
|
ft.dropdown.Option(str(c["id"]), c["name"]) for c in self._categories
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.article_id:
|
||||||
|
self._article = await api.admin_get_article(token, self.article_id)
|
||||||
|
if self._article:
|
||||||
|
self._fill_form(self._article)
|
||||||
|
|
||||||
|
self.page.run_javascript(_TRACK_JS)
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _fill_form(self, a: dict):
|
||||||
|
self._title.value = a.get("title", "")
|
||||||
|
self._slug.value = a.get("slug", "")
|
||||||
|
self._excerpt.value = a.get("excerpt", "")
|
||||||
|
self._cover_url.value = a.get("cover_url", "") or ""
|
||||||
|
self._content_field.value = a.get("content", "")
|
||||||
|
self._preview.value = a.get("content", "")
|
||||||
|
self._tags.value = ", ".join(t["name"] for t in (a.get("tags") or []))
|
||||||
|
cat = a.get("category")
|
||||||
|
self._cat_dd.value = str(cat["id"]) if cat else ""
|
||||||
|
self._status_sw.value = a.get("status") == "published"
|
||||||
|
font = a.get("font_family", "Merriweather")
|
||||||
|
self._font_dd.value = font
|
||||||
|
self._content_field.text_style = ft.TextStyle(font_family=font, size=14, color=d.TEXT_PRIMARY)
|
||||||
|
|
||||||
|
if a.get("cover_url"):
|
||||||
|
self._cover_preview.src = a["cover_url"]
|
||||||
|
self._cover_preview.visible = True
|
||||||
|
|
||||||
|
async def _save(self):
|
||||||
|
self._save_btn.disabled = True
|
||||||
|
self._loading.visible = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
tag_names = [t.strip() for t in (self._tags.value or "").split(",") if t.strip()]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"title": (self._title.value or "").strip(),
|
||||||
|
"slug": (self._slug.value or "").strip() or None,
|
||||||
|
"content": self._content_field.value or "",
|
||||||
|
"excerpt": (self._excerpt.value or "").strip(),
|
||||||
|
"cover_url": (self._cover_url.value or "").strip() or None,
|
||||||
|
"font_family": self._font_dd.value or "Merriweather",
|
||||||
|
"category_id": self._cat_dd.value or None,
|
||||||
|
"tag_names": tag_names,
|
||||||
|
"status": "published" if self._status_sw.value else "draft",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not payload["title"]:
|
||||||
|
d.snack(self.page, "Заголовок обязателен", error=True)
|
||||||
|
self._save_btn.disabled = False
|
||||||
|
self._loading.visible = False
|
||||||
|
self.page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.article_id:
|
||||||
|
result = await api.admin_update_article(token, self.article_id, payload)
|
||||||
|
else:
|
||||||
|
result = await api.admin_create_article(token, payload)
|
||||||
|
|
||||||
|
self._save_btn.disabled = False
|
||||||
|
self._loading.visible = False
|
||||||
|
|
||||||
|
if result:
|
||||||
|
d.snack(self.page, "Сохранено")
|
||||||
|
if not self.article_id:
|
||||||
|
self.article_id = result["id"]
|
||||||
|
self.page.go(f"/admin/articles/{self.article_id}")
|
||||||
|
else:
|
||||||
|
self._fill_form(result)
|
||||||
|
self.page.update()
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка сохранения", error=True)
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _slugify(text: str) -> str:
|
||||||
|
import re
|
||||||
|
text = text.lower().strip()
|
||||||
|
text = re.sub(r"[^\w\s-]", "", text)
|
||||||
|
text = re.sub(r"[\s_-]+", "-", text)
|
||||||
|
return text[:100]
|
||||||
|
|
||||||
|
|
||||||
|
def _content_type(ext: str, kind: str) -> str:
|
||||||
|
image_map = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
|
||||||
|
"gif": "image/gif", "webp": "image/webp", "svg": "image/svg+xml"}
|
||||||
|
video_map = {"mp4": "video/mp4", "webm": "video/webm", "ogg": "video/ogg", "mov": "video/quicktime"}
|
||||||
|
if kind == "image":
|
||||||
|
return image_map.get(ext, "image/jpeg")
|
||||||
|
return video_map.get(ext, "video/mp4")
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_file(f) -> bytes | None:
|
||||||
|
try:
|
||||||
|
if hasattr(f, "path") and f.path:
|
||||||
|
with open(f.path, "rb") as fh:
|
||||||
|
return fh.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
207
web/views/articles_list.py
Normal file
207
web/views/articles_list.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
from views.dashboard import _admin_appbar, _sidebar
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlesListView:
|
||||||
|
def __init__(self, page: ft.Page):
|
||||||
|
self.page = page
|
||||||
|
self._table_col = ft.Column(spacing=0, expand=True)
|
||||||
|
self._filter = "all"
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
filter_tabs = ft.Tabs(
|
||||||
|
selected_index=0,
|
||||||
|
on_change=self._on_filter,
|
||||||
|
tabs=[
|
||||||
|
ft.Tab(text="Все"),
|
||||||
|
ft.Tab(text="Опубликовано"),
|
||||||
|
ft.Tab(text="Черновики"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/admin/articles",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
appbar=_admin_appbar(self.page, "Статьи"),
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
_sidebar(self.page, active="/admin/articles"),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
d.headline("Статьи", size=22),
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
d.btn_primary(
|
||||||
|
"Новая статья",
|
||||||
|
icon=ft.icons.ADD,
|
||||||
|
on_click=lambda e: self.page.go("/admin/articles/new"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
filter_tabs,
|
||||||
|
self._table_col,
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
spacing=16,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
padding=24,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
await self._load_articles()
|
||||||
|
|
||||||
|
async def _on_filter(self, e):
|
||||||
|
idx = e.control.selected_index
|
||||||
|
self._filter = ["all", "published", "draft"][idx]
|
||||||
|
await self._load_articles()
|
||||||
|
|
||||||
|
async def _load_articles(self):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
status = None if self._filter == "all" else self._filter
|
||||||
|
data = await api.admin_list_articles(token, limit=200, status=status)
|
||||||
|
items = data.get("items", []) if data else []
|
||||||
|
self._render(items)
|
||||||
|
|
||||||
|
def _render(self, items: list):
|
||||||
|
if not items:
|
||||||
|
self._table_col.controls = [
|
||||||
|
ft.Container(
|
||||||
|
content=d.empty_state(ft.icons.ARTICLE_OUTLINED, "Статей пока нет"),
|
||||||
|
alignment=ft.alignment.center,
|
||||||
|
padding=40,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
header = ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Text("Заголовок", size=12, color=d.TEXT_SECONDARY, expand=3),
|
||||||
|
ft.Text("Категория", size=12, color=d.TEXT_SECONDARY, expand=1),
|
||||||
|
ft.Text("Статус", size=12, color=d.TEXT_SECONDARY, width=110),
|
||||||
|
ft.Text("Дата", size=12, color=d.TEXT_SECONDARY, width=110),
|
||||||
|
ft.Text("Действия", size=12, color=d.TEXT_SECONDARY, width=130),
|
||||||
|
],
|
||||||
|
spacing=8,
|
||||||
|
),
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
padding=ft.padding.symmetric(horizontal=16, vertical=10),
|
||||||
|
border_radius=ft.BorderRadius(8, 8, 0, 0),
|
||||||
|
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = [header] + [self._build_row(a) for a in items]
|
||||||
|
self._table_col.controls = [
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(rows, spacing=0),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border_radius=8,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _build_row(self, article: dict) -> ft.Container:
|
||||||
|
pub_date = ""
|
||||||
|
if article.get("published_at"):
|
||||||
|
pub_date = article["published_at"][:10]
|
||||||
|
elif article.get("created_at"):
|
||||||
|
pub_date = article["created_at"][:10]
|
||||||
|
|
||||||
|
cat_name = article.get("category", {})
|
||||||
|
cat_name = (cat_name or {}).get("name", "—")
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Text(
|
||||||
|
article["title"],
|
||||||
|
size=14,
|
||||||
|
expand=3,
|
||||||
|
max_lines=2,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS,
|
||||||
|
),
|
||||||
|
ft.Text(cat_name, size=13, color=d.TEXT_SECONDARY, expand=1),
|
||||||
|
ft.Container(content=d.status_badge(article.get("status", "draft")), width=110),
|
||||||
|
ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY, width=110),
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
d.btn_icon(
|
||||||
|
ft.icons.EDIT_OUTLINED,
|
||||||
|
"Редактировать",
|
||||||
|
on_click=lambda e, aid=article["id"]: self.page.go(f"/admin/articles/{aid}"),
|
||||||
|
color=d.PRIMARY,
|
||||||
|
),
|
||||||
|
*([
|
||||||
|
d.btn_icon(
|
||||||
|
ft.icons.PUBLISH,
|
||||||
|
"Опубликовать",
|
||||||
|
on_click=lambda e, aid=article["id"]: self.page.run_task(self._publish, aid),
|
||||||
|
color=d.SUCCESS,
|
||||||
|
)
|
||||||
|
] if article.get("status") == "draft" else []),
|
||||||
|
d.btn_icon(
|
||||||
|
ft.icons.DELETE_OUTLINE,
|
||||||
|
"Удалить",
|
||||||
|
on_click=lambda e, a=article: self._confirm_delete(a),
|
||||||
|
color=d.ERROR,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
width=130,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=8,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||||
|
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||||
|
ink=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _confirm_delete(self, article: dict):
|
||||||
|
dlg = d.confirm_dialog(
|
||||||
|
self.page,
|
||||||
|
"Удалить статью?",
|
||||||
|
f'"{article["title"]}" будет удалена навсегда.',
|
||||||
|
lambda: self.page.run_task(self._delete, article["id"]),
|
||||||
|
)
|
||||||
|
self.page.dialog = dlg
|
||||||
|
dlg.open = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _delete(self, article_id: str):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
ok = await api.admin_delete_article(token, article_id)
|
||||||
|
if ok:
|
||||||
|
d.snack(self.page, "Статья удалена")
|
||||||
|
await self._load_articles()
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка удаления", error=True)
|
||||||
|
|
||||||
|
async def _publish(self, article_id: str):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
ok = await api.admin_publish_article(token, article_id)
|
||||||
|
if ok:
|
||||||
|
d.snack(self.page, "Статья опубликована")
|
||||||
|
await self._load_articles()
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка публикации", error=True)
|
||||||
165
web/views/categories.py
Normal file
165
web/views/categories.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
from views.dashboard import _admin_appbar, _sidebar
|
||||||
|
|
||||||
|
|
||||||
|
class CategoriesView:
|
||||||
|
def __init__(self, page: ft.Page):
|
||||||
|
self.page = page
|
||||||
|
self._list_col = ft.Column(spacing=0)
|
||||||
|
self._name_field = d.text_field("Название категории")
|
||||||
|
self._slug_field = d.text_field("Slug (необязательно)", hint="автогенерация из названия")
|
||||||
|
self._edit_id: str | None = None
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
form = ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
d.headline("Категории", size=22),
|
||||||
|
ft.Divider(height=8, color="transparent"),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
d.headline("Добавить категорию", size=16),
|
||||||
|
ft.Divider(height=4, color="transparent"),
|
||||||
|
self._name_field,
|
||||||
|
self._slug_field,
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
d.btn_primary("Сохранить", on_click=lambda e: self.page.run_task(self._save)),
|
||||||
|
d.btn_outlined("Сбросить", on_click=self._reset, color=d.TEXT_SECONDARY),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border_radius=12,
|
||||||
|
padding=20,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
),
|
||||||
|
ft.Divider(height=16, color="transparent"),
|
||||||
|
d.headline("Список категорий", size=16),
|
||||||
|
self._list_col,
|
||||||
|
],
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
spacing=8,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
padding=24,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/admin/categories",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
appbar=_admin_appbar(self.page, "Категории"),
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
[_sidebar(self.page, active="/admin/categories"), form],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
await self._reload()
|
||||||
|
|
||||||
|
async def _reload(self):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
cats = await api.admin_list_categories(token)
|
||||||
|
self._list_col.controls = [self._build_row(c) for c in cats] if cats else [
|
||||||
|
ft.Container(
|
||||||
|
content=d.empty_state(ft.icons.CATEGORY_OUTLINED, "Категорий пока нет"),
|
||||||
|
padding=30,
|
||||||
|
alignment=ft.alignment.center,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _build_row(self, cat: dict) -> ft.Container:
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Column(
|
||||||
|
[
|
||||||
|
ft.Text(cat["name"], size=14, weight=ft.FontWeight.W_500),
|
||||||
|
ft.Text(cat["slug"], size=12, color=d.TEXT_SECONDARY),
|
||||||
|
],
|
||||||
|
spacing=2,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
d.btn_icon(
|
||||||
|
ft.icons.EDIT_OUTLINED, "Редактировать",
|
||||||
|
on_click=lambda e, c=cat: self._start_edit(c),
|
||||||
|
color=d.PRIMARY,
|
||||||
|
),
|
||||||
|
d.btn_icon(
|
||||||
|
ft.icons.DELETE_OUTLINE, "Удалить",
|
||||||
|
on_click=lambda e, c=cat: self._confirm_delete(c),
|
||||||
|
color=d.ERROR,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=8,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border_radius=8,
|
||||||
|
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
margin=ft.margin.only(bottom=8),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_edit(self, cat: dict):
|
||||||
|
self._edit_id = cat["id"]
|
||||||
|
self._name_field.value = cat["name"]
|
||||||
|
self._slug_field.value = cat["slug"]
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _reset(self, e=None):
|
||||||
|
self._edit_id = None
|
||||||
|
self._name_field.value = ""
|
||||||
|
self._slug_field.value = ""
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _save(self):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
name = (self._name_field.value or "").strip()
|
||||||
|
slug = (self._slug_field.value or "").strip()
|
||||||
|
if not name:
|
||||||
|
d.snack(self.page, "Введите название", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._edit_id:
|
||||||
|
result = await api.admin_update_category(token, self._edit_id, name, slug)
|
||||||
|
else:
|
||||||
|
result = await api.admin_create_category(token, name, slug)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
d.snack(self.page, "Сохранено")
|
||||||
|
self._reset()
|
||||||
|
await self._reload()
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка сохранения", error=True)
|
||||||
|
|
||||||
|
def _confirm_delete(self, cat: dict):
|
||||||
|
dlg = d.confirm_dialog(
|
||||||
|
self.page,
|
||||||
|
"Удалить категорию?",
|
||||||
|
f'"{cat["name"]}" будет удалена. Статьи останутся без категории.',
|
||||||
|
lambda: self.page.run_task(self._delete, cat["id"]),
|
||||||
|
)
|
||||||
|
self.page.dialog = dlg
|
||||||
|
dlg.open = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _delete(self, cat_id: str):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
ok = await api.admin_delete_category(token, cat_id)
|
||||||
|
if ok:
|
||||||
|
d.snack(self.page, "Категория удалена")
|
||||||
|
await self._reload()
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка удаления", error=True)
|
||||||
148
web/views/dashboard.py
Normal file
148
web/views/dashboard.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView:
|
||||||
|
def __init__(self, page: ft.Page):
|
||||||
|
self.page = page
|
||||||
|
self._stats_row = ft.Row(wrap=True, spacing=16, run_spacing=16)
|
||||||
|
self._recent = ft.Column(spacing=0)
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
return ft.View(
|
||||||
|
route="/admin",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
appbar=_admin_appbar(self.page, "Главная"),
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
_sidebar(self.page, active="/admin"),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
d.headline("Обзор", size=22),
|
||||||
|
ft.Divider(height=8, color="transparent"),
|
||||||
|
self._stats_row,
|
||||||
|
ft.Divider(height=16, color="transparent"),
|
||||||
|
d.headline("Последние статьи", size=18),
|
||||||
|
ft.Divider(height=8, color="transparent"),
|
||||||
|
self._recent,
|
||||||
|
],
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
padding=24,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
data = await api.admin_list_articles(token, limit=100)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = data.get("items", [])
|
||||||
|
total = data.get("total", 0)
|
||||||
|
published = sum(1 for a in items if a.get("status") == "published")
|
||||||
|
drafts = total - published
|
||||||
|
|
||||||
|
self._stats_row.controls = [
|
||||||
|
d.stat_card("Всего статей", str(total), ft.icons.ARTICLE, d.PRIMARY),
|
||||||
|
d.stat_card("Опубликовано", str(published), ft.icons.CHECK_CIRCLE, d.SUCCESS),
|
||||||
|
d.stat_card("Черновики", str(drafts), ft.icons.EDIT_NOTE, d.WARNING),
|
||||||
|
]
|
||||||
|
|
||||||
|
self._recent.controls = [_recent_item(a, self.page) for a in items[:10]]
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_item(article: dict, page: ft.Page) -> ft.Container:
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
d.status_badge(article.get("status", "draft")),
|
||||||
|
ft.Text(article["title"], size=14, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.TextButton(
|
||||||
|
"Редактировать",
|
||||||
|
on_click=lambda e, aid=article["id"]: page.go(f"/admin/articles/{aid}"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
padding=ft.padding.symmetric(vertical=10, horizontal=4),
|
||||||
|
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Общие admin-компоненты ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_appbar(page: ft.Page, title: str) -> ft.AppBar:
|
||||||
|
return ft.AppBar(
|
||||||
|
title=ft.Text(title, size=18, weight=ft.FontWeight.W_600, color=ft.colors.WHITE),
|
||||||
|
bgcolor=d.ADMIN_SIDEBAR,
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
actions=[
|
||||||
|
ft.TextButton(
|
||||||
|
"На сайт",
|
||||||
|
icon=ft.icons.OPEN_IN_NEW,
|
||||||
|
style=ft.ButtonStyle(color=ft.colors.WHITE70),
|
||||||
|
on_click=lambda e: page.launch_url("/"),
|
||||||
|
),
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.LOGOUT,
|
||||||
|
tooltip="Выйти",
|
||||||
|
icon_color=ft.colors.WHITE70,
|
||||||
|
on_click=lambda e: _logout(page),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _logout(page: ft.Page):
|
||||||
|
page.session.remove("token")
|
||||||
|
page.session.remove("username")
|
||||||
|
page.go("/admin/login")
|
||||||
|
|
||||||
|
|
||||||
|
def _sidebar(page: ft.Page, active: str = "") -> ft.Container:
|
||||||
|
items = [
|
||||||
|
(ft.icons.DASHBOARD, "Главная", "/admin"),
|
||||||
|
(ft.icons.ARTICLE, "Статьи", "/admin/articles"),
|
||||||
|
(ft.icons.CATEGORY, "Категории", "/admin/categories"),
|
||||||
|
(ft.icons.PERM_MEDIA, "Медиа", "/admin/media"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def nav_item(icon, label, route):
|
||||||
|
is_active = active == route
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Icon(icon, color=d.ADMIN_SIDEBAR_TEXT if not is_active else d.ACCENT, size=20),
|
||||||
|
ft.Text(label, color=d.ADMIN_SIDEBAR_TEXT, size=14, weight=ft.FontWeight.W_500 if is_active else ft.FontWeight.NORMAL),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
),
|
||||||
|
bgcolor=d.ADMIN_SIDEBAR_ACTIVE if is_active else None,
|
||||||
|
border_radius=8,
|
||||||
|
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||||
|
on_click=lambda e, r=route: page.go(r),
|
||||||
|
ink=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[ft.Container(height=16)] + [nav_item(i, l, r) for i, l, r in items],
|
||||||
|
spacing=2,
|
||||||
|
),
|
||||||
|
bgcolor=d.ADMIN_SIDEBAR,
|
||||||
|
width=220,
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=8),
|
||||||
|
)
|
||||||
76
web/views/login.py
Normal file
76
web/views/login.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView:
|
||||||
|
def __init__(self, page: ft.Page):
|
||||||
|
self.page = page
|
||||||
|
self._username = d.text_field("Логин", prefix_icon=ft.icons.PERSON_OUTLINE)
|
||||||
|
self._password = d.text_field("Пароль", password=True, prefix_icon=ft.icons.LOCK_OUTLINE)
|
||||||
|
self._error = ft.Text("", color=d.ERROR, size=13)
|
||||||
|
self._btn = d.btn_primary("Войти", on_click=self._on_login, width=280)
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
self._username.on_submit = self._on_login
|
||||||
|
self._password.on_submit = self._on_login
|
||||||
|
|
||||||
|
form = ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Icon(ft.icons.NEWSPAPER, size=56, color=d.PRIMARY),
|
||||||
|
d.headline("Новостная CMS", size=24),
|
||||||
|
d.caption("Войдите в панель управления", color=d.TEXT_SECONDARY),
|
||||||
|
ft.Divider(height=20, color="transparent"),
|
||||||
|
self._username,
|
||||||
|
self._password,
|
||||||
|
self._error,
|
||||||
|
ft.Divider(height=8, color="transparent"),
|
||||||
|
self._btn,
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=14,
|
||||||
|
width=320,
|
||||||
|
),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border_radius=16,
|
||||||
|
padding=40,
|
||||||
|
shadow=ft.BoxShadow(blur_radius=24, color=ft.colors.with_opacity(0.10, ft.colors.BLACK)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/admin/login",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
controls=[
|
||||||
|
ft.Container(
|
||||||
|
content=form,
|
||||||
|
alignment=ft.alignment.center,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _on_login(self, e=None):
|
||||||
|
self._error.value = ""
|
||||||
|
username = self._username.value.strip()
|
||||||
|
password = self._password.value.strip()
|
||||||
|
if not username or not password:
|
||||||
|
self._error.value = "Заполните все поля"
|
||||||
|
self.page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._btn.disabled = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
token = await api.admin_login(username, password)
|
||||||
|
self._btn.disabled = False
|
||||||
|
|
||||||
|
if token:
|
||||||
|
self.page.session.set("token", token)
|
||||||
|
self.page.session.set("username", username)
|
||||||
|
self.page.go("/admin")
|
||||||
|
else:
|
||||||
|
self._error.value = "Неверный логин или пароль"
|
||||||
|
self.page.update()
|
||||||
217
web/views/media_library.py
Normal file
217
web/views/media_library.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
from views.dashboard import _admin_appbar, _sidebar
|
||||||
|
|
||||||
|
|
||||||
|
class MediaLibraryView:
|
||||||
|
def __init__(self, page: ft.Page):
|
||||||
|
self.page = page
|
||||||
|
self._grid = ft.GridView(
|
||||||
|
expand=True,
|
||||||
|
runs_count=4,
|
||||||
|
max_extent=200,
|
||||||
|
child_aspect_ratio=1.0,
|
||||||
|
spacing=12,
|
||||||
|
run_spacing=12,
|
||||||
|
)
|
||||||
|
self._filter = None
|
||||||
|
self._picker = ft.FilePicker(on_result=self._on_files_picked)
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
filter_tabs = ft.Tabs(
|
||||||
|
selected_index=0,
|
||||||
|
on_change=self._on_filter,
|
||||||
|
tabs=[ft.Tab(text="Все"), ft.Tab(text="Изображения"), ft.Tab(text="Видео")],
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/admin/media",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
appbar=_admin_appbar(self.page, "Медиа"),
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
_sidebar(self.page, active="/admin/media"),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
d.headline("Медиатека", size=22),
|
||||||
|
d.btn_primary(
|
||||||
|
"Загрузить файлы",
|
||||||
|
icon=ft.icons.UPLOAD,
|
||||||
|
on_click=lambda e: self._picker.pick_files(
|
||||||
|
allow_multiple=True,
|
||||||
|
dialog_title="Выберите изображения или видео",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
filter_tabs,
|
||||||
|
self._grid,
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=16,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
padding=24,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
),
|
||||||
|
self._picker,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
await self._reload()
|
||||||
|
|
||||||
|
async def _on_filter(self, e):
|
||||||
|
self._filter = [None, "image", "video"][e.control.selected_index]
|
||||||
|
await self._reload()
|
||||||
|
|
||||||
|
async def _reload(self):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
items = await api.admin_list_media(token, limit=200, media_type=self._filter)
|
||||||
|
self._render(items)
|
||||||
|
|
||||||
|
def _render(self, items: list):
|
||||||
|
if not items:
|
||||||
|
self._grid.controls = [
|
||||||
|
d.empty_state(ft.icons.PERM_MEDIA_OUTLINED, "Медиафайлов пока нет")
|
||||||
|
]
|
||||||
|
self.page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._grid.controls = [self._build_card(m) for m in items]
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _build_card(self, m: dict) -> ft.Container:
|
||||||
|
is_image = m.get("media_type") == "image"
|
||||||
|
content = (
|
||||||
|
ft.Image(src=m["url"], fit=ft.ImageFit.COVER, expand=True, error_content=ft.Icon(ft.icons.BROKEN_IMAGE))
|
||||||
|
if is_image
|
||||||
|
else ft.Column(
|
||||||
|
[ft.Icon(ft.icons.PLAY_CIRCLE_OUTLINED, size=40, color=d.TEXT_SECONDARY),
|
||||||
|
ft.Text("Видео", size=11, color=d.TEXT_SECONDARY)],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
size_kb = round(m.get("size_bytes", 0) / 1024, 1)
|
||||||
|
name = m.get("filename", "")[:20]
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Stack(
|
||||||
|
[
|
||||||
|
ft.Container(content=content, expand=True, bgcolor="#F0F0F0", border_radius=8),
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Text(name, size=10, color=ft.colors.WHITE, max_lines=1,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.Text(f"{size_kb} КБ", size=9, color=ft.colors.WHITE70),
|
||||||
|
],
|
||||||
|
spacing=2,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.COPY,
|
||||||
|
icon_size=14,
|
||||||
|
icon_color=ft.colors.WHITE,
|
||||||
|
tooltip="Копировать URL",
|
||||||
|
on_click=lambda e, url=m["url"]: self._copy_url(url),
|
||||||
|
),
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.DELETE_OUTLINE,
|
||||||
|
icon_size=14,
|
||||||
|
icon_color=ft.colors.WHITE,
|
||||||
|
tooltip="Удалить",
|
||||||
|
on_click=lambda e, mid=m["id"]: self._confirm_delete(mid, m.get("filename", "")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
),
|
||||||
|
bgcolor=ft.colors.with_opacity(0.6, ft.colors.BLACK),
|
||||||
|
border_radius=ft.BorderRadius(0, 0, 8, 8),
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
||||||
|
bottom=0,
|
||||||
|
left=0,
|
||||||
|
right=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
height=180,
|
||||||
|
border_radius=8,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _copy_url(self, url: str):
|
||||||
|
self.page.set_clipboard(url)
|
||||||
|
d.snack(self.page, "URL скопирован в буфер обмена")
|
||||||
|
|
||||||
|
def _confirm_delete(self, media_id: str, filename: str):
|
||||||
|
dlg = d.confirm_dialog(
|
||||||
|
self.page,
|
||||||
|
"Удалить файл?",
|
||||||
|
f'"{filename}" будет удалён с сервера.',
|
||||||
|
lambda: self.page.run_task(self._delete, media_id),
|
||||||
|
)
|
||||||
|
self.page.dialog = dlg
|
||||||
|
dlg.open = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _delete(self, media_id: str):
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
ok = await api.admin_delete_media(token, media_id)
|
||||||
|
if ok:
|
||||||
|
d.snack(self.page, "Файл удалён")
|
||||||
|
await self._reload()
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Ошибка удаления", error=True)
|
||||||
|
|
||||||
|
async def _on_files_picked(self, e: ft.FilePickerResultEvent):
|
||||||
|
if not e.files:
|
||||||
|
return
|
||||||
|
token = self.page.session.get("token")
|
||||||
|
uploaded = 0
|
||||||
|
for f in e.files:
|
||||||
|
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
||||||
|
video_exts = {"mp4", "webm", "ogg", "mov"}
|
||||||
|
kind = "video" if ext in video_exts else "image"
|
||||||
|
ct_map = {
|
||||||
|
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
|
||||||
|
"gif": "image/gif", "webp": "image/webp",
|
||||||
|
"mp4": "video/mp4", "webm": "video/webm", "ogg": "video/ogg", "mov": "video/quicktime",
|
||||||
|
}
|
||||||
|
ct = ct_map.get(ext, "application/octet-stream")
|
||||||
|
try:
|
||||||
|
if hasattr(f, "path") and f.path:
|
||||||
|
with open(f.path, "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
result = await api.admin_upload_media(token, data, f.name, ct)
|
||||||
|
if result:
|
||||||
|
uploaded += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
d.snack(self.page, f"Загружено файлов: {uploaded}")
|
||||||
|
await self._reload()
|
||||||
189
web/views/news_detail.py
Normal file
189
web/views/news_detail.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
import theme_manager
|
||||||
|
|
||||||
|
|
||||||
|
class NewsDetailView:
|
||||||
|
def __init__(self, page: ft.Page, slug: str):
|
||||||
|
self.page = page
|
||||||
|
self.slug = slug
|
||||||
|
self._content_col = ft.Column(
|
||||||
|
[ft.Container(content=d.loader(), alignment=ft.alignment.center, padding=40)],
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
header = ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.IconButton(
|
||||||
|
ft.icons.ARROW_BACK,
|
||||||
|
icon_color=ft.colors.WHITE,
|
||||||
|
tooltip="Назад",
|
||||||
|
on_click=lambda e: self.page.go("/"),
|
||||||
|
),
|
||||||
|
ft.Row([
|
||||||
|
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=24),
|
||||||
|
ft.Text("Новости", size=18, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
|
||||||
|
], spacing=8),
|
||||||
|
ft.Container(width=40),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
bgcolor=d.ADMIN_SIDEBAR,
|
||||||
|
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route=f"/news/{self.slug}",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
controls=[
|
||||||
|
ft.Column(
|
||||||
|
[
|
||||||
|
header,
|
||||||
|
ft.Container(
|
||||||
|
content=self._content_col,
|
||||||
|
alignment=ft.alignment.top_center,
|
||||||
|
expand=True,
|
||||||
|
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
expand=True,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
article = await api.get_article(self.slug)
|
||||||
|
if not article:
|
||||||
|
theme_manager.reset_font(self.page)
|
||||||
|
self._content_col.controls = [
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Icon(ft.icons.ARTICLE_OUTLINED, size=64, color=d.DIVIDER),
|
||||||
|
d.headline("Статья не найдена", size=20),
|
||||||
|
d.btn_outlined("На главную", on_click=lambda e: self.page.go("/")),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=16,
|
||||||
|
),
|
||||||
|
alignment=ft.alignment.center,
|
||||||
|
padding=60,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
font = article.get("font_family", "Merriweather")
|
||||||
|
theme_manager.apply_article_font(self.page, font)
|
||||||
|
self._render(article)
|
||||||
|
|
||||||
|
def _render(self, a: dict):
|
||||||
|
font = a.get("font_family", "Merriweather")
|
||||||
|
cat = a.get("category")
|
||||||
|
tags = a.get("tags", [])
|
||||||
|
pub_date = a.get("published_at", "")[:10] if a.get("published_at") else ""
|
||||||
|
|
||||||
|
meta_chips = ft.Row(wrap=True, spacing=8, run_spacing=8)
|
||||||
|
if cat:
|
||||||
|
meta_chips.controls.append(
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text(cat["name"], size=12, color=ft.colors.WHITE),
|
||||||
|
bgcolor=d.PRIMARY,
|
||||||
|
border_radius=4,
|
||||||
|
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for tag in tags:
|
||||||
|
meta_chips.controls.append(
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text(f"#{tag['name']}", size=12, color=d.PRIMARY),
|
||||||
|
bgcolor=ft.colors.with_opacity(0.1, d.PRIMARY),
|
||||||
|
border_radius=4,
|
||||||
|
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controls = [
|
||||||
|
d.headline(a["title"], size=30, font=d.FONT_DISPLAY),
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
meta_chips,
|
||||||
|
ft.Row([
|
||||||
|
ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY),
|
||||||
|
ft.Row([
|
||||||
|
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||||
|
ft.Text(str(a.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||||
|
], spacing=4),
|
||||||
|
], spacing=16),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
wrap=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if a.get("cover_url"):
|
||||||
|
controls.append(
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Image(
|
||||||
|
src=a["cover_url"],
|
||||||
|
width=float("inf"),
|
||||||
|
height=400,
|
||||||
|
fit=ft.ImageFit.COVER,
|
||||||
|
border_radius=12,
|
||||||
|
),
|
||||||
|
border_radius=12,
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if a.get("excerpt"):
|
||||||
|
controls.append(
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text(
|
||||||
|
a["excerpt"],
|
||||||
|
size=16,
|
||||||
|
color=d.TEXT_SECONDARY,
|
||||||
|
italic=True,
|
||||||
|
),
|
||||||
|
border=ft.Border(left=ft.BorderSide(4, d.PRIMARY)),
|
||||||
|
padding=ft.padding.only(left=16, top=8, bottom=8),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controls.append(d.divider())
|
||||||
|
|
||||||
|
controls.append(
|
||||||
|
ft.Markdown(
|
||||||
|
value=a.get("content", ""),
|
||||||
|
selectable=True,
|
||||||
|
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||||
|
on_tap_link=lambda e: self.page.launch_url(e.data),
|
||||||
|
auto_follow_links=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controls.append(ft.Divider(height=32, color="transparent"))
|
||||||
|
controls.append(
|
||||||
|
ft.TextButton(
|
||||||
|
"← Назад к новостям",
|
||||||
|
icon=ft.icons.ARROW_BACK,
|
||||||
|
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||||
|
on_click=lambda e: self.page.go("/"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._content_col.controls = [
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column(controls, spacing=16),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border_radius=12,
|
||||||
|
padding=32,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.page.update()
|
||||||
240
web/views/news_feed.py
Normal file
240
web/views/news_feed.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import flet as ft
|
||||||
|
import designer as d
|
||||||
|
import api_client as api
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFeedView:
|
||||||
|
def __init__(self, page: ft.Page):
|
||||||
|
self.page = page
|
||||||
|
self._articles: list = []
|
||||||
|
self._offset = 0
|
||||||
|
self._limit = 20
|
||||||
|
self._has_more = True
|
||||||
|
self._loading = False
|
||||||
|
self._category: str | None = None
|
||||||
|
self._categories: list = []
|
||||||
|
|
||||||
|
self._list_col = ft.Column(spacing=16)
|
||||||
|
self._loader = ft.Container(
|
||||||
|
content=d.loader(36),
|
||||||
|
alignment=ft.alignment.center,
|
||||||
|
padding=20,
|
||||||
|
visible=True,
|
||||||
|
)
|
||||||
|
self._load_more_btn = ft.ElevatedButton(
|
||||||
|
"Загрузить ещё",
|
||||||
|
icon=ft.icons.EXPAND_MORE,
|
||||||
|
on_click=lambda e: self.page.run_task(self._load_more),
|
||||||
|
visible=False,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
color=d.PRIMARY,
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=8),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8)
|
||||||
|
|
||||||
|
def build_page_view(self) -> ft.View:
|
||||||
|
header = ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Row([
|
||||||
|
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=28),
|
||||||
|
ft.Text("Новости", size=20, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
|
||||||
|
], spacing=8),
|
||||||
|
ft.Row([
|
||||||
|
ft.TextButton(
|
||||||
|
"Войти",
|
||||||
|
icon=ft.icons.ADMIN_PANEL_SETTINGS_OUTLINED,
|
||||||
|
style=ft.ButtonStyle(color=ft.colors.WHITE70),
|
||||||
|
on_click=lambda e: self.page.go("/admin/login"),
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
bgcolor=d.ADMIN_SIDEBAR,
|
||||||
|
padding=ft.padding.symmetric(horizontal=24, vertical=14),
|
||||||
|
)
|
||||||
|
|
||||||
|
cat_section = ft.Container(
|
||||||
|
content=ft.Column([
|
||||||
|
ft.Container(content=self._cat_row, padding=ft.padding.symmetric(horizontal=24, vertical=8)),
|
||||||
|
]),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||||
|
)
|
||||||
|
|
||||||
|
main_content = ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
self._list_col,
|
||||||
|
self._loader,
|
||||||
|
ft.Container(
|
||||||
|
content=self._load_more_btn,
|
||||||
|
alignment=ft.alignment.center,
|
||||||
|
padding=16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
),
|
||||||
|
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||||
|
alignment=ft.alignment.top_center,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/",
|
||||||
|
bgcolor=d.BACKGROUND,
|
||||||
|
controls=[
|
||||||
|
ft.Column(
|
||||||
|
[
|
||||||
|
header,
|
||||||
|
cat_section,
|
||||||
|
ft.Container(
|
||||||
|
content=main_content,
|
||||||
|
alignment=ft.alignment.top_center,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
expand=True,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load(self):
|
||||||
|
self._categories = await api.get_categories()
|
||||||
|
self._render_categories()
|
||||||
|
await self._load_articles()
|
||||||
|
|
||||||
|
async def _load_more(self):
|
||||||
|
if self._loading or not self._has_more:
|
||||||
|
return
|
||||||
|
self._loading = True
|
||||||
|
self._load_more_btn.disabled = True
|
||||||
|
self.page.update()
|
||||||
|
self._offset += self._limit
|
||||||
|
await self._load_articles()
|
||||||
|
self._loading = False
|
||||||
|
self._load_more_btn.disabled = False
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
async def _load_articles(self):
|
||||||
|
self._loader.visible = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
data = await api.get_news(offset=self._offset, limit=self._limit, category=self._category)
|
||||||
|
self._loader.visible = False
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
self.page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
items = data.get("items", [])
|
||||||
|
self._articles.extend(items)
|
||||||
|
self._has_more = data.get("has_more", False)
|
||||||
|
|
||||||
|
for a in items:
|
||||||
|
self._list_col.controls.append(self._build_card(a))
|
||||||
|
|
||||||
|
self._load_more_btn.visible = self._has_more
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _render_categories(self):
|
||||||
|
self._cat_row.controls = [
|
||||||
|
self._cat_chip(None, "Все")
|
||||||
|
] + [
|
||||||
|
self._cat_chip(c["slug"], c["name"]) for c in self._categories
|
||||||
|
]
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _cat_chip(self, slug: str | None, label: str) -> ft.Chip:
|
||||||
|
is_active = self._category == slug
|
||||||
|
return ft.Chip(
|
||||||
|
label=ft.Text(label, size=13),
|
||||||
|
bgcolor=d.PRIMARY if is_active else d.SURFACE,
|
||||||
|
label_style=ft.TextStyle(color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY),
|
||||||
|
on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s),
|
||||||
|
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||||
|
side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _filter_category(self, slug: str | None):
|
||||||
|
self._category = slug
|
||||||
|
self._articles = []
|
||||||
|
self._offset = 0
|
||||||
|
self._has_more = True
|
||||||
|
self._list_col.controls.clear()
|
||||||
|
self._render_categories()
|
||||||
|
await self._load_articles()
|
||||||
|
|
||||||
|
def _build_card(self, article: dict) -> ft.Container:
|
||||||
|
pub_date = ""
|
||||||
|
if article.get("published_at"):
|
||||||
|
pub_date = article["published_at"][:10]
|
||||||
|
|
||||||
|
cat = article.get("category")
|
||||||
|
cat_chip = ft.Container(
|
||||||
|
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE),
|
||||||
|
bgcolor=d.PRIMARY,
|
||||||
|
border_radius=4,
|
||||||
|
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||||
|
visible=bool(cat),
|
||||||
|
) if cat else ft.Container(visible=False)
|
||||||
|
|
||||||
|
cover = ft.Image(
|
||||||
|
src=article["cover_url"],
|
||||||
|
width=260,
|
||||||
|
height=160,
|
||||||
|
fit=ft.ImageFit.COVER,
|
||||||
|
border_radius=ft.BorderRadius(0, 8, 8, 0),
|
||||||
|
) if article.get("cover_url") else ft.Container(visible=False)
|
||||||
|
|
||||||
|
text_part = ft.Column(
|
||||||
|
[
|
||||||
|
ft.Row([cat_chip, ft.Text(pub_date, size=11, color=d.TEXT_SECONDARY)], spacing=8),
|
||||||
|
ft.Text(
|
||||||
|
article["title"],
|
||||||
|
size=18,
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
color=d.TEXT_PRIMARY,
|
||||||
|
max_lines=3,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
article.get("excerpt", ""),
|
||||||
|
size=13,
|
||||||
|
color=d.TEXT_SECONDARY,
|
||||||
|
max_lines=3,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS,
|
||||||
|
),
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
ft.Row([
|
||||||
|
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||||
|
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||||
|
], spacing=4),
|
||||||
|
ft.TextButton(
|
||||||
|
"Читать →",
|
||||||
|
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||||
|
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=8,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row([ft.Container(content=text_part, expand=True, padding=20), cover], spacing=0),
|
||||||
|
bgcolor=d.SURFACE,
|
||||||
|
border_radius=12,
|
||||||
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
|
shadow=ft.BoxShadow(blur_radius=6, color=ft.colors.with_opacity(0.05, ft.colors.BLACK)),
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
||||||
|
ink=True,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user