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).
70 lines
1.9 KiB
TypeScript
70 lines
1.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|