Files
news_all_spo/web/views/article_editor.py
2026-05-14 16:47:58 +05:00

565 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import asyncio
import flet as ft
import designer as d
import api_client as api
from views.dashboard import _admin_appbar, _sidebar
_UPLOAD_DIR = os.getenv("FLET_UPLOAD_DIR", "/tmp/flet_uploads")
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,
)
# 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,
)
# Pending upload state for web mode (f.path is None)
self._pending_img: tuple | None = None
self._pending_vid: tuple | None = None
self._pending_cover = None
# File pickers
self._img_picker = ft.FilePicker(on_result=self._on_image_picked, on_upload=self._on_img_upload)
self._vid_picker = ft.FilePicker(on_result=self._on_video_picked, on_upload=self._on_vid_upload)
self._cover_picker = ft.FilePicker(on_result=self._on_cover_picked, on_upload=self._on_cover_upload)
# 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, on_click) -> ft.IconButton:
return ft.IconButton(
icon=icon,
tooltip=tooltip,
icon_size=18,
icon_color=d.TEXT_PRIMARY,
on_click=on_click,
style=ft.ButtonStyle(
shape=ft.RoundedRectangleBorder(radius=4),
padding=ft.padding.all(6),
),
)
def sep():
return ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4))
buttons = [
tb_btn(ft.icons.FORMAT_BOLD, "Жирный", lambda e: self._fmt("**", "**", "жирный текст")),
tb_btn(ft.icons.FORMAT_ITALIC, "Курсив", lambda e: self._fmt("*", "*", "курсив")),
tb_btn(ft.icons.FORMAT_STRIKETHROUGH, "Зачёркнутый", lambda e: self._fmt("~~", "~~", "текст")),
sep(),
tb_btn(ft.icons.TITLE, "Заголовок H1", lambda e: self._fmt("# ", "", "Заголовок")),
ft.Container(
content=ft.Text("H2", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
on_click=lambda e: self._fmt("## ", "", "Заголовок"),
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._fmt("### ", "", "Заголовок"),
ink=True, border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=6),
tooltip="Заголовок H3",
),
sep(),
tb_btn(ft.icons.FORMAT_LIST_BULLETED, "Список", lambda e: self._fmt("- ", "", "пункт")),
tb_btn(ft.icons.FORMAT_LIST_NUMBERED, "Нумерованный список", lambda e: self._fmt("1. ", "", "пункт")),
tb_btn(ft.icons.FORMAT_QUOTE, "Цитата", lambda e: self._fmt("> ", "", "цитата")),
tb_btn(ft.icons.CODE, "Инлайн-код", lambda e: self._fmt("`", "`", "код")),
tb_btn(ft.icons.DATA_OBJECT, "Блок кода", lambda e: self._fmt("```\n", "\n```", "код")),
sep(),
tb_btn(ft.icons.LINK, "Ссылка", lambda e: self._fmt("[", "](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="Выберите видео",
),
),
sep(),
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", lambda e: self._fmt("", "", "\n---\n")),
]
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),
)
# ─── Formatting ──────────────────────────────────────────────────────────
def _fmt(self, prefix: str, suffix: str = "", placeholder: str = "текст"):
cur = self._content_field.value or ""
sep = "\n" if cur and not cur.endswith("\n") else ""
self._content_field.value = cur + sep + prefix + placeholder + suffix
self._preview.value = self._content_field.value
self.page.update()
# ─── Events ──────────────────────────────────────────────────────────────
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_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]
file_bytes = await _read_file(f)
if file_bytes is not None:
await self._upload_and_insert(f, "image", file_bytes)
else:
self._pending_img = (f, "image")
d.snack(self.page, f"Загрузка {f.name}...")
upload_url = self.page.get_upload_url(f.name, 600)
self._img_picker.upload([ft.FilePickerUploadFile(name=f.name, upload_url=upload_url)])
async def _on_video_picked(self, e: ft.FilePickerResultEvent):
if not e.files:
return
f = e.files[0]
file_bytes = await _read_file(f)
if file_bytes is not None:
await self._upload_and_insert(f, "video", file_bytes)
else:
self._pending_vid = (f, "video")
d.snack(self.page, f"Загрузка {f.name}...")
upload_url = self.page.get_upload_url(f.name, 600)
self._vid_picker.upload([ft.FilePickerUploadFile(name=f.name, upload_url=upload_url)])
async def _on_img_upload(self, e: ft.FilePickerUploadEvent):
if e.error:
d.snack(self.page, "Ошибка загрузки файла", error=True)
self._pending_img = None
return
if e.progress == 1.0 and self._pending_img:
f, kind = self._pending_img
self._pending_img = None
file_bytes = _read_upload(e.file_name)
if file_bytes:
await self._upload_and_insert(f, kind, file_bytes)
else:
d.snack(self.page, "Не удалось прочитать загруженный файл", error=True)
async def _on_vid_upload(self, e: ft.FilePickerUploadEvent):
if e.error:
d.snack(self.page, "Ошибка загрузки видео", error=True)
self._pending_vid = None
return
if e.progress == 1.0 and self._pending_vid:
f, kind = self._pending_vid
self._pending_vid = None
file_bytes = _read_upload(e.file_name)
if file_bytes:
await self._upload_and_insert(f, kind, file_bytes)
else:
d.snack(self.page, "Не удалось прочитать загруженный файл", error=True)
async def _on_cover_picked(self, e: ft.FilePickerResultEvent):
if not e.files:
return
f = e.files[0]
file_bytes = await _read_file(f)
if file_bytes is None:
self._pending_cover = f
upload_url = self.page.get_upload_url(f.name, 600)
self._cover_picker.upload([ft.FilePickerUploadFile(name=f.name, upload_url=upload_url)])
return
await self._save_cover(f, file_bytes)
async def _on_cover_upload(self, e: ft.FilePickerUploadEvent):
if e.error:
d.snack(self.page, "Ошибка загрузки обложки", error=True)
self._pending_cover = None
return
if e.progress == 1.0 and self._pending_cover:
f = self._pending_cover
self._pending_cover = None
file_bytes = _read_upload(e.file_name)
if file_bytes:
await self._save_cover(f, file_bytes)
else:
d.snack(self.page, "Не удалось прочитать загруженный файл", error=True)
async def _save_cover(self, f, file_bytes: bytes):
token = self.page.session.get("token")
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, file_bytes: bytes):
token = self.page.session.get("token")
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>'
)
cur = self._content_field.value or ""
sep = "\n" if cur and not cur.endswith("\n") else ""
self._content_field.value = cur + sep + 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.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:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: open(f.path, "rb").read())
except Exception:
pass
return None
def _read_upload(file_name: str) -> bytes | None:
try:
with open(os.path.join(_UPLOAD_DIR, file_name), "rb") as fh:
return fh.read()
except Exception:
return None