refactor: extract admin UI to static files (#395)

- Move HTML/CSS/JS from inline const to static/admin.{html,css,js}
- Serve static files via tower-http ServeDir
- Add Io error variant for file read errors
- Update Dockerfile to copy static directory

admin.rs: 730 → 310 lines

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

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

authored by zzstoatzz.io Claude and committed by GitHub 4486cff9 0b965f3b

Changed files
+596 -320
moderation
+2
moderation/Dockerfile
··· 10 10 11 11 RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* 12 12 13 + WORKDIR /app 13 14 COPY --from=builder /app/target/release/moderation /usr/local/bin/moderation 15 + COPY static ./static 14 16 15 17 CMD ["moderation"]
+169 -320
moderation/src/admin.rs
··· 1 1 //! Admin API for reviewing and resolving copyright flags. 2 + //! 3 + //! Uses htmx for interactivity with server-rendered HTML. 2 4 3 - use axum::{extract::State, response::Html, Json}; 5 + use axum::{ 6 + extract::State, 7 + http::header::CONTENT_TYPE, 8 + response::{IntoResponse, Response}, 9 + Json, 10 + }; 4 11 use serde::{Deserialize, Serialize}; 5 12 6 13 use crate::db::LabelContext; ··· 13 20 pub uri: String, 14 21 pub val: String, 15 22 pub created_at: String, 16 - /// If there's a negation label for this URI+val, it's been resolved. 23 + /// Track status: pending review, resolved (false positive), or confirmed (takedown). 17 24 pub resolved: bool, 18 25 /// Optional context about the track (title, artist, matches). 19 26 #[serde(skip_serializing_if = "Option::is_none")] ··· 68 75 pub message: String, 69 76 } 70 77 71 - /// List all flagged tracks (copyright-violation labels without negations). 78 + /// List all flagged tracks - returns JSON for API, HTML for htmx. 72 79 pub async fn list_flagged( 73 80 State(state): State<AppState>, 74 81 ) -> Result<Json<ListFlaggedResponse>, AppError> { 75 82 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 83 + let tracks = db.get_pending_flags().await?; 84 + Ok(Json(ListFlaggedResponse { tracks })) 85 + } 76 86 87 + /// Render flags as HTML partial for htmx. 88 + pub async fn list_flagged_html(State(state): State<AppState>) -> Result<Response, AppError> { 89 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 77 90 let tracks = db.get_pending_flags().await?; 78 91 79 - Ok(Json(ListFlaggedResponse { tracks })) 92 + let html = render_flags_list(&tracks); 93 + 94 + Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 80 95 } 81 96 82 97 /// Resolve (negate) a copyright flag, marking it as a false positive. ··· 106 121 })) 107 122 } 108 123 124 + /// Resolve flag and return HTML response for htmx. 125 + pub async fn resolve_flag_htmx( 126 + State(state): State<AppState>, 127 + axum::Form(request): axum::Form<ResolveRequest>, 128 + ) -> Result<Response, AppError> { 129 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 130 + let signer = state.signer.as_ref().ok_or(AppError::LabelerNotConfigured)?; 131 + 132 + tracing::info!(uri = %request.uri, val = %request.val, "resolving flag via htmx"); 133 + 134 + // Create a negation label 135 + let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated(); 136 + let label = signer.sign_label(label)?; 137 + 138 + let seq = db.store_label(&label).await?; 139 + 140 + // Broadcast to subscribers 141 + if let Some(tx) = &state.label_tx { 142 + let _ = tx.send((seq, label)); 143 + } 144 + 145 + // Return success toast + trigger refresh 146 + let html = format!( 147 + r#"<div id="toast" class="toast success" hx-swap-oob="true">resolved (seq: {})</div>"#, 148 + seq 149 + ); 150 + 151 + Ok(( 152 + [ 153 + (CONTENT_TYPE, "text/html; charset=utf-8"), 154 + ], 155 + [(axum::http::header::HeaderName::from_static("hx-trigger"), "flagsUpdated")], 156 + html, 157 + ) 158 + .into_response()) 159 + } 160 + 109 161 /// Store context for a label (for backfill without re-emitting labels). 110 162 pub async fn store_context( 111 163 State(state): State<AppState>, ··· 130 182 })) 131 183 } 132 184 133 - /// Serve the admin UI HTML. 134 - pub async fn admin_ui() -> Html<&'static str> { 135 - Html(ADMIN_HTML) 185 + /// Serve the admin UI HTML from static file. 186 + pub async fn admin_ui() -> Result<Response, AppError> { 187 + let html = tokio::fs::read_to_string("static/admin.html").await?; 188 + Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 136 189 } 137 190 138 - const ADMIN_HTML: &str = r##"<!DOCTYPE html> 139 - <html> 140 - <head> 141 - <meta charset="utf-8"> 142 - <meta name="viewport" content="width=device-width, initial-scale=1"> 143 - <title>plyr.fm moderation admin</title> 144 - <style> 145 - * { box-sizing: border-box; } 146 - body { 147 - font-family: system-ui, -apple-system, sans-serif; 148 - background: #0a0a0a; 149 - color: #e5e5e5; 150 - max-width: 1100px; 151 - margin: 0 auto; 152 - padding: 20px; 153 - line-height: 1.6; 154 - } 155 - h1 { color: #fff; margin-bottom: 8px; } 156 - .subtitle { color: #888; margin-bottom: 24px; } 157 - .auth-form { 158 - background: #111; 159 - border: 1px solid #222; 160 - border-radius: 8px; 161 - padding: 20px; 162 - margin-bottom: 24px; 163 - } 164 - .auth-form input { 165 - background: #1a1a1a; 166 - border: 1px solid #333; 167 - color: #fff; 168 - padding: 8px 12px; 169 - border-radius: 4px; 170 - width: 300px; 171 - margin-right: 8px; 172 - } 173 - .auth-form button { 174 - background: #3b82f6; 175 - color: #fff; 176 - border: none; 177 - padding: 8px 16px; 178 - border-radius: 4px; 179 - cursor: pointer; 180 - } 181 - .auth-form button:hover { background: #2563eb; } 182 - .status { 183 - padding: 8px 12px; 184 - border-radius: 4px; 185 - margin-bottom: 16px; 186 - display: none; 187 - } 188 - .status.error { display: block; background: rgba(239, 68, 68, 0.2); color: #ef4444; } 189 - .status.success { display: block; background: rgba(34, 197, 94, 0.2); color: #22c55e; } 190 - .flags-list { 191 - display: flex; 192 - flex-direction: column; 193 - gap: 12px; 194 - } 195 - .flag-card { 196 - background: #111; 197 - border: 1px solid #222; 198 - border-radius: 8px; 199 - padding: 16px; 200 - } 201 - .flag-card.resolved { opacity: 0.6; } 202 - .flag-header { 203 - display: flex; 204 - justify-content: space-between; 205 - align-items: flex-start; 206 - margin-bottom: 12px; 207 - } 208 - .track-info h3 { 209 - margin: 0 0 4px 0; 210 - color: #fff; 211 - font-size: 1.1em; 212 - } 213 - .track-info .artist { 214 - color: #888; 215 - font-size: 0.9em; 216 - } 217 - .track-info .uri { 218 - font-family: monospace; 219 - font-size: 0.75em; 220 - color: #666; 221 - word-break: break-all; 222 - margin-top: 4px; 223 - } 224 - .flag-badges { 225 - display: flex; 226 - gap: 8px; 227 - align-items: center; 228 - } 229 - .badge { 230 - display: inline-block; 231 - padding: 2px 8px; 232 - border-radius: 4px; 233 - font-size: 0.8em; 234 - } 235 - .badge.pending { background: rgba(234, 179, 8, 0.2); color: #eab308; } 236 - .badge.resolved { background: rgba(34, 197, 94, 0.2); color: #22c55e; } 237 - .badge.score { background: rgba(239, 68, 68, 0.2); color: #ef4444; } 238 - .matches { 239 - background: #0a0a0a; 240 - border-radius: 4px; 241 - padding: 12px; 242 - margin-top: 12px; 243 - } 244 - .matches h4 { 245 - margin: 0 0 8px 0; 246 - color: #888; 247 - font-size: 0.85em; 248 - font-weight: 500; 249 - } 250 - .match-item { 251 - display: flex; 252 - justify-content: space-between; 253 - padding: 6px 0; 254 - border-bottom: 1px solid #1a1a1a; 255 - font-size: 0.9em; 256 - } 257 - .match-item:last-child { border-bottom: none; } 258 - .match-item .title { color: #e5e5e5; } 259 - .match-item .artist { color: #888; } 260 - .match-item .score { 261 - color: #ef4444; 262 - font-family: monospace; 263 - } 264 - .flag-actions { 265 - display: flex; 266 - justify-content: flex-end; 267 - margin-top: 12px; 268 - padding-top: 12px; 269 - border-top: 1px solid #222; 270 - } 271 - .resolve-btn { 272 - background: #f59e0b; 273 - color: #000; 274 - border: none; 275 - padding: 8px 16px; 276 - border-radius: 4px; 277 - cursor: pointer; 278 - font-size: 0.85em; 279 - font-weight: 500; 280 - } 281 - .resolve-btn:hover { background: #d97706; } 282 - .resolve-btn:disabled { background: #333; color: #666; cursor: not-allowed; } 283 - .empty { color: #666; text-align: center; padding: 40px; } 284 - .loading { color: #888; text-align: center; padding: 40px; } 285 - .refresh-btn { 286 - background: #333; 287 - color: #fff; 288 - border: none; 289 - padding: 8px 16px; 290 - border-radius: 4px; 291 - cursor: pointer; 292 - margin-left: auto; 293 - } 294 - .refresh-btn:hover { background: #444; } 295 - .header-row { 296 - display: flex; 297 - align-items: center; 298 - margin-bottom: 16px; 299 - } 300 - .header-row h2 { margin: 0; } 301 - .no-context { color: #666; font-style: italic; font-size: 0.9em; } 302 - </style> 303 - </head> 304 - <body> 305 - <h1>moderation admin</h1> 306 - <p class="subtitle">review and resolve copyright flags</p> 191 + /// Render the flags list as HTML. 192 + fn render_flags_list(tracks: &[FlaggedTrack]) -> String { 193 + if tracks.is_empty() { 194 + return r#"<div class="empty">no flagged tracks</div>"#.to_string(); 195 + } 307 196 308 - <div class="auth-form"> 309 - <input type="password" id="token" placeholder="moderation auth token"> 310 - <button onclick="authenticate()">authenticate</button> 311 - </div> 312 - 313 - <div id="status" class="status"></div> 314 - 315 - <div id="content" style="display: none;"> 316 - <div class="header-row"> 317 - <h2>flagged tracks</h2> 318 - <button class="refresh-btn" onclick="loadFlags()">refresh</button> 319 - </div> 320 - <div id="flags-list" class="flags-list"> 321 - <div class="loading">loading...</div> 322 - </div> 323 - </div> 324 - 325 - <script> 326 - let authToken = localStorage.getItem('moderation_token') || ''; 327 - 328 - if (authToken) { 329 - document.getElementById('token').value = '••••••••'; 330 - showContent(); 331 - loadFlags(); 332 - } 333 - 334 - function authenticate() { 335 - authToken = document.getElementById('token').value; 336 - localStorage.setItem('moderation_token', authToken); 337 - showContent(); 338 - loadFlags(); 339 - } 340 - 341 - function showContent() { 342 - document.getElementById('content').style.display = 'block'; 343 - } 344 - 345 - function showStatus(message, type) { 346 - const status = document.getElementById('status'); 347 - status.textContent = message; 348 - status.className = 'status ' + type; 349 - } 350 - 351 - async function loadFlags() { 352 - const container = document.getElementById('flags-list'); 353 - container.innerHTML = '<div class="loading">loading...</div>'; 354 - 355 - try { 356 - const res = await fetch('/admin/flags', { 357 - headers: { 'X-Moderation-Key': authToken } 358 - }); 359 - 360 - if (!res.ok) { 361 - if (res.status === 401) { 362 - showStatus('invalid token', 'error'); 363 - localStorage.removeItem('moderation_token'); 364 - return; 365 - } 366 - throw new Error('failed to load flags'); 367 - } 368 - 369 - const data = await res.json(); 197 + let cards: Vec<String> = tracks.iter().map(render_flag_card).collect(); 198 + cards.join("\n") 199 + } 370 200 371 - if (data.tracks.length === 0) { 372 - container.innerHTML = '<div class="empty">no flagged tracks</div>'; 373 - return; 374 - } 201 + /// Render a single flag card as HTML. 202 + fn render_flag_card(track: &FlaggedTrack) -> String { 203 + let ctx = track.context.as_ref(); 204 + let has_context = ctx.map_or(false, |c| c.track_title.is_some() || c.artist_handle.is_some()); 375 205 376 - container.innerHTML = data.tracks.map(track => { 377 - const ctx = track.context || {}; 378 - const hasContext = ctx.track_title || ctx.artist_handle; 379 - const matches = ctx.matches || []; 206 + let track_info = if has_context { 207 + let c = ctx.unwrap(); 208 + format!( 209 + r#"<h3>{}</h3> 210 + <div class="artist">by @{}</div>"#, 211 + html_escape(c.track_title.as_deref().unwrap_or("unknown track")), 212 + html_escape(c.artist_handle.as_deref().unwrap_or("unknown")) 213 + ) 214 + } else { 215 + r#"<div class="no-context">no track info available</div>"#.to_string() 216 + }; 380 217 381 - return ` 382 - <div class="flag-card ${track.resolved ? 'resolved' : ''}"> 383 - <div class="flag-header"> 384 - <div class="track-info"> 385 - ${hasContext ? ` 386 - <h3>${escapeHtml(ctx.track_title || 'unknown track')}</h3> 387 - <div class="artist">by @${escapeHtml(ctx.artist_handle || 'unknown')}</div> 388 - ` : ` 389 - <div class="no-context">no track info available</div> 390 - `} 391 - <div class="uri">${escapeHtml(track.uri)}</div> 392 - </div> 393 - <div class="flag-badges"> 394 - ${ctx.highest_score ? `<span class="badge score">${(ctx.highest_score * 100).toFixed(0)}% match</span>` : ''} 395 - <span class="badge ${track.resolved ? 'resolved' : 'pending'}">${track.resolved ? 'resolved' : 'pending'}</span> 396 - </div> 397 - </div> 398 - ${matches.length > 0 ? ` 399 - <div class="matches"> 400 - <h4>potential matches</h4> 401 - ${matches.slice(0, 3).map(m => ` 402 - <div class="match-item"> 403 - <span><span class="title">${escapeHtml(m.title)}</span> <span class="artist">by ${escapeHtml(m.artist)}</span></span> 404 - <span class="score">${(m.score * 100).toFixed(0)}%</span> 405 - </div> 406 - `).join('')} 407 - </div> 408 - ` : ''} 409 - <div class="flag-actions"> 410 - <button class="resolve-btn" 411 - onclick="resolveFlag('${escapeHtml(track.uri)}', '${escapeHtml(track.val)}')" 412 - ${track.resolved ? 'disabled' : ''}> 413 - ${track.resolved ? 'resolved' : 'mark false positive'} 414 - </button> 415 - </div> 416 - </div> 417 - `; 418 - }).join(''); 218 + let score_badge = ctx 219 + .and_then(|c| c.highest_score) 220 + .filter(|&s| s > 0.0) 221 + .map(|s| format!(r#"<span class="badge score">{}% match</span>"#, (s * 100.0) as i32)) 222 + .unwrap_or_default(); 419 223 420 - } catch (err) { 421 - container.innerHTML = `<div class="empty">error: ${err.message}</div>`; 422 - } 423 - } 224 + let status_badge = if track.resolved { 225 + r#"<span class="badge resolved">resolved</span>"# 226 + } else { 227 + r#"<span class="badge pending">pending</span>"# 228 + }; 424 229 425 - async function resolveFlag(uri, val) { 426 - try { 427 - const res = await fetch('/admin/resolve', { 428 - method: 'POST', 429 - headers: { 430 - 'Content-Type': 'application/json', 431 - 'X-Moderation-Key': authToken 432 - }, 433 - body: JSON.stringify({ uri, val }) 434 - }); 230 + let matches_html = ctx 231 + .and_then(|c| c.matches.as_ref()) 232 + .filter(|m| !m.is_empty()) 233 + .map(|matches| { 234 + let items: Vec<String> = matches 235 + .iter() 236 + .take(3) 237 + .map(|m| { 238 + format!( 239 + r#"<div class="match-item"> 240 + <span><span class="title">{}</span> <span class="artist">by {}</span></span> 241 + <span class="score">{}%</span> 242 + </div>"#, 243 + html_escape(&m.title), 244 + html_escape(&m.artist), 245 + (m.score * 100.0) as i32 246 + ) 247 + }) 248 + .collect(); 249 + format!( 250 + r#"<div class="matches"> 251 + <h4>potential matches</h4> 252 + {} 253 + </div>"#, 254 + items.join("\n") 255 + ) 256 + }) 257 + .unwrap_or_default(); 435 258 436 - if (!res.ok) { 437 - const data = await res.json(); 438 - throw new Error(data.message || 'failed to resolve'); 439 - } 259 + let action_button = if track.resolved { 260 + r#"<button class="btn btn-secondary" disabled>resolved</button>"#.to_string() 261 + } else { 262 + format!( 263 + r#"<form hx-post="/admin/resolve-htmx" hx-swap="none" style="display:inline"> 264 + <input type="hidden" name="uri" value="{}"> 265 + <input type="hidden" name="val" value="{}"> 266 + <button type="submit" class="btn btn-warning">mark false positive</button> 267 + </form>"#, 268 + html_escape(&track.uri), 269 + html_escape(&track.val) 270 + ) 271 + }; 440 272 441 - showStatus('flag resolved successfully', 'success'); 442 - loadFlags(); 273 + let resolved_class = if track.resolved { " resolved" } else { "" }; 443 274 444 - } catch (err) { 445 - showStatus(err.message, 'error'); 446 - } 447 - } 275 + format!( 276 + r#"<div class="flag-card{}"> 277 + <div class="flag-header"> 278 + <div class="track-info"> 279 + {} 280 + <div class="uri">{}</div> 281 + </div> 282 + <div class="flag-badges"> 283 + {} 284 + {} 285 + </div> 286 + </div> 287 + {} 288 + <div class="flag-actions"> 289 + {} 290 + </div> 291 + </div>"#, 292 + resolved_class, 293 + track_info, 294 + html_escape(&track.uri), 295 + score_badge, 296 + status_badge, 297 + matches_html, 298 + action_button 299 + ) 300 + } 448 301 449 - function escapeHtml(str) { 450 - if (!str) return ''; 451 - return str.replace(/&/g, '&amp;') 452 - .replace(/</g, '&lt;') 453 - .replace(/>/g, '&gt;') 454 - .replace(/"/g, '&quot;') 455 - .replace(/'/g, '&#039;'); 456 - } 457 - </script> 458 - </body> 459 - </html> 460 - "##; 302 + /// Simple HTML escaping. 303 + fn html_escape(s: &str) -> String { 304 + s.replace('&', "&amp;") 305 + .replace('<', "&lt;") 306 + .replace('>', "&gt;") 307 + .replace('"', "&quot;") 308 + .replace('\'', "&#039;") 309 + }
+5
moderation/src/main.rs
··· 15 15 Router, 16 16 }; 17 17 use tokio::{net::TcpListener, sync::broadcast}; 18 + use tower_http::services::ServeDir; 18 19 use tracing::{info, warn}; 19 20 20 21 mod admin; ··· 78 79 // Admin UI and API 79 80 .route("/admin", get(admin::admin_ui)) 80 81 .route("/admin/flags", get(admin::list_flagged)) 82 + .route("/admin/flags-html", get(admin::list_flagged_html)) 81 83 .route("/admin/resolve", post(admin::resolve_flag)) 84 + .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx)) 82 85 .route("/admin/context", post(admin::store_context)) 86 + // Static files (CSS, JS for admin UI) 87 + .nest_service("/static", ServeDir::new("static")) 83 88 // ATProto XRPC endpoints (public) 84 89 .route("/xrpc/com.atproto.label.queryLabels", get(xrpc::query_labels)) 85 90 .route(
+4
moderation/src/state.rs
··· 37 37 38 38 #[error("database error: {0}")] 39 39 Database(#[from] sqlx::Error), 40 + 41 + #[error("io error: {0}")] 42 + Io(#[from] std::io::Error), 40 43 } 41 44 42 45 impl IntoResponse for AppError { ··· 49 52 } 50 53 AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"), 51 54 AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"), 55 + AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"), 52 56 }; 53 57 let body = serde_json::json!({ 54 58 "error": error_type,
+325
moderation/static/admin.css
··· 1 + /* plyr.fm design tokens */ 2 + :root { 3 + --accent: #6a9fff; 4 + --accent-hover: #8ab3ff; 5 + --accent-muted: #4a7ddd; 6 + 7 + --bg-primary: #0a0a0a; 8 + --bg-secondary: #141414; 9 + --bg-tertiary: #1a1a1a; 10 + --bg-hover: #1f1f1f; 11 + 12 + --border-subtle: #282828; 13 + --border-default: #333333; 14 + --border-emphasis: #444444; 15 + 16 + --text-primary: #e8e8e8; 17 + --text-secondary: #b0b0b0; 18 + --text-tertiary: #808080; 19 + --text-muted: #666666; 20 + 21 + --success: #4ade80; 22 + --warning: #fbbf24; 23 + --error: #ef4444; 24 + } 25 + 26 + * { box-sizing: border-box; } 27 + 28 + body { 29 + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; 30 + background: var(--bg-primary); 31 + color: var(--text-primary); 32 + max-width: 900px; 33 + margin: 0 auto; 34 + padding: 24px; 35 + line-height: 1.6; 36 + -webkit-font-smoothing: antialiased; 37 + } 38 + 39 + h1 { 40 + color: var(--text-primary); 41 + font-size: 1.5rem; 42 + font-weight: 600; 43 + margin: 0 0 4px 0; 44 + } 45 + 46 + .subtitle { 47 + color: var(--text-tertiary); 48 + margin: 0 0 32px 0; 49 + font-size: 0.9rem; 50 + } 51 + 52 + /* auth form */ 53 + .auth-section { 54 + background: var(--bg-secondary); 55 + border: 1px solid var(--border-subtle); 56 + border-radius: 8px; 57 + padding: 20px; 58 + margin-bottom: 24px; 59 + } 60 + 61 + .auth-section input[type="password"] { 62 + font-family: inherit; 63 + background: var(--bg-tertiary); 64 + border: 1px solid var(--border-default); 65 + color: var(--text-primary); 66 + padding: 10px 14px; 67 + border-radius: 6px; 68 + width: 280px; 69 + font-size: 0.9rem; 70 + } 71 + 72 + .auth-section input:focus { 73 + outline: none; 74 + border-color: var(--accent); 75 + } 76 + 77 + /* buttons */ 78 + .btn { 79 + font-family: inherit; 80 + font-size: 0.85rem; 81 + font-weight: 500; 82 + padding: 10px 16px; 83 + border-radius: 6px; 84 + border: none; 85 + cursor: pointer; 86 + transition: all 0.15s ease; 87 + } 88 + 89 + .btn-primary { 90 + background: var(--accent); 91 + color: var(--bg-primary); 92 + } 93 + .btn-primary:hover { background: var(--accent-hover); } 94 + 95 + .btn-secondary { 96 + background: var(--bg-tertiary); 97 + color: var(--text-secondary); 98 + border: 1px solid var(--border-default); 99 + } 100 + .btn-secondary:hover { 101 + background: var(--bg-hover); 102 + border-color: var(--border-emphasis); 103 + } 104 + 105 + .btn-warning { 106 + background: var(--warning); 107 + color: var(--bg-primary); 108 + } 109 + .btn-warning:hover { 110 + background: #d97706; 111 + } 112 + 113 + .btn:disabled { 114 + background: var(--bg-tertiary); 115 + color: var(--text-muted); 116 + cursor: not-allowed; 117 + border: 1px solid var(--border-subtle); 118 + } 119 + 120 + /* header row */ 121 + .header-row { 122 + display: flex; 123 + align-items: center; 124 + justify-content: space-between; 125 + margin-bottom: 20px; 126 + } 127 + 128 + .header-row h2 { 129 + margin: 0; 130 + font-size: 1.1rem; 131 + font-weight: 500; 132 + color: var(--text-primary); 133 + } 134 + 135 + /* flags list */ 136 + .flags-list { 137 + display: flex; 138 + flex-direction: column; 139 + gap: 16px; 140 + } 141 + 142 + .flag-card { 143 + background: var(--bg-secondary); 144 + border: 1px solid var(--border-subtle); 145 + border-radius: 8px; 146 + padding: 20px; 147 + } 148 + 149 + .flag-card.resolved { 150 + opacity: 0.5; 151 + } 152 + 153 + .flag-header { 154 + display: flex; 155 + justify-content: space-between; 156 + align-items: flex-start; 157 + gap: 16px; 158 + } 159 + 160 + .track-info { 161 + flex: 1; 162 + min-width: 0; 163 + } 164 + 165 + .track-info h3 { 166 + margin: 0 0 4px 0; 167 + font-size: 1rem; 168 + font-weight: 500; 169 + color: var(--text-primary); 170 + } 171 + 172 + .track-info .artist { 173 + color: var(--text-secondary); 174 + font-size: 0.9rem; 175 + } 176 + 177 + .track-info .uri { 178 + font-size: 0.75rem; 179 + color: var(--text-muted); 180 + word-break: break-all; 181 + margin-top: 8px; 182 + } 183 + 184 + .flag-badges { 185 + display: flex; 186 + gap: 8px; 187 + flex-shrink: 0; 188 + } 189 + 190 + .badge { 191 + display: inline-block; 192 + padding: 4px 10px; 193 + border-radius: 4px; 194 + font-size: 0.75rem; 195 + font-weight: 500; 196 + } 197 + 198 + .badge.pending { 199 + background: rgba(251, 191, 36, 0.15); 200 + color: var(--warning); 201 + } 202 + 203 + .badge.resolved { 204 + background: rgba(74, 222, 128, 0.15); 205 + color: var(--success); 206 + } 207 + 208 + .badge.score { 209 + background: rgba(239, 68, 68, 0.15); 210 + color: var(--error); 211 + } 212 + 213 + /* matches section */ 214 + .matches { 215 + background: var(--bg-primary); 216 + border-radius: 6px; 217 + padding: 14px; 218 + margin-top: 16px; 219 + } 220 + 221 + .matches h4 { 222 + margin: 0 0 10px 0; 223 + color: var(--text-tertiary); 224 + font-size: 0.8rem; 225 + font-weight: 500; 226 + text-transform: lowercase; 227 + } 228 + 229 + .match-item { 230 + display: flex; 231 + justify-content: space-between; 232 + align-items: center; 233 + padding: 8px 0; 234 + border-bottom: 1px solid var(--border-subtle); 235 + font-size: 0.85rem; 236 + } 237 + 238 + .match-item:last-child { border-bottom: none; } 239 + 240 + .match-item .title { 241 + color: var(--text-primary); 242 + } 243 + 244 + .match-item .artist { 245 + color: var(--text-tertiary); 246 + } 247 + 248 + .match-item .score { 249 + color: var(--error); 250 + font-weight: 500; 251 + } 252 + 253 + /* actions */ 254 + .flag-actions { 255 + display: flex; 256 + justify-content: flex-end; 257 + gap: 10px; 258 + margin-top: 16px; 259 + padding-top: 16px; 260 + border-top: 1px solid var(--border-subtle); 261 + } 262 + 263 + /* states */ 264 + .empty, .loading { 265 + color: var(--text-muted); 266 + text-align: center; 267 + padding: 48px 24px; 268 + } 269 + 270 + .no-context { 271 + color: var(--text-muted); 272 + font-style: italic; 273 + } 274 + 275 + /* toast */ 276 + .toast { 277 + position: fixed; 278 + bottom: 24px; 279 + left: 24px; 280 + padding: 12px 20px; 281 + border-radius: 6px; 282 + font-size: 0.85rem; 283 + animation: fadeInUp 0.2s ease, fadeOut 0.3s ease 2.7s forwards; 284 + } 285 + 286 + .toast.success { 287 + background: rgba(74, 222, 128, 0.15); 288 + color: var(--success); 289 + border: 1px solid rgba(74, 222, 128, 0.3); 290 + } 291 + 292 + .toast.error { 293 + background: rgba(239, 68, 68, 0.15); 294 + color: var(--error); 295 + border: 1px solid rgba(239, 68, 68, 0.3); 296 + } 297 + 298 + @keyframes fadeInUp { 299 + from { opacity: 0; transform: translateY(10px); } 300 + to { opacity: 1; transform: translateY(0); } 301 + } 302 + 303 + @keyframes fadeOut { 304 + to { opacity: 0; } 305 + } 306 + 307 + /* htmx indicator */ 308 + .htmx-indicator { 309 + opacity: 0; 310 + transition: opacity 0.2s ease; 311 + } 312 + .htmx-request .htmx-indicator { 313 + opacity: 1; 314 + } 315 + .htmx-request.htmx-indicator { 316 + opacity: 1; 317 + } 318 + 319 + /* mobile */ 320 + @media (max-width: 640px) { 321 + body { padding: 16px; } 322 + .auth-section input[type="password"] { width: 100%; margin-bottom: 12px; } 323 + .flag-header { flex-direction: column; } 324 + .flag-badges { margin-top: 12px; } 325 + }
+48
moderation/static/admin.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>moderation · plyr.fm</title> 7 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 8 + <link rel="stylesheet" href="/static/admin.css"> 9 + </head> 10 + <body> 11 + <h1>moderation</h1> 12 + <p class="subtitle">review and resolve copyright flags</p> 13 + 14 + <div class="auth-section"> 15 + <input type="password" 16 + id="auth-token" 17 + placeholder="auth token" 18 + onkeyup="if(event.key==='Enter')authenticate()"> 19 + <button class="btn btn-primary" onclick="authenticate()" style="margin-left: 10px"> 20 + authenticate 21 + </button> 22 + </div> 23 + 24 + <div id="main-content" style="display: none;"> 25 + <div class="header-row"> 26 + <h2>flagged tracks</h2> 27 + <button class="btn btn-secondary" 28 + hx-get="/admin/flags-html" 29 + hx-target="#flags-list" 30 + hx-indicator="#refresh-indicator"> 31 + <span id="refresh-indicator" class="htmx-indicator">...</span> 32 + refresh 33 + </button> 34 + </div> 35 + 36 + <div id="flags-list" class="flags-list" 37 + hx-get="/admin/flags-html" 38 + hx-trigger="load, flagsUpdated from:body" 39 + hx-indicator="#loading"> 40 + <div id="loading" class="loading htmx-indicator">loading...</div> 41 + </div> 42 + </div> 43 + 44 + <div id="toast"></div> 45 + 46 + <script src="/static/admin.js"></script> 47 + </body> 48 + </html>
+43
moderation/static/admin.js
··· 1 + // Check for saved token on load 2 + const savedToken = localStorage.getItem('mod_token'); 3 + if (savedToken) { 4 + document.getElementById('auth-token').value = '••••••••'; 5 + showMain(); 6 + setAuthHeader(savedToken); 7 + } 8 + 9 + function authenticate() { 10 + const token = document.getElementById('auth-token').value; 11 + if (token && token !== '••••••••') { 12 + localStorage.setItem('mod_token', token); 13 + setAuthHeader(token); 14 + showMain(); 15 + htmx.trigger('#flags-list', 'load'); 16 + } 17 + } 18 + 19 + function showMain() { 20 + document.getElementById('main-content').style.display = 'block'; 21 + } 22 + 23 + function setAuthHeader(token) { 24 + document.body.addEventListener('htmx:configRequest', function(evt) { 25 + evt.detail.headers['X-Moderation-Key'] = token; 26 + }); 27 + } 28 + 29 + // Handle auth errors 30 + document.body.addEventListener('htmx:responseError', function(evt) { 31 + if (evt.detail.xhr.status === 401) { 32 + localStorage.removeItem('mod_token'); 33 + showToast('invalid token', 'error'); 34 + } 35 + }); 36 + 37 + function showToast(message, type) { 38 + const toast = document.getElementById('toast'); 39 + toast.className = 'toast ' + type; 40 + toast.textContent = message; 41 + toast.style.display = 'block'; 42 + setTimeout(() => { toast.style.display = 'none'; }, 3000); 43 + }