565 lines
23 KiB
Python
565 lines
23 KiB
Python
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""
|
||
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
|