Files
news_all_spo/web/views/news_feed.py
2026-05-15 03:31:28 +05:00

453 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)