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 %}
+