feat(admin): article type tabs + fix affiliate delete buttons

- Migration 0029: article_type column (cornerstone/editorial/generated)
- Tab bar on /admin/articles with per-type counts
- Template filter only on Generated tab; delete guard uses article_type
- Type dropdown in article_new/edit form
- Fix: affiliate program and product Delete buttons had missing text/tag
This commit is contained in:
Deeman
2026-03-07 13:50:44 +01:00
10 changed files with 150 additions and 44 deletions

View File

@@ -2255,13 +2255,14 @@ async def _sync_static_articles() -> None:
meta_description = fm.get("meta_description", "") meta_description = fm.get("meta_description", "")
template_slug = fm.get("template_slug") or None template_slug = fm.get("template_slug") or None
group_key = fm.get("cornerstone") or None group_key = fm.get("cornerstone") or None
article_type = "cornerstone" if fm.get("cornerstone") else "editorial"
now_iso = utcnow_iso() now_iso = utcnow_iso()
await execute( await execute(
"""INSERT INTO articles """INSERT INTO articles
(slug, title, url_path, language, meta_description, (slug, title, url_path, language, meta_description,
status, template_slug, group_key, created_at, updated_at) status, template_slug, group_key, article_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET ON CONFLICT(slug) DO UPDATE SET
title = excluded.title, title = excluded.title,
url_path = excluded.url_path, url_path = excluded.url_path,
@@ -2269,9 +2270,10 @@ async def _sync_static_articles() -> None:
meta_description = excluded.meta_description, meta_description = excluded.meta_description,
template_slug = excluded.template_slug, template_slug = excluded.template_slug,
group_key = excluded.group_key, group_key = excluded.group_key,
article_type = excluded.article_type,
updated_at = excluded.updated_at""", updated_at = excluded.updated_at""",
(slug, title, url_path, language, meta_description, (slug, title, url_path, language, meta_description,
template_slug, group_key, now_iso, now_iso), template_slug, group_key, article_type, now_iso, now_iso),
) )
# Build HTML so the article is immediately servable (cornerstones have no template) # Build HTML so the article is immediately servable (cornerstones have no template)
@@ -2292,12 +2294,9 @@ def _build_article_where(
template_slug: str = None, template_slug: str = None,
language: str = None, language: str = None,
search: str = None, search: str = None,
article_type: str = None,
) -> tuple[list[str], list]: ) -> tuple[list[str], list]:
"""Build WHERE clauses and params for article queries. """Build WHERE clauses and params for article queries."""
template_slug='__manual__' filters for articles with template_slug IS NULL
(cornerstone / manually written articles, no pSEO template).
"""
wheres = ["1=1"] wheres = ["1=1"]
params: list = [] params: list = []
@@ -2307,9 +2306,7 @@ def _build_article_where(
wheres.append("status = 'published' AND published_at > datetime('now')") wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft": elif status == "draft":
wheres.append("status = 'draft'") wheres.append("status = 'draft'")
if template_slug == "__manual__": if template_slug:
wheres.append("template_slug IS NULL")
elif template_slug:
wheres.append("template_slug = ?") wheres.append("template_slug = ?")
params.append(template_slug) params.append(template_slug)
if language: if language:
@@ -2318,6 +2315,9 @@ def _build_article_where(
if search: if search:
wheres.append("title LIKE ?") wheres.append("title LIKE ?")
params.append(f"%{search}%") params.append(f"%{search}%")
if article_type:
wheres.append("article_type = ?")
params.append(article_type)
return wheres, params return wheres, params
@@ -2327,12 +2327,14 @@ async def _get_article_list(
template_slug: str = None, template_slug: str = None,
language: str = None, language: str = None,
search: str = None, search: str = None,
article_type: str = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
) -> list[dict]: ) -> list[dict]:
"""Get articles with optional filters and pagination.""" """Get articles with optional filters and pagination."""
wheres, params = _build_article_where(status=status, template_slug=template_slug, wheres, params = _build_article_where(status=status, template_slug=template_slug,
language=language, search=search) language=language, search=search,
article_type=article_type)
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
params.extend([per_page, offset]) params.extend([per_page, offset])
@@ -2353,6 +2355,7 @@ async def _get_article_list_grouped(
status: str = None, status: str = None,
template_slug: str = None, template_slug: str = None,
search: str = None, search: str = None,
article_type: str = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
) -> list[dict]: ) -> list[dict]:
@@ -2363,7 +2366,7 @@ async def _get_article_list_grouped(
Each returned item has a 'variants' list (one dict per language variant). Each returned item has a 'variants' list (one dict per language variant).
""" """
wheres, params = _build_article_where(status=status, template_slug=template_slug, wheres, params = _build_article_where(status=status, template_slug=template_slug,
search=search) search=search, article_type=article_type)
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
@@ -2418,19 +2421,32 @@ async def _get_article_list_grouped(
return groups return groups
async def _get_article_stats() -> dict: async def _get_article_stats(article_type: str = None) -> dict:
"""Get aggregate article stats for the admin list header.""" """Get aggregate article stats for the admin list header."""
where = f"WHERE article_type = '{article_type}'" if article_type else ""
row = await fetch_one( row = await fetch_one(
"""SELECT f"""SELECT
COUNT(*) AS total, COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END), 0) AS live, COALESCE(SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END), 0) AS live,
COALESCE(SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END), 0) AS scheduled, COALESCE(SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END), 0) AS scheduled,
COALESCE(SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END), 0) AS draft COALESCE(SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END), 0) AS draft
FROM articles""" FROM articles {where}"""
) )
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0} return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
async def _get_article_type_counts() -> dict[str, int]:
"""Return per-type article counts for the tab bar."""
rows = await fetch_all(
"SELECT article_type, COUNT(*) AS cnt FROM articles GROUP BY article_type"
)
counts: dict[str, int] = {"cornerstone": 0, "editorial": 0, "generated": 0}
for r in rows:
if r["article_type"] in counts:
counts[r["article_type"]] = r["cnt"]
return counts
async def _is_generating() -> bool: async def _is_generating() -> bool:
"""Return True if a generate_articles task is currently pending.""" """Return True if a generate_articles task is currently pending."""
row = await fetch_one( row = await fetch_one(
@@ -2448,34 +2464,43 @@ async def articles():
status_filter = request.args.get("status", "") status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "") template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "") language_filter = request.args.get("language", "")
article_type = request.args.get("article_type", "cornerstone")
page = max(1, int(request.args.get("page", "1") or "1")) page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter grouped = not language_filter
if grouped: if grouped:
article_list = await _get_article_list_grouped( article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
search=search or None, page=page, search=search or None, article_type=article_type or None, page=page,
) )
else: else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page, language=language_filter or None, search=search or None,
article_type=article_type or None, page=page,
) )
stats = await _get_article_stats() stats = await _get_article_stats(article_type=article_type or None)
type_counts = await _get_article_type_counts()
template_slugs: list[str] = []
if article_type == "generated":
templates = await fetch_all( templates = await fetch_all(
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug" "SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
) )
template_slugs = [t["template_slug"] for t in templates]
return await render_template( return await render_template(
"admin/articles.html", "admin/articles.html",
articles=article_list, articles=article_list,
grouped=grouped, grouped=grouped,
stats=stats, stats=stats,
template_slugs=[t["template_slug"] for t in templates], template_slugs=template_slugs,
current_search=search, current_search=search,
current_status=status_filter, current_status=status_filter,
current_template=template_filter, current_template=template_filter,
current_language=language_filter, current_language=language_filter,
current_article_type=article_type,
type_counts=type_counts,
page=page, page=page,
is_generating=await _is_generating(), is_generating=await _is_generating(),
) )
@@ -2501,23 +2526,26 @@ async def article_results():
status_filter = request.args.get("status", "") status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "") template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "") language_filter = request.args.get("language", "")
article_type = request.args.get("article_type", "cornerstone")
page = max(1, int(request.args.get("page", "1") or "1")) page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter grouped = not language_filter
if grouped: if grouped:
article_list = await _get_article_list_grouped( article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
search=search or None, page=page, search=search or None, article_type=article_type or None, page=page,
) )
else: else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page, language=language_filter or None, search=search or None,
article_type=article_type or None, page=page,
) )
return await render_template( return await render_template(
"admin/partials/article_results.html", "admin/partials/article_results.html",
articles=article_list, articles=article_list,
grouped=grouped, grouped=grouped,
current_article_type=article_type,
page=page, page=page,
is_generating=await _is_generating(), is_generating=await _is_generating(),
) )
@@ -2530,6 +2558,7 @@ async def articles_matching_count():
status_filter = request.args.get("status", "") status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "") template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "") language_filter = request.args.get("language", "")
article_type = request.args.get("article_type", "cornerstone")
search = request.args.get("search", "").strip() search = request.args.get("search", "").strip()
wheres, params = _build_article_where( wheres, params = _build_article_where(
@@ -2537,6 +2566,7 @@ async def articles_matching_count():
template_slug=template_filter or None, template_slug=template_filter or None,
language=language_filter or None, language=language_filter or None,
search=search or None, search=search or None,
article_type=article_type or None,
) )
where = " AND ".join(wheres) where = " AND ".join(wheres)
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params)) row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
@@ -2563,6 +2593,7 @@ async def articles_bulk():
status_filter = form.get("status", "") status_filter = form.get("status", "")
template_filter = form.get("template", "") template_filter = form.get("template", "")
language_filter = form.get("language", "") language_filter = form.get("language", "")
article_type = form.get("article_type", "cornerstone")
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete") valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions: if action not in valid_actions:
@@ -2576,6 +2607,7 @@ async def articles_bulk():
template_slug=template_filter or None, template_slug=template_filter or None,
language=language_filter or None, language=language_filter or None,
search=search or None, search=search or None,
article_type=article_type or None,
) )
where = " AND ".join(wheres) where = " AND ".join(wheres)
@@ -2625,15 +2657,15 @@ async def articles_bulk():
from ..content.routes import BUILD_DIR from ..content.routes import BUILD_DIR
rows = await fetch_all( rows = await fetch_all(
f"SELECT id, slug, template_slug FROM articles WHERE {where} LIMIT 5000", f"SELECT id, slug, article_type FROM articles WHERE {where} LIMIT 5000",
tuple(where_params), tuple(where_params),
) )
for a in rows: for a in rows:
build_path = BUILD_DIR / f"{a['slug']}.html" build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists(): if build_path.exists():
build_path.unlink() build_path.unlink()
# Only remove source .md for generated articles; cornerstones have no template # Only remove source .md for generated articles
if a["template_slug"] is not None: if a["article_type"] == "generated":
md_path = Path("data/content/articles") / f"{a['slug']}.md" md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists(): if md_path.exists():
md_path.unlink() md_path.unlink()
@@ -2682,15 +2714,15 @@ async def articles_bulk():
from ..content.routes import BUILD_DIR from ..content.routes import BUILD_DIR
articles_rows = await fetch_all( articles_rows = await fetch_all(
f"SELECT id, slug, template_slug FROM articles WHERE id IN ({placeholders})", f"SELECT id, slug, article_type FROM articles WHERE id IN ({placeholders})",
tuple(article_ids), tuple(article_ids),
) )
for a in articles_rows: for a in articles_rows:
build_path = BUILD_DIR / f"{a['slug']}.html" build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists(): if build_path.exists():
build_path.unlink() build_path.unlink()
# Only remove source .md for generated articles; cornerstones have no template # Only remove source .md for generated articles
if a["template_slug"] is not None: if a["article_type"] == "generated":
md_path = Path("data/content/articles") / f"{a['slug']}.md" md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists(): if md_path.exists():
md_path.unlink() md_path.unlink()
@@ -2706,17 +2738,19 @@ async def articles_bulk():
if grouped: if grouped:
article_list = await _get_article_list_grouped( article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
search=search or None, search=search or None, article_type=article_type or None,
) )
else: else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, language=language_filter or None, search=search or None,
article_type=article_type or None,
) )
return await render_template( return await render_template(
"admin/partials/article_results.html", "admin/partials/article_results.html",
articles=article_list, articles=article_list,
grouped=grouped, grouped=grouped,
current_article_type=article_type,
page=1, page=1,
is_generating=await _is_generating(), is_generating=await _is_generating(),
) )
@@ -2747,6 +2781,8 @@ async def article_new():
language = form.get("language", "en").strip() or "en" language = form.get("language", "en").strip() or "en"
status = form.get("status", "draft") status = form.get("status", "draft")
published_at = form.get("published_at", "").strip() published_at = form.get("published_at", "").strip()
article_type = form.get("article_type", "editorial")
assert article_type in ("editorial", "cornerstone"), f"invalid article_type: {article_type}"
if not title or not body: if not title or not body:
await flash("Title and body are required.", "error") await flash("Title and body are required.", "error")
@@ -2776,10 +2812,10 @@ async def article_new():
await execute( await execute(
"""INSERT INTO articles """INSERT INTO articles
(url_path, slug, title, meta_description, og_image_url, (url_path, slug, title, meta_description, og_image_url,
country, region, language, status, published_at, seo_head) country, region, language, status, published_at, seo_head, article_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(url_path, article_slug, title, meta_description, og_image_url, (url_path, article_slug, title, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head), country, region, language, status, pub_dt, seo_head, article_type),
) )
from ..sitemap import invalidate_sitemap_cache from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache() invalidate_sitemap_cache()
@@ -2819,6 +2855,8 @@ async def article_edit(article_id: int):
language = form.get("language", article.get("language", "en")).strip() or "en" language = form.get("language", article.get("language", "en")).strip() or "en"
status = form.get("status", article["status"]) status = form.get("status", article["status"])
published_at = form.get("published_at", "").strip() published_at = form.get("published_at", "").strip()
article_type = form.get("article_type", article.get("article_type", "editorial"))
assert article_type in ("editorial", "cornerstone"), f"invalid article_type: {article_type}"
if is_reserved_path(url_path): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
@@ -2847,10 +2885,10 @@ async def article_edit(article_id: int):
"""UPDATE articles """UPDATE articles
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?, SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
country = ?, region = ?, language = ?, status = ?, published_at = ?, country = ?, region = ?, language = ?, status = ?, published_at = ?,
seo_head = ?, updated_at = ? seo_head = ?, article_type = ?, updated_at = ?
WHERE id = ?""", WHERE id = ?""",
(title, url_path, meta_description, og_image_url, (title, url_path, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head, now, article_id), country, region, language, status, pub_dt, seo_head, article_type, now, article_id),
) )
await flash("Article updated.", "success") await flash("Article updated.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))

View File

@@ -310,6 +310,13 @@
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option> <option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
</select> </select>
</div> </div>
<div class="ae-field ae-field--fixed120">
<label for="article_type">Type</label>
<select id="article_type" name="article_type">
<option value="editorial" {% if data.get('article_type', 'editorial') == 'editorial' %}selected{% endif %}>Editorial</option>
<option value="cornerstone" {% if data.get('article_type') == 'cornerstone' %}selected{% endif %}>Cornerstone</option>
</select>
</div>
<div class="ae-field ae-field--fixed120"> <div class="ae-field ae-field--fixed120">
<label for="status">Status</label> <label for="status">Status</label>
<select id="status" name="status"> <select id="status" name="status">

View File

@@ -3,8 +3,23 @@
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
{% block head %}{{ super() }}
<style>
.tab-btn { display:inline-flex; align-items:center; gap:0.4rem;
padding:0.5rem 1rem; font-size:0.8125rem; font-weight:600;
color:#64748B; text-decoration:none; border-bottom:2px solid transparent;
transition: color 0.15s, border-color 0.15s; }
.tab-btn:hover { color:#0F172A; }
.tab-btn--active { color:#1D4ED8; border-bottom-color:#1D4ED8; }
.tab-badge { font-size:0.6875rem; font-weight:700;
background:#F1F5F9; color:#64748B; padding:0.1rem 0.45rem;
border-radius:9999px; min-width:1.25rem; text-align:center; }
.tab-btn--active .tab-badge { background:#EFF6FF; color:#1D4ED8; }
</style>
{% endblock %}
{% block admin_content %} {% block admin_content %}
<header class="flex justify-between items-center mb-6"> <header class="flex justify-between items-center mb-4">
<div> <div>
<h1 class="text-2xl">Articles</h1> <h1 class="text-2xl">Articles</h1>
{% include "admin/partials/article_stats.html" %} {% include "admin/partials/article_stats.html" %}
@@ -19,6 +34,18 @@
</div> </div>
</header> </header>
{# Tab bar #}
<nav class="flex gap-1 mb-4 border-b border-slate-200" role="tablist">
{% for key, label in [('cornerstone','Cornerstone'),('editorial','Editorial'),('generated','Generated')] %}
<a href="{{ url_for('admin.articles', article_type=key) }}"
role="tab" class="tab-btn {% if current_article_type == key %}tab-btn--active{% endif %}"
hx-boost="true">
{{ label }}
<span class="tab-badge">{{ type_counts[key] }}</span>
</a>
{% endfor %}
</nav>
{# Filters #} {# Filters #}
<div class="card mb-6" style="padding:1rem 1.25rem"> <div class="card mb-6" style="padding:1rem 1.25rem">
<form class="flex flex-wrap gap-3 items-end" <form class="flex flex-wrap gap-3 items-end"
@@ -27,6 +54,7 @@
hx-trigger="change, input delay:300ms" hx-trigger="change, input delay:300ms"
hx-indicator="#articles-loading"> hx-indicator="#articles-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_type" value="{{ current_article_type }}">
<div> <div>
<label class="text-xs font-semibold text-slate block mb-1">Search</label> <label class="text-xs font-semibold text-slate block mb-1">Search</label>
@@ -44,16 +72,17 @@
</select> </select>
</div> </div>
{% if current_article_type == 'generated' %}
<div> <div>
<label class="text-xs font-semibold text-slate block mb-1">Template</label> <label class="text-xs font-semibold text-slate block mb-1">Template</label>
<select name="template" class="form-input" style="min-width:140px"> <select name="template" class="form-input" style="min-width:140px">
<option value="">All</option> <option value="">All</option>
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
{% for t in template_slugs %} {% for t in template_slugs %}
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option> <option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{% endif %}
<div> <div>
<label class="text-xs font-semibold text-slate block mb-1">Language</label> <label class="text-xs font-semibold text-slate block mb-1">Language</label>
@@ -81,6 +110,7 @@
<input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}"> <input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
<input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}"> <input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}">
<input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}"> <input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}">
<input type="hidden" name="article_type" id="article-bulk-article-type" value="{{ current_article_type }}">
</form> </form>
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;"> <div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span> <span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
@@ -201,6 +231,7 @@ function updateArticleBulkBar() {
status: document.getElementById('article-bulk-status').value, status: document.getElementById('article-bulk-status').value,
template: document.getElementById('article-bulk-template').value, template: document.getElementById('article-bulk-template').value,
language: document.getElementById('article-bulk-language').value, language: document.getElementById('article-bulk-language').value,
article_type: document.getElementById('article-bulk-article-type').value,
}); });
fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString()) fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString())
.then(function(r) { return r.text(); }) .then(function(r) { return r.text(); })
@@ -260,10 +291,12 @@ function syncBulkFilters() {
var statusEl = document.getElementById('article-bulk-status'); var statusEl = document.getElementById('article-bulk-status');
var templateEl = document.getElementById('article-bulk-template'); var templateEl = document.getElementById('article-bulk-template');
var languageEl = document.getElementById('article-bulk-language'); var languageEl = document.getElementById('article-bulk-language');
var typeEl = document.getElementById('article-bulk-article-type');
if (searchEl) searchEl.value = fd.get('search') || ''; if (searchEl) searchEl.value = fd.get('search') || '';
if (statusEl) statusEl.value = fd.get('status') || ''; if (statusEl) statusEl.value = fd.get('status') || '';
if (templateEl) templateEl.value = fd.get('template') || ''; if (templateEl) templateEl.value = fd.get('template') || '';
if (languageEl) languageEl.value = fd.get('language') || ''; if (languageEl) languageEl.value = fd.get('language') || '';
if (typeEl) typeEl.value = fd.get('article_type') || '';
// Changing filters clears apply-to-all and resets selection // Changing filters clears apply-to-all and resets selection
clearArticleSelection(); clearArticleSelection();
} }

