mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
feat(web): архив опубликованных — дерево навигации по годам/месяцам + пагинация (openspec archive-navigation)
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-20
|
||||||
@@ -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 — пагинация с номерами; вне диапазона — клампится к последней.
|
||||||
|
- Мобильная вёрстка: сужение окна → колонка сворачивается.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# archive-navigation
|
||||||
|
|
||||||
|
Страница «Опубликовано» (`/published`) — настоящий архив с навигацией по годам и месяцам.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Сейчас `/published` — просто плоский список последних 100 опубликованных постов
|
||||||
|
без какой-либо навигации. С ростом архива найти пост по дате невозможно: нет ни
|
||||||
|
периодов, ни фильтров, ни разбивки. Это противоречит назначению страницы —
|
||||||
|
«архив опубликованного».
|
||||||
|
|
||||||
|
Пользователь хочет классический архив: дерево лет/месяцев слева или справа,
|
||||||
|
посты — за выбранный период. Чтобы не занимать место вверху страницы
|
||||||
|
(навигация вверху слишком громоздкая), выбрана правая колонка (Q1–Q10,
|
||||||
|
grill-with-docs 2026-09-20, записано в AGENT.MD).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **web/app.py**, роут `GET /published`:
|
||||||
|
- принимает query-параметры `year` (YYYY), `month` (MM), `page` (по умолчанию 1).
|
||||||
|
- строит дерево навигации: `SELECT strftime('%Y', published_at) y, strftime('%m', published_at) m, COUNT(*) n FROM posts WHERE status='published' GROUP BY y, m` → группировка по годам, внутри — месяцы с количеством. Группа «Без даты» — посты с пустым `published_at` (характер: могут появиться; сейчас 0).
|
||||||
|
- фильтрует посты по выбранному периоду: `year` + `month` (все посты за год, если месяц не указан), либо «Все посты» (без параметров).
|
||||||
|
- пагинация по 10 постов (LIMIT 10 OFFSET).
|
||||||
|
- рендер в `published.html` с деревом навигации, списком постов, подсветкой активного периода.
|
||||||
|
- **web/templates/published.html**:
|
||||||
|
- правая колонка (Bootstrap `col-md-3` + sticky) — дерево годов/месяцев со счётчиками, ссылка «Все посты», группа «Без даты»; пустые периоды не выводятся.
|
||||||
|
- слева — список постов за выбранный период + Bootstrap pagination по 10 (prev/next + номера).
|
||||||
|
- на мобильных — колонка сворачивается (Bootstrap collapse).
|
||||||
|
- **web/app.py**: хелпер построения дерева (чистая функция, тестируемая).
|
||||||
|
- Обновить `STATUS.md`, `CONTEXT.md` (глоссарий уже добавлен).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Откат: `git checkout -- web/app.py web/templates/published.html` (change фиксирует
|
||||||
|
состояние до правок), рестарт vesti-web.
|
||||||
|
|
||||||
|
## Затронутые сервисы/порты
|
||||||
|
|
||||||
|
- vesti-web (:8400), внешний https://vesti.nixg.ru. БД SQLite — только чтение
|
||||||
|
через существующий `_db()` (WAL + busy_timeout).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Spec — archive-navigation
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Архив с навигацией по годам и месяцам
|
||||||
|
|
||||||
|
Страница «Опубликовано» (`/published`) показывает опубликованные посты,
|
||||||
|
сгруппированные по времени публикации, с навигацией по годам и месяцам
|
||||||
|
в правой колонке. Посты показываются за выбранный период (месяц, год или все).
|
||||||
|
|
||||||
|
#### Scenario: Открытие архива без параметров
|
||||||
|
|
||||||
|
Given модератор открывает `/published` без параметров
|
||||||
|
Then показываются опубликованные посты (страница 1, по 10)
|
||||||
|
And в правой колонке — дерево навигации: годы, внутри — месяцы со счётчиками
|
||||||
|
And дерево содержит только периоды, в которых есть посты
|
||||||
|
And сверху дерева есть ссылка «Все посты»
|
||||||
|
|
||||||
|
#### Scenario: Выбор месяца
|
||||||
|
|
||||||
|
Given в дереве навигации есть месяц «Сен 2026» (4 поста)
|
||||||
|
When модератор кликает «Сен 2026»
|
||||||
|
Then показываются только посты за сентябрь 2026 (4 поста)
|
||||||
|
And месяц подсвечен в дереве как активный
|
||||||
|
|
||||||
|
#### Scenario: Выбор года (без месяца)
|
||||||
|
|
||||||
|
Given модератор на странице архива
|
||||||
|
When модератор кликает на год «2026»
|
||||||
|
Then показываются все посты за 2026 год целиком (без разбивки по месяцам)
|
||||||
|
And год подсвечен в дереве как активный
|
||||||
|
|
||||||
|
#### Scenario: Пустой период не появляется в навигации
|
||||||
|
|
||||||
|
Given в архиве нет постов за февраль 2026
|
||||||
|
When модератор смотрит дерево навигации
|
||||||
|
Then в дереве нет ссылки/узла «Фев 2026»
|
||||||
|
(Периоды в дереве порождаются только постами, как в Hugo-архивах)
|
||||||
|
|
||||||
|
### Requirement: Пагинация по 10 постов
|
||||||
|
|
||||||
|
Список постов на странице архива разбивается постранично, по 10 постов
|
||||||
|
на страницу. Навигация — Bootstrap-пагинация с номерами и prev/next,
|
||||||
|
сохраняющая выбранный период.
|
||||||
|
|
||||||
|
#### Scenario: Переход на следующую страницу
|
||||||
|
|
||||||
|
Given в выбранном периоде 25 постов (3 страницы)
|
||||||
|
When модератор кликает «2» в пагинации
|
||||||
|
Then показываются посты 11–20 за тот же период
|
||||||
|
And адрес содержит `page=2` и сохранённые `year`/`month`
|
||||||
|
|
||||||
|
#### Scenario: Пагинация за пределами диапазона
|
||||||
|
|
||||||
|
Given в периоде 1 страница постов
|
||||||
|
When модератор запрашивает `page=5`
|
||||||
|
Then показывается последняя (единственная) страница, без ошибки
|
||||||
|
|
||||||
|
### Requirement: Группа «Без даты»
|
||||||
|
|
||||||
|
Посты со статусом «опубликован», у которых не заполнена дата публикации,
|
||||||
|
не теряются: они показываются в отдельной группе «Без даты» в дереве
|
||||||
|
навигации и по клику — список этих постов.
|
||||||
|
|
||||||
|
#### Scenario: Пост без даты виден в архиве
|
||||||
|
|
||||||
|
Given существует опубликованный пост с пустым `published_at`
|
||||||
|
When модератор открывает `/published`
|
||||||
|
Then в дереве навигации есть ссылка «Без даты»
|
||||||
|
And по клику показываются посты без даты (с пагинацией)
|
||||||
|
|
||||||
|
### Requirement: Правая колонка навигации
|
||||||
|
|
||||||
|
Дерево навигации располагается в правой колонке страницы и остаётся
|
||||||
|
видимым при прокрутке (position-sticky). На узких экранах колонка
|
||||||
|
сворачивается под кнопку «🗂 Архив» (Bootstrap collapse).
|
||||||
|
|
||||||
|
#### Scenario: Правая колонка при прокрутке
|
||||||
|
|
||||||
|
Given модератор прокручивает длинный список постов
|
||||||
|
Then дерево навигации остаётся видимым справа (sticky)
|
||||||
|
|
||||||
|
#### Scenario: Мобильный вид
|
||||||
|
|
||||||
|
Given модератор открывает архив на узком экране (< 768px)
|
||||||
|
Then дерево навигации свёрнуто под кнопку «🗂 Архив» и раскрывается по клику
|
||||||
|
|
||||||
|
### Requirement: Пустой период — заглушка
|
||||||
|
|
||||||
|
Если в выбранном периоде нет постов, показывается сообщение
|
||||||
|
«Нет постов за этот период», дерево навигации остаётся доступным.
|
||||||
|
|
||||||
|
#### Scenario: Запрос пустого периода напрямую
|
||||||
|
|
||||||
|
Given модератор открывает `/published?year=2025&month=01` (нет постов)
|
||||||
|
Then показывается заглушка «Нет постов за этот период»
|
||||||
|
And дерево навигации справа доступно для перехода
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Задачи
|
||||||
|
|
||||||
|
- [x] web/app.py: хелпер `_archive_tree(conn)` — дерево годов/месяцев GROUP BY + счётчики + «Без даты»
|
||||||
|
- [x] web/app.py: роут `GET /published` — параметры year/month/page, фильтр периода, пагинация по 10 (LIMIT/OFFSET, клампинг)
|
||||||
|
- [x] web/templates/published.html: правая колонка (col-lg-3 sticky) — дерево навигации, «Все посты», «Без даты»
|
||||||
|
- [x] web/templates/published.html: список постов за период + Bootstrap pagination (prev/next + номера)
|
||||||
|
- [x] web/templates/published.html: collapse на мобильных (кнопка «🗂 Архив»)
|
||||||
|
- [x] web/app.py: `_archive_q` + `tpl.globals["_published_q"]` — построение query-строки с сохранением фильтров
|
||||||
|
- [x] Тест: py_compile + рендер шаблона (дерево, пагинация, active-класс) через .venv
|
||||||
|
- [x] openspec validate archive-navigation — чисто (только стилевые SHALL/MUST warned, спека по-русски)
|
||||||
|
- [x] Рестарт vesti-web, проверка: /published (дерево 2026, 8 постов, без дублей), ?year=2026&month=09 (4), month=08 (2), 2025 (заглушка), пагинация скрыта при 1 стр, битые /published? — 0
|
||||||
|
- [ ] Обновить STATUS.md / TODO.md
|
||||||
+89
-5
@@ -110,6 +110,24 @@ tpl.filters["safe_html"] = safe_html_filter
|
|||||||
tpl.filters["dt"] = dt_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:
|
def _db() -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -136,6 +154,41 @@ def _migrate():
|
|||||||
_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:
|
def _session_ok(request: Request) -> bool:
|
||||||
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
|
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)
|
@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)
|
_require_auth(request)
|
||||||
|
page = max(1, int(page or 1))
|
||||||
conn = _db()
|
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(
|
rows = conn.execute(
|
||||||
"""SELECT p.*, s.name source_name,
|
f"""SELECT p.*, s.name source_name,
|
||||||
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN sources s ON s.id=p.source_id
|
LEFT JOIN sources s ON s.id=p.source_id
|
||||||
LEFT JOIN published pub ON pub.post_id=p.id
|
LEFT JOIN (SELECT post_id, MAX(id) mid FROM published GROUP BY post_id) lastpub
|
||||||
WHERE p.status='published' ORDER BY p.published_at DESC LIMIT 100"""
|
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()
|
).fetchall()
|
||||||
conn.close()
|
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)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Опубликовано{% endblock %}
|
{% block title %}Архив — Опубликовано{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-3">Опубликованные посты</h4>
|
<div class="row">
|
||||||
{% for p in posts %}
|
<!-- Левая колонка: список постов за выбранный период -->
|
||||||
|
<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 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 mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
@@ -37,7 +55,83 @@
|
|||||||
{% if p.tg_message_id %}<span class="badge bg-light text-dark ms-2">msg #{{ p.tg_message_id }}</span>{% endif %}
|
{% if p.tg_message_id %}<span class="badge bg-light text-dark ms-2">msg #{{ p.tg_message_id }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info">Пока ничего не опубликовано</div>
|
<div class="alert alert-info">Нет постов за этот период</div>
|
||||||
{% endfor %}
|
{% 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="Назад">«</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="Вперёд">»</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>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user