Files
news_all_spo/web/views/article_editor.py
2026-05-13 19:05:07 +05:00

616 lines
25 KiB
Python
Raw 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 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