const { useState, useEffect, useRef } = React; // Profile → API tokens: create (shown once), list, revoke. Uses window.BBApiTokens (utils/api-tokens.js). function ApiTokensSection({ apiBase, token }) { const T = window.BBApiTokens; const [tokens, setTokens] = useState([]); const [name, setName] = useState(""); const [fresh, setFresh] = useState(null); // {name, token}: shown once, kept in memory only const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false); const load = () => T.list(apiBase, token).then(setTokens).catch((e) => setErr(e.message || "Failed to load")); useEffect(() => { if (T && token) load(); }, [apiBase, token]); if (!T) return null; const submit = async (e) => { e.preventDefault(); setErr(""); setCopied(false); const bad = T.validateName(name); if (bad) { setErr(bad); return; } setBusy(true); try { const j = await T.create(apiBase, token, name); setFresh({ name: j.name, token: j.token }); setName(""); await load(); } catch (e2) { setErr(e2.message || "Failed"); } finally { setBusy(false); } }; const revoke = async (t) => { if (!window.confirm(`Revoke "${t.name}"? Anything using it stops working.`)) return; setErr(""); try { await T.revoke(apiBase, token, t.id); await load(); } catch (e) { setErr(e.message || "Failed"); } }; const copy = () => { if (navigator.clipboard && fresh) navigator.clipboard.writeText(fresh.token).then(() => setCopied(true)).catch(() => {}); }; const live = T.active(tokens); return (
API tokens
For scripts and integrations: send as Authorization: Bearer <token>. A token can do anything you can. API docs
{fresh && (
Copy "{fresh.name}" now. It won't be shown again.
e.target.select()} />
)} {live.map((t) => (
{t.name}
{t.token_prefix}… · created {T.when(t.created_at)} · used {T.when(t.last_used_at)}
))} {!live.length && !err &&
No API tokens.
}
setName(e.target.value)} />
{err &&
{err}
}
); } function ProfileModal({ apiBase, token, visible, onClose, onHandleChange, onAvatarChange }) { const [tab, setTab] = useState("account"); const [newHandle, setNewHandle] = useState(""); const [currentPin, setCurrentPin] = useState(""); const [newPin, setNewPin] = useState(""); const [errHandle, setErrHandle] = useState(""); const [errPin, setErrPin] = useState(""); const [busyHandle, setBusyHandle] = useState(false); const [busyPin, setBusyPin] = useState(false); const [reactionName, setReactionName] = useState(() => localStorage.getItem("bb_reactor_handle") || ""); const overlayRef = useRef(null); const fileRef = useRef(null); const [avatarUrl, setAvatarUrl] = useState(null); const [avatarBusy, setAvatarBusy] = useState(false); const [errAvatar, setErrAvatar] = useState(""); // Current avatar comes from /auth/me (avatar_url is only present when one is set). useEffect(() => { if (!visible || !token) return; let cancelled = false; setErrAvatar(""); fetch(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }) .then((r) => r.json()) .then((j) => { if (!cancelled && j && j.ok) setAvatarUrl(j.avatar_url || null); }) .catch(() => {}); return () => { cancelled = true; }; }, [visible, token, apiBase]); // Non-passive touchmove on overlay prevents page scroll on iOS (React onTouchMove is passive) useEffect(() => { const el = overlayRef.current; if (!el || !visible) return; const trap = (e) => { // Allow scroll inside the card; block everything else from reaching the page if (!e.target.closest('[data-modal-scroll]')) e.preventDefault(); }; el.addEventListener('touchmove', trap, { passive: false }); return () => el.removeEventListener('touchmove', trap); }, [visible]); if (!visible) return null; const submitHandle = async (e) => { e.preventDefault(); setErrHandle(""); const h = newHandle.trim(); if (!h) { setErrHandle("Handle required"); return; } setBusyHandle(true); try { const r = await fetch(`${apiBase}/profile/handle`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ handle: h }), }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`); localStorage.setItem("bb_handle", h); if (onHandleChange) onHandleChange(j.handle || h); setNewHandle(""); } catch (e) { setErrHandle(e.message || "Failed"); } finally { setBusyHandle(false); } }; const submitPin = async (e) => { e.preventDefault(); setErrPin(""); if (!/^[0-9]{4}$/.test(newPin)) { setErrPin("PIN must be exactly 4 digits"); return; } setBusyPin(true); try { const r = await fetch(`${apiBase}/profile/pin`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ current: currentPin, pin: newPin }), }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`); setCurrentPin(""); setNewPin(""); } catch (e) { setErrPin(e.message || "Failed"); } finally { setBusyPin(false); } }; const uploadAvatar = async (e) => { const file = e.target.files && e.target.files[0]; e.target.value = ""; if (!file) return; setErrAvatar(""); setAvatarBusy(true); try { const body = await window.BBAvatar.prepareUpload(file); const r = await fetch(`${apiBase}/profile/avatar`, { method: "POST", headers: { "Content-Type": body.type || "image/jpeg", Authorization: `Bearer ${token}` }, body, }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) throw new Error(j.error || j.detail || `HTTP ${r.status}`); setAvatarUrl(j.avatar_url || null); if (onAvatarChange) onAvatarChange(j.avatar_url || null); } catch (err) { setErrAvatar(err.message || "Upload failed"); } finally { setAvatarBusy(false); } }; const removeAvatar = async () => { setErrAvatar(""); setAvatarBusy(true); try { const r = await fetch(`${apiBase}/profile/avatar`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) throw new Error(j.error || j.detail || `HTTP ${r.status}`); setAvatarUrl(null); if (onAvatarChange) onAvatarChange(null); } catch (err) { setErrAvatar(err.message || "Remove failed"); } finally { setAvatarBusy(false); } }; const myHandle = localStorage.getItem("bb_handle") || ""; const TAB = "px-3 py-1.5 rounded text-sm font-medium transition-colors"; const activeTab = TAB + " btn"; const inactiveTab = TAB + " btn-alt"; return (

Profile

{tab === "account" && (
Profile photo
{window.BBAvatar && }
{avatarUrl && ( )}
Shown next to your name on the board. JPEG, PNG or WebP, up to 2 MB.
{errAvatar &&
{errAvatar}
}
Handle
setNewHandle(e.target.value)} /> {errHandle &&
{errHandle}
}
Change PIN
setCurrentPin(e.target.value.replace(/[^0-9]/g, ""))} maxLength={4} /> setNewPin(e.target.value.replace(/[^0-9]/g, ""))} maxLength={4} /> {errPin &&
{errPin}
}
Reaction Display Name
Shown when you react to a bet on the board
{ setReactionName(e.target.value); localStorage.setItem("bb_reactor_handle", e.target.value); }} />
)} {tab === "display" && (
Changes go straight to your LED panel Full screen
{window.DisplayControl ? :
Display controls failed to load. Open /display.
}
)}
); } window.ProfileModal = ProfileModal;