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

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