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.
}
{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}
}
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" && (
{window.DisplayControl ?
:
Display controls failed to load. Open
/display.
}
)}
);
}
window.ProfileModal = ProfileModal;