- 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:
+20
-1
@@ -1,5 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
|
||||
@@ -47,6 +47,25 @@ impl TryFrom<String> for PhotoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Client accept/reject vote. Same convention as PhotoStatus: text in
|
||||
/// Postgres (check-constrained), this enum everywhere Rust touches the value
|
||||
/// — serde rejects anything but "accept"/"reject" at the API boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Verdict {
|
||||
Accept,
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accept => "accept",
|
||||
Self::Reject => "reject",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JobKind {
|
||||
ProcessPhoto,
|
||||
|
||||
+70
-34
@@ -79,12 +79,40 @@ pub struct ShareTag {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ShareVerdict {
|
||||
pub share_label: String,
|
||||
pub verdict: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct PhotoFeedback {
|
||||
pub ratings: Vec<ShareRating>,
|
||||
pub verdicts: Vec<ShareVerdict>,
|
||||
pub tags: Vec<ShareTag>,
|
||||
}
|
||||
|
||||
async fn feedback_rows<T>(
|
||||
db: &sqlx::PgPool,
|
||||
sql: &str,
|
||||
album_id: Uuid,
|
||||
) -> Result<Vec<(Uuid, String, T)>, sqlx::Error>
|
||||
where
|
||||
(Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
|
||||
{
|
||||
sqlx::query_as(sql).bind(album_id).fetch_all(db).await
|
||||
}
|
||||
|
||||
fn fold_feedback<T>(
|
||||
feedback: &mut HashMap<Uuid, PhotoFeedback>,
|
||||
rows: Vec<(Uuid, String, T)>,
|
||||
push: impl Fn(&mut PhotoFeedback, String, T),
|
||||
) {
|
||||
for (photo_id, share_label, value) in rows {
|
||||
push(feedback.entry(photo_id).or_default(), share_label, value);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AlbumDetail {
|
||||
pub album: Album,
|
||||
@@ -108,41 +136,49 @@ pub async fn get_one(
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
// The three feedback kinds are independent (photo_id, share label, value)
|
||||
// queries — run them concurrently and fold with one shared shape.
|
||||
let (ratings, verdicts, tags) = tokio::try_join!(
|
||||
feedback_rows::<i32>(
|
||||
&state.db,
|
||||
"select r.photo_id, s.label, r.rating
|
||||
from ratings r join shares s on s.id = r.share_id
|
||||
where s.album_id = $1",
|
||||
album_id,
|
||||
),
|
||||
feedback_rows::<String>(
|
||||
&state.db,
|
||||
"select v.photo_id, s.label, v.verdict
|
||||
from verdicts v join shares s on s.id = v.share_id
|
||||
where s.album_id = $1",
|
||||
album_id,
|
||||
),
|
||||
feedback_rows::<String>(
|
||||
&state.db,
|
||||
"select t.photo_id, s.label, t.tag
|
||||
from tags t join shares s on s.id = t.share_id
|
||||
where s.album_id = $1
|
||||
order by t.created_at",
|
||||
album_id,
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
|
||||
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
|
||||
"select r.photo_id, s.label, r.rating
|
||||
from ratings r join shares s on s.id = r.share_id
|
||||
where s.album_id = $1",
|
||||
)
|
||||
.bind(album_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (photo_id, share_label, rating) in ratings {
|
||||
feedback
|
||||
.entry(photo_id)
|
||||
.or_default()
|
||||
.ratings
|
||||
.push(ShareRating {
|
||||
share_label,
|
||||
rating,
|
||||
});
|
||||
}
|
||||
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
|
||||
"select t.photo_id, s.label, t.tag
|
||||
from tags t join shares s on s.id = t.share_id
|
||||
where s.album_id = $1
|
||||
order by t.created_at",
|
||||
)
|
||||
.bind(album_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (photo_id, share_label, tag) in tags {
|
||||
feedback
|
||||
.entry(photo_id)
|
||||
.or_default()
|
||||
.tags
|
||||
.push(ShareTag { share_label, tag });
|
||||
}
|
||||
fold_feedback(&mut feedback, ratings, |f, share_label, rating| {
|
||||
f.ratings.push(ShareRating {
|
||||
share_label,
|
||||
rating,
|
||||
})
|
||||
});
|
||||
fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
|
||||
f.verdicts.push(ShareVerdict {
|
||||
share_label,
|
||||
verdict,
|
||||
})
|
||||
});
|
||||
fold_feedback(&mut feedback, tags, |f, share_label, tag| {
|
||||
f.tags.push(ShareTag { share_label, tag })
|
||||
});
|
||||
|
||||
Ok(Json(AlbumDetail {
|
||||
album,
|
||||
|
||||
+40
-2
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{PhotoStatus, Share};
|
||||
use crate::models::{PhotoStatus, Share, Verdict};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
|
||||
@@ -137,6 +137,7 @@ struct ClientPhotoRow {
|
||||
taken_at: Option<DateTime<Utc>>,
|
||||
processed_at: Option<DateTime<Utc>>,
|
||||
my_rating: Option<i32>,
|
||||
my_verdict: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -185,9 +186,10 @@ pub async fn get_share(
|
||||
let jar = grant_access(&state, jar, share.id);
|
||||
|
||||
let rows: Vec<ClientPhotoRow> = sqlx::query_as(
|
||||
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating
|
||||
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating, v.verdict as my_verdict
|
||||
from photos p
|
||||
left join ratings r on r.photo_id = p.id and r.share_id = $2
|
||||
left join verdicts v on v.photo_id = p.id and v.share_id = $2
|
||||
where p.album_id = $1 and p.status = $3
|
||||
order by coalesce(p.taken_at, p.created_at), p.filename",
|
||||
)
|
||||
@@ -352,6 +354,42 @@ pub async fn set_rating(
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VerdictBody {
|
||||
verdict: Option<Verdict>,
|
||||
}
|
||||
|
||||
pub async fn set_verdict(
|
||||
State(state): State<AppState>,
|
||||
Path((token, photo_id)): Path<(String, Uuid)>,
|
||||
jar: SignedCookieJar,
|
||||
Json(body): Json<VerdictBody>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let share = share_photo(&state, &jar, &token, photo_id).await?;
|
||||
match body.verdict {
|
||||
None => {
|
||||
sqlx::query("delete from verdicts where share_id = $1 and photo_id = $2")
|
||||
.bind(share.id)
|
||||
.bind(photo_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Some(verdict) => {
|
||||
sqlx::query(
|
||||
"insert into verdicts (share_id, photo_id, verdict) values ($1, $2, $3)
|
||||
on conflict (share_id, photo_id)
|
||||
do update set verdict = excluded.verdict, updated_at = now()",
|
||||
)
|
||||
.bind(share.id)
|
||||
.bind(photo_id)
|
||||
.bind(verdict.as_str())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TagsBody {
|
||||
tags: Vec<String>,
|
||||
|
||||
@@ -100,6 +100,10 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
||||
"/api/share/{token}/photos/{photo_id}/rating",
|
||||
put(client::set_rating),
|
||||
)
|
||||
.route(
|
||||
"/api/share/{token}/photos/{photo_id}/verdict",
|
||||
put(client::set_verdict),
|
||||
)
|
||||
.route(
|
||||
"/api/share/{token}/photos/{photo_id}/tags",
|
||||
put(client::set_tags),
|
||||
|
||||
+20
-9
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user