Client accept/reject votes with keyboard-driven culling
ci / docker (push) Successful in 13s

- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum
- share page: thumbs up/down, verdict filter with counts, view-scoped selection bar
- lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action
  toast when a keyboard vote auto-advances
- shared useLightbox hook; single keydown subscription reading live state via ref
- album view: per-link thumbs and vote counts; share list shows accept/reject totals
- feedback queries deduped and run concurrently; verdict counts in one scan
This commit is contained in:
2026-07-17 15:55:46 +02:00
parent 6259ca84d8
commit 30d0b3064e
14 changed files with 612 additions and 126 deletions
+20 -9
View File
@@ -23,6 +23,8 @@ struct ShareAdminRow {
created_at: DateTime<Utc>,
rating_count: i64,
tag_count: i64,
accept_count: i64,
reject_count: i64,
}
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
@@ -38,20 +40,30 @@ fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
"created_at": row.created_at,
"rating_count": row.rating_count,
"tag_count": row.tag_count,
"accept_count": row.accept_count,
"reject_count": row.reject_count,
})
}
const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download,
// Aggregates run as laterals so each feedback table is scanned once per
// share (the verdict lateral yields both counts from a single pass).
const SHARE_SELECT: &str = "select s.id, s.token, s.label, s.password_hash, s.allow_download,
s.expires_at, s.locked_until, s.created_at,
(select count(*) from ratings r where r.share_id = s.id) as rating_count,
(select count(*) from tags t where t.share_id = s.id) as tag_count";
rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count
from shares s
cross join lateral (select count(*) as rating_count from ratings r where r.share_id = s.id) rc
cross join lateral (select count(*) as tag_count from tags t where t.share_id = s.id) tc
cross join lateral (
select count(*) filter (where v.verdict = 'accept') as accept_count,
count(*) filter (where v.verdict = 'reject') as reject_count
from verdicts v where v.share_id = s.id) vc";
pub async fn list(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
"{SHARE_SELECT} where s.album_id = $1 order by s.created_at desc"
))
.bind(album_id)
.fetch_all(&state.db)
@@ -119,11 +131,10 @@ pub async fn create(
.fetch_one(&state.db)
.await?;
let row: ShareAdminRow =
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1"))
.bind(share_id)
.fetch_one(&state.db)
.await?;
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
.bind(share_id)
.fetch_one(&state.db)
.await?;
Ok(Json(share_json(&state, &row)))
}