+2
moderation/Dockerfile
+2
moderation/Dockerfile
+169
-320
moderation/src/admin.rs
+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, '&')
452
-
.replace(/</g, '<')
453
-
.replace(/>/g, '>')
454
-
.replace(/"/g, '"')
455
-
.replace(/'/g, ''');
456
-
}
457
-
</script>
458
-
</body>
459
-
</html>
460
-
"##;
302
+
/// Simple HTML escaping.
303
+
fn html_escape(s: &str) -> String {
304
+
s.replace('&', "&")
305
+
.replace('<', "<")
306
+
.replace('>', ">")
307
+
.replace('"', """)
308
+
.replace('\'', "'")
309
+
}
+5
moderation/src/main.rs
+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
+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
+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
+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
+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
+
}