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, )