View File

@@ -24,6 +24,7 @@
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true"> <form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete this program?')">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -23,6 +23,7 @@
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true"> <form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete this product?')">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -31,5 +31,5 @@
{% endfor %} {% endfor %}
</td> </td>
<td class="mono">{{ g.published_at[:10] if g.published_at else '-' }}</td> <td class="mono">{{ g.published_at[:10] if g.published_at else '-' }}</td>
<td class="text-slate">{{ g.template_slug or 'Manual' }}</td> {% if current_article_type == 'generated' %}<td class="text-slate">{{ g.template_slug or '-' }}</td>{% endif %}
</tr> </tr>

View File

@@ -63,7 +63,7 @@
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th> <th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th> <th>Published</th>
{% if not grouped %}<th>Lang</th>{% endif %} {% if not grouped %}<th>Lang</th>{% endif %}
<th>Template</th> {% if current_article_type == 'generated' %}<th>Template</th>{% endif %}
{% if not grouped %}<th></th>{% endif %} {% if not grouped %}<th></th>{% endif %}
</tr> </tr>
</thead> </thead>

View File

@@ -17,7 +17,7 @@
</td> </td>
<td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td> <td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td>
<td>{{ a.language | upper if a.language else '-' }}</td> <td>{{ a.language | upper if a.language else '-' }}</td>
<td class="text-slate">{{ a.template_slug or 'Manual' }}</td> {% if current_article_type == 'generated' %}<td class="text-slate">{{ a.template_slug or '-' }}</td>{% endif %}
<td class="text-right" style="white-space:nowrap"> <td class="text-right" style="white-space:nowrap">
{% if a.display_status == 'live' %} {% if a.display_status == 'live' %}
<a href="/{{ a.language or 'en' }}{{ a.url_path }}" target="_blank" class="btn-outline btn-sm">View</a> <a href="/{{ a.language or 'en' }}{{ a.url_path }}" target="_blank" class="btn-outline btn-sm">View</a>

