feat: HTMX articles list with filter, search, pagination, and view links

- Add _get_article_list() with filters: status, template, language, search
- Add _get_article_stats() for header stats strip (total/live/scheduled/draft)
- Add /articles/results HTMX partial endpoint
- Add filter bar: search input + status/template/language dropdowns
- Paginate at 50 articles per page
- Add "View" link to live articles (opens public URL in new tab)
- Remove URL column (redundant), add Language column

Subtasks 2+3 of CMS admin improvement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 01:14:40 +01:00
parent 566c578770
commit 0c69d9e1a0
3 changed files with 233 additions and 66 deletions

View File

@@ -1581,19 +1581,112 @@ async def scenario_pdf(scenario_id: int):
# Article Management # Article Management
# ============================================================================= # =============================================================================
async def _get_article_list(
status: str = None,
template_slug: str = None,
language: str = None,
search: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
"""Get articles with optional filters and pagination."""
wheres = ["1=1"]
params: list = []
if status == "live":
wheres.append("status = 'published' AND published_at <= datetime('now')")
elif status == "scheduled":
wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft":
wheres.append("status = 'draft'")
if template_slug:
wheres.append("template_slug = ?")
params.append(template_slug)
if language:
wheres.append("language = ?")
params.append(language)
if search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
where = " AND ".join(wheres)
offset = (page - 1) * per_page
params.extend([per_page, offset])
return await fetch_all(
f"""SELECT *,
CASE WHEN status = 'published' AND published_at > datetime('now')
THEN 'scheduled'
WHEN status = 'published' THEN 'live'
ELSE status END AS display_status
FROM articles WHERE {where}
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
tuple(params),
)
async def _get_article_stats() -> dict:
"""Get aggregate article stats for the admin list header."""
row = await fetch_one(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END) AS live,
SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END) AS scheduled,
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft
FROM articles"""
)
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
@bp.route("/articles") @bp.route("/articles")
@role_required("admin") @role_required("admin")
async def articles(): async def articles():
"""List all articles.""" """List all articles with filters."""
article_list = await fetch_all( search = request.args.get("search", "").strip()
"""SELECT *, status_filter = request.args.get("status", "")
CASE WHEN status = 'published' AND published_at > datetime('now') template_filter = request.args.get("template", "")
THEN 'scheduled' language_filter = request.args.get("language", "")
WHEN status = 'published' THEN 'live' page = max(1, int(request.args.get("page", "1") or "1"))
ELSE status END AS display_status
FROM articles ORDER BY created_at DESC""" article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page,
)
stats = await _get_article_stats()
templates = await fetch_all(
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
)
return await render_template(
"admin/articles.html",
articles=article_list,
stats=stats,
template_slugs=[t["template_slug"] for t in templates],
current_search=search,
current_status=status_filter,
current_template=template_filter,
current_language=language_filter,
page=page,
)
@bp.route("/articles/results")
@role_required("admin")
async def article_results():
"""HTMX partial for filtered article results."""
search = request.args.get("search", "").strip()
status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "")
page = max(1, int(request.args.get("page", "1") or "1"))
article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page,
)
return await render_template(
"admin/partials/article_results.html", articles=article_list, page=page,
) )
return await render_template("admin/articles.html", articles=article_list)
@bp.route("/articles/new", methods=["GET", "POST"]) @bp.route("/articles/new", methods=["GET", "POST"])

View File

