Files

6.2 KiB
Raw Permalink Blame History

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():

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

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 — пагинация с номерами; вне диапазона — клампится к последней.
  • Мобильная вёрстка: сужение окна → колонка сворачивается.