feat(moderation): improve review UI with admin consistency (#680)

- move review to /admin/review/:id (proper admin namespace)
- use admin.css for consistent styling with dashboard
- add "← back to dashboard" navigation link
- add three action types:
- clear: false positive, emit negation label
- defer: acknowledge but take no action (flag stays active)
- confirm: mark as real violation (flag stays active)
- toggle decisions by clicking same button again

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 3e71772e 845182f1

Changed files
+158 -149
moderation
+1 -1
moderation/src/admin.rs
··· 380 380 db.create_batch(&id, &uris, request.created_by.as_deref()) 381 381 .await?; 382 382 383 - let url = format!("/review/{}", id); 383 + let url = format!("/admin/review/{}", id); 384 384 385 385 Ok(Json(CreateBatchResponse { id, url, flag_count })) 386 386 }
+2 -2
moderation/src/auth.rs
··· 12 12 let path = req.uri().path(); 13 13 14 14 // Public endpoints - no auth required 15 - // Note: /admin and /review/:id serve HTML, auth is handled client-side for API calls 15 + // Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls 16 16 // Static files must be public for admin UI CSS/JS to load 17 - let is_review_page = path.starts_with("/review/") 17 + let is_review_page = path.starts_with("/admin/review/") 18 18 && !path.ends_with("/data") 19 19 && !path.ends_with("/submit"); 20 20 if path == "/"
+4 -4
moderation/src/main.rs
··· 93 93 post(admin::remove_sensitive_image), 94 94 ) 95 95 .route("/admin/batches", post(admin::create_batch)) 96 - // Review endpoints (auth protected) 97 - .route("/review/:id", get(review::review_page)) 98 - .route("/review/:id/data", get(review::review_data)) 99 - .route("/review/:id/submit", post(review::submit_review)) 96 + // Review endpoints (under admin, auth protected) 97 + .route("/admin/review/:id", get(review::review_page)) 98 + .route("/admin/review/:id/data", get(review::review_data)) 99 + .route("/admin/review/:id/submit", post(review::submit_review)) 100 100 // Static files (CSS, JS for admin UI) 101 101 .nest_service("/static", ServeDir::new("static")) 102 102 // ATProto XRPC endpoints (public)
+151 -142
moderation/src/review.rs
··· 31 31 #[derive(Debug, Deserialize)] 32 32 pub struct ReviewDecision { 33 33 pub uri: String, 34 - pub decision: String, // "approved" or "rejected" 34 + /// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation) 35 + pub decision: String, 35 36 } 36 37 37 38 /// Response after submitting review. ··· 110 111 db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision) 111 112 .await?; 112 113 113 - if decision.decision == "approved" { 114 - let label = 115 - crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation") 116 - .negated(); 117 - let label = signer.sign_label(label)?; 118 - let seq = db.store_label(&label).await?; 114 + match decision.decision.as_str() { 115 + "clear" => { 116 + // False positive - emit negation label to clear the flag 117 + let label = 118 + crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation") 119 + .negated(); 120 + let label = signer.sign_label(label)?; 121 + let seq = db.store_label(&label).await?; 119 122 120 - db.store_resolution( 121 - &decision.uri, 122 - crate::db::ResolutionReason::FingerprintNoise, 123 - Some("batch review"), 124 - ) 125 - .await?; 123 + db.store_resolution( 124 + &decision.uri, 125 + crate::db::ResolutionReason::FingerprintNoise, 126 + Some("batch review: cleared"), 127 + ) 128 + .await?; 126 129 127 - if let Some(tx) = &state.label_tx { 128 - let _ = tx.send((seq, label)); 129 - } 130 + if let Some(tx) = &state.label_tx { 131 + let _ = tx.send((seq, label)); 132 + } 130 133 131 - resolved_count += 1; 134 + resolved_count += 1; 135 + } 136 + "defer" => { 137 + // Acknowledge but take no action - flag stays active 138 + // Just mark as reviewed in the batch, no label changes 139 + tracing::info!(uri = %decision.uri, "deferred - no action taken"); 140 + } 141 + "confirm" => { 142 + // Real violation - flag stays active, could add enforcement later 143 + tracing::info!(uri = %decision.uri, "confirmed as violation"); 144 + } 145 + _ => { 146 + tracing::warn!(uri = %decision.uri, decision = %decision.decision, "unknown decision type"); 147 + } 132 148 } 133 149 } 134 150 ··· 175 191 }; 176 192 177 193 let status_badge = if status == "completed" { 178 - r#"<span class="status-badge completed">completed</span>"# 194 + r#"<span class="badge resolved">completed</span>"# 179 195 } else { 180 196 "" 181 197 }; ··· 187 203 <meta charset="utf-8"> 188 204 <meta name="viewport" content="width=device-width, initial-scale=1"> 189 205 <title>review batch - plyr.fm</title> 206 + <link rel="stylesheet" href="/static/admin.css"> 190 207 <style>{}</style> 191 208 </head> 192 209 <body> 193 - <div class="container"> 194 - <header> 195 - <h1>plyr.fm moderation</h1> 196 - <div class="batch-info">{} pending {}</div> 197 - </header> 210 + <h1>plyr.fm moderation</h1> 211 + <p class="subtitle"> 212 + <a href="/admin">← back to dashboard</a> 213 + <span style="margin: 0 12px; color: var(--text-muted);">|</span> 214 + batch review: {} pending {} 215 + </p> 198 216 199 - <div class="auth-section" id="auth-section"> 200 - <input type="password" id="auth-token" placeholder="auth token" 201 - onkeyup="if(event.key==='Enter')authenticate()"> 202 - <button class="btn-submit" onclick="authenticate()">authenticate</button> 217 + <div class="auth-section" id="auth-section"> 218 + <input type="password" id="auth-token" placeholder="auth token" 219 + onkeyup="if(event.key==='Enter')authenticate()"> 220 + <button class="btn btn-primary" onclick="authenticate()">authenticate</button> 221 + </div> 222 + 223 + <form id="review-form" style="display: none;"> 224 + <div class="flags-list"> 225 + {} 203 226 </div> 204 227 205 - <form id="review-form" class="review-form" style="display: none;"> 206 - <div class="flags-list"> 207 - {} 208 - </div> 228 + {} 209 229 210 - {} 211 - 212 - <div class="submit-bar"> 213 - <button type="submit" class="btn-submit" id="submit-btn" disabled> 214 - submit decisions 215 - </button> 216 - </div> 217 - </form> 218 - </div> 230 + <div class="submit-bar"> 231 + <button type="submit" class="btn btn-primary" id="submit-btn" disabled> 232 + submit decisions 233 + </button> 234 + </div> 235 + </form> 219 236 220 237 <script> 221 238 const form = document.getElementById('review-form'); ··· 255 272 }} 256 273 257 274 function setDecision(uri, decision) {{ 258 - decisions[uri] = decision; 259 - const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`); 260 - if (card) {{ 261 - card.classList.remove('approved', 'rejected'); 262 - card.classList.add(decision); 275 + // Toggle off if clicking the same decision 276 + if (decisions[uri] === decision) {{ 277 + delete decisions[uri]; 278 + const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`); 279 + if (card) card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm'); 280 + }} else {{ 281 + decisions[uri] = decision; 282 + const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`); 283 + if (card) {{ 284 + card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm'); 285 + card.classList.add('decision-' + decision); 286 + }} 263 287 }} 264 288 updateSubmitBtn(); 265 289 }} ··· 270 294 submitBtn.textContent = 'submitting...'; 271 295 272 296 try {{ 273 - const response = await fetch(`/review/${{batchId}}/submit`, {{ 297 + const response = await fetch(`/admin/review/${{batchId}}/submit`, {{ 274 298 method: 'POST', 275 299 headers: {{ 276 300 'Content-Type': 'application/json', ··· 284 308 if (response.status === 401) {{ 285 309 localStorage.removeItem('mod_token'); 286 310 currentToken = ''; 287 - authSection.style.display = 'flex'; 311 + authSection.style.display = 'block'; 288 312 form.style.display = 'none'; 289 313 document.getElementById('auth-token').value = ''; 290 314 alert('invalid token'); ··· 347 371 .map(|matches| { 348 372 let items: Vec<String> = matches 349 373 .iter() 350 - .take(2) 374 + .take(3) 351 375 .map(|m| { 352 376 format!( 353 - r#"<span class="match">{} - {}</span>"#, 377 + r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#, 354 378 html_escape(&m.title), 355 379 html_escape(&m.artist) 356 380 ) 357 381 }) 358 382 .collect(); 359 - format!(r#"<div class="matches">{}</div>"#, items.join("")) 383 + format!( 384 + r#"<div class="matches"><h4>potential matches</h4>{}</div>"#, 385 + items.join("\n") 386 + ) 360 387 }) 361 388 .unwrap_or_default(); 362 389 363 390 let resolved_badge = if track.resolved { 364 391 r#"<span class="badge resolved">resolved</span>"# 365 392 } else { 366 - "" 393 + r#"<span class="badge pending">pending</span>"# 367 394 }; 368 395 369 396 let action_buttons = if !track.resolved { 370 397 format!( 371 - r#"<div class="actions"> 372 - <button type="button" class="btn-approve" onclick="setDecision('{}', 'approved')">approve</button> 373 - <button type="button" class="btn-reject" onclick="setDecision('{}', 'rejected')">reject</button> 398 + r#"<div class="flag-actions"> 399 + <button type="button" class="btn btn-clear" onclick="setDecision('{}', 'clear')">clear</button> 400 + <button type="button" class="btn btn-defer" onclick="setDecision('{}', 'defer')">defer</button> 401 + <button type="button" class="btn btn-confirm" onclick="setDecision('{}', 'confirm')">confirm</button> 374 402 </div>"#, 403 + html_escape(&track.uri), 375 404 html_escape(&track.uri), 376 405 html_escape(&track.uri) 377 406 ) ··· 380 409 }; 381 410 382 411 format!( 383 - r#"<div class="review-card{}" data-uri="{}"> 384 - <div class="track-info"> 385 - <div class="title">{}</div> 386 - <div class="artist">@{}</div> 387 - {} 412 + r#"<div class="flag-card{}" data-uri="{}"> 413 + <div class="flag-header"> 414 + <div class="track-info"> 415 + <h3>{}</h3> 416 + <div class="artist">@{}</div> 417 + </div> 418 + <div class="flag-badges"> 419 + {} 420 + </div> 388 421 </div> 389 422 {} 390 423 {} ··· 393 426 html_escape(&track.uri), 394 427 title_html, 395 428 html_escape(artist), 396 - matches_html, 397 429 resolved_badge, 430 + matches_html, 398 431 action_buttons 399 432 ) 400 433 } ··· 407 440 .replace('\'', "&#039;") 408 441 } 409 442 443 + /// Additional CSS for review page (supplements admin.css) 410 444 const REVIEW_CSS: &str = r#" 411 - * { box-sizing: border-box; margin: 0; padding: 0; } 445 + /* review page specific styles */ 446 + body { padding-bottom: 80px; } 412 447 413 - body { 414 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 415 - background: #0a0a0a; 416 - color: #e0e0e0; 417 - min-height: 100vh; 448 + .subtitle a { 449 + color: var(--accent); 450 + text-decoration: none; 418 451 } 452 + .subtitle a:hover { text-decoration: underline; } 419 453 420 - .container { 421 - max-width: 600px; 422 - margin: 0 auto; 423 - padding: 16px; 424 - padding-bottom: 80px; 454 + /* action buttons */ 455 + .btn-clear { 456 + background: rgba(74, 222, 128, 0.15); 457 + color: var(--success); 458 + border: 1px solid rgba(74, 222, 128, 0.3); 425 459 } 426 - 427 - header { 428 - display: flex; 429 - justify-content: space-between; 430 - align-items: center; 431 - margin-bottom: 20px; 432 - padding-bottom: 12px; 433 - border-bottom: 1px solid #333; 460 + .btn-clear:hover { 461 + background: rgba(74, 222, 128, 0.25); 434 462 } 435 463 436 - h1 { font-size: 1.25rem; font-weight: 600; color: #fff; } 437 - .batch-info { font-size: 0.875rem; color: #888; } 438 - .status-badge { font-size: 0.7rem; background: #1a3a1a; color: #6d9; padding: 2px 6px; border-radius: 4px; margin-left: 8px; } 439 - 440 - .auth-section { 441 - display: flex; 442 - gap: 10px; 443 - margin-bottom: 20px; 444 - align-items: center; 445 - } 446 - .auth-section input[type="password"] { 447 - flex: 1; 448 - padding: 10px 12px; 449 - background: #1a1a1a; 450 - border: 1px solid #333; 451 - border-radius: 6px; 452 - color: #fff; 453 - font-size: 0.875rem; 464 + .btn-defer { 465 + background: rgba(251, 191, 36, 0.15); 466 + color: var(--warning); 467 + border: 1px solid rgba(251, 191, 36, 0.3); 454 468 } 455 - .auth-section input:focus { 456 - outline: none; 457 - border-color: #4a9eff; 469 + .btn-defer:hover { 470 + background: rgba(251, 191, 36, 0.25); 458 471 } 459 472 460 - .flags-list { display: flex; flex-direction: column; gap: 12px; } 461 - 462 - .review-card { 463 - background: #1a1a1a; 464 - border: 1px solid #333; 465 - border-radius: 8px; 466 - padding: 12px; 467 - transition: border-color 0.2s, background 0.2s; 473 + .btn-confirm { 474 + background: rgba(239, 68, 68, 0.15); 475 + color: var(--error); 476 + border: 1px solid rgba(239, 68, 68, 0.3); 477 + } 478 + .btn-confirm:hover { 479 + background: rgba(239, 68, 68, 0.25); 468 480 } 469 481 470 - .review-card.approved { border-color: #2d5a27; background: #1a2a18; } 471 - .review-card.rejected { border-color: #5a2727; background: #2a1818; } 472 - .review-card.resolved { opacity: 0.6; } 473 - .track-info { margin-bottom: 8px; } 474 - .title { font-weight: 600; font-size: 1rem; margin-bottom: 2px; } 475 - .title a { color: inherit; text-decoration: none; } 476 - .title a:hover { text-decoration: underline; } 477 - .artist { font-size: 0.875rem; color: #888; } 478 - .matches { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; } 479 - .match { font-size: 0.75rem; background: #2a2a2a; padding: 2px 6px; border-radius: 4px; color: #aaa; } 480 - .badge { display: inline-block; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 500; } 481 - .badge.resolved { background: #1a3a1a; color: #6d9; } 482 - .actions { display: flex; gap: 8px; margin-top: 10px; } 483 - .actions button { flex: 1; padding: 10px; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: opacity 0.2s; } 484 - .btn-approve { background: #2d5a27; color: #fff; } 485 - .btn-reject { background: #5a2727; color: #fff; } 486 - .actions button:active { opacity: 0.8; } 482 + /* card selection states */ 483 + .flag-card.decision-clear { 484 + border-color: var(--success); 485 + background: rgba(74, 222, 128, 0.05); 486 + } 487 + .flag-card.decision-defer { 488 + border-color: var(--warning); 489 + background: rgba(251, 191, 36, 0.05); 490 + } 491 + .flag-card.decision-confirm { 492 + border-color: var(--error); 493 + background: rgba(239, 68, 68, 0.05); 494 + } 487 495 496 + /* submit bar */ 488 497 .submit-bar { 489 498 position: fixed; 490 499 bottom: 0; 491 500 left: 0; 492 501 right: 0; 493 - padding: 12px 16px; 494 - background: #111; 495 - border-top: 1px solid #333; 502 + padding: 16px 24px; 503 + background: var(--bg-secondary); 504 + border-top: 1px solid var(--border-subtle); 496 505 } 497 - 498 - .btn-submit { 506 + .submit-bar .btn { 499 507 width: 100%; 500 - max-width: 600px; 508 + max-width: 900px; 501 509 margin: 0 auto; 502 510 display: block; 503 511 padding: 14px; 504 - background: #4a9eff; 505 - color: #fff; 506 - border: none; 507 - border-radius: 8px; 508 - font-size: 1rem; 509 - font-weight: 600; 510 - cursor: pointer; 511 512 } 512 513 513 - .btn-submit:disabled { background: #333; color: #666; cursor: not-allowed; } 514 - .empty { text-align: center; padding: 40px 20px; color: #666; } 515 - .resolved-section { margin-top: 20px; border-top: 1px solid #333; padding-top: 16px; } 516 - .resolved-section summary { cursor: pointer; color: #888; font-size: 0.875rem; margin-bottom: 12px; } 514 + /* resolved section */ 515 + .resolved-section { 516 + margin-top: 24px; 517 + padding-top: 16px; 518 + border-top: 1px solid var(--border-subtle); 519 + } 520 + .resolved-section summary { 521 + cursor: pointer; 522 + color: var(--text-tertiary); 523 + font-size: 0.85rem; 524 + margin-bottom: 12px; 525 + } 517 526 "#;