test ract
This commit is contained in:
358
web/public_pages.py
Normal file
358
web/public_pages.py
Normal file
@@ -0,0 +1,358 @@
|
||||
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()">← Назад</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">📅 %%PUB_DATE%%</span>
|
||||
<span class="meta-sep">·</span>
|
||||
<span class="meta-text">👁 %%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>🖼</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">·</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()))
|
||||
Reference in New Issue
Block a user