254 lines
9.9 KiB
Python
254 lines
9.9 KiB
Python
import flet as ft
|
|
|
|
# ─── Шрифты ──────────────────────────────────────────────────────────────────
|
|
FONT_UI = "Inter"
|
|
FONT_SERIF = "Merriweather"
|
|
FONT_DISPLAY = "Playfair Display"
|
|
FONT_MONO = "JetBrains Mono"
|
|
|
|
ARTICLE_FONTS: dict[str, str] = {
|
|
"Merriweather": "Merriweather — для чтения (засечки)",
|
|
"Inter": "Inter — современный (без засечек)",
|
|
"Playfair Display": "Playfair Display — газетный",
|
|
"Roboto": "Roboto — нейтральный",
|
|
"JetBrains Mono": "JetBrains Mono — моноширинный",
|
|
}
|
|
|
|
GOOGLE_FONTS_JS = """
|
|
(function(){
|
|
if(document.getElementById('gfonts-news')) return;
|
|
var l=document.createElement('link');
|
|
l.id='gfonts-news';
|
|
l.rel='stylesheet';
|
|
l.href='https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&family=Roboto:wght@400;500;700&display=swap';
|
|
document.head.appendChild(l);
|
|
})();
|
|
"""
|
|
|
|
# ─── Цвета ───────────────────────────────────────────────────────────────────
|
|
PRIMARY = "#2787F5" # VK синий
|
|
PRIMARY_LIGHT = "#5BA4F7"
|
|
PRIMARY_DARK = "#1565C0"
|
|
ACCENT = "#FF3F4B" # красный акцент (лайки / важное)
|
|
SUCCESS = "#3DC47E"
|
|
WARNING = "#FFA726"
|
|
ERROR = "#E64646"
|
|
SURFACE = "#FFFFFF"
|
|
BACKGROUND = "#F0F2F5" # VK светло-серый фон
|
|
CARD_BG = "#FFFFFF"
|
|
TEXT_PRIMARY = "#050505"
|
|
TEXT_SECONDARY = "#818C99" # VK серый для мета-текста
|
|
DIVIDER = "#E5E9EF"
|
|
ADMIN_SIDEBAR = "#1A237E"
|
|
ADMIN_SIDEBAR_TEXT = "#E8EAF6"
|
|
ADMIN_SIDEBAR_ACTIVE = "#283593"
|
|
|
|
|
|
# ─── Типографика ─────────────────────────────────────────────────────────────
|
|
|
|
def headline(text: str, size: int = 28, color: str = TEXT_PRIMARY, weight=ft.FontWeight.BOLD, font: str = FONT_UI) -> ft.Text:
|
|
return ft.Text(text, size=size, color=color, weight=weight, font_family=font)
|
|
|
|
|
|
def body(text: str, size: int = 14, color: str = TEXT_PRIMARY, font: str = FONT_UI) -> ft.Text:
|
|
return ft.Text(text, size=size, color=color, font_family=font)
|
|
|
|
|
|
def caption(text: str, color: str = TEXT_SECONDARY) -> ft.Text:
|
|
return ft.Text(text, size=12, color=color, font_family=FONT_UI)
|
|
|
|
|
|
# ─── Кнопки ──────────────────────────────────────────────────────────────────
|
|
|
|
def btn_primary(text: str, on_click=None, icon=None, width: int | None = None) -> ft.ElevatedButton:
|
|
return ft.ElevatedButton(
|
|
text=text,
|
|
icon=icon,
|
|
on_click=on_click,
|
|
width=width,
|
|
style=ft.ButtonStyle(
|
|
bgcolor=PRIMARY,
|
|
color=ft.colors.WHITE,
|
|
shape=ft.RoundedRectangleBorder(radius=8),
|
|
padding=ft.padding.symmetric(horizontal=20, vertical=12),
|
|
),
|
|
)
|
|
|
|
|
|
def btn_outlined(text: str, on_click=None, icon=None, color: str = PRIMARY) -> ft.OutlinedButton:
|
|
return ft.OutlinedButton(
|
|
text=text,
|
|
icon=icon,
|
|
on_click=on_click,
|
|
style=ft.ButtonStyle(
|
|
color=color,
|
|
side=ft.BorderSide(1, color),
|
|
shape=ft.RoundedRectangleBorder(radius=8),
|
|
padding=ft.padding.symmetric(horizontal=16, vertical=10),
|
|
),
|
|
)
|
|
|
|
|
|
def btn_danger(text: str, on_click=None, icon=None) -> ft.ElevatedButton:
|
|
return ft.ElevatedButton(
|
|
text=text,
|
|
icon=icon,
|
|
on_click=on_click,
|
|
style=ft.ButtonStyle(
|
|
bgcolor=ERROR,
|
|
color=ft.colors.WHITE,
|
|
shape=ft.RoundedRectangleBorder(radius=8),
|
|
),
|
|
)
|
|
|
|
|
|
def btn_icon(icon, tooltip: str = "", on_click=None, color: str = TEXT_SECONDARY) -> ft.IconButton:
|
|
return ft.IconButton(icon=icon, tooltip=tooltip, on_click=on_click, icon_color=color, icon_size=20)
|
|
|
|
|
|
# ─── Поля ввода ──────────────────────────────────────────────────────────────
|
|
|
|
def text_field(
|
|
label: str,
|
|
value: str = "",
|
|
on_change=None,
|
|
multiline: bool = False,
|
|
min_lines: int = 1,
|
|
max_lines: int | None = 1,
|
|
hint: str = "",
|
|
password: bool = False,
|
|
expand: bool = False,
|
|
read_only: bool = False,
|
|
prefix_icon=None,
|
|
) -> ft.TextField:
|
|
return ft.TextField(
|
|
label=label,
|
|
value=value,
|
|
on_change=on_change,
|
|
multiline=multiline,
|
|
min_lines=min_lines,
|
|
max_lines=max_lines,
|
|
hint_text=hint,
|
|
password=password,
|
|
can_reveal_password=password,
|
|
expand=expand,
|
|
read_only=read_only,
|
|
prefix_icon=prefix_icon,
|
|
border_color=DIVIDER,
|
|
focused_border_color=PRIMARY,
|
|
label_style=ft.TextStyle(color=TEXT_SECONDARY, size=13, font_family=FONT_UI),
|
|
text_style=ft.TextStyle(color=TEXT_PRIMARY, size=14, font_family=FONT_UI),
|
|
border_radius=8,
|
|
content_padding=ft.padding.symmetric(horizontal=12, vertical=10),
|
|
)
|
|
|
|
|
|
def dropdown(label: str, options: list[ft.dropdown.Option], value: str | None = None, on_change=None) -> ft.Dropdown:
|
|
return ft.Dropdown(
|
|
label=label,
|
|
value=value,
|
|
options=options,
|
|
on_change=on_change,
|
|
border_color=DIVIDER,
|
|
focused_border_color=PRIMARY,
|
|
label_style=ft.TextStyle(color=TEXT_SECONDARY, size=13),
|
|
border_radius=8,
|
|
)
|
|
|
|
|
|
# ─── Карточки ────────────────────────────────────────────────────────────────
|
|
|
|
def card(content: ft.Control, padding: int = 16, elevation: int = 1) -> ft.Card:
|
|
return ft.Card(
|
|
content=ft.Container(content=content, padding=padding),
|
|
elevation=elevation,
|
|
surface_tint_color=SURFACE,
|
|
)
|
|
|
|
|
|
def stat_card(title: str, value: str, icon, color: str = PRIMARY) -> ft.Container:
|
|
return ft.Container(
|
|
content=ft.Column(
|
|
[
|
|
ft.Row([ft.Icon(icon, color=color, size=28), body(title, color=TEXT_SECONDARY)], spacing=8),
|
|
ft.Text(value, size=32, weight=ft.FontWeight.BOLD, color=color),
|
|
],
|
|
spacing=8,
|
|
),
|
|
bgcolor=SURFACE,
|
|
border_radius=12,
|
|
padding=20,
|
|
shadow=ft.BoxShadow(spread_radius=0, blur_radius=8, color=ft.colors.with_opacity(0.08, ft.colors.BLACK)),
|
|
width=200,
|
|
)
|
|
|
|
|
|
# ─── Статус-бейджи ────────────────────────────────────────────────────────────
|
|
|
|
def status_badge(status: str) -> ft.Container:
|
|
cfg = {
|
|
"published": (SUCCESS, "Опубликовано"),
|
|
"draft": (TEXT_SECONDARY, "Черновик"),
|
|
}
|
|
color, label = cfg.get(status, (TEXT_SECONDARY, status))
|
|
return ft.Container(
|
|
content=ft.Text(label, size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_500),
|
|
bgcolor=color,
|
|
border_radius=4,
|
|
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
|
)
|
|
|
|
|
|
# ─── Разделитель ─────────────────────────────────────────────────────────────
|
|
|
|
def divider() -> ft.Divider:
|
|
return ft.Divider(height=1, color=DIVIDER)
|
|
|
|
|
|
# ─── Загрузчик ───────────────────────────────────────────────────────────────
|
|
|
|
def loader(size: int = 40) -> ft.ProgressRing:
|
|
return ft.ProgressRing(width=size, height=size, stroke_width=3, color=PRIMARY)
|
|
|
|
|
|
# ─── Снэкбар ─────────────────────────────────────────────────────────────────
|
|
|
|
def snack(page: ft.Page, message: str, error: bool = False):
|
|
page.snack_bar = ft.SnackBar(
|
|
content=ft.Text(message, color=ft.colors.WHITE),
|
|
bgcolor=ERROR if error else SUCCESS,
|
|
duration=3000,
|
|
)
|
|
page.snack_bar.open = True
|
|
page.update()
|
|
|
|
|
|
# ─── Диалог подтверждения ─────────────────────────────────────────────────────
|
|
|
|
def confirm_dialog(page: ft.Page, title: str, message: str, on_confirm) -> ft.AlertDialog:
|
|
dlg = ft.AlertDialog(
|
|
modal=True,
|
|
title=ft.Text(title, weight=ft.FontWeight.BOLD),
|
|
content=ft.Text(message),
|
|
actions=[
|
|
ft.TextButton("Отмена", on_click=lambda e: _close_dlg(page, dlg)),
|
|
btn_danger("Удалить", on_click=lambda e: (_close_dlg(page, dlg), on_confirm())),
|
|
],
|
|
)
|
|
return dlg
|
|
|
|
|
|
def _close_dlg(page: ft.Page, dlg: ft.AlertDialog):
|
|
dlg.open = False
|
|
page.update()
|
|
|
|
|
|
# ─── Пустое состояние ────────────────────────────────────────────────────────
|
|
|
|
def empty_state(icon, message: str) -> ft.Column:
|
|
return ft.Column(
|
|
[ft.Icon(icon, size=64, color=DIVIDER), body(message, color=TEXT_SECONDARY)],
|
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
|
spacing=12,
|
|
)
|