from datetime import datetime, timedelta from urllib.parse import urlparse import flet as ft import designer as d import api_client as api _DATE_PRESETS = [ (None, "Всё время"), ("week", "Неделя"), ("month", "Месяц"), ("3months", "3 месяца"), ("year", "Год"), ] def _preset_to_date_from(preset: str | None) -> str | None: if preset is None: return None deltas = { "week": timedelta(weeks=1), "month": timedelta(days=30), "3months": timedelta(days=90), "year": timedelta(days=365), } if preset in deltas: return (datetime.now() - deltas[preset]).isoformat() return None 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._tag: str | None = None self._date_preset: str | None = None self._categories: list = [] self._tags: list = [] self._grid = ft.GridView( max_extent=290, child_aspect_ratio=0.72, spacing=12, run_spacing=12, expand=True, padding=0, ) self._loader = ft.Container( content=d.loader(36), alignment=ft.alignment.center, padding=20, visible=True, ) self._load_more_btn = ft.Container( content=ft.TextButton( "Показать ещё", icon=ft.icons.EXPAND_MORE, on_click=lambda e: self.page.run_task(self._load_more), style=ft.ButtonStyle(color=d.PRIMARY), ), alignment=ft.alignment.center, padding=ft.padding.only(bottom=24), visible=False, ) self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8) self._tag_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6) self._date_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6) def build_page_view(self) -> ft.View: header = ft.Container( content=ft.Row( [ ft.Row([ ft.Container( content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, color=ft.colors.WHITE, size=22), bgcolor=ft.colors.with_opacity(0.2, ft.colors.WHITE), border_radius=8, padding=6, ), ft.Text( "Новости колледжей", size=18, weight=ft.FontWeight.W_700, color=ft.colors.WHITE, font_family=d.FONT_UI, ), ], spacing=10), ft.IconButton( icon=ft.icons.MANAGE_ACCOUNTS_OUTLINED, icon_color=ft.colors.WHITE70, tooltip="Войти в панель управления", on_click=lambda e: self.page.go("/admin/login"), icon_size=22, ), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, ), bgcolor=d.PRIMARY, padding=ft.padding.symmetric(horizontal=20, vertical=12), shadow=ft.BoxShadow( blur_radius=8, offset=ft.Offset(0, 2), color=ft.colors.with_opacity(0.18, ft.colors.BLACK), ), ) filters = ft.Container( content=ft.Column([ ft.Container( content=self._cat_row, padding=ft.padding.only(left=16, right=16, top=10, bottom=4), ), ft.Container( content=self._tag_row, padding=ft.padding.symmetric(horizontal=16, vertical=4), ), ft.Container( content=self._date_row, padding=ft.padding.only(left=16, right=16, top=4, bottom=10), ), ], spacing=0), bgcolor=d.SURFACE, border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)), ) feed = ft.Container( content=ft.Column( [self._grid, self._loader, self._load_more_btn], spacing=0, ), padding=ft.padding.symmetric(horizontal=12, vertical=16), expand=True, ) return ft.View( route="/", bgcolor=d.BACKGROUND, controls=[ ft.Column( [ header, filters, ft.Container( content=feed, expand=True, ), ], spacing=0, expand=True, scroll=ft.ScrollMode.AUTO, ) ], ) async def load(self): self._categories, self._tags = await _gather( api.get_categories(), api.get_tags(30), ) # Все фильтры — один update вместо трёх self._render_categories(update=False) self._render_tags(update=False) self._render_dates(update=False) self.page.update() 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.visible = False self.page.update() self._offset += self._limit await self._load_articles() self._loading = False async def _load_articles(self): self._loader.visible = True self.page.update() date_from = _preset_to_date_from(self._date_preset) data = await api.get_news( offset=self._offset, limit=self._limit, category=self._category, tag=self._tag, date_from=date_from, ) 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._grid.controls.append(self._build_card(a)) self._load_more_btn.visible = self._has_more self.page.update() def _reset_feed(self): self._articles = [] self._offset = 0 self._has_more = True self._grid.controls.clear() # ── Category filter ──────────────────────────────────────────────────────── def _render_categories(self, update: bool = True): self._cat_row.controls = [ self._cat_chip(None, "Все") ] + [ self._cat_chip(c["slug"], c["name"]) for c in self._categories ] if update: 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=12, font_family=d.FONT_UI), bgcolor=d.PRIMARY if is_active else d.SURFACE, label_style=ft.TextStyle( color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY, weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL, ), on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s), padding=ft.padding.symmetric(horizontal=10, vertical=2), border_side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER), elevation=0, ) async def _filter_category(self, slug: str | None): self._category = slug self._reset_feed() self._render_categories() await self._load_articles() # ── Tag filter ───────────────────────────────────────────────────────────── def _render_tags(self, update: bool = True): if not self._tags: self._tag_row.controls = [] else: self._tag_row.controls = [ ft.Container( content=ft.Text("Теги:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI), padding=ft.padding.only(right=4), ) ] + [self._tag_chip(t["slug"], t["name"]) for t in self._tags] if update: self.page.update() def _tag_chip(self, slug: str, label: str) -> ft.Container: is_active = self._tag == slug return ft.Container( content=ft.Text( f"#{label}", size=11, color=ft.colors.WHITE if is_active else d.PRIMARY, font_family=d.FONT_UI, weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL, ), bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.08, d.PRIMARY), border_radius=4, padding=ft.padding.symmetric(horizontal=8, vertical=3), on_click=lambda e, s=slug: self.page.run_task(self._filter_tag, s), ink=True, ) async def _filter_tag(self, slug: str): self._tag = None if self._tag == slug else slug self._reset_feed() self._render_tags() await self._load_articles() # ── Date filter ──────────────────────────────────────────────────────────── def _render_dates(self, update: bool = True): self._date_row.controls = [ ft.Container( content=ft.Text("Период:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI), padding=ft.padding.only(right=4), ) ] + [self._date_btn(preset, label) for preset, label in _DATE_PRESETS] if update: self.page.update() def _date_btn(self, preset: str | None, label: str) -> ft.Container: is_active = self._date_preset == preset return ft.Container( content=ft.Text( label, size=11, color=ft.colors.WHITE if is_active else d.TEXT_SECONDARY, font_family=d.FONT_UI, weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL, ), bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.06, ft.colors.BLACK), border_radius=4, padding=ft.padding.symmetric(horizontal=8, vertical=3), border=ft.border.all(1, d.PRIMARY if is_active else d.DIVIDER), on_click=lambda e, p=preset: self.page.run_task(self._filter_date, p), ink=True, ) async def _filter_date(self, preset: str | None): self._date_preset = preset self._reset_feed() self._render_dates() await self._load_articles() # ── Card ─────────────────────────────────────────────────────────────────── def _build_card(self, article: dict) -> ft.Container: slug = article["slug"] source_url = article.get("source_url", "") or "" src_domain = "" if source_url: try: src_domain = urlparse(source_url).netloc.replace("www.", "") except Exception: src_domain = "vk.com" pub_date = "" if article.get("published_at"): try: dt = datetime.fromisoformat(article["published_at"].replace("Z", "+00:00")) pub_date = dt.strftime("%d.%m.%Y") except Exception: pub_date = article["published_at"][:10] cat = article.get("category") # Cover if article.get("cover_url"): cover = ft.Container( content=ft.Image( src=article["cover_url"], fit=ft.ImageFit.COVER, width=float("inf"), height=170, error_content=ft.Container( bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY), content=ft.Icon(ft.icons.IMAGE_NOT_SUPPORTED_OUTLINED, size=28, color=d.DIVIDER), alignment=ft.alignment.center, height=170, ), ), height=170, clip_behavior=ft.ClipBehavior.HARD_EDGE, ) else: cover = ft.Container( height=120, bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY), content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, size=32, color=ft.colors.with_opacity(0.25, d.PRIMARY)), alignment=ft.alignment.center, ) # Category + date row meta_row = ft.Row([ ft.Text( cat["name"] if cat else "", size=10, color=d.PRIMARY, weight=ft.FontWeight.W_600, font_family=d.FONT_UI, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=True, ), ft.Text(pub_date, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI), ], spacing=4, vertical_alignment=ft.CrossAxisAlignment.CENTER) title = ft.Text( article["title"], size=13, weight=ft.FontWeight.W_700, color=d.TEXT_PRIMARY, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS, font_family=d.FONT_UI, ) tags = article.get("tags", []) tag_row = ft.Row( [ ft.Container( content=ft.Text(f"#{t['name']}", size=10, color=d.PRIMARY, font_family=d.FONT_UI), bgcolor=ft.colors.with_opacity(0.08, d.PRIMARY), border_radius=4, padding=ft.padding.symmetric(horizontal=6, vertical=2), ) for t in tags[:3] ], spacing=4, wrap=True, ) if tags else ft.Container(height=2) # Footer: source domain + "Читать →" footer = ft.Row([ ft.Row([ ft.Icon(ft.icons.LINK, size=11, color=d.TEXT_SECONDARY), ft.Text(src_domain, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI), ], spacing=2) if src_domain else ft.Container(), ft.Container(expand=True), ft.Text("Читать →", size=11, color=d.PRIMARY, weight=ft.FontWeight.W_600, font_family=d.FONT_UI), ], vertical_alignment=ft.CrossAxisAlignment.CENTER) content_col = ft.Column( [meta_row, title, tag_row, ft.Container(expand=True), footer], spacing=6, expand=True, ) return ft.Container( content=ft.Column([ cover, ft.Container( content=content_col, padding=ft.padding.symmetric(horizontal=10, vertical=8), expand=True, ), ], spacing=0, expand=True), bgcolor=d.CARD_BG, border_radius=12, shadow=ft.BoxShadow( blur_radius=6, offset=ft.Offset(0, 1), color=ft.colors.with_opacity(0.08, ft.colors.BLACK), ), on_click=lambda e, s=slug: self.page.go(f"/news/{s}"), ink=True, clip_behavior=ft.ClipBehavior.HARD_EDGE, ) async def _gather(*coros): import asyncio return await asyncio.gather(*coros)