feat(web): архив опубликованных — дерево навигации по годам/месяцам + пагинация (openspec archive-navigation)

This commit is contained in:
kpa39l
2026-09-22 17:39:03 +00:00
parent 6a1c894ff7
commit 56610fa836
7 changed files with 497 additions and 44 deletions
+92 -8
View File
@@ -110,6 +110,24 @@ tpl.filters["safe_html"] = safe_html_filter
tpl.filters["dt"] = dt_filter
def _archive_href(year: str = "", month: str = "", nodate: bool = False, page: int = 1) -> str:
"""URL ссылки архива: '/published' без параметров или с query (страница 1 не пишется)."""
parts = []
if year:
parts.append(f"year={year}")
if month:
parts.append(f"month={month}")
if nodate:
parts.append("nodate=1")
if page and page > 1:
parts.append(f"page={page}")
qs = "&".join(parts)
return f"/published?{qs}" if qs else "/published"
tpl.globals["_archive_href"] = _archive_href
def _db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
@@ -136,6 +154,41 @@ def _migrate():
_migrate()
# --- Архив опубликованных: дерево навигации по годам/месяцам ---
MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек")
ARCHIVE_PAGE_SIZE = 10
def _archive_tree(conn: sqlite3.Connection) -> dict:
"""Дерево навигации архива: {years: [{year, months:[{m, label, n}], n}], no_date: n}.
Строится только из существующих периодов (GROUP BY); пустые месяцы отсутствуют —
как в Hugo-архивах, где дерево порождается постами, а не календарём."""
rows = conn.execute(
"""SELECT strftime('%Y', published_at) y, strftime('%m', published_at) m,
COUNT(*) n
FROM posts WHERE status='published'
AND published_at IS NOT NULL AND published_at != ''
GROUP BY y, m ORDER BY y DESC, m DESC"""
).fetchall()
years, cur = [], None
for r in rows:
if cur is None or cur["year"] != r["y"]:
cur = {"year": r["y"], "months": [], "n": 0}
years.append(cur)
cur["months"].append({
"m": r["m"],
"label": f"{MONTH_NAMES_RU[int(r['m'])]}",
"n": r["n"],
})
cur["n"] += r["n"]
no_date = conn.execute(
"""SELECT COUNT(*) FROM posts
WHERE status='published' AND (published_at IS NULL OR published_at='')"""
).fetchone()[0]
return {"years": years, "no_date": no_date}
def _session_ok(request: Request) -> bool:
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
@@ -733,19 +786,50 @@ def post_translate(post_id: int, request: Request,
@app.get("/published", response_class=HTMLResponse)
def published(request: Request):
def published(request: Request, year: str = "", month: str = "", nodate: str = "", page: int = 1):
"""Архив опубликованных: дерево навигации по годам/месяцам (правая колонка),
посты за выбранный период, пагинация по 10."""
_require_auth(request)
page = max(1, int(page or 1))
conn = _db()
tree = _archive_tree(conn)
filters, params = [], []
filters.append("p.status='published'")
if nodate:
filters.append("(p.published_at IS NULL OR p.published_at='')")
else:
filters.append("(p.published_at IS NOT NULL AND p.published_at != '')")
if year:
filters.append("strftime('%Y', p.published_at) = ?")
params.append(year)
if month:
filters.append("strftime('%m', p.published_at) = ?")
params.append(month)
where = " AND ".join(filters)
total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {where}", params).fetchone()[0]
pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE)
page = min(page, pages)
rows = conn.execute(
"""SELECT p.*, s.name source_name,
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
FROM posts p
LEFT JOIN sources s ON s.id=p.source_id
LEFT JOIN published pub ON pub.post_id=p.id
WHERE p.status='published' ORDER BY p.published_at DESC LIMIT 100"""
f"""SELECT p.*, s.name source_name,
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
FROM posts p
LEFT JOIN sources s ON s.id=p.source_id
LEFT JOIN (SELECT post_id, MAX(id) mid FROM published GROUP BY post_id) lastpub
ON lastpub.post_id=p.id
LEFT JOIN published pub ON pub.id=lastpub.mid
WHERE {where}
ORDER BY p.published_at DESC LIMIT ? OFFSET ?""",
params + [ARCHIVE_PAGE_SIZE, (page - 1) * ARCHIVE_PAGE_SIZE]
).fetchall()
conn.close()
html = tpl.get_template("published.html").render(posts=rows)
html = tpl.get_template("published.html").render(
posts=rows, tree=tree, year=year, month=month, nodate=bool(nodate),
page=page, pages=pages, total=total,
)
return HTMLResponse(html)
+130 -36
View File
@@ -1,43 +1,137 @@
{% extends "base.html" %}
{% block title %}Опубликовано{% endblock %}
{% block title %}Архив — Опубликовано{% endblock %}
{% block content %}
<h4 class="mb-3">Опубликованные посты</h4>
{% for p in posts %}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between">
<span class="badge bg-secondary">#{{ p.id }} · {{ p.direction or '?' }}</span>
<small class="text-muted">{{ p.source_name }} · {{ p.published_at | dt }}</small>
</div>
<div class="post-text mt-2">{{ (p.summary or p.text or '')[:2000] | markdown }}</div>
{% if p.media_path %}
{% set media_src = '/media/' ~ p.media_path.split('/')[-1] %}
{% set media_ext = p.media_path.lower().rsplit('.', 1)[-1] if '.' in p.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:180px" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:180px"></video>
{% endif %}
<div class="row">
<!-- Левая колонка: список постов за выбранный период -->
<div class="col-lg-9">
<h4 class="mb-3">
Опубликованные посты
{% set filter_label = '' %}
{% if nodate %}
{% set filter_label = 'Без даты' %}
{% elif year and month %}
{% set filter_label = year ~ '-' ~ month %}
{% elif year %}
{% set filter_label = year %}
{% endif %}
{% if p.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
<small class="text-muted">👁 {{ p.pub_views or p.views or 0 }}</small>
{% if p.distributed_dirs %}
<div class="mt-2">
<small class="text-muted">Разослан в:</small>
{% for d in (p.distributed_dirs | from_json) %}
<span class="badge bg-info text-dark">@dedinit_vesti_{{ d }}_ru_bot</span>
{% endfor %}
{% if filter_label %}
<span class="badge bg-secondary ms-2">{{ filter_label }}</span>
{% endif %}
<small class="text-muted fs-6 fw-normal">({{ total }})</small>
</h4>
{% for p in posts %}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between">
<span class="badge bg-secondary">#{{ p.id }} · {{ p.direction or '?' }}</span>
<small class="text-muted">{{ p.source_name }} · {{ p.published_at | dt }}</small>
</div>
<div class="post-text mt-2">{{ (p.summary or p.text or '')[:2000] | markdown }}</div>
{% if p.media_path %}
{% set media_src = '/media/' ~ p.media_path.split('/')[-1] %}
{% set media_ext = p.media_path.lower().rsplit('.', 1)[-1] if '.' in p.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:180px" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:180px"></video>
{% endif %}
{% endif %}
{% if p.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
<small class="text-muted">👁 {{ p.pub_views or p.views or 0 }}</small>
{% if p.distributed_dirs %}
<div class="mt-2">
<small class="text-muted">Разослан в:</small>
{% for d in (p.distributed_dirs | from_json) %}
<span class="badge bg-info text-dark">@dedinit_vesti_{{ d }}_ru_bot</span>
{% endfor %}
</div>
{% endif %}
{% if p.bundle_path %}
<a class="btn btn-sm btn-outline-primary ms-2" href="/bundle/{{ p.bundle_path }}" target="_blank">📄 Бандл</a>
{% endif %}
{% if p.tg_message_id %}<span class="badge bg-light text-dark ms-2">msg #{{ p.tg_message_id }}</span>{% endif %}
</div>
{% endif %}
{% if p.bundle_path %}
<a class="btn btn-sm btn-outline-primary ms-2" href="/bundle/{{ p.bundle_path }}" target="_blank">📄 Бандл</a>
{% endif %}
{% if p.tg_message_id %}<span class="badge bg-light text-dark ms-2">msg #{{ p.tg_message_id }}</span>{% endif %}
</div>
{% else %}
<div class="alert alert-info">Нет постов за этот период</div>
{% endfor %}
{% if pages > 1 %}
<nav aria-label="Пагинация архива">
<ul class="pagination justify-content-center">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="{{ _archive_href(year, month, nodate, page-1) }}" aria-label="Назад">&laquo;</a>
</li>
{% set from = [1, page - 3] | max %}
{% set to = [pages, page + 3] | min %}
{% if from > 1 %}
<li class="page-item"><a class="page-link" href="{{ _archive_href(year, month, nodate, 1) }}">1</a></li>
{% if from > 2 %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
{% endif %}
{% for n in range(from, to + 1) %}
<li class="page-item {% if n == page %}active{% endif %}">
<a class="page-link" href="{{ _archive_href(year, month, nodate, n) }}">{{ n }}</a>
</li>
{% endfor %}
{% if to < pages %}
{% if to < pages - 1 %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
<li class="page-item"><a class="page-link" href="{{ _archive_href(year, month, nodate, pages) }}">{{ pages }}</a></li>
{% endif %}
<li class="page-item {% if page >= pages %}disabled{% endif %}">
<a class="page-link" href="{{ _archive_href(year, month, nodate, page+1) }}" aria-label="Вперёд">&raquo;</a>
</li>
</ul>
</nav>
{% endif %}
</div>
<!-- Правая колонка: дерево навигации по годам/месяцам -->
<div class="col-lg-3">
<!-- На мобильных — сворачивается под кнопку -->
<button class="btn btn-outline-secondary d-lg-none w-100 mb-2" type="button"
data-bs-toggle="collapse" data-bs-target="#archive-tree" aria-expanded="false" aria-controls="archive-tree">
🗂 Архив
</button>
<div id="archive-tree" class="collapse d-lg-block position-sticky" style="top: 5rem;">
<div class="card shadow-sm">
<div class="card-body py-3">
<h6 class="card-title mb-2">🗂 Архив</h6>
<div class="list-group list-group-flush">
<a href="/published"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if not year and not month and not nodate %}active{% endif %}">
Все посты
<span class="badge rounded-pill {% if not year and not month and not nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ tree.years | sum(attribute='n') + tree.no_date }}</span>
</a>
{% for y in tree.years %}
<a href="/published?year={{ y.year }}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if year == y.year and not month and not nodate %}active{% endif %}">
{{ y.year }}
<span class="badge rounded-pill {% if year == y.year and not month and not nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ y.n }}</span>
</a>
{% if year == y.year %}
{% for m in y.months %}
<a href="/published?year={{ y.year }}&month={{ m.m }}"
class="list-group-item list-group-item-action ps-4 d-flex justify-content-between align-items-center {% if month == m.m and not nodate %}active{% endif %}">
{{ m.label }}
<span class="badge rounded-pill {% if month == m.m and not nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ m.n }}</span>
</a>
{% endfor %}
{% endif %}
{% endfor %}
{% if tree.no_date > 0 %}
<a href="/published?nodate=1"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if nodate %}active{% endif %}">
Без даты
<span class="badge rounded-pill {% if nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ tree.no_date }}</span>
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">Пока ничего не опубликовано</div>
{% endfor %}
</div>
{% endblock %}