test ferst
This commit is contained in:
0
web/views/__init__.py
Normal file
0
web/views/__init__.py
Normal file
615
web/views/article_editor.py
Normal file
615
web/views/article_editor.py
Normal 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""
|
||||
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
|
||||
207
web/views/articles_list.py
Normal file
207
web/views/articles_list.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
from views.dashboard import _admin_appbar, _sidebar
|
||||
|
||||
|
||||
class ArticlesListView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._table_col = ft.Column(spacing=0, expand=True)
|
||||
self._filter = "all"
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
filter_tabs = ft.Tabs(
|
||||
selected_index=0,
|
||||
on_change=self._on_filter,
|
||||
tabs=[
|
||||
ft.Tab(text="Все"),
|
||||
ft.Tab(text="Опубликовано"),
|
||||
ft.Tab(text="Черновики"),
|
||||
],
|
||||
)
|
||||
|
||||
return ft.View(
|
||||
route="/admin/articles",
|
||||
bgcolor=d.BACKGROUND,
|
||||
appbar=_admin_appbar(self.page, "Статьи"),
|
||||
controls=[
|
||||
ft.Row(
|
||||
[
|
||||
_sidebar(self.page, active="/admin/articles"),
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(
|
||||
[
|
||||
d.headline("Статьи", size=22),
|
||||
ft.Row(
|
||||
[
|
||||
d.btn_primary(
|
||||
"Новая статья",
|
||||
icon=ft.icons.ADD,
|
||||
on_click=lambda e: self.page.go("/admin/articles/new"),
|
||||
)
|
||||
]
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
filter_tabs,
|
||||
self._table_col,
|
||||
],
|
||||
expand=True,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
spacing=16,
|
||||
),
|
||||
expand=True,
|
||||
padding=24,
|
||||
),
|
||||
],
|
||||
expand=True,
|
||||
spacing=0,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
await self._load_articles()
|
||||
|
||||
async def _on_filter(self, e):
|
||||
idx = e.control.selected_index
|
||||
self._filter = ["all", "published", "draft"][idx]
|
||||
await self._load_articles()
|
||||
|
||||
async def _load_articles(self):
|
||||
token = self.page.session.get("token")
|
||||
status = None if self._filter == "all" else self._filter
|
||||
data = await api.admin_list_articles(token, limit=200, status=status)
|
||||
items = data.get("items", []) if data else []
|
||||
self._render(items)
|
||||
|
||||
def _render(self, items: list):
|
||||
if not items:
|
||||
self._table_col.controls = [
|
||||
ft.Container(
|
||||
content=d.empty_state(ft.icons.ARTICLE_OUTLINED, "Статей пока нет"),
|
||||
alignment=ft.alignment.center,
|
||||
padding=40,
|
||||
)
|
||||
]
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
header = ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Text("Заголовок", size=12, color=d.TEXT_SECONDARY, expand=3),
|
||||
ft.Text("Категория", size=12, color=d.TEXT_SECONDARY, expand=1),
|
||||
ft.Text("Статус", size=12, color=d.TEXT_SECONDARY, width=110),
|
||||
ft.Text("Дата", size=12, color=d.TEXT_SECONDARY, width=110),
|
||||
ft.Text("Действия", size=12, color=d.TEXT_SECONDARY, width=130),
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
bgcolor=d.BACKGROUND,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=10),
|
||||
border_radius=ft.BorderRadius(8, 8, 0, 0),
|
||||
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||
)
|
||||
|
||||
rows = [header] + [self._build_row(a) for a in items]
|
||||
self._table_col.controls = [
|
||||
ft.Container(
|
||||
content=ft.Column(rows, spacing=0),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=8,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
)
|
||||
]
|
||||
self.page.update()
|
||||
|
||||
def _build_row(self, article: dict) -> ft.Container:
|
||||
pub_date = ""
|
||||
if article.get("published_at"):
|
||||
pub_date = article["published_at"][:10]
|
||||
elif article.get("created_at"):
|
||||
pub_date = article["created_at"][:10]
|
||||
|
||||
cat_name = article.get("category", {})
|
||||
cat_name = (cat_name or {}).get("name", "—")
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Text(
|
||||
article["title"],
|
||||
size=14,
|
||||
expand=3,
|
||||
max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Text(cat_name, size=13, color=d.TEXT_SECONDARY, expand=1),
|
||||
ft.Container(content=d.status_badge(article.get("status", "draft")), width=110),
|
||||
ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY, width=110),
|
||||
ft.Row(
|
||||
[
|
||||
d.btn_icon(
|
||||
ft.icons.EDIT_OUTLINED,
|
||||
"Редактировать",
|
||||
on_click=lambda e, aid=article["id"]: self.page.go(f"/admin/articles/{aid}"),
|
||||
color=d.PRIMARY,
|
||||
),
|
||||
*([
|
||||
d.btn_icon(
|
||||
ft.icons.PUBLISH,
|
||||
"Опубликовать",
|
||||
on_click=lambda e, aid=article["id"]: self.page.run_task(self._publish, aid),
|
||||
color=d.SUCCESS,
|
||||
)
|
||||
] if article.get("status") == "draft" else []),
|
||||
d.btn_icon(
|
||||
ft.icons.DELETE_OUTLINE,
|
||||
"Удалить",
|
||||
on_click=lambda e, a=article: self._confirm_delete(a),
|
||||
color=d.ERROR,
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
width=130,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||
ink=True,
|
||||
)
|
||||
|
||||
def _confirm_delete(self, article: dict):
|
||||
dlg = d.confirm_dialog(
|
||||
self.page,
|
||||
"Удалить статью?",
|
||||
f'"{article["title"]}" будет удалена навсегда.',
|
||||
lambda: self.page.run_task(self._delete, article["id"]),
|
||||
)
|
||||
self.page.dialog = dlg
|
||||
dlg.open = True
|
||||
self.page.update()
|
||||
|
||||
async def _delete(self, article_id: str):
|
||||
token = self.page.session.get("token")
|
||||
ok = await api.admin_delete_article(token, article_id)
|
||||
if ok:
|
||||
d.snack(self.page, "Статья удалена")
|
||||
await self._load_articles()
|
||||
else:
|
||||
d.snack(self.page, "Ошибка удаления", error=True)
|
||||
|
||||
async def _publish(self, article_id: str):
|
||||
token = self.page.session.get("token")
|
||||
ok = await api.admin_publish_article(token, article_id)
|
||||
if ok:
|
||||
d.snack(self.page, "Статья опубликована")
|
||||
await self._load_articles()
|
||||
else:
|
||||
d.snack(self.page, "Ошибка публикации", error=True)
|
||||
165
web/views/categories.py
Normal file
165
web/views/categories.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
from views.dashboard import _admin_appbar, _sidebar
|
||||
|
||||
|
||||
class CategoriesView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._list_col = ft.Column(spacing=0)
|
||||
self._name_field = d.text_field("Название категории")
|
||||
self._slug_field = d.text_field("Slug (необязательно)", hint="автогенерация из названия")
|
||||
self._edit_id: str | None = None
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
form = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
d.headline("Категории", size=22),
|
||||
ft.Divider(height=8, color="transparent"),
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
d.headline("Добавить категорию", size=16),
|
||||
ft.Divider(height=4, color="transparent"),
|
||||
self._name_field,
|
||||
self._slug_field,
|
||||
ft.Row(
|
||||
[
|
||||
d.btn_primary("Сохранить", on_click=lambda e: self.page.run_task(self._save)),
|
||||
d.btn_outlined("Сбросить", on_click=self._reset, color=d.TEXT_SECONDARY),
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=12,
|
||||
padding=20,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
),
|
||||
ft.Divider(height=16, color="transparent"),
|
||||
d.headline("Список категорий", size=16),
|
||||
self._list_col,
|
||||
],
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
spacing=8,
|
||||
),
|
||||
expand=True,
|
||||
padding=24,
|
||||
)
|
||||
|
||||
return ft.View(
|
||||
route="/admin/categories",
|
||||
bgcolor=d.BACKGROUND,
|
||||
appbar=_admin_appbar(self.page, "Категории"),
|
||||
controls=[
|
||||
ft.Row(
|
||||
[_sidebar(self.page, active="/admin/categories"), form],
|
||||
expand=True,
|
||||
spacing=0,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
await self._reload()
|
||||
|
||||
async def _reload(self):
|
||||
token = self.page.session.get("token")
|
||||
cats = await api.admin_list_categories(token)
|
||||
self._list_col.controls = [self._build_row(c) for c in cats] if cats else [
|
||||
ft.Container(
|
||||
content=d.empty_state(ft.icons.CATEGORY_OUTLINED, "Категорий пока нет"),
|
||||
padding=30,
|
||||
alignment=ft.alignment.center,
|
||||
)
|
||||
]
|
||||
self.page.update()
|
||||
|
||||
def _build_row(self, cat: dict) -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text(cat["name"], size=14, weight=ft.FontWeight.W_500),
|
||||
ft.Text(cat["slug"], size=12, color=d.TEXT_SECONDARY),
|
||||
],
|
||||
spacing=2,
|
||||
expand=True,
|
||||
),
|
||||
d.btn_icon(
|
||||
ft.icons.EDIT_OUTLINED, "Редактировать",
|
||||
on_click=lambda e, c=cat: self._start_edit(c),
|
||||
color=d.PRIMARY,
|
||||
),
|
||||
d.btn_icon(
|
||||
ft.icons.DELETE_OUTLINE, "Удалить",
|
||||
on_click=lambda e, c=cat: self._confirm_delete(c),
|
||||
color=d.ERROR,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=8,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
margin=ft.margin.only(bottom=8),
|
||||
)
|
||||
|
||||
def _start_edit(self, cat: dict):
|
||||
self._edit_id = cat["id"]
|
||||
self._name_field.value = cat["name"]
|
||||
self._slug_field.value = cat["slug"]
|
||||
self.page.update()
|
||||
|
||||
def _reset(self, e=None):
|
||||
self._edit_id = None
|
||||
self._name_field.value = ""
|
||||
self._slug_field.value = ""
|
||||
self.page.update()
|
||||
|
||||
async def _save(self):
|
||||
token = self.page.session.get("token")
|
||||
name = (self._name_field.value or "").strip()
|
||||
slug = (self._slug_field.value or "").strip()
|
||||
if not name:
|
||||
d.snack(self.page, "Введите название", error=True)
|
||||
return
|
||||
|
||||
if self._edit_id:
|
||||
result = await api.admin_update_category(token, self._edit_id, name, slug)
|
||||
else:
|
||||
result = await api.admin_create_category(token, name, slug)
|
||||
|
||||
if result:
|
||||
d.snack(self.page, "Сохранено")
|
||||
self._reset()
|
||||
await self._reload()
|
||||
else:
|
||||
d.snack(self.page, "Ошибка сохранения", error=True)
|
||||
|
||||
def _confirm_delete(self, cat: dict):
|
||||
dlg = d.confirm_dialog(
|
||||
self.page,
|
||||
"Удалить категорию?",
|
||||
f'"{cat["name"]}" будет удалена. Статьи останутся без категории.',
|
||||
lambda: self.page.run_task(self._delete, cat["id"]),
|
||||
)
|
||||
self.page.dialog = dlg
|
||||
dlg.open = True
|
||||
self.page.update()
|
||||
|
||||
async def _delete(self, cat_id: str):
|
||||
token = self.page.session.get("token")
|
||||
ok = await api.admin_delete_category(token, cat_id)
|
||||
if ok:
|
||||
d.snack(self.page, "Категория удалена")
|
||||
await self._reload()
|
||||
else:
|
||||
d.snack(self.page, "Ошибка удаления", error=True)
|
||||
148
web/views/dashboard.py
Normal file
148
web/views/dashboard.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
|
||||
|
||||
class DashboardView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._stats_row = ft.Row(wrap=True, spacing=16, run_spacing=16)
|
||||
self._recent = ft.Column(spacing=0)
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
return ft.View(
|
||||
route="/admin",
|
||||
bgcolor=d.BACKGROUND,
|
||||
appbar=_admin_appbar(self.page, "Главная"),
|
||||
controls=[
|
||||
ft.Row(
|
||||
[
|
||||
_sidebar(self.page, active="/admin"),
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
d.headline("Обзор", size=22),
|
||||
ft.Divider(height=8, color="transparent"),
|
||||
self._stats_row,
|
||||
ft.Divider(height=16, color="transparent"),
|
||||
d.headline("Последние статьи", size=18),
|
||||
ft.Divider(height=8, color="transparent"),
|
||||
self._recent,
|
||||
],
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
expand=True,
|
||||
padding=24,
|
||||
),
|
||||
],
|
||||
expand=True,
|
||||
spacing=0,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
token = self.page.session.get("token")
|
||||
data = await api.admin_list_articles(token, limit=100)
|
||||
if not data:
|
||||
return
|
||||
|
||||
items = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
published = sum(1 for a in items if a.get("status") == "published")
|
||||
drafts = total - published
|
||||
|
||||
self._stats_row.controls = [
|
||||
d.stat_card("Всего статей", str(total), ft.icons.ARTICLE, d.PRIMARY),
|
||||
d.stat_card("Опубликовано", str(published), ft.icons.CHECK_CIRCLE, d.SUCCESS),
|
||||
d.stat_card("Черновики", str(drafts), ft.icons.EDIT_NOTE, d.WARNING),
|
||||
]
|
||||
|
||||
self._recent.controls = [_recent_item(a, self.page) for a in items[:10]]
|
||||
self.page.update()
|
||||
|
||||
|
||||
def _recent_item(article: dict, page: ft.Page) -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
d.status_badge(article.get("status", "draft")),
|
||||
ft.Text(article["title"], size=14, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
ft.TextButton(
|
||||
"Редактировать",
|
||||
on_click=lambda e, aid=article["id"]: page.go(f"/admin/articles/{aid}"),
|
||||
),
|
||||
],
|
||||
spacing=12,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
padding=ft.padding.symmetric(vertical=10, horizontal=4),
|
||||
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||
)
|
||||
|
||||
|
||||
# ─── Общие admin-компоненты ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _admin_appbar(page: ft.Page, title: str) -> ft.AppBar:
|
||||
return ft.AppBar(
|
||||
title=ft.Text(title, size=18, weight=ft.FontWeight.W_600, color=ft.colors.WHITE),
|
||||
bgcolor=d.ADMIN_SIDEBAR,
|
||||
color=ft.colors.WHITE,
|
||||
actions=[
|
||||
ft.TextButton(
|
||||
"На сайт",
|
||||
icon=ft.icons.OPEN_IN_NEW,
|
||||
style=ft.ButtonStyle(color=ft.colors.WHITE70),
|
||||
on_click=lambda e: page.launch_url("/"),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.LOGOUT,
|
||||
tooltip="Выйти",
|
||||
icon_color=ft.colors.WHITE70,
|
||||
on_click=lambda e: _logout(page),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _logout(page: ft.Page):
|
||||
page.session.remove("token")
|
||||
page.session.remove("username")
|
||||
page.go("/admin/login")
|
||||
|
||||
|
||||
def _sidebar(page: ft.Page, active: str = "") -> ft.Container:
|
||||
items = [
|
||||
(ft.icons.DASHBOARD, "Главная", "/admin"),
|
||||
(ft.icons.ARTICLE, "Статьи", "/admin/articles"),
|
||||
(ft.icons.CATEGORY, "Категории", "/admin/categories"),
|
||||
(ft.icons.PERM_MEDIA, "Медиа", "/admin/media"),
|
||||
]
|
||||
|
||||
def nav_item(icon, label, route):
|
||||
is_active = active == route
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Icon(icon, color=d.ADMIN_SIDEBAR_TEXT if not is_active else d.ACCENT, size=20),
|
||||
ft.Text(label, color=d.ADMIN_SIDEBAR_TEXT, size=14, weight=ft.FontWeight.W_500 if is_active else ft.FontWeight.NORMAL),
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
bgcolor=d.ADMIN_SIDEBAR_ACTIVE if is_active else None,
|
||||
border_radius=8,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||
on_click=lambda e, r=route: page.go(r),
|
||||
ink=True,
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
[ft.Container(height=16)] + [nav_item(i, l, r) for i, l, r in items],
|
||||
spacing=2,
|
||||
),
|
||||
bgcolor=d.ADMIN_SIDEBAR,
|
||||
width=220,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=8),
|
||||
)
|
||||
76
web/views/login.py
Normal file
76
web/views/login.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
|
||||
|
||||
class LoginView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._username = d.text_field("Логин", prefix_icon=ft.icons.PERSON_OUTLINE)
|
||||
self._password = d.text_field("Пароль", password=True, prefix_icon=ft.icons.LOCK_OUTLINE)
|
||||
self._error = ft.Text("", color=d.ERROR, size=13)
|
||||
self._btn = d.btn_primary("Войти", on_click=self._on_login, width=280)
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
self._username.on_submit = self._on_login
|
||||
self._password.on_submit = self._on_login
|
||||
|
||||
form = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Icon(ft.icons.NEWSPAPER, size=56, color=d.PRIMARY),
|
||||
d.headline("Новостная CMS", size=24),
|
||||
d.caption("Войдите в панель управления", color=d.TEXT_SECONDARY),
|
||||
ft.Divider(height=20, color="transparent"),
|
||||
self._username,
|
||||
self._password,
|
||||
self._error,
|
||||
ft.Divider(height=8, color="transparent"),
|
||||
self._btn,
|
||||
],
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=14,
|
||||
width=320,
|
||||
),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=16,
|
||||
padding=40,
|
||||
shadow=ft.BoxShadow(blur_radius=24, color=ft.colors.with_opacity(0.10, ft.colors.BLACK)),
|
||||
)
|
||||
|
||||
return ft.View(
|
||||
route="/admin/login",
|
||||
bgcolor=d.BACKGROUND,
|
||||
controls=[
|
||||
ft.Container(
|
||||
content=form,
|
||||
alignment=ft.alignment.center,
|
||||
expand=True,
|
||||
)
|
||||
],
|
||||
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
)
|
||||
|
||||
async def _on_login(self, e=None):
|
||||
self._error.value = ""
|
||||
username = self._username.value.strip()
|
||||
password = self._password.value.strip()
|
||||
if not username or not password:
|
||||
self._error.value = "Заполните все поля"
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
self._btn.disabled = True
|
||||
self.page.update()
|
||||
|
||||
token = await api.admin_login(username, password)
|
||||
self._btn.disabled = False
|
||||
|
||||
if token:
|
||||
self.page.session.set("token", token)
|
||||
self.page.session.set("username", username)
|
||||
self.page.go("/admin")
|
||||
else:
|
||||
self._error.value = "Неверный логин или пароль"
|
||||
self.page.update()
|
||||
217
web/views/media_library.py
Normal file
217
web/views/media_library.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
from views.dashboard import _admin_appbar, _sidebar
|
||||
|
||||
|
||||
class MediaLibraryView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._grid = ft.GridView(
|
||||
expand=True,
|
||||
runs_count=4,
|
||||
max_extent=200,
|
||||
child_aspect_ratio=1.0,
|
||||
spacing=12,
|
||||
run_spacing=12,
|
||||
)
|
||||
self._filter = None
|
||||
self._picker = ft.FilePicker(on_result=self._on_files_picked)
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
filter_tabs = ft.Tabs(
|
||||
selected_index=0,
|
||||
on_change=self._on_filter,
|
||||
tabs=[ft.Tab(text="Все"), ft.Tab(text="Изображения"), ft.Tab(text="Видео")],
|
||||
)
|
||||
|
||||
return ft.View(
|
||||
route="/admin/media",
|
||||
bgcolor=d.BACKGROUND,
|
||||
appbar=_admin_appbar(self.page, "Медиа"),
|
||||
controls=[
|
||||
ft.Row(
|
||||
[
|
||||
_sidebar(self.page, active="/admin/media"),
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(
|
||||
[
|
||||
d.headline("Медиатека", size=22),
|
||||
d.btn_primary(
|
||||
"Загрузить файлы",
|
||||
icon=ft.icons.UPLOAD,
|
||||
on_click=lambda e: self._picker.pick_files(
|
||||
allow_multiple=True,
|
||||
dialog_title="Выберите изображения или видео",
|
||||
),
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
filter_tabs,
|
||||
self._grid,
|
||||
],
|
||||
expand=True,
|
||||
spacing=16,
|
||||
),
|
||||
expand=True,
|
||||
padding=24,
|
||||
),
|
||||
],
|
||||
expand=True,
|
||||
spacing=0,
|
||||
),
|
||||
self._picker,
|
||||
],
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
await self._reload()
|
||||
|
||||
async def _on_filter(self, e):
|
||||
self._filter = [None, "image", "video"][e.control.selected_index]
|
||||
await self._reload()
|
||||
|
||||
async def _reload(self):
|
||||
token = self.page.session.get("token")
|
||||
items = await api.admin_list_media(token, limit=200, media_type=self._filter)
|
||||
self._render(items)
|
||||
|
||||
def _render(self, items: list):
|
||||
if not items:
|
||||
self._grid.controls = [
|
||||
d.empty_state(ft.icons.PERM_MEDIA_OUTLINED, "Медиафайлов пока нет")
|
||||
]
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
self._grid.controls = [self._build_card(m) for m in items]
|
||||
self.page.update()
|
||||
|
||||
def _build_card(self, m: dict) -> ft.Container:
|
||||
is_image = m.get("media_type") == "image"
|
||||
content = (
|
||||
ft.Image(src=m["url"], fit=ft.ImageFit.COVER, expand=True, error_content=ft.Icon(ft.icons.BROKEN_IMAGE))
|
||||
if is_image
|
||||
else ft.Column(
|
||||
[ft.Icon(ft.icons.PLAY_CIRCLE_OUTLINED, size=40, color=d.TEXT_SECONDARY),
|
||||
ft.Text("Видео", size=11, color=d.TEXT_SECONDARY)],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
expand=True,
|
||||
)
|
||||
)
|
||||
|
||||
size_kb = round(m.get("size_bytes", 0) / 1024, 1)
|
||||
name = m.get("filename", "")[:20]
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Stack(
|
||||
[
|
||||
ft.Container(content=content, expand=True, bgcolor="#F0F0F0", border_radius=8),
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text(name, size=10, color=ft.colors.WHITE, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS),
|
||||
ft.Text(f"{size_kb} КБ", size=9, color=ft.colors.WHITE70),
|
||||
],
|
||||
spacing=2,
|
||||
expand=True,
|
||||
),
|
||||
expand=True,
|
||||
),
|
||||
ft.Row(
|
||||
[
|
||||
ft.IconButton(
|
||||
ft.icons.COPY,
|
||||
icon_size=14,
|
||||
icon_color=ft.colors.WHITE,
|
||||
tooltip="Копировать URL",
|
||||
on_click=lambda e, url=m["url"]: self._copy_url(url),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.DELETE_OUTLINE,
|
||||
icon_size=14,
|
||||
icon_color=ft.colors.WHITE,
|
||||
tooltip="Удалить",
|
||||
on_click=lambda e, mid=m["id"]: self._confirm_delete(mid, m.get("filename", "")),
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
),
|
||||
bgcolor=ft.colors.with_opacity(0.6, ft.colors.BLACK),
|
||||
border_radius=ft.BorderRadius(0, 0, 8, 8),
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
||||
bottom=0,
|
||||
left=0,
|
||||
right=0,
|
||||
),
|
||||
],
|
||||
expand=True,
|
||||
),
|
||||
height=180,
|
||||
border_radius=8,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
)
|
||||
|
||||
def _copy_url(self, url: str):
|
||||
self.page.set_clipboard(url)
|
||||
d.snack(self.page, "URL скопирован в буфер обмена")
|
||||
|
||||
def _confirm_delete(self, media_id: str, filename: str):
|
||||
dlg = d.confirm_dialog(
|
||||
self.page,
|
||||
"Удалить файл?",
|
||||
f'"{filename}" будет удалён с сервера.',
|
||||
lambda: self.page.run_task(self._delete, media_id),
|
||||
)
|
||||
self.page.dialog = dlg
|
||||
dlg.open = True
|
||||
self.page.update()
|
||||
|
||||
async def _delete(self, media_id: str):
|
||||
token = self.page.session.get("token")
|
||||
ok = await api.admin_delete_media(token, media_id)
|
||||
if ok:
|
||||
d.snack(self.page, "Файл удалён")
|
||||
await self._reload()
|
||||
else:
|
||||
d.snack(self.page, "Ошибка удаления", error=True)
|
||||
|
||||
async def _on_files_picked(self, e: ft.FilePickerResultEvent):
|
||||
if not e.files:
|
||||
return
|
||||
token = self.page.session.get("token")
|
||||
uploaded = 0
|
||||
for f in e.files:
|
||||
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
||||
video_exts = {"mp4", "webm", "ogg", "mov"}
|
||||
kind = "video" if ext in video_exts else "image"
|
||||
ct_map = {
|
||||
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
|
||||
"gif": "image/gif", "webp": "image/webp",
|
||||
"mp4": "video/mp4", "webm": "video/webm", "ogg": "video/ogg", "mov": "video/quicktime",
|
||||
}
|
||||
ct = ct_map.get(ext, "application/octet-stream")
|
||||
try:
|
||||
if hasattr(f, "path") and f.path:
|
||||
with open(f.path, "rb") as fh:
|
||||
data = fh.read()
|
||||
result = await api.admin_upload_media(token, data, f.name, ct)
|
||||
if result:
|
||||
uploaded += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
d.snack(self.page, f"Загружено файлов: {uploaded}")
|
||||
await self._reload()
|
||||
189
web/views/news_detail.py
Normal file
189
web/views/news_detail.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
import theme_manager
|
||||
|
||||
|
||||
class NewsDetailView:
|
||||
def __init__(self, page: ft.Page, slug: str):
|
||||
self.page = page
|
||||
self.slug = slug
|
||||
self._content_col = ft.Column(
|
||||
[ft.Container(content=d.loader(), alignment=ft.alignment.center, padding=40)],
|
||||
expand=True,
|
||||
)
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
header = ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.IconButton(
|
||||
ft.icons.ARROW_BACK,
|
||||
icon_color=ft.colors.WHITE,
|
||||
tooltip="Назад",
|
||||
on_click=lambda e: self.page.go("/"),
|
||||
),
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=24),
|
||||
ft.Text("Новости", size=18, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
|
||||
], spacing=8),
|
||||
ft.Container(width=40),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
bgcolor=d.ADMIN_SIDEBAR,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||
)
|
||||
|
||||
return ft.View(
|
||||
route=f"/news/{self.slug}",
|
||||
bgcolor=d.BACKGROUND,
|
||||
controls=[
|
||||
ft.Column(
|
||||
[
|
||||
header,
|
||||
ft.Container(
|
||||
content=self._content_col,
|
||||
alignment=ft.alignment.top_center,
|
||||
expand=True,
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
expand=True,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
article = await api.get_article(self.slug)
|
||||
if not article:
|
||||
theme_manager.reset_font(self.page)
|
||||
self._content_col.controls = [
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Icon(ft.icons.ARTICLE_OUTLINED, size=64, color=d.DIVIDER),
|
||||
d.headline("Статья не найдена", size=20),
|
||||
d.btn_outlined("На главную", on_click=lambda e: self.page.go("/")),
|
||||
],
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=16,
|
||||
),
|
||||
alignment=ft.alignment.center,
|
||||
padding=60,
|
||||
)
|
||||
]
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
font = article.get("font_family", "Merriweather")
|
||||
theme_manager.apply_article_font(self.page, font)
|
||||
self._render(article)
|
||||
|
||||
def _render(self, a: dict):
|
||||
font = a.get("font_family", "Merriweather")
|
||||
cat = a.get("category")
|
||||
tags = a.get("tags", [])
|
||||
pub_date = a.get("published_at", "")[:10] if a.get("published_at") else ""
|
||||
|
||||
meta_chips = ft.Row(wrap=True, spacing=8, run_spacing=8)
|
||||
if cat:
|
||||
meta_chips.controls.append(
|
||||
ft.Container(
|
||||
content=ft.Text(cat["name"], size=12, color=ft.colors.WHITE),
|
||||
bgcolor=d.PRIMARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||||
)
|
||||
)
|
||||
for tag in tags:
|
||||
meta_chips.controls.append(
|
||||
ft.Container(
|
||||
content=ft.Text(f"#{tag['name']}", size=12, color=d.PRIMARY),
|
||||
bgcolor=ft.colors.with_opacity(0.1, d.PRIMARY),
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||||
)
|
||||
)
|
||||
|
||||
controls = [
|
||||
d.headline(a["title"], size=30, font=d.FONT_DISPLAY),
|
||||
ft.Row(
|
||||
[
|
||||
meta_chips,
|
||||
ft.Row([
|
||||
ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY),
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||
ft.Text(str(a.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||
], spacing=4),
|
||||
], spacing=16),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
wrap=True,
|
||||
),
|
||||
]
|
||||
|
||||
if a.get("cover_url"):
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Image(
|
||||
src=a["cover_url"],
|
||||
width=float("inf"),
|
||||
height=400,
|
||||
fit=ft.ImageFit.COVER,
|
||||
border_radius=12,
|
||||
),
|
||||
border_radius=12,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
)
|
||||
)
|
||||
|
||||
if a.get("excerpt"):
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
a["excerpt"],
|
||||
size=16,
|
||||
color=d.TEXT_SECONDARY,
|
||||
italic=True,
|
||||
),
|
||||
border=ft.Border(left=ft.BorderSide(4, d.PRIMARY)),
|
||||
padding=ft.padding.only(left=16, top=8, bottom=8),
|
||||
)
|
||||
)
|
||||
|
||||
controls.append(d.divider())
|
||||
|
||||
controls.append(
|
||||
ft.Markdown(
|
||||
value=a.get("content", ""),
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
on_tap_link=lambda e: self.page.launch_url(e.data),
|
||||
auto_follow_links=False,
|
||||
)
|
||||
)
|
||||
|
||||
controls.append(ft.Divider(height=32, color="transparent"))
|
||||
controls.append(
|
||||
ft.TextButton(
|
||||
"← Назад к новостям",
|
||||
icon=ft.icons.ARROW_BACK,
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e: self.page.go("/"),
|
||||
)
|
||||
)
|
||||
|
||||
self._content_col.controls = [
|
||||
ft.Container(
|
||||
content=ft.Column(controls, spacing=16),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=12,
|
||||
padding=32,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
)
|
||||
]
|
||||
self.page.update()
|
||||
240
web/views/news_feed.py
Normal file
240
web/views/news_feed.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
|
||||
|
||||
class NewsFeedView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._articles: list = []
|
||||
self._offset = 0
|
||||
self._limit = 20
|
||||
self._has_more = True
|
||||
self._loading = False
|
||||
self._category: str | None = None
|
||||
self._categories: list = []
|
||||
|
||||
self._list_col = ft.Column(spacing=16)
|
||||
self._loader = ft.Container(
|
||||
content=d.loader(36),
|
||||
alignment=ft.alignment.center,
|
||||
padding=20,
|
||||
visible=True,
|
||||
)
|
||||
self._load_more_btn = ft.ElevatedButton(
|
||||
"Загрузить ещё",
|
||||
icon=ft.icons.EXPAND_MORE,
|
||||
on_click=lambda e: self.page.run_task(self._load_more),
|
||||
visible=False,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=d.SURFACE,
|
||||
color=d.PRIMARY,
|
||||
shape=ft.RoundedRectangleBorder(radius=8),
|
||||
),
|
||||
)
|
||||
self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8)
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
header = ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=28),
|
||||
ft.Text("Новости", size=20, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
|
||||
], spacing=8),
|
||||
ft.Row([
|
||||
ft.TextButton(
|
||||
"Войти",
|
||||
icon=ft.icons.ADMIN_PANEL_SETTINGS_OUTLINED,
|
||||
style=ft.ButtonStyle(color=ft.colors.WHITE70),
|
||||
on_click=lambda e: self.page.go("/admin/login"),
|
||||
)
|
||||
]),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
bgcolor=d.ADMIN_SIDEBAR,
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=14),
|
||||
)
|
||||
|
||||
cat_section = ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Container(content=self._cat_row, padding=ft.padding.symmetric(horizontal=24, vertical=8)),
|
||||
]),
|
||||
bgcolor=d.SURFACE,
|
||||
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||
)
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||
alignment=ft.alignment.top_center,
|
||||
)
|
||||
|
||||
return ft.View(
|
||||
route="/",
|
||||
bgcolor=d.BACKGROUND,
|
||||
controls=[
|
||||
ft.Column(
|
||||
[
|
||||
header,
|
||||
cat_section,
|
||||
ft.Container(
|
||||
content=main_content,
|
||||
alignment=ft.alignment.top_center,
|
||||
expand=True,
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
expand=True,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
self._categories = await api.get_categories()
|
||||
self._render_categories()
|
||||
await self._load_articles()
|
||||
|
||||
async def _load_more(self):
|
||||
if self._loading or not self._has_more:
|
||||
return
|
||||
self._loading = True
|
||||
self._load_more_btn.disabled = True
|
||||
self.page.update()
|
||||
self._offset += self._limit
|
||||
await self._load_articles()
|
||||
self._loading = False
|
||||
self._load_more_btn.disabled = False
|
||||
self.page.update()
|
||||
|
||||
async def _load_articles(self):
|
||||
self._loader.visible = True
|
||||
self.page.update()
|
||||
|
||||
data = await api.get_news(offset=self._offset, limit=self._limit, category=self._category)
|
||||
self._loader.visible = False
|
||||
|
||||
if not data:
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
items = data.get("items", [])
|
||||
self._articles.extend(items)
|
||||
self._has_more = data.get("has_more", False)
|
||||
|
||||
for a in items:
|
||||
self._list_col.controls.append(self._build_card(a))
|
||||
|
||||
self._load_more_btn.visible = self._has_more
|
||||
self.page.update()
|
||||
|
||||
def _render_categories(self):
|
||||
self._cat_row.controls = [
|
||||
self._cat_chip(None, "Все")
|
||||
] + [
|
||||
self._cat_chip(c["slug"], c["name"]) for c in self._categories
|
||||
]
|
||||
self.page.update()
|
||||
|
||||
def _cat_chip(self, slug: str | None, label: str) -> ft.Chip:
|
||||
is_active = self._category == slug
|
||||
return ft.Chip(
|
||||
label=ft.Text(label, size=13),
|
||||
bgcolor=d.PRIMARY if is_active else d.SURFACE,
|
||||
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),
|
||||
)
|
||||
|
||||
async def _filter_category(self, slug: str | None):
|
||||
self._category = slug
|
||||
self._articles = []
|
||||
self._offset = 0
|
||||
self._has_more = True
|
||||
self._list_col.controls.clear()
|
||||
self._render_categories()
|
||||
await self._load_articles()
|
||||
|
||||
def _build_card(self, article: dict) -> ft.Container:
|
||||
pub_date = ""
|
||||
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)
|
||||
|
||||
cover = ft.Image(
|
||||
src=article["cover_url"],
|
||||
width=260,
|
||||
height=160,
|
||||
fit=ft.ImageFit.COVER,
|
||||
border_radius=ft.BorderRadius(0, 8, 8, 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,
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Row([ft.Container(content=text_part, expand=True, padding=20), cover], 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)),
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
||||
ink=True,
|
||||
)
|
||||
Reference in New Issue
Block a user