test ract
This commit is contained in:
@@ -1,112 +1,11 @@
|
||||
import os
|
||||
import asyncio
|
||||
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;
|
||||
}})();
|
||||
"""
|
||||
_UPLOAD_DIR = os.getenv("FLET_UPLOAD_DIR", "/tmp/flet_uploads")
|
||||
|
||||
|
||||
class ArticleEditorView:
|
||||
@@ -140,16 +39,6 @@ class ArticleEditorView:
|
||||
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(
|
||||
@@ -181,10 +70,15 @@ class ArticleEditorView:
|
||||
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)
|
||||
self._vid_picker = ft.FilePicker(on_result=self._on_video_picked)
|
||||
self._cover_picker = ft.FilePicker(on_result=self._on_cover_picked)
|
||||
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)
|
||||
@@ -324,49 +218,50 @@ class ArticleEditorView:
|
||||
)
|
||||
|
||||
def _build_toolbar(self) -> ft.Container:
|
||||
def tb_btn(icon, tooltip: str, js_code: str) -> ft.IconButton:
|
||||
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=lambda e, js=js_code: self.page.run_task(self._exec_js, js),
|
||||
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, "Жирный (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("# ")),
|
||||
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.page.run_task(self._exec_js, _js_prepend_line("## ")),
|
||||
ink=True,
|
||||
border_radius=4,
|
||||
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.page.run_task(self._exec_js, _js_prepend_line("### ")),
|
||||
ink=True,
|
||||
border_radius=4,
|
||||
on_click=lambda e: self._fmt("### ", "", "Заголовок"),
|
||||
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://)", "текст ссылки")),
|
||||
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="Вставить картинку",
|
||||
@@ -387,10 +282,8 @@ class ArticleEditorView:
|
||||
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),
|
||||
sep(),
|
||||
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", lambda e: self._fmt("", "", "\n---\n")),
|
||||
]
|
||||
|
||||
return ft.Container(
|
||||
@@ -401,17 +294,16 @@ class ArticleEditorView:
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=4),
|
||||
)
|
||||
|
||||
# ─── Events ──────────────────────────────────────────────────────────────
|
||||
# ─── Formatting ──────────────────────────────────────────────────────────
|
||||
|
||||
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 _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"
|
||||
@@ -419,13 +311,6 @@ class ArticleEditorView:
|
||||
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)
|
||||
@@ -445,23 +330,84 @@ class ArticleEditorView:
|
||||
if not e.files:
|
||||
return
|
||||
f = e.files[0]
|
||||
await self._upload_and_insert(f, "image")
|
||||
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]
|
||||
await self._upload_and_insert(f, "video")
|
||||
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]
|
||||
token = self.page.session.get("token")
|
||||
file_bytes = await _read_file(f)
|
||||
if file_bytes is None:
|
||||
d.snack(self.page, "Не удалось прочитать файл", error=True)
|
||||
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)
|
||||
@@ -474,13 +420,8 @@ class ArticleEditorView:
|
||||
else:
|
||||
d.snack(self.page, "Ошибка загрузки обложки", error=True)
|
||||
|
||||
async def _upload_and_insert(self, f, media_kind: str):
|
||||
async def _upload_and_insert(self, f, media_kind: str, file_bytes: bytes):
|
||||
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)
|
||||
@@ -494,8 +435,9 @@ class ArticleEditorView:
|
||||
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
|
||||
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}")
|
||||
@@ -516,7 +458,6 @@ class ArticleEditorView:
|
||||
if self._article:
|
||||
self._fill_form(self._article)
|
||||
|
||||
self.page.run_javascript(_TRACK_JS)
|
||||
self.page.update()
|
||||
|
||||
def _fill_form(self, a: dict):
|
||||
@@ -608,8 +549,16 @@ def _content_type(ext: str, kind: str) -> str:
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -37,12 +37,15 @@ class ArticlesListView:
|
||||
d.headline("Статьи", size=22),
|
||||
ft.Row(
|
||||
[
|
||||
d.btn_icon(ft.icons.REFRESH, "Обновить список",
|
||||
on_click=lambda e: self.page.run_task(self._load_articles)),
|
||||
d.btn_primary(
|
||||
"Новая статья",
|
||||
icon=ft.icons.ADD,
|
||||
on_click=lambda e: self.page.go("/admin/articles/new"),
|
||||
)
|
||||
]
|
||||
on_click=lambda e: self._open_editor("new"),
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
@@ -64,7 +67,13 @@ class ArticlesListView:
|
||||
],
|
||||
)
|
||||
|
||||
def _open_editor(self, article_id: str):
|
||||
token = self.page.session.get("token") or ""
|
||||
url = f"/editor/{article_id}?t={token}"
|
||||
self.page.launch_url(url, web_window_name="_blank")
|
||||
|
||||
async def load(self):
|
||||
self.page.window.on_focus = lambda e: self.page.run_task(self._load_articles)
|
||||
await self._load_articles()
|
||||
|
||||
async def _on_filter(self, e):
|
||||
@@ -147,7 +156,7 @@ class ArticlesListView:
|
||||
d.btn_icon(
|
||||
ft.icons.EDIT_OUTLINED,
|
||||
"Редактировать",
|
||||
on_click=lambda e, aid=article["id"]: self.page.go(f"/admin/articles/{aid}"),
|
||||
on_click=lambda e, aid=article["id"]: self._open_editor(aid),
|
||||
color=d.PRIMARY,
|
||||
),
|
||||
*([
|
||||
|
||||
@@ -68,18 +68,27 @@ class NewsFeedView:
|
||||
main_content = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
self._list_col,
|
||||
self._loader,
|
||||
ft.Container(
|
||||
content=self._load_more_btn,
|
||||
alignment=ft.alignment.center,
|
||||
padding=16,
|
||||
content=ft.Column(
|
||||
[
|
||||
self._list_col,
|
||||
self._loader,
|
||||
ft.Container(
|
||||
content=self._load_more_btn,
|
||||
alignment=ft.alignment.center,
|
||||
padding=16,
|
||||
),
|
||||
],
|
||||
spacing=16,
|
||||
),
|
||||
width=680,
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=24),
|
||||
alignment=ft.alignment.top_center,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
return ft.View(
|
||||
@@ -157,7 +166,7 @@ class NewsFeedView:
|
||||
label_style=ft.TextStyle(color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY),
|
||||
on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s),
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||
side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||
border_side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||
)
|
||||
|
||||
async def _filter_category(self, slug: str | None):
|
||||
@@ -174,67 +183,82 @@ class NewsFeedView:
|
||||
if article.get("published_at"):
|
||||
pub_date = article["published_at"][:10]
|
||||
|
||||
cat = article.get("category")
|
||||
cat_chip = ft.Container(
|
||||
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE),
|
||||
bgcolor=d.PRIMARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
visible=bool(cat),
|
||||
) if cat else ft.Container(visible=False)
|
||||
slug = article["slug"]
|
||||
|
||||
cover = ft.Image(
|
||||
src=article["cover_url"],
|
||||
width=260,
|
||||
height=160,
|
||||
fit=ft.ImageFit.COVER,
|
||||
border_radius=ft.BorderRadius(0, 8, 8, 0),
|
||||
cat = article.get("category")
|
||||
meta_row_controls = []
|
||||
if cat:
|
||||
meta_row_controls.append(ft.Container(
|
||||
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_600),
|
||||
bgcolor=d.PRIMARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
))
|
||||
if pub_date:
|
||||
meta_row_controls.append(ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY))
|
||||
|
||||
cover_section = ft.Container(
|
||||
content=ft.Image(
|
||||
src=article["cover_url"],
|
||||
width=float("inf"),
|
||||
height=200,
|
||||
fit=ft.ImageFit.COVER,
|
||||
),
|
||||
height=200,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
border_radius=ft.BorderRadius(12, 12, 0, 0),
|
||||
) if article.get("cover_url") else ft.Container(visible=False)
|
||||
|
||||
text_part = ft.Column(
|
||||
[
|
||||
ft.Row([cat_chip, ft.Text(pub_date, size=11, color=d.TEXT_SECONDARY)], spacing=8),
|
||||
ft.Text(
|
||||
article["title"],
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Text(
|
||||
article.get("excerpt", ""),
|
||||
size=13,
|
||||
color=d.TEXT_SECONDARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||
], spacing=4),
|
||||
ft.TextButton(
|
||||
"Читать →",
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
expand=True,
|
||||
body = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(meta_row_controls, spacing=8) if meta_row_controls else ft.Container(visible=False),
|
||||
ft.Text(
|
||||
article["title"],
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Text(
|
||||
article.get("excerpt", ""),
|
||||
size=13,
|
||||
color=d.TEXT_SECONDARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
) if article.get("excerpt") else ft.Container(visible=False),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||
], spacing=4),
|
||||
ft.TextButton(
|
||||
"Читать →",
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, s=slug: self.page.launch_url(
|
||||
f"/article/{s}", web_window_name="_self"
|
||||
),
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=20, vertical=16),
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Row([ft.Container(content=text_part, expand=True, padding=20), cover], spacing=0),
|
||||
content=ft.Column([cover_section, body], spacing=0),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=12,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
shadow=ft.BoxShadow(blur_radius=6, color=ft.colors.with_opacity(0.05, ft.colors.BLACK)),
|
||||
shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.with_opacity(0.07, ft.colors.BLACK)),
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
||||
on_click=lambda e, s=slug: self.page.launch_url(
|
||||
f"/article/{s}", web_window_name="_self"
|
||||
),
|
||||
ink=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user