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