test ferst

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

6
web/Dockerfile Normal file
View File

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

232
web/api_client.py Normal file
View File

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

252
web/designer.py Normal file
View File

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

41
web/main.py Normal file
View File

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

3
web/requirements.txt Normal file
View File

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

70
web/router.py Normal file
View File

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

69
web/theme_manager.py Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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