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='' + selected + ''; 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'' ) 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