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