From 91972fe0aca4f9f1c243a296645156b25d81630c Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sat, 8 Aug 2026 09:53:58 +1000 Subject: [PATCH] Dashboard: select-all + bulk keep/reject/reset (htmx + bulk endpoint) --- dashboard/app.py | 49 +++++++++++++++++++ dashboard/templates/_card.html | 3 ++ dashboard/templates/review.html | 84 +++++++++++++++++++++++++++++++-- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/dashboard/app.py b/dashboard/app.py index 174faf1..ee700a8 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -177,6 +177,55 @@ def reset_status(sha: str, request: Request): return _set_status(sha, "scanned", None, request) +@app.post("/review/bulk") +async def bulk_action(request: Request): + """Bulk keep/reject/reset for a list of sha256 values. + + Body: JSON {"action": "approve"|"reject"|"reset", "shas": ["..."]} + Moves files accordingly; returns counts. + """ + import json + + body = json.loads(await request.body()) + action = body.get("action") + shas = body.get("shas", []) + if action not in ("approve", "reject", "reset"): + raise HTTPException(400, "action must be approve/reject/reset") + + move_map = {"approve": "01_keep", "reject": "03_delete", "reset": None} + status_map = {"approve": "approved", "reject": "rejected", "reset": "scanned"} + move_to = move_map[action] + status = status_map[action] + + conn = _conn() + moved = 0 + missing = 0 + for sha in shas: + row = conn.execute("SELECT path FROM image_hashes WHERE sha256=?", (sha,)).fetchone() + if not row: + missing += 1 + continue + src = Path(row["path"]) + if move_to and src.exists(): + dest_dir = STAGING / move_to + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / src.name + try: + shutil.move(str(src), str(dest)) + conn.execute( + "UPDATE image_hashes SET path=?, status=? WHERE sha256=?", + (str(dest), status, sha)) + moved += 1 + continue + except Exception: + pass + conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) + moved += 1 + conn.commit() + conn.close() + return {"action": action, "moved": moved, "missing": missing, "total": len(shas)} + + @app.get("/file/{sha}") def full_file(sha: str): conn = _conn() diff --git a/dashboard/templates/_card.html b/dashboard/templates/_card.html index ddaba39..c2d9558 100644 --- a/dashboard/templates/_card.html +++ b/dashboard/templates/_card.html @@ -1,5 +1,8 @@ {% set sha8 = item["sha256"][:8] %}
+ {% if item["thumb"] %} diff --git a/dashboard/templates/review.html b/dashboard/templates/review.html index b7a7449..e493eab 100644 --- a/dashboard/templates/review.html +++ b/dashboard/templates/review.html @@ -11,13 +11,22 @@ header { padding: 1rem 1.5rem; border-bottom: 1px solid #333; display: flex; gap: 1.5rem; align-items: baseline; } header h1 { font-size: 1.2rem; margin: 0; } header a { color: #6cf; text-decoration: none; } - .filters { padding: 1rem 1.5rem; display: flex; gap: .5rem; flex-wrap: wrap; } + .filters { padding: 1rem 1.5rem; display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; } .filters select, .filters input, .filters button { background: #222; color: #eee; border: 1px solid #444; border-radius: 6px; padding: .4rem .8rem; } + .bulk-bar { display: none; gap: .5rem; align-items: center; padding: .6rem 1.5rem; background: #16222a; border-top: 1px solid #2a4a5a; border-bottom: 1px solid #2a4a5a; } + .bulk-bar.visible { display: flex; } + .bulk-bar .count { color: #6cf; } + .btn-keep { background: #1d4; color: #031; border: 0; border-radius: 6px; padding: .45rem 1rem; cursor: pointer; font-weight: 600; } + .btn-reject { background: #d43; color: #fff; border: 0; border-radius: 6px; padding: .45rem 1rem; cursor: pointer; font-weight: 600; } + .btn-reset { background: #444; color: #eee; border: 0; border-radius: 6px; padding: .45rem 1rem; cursor: pointer; } .count { padding: 0 1.5rem .5rem; color: #999; font-size: .9rem; } main { padding: 1.5rem; max-width: 1400px; margin: 0 auto; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; } - .card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden; } + .card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden; position: relative; } .card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; } + .card .check { position: absolute; top: .4rem; left: .4rem; z-index: 2; } + .card .check input { width: 1.1rem; height: 1.1rem; cursor: pointer; } + .card.selected { border-color: #6cf; box-shadow: 0 0 0 1px #6cf; } .card .meta { padding: .5rem; font-size: .7rem; color: #aaa; word-break: break-all; } .card .badge { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; margin-top: .25rem; } .badge.approved { background: #1d4; color: #031; } @@ -30,7 +39,7 @@ .reject { background: #d43; color: #fff; } .reset { background: #444; color: #eee; } .none { color: #666; font-style: italic; padding: 2rem; text-align: center; } - .toast { position: fixed; bottom: 1rem; right: 1rem; background: #1d4; color: #031; padding: .75rem 1rem; border-radius: 8px; display: none; } + .selall { display: inline-flex; align-items: center; gap: .35rem; background: #222; border: 1px solid #444; border-radius: 6px; padding: .4rem .8rem; cursor: pointer; } @@ -51,6 +60,15 @@ + +
+
+ 0 selected + + +

{{ items|length }} images

@@ -62,5 +80,65 @@ {% endfor %}
+