Files
news_all_spo/web/public_pages.py
2026-05-14 16:47:58 +05:00

359 lines
12 KiB
Python

import json
import html as _esc
import httpx
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from editor_page import _API_URL
router = APIRouter()
# ── Google Fonts ────────────────────────────────────────────────────────────
_GFONTS = (
"https://fonts.googleapis.com/css2?family=Arimo:ital,wght@0,400;0,700;1,400;1,700"
"&family=Caladea:ital,wght@0,400;0,700;1,400;1,700"
"&family=Cormorant+Garamond:ital,wght@0,400;0,700;1,400;1,700"
"&family=Cousine:ital,wght@0,400;0,700;1,400;1,700"
"&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700"
"&family=EB+Garamond:ital,wght@0,400;0,700;1,400;1,700"
"&family=Fira+Code:wght@400;700"
"&family=Inter:wght@400;500;700"
"&family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700"
"&family=Josefin+Sans:ital,wght@0,400;0,700;1,400;1,700"
"&family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700"
"&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400"
"&family=Libre+Franklin:ital,wght@0,400;0,700;1,400;1,700"
"&family=Lora:ital,wght@0,400;0,700;1,400;1,700"
"&family=Merriweather:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700"
"&family=Nunito:ital,wght@0,400;0,700;1,400;1,700"
"&family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700"
"&family=Oswald:wght@400;500;700"
"&family=Outfit:wght@400;700"
"&family=Patrick+Hand"
"&family=Playfair+Display:ital,wght@0,400;0,700;1,400;1,700"
"&family=Poppins:ital,wght@0,400;0,700;1,400;1,700"
"&family=Roboto+Mono:ital,wght@0,400;0,700;1,400;1,700"
"&family=Roboto:ital,wght@0,400;0,700;1,400;1,700"
"&family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700"
"&family=Tinos:ital,wght@0,400;0,700;1,400;1,700"
"&display=swap"
)
# ── HTML template ───────────────────────────────────────────────────────────
# Placeholders use %%NAME%% — never clashes with CSS/JS { } braces.
_TMPL = """\
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="%%EXCERPT_TEXT%%">
<title>%%TITLE%% — Новости</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="%%GFONTS%%" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:Inter,sans-serif;background:#f0f2f5;color:#1a1a1a;min-height:100vh}
/* top bar */
#topbar{
position:sticky;top:0;z-index:100;
background:#1a237e;padding:10px 20px;
display:flex;align-items:center;justify-content:space-between;
box-shadow:0 2px 8px rgba(0,0,0,.25);
}
.site-name{
color:#fff;font-size:18px;font-weight:700;
text-decoration:none;display:flex;align-items:center;gap:8px;
}
.site-name::before{content:"📰";font-size:20px}
.btn-back{
color:rgba(255,255,255,.85);font-size:13px;
background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);
border-radius:6px;padding:6px 14px;cursor:pointer;
text-decoration:none;transition:background .15s;
}
.btn-back:hover{background:rgba(255,255,255,.22)}
/* layout */
#page{max-width:820px;margin:32px auto 60px;padding:0 16px}
/* card */
#article{
background:#fff;border-radius:16px;
box-shadow:0 2px 16px rgba(0,0,0,.08);overflow:hidden;
}
/* cover */
.cover-wrap{width:100%;max-height:460px;overflow:hidden;background:#e8eaf0}
.cover-wrap img{width:100%;height:460px;object-fit:cover;display:block}
/* header */
.art-header{padding:28px 36px 0}
.meta-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:16px}
.cat-chip{
background:#1565c0;color:#fff;font-size:11px;font-weight:700;
letter-spacing:.5px;border-radius:4px;padding:3px 10px;text-transform:uppercase;
}
.meta-sep{color:#ccc;font-size:13px}
.meta-text{color:#888;font-size:13px;display:flex;align-items:center;gap:4px}
.art-title{font-size:32px;font-weight:800;line-height:1.25;color:#111;margin-bottom:16px}
.art-excerpt{
font-size:17px;line-height:1.65;color:#555;
border-left:4px solid #1565c0;
padding:10px 16px;background:#f5f8ff;
border-radius:0 8px 8px 0;font-style:italic;
}
.art-divider{border:none;border-top:1px solid #eee;margin:28px 36px}
/* content */
#content{padding:0 36px 36px;font-size:17px;line-height:1.75}
#content p{margin:14px 0}
#content h1{font-size:2em;margin:28px 0 12px;line-height:1.2}
#content h2{font-size:1.6em;margin:24px 0 10px;line-height:1.3}
#content h3{font-size:1.3em;margin:20px 0 8px;line-height:1.35}
#content h4,#content h5,#content h6{font-size:1.1em;margin:16px 0 6px}
#content ul,#content ol{padding-left:26px;margin:14px 0}
#content li{margin:6px 0;line-height:1.65}
#content a{color:#1565c0;text-decoration:underline;text-underline-offset:2px}
#content a:hover{color:#0d47a1}
#content strong{font-weight:700}
#content em{font-style:italic}
#content sup{font-size:.75em;vertical-align:super}
#content sub{font-size:.75em;vertical-align:sub}
#content blockquote{
border-left:4px solid #1565c0;background:#f5f8ff;
padding:12px 20px;margin:20px 0;border-radius:0 8px 8px 0;
color:#444;font-style:italic;
}
#content blockquote p{margin:6px 0}
#content code{
background:#f0f0f0;padding:2px 7px;border-radius:4px;
font-family:"JetBrains Mono","Fira Code",monospace;
font-size:.88em;color:#c7254e;
}
#content pre{
background:#1e1e2e;color:#cdd6f4;
padding:20px 24px;border-radius:10px;overflow-x:auto;
margin:20px 0;
font-family:"JetBrains Mono","Fira Code",monospace;
font-size:14px;line-height:1.6;
}
#content pre code{background:none;padding:0;color:inherit;font-size:inherit;border-radius:0}
#content img{
max-width:100%;height:auto;border-radius:8px;
display:block;margin:20px auto;
box-shadow:0 2px 12px rgba(0,0,0,.10);
}
.img-error{
display:flex;align-items:center;justify-content:center;
background:#f5f5f5;border:1px dashed #ccc;border-radius:8px;
height:120px;color:#999;font-size:14px;margin:20px 0;gap:8px;
}
#content video{
max-width:100%;height:auto;border-radius:8px;
display:block;margin:20px auto;
box-shadow:0 2px 12px rgba(0,0,0,.10);
}
.video-wrap{
position:relative;padding-bottom:56.25%;
height:0;overflow:hidden;border-radius:8px;margin:20px 0;
}
.video-wrap video{
position:absolute;top:0;left:0;
width:100%;height:100%;border-radius:8px;margin:0;box-shadow:none;
}
#content table{
width:100%;border-collapse:collapse;
margin:20px 0;overflow-x:auto;
display:block;font-size:15px;
}
#content thead{background:#1565c0;color:#fff}
#content th{padding:10px 16px;text-align:left;font-weight:600;border:1px solid #1255a8}
#content td{padding:9px 16px;border:1px solid #e0e0e0;vertical-align:top}
#content tbody tr:nth-child(even){background:#f7f9fc}
#content tbody tr:hover{background:#eef2ff}
#content hr{border:none;border-top:2px solid #eee;margin:28px 0}
/* tags */
.tags-row{
display:flex;flex-wrap:wrap;gap:8px;
padding:20px 36px 28px;border-top:1px solid #eee;
}
.tag-chip{
background:#eef2ff;color:#3949ab;font-size:12px;font-weight:600;
border-radius:20px;padding:4px 12px;border:1px solid #c5cae9;
}
/* mobile */
@media(max-width:600px){
#page{margin:16px auto 40px;padding:0 8px}
.art-header{padding:20px 20px 0}
.art-divider{margin:20px}
#content{padding:0 20px 24px}
.art-title{font-size:24px}
.cover-wrap img{height:220px}
.tags-row{padding:16px 20px 20px}
}
</style>
</head>
<body>
<div id="topbar">
<a class="site-name" href="/">Новости</a>
<a class="btn-back" href="javascript:history.back()">&#8592; Назад</a>
</div>
<div id="page">
<article id="article">
%%COVER_HTML%%
<div class="art-header">
<div class="meta-row">
%%CAT_HTML%%
%%META_SEP%%
<span class="meta-text">&#128197; %%PUB_DATE%%</span>
<span class="meta-sep">&middot;</span>
<span class="meta-text">&#128065; %%VIEW_COUNT%%</span>
</div>
<h1 class="art-title">%%TITLE%%</h1>
%%EXCERPT_HTML%%
</div>
<hr class="art-divider">
<div id="content" style="font-family:%%FONT_FAMILY%%">
<!-- rendered by JS below -->
</div>
%%TAGS_HTML%%
</article>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
(function(){
var raw = %%CONTENT_JSON%%;
var el = document.getElementById('content');
if (raw.trim().charAt(0) === '<') {
el.innerHTML = raw;
} else {
el.innerHTML = marked.parse(raw);
}
// lazy-load + error fallback for images
el.querySelectorAll('img').forEach(function(img){
img.loading = 'lazy';
img.decoding = 'async';
img.onerror = function(){
var d = document.createElement('div');
d.className = 'img-error';
d.innerHTML = '<span>&#128444;</span><span>Изображение недоступно</span>';
if (this.parentNode) this.parentNode.replaceChild(d, this);
};
});
// wrap videos + force controls
el.querySelectorAll('video').forEach(function(v){
v.controls = true;
v.setAttribute('playsinline','');
v.preload = 'metadata';
var wrap = document.createElement('div');
wrap.className = 'video-wrap';
v.parentNode.insertBefore(wrap, v);
wrap.appendChild(v);
});
// open external links in new tab
el.querySelectorAll('a[href]').forEach(function(a){
try {
var u = new URL(a.href);
if (u.origin !== window.location.origin){
a.target = '_blank';
a.rel = 'noopener noreferrer';
}
} catch(e){}
});
})();
</script>
</body>
</html>
"""
def _build_article_page(a: dict) -> str:
title = _esc.escape(a.get("title") or "Статья")
excerpt_text = _esc.escape(a.get("excerpt") or "")
font_family = _esc.escape(a.get("font_family") or "Merriweather")
pub_date = (a.get("published_at") or "")[:10]
view_count = str(a.get("view_count") or 0)
cover_url = a.get("cover_url") or ""
cover_html = (
f'<div class="cover-wrap">'
f'<img src="{_esc.escape(cover_url)}" alt="{title}" loading="lazy" decoding="async">'
f'</div>'
) if cover_url else ""
cat = a.get("category")
cat_html = (
f'<span class="cat-chip">{_esc.escape(cat["name"])}</span>' if cat else ""
)
meta_sep = '<span class="meta-sep">&middot;</span>' if cat else ""
excerpt_html = (
f'<p class="art-excerpt">{excerpt_text}</p>' if excerpt_text else ""
)
tags = a.get("tags") or []
tags_html = ""
if tags:
chips = "".join(
f'<span class="tag-chip">#{_esc.escape(t["name"])}</span>' for t in tags
)
tags_html = f'<div class="tags-row">{chips}</div>'
# Content is safely embedded as a JS string via json.dumps —
# no risk of HTML injection or format-string collisions.
content_json = json.dumps(a.get("content") or "")
return (
_TMPL
.replace("%%GFONTS%%", _GFONTS)
.replace("%%TITLE%%", title)
.replace("%%EXCERPT_TEXT%%", excerpt_text)
.replace("%%FONT_FAMILY%%", font_family)
.replace("%%PUB_DATE%%", pub_date)
.replace("%%VIEW_COUNT%%", view_count)
.replace("%%COVER_HTML%%", cover_html)
.replace("%%CAT_HTML%%", cat_html)
.replace("%%META_SEP%%", meta_sep)
.replace("%%EXCERPT_HTML%%", excerpt_html)
.replace("%%TAGS_HTML%%", tags_html)
.replace("%%CONTENT_JSON%%", content_json)
)
@router.get("/article/{slug}", response_class=HTMLResponse)
async def article_page(slug: str):
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{_API_URL}/news/{slug}")
if r.status_code == 404:
return HTMLResponse(
content='<h1 style="font-family:sans-serif;padding:40px">Статья не найдена</h1>',
status_code=404,
)
if r.status_code != 200:
return HTMLResponse(
content='<h1 style="font-family:sans-serif;padding:40px">Ошибка сервера</h1>',
status_code=500,
)
return HTMLResponse(content=_build_article_page(r.json()))