Fill the remaining gaps: badge upload, radio tools, VPN, writeable boxes
Built the admin tools previously listed as missing: - Badge upload (/admin/badges): uploads a <code>.gif into the emulator's badge dir via BADGE_UPLOAD_DIR (node:fs); validated code/type/size, logged. Made configurable rather than skipped. - Radio tools: /admin/radio/api-keys (CRUD, server-generated keys), /admin/radio/autodj (Auto-DJ playlist CRUD), /admin/radio/embed (embed snippet generator), /admin/radio/points (points settings), /admin/radio/monitoring (live stream/now-playing/listeners status). radio_api_keys + radio_auto_dj_playlist already had real columns. - /admin/vpn: VPN/proxy detection config (block toggle + provider + key), complementing /admin/ip's raw blacklist. - Writeable boxes: new website_writeable_boxes table (model + migration 0006) + /admin/writeable-boxes CRUD; active boxes render on the public home page. env: BADGE_UPLOAD_DIR. Verified live (prod, amx_test): all 8 pages render with real data; a test writeable box appeared on the public home and was reverted. tsc 0, vitest 49/49, next build 0 (7 new admin routes).
This commit is contained in:
1 parent
e004dfedaf
commit
0cd4d06ff6
21 files changed
+2443
-61
No files matched your search
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Read-only copy box for an embed snippet. Renders the code inside a
|
||||
* <textarea readOnly> so staff can select/copy it, plus a one-click copy
|
||||
* button (with clipboard fallback to selecting the text).
|
||||
*/
|
||||
export function EmbedSnippet({
|
||||
title,
|
||||
description,
|
||||
snippet,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
snippet: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API unavailable (e.g. insecure context) — fall back to
|
||||
// selecting the textarea so the user can copy manually.
|
||||
const el = document.getElementById(
|
||||
`snippet-${title.replace(/\s+/g, '-').toLowerCase()}`,
|
||||
) as HTMLTextAreaElement | null;
|
||||
el?.select();
|
||||
}
|
||||
}
|
||||
|
||||
const id = `snippet-${title.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
const lines = snippet.split('\n').length;
|
||||
|
||||
return (
|
||||
<section className="admin-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2>
|
||||
<button type="button" className="btn btn-primary" onClick={copy}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="muted" style={{ marginTop: '0.4rem' }}>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<textarea
|
||||
id={id}
|
||||
readOnly
|
||||
value={snippet}
|
||||
rows={Math.min(Math.max(lines + 1, 3), 14)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontSize: '0.85rem',
|
||||
whiteSpace: 'pre',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user