@@ -4,71 +4,72 @@
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %} {% block admin_content %}
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-2xl">Articles</h1> <h1 class="text-2xl">Articles</h1>
<p class="text-slate text-sm">{{ articles | length }} article{{ 's' if articles | length != 1 }}</p> <p class="text-sm text-slate mt-1">
{{ stats.total }} total
&middot; {{ stats.live }} live
&middot; {{ stats.scheduled }} scheduled
&middot; {{ stats.draft }} draft
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('admin.article_new') }}" class="btn">New Article</a> <a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display: inline;"> <form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" onclick="return confirm('Rebuild all articles?')">Rebuild All</button> <button type="submit" class="btn-outline btn-sm" onclick="return confirm('Rebuild all articles?')">Rebuild All</button>
</form> </form>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div> </div>
</header> </header>
<div class="card"> {# Filters #}
{% if articles %} <div class="card mb-6" style="padding:1rem 1.25rem">
<table class="table"> <form class="flex flex-wrap gap-3 items-end"
<thead> hx-get="{{ url_for('admin.article_results') }}"
<tr> hx-target="#article-results"
<th>Title</th> hx-trigger="change, input delay:300ms from:find input">
<th>URL</th> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<th>Status</th>
<th>Published</th> <div>
<th>Source</th> <label class="text-xs font-semibold text-slate block mb-1">Search</label>
<th></th> <input type="text" name="search" value="{{ current_search }}" placeholder="Title..."
</tr> class="form-input" style="min-width:180px">
</thead> </div>
<tbody>
{% for a in articles %} <div>
<tr> <label class="text-xs font-semibold text-slate block mb-1">Status</label>
<td>{{ a.title }}</td> <select name="status" class="form-input" style="min-width:120px">
<td class="mono text-sm">{{ a.url_path }}</td> <option value="">All</option>
<td> <option value="live" {% if current_status == 'live' %}selected{% endif %}>Live</option>
{% if a.display_status == 'live' %} <option value="scheduled" {% if current_status == 'scheduled' %}selected{% endif %}>Scheduled</option>
<span class="badge-success">Published</span> <option value="draft" {% if current_status == 'draft' %}selected{% endif %}>Draft</option>
{% elif a.display_status == 'scheduled' %} </select>
<span class="badge-warning">Scheduled</span> </div>
{% else %}
<span class="badge">Draft</span> <div>
{% endif %} <label class="text-xs font-semibold text-slate block mb-1">Template</label>
</td> <select name="template" class="form-input" style="min-width:140px">
<td class="mono text-sm">{{ a.published_at[:10] if a.published_at else '-' }}</td> <option value="">All</option>
<td class="text-sm">{% if a.template_slug %}{{ a.template_slug }}{% else %}Manual{% endif %}</td> {% for t in template_slugs %}
<td class="text-right" style="white-space: nowrap;"> <option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
<form method="post" action="{{ url_for('admin.article_publish', article_id=a.id) }}" class="m-0" style="display: inline;"> {% endfor %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> </select>
<button type="submit" class="btn-outline btn-sm">{% if a.status == 'published' %}Unpublish{% else %}Publish{% endif %}</button> </div>
</form>
<a href="{{ url_for('admin.article_edit', article_id=a.id) }}" class="btn-outline btn-sm">Edit</a> <div>
<form method="post" action="{{ url_for('admin.article_rebuild', article_id=a.id) }}" class="m-0" style="display: inline;"> <label class="text-xs font-semibold text-slate block mb-1">Language</label>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <select name="language" class="form-input" style="min-width:80px">
<button type="submit" class="btn-outline btn-sm">Rebuild</button> <option value="">All</option>
</form> <option value="en" {% if current_language == 'en' %}selected{% endif %}>EN</option>
<form method="post" action="{{ url_for('admin.article_delete', article_id=a.id) }}" class="m-0" style="display: inline;"> <option value="de" {% if current_language == 'de' %}selected{% endif %}>DE</option>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> </select>
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this article?')">Delete</button> </div>
</form> </form>
</td> </div>
</tr>
{% endfor %} {# Results #}
</tbody> <div id="article-results">
</table> {% include "admin/partials/article_results.html" %}
{% else %}
<p class="text-slate text-sm">No articles yet. Create one or generate from a template.</p>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,73 @@
{% if articles %}
<div class="card">
<table class="table text-sm">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Published</th>
<th>Lang</th>
<th>Template</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in articles %}
<tr>
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"
title="{{ a.url_path }}">{{ a.title }}</td>
<td>
{% if a.display_status == 'live' %}
<span class="badge-success">Live</span>
{% elif a.display_status == 'scheduled' %}
<span class="badge-warning">Scheduled</span>
{% else %}
<span class="badge">Draft</span>
{% endif %}
</td>
<td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td>
<td>{{ a.language | upper if a.language else '-' }}</td>
<td class="text-slate">{{ a.template_slug or 'Manual' }}</td>
<td class="text-right" style="white-space:nowrap">
{% if a.display_status == 'live' %}
<a href="/{{ a.language or 'en' }}{{ a.url_path }}" target="_blank" class="btn-outline btn-sm">View</a>
{% endif %}
<a href="{{ url_for('admin.article_edit', article_id=a.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.article_publish', article_id=a.id) }}" class="m-0" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">
{% if a.display_status != 'draft' %}Unpublish{% else %}Publish{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('admin.article_delete', article_id=a.id) }}" class="m-0" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if articles | length >= 50 %}
<div class="flex justify-between items-center" style="padding:0.75rem 1rem; border-top:1px solid #E2E8F0">
{% if page > 1 %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.article_results') }}?page={{ page - 1 }}"
hx-target="#article-results"
hx-include="closest form">Prev</button>
{% else %}
<span></span>
{% endif %}
<span class="text-xs text-slate">Page {{ page }}</span>
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.article_results') }}?page={{ page + 1 }}"
hx-target="#article-results"
hx-include="closest form">Next</button>
</div>
{% endif %}
</div>
{% else %}
<div class="card text-center" style="padding:2rem">
<p class="text-slate">No articles match the current filters.</p>
</div>
{% endif %}