diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte
index ef4f61e..a2acab4 100644
--- a/frontend/src/routes/admin/+page.svelte
+++ b/frontend/src/routes/admin/+page.svelte
@@ -293,6 +293,12 @@
Object.assign(c, res, { _deep: false });
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
}
+ function startRename(c) { c._editName = c.name || ''; c._editing = true; c._err = ''; }
+ async function saveRename(c) {
+ try {
+ Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/rename`, { name: (c._editName || '').trim() }), { _editing: false });
+ } catch (e) { c._err = e?.message || 'Rename failed.'; }
+ }
async function promoteCandidate(c) {
c._err = '';
try {
@@ -570,8 +576,16 @@
{#each pendingCandidates as c (c.id)}
- {c.name || c.feed_url}
- {c.status}
+ {#if c._editing}
+ (e.key === 'Enter' ? saveRename(c) : e.key === 'Escape' ? (c._editing = false) : null)} />
+
+
+ {:else}
+ {c.name || c.feed_url}
+
+ {c.status}
+ {/if}
{c.feed_url}
{#if c.preview}
@@ -1102,9 +1116,15 @@
.addrow input:focus { outline: none; border-color: var(--accent); }
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
- .chead { display: flex; align-items: baseline; gap: 10px; }
+ .chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.chead .cname { font-weight: 600; color: var(--ink); }
.chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
+ .chead .crename { font: inherit; font-size: 0.9rem; font-weight: 600; padding: 4px 9px;
+ border: 1px solid var(--accent); border-radius: 8px; background: var(--bg); color: var(--ink); }
+ .chead .crename:focus { outline: none; }
+ .act.mini { font-size: 0.72rem; padding: 2px 8px; color: var(--accent-deep);
+ background: none; border: 1px solid var(--line); border-radius: 999px; cursor: pointer; }
+ .act.mini:hover { border-color: var(--accent); }
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
diff --git a/goodnews/api.py b/goodnews/api.py
index 96eca76..a72add4 100644
--- a/goodnews/api.py
+++ b/goodnews/api.py
@@ -444,6 +444,10 @@ class CandidatePromoteBody(BaseModel):
poll_interval_minutes: int = 180
+class CandidateRenameBody(BaseModel):
+ name: str = ""
+
+
class DigestBody(BaseModel):
enabled: bool = True
@@ -1197,6 +1201,16 @@ def create_app() -> FastAPI:
row = sources.save_candidate(conn, url, preview=preview)
return _candidate_dict(row)
+ @app.post("/api/admin/candidates/{cid}/rename")
+ def admin_candidate_rename(cid: int, body: CandidateRenameBody, request: Request) -> dict:
+ with get_conn() as conn:
+ _require_admin(conn, request)
+ try:
+ row = sources.rename_candidate(conn, cid, (body.name or "").strip() or None)
+ except ValueError:
+ raise HTTPException(status_code=404, detail="candidate not found")
+ return _candidate_dict(row)
+
@app.post("/api/admin/candidates/{cid}/promote")
def admin_candidate_promote(cid: int, body: CandidatePromoteBody, request: Request) -> dict:
with get_conn() as conn:
diff --git a/goodnews/sources.py b/goodnews/sources.py
index adbdb88..13f1511 100644
--- a/goodnews/sources.py
+++ b/goodnews/sources.py
@@ -153,6 +153,19 @@ def list_candidates(conn: sqlite3.Connection, status: str | None = None) -> list
return conn.execute("SELECT * FROM source_candidates ORDER BY updated_at DESC").fetchall()
+def rename_candidate(conn: sqlite3.Connection, candidate_id: int, name: str | None) -> sqlite3.Row:
+ """Fix a staged candidate's display name without re-fetching it. An empty
+ name clears it (promote then derives one from the feed host)."""
+ cur = conn.execute(
+ "UPDATE source_candidates SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (name or None, candidate_id),
+ )
+ conn.commit()
+ if cur.rowcount == 0:
+ raise ValueError(f"no candidate with id {candidate_id}")
+ return conn.execute("SELECT * FROM source_candidates WHERE id = ?", (candidate_id,)).fetchone()
+
+
def reject_candidate(conn: sqlite3.Connection, candidate_id: int) -> bool:
cur = conn.execute(
"UPDATE source_candidates SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
diff --git a/tests/test_admin.py b/tests/test_admin.py
index 545230c..aea1fe7 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -169,6 +169,24 @@ def test_candidate_deep_preview_and_dedup(tmp_path, monkeypatch):
assert tc.post(f"/api/admin/candidates/{cand['id']}/promote", json={}).status_code == 409
+def test_candidate_rename(tmp_path, monkeypatch):
+ app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
+ monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 3, "accepted": 2})
+ tc = _signin(app, api, "boss@x.com")
+ cand = tc.post("/api/admin/candidates", json={"feed_url": "http://site/feed", "name": "Wrong Naem"}).json()
+ cid = cand["id"]
+ # Fix the typo in place — no reject/re-add, preview is preserved.
+ fixed = tc.post(f"/api/admin/candidates/{cid}/rename", json={"name": "Right Name"}).json()
+ assert fixed["name"] == "Right Name" and fixed["preview"]["accepted"] == 2
+ assert any(c["id"] == cid and c["name"] == "Right Name" for c in tc.get("/api/admin/candidates").json())
+ # The fixed name carries through to promotion.
+ res = tc.post(f"/api/admin/candidates/{cid}/promote", json={}).json()
+ assert res["source"]["name"] == "Right Name"
+ # Gated; unknown id → 404.
+ assert TestClient(app).post(f"/api/admin/candidates/{cid}/rename", json={"name": "x"}).status_code == 401
+ assert tc.post("/api/admin/candidates/99999/rename", json={"name": "x"}).status_code == 404
+
+
def test_candidate_reject_and_gating(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 1, "accepted": 0})