453 lines
16 KiB
Python
453 lines
16 KiB
Python
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)
|