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
@@ -0,0 +1,124 @@
# Design — archive-navigation
## Контекст
- Роут: `GET /published` (`web/app.py:735`), шаблон `web/templates/published.html` (42 строки).
- Дата: `posts.published_at TEXT` — ISO 8601 с таймзоной (`2026-09-08T08:02:45+00:00`).
- Сейчас: `LIMIT 100` без пагинации, без фильтров.
- Фронтенд: локальный Bootstrap (`web/static/bootstrap.min.css`), без JS-фреймворков,
htmx запрещён (AGENT.MD). Паттерн фильтра по направлениям — query-параметры (`_cand_back`).
## 1. Хелпер дерева навигации
В `web/app.py`, рядом с `_db()`:
```python
MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек")
def _archive_tree(conn):
"""Дерево навигации архива: [{year, months:[{m, label, n}], n}] + {no_date:n}.
Строится только из существующих периодов; пустые месяцы отсутствуют."""
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'])]} {r['y']}",
"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}
```
- Сортировка: годы и месяцы — по убыванию (свежие сверху, Q9).
- Пустой период исключается самой группировкой `GROUP BY` — ровно как Hugo-архивы
(`site.RegularPages | groupBy "Date.Year"`), где календарь не итерируется (Q8).
## 2. Роут `GET /published`
```python
ARCHIVE_PAGE_SIZE = 10
@app.get("/published", response_class=HTMLResponse)
def published(request: Request, year: str = "", month: str = "", page: int = 1):
_require_auth(request)
page = max(1, page)
conn = _db()
tree = _archive_tree(conn)
where, params = ["p.status='published'"], []
if year:
where.append("strftime('%Y', p.published_at) = ?"); params.append(year)
if month:
where.append("strftime('%m', p.published_at) = ?"); params.append(month)
# год без месяца = все посты за год; "Без даты" не попадает в year/month
if not year and not month:
where.append("(p.published_at IS NOT NULL AND p.published_at != '')")
total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {' AND '.join(where)}",
params).fetchone()[0]
pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE)
page = min(page, pages)
rows = conn.execute(
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 published pub ON pub.post_id=p.id
WHERE {' AND '.join(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, tree=tree, year=year, month=month,
page=page, pages=pages, total=total,
)
return HTMLResponse(html)
```
- Блок «Без даты» показывается как ссылка `/?year=&month=`? Нет — отдельный
фильтр: кнопка «Без даты» (query `nodate=1`), тоже с деревом и пагинацией.
(Добавлено в spec ниже — сценарий «Без даты»).
## 3. Шаблон `published.html`
- Обёртка: `row` → `col-lg-9` (список) + `col-lg-3` (дерево, `position-sticky top-2`).
- Дерево:
- ссылка «📄 Все посты» → `/published` (активна, если нет year/month/nodate).
- по годам: `<h6>2026 ▾</h6>` + `<ul>` месяцев; месяц — ссылка
`/published?year=2026&month=09`, счётчик `(4)`. Активный месяц подсвечен
(`active-row`-стиль, как в кандидатах).
- на мобильных: дерево в `collapse` (кнопка «🗂 Архив» + `data-bs-toggle`).
- Список: карточки как сейчас, но посты только за выбранный период.
- Пагинация снизу: Bootstrap `pagination`, `page-item active` для текущей,
prev/next, номера 1..pages (с сохранением year/month/nodate).
- Пустой период: `alert-info` «Нет постов за этот период» (дерево всё равно видно).
## 4. Пагинация
- Bootstrap HTML, без JS: `?year=2026&month=09&page=N`.
- Текущая страница — `active`, prev/next — disabled на границах.
- total = число постов в периоде; pages = ceil(total/10).
## 5. Проверка
- `openspec validate archive-navigation` — чисто.
- `curl -s 'http://127.0.0.1:8400/published?year=2026&month=09'` — видно 4 поста.
- `curl -s 'http://127.0.0.1:8400/published?month=07'` — 2 поста.
- В HTML дерева нет месяца без постов (только 07, 08, 09) — «февраль отсутствует».
- `?page=1`..N — пагинация с номерами; вне диапазона — клампится к последней.
- Мобильная вёрстка: сужение окна → колонка сворачивается.