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
|
# 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"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
· {{ stats.live }} live
|
||||||
|
· {{ stats.scheduled }} scheduled
|
||||||
|
· {{ 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 %}
|
||||||
|
|||||||
@@ -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