View File

@@ -520,8 +520,8 @@ async def generate_articles(
"""INSERT INTO articles """INSERT INTO articles
(url_path, slug, title, meta_description, country, region, (url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified, status, published_at, template_slug, language, date_modified,
seo_head, noindex, created_at) seo_head, noindex, article_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, 'generated', ?)
ON CONFLICT(url_path, language) DO UPDATE SET ON CONFLICT(url_path, language) DO UPDATE SET
title = excluded.title, title = excluded.title,
meta_description = excluded.meta_description, meta_description = excluded.meta_description,
@@ -529,6 +529,7 @@ async def generate_articles(
date_modified = excluded.date_modified, date_modified = excluded.date_modified,
seo_head = excluded.seo_head, seo_head = excluded.seo_head,
noindex = excluded.noindex, noindex = excluded.noindex,
article_type = 'generated',
updated_at = excluded.date_modified""", updated_at = excluded.date_modified""",
( (
url_path, article_slug, title, meta_desc, url_path, article_slug, title, meta_desc,

View File

@@ -0,0 +1,25 @@
"""Migration 0029: Add article_type column to articles table.
Values: 'cornerstone' | 'editorial' | 'generated'
Backfill from existing data:
- template_slug IS NOT NULL → generated
- template_slug IS NULL AND group_key IS NOT NULL → cornerstone
- template_slug IS NULL AND group_key IS NULL → editorial
"""
def up(conn) -> None:
conn.execute("""
ALTER TABLE articles ADD COLUMN article_type TEXT NOT NULL DEFAULT 'editorial'
""")
conn.execute("""
UPDATE articles SET article_type = 'generated'
WHERE template_slug IS NOT NULL
""")
conn.execute("""
UPDATE articles SET article_type = 'cornerstone'
WHERE template_slug IS NULL AND group_key IS NOT NULL
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_articles_article_type ON articles(article_type)
""")