242 lines
8.9 KiB
Python
242 lines
8.9 KiB
Python
import re
|
||
import flet as ft
|
||
import designer as d
|
||
import api_client as api
|
||
import theme_manager
|
||
|
||
|
||
def _html_to_markdown(html: str) -> str:
|
||
"""Convert TipTap HTML output to Markdown for ft.Markdown rendering."""
|
||
t = html or ""
|
||
# Headings
|
||
t = re.sub(r"<h([1-6])[^>]*>(.*?)</h\1>",
|
||
lambda m: "#" * int(m[1]) + " " + m[2] + "\n\n",
|
||
t, flags=re.DOTALL | re.IGNORECASE)
|
||
# Bold / italic
|
||
t = re.sub(r"<(strong|b)[^>]*>(.*?)</\1>", r"**\2**", t, flags=re.DOTALL | re.IGNORECASE)
|
||
t = re.sub(r"<(em|i)[^>]*>(.*?)</\1>", r"*\2*", t, flags=re.DOTALL | re.IGNORECASE)
|
||
# Links
|
||
t = re.sub(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
|
||
r"[\2](\1)", t, flags=re.DOTALL | re.IGNORECASE)
|
||
# Images
|
||
t = re.sub(r'<img[^>]+src=["\']([^"\']+)["\'][^>]*>', r"", t, flags=re.IGNORECASE)
|
||
# Lists
|
||
t = re.sub(r"<li[^>]*>(.*?)</li>", r"- \1\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||
t = re.sub(r"</?[uo]l[^>]*>", "\n", t, flags=re.IGNORECASE)
|
||
# Paragraphs / line breaks
|
||
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"<p[^>]*>(.*?)</p>", r"\1\n\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||
# Strip remaining tags
|
||
t = re.sub(r"<[^>]+>", "", t)
|
||
# HTML entities
|
||
t = (t.replace(" ", " ").replace("&", "&")
|
||
.replace("<", "<").replace(">", ">").replace(""", '"'))
|
||
# Collapse excessive blank lines
|
||
t = re.sub(r"\n{3,}", "\n\n", t)
|
||
return t.strip()
|
||
|
||
|
||
class NewsDetailView:
|
||
def __init__(self, page: ft.Page, slug: str):
|
||
self.page = page
|
||
self.slug = slug
|
||
self._content_col = ft.Column(
|
||
[ft.Container(content=d.loader(), alignment=ft.alignment.center, padding=40)],
|
||
expand=True,
|
||
)
|
||
|
||
def build_page_view(self) -> ft.View:
|
||
header = ft.Container(
|
||
content=ft.Row(
|
||
[
|
||
ft.IconButton(
|
||
ft.icons.ARROW_BACK,
|
||
icon_color=ft.colors.WHITE,
|
||
tooltip="Назад",
|
||
on_click=lambda e: self.page.go("/"),
|
||
),
|
||
ft.Row([
|
||
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=24),
|
||
ft.Text("Новости", size=18, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
|
||
], spacing=8),
|
||
ft.Container(width=40),
|
||
],
|
||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||
),
|
||
bgcolor=d.ADMIN_SIDEBAR,
|
||
padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||
)
|
||
|
||
return ft.View(
|
||
route=f"/news/{self.slug}",
|
||
bgcolor=d.BACKGROUND,
|
||
controls=[
|
||
ft.Column(
|
||
[
|
||
header,
|
||
ft.Container(
|
||
content=self._content_col,
|
||
alignment=ft.alignment.top_center,
|
||
expand=True,
|
||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||
),
|
||
],
|
||
spacing=0,
|
||
expand=True,
|
||
scroll=ft.ScrollMode.AUTO,
|
||
)
|
||
],
|
||
)
|
||
|
||
async def load(self):
|
||
article = await api.get_article(self.slug)
|
||
if not article:
|
||
theme_manager.reset_font(self.page)
|
||
self._content_col.controls = [
|
||
ft.Container(
|
||
content=ft.Column(
|
||
[
|
||
ft.Icon(ft.icons.ARTICLE_OUTLINED, size=64, color=d.DIVIDER),
|
||
d.headline("Статья не найдена", size=20),
|
||
d.btn_outlined("На главную", on_click=lambda e: self.page.go("/")),
|
||
],
|
||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||
spacing=16,
|
||
),
|
||
alignment=ft.alignment.center,
|
||
padding=60,
|
||
)
|
||
]
|
||
self.page.update()
|
||
return
|
||
|
||
font = article.get("font_family", "Merriweather")
|
||
theme_manager.apply_article_font(self.page, font)
|
||
self._render(article)
|
||
|
||
def _render(self, a: dict):
|
||
font = a.get("font_family", "Merriweather")
|
||
cat = a.get("category")
|
||
tags = a.get("tags", [])
|
||
pub_date = a.get("published_at", "")[:10] if a.get("published_at") else ""
|
||
|
||
meta_chips = ft.Row(wrap=True, spacing=8, run_spacing=8)
|
||
if cat:
|
||
meta_chips.controls.append(
|
||
ft.Container(
|
||
content=ft.Text(cat["name"], size=12, color=ft.colors.WHITE),
|
||
bgcolor=d.PRIMARY,
|
||
border_radius=4,
|
||
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||
)
|
||
)
|
||
for tag in tags:
|
||
meta_chips.controls.append(
|
||
ft.Container(
|
||
content=ft.Text(f"#{tag['name']}", size=12, color=d.PRIMARY),
|
||
bgcolor=ft.colors.with_opacity(0.1, d.PRIMARY),
|
||
border_radius=4,
|
||
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||
)
|
||
)
|
||
|
||
controls = [
|
||
d.headline(a["title"], size=30, font=d.FONT_DISPLAY),
|
||
ft.Row(
|
||
[
|
||
meta_chips,
|
||
ft.Row([
|
||
ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY),
|
||
ft.Row([
|
||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||
ft.Text(str(a.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||
], spacing=4),
|
||
], spacing=16),
|
||
],
|
||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||
wrap=True,
|
||
),
|
||
]
|
||
|
||
if a.get("cover_url"):
|
||
controls.append(
|
||
ft.Container(
|
||
content=ft.Image(
|
||
src=a["cover_url"],
|
||
width=float("inf"),
|
||
height=400,
|
||
fit=ft.ImageFit.COVER,
|
||
border_radius=12,
|
||
),
|
||
border_radius=12,
|
||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||
)
|
||
)
|
||
|
||
if a.get("excerpt"):
|
||
controls.append(
|
||
ft.Container(
|
||
content=ft.Text(
|
||
a["excerpt"],
|
||
size=16,
|
||
color=d.TEXT_SECONDARY,
|
||
italic=True,
|
||
),
|
||
border=ft.Border(left=ft.BorderSide(4, d.PRIMARY)),
|
||
padding=ft.padding.only(left=16, top=8, bottom=8),
|
||
)
|
||
)
|
||
|
||
controls.append(d.divider())
|
||
|
||
controls.append(
|
||
ft.Markdown(
|
||
value=_html_to_markdown(a.get("content", "")),
|
||
selectable=True,
|
||
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||
on_tap_link=lambda e: self.page.launch_url(e.data),
|
||
auto_follow_links=False,
|
||
)
|
||
)
|
||
|
||
if a.get("source_url"):
|
||
source_url = a["source_url"]
|
||
controls.append(
|
||
ft.Container(
|
||
content=ft.Row([
|
||
ft.Icon(ft.icons.OPEN_IN_NEW, size=15, color=d.PRIMARY),
|
||
ft.Text("Источник:", size=13, color=d.TEXT_SECONDARY),
|
||
ft.TextButton(
|
||
source_url,
|
||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||
on_click=lambda e, url=source_url: self.page.launch_url(url),
|
||
),
|
||
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER, wrap=True),
|
||
bgcolor=ft.colors.with_opacity(0.04, d.PRIMARY),
|
||
border_radius=8,
|
||
padding=12,
|
||
border=ft.border.all(1, ft.colors.with_opacity(0.15, d.PRIMARY)),
|
||
)
|
||
)
|
||
|
||
controls.append(ft.Divider(height=32, color="transparent"))
|
||
controls.append(
|
||
ft.TextButton(
|
||
"← Назад к новостям",
|
||
icon=ft.icons.ARROW_BACK,
|
||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||
on_click=lambda e: self.page.go("/"),
|
||
)
|
||
)
|
||
|
||
self._content_col.controls = [
|
||
ft.Container(
|
||
content=ft.Column(controls, spacing=16),
|
||
bgcolor=d.SURFACE,
|
||
border_radius=12,
|
||
padding=32,
|
||
border=ft.border.all(1, d.DIVIDER),
|
||
)
|
||
]
|
||
self.page.update()
|