feat(admin): bulk-editable article list with WP ID inline editing
- New /admin/article-list: paginated (50/page) table with thumbnail, title, excerpt (120 chars), status, scheduled date, and WP ID input - Sticky save bar with live change counter (JS tracks modified inputs, highlights changed cells in amber, disables save when nothing changed) - POST /admin/article-list/update: saves only changed WP IDs in one request; clears stale wp_post_url so WP-Sync repopulates it cleanly - Filter by status + free-text search (title or article ID) - Pagination with page/filter state preserved through save redirects - repositories: add list_articles_page() (offset + search) and bulk_update_wp_post_ids() - Dashboard nav: add Artikelliste link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2d02b56b65
commit
cdcf441daf
4 changed files with 384 additions and 0 deletions
|
|
@ -33,6 +33,8 @@ from .repositories import (
|
|||
get_article_by_id,
|
||||
get_feed_by_id,
|
||||
list_articles,
|
||||
list_articles_page,
|
||||
bulk_update_wp_post_ids,
|
||||
list_feeds,
|
||||
list_publish_jobs,
|
||||
list_runs,
|
||||
|
|
@ -931,6 +933,105 @@ def admin_transition_article(request: Request, article_id: int, target_status: s
|
|||
return _dashboard_redirect(msg=f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error")
|
||||
|
||||
|
||||
_PAGE_SIZE = 50
|
||||
|
||||
|
||||
@router.get("/admin/article-list", response_class=HTMLResponse)
|
||||
def admin_article_list(request: Request):
|
||||
"""Paginated article list with inline WP ID editing."""
|
||||
user = _admin_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/admin/login", status_code=303)
|
||||
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
status_filter = request.query_params.get("status_filter", "") or None
|
||||
search = request.query_params.get("search", "").strip() or None
|
||||
offset = (page - 1) * _PAGE_SIZE
|
||||
|
||||
articles, total = list_articles_page(
|
||||
limit=_PAGE_SIZE, offset=offset,
|
||||
status_filter=status_filter, search=search,
|
||||
)
|
||||
|
||||
# Enrich each article with thumbnail URL
|
||||
for a in articles:
|
||||
meta = _parse_meta_json(a.get("meta_json"))
|
||||
image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {}
|
||||
sel = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None
|
||||
if not sel:
|
||||
sel = (meta.get("extraction") or {}).get("image_selection", {}).get("primary")
|
||||
a["thumb_url"] = sel
|
||||
a["thumb_proxy"] = f"/admin/images/proxy?{urlencode({'url': sel})}" if sel else None
|
||||
raw = (a.get("content_raw") or a.get("summary") or "").strip()
|
||||
a["excerpt"] = raw[:120] + "…" if len(raw) > 120 else raw
|
||||
|
||||
total_pages = max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin_article_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"title": "Artikelliste",
|
||||
"user": user,
|
||||
"articles": articles,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"total": total,
|
||||
"page_size": _PAGE_SIZE,
|
||||
"status_filter": status_filter or "",
|
||||
"search": search or "",
|
||||
"flash_msg": request.query_params.get("msg", ""),
|
||||
"flash_type": request.query_params.get("type", "success"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/article-list/update")
|
||||
async def admin_article_list_update(request: Request):
|
||||
"""Bulk update WP post IDs from the article list form."""
|
||||
user = _admin_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/admin/login", status_code=303)
|
||||
|
||||
form = await request.form()
|
||||
updates: list[tuple[int, int | None]] = []
|
||||
|
||||
# Form fields: wp_<article_id> = new value, orig_<article_id> = original value
|
||||
for key, new_val in form.items():
|
||||
if not key.startswith("wp_"):
|
||||
continue
|
||||
try:
|
||||
article_id = int(key[3:])
|
||||
except ValueError:
|
||||
continue
|
||||
orig_val = str(form.get(f"orig_{article_id}", "")).strip()
|
||||
new_val_s = str(new_val).strip()
|
||||
if new_val_s == orig_val:
|
||||
continue # unchanged
|
||||
new_wp_id = int(new_val_s) if new_val_s else None
|
||||
updates.append((article_id, new_wp_id))
|
||||
|
||||
if updates:
|
||||
count = bulk_update_wp_post_ids(updates)
|
||||
msg = f"{count} WP-ID(s) aktualisiert. Bitte jetzt WP-Sync ausführen um Slots & URLs zu aktualisieren."
|
||||
msg_type = "success"
|
||||
else:
|
||||
msg = "Keine Änderungen erkannt."
|
||||
msg_type = "success"
|
||||
|
||||
# Preserve pagination/filter params from referer
|
||||
page = form.get("page", "1")
|
||||
status_filter = form.get("status_filter", "")
|
||||
search = form.get("search", "")
|
||||
qs: dict[str, str] = {"msg": msg, "type": msg_type, "page": page}
|
||||
if status_filter:
|
||||
qs["status_filter"] = status_filter
|
||||
if search:
|
||||
qs["search"] = search
|
||||
return RedirectResponse(url=f"/admin/article-list?{urlencode(qs)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/wp-sync")
|
||||
def admin_wp_sync(request: Request):
|
||||
"""Sync scheduled_publish_at and WP references in the DB from WordPress."""
|
||||
|
|
|
|||
|
|
@ -757,6 +757,67 @@ def upsert_article(payload: ArticleUpsert) -> int:
|
|||
return int(existing_id) if existing_id else 0
|
||||
|
||||
|
||||
def list_articles_page(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return (articles, total_count) with optional status filter and title search."""
|
||||
safe_limit = max(1, min(limit, 200))
|
||||
safe_offset = max(0, offset)
|
||||
|
||||
conditions: list[str] = []
|
||||
params: list[Any] = []
|
||||
if status_filter:
|
||||
conditions.append("a.status = ?")
|
||||
params.append(status_filter)
|
||||
if search:
|
||||
conditions.append("(a.title LIKE ? OR a.id = ?)")
|
||||
try:
|
||||
params.extend([f"%{search}%", int(search)])
|
||||
except ValueError:
|
||||
params.extend([f"%{search}%", -1])
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
select = """
|
||||
SELECT a.id, a.title, a.status, a.published_at, a.summary, a.content_raw,
|
||||
a.meta_json, a.wp_post_id, a.wp_post_url, a.scheduled_publish_at,
|
||||
a.word_count, f.name AS feed_name
|
||||
FROM articles a
|
||||
LEFT JOIN feeds f ON f.id = a.feed_id
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) FROM articles a {where}", params
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"{select} {where} ORDER BY a.id DESC LIMIT ? OFFSET ?",
|
||||
params + [safe_limit, safe_offset],
|
||||
).fetchall()
|
||||
return rows_to_dicts(rows), total
|
||||
|
||||
|
||||
def bulk_update_wp_post_ids(updates: list[tuple[int, int | None]]) -> int:
|
||||
"""Update wp_post_id (and clear stale wp_post_url) for multiple articles.
|
||||
|
||||
Returns the number of rows actually updated.
|
||||
Call sync_db_from_wordpress() afterwards to repopulate wp_post_url and
|
||||
scheduled_publish_at from the live WordPress data.
|
||||
"""
|
||||
if not updates:
|
||||
return 0
|
||||
updated = 0
|
||||
with get_conn() as conn:
|
||||
for article_id, new_wp_id in updates:
|
||||
conn.execute(
|
||||
"UPDATE articles SET wp_post_id = ?, wp_post_url = NULL WHERE id = ?",
|
||||
(new_wp_id, article_id),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def list_articles(limit: int = 100, status_filter: str | None = None) -> list[dict[str, Any]]:
|
||||
safe_limit = max(1, min(limit, 500))
|
||||
with get_conn() as conn:
|
||||
|
|
|
|||
221
backend/templates/admin_article_list.html
Normal file
221
backend/templates/admin_article_list.html
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="/admin/static/admin.css" />
|
||||
<style>
|
||||
.al-table { width: 100%; border-collapse: collapse; }
|
||||
.al-table th, .al-table td { padding: 8px 10px; border-bottom: 1px solid #e5e7eb; vertical-align: middle; }
|
||||
.al-table th { background: #f3f4f6; font-size: 0.85em; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.al-table tr:hover td { background: #fafafa; }
|
||||
.al-thumb { width: 72px; height: 52px; object-fit: cover; border-radius: 4px; display: block; }
|
||||
.al-thumb-placeholder { width: 72px; height: 52px; background: #e5e7eb; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #9ca3af; font-size: 1.4em; }
|
||||
.al-title { font-weight: 600; font-size: 0.95em; }
|
||||
.al-excerpt { font-size: 0.82em; color: #6b7280; margin-top: 3px; }
|
||||
.wp-id-input { width: 90px; font-family: monospace; font-size: 0.9em; padding: 4px 6px; border: 1px solid #d1d5db; border-radius: 4px; }
|
||||
.wp-id-input.changed { border-color: #f59e0b; background: #fffbeb; font-weight: bold; }
|
||||
.wp-link { font-size: 0.8em; margin-top: 3px; display: block; }
|
||||
.sticky-bar { position: sticky; top: 0; z-index: 100; background: #1e3a5f; color: #fff; padding: 10px 20px; display: flex; align-items: center; gap: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,.2); }
|
||||
.sticky-bar button { background: #f59e0b; color: #000; border: none; padding: 8px 20px; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 0.95em; }
|
||||
.sticky-bar button:disabled { background: #9ca3af; color: #fff; cursor: default; }
|
||||
.change-badge { background: #f59e0b; color: #000; border-radius: 12px; padding: 2px 10px; font-weight: bold; font-size: 0.85em; display: none; }
|
||||
.change-badge.visible { display: inline; }
|
||||
.filter-bar { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.filter-bar label { font-size: 0.85em; color: #6b7280; display: block; margin-bottom: 3px; }
|
||||
.filter-bar input, .filter-bar select { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.9em; }
|
||||
.pagination { display: flex; gap: 8px; align-items: center; justify-content: center; margin-top: 1.5rem; flex-wrap: wrap; }
|
||||
.pagination a, .pagination span { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.9em; text-decoration: none; color: #374151; }
|
||||
.pagination .current { background: #1e3a5f; color: #fff; border-color: #1e3a5f; font-weight: bold; }
|
||||
.pagination a:hover { background: #f3f4f6; }
|
||||
.badge-sm { padding: 2px 7px; border-radius: 10px; font-size: 0.75em; font-weight: 600; }
|
||||
.badge-new { background: #dbeafe; color: #1e40af; }
|
||||
.badge-approved { background: #d1fae5; color: #065f46; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
.badge-published { background: #ede9fe; color: #5b21b6; }
|
||||
.badge-review { background: #fef3c7; color: #92400e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Artikelliste</h1>
|
||||
<p>Angemeldet als <strong>{{ user }}</strong></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
|
||||
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="secondary">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% if flash_msg %}
|
||||
<section class="card flash {{ 'flash-error' if flash_type == 'error' else 'flash-success' }}">
|
||||
{{ flash_msg }}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filter bar (outside main form so it doesn't submit with bulk save) -->
|
||||
<section class="card" style="padding-bottom: 0.5rem;">
|
||||
<form method="get" action="/admin/article-list">
|
||||
<div class="filter-bar">
|
||||
<div>
|
||||
<label>Suche (Titel / ID)</label>
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="z.B. Camping …" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Status</label>
|
||||
<select name="status_filter">
|
||||
<option value="">Alle</option>
|
||||
{% for s in ["new","review","approved","published","error","no_image"] %}
|
||||
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div style="padding-bottom:1px;">
|
||||
<button type="submit">Filtern</button>
|
||||
<a href="/admin/article-list" class="linkbtn" style="margin-left:4px;">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<p class="subtle" style="margin: 4px 0 0;">{{ total }} Artikel gesamt · Seite {{ page }} / {{ total_pages }} · {{ page_size }} pro Seite</p>
|
||||
</section>
|
||||
|
||||
<!-- Main form for bulk WP ID editing -->
|
||||
<form method="post" action="/admin/article-list/update" id="bulk-form">
|
||||
<!-- Pass filter/page state so redirect goes back to same view -->
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
<input type="hidden" name="status_filter" value="{{ status_filter }}">
|
||||
<input type="hidden" name="search" value="{{ search }}">
|
||||
|
||||
<div class="sticky-bar">
|
||||
<button type="submit" id="save-btn" disabled>💾 Änderungen speichern</button>
|
||||
<span class="change-badge" id="change-badge">0 Änderungen</span>
|
||||
<span style="font-size:0.85em;opacity:.8;">Nur geänderte WP-IDs werden gespeichert. Danach WP-Sync ausführen.</span>
|
||||
</div>
|
||||
|
||||
<section class="card" style="padding: 0; overflow-x: auto;">
|
||||
<table class="al-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:80px;">Bild</th>
|
||||
<th>Titel & Kurztext</th>
|
||||
<th style="width:90px;">Status</th>
|
||||
<th style="width:110px;">Datum</th>
|
||||
<th style="width:140px;">WP ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in articles %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if a.thumb_proxy %}
|
||||
<a href="{{ a.thumb_url }}" target="_blank" rel="noopener">
|
||||
<img src="{{ a.thumb_proxy }}"
|
||||
class="al-thumb"
|
||||
alt="Vorschau"
|
||||
loading="lazy"
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" />
|
||||
<div class="al-thumb-placeholder" style="display:none;">🖼</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="al-thumb-placeholder">🖼</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="al-title">
|
||||
<a href="/admin/articles/{{ a.id }}">#{{ a.id }} {{ a.title }}</a>
|
||||
</div>
|
||||
{% if a.excerpt %}
|
||||
<div class="al-excerpt">{{ a.excerpt }}</div>
|
||||
{% endif %}
|
||||
{% if a.feed_name %}
|
||||
<div class="al-excerpt" style="margin-top:4px;">📡 {{ a.feed_name }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge-sm badge-{{ a.status }}">{{ a.status }}</span>
|
||||
</td>
|
||||
<td style="font-size:0.82em;">
|
||||
{% if a.scheduled_publish_at %}
|
||||
📅 {{ a.scheduled_publish_at[:16] }}
|
||||
{% elif a.published_at %}
|
||||
{{ a.published_at[:10] }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<!-- Hidden original value for change detection -->
|
||||
<input type="hidden" name="orig_{{ a.id }}" value="{{ a.wp_post_id or '' }}">
|
||||
<input
|
||||
type="text"
|
||||
name="wp_{{ a.id }}"
|
||||
value="{{ a.wp_post_id or '' }}"
|
||||
data-orig="{{ a.wp_post_id or '' }}"
|
||||
class="wp-id-input"
|
||||
placeholder="—"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
/>
|
||||
{% if a.wp_post_url %}
|
||||
<a href="{{ a.wp_post_url }}" target="_blank" rel="noopener" class="wp-link">↗ WP öffnen</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<!-- Pagination (outside form) -->
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="?page=1&status_filter={{ status_filter }}&search={{ search }}">«</a>
|
||||
<a href="?page={{ page - 1 }}&status_filter={{ status_filter }}&search={{ search }}">‹ Zurück</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range([1, page - 2]|max, [total_pages + 1, page + 3]|min) %}
|
||||
{% if p == page %}
|
||||
<span class="current">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="?page={{ p }}&status_filter={{ status_filter }}&search={{ search }}">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&status_filter={{ status_filter }}&search={{ search }}">Weiter ›</a>
|
||||
<a href="?page={{ total_pages }}&status_filter={{ status_filter }}&search={{ search }}">»</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const inputs = document.querySelectorAll('.wp-id-input');
|
||||
const btn = document.getElementById('save-btn');
|
||||
const badge = document.getElementById('change-badge');
|
||||
|
||||
function countChanges() {
|
||||
let n = 0;
|
||||
inputs.forEach(inp => {
|
||||
const changed = inp.value.trim() !== inp.dataset.orig.trim();
|
||||
inp.classList.toggle('changed', changed);
|
||||
if (changed) n++;
|
||||
});
|
||||
btn.disabled = n === 0;
|
||||
badge.textContent = n + (n === 1 ? ' Änderung' : ' Änderungen');
|
||||
badge.classList.toggle('visible', n > 0);
|
||||
}
|
||||
|
||||
inputs.forEach(inp => inp.addEventListener('input', countChanges));
|
||||
countChanges();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
<p>Angemeldet als <strong>{{ user }}</strong></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a class="linkbtn" href="/admin/article-list">Artikelliste</a>
|
||||
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
|
||||
<a class="linkbtn" href="/admin/connectivity">Connectivity Check</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue