fix: admin UI styling + add artist/track links (#414)

* fix: admin UI styling + add artist/track links

- Fix badge layout (stack vertically, align right)
- Change matches badge to blue (distinct from pending yellow)
- Add link to artist profile (plyr.fm/u/{handle})
- Add link to track page when track_id is available
- Store track_id in label context for future flags

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: update test to include track_id parameter

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

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

authored by zzstoatzz.io Claude and committed by GitHub d1f18ac7 32711970

Changed files
+94 -30
backend
src
backend
_internal
tests
moderation
+5 -1
backend/src/backend/_internal/moderation.py
··· 121 await _emit_copyright_label( 122 uri=track.atproto_record_uri, 123 cid=track.atproto_record_cid, 124 track_title=track.title, 125 artist_handle=track.artist.handle if track.artist else None, 126 artist_did=track.artist_did, ··· 132 async def _emit_copyright_label( 133 uri: str, 134 cid: str | None, 135 track_title: str | None = None, 136 artist_handle: str | None = None, 137 artist_did: str | None = None, ··· 145 args: 146 uri: AT URI of the track record 147 cid: optional CID of the record 148 track_title: title of the track (for admin UI context) 149 artist_handle: handle of the artist (for admin UI context) 150 artist_did: DID of the artist (for admin UI context) ··· 154 try: 155 # build context for admin UI display 156 context: dict[str, Any] | None = None 157 - if track_title or artist_handle or matches: 158 context = { 159 "track_title": track_title, 160 "artist_handle": artist_handle, 161 "artist_did": artist_did,
··· 121 await _emit_copyright_label( 122 uri=track.atproto_record_uri, 123 cid=track.atproto_record_cid, 124 + track_id=track_id, 125 track_title=track.title, 126 artist_handle=track.artist.handle if track.artist else None, 127 artist_did=track.artist_did, ··· 133 async def _emit_copyright_label( 134 uri: str, 135 cid: str | None, 136 + track_id: int | None = None, 137 track_title: str | None = None, 138 artist_handle: str | None = None, 139 artist_did: str | None = None, ··· 147 args: 148 uri: AT URI of the track record 149 cid: optional CID of the record 150 + track_id: database ID of the track (for admin UI links) 151 track_title: title of the track (for admin UI context) 152 artist_handle: handle of the artist (for admin UI context) 153 artist_did: DID of the artist (for admin UI context) ··· 157 try: 158 # build context for admin UI display 159 context: dict[str, Any] | None = None 160 + if track_id or track_title or artist_handle or matches: 161 context = { 162 + "track_id": track_id, 163 "track_title": track_title, 164 "artist_handle": artist_handle, 165 "artist_did": artist_did,
+1
backend/tests/test_moderation.py
··· 158 mock_emit.assert_called_once_with( 159 uri="at://did:plc:labelertest/fm.plyr.track/abc123", 160 cid="bafyreiabc123", 161 track_title="Labeler Test Track", 162 artist_handle="labeler.bsky.social", 163 artist_did="did:plc:labelertest",
··· 158 mock_emit.assert_called_once_with( 159 uri="at://did:plc:labelertest/fm.plyr.track/abc123", 160 cid="bafyreiabc123", 161 + track_id=track.id, 162 track_title="Labeler Test Track", 163 artist_handle="labeler.bsky.social", 164 artist_did="did:plc:labelertest",
+29 -3
moderation/src/admin.rs
··· 78 /// Context payload for storage. 79 #[derive(Debug, Deserialize)] 80 pub struct ContextPayload { 81 pub track_title: Option<String>, 82 pub artist_handle: Option<String>, 83 pub artist_did: Option<String>, ··· 225 tracing::info!(uri = %request.uri, "storing label context"); 226 227 let label_ctx = LabelContext { 228 track_title: request.context.track_title, 229 artist_handle: request.context.artist_handle, 230 artist_did: request.context.artist_did, ··· 306 307 let track_info = if has_context { 308 let c = ctx.unwrap(); 309 format!( 310 r#"<h3>{}</h3> 311 - <div class="artist">by @{}</div>"#, 312 - html_escape(c.track_title.as_deref().unwrap_or("unknown track")), 313 - html_escape(c.artist_handle.as_deref().unwrap_or("unknown")) 314 ) 315 } else { 316 r#"<div class="no-context">no track info available</div>"#.to_string()
··· 78 /// Context payload for storage. 79 #[derive(Debug, Deserialize)] 80 pub struct ContextPayload { 81 + pub track_id: Option<i64>, 82 pub track_title: Option<String>, 83 pub artist_handle: Option<String>, 84 pub artist_did: Option<String>, ··· 226 tracing::info!(uri = %request.uri, "storing label context"); 227 228 let label_ctx = LabelContext { 229 + track_id: request.context.track_id, 230 track_title: request.context.track_title, 231 artist_handle: request.context.artist_handle, 232 artist_did: request.context.artist_did, ··· 308 309 let track_info = if has_context { 310 let c = ctx.unwrap(); 311 + let handle = c.artist_handle.as_deref().unwrap_or("unknown"); 312 + let title = c.track_title.as_deref().unwrap_or("unknown track"); 313 + 314 + // Link to track if we have track_id 315 + let title_html = if let Some(track_id) = c.track_id { 316 + format!( 317 + r#"<a href="https://plyr.fm/track/{}" target="_blank" rel="noopener">{}</a>"#, 318 + track_id, 319 + html_escape(title) 320 + ) 321 + } else { 322 + html_escape(title) 323 + }; 324 + 325 + // Link to artist if we have handle 326 + let artist_link = if handle != "unknown" { 327 + format!( 328 + r#"<a href="https://plyr.fm/u/{}" target="_blank" rel="noopener">@{}</a>"#, 329 + html_escape(handle), 330 + html_escape(handle) 331 + ) 332 + } else { 333 + format!("@{}", html_escape(handle)) 334 + }; 335 format!( 336 r#"<h3>{}</h3> 337 + <div class="artist">by {}</div>"#, 338 + title_html, 339 + artist_link 340 ) 341 } else { 342 r#"<div class="no-context">no track info available</div>"#.to_string()
+33 -23
moderation/src/db.rs
··· 9 10 /// Type alias for context row from database query. 11 type ContextRow = ( 12 - Option<String>, 13 - Option<String>, 14 - Option<String>, 15 - Option<f64>, 16 - Option<serde_json::Value>, 17 - Option<String>, 18 - Option<String>, 19 ); 20 21 /// Type alias for flagged track row from database query. 22 type FlaggedRow = ( 23 - i64, 24 - String, 25 - String, 26 - DateTime<Utc>, 27 - Option<String>, 28 - Option<String>, 29 - Option<String>, 30 - Option<f64>, 31 - Option<serde_json::Value>, 32 - Option<String>, 33 - Option<String>, 34 ); 35 36 /// Copyright match info stored alongside labels. ··· 85 /// Context stored alongside a label for display in admin UI. 86 #[derive(Debug, Clone, Serialize, Deserialize, Default)] 87 pub struct LabelContext { 88 pub track_title: Option<String>, 89 pub artist_handle: Option<String>, 90 pub artist_did: Option<String>, ··· 212 213 sqlx::query( 214 r#" 215 - INSERT INTO label_context (uri, track_title, artist_handle, artist_did, highest_score, matches, resolution_reason, resolution_notes) 216 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 217 ON CONFLICT (uri) DO UPDATE SET 218 track_title = COALESCE(EXCLUDED.track_title, label_context.track_title), 219 artist_handle = COALESCE(EXCLUDED.artist_handle, label_context.artist_handle), 220 artist_did = COALESCE(EXCLUDED.artist_did, label_context.artist_did), ··· 225 "#, 226 ) 227 .bind(uri) 228 .bind(&context.track_title) 229 .bind(&context.artist_handle) 230 .bind(&context.artist_did) ··· 268 pub async fn get_context(&self, uri: &str) -> Result<Option<LabelContext>, sqlx::Error> { 269 let row: Option<ContextRow> = sqlx::query_as( 270 r#" 271 - SELECT track_title, artist_handle, artist_did, highest_score, matches, resolution_reason, resolution_notes 272 FROM label_context 273 WHERE uri = $1 274 "#, ··· 279 280 Ok(row.map( 281 |( 282 track_title, 283 artist_handle, 284 artist_did, ··· 288 resolution_notes, 289 )| { 290 LabelContext { 291 track_title, 292 artist_handle, 293 artist_did, ··· 470 let rows: Vec<FlaggedRow> = sqlx::query_as( 471 r#" 472 SELECT l.seq, l.uri, l.val, l.cts, 473 - c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches, 474 c.resolution_reason, c.resolution_notes 475 FROM labels l 476 LEFT JOIN label_context c ON l.uri = c.uri ··· 502 uri, 503 val, 504 cts, 505 track_title, 506 artist_handle, 507 artist_did, ··· 510 resolution_reason, 511 resolution_notes, 512 )| { 513 - let context = if track_title.is_some() 514 || artist_handle.is_some() 515 || resolution_reason.is_some() 516 { 517 Some(LabelContext { 518 track_title, 519 artist_handle, 520 artist_did,
··· 9 10 /// Type alias for context row from database query. 11 type ContextRow = ( 12 + Option<i64>, // track_id 13 + Option<String>, // track_title 14 + Option<String>, // artist_handle 15 + Option<String>, // artist_did 16 + Option<f64>, // highest_score 17 + Option<serde_json::Value>, // matches 18 + Option<String>, // resolution_reason 19 + Option<String>, // resolution_notes 20 ); 21 22 /// Type alias for flagged track row from database query. 23 type FlaggedRow = ( 24 + i64, // seq 25 + String, // uri 26 + String, // val 27 + DateTime<Utc>, // cts 28 + Option<i64>, // track_id 29 + Option<String>, // track_title 30 + Option<String>, // artist_handle 31 + Option<String>, // artist_did 32 + Option<f64>, // highest_score 33 + Option<serde_json::Value>, // matches 34 + Option<String>, // resolution_reason 35 + Option<String>, // resolution_notes 36 ); 37 38 /// Copyright match info stored alongside labels. ··· 87 /// Context stored alongside a label for display in admin UI. 88 #[derive(Debug, Clone, Serialize, Deserialize, Default)] 89 pub struct LabelContext { 90 + pub track_id: Option<i64>, 91 pub track_title: Option<String>, 92 pub artist_handle: Option<String>, 93 pub artist_did: Option<String>, ··· 215 216 sqlx::query( 217 r#" 218 + INSERT INTO label_context (uri, track_id, track_title, artist_handle, artist_did, highest_score, matches, resolution_reason, resolution_notes) 219 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 220 ON CONFLICT (uri) DO UPDATE SET 221 + track_id = COALESCE(EXCLUDED.track_id, label_context.track_id), 222 track_title = COALESCE(EXCLUDED.track_title, label_context.track_title), 223 artist_handle = COALESCE(EXCLUDED.artist_handle, label_context.artist_handle), 224 artist_did = COALESCE(EXCLUDED.artist_did, label_context.artist_did), ··· 229 "#, 230 ) 231 .bind(uri) 232 + .bind(context.track_id) 233 .bind(&context.track_title) 234 .bind(&context.artist_handle) 235 .bind(&context.artist_did) ··· 273 pub async fn get_context(&self, uri: &str) -> Result<Option<LabelContext>, sqlx::Error> { 274 let row: Option<ContextRow> = sqlx::query_as( 275 r#" 276 + SELECT track_id, track_title, artist_handle, artist_did, highest_score, matches, resolution_reason, resolution_notes 277 FROM label_context 278 WHERE uri = $1 279 "#, ··· 284 285 Ok(row.map( 286 |( 287 + track_id, 288 track_title, 289 artist_handle, 290 artist_did, ··· 294 resolution_notes, 295 )| { 296 LabelContext { 297 + track_id, 298 track_title, 299 artist_handle, 300 artist_did, ··· 477 let rows: Vec<FlaggedRow> = sqlx::query_as( 478 r#" 479 SELECT l.seq, l.uri, l.val, l.cts, 480 + c.track_id, c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches, 481 c.resolution_reason, c.resolution_notes 482 FROM labels l 483 LEFT JOIN label_context c ON l.uri = c.uri ··· 509 uri, 510 val, 511 cts, 512 + track_id, 513 track_title, 514 artist_handle, 515 artist_did, ··· 518 resolution_reason, 519 resolution_notes, 520 )| { 521 + let context = if track_id.is_some() 522 + || track_title.is_some() 523 || artist_handle.is_some() 524 || resolution_reason.is_some() 525 { 526 Some(LabelContext { 527 + track_id, 528 track_title, 529 artist_handle, 530 artist_did,
+2
moderation/src/handlers.rs
··· 19 /// Context info for display in admin UI. 20 #[derive(Debug, Deserialize)] 21 pub struct EmitLabelContext { 22 pub track_title: Option<String>, 23 pub artist_handle: Option<String>, 24 pub artist_did: Option<String>, ··· 176 // Store context if provided (for admin UI) 177 if let Some(ctx) = request.context { 178 let label_ctx = LabelContext { 179 track_title: ctx.track_title, 180 artist_handle: ctx.artist_handle, 181 artist_did: ctx.artist_did,
··· 19 /// Context info for display in admin UI. 20 #[derive(Debug, Deserialize)] 21 pub struct EmitLabelContext { 22 + pub track_id: Option<i64>, 23 pub track_title: Option<String>, 24 pub artist_handle: Option<String>, 25 pub artist_did: Option<String>, ··· 177 // Store context if provided (for admin UI) 178 if let Some(ctx) = request.context { 179 let label_ctx = LabelContext { 180 + track_id: ctx.track_id, 181 track_title: ctx.track_title, 182 artist_handle: ctx.artist_handle, 183 artist_did: ctx.artist_did,
+24 -3
moderation/static/admin.css
··· 209 color: var(--text-primary); 210 } 211 212 .track-info .artist { 213 color: var(--text-secondary); 214 font-size: 0.9rem; 215 } 216 217 .track-info .uri { 218 font-size: 0.75rem; 219 color: var(--text-muted); ··· 223 224 .flag-badges { 225 display: flex; 226 - gap: 8px; 227 flex-shrink: 0; 228 } 229 ··· 246 } 247 248 .badge.matches { 249 - background: rgba(251, 191, 36, 0.15); 250 - color: #fbbf24; 251 } 252 253 .badge.env {
··· 209 color: var(--text-primary); 210 } 211 212 + .track-info h3 a { 213 + color: var(--text-primary); 214 + text-decoration: none; 215 + } 216 + 217 + .track-info h3 a:hover { 218 + color: var(--accent); 219 + text-decoration: underline; 220 + } 221 + 222 .track-info .artist { 223 color: var(--text-secondary); 224 font-size: 0.9rem; 225 } 226 227 + .track-info .artist a { 228 + color: var(--accent); 229 + text-decoration: none; 230 + } 231 + 232 + .track-info .artist a:hover { 233 + text-decoration: underline; 234 + } 235 + 236 .track-info .uri { 237 font-size: 0.75rem; 238 color: var(--text-muted); ··· 242 243 .flag-badges { 244 display: flex; 245 + flex-direction: column; 246 + align-items: flex-end; 247 + gap: 6px; 248 flex-shrink: 0; 249 } 250 ··· 267 } 268 269 .badge.matches { 270 + background: rgba(106, 159, 255, 0.15); 271 + color: var(--accent); 272 } 273 274 .badge.env {