// Display control for the LED ticker (spec v2). Mobile-first: big targets, high contrast,
// live preview, auto-send with "applied on the panel" confirmation.
// Used inside ProfileModal (Display tab) and standalone at /display (display.html).
(function () {
const DC = window.DisplaySpec;
const DC_CSS = `
.dc{--dc-bg:#0b0d12;--dc-card:#151923;--dc-line:#2a3142;--dc-fg:#f4f6fb;--dc-mute:#a9b3c7;--dc-ok:#22c55e;--dc-warn:#fbbf24;--dc-bad:#f87171;--dc-acc:#7cc4ff;
color:var(--dc-fg);background:var(--dc-bg);font:16px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
.dc *{box-sizing:border-box}
.dc-status{position:sticky;top:0;z-index:5;padding:10px 12px;border-bottom:2px solid var(--dc-line);background:#05070b;display:grid;gap:4px}
.dc-status-row{display:flex;align-items:center;gap:8px;font-weight:700;font-size:17px}
.dc-dot{width:14px;height:14px;border-radius:50%;flex:0 0 14px}
.dc-sub{font-size:14px;color:var(--dc-mute)}
.dc-err{color:#fff;background:#7f1d1d;border:2px solid var(--dc-bad);border-radius:10px;padding:8px 10px;font-size:15px;font-weight:600}
.dc-okbox{color:#04130a;background:var(--dc-ok);border-radius:10px;padding:8px 10px;font-size:16px;font-weight:800}
.dc-body{padding:12px;display:grid;grid-template-columns:minmax(0,1fr);gap:14px;max-width:640px;margin:0 auto}
.dc-card{background:var(--dc-card);border:1px solid var(--dc-line);border-radius:14px;padding:12px;display:grid;grid-template-columns:minmax(0,1fr);gap:10px}
.dc-h{font-size:18px;font-weight:800;margin:0}
.dc-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
.dc-row>*{min-width:0}
.dc-btn{min-height:48px;padding:10px 14px;border-radius:12px;border:2px solid var(--dc-line);background:#222838;color:var(--dc-fg);font-size:16px;font-weight:700;cursor:pointer;touch-action:manipulation}
.dc-btn:active{transform:scale(.98)}
.dc-btn[disabled]{opacity:.45}
.dc-btn.big{width:100%;min-height:56px;font-size:19px}
.dc-btn.go{background:#16a34a;border-color:#22c55e;color:#fff}
.dc-btn.on{background:#1d4ed8;border-color:#60a5fa;color:#fff}
.dc-btn.danger{background:#3b0d0d;border-color:#b91c1c;color:#fecaca}
.dc-btn.sm{min-height:44px;min-width:44px;padding:6px 10px;font-size:15px}
.dc-tabs{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.dc-toggle{display:flex;align-items:center;justify-content:space-between;width:100%;min-height:52px;padding:10px 12px;border-radius:12px;border:2px solid var(--dc-line);background:#1b2030;color:var(--dc-fg);font-size:16px;font-weight:700;text-align:left}
.dc-pill{min-width:64px;text-align:center;padding:6px 10px;border-radius:999px;font-weight:800;font-size:15px}
.dc-pill.on{background:var(--dc-ok);color:#04130a}.dc-pill.off{background:#3a4152;color:#e5e7eb}
.dc input[type=text],.dc input[type=time],.dc input[type=number],.dc input[type=password],.dc select,.dc textarea{width:100%;min-height:48px;padding:10px 12px;border-radius:10px;border:2px solid var(--dc-line);background:#0d1018;color:var(--dc-fg);font-size:16px}
.dc textarea{font-family:ui-monospace,Menlo,monospace;font-size:14px}
.dc input[type=range]{width:100%;height:36px;accent-color:#60a5fa}
.dc input[type=color]{width:52px;height:44px;border:2px solid var(--dc-line);border-radius:10px;background:none;padding:2px}
.dc label.dc-l{display:grid;gap:6px;font-weight:700;font-size:15px}
.dc-presets{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
.dc-preset{min-height:64px;border-radius:12px;border:3px solid transparent;font-weight:800;font-size:16px;cursor:pointer;padding:6px}
.dc-colors{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
.dc-color{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600}
.dc-page{border:2px solid var(--dc-line);border-radius:12px;padding:10px;display:grid;grid-template-columns:minmax(0,1fr);gap:10px;background:#11151f}
.dc-page.offp{opacity:.6}
.dc-page-head{display:flex;align-items:center;gap:6px}
.dc-page-name{flex:1;font-weight:800;font-size:16px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dc-canvas-wrap{background:#000;border-radius:12px;padding:8px;border:2px solid #333}
.dc-canvas{width:100%;image-rendering:pixelated;image-rendering:crisp-edges;display:block;border-radius:4px}
.dc-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
.dc-chip{min-height:44px;padding:8px 14px;border-radius:22px;border:2px solid var(--dc-line);background:#11151f;color:#cfd6e4;font-weight:800;font-size:15px;cursor:pointer}
.dc-chip.on{background:#1f7a4d;border-color:#5cffac;color:#fff}
.dc-mono{font-family:ui-monospace,Menlo,monospace}
.dc-grid2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px}
`;
const PANEL_W = 128, PANEL_H = 64, HEADER_H = 12, FOOTER_H = 11;
const MOCK = {
wagers: [["ROOSRER", "KC -3.5", "$50"], ["BILL", "NYY ML", "$20"], ["JAY", "O 47.5", "$25"], ["MIKE", "LAL +4", "$40"], ["SAM", "DAL ML", "$10"]],
balances: [["ROOSRER", "+420", 1], ["BILL", "-85", -1], ["JAY", "+1.2K", 1], ["MIKE", "-300", -1], ["SAM", "+15", 1]],
results: [["KC 27 BUF 24", "ROOSRER WON +45", 1], ["NYY 3 BOS 5", "BILL LOST -20", -1], ["DAL 20 PHI 20", "JAY PUSH", 0]],
reactions: ["BILL: NICE HIT", "JAY: LOL", "MIKE: PAY UP"],
movers: ["ROOSRER +45 ▲"],
live: false,
};
function dcApiBase(explicit) {
// On the Blackbook site always use the same-origin proxy (fastest, no CORS); else the explicit base.
const h = (typeof location !== "undefined" && location.hostname) || "";
if (/blackbook/.test(h)) return "/api/bb";
if (explicit) return String(explicit).replace(/\/$/, "");
const saved = (typeof localStorage !== "undefined" && localStorage.getItem("bb_api_base")) || "";
return (saved || "https://api.blackbook.kinjar.com").replace(/\/$/, "");
}
// ------------------------------------------------------------------ preview
function drawPanel(ctx, spec, defaults, t, now, popup, data) {
const D = data || MOCK;
ctx.imageSmoothingEnabled = false;
const pages = DC.enabledPages(spec);
const hdr = spec.header || {}, ftr = spec.footer || {};
const top = hdr.show !== false ? HEADER_H + 1 : 0;
const bot = ftr.show !== false ? PANEL_H - FOOTER_H - 1 : PANEL_H - 1;
const mh = bot - top + 1;
const font = (px) => `bold ${px}px "DejaVu Sans", Verdana, sans-serif`;
const theme = DC.themeColors(spec, defaults);
const off = DC.sleeping(spec, now);
ctx.clearRect(0, 0, PANEL_W, PANEL_H);
if (off || !pages.length) {
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, PANEL_W, PANEL_H);
ctx.fillStyle = "#444"; ctx.font = font(9); ctx.textAlign = "center";
ctx.fillText(spec.enabled === false ? "DISPLAY OFF" : "SLEEPING", PANEL_W / 2, 36); ctx.textAlign = "left";
return { page: null, label: spec.enabled === false ? "Display off" : "Sleeping (schedule)" };
}
if (popup) {
const a = theme.accent;
ctx.fillStyle = "#040814"; ctx.fillRect(0, 0, PANEL_W, PANEL_H);
ctx.strokeStyle = a; ctx.lineWidth = Math.floor(t * 5) % 2 ? 2 : 1; ctx.strokeRect(0.5, 0.5, PANEL_W - 1, PANEL_H - 1);
ctx.fillStyle = a; ctx.fillRect(0, 0, PANEL_W, 16);
ctx.textAlign = "center"; ctx.fillStyle = "#060a18"; ctx.font = font(9); ctx.fillText(popup.kind, PANEL_W / 2, 11);
ctx.fillStyle = theme.text; ctx.font = font(12); ctx.fillText(popup.handle, PANEL_W / 2, 31);
ctx.fillStyle = a; ctx.font = font(8); (popup.lines || []).forEach((ln, i) => ctx.fillText(ln, PANEL_W / 2, 45 + i * 10));
ctx.textAlign = "left";
return { page: null, label: "Popup preview" };
}
// per-page content size (px) for timing
const rowsOf = (p) => (p.type === "wagers" || p.type === "ticker") ? D.wagers.length * 10
: p.type === "balances" ? D.balances.length * 10 : (p.type === "results" || p.type === "scores") ? D.results.length * 18
: p.type === "reactions" ? Math.min(p.limit || 8, D.reactions.length) * 10 : 0;
ctx.font = font(Number(13));
const marqW = (p) => { ctx.font = font(Math.min(24, p.size || 16)); return ctx.measureText(DC.renderTemplate(p.text || p.title || "", fieldsFor(spec, p, now))).width; };
const durs = pages.map((p) => p.type === "marquee" ? DC.pageDuration(p, marqW(p), PANEL_W) : DC.pageDuration(p, rowsOf(p), mh));
const { index, elapsed } = DC.pageAt(durs, t);
const page = pages[index];
const c = DC.pageColors(spec, page, defaults);
ctx.fillStyle = c.background; ctx.fillRect(0, 0, PANEL_W, PANEL_H);
// content
ctx.save(); ctx.beginPath(); ctx.rect(0, top, PANEL_W, mh); ctx.clip();
const hold = Number(page.hold) || 2.2;
const scrollY = (contentPx) => {
const over = Math.max(0, contentPx - mh);
if (!over) return 0;
const s = elapsed - hold;
if (s <= 0) return 0;
return Math.min(over, s * DC.scrollPxPerSec(page.speed));
};
const f = fieldsFor(spec, page, now);
ctx.textBaseline = "alphabetic";
const empty = (msg) => { ctx.font = font(9); ctx.fillStyle = c.dim; ctx.fillText(msg, 6, top + 20); };
const emptyMsg = { wagers: "NO OPEN WAGERS", ticker: "NO OPEN WAGERS", balances: "NO ACTIVE PLAYERS",
results: "NO RESULTS YET", scores: "NO RESULTS YET", reactions: "NO REACTIONS" }[page.type];
const list = { wagers: D.wagers, ticker: D.wagers, balances: D.balances, results: D.results, scores: D.results, reactions: D.reactions }[page.type];
if (emptyMsg && !(list && list.length)) empty(emptyMsg);
if (page.type === "wagers" || page.type === "ticker") {
const y0 = top - scrollY(rowsOf(page));
D.wagers.forEach((r, i) => {
const y = y0 + i * 10;
if (i % 2) { ctx.fillStyle = c.row_tint; ctx.fillRect(0, y, PANEL_W, 10); }
ctx.font = font(8); ctx.fillStyle = c.accent; ctx.fillText(r[0].slice(0, 7), 2, y + 8);
ctx.fillStyle = c.text; ctx.fillText(r[1], 50, y + 8);
ctx.fillStyle = c.pending; ctx.textAlign = "right"; ctx.fillText(r[2], PANEL_W - 2, y + 8); ctx.textAlign = "left";
});
} else if (page.type === "balances") {
const y0 = top - scrollY(rowsOf(page));
D.balances.forEach((r, i) => {
const y = y0 + i * 10;
if (i % 2) { ctx.fillStyle = c.row_tint; ctx.fillRect(0, y, PANEL_W, 10); }
ctx.font = font(8); ctx.fillStyle = c.text; ctx.fillText(`${i + 1}. ${r[0]}`, 2, y + 8);
ctx.fillStyle = r[2] > 0 ? c.win : c.loss; ctx.textAlign = "right"; ctx.fillText(r[1], PANEL_W - 2, y + 8); ctx.textAlign = "left";
});
} else if (page.type === "results" || page.type === "scores") {
const y0 = top - scrollY(rowsOf(page));
D.results.forEach((r, i) => {
const y = y0 + i * 18;
ctx.font = font(8); ctx.fillStyle = c.text; ctx.fillText(r[0], 2, y + 8);
ctx.fillStyle = r[2] > 0 ? c.win : r[2] < 0 ? c.loss : c.dim; ctx.fillText(r[1], 2, y + 17);
});
} else if (page.type === "reactions") {
const n = Math.min(page.limit || 8, D.reactions.length);
const y0 = top - scrollY(n * 10);
D.reactions.slice(0, n).forEach((r, i) => { ctx.font = font(8); ctx.fillStyle = c.text; ctx.fillText(r, 2, y0 + i * 10 + 8); });
} else if (page.type === "text" || page.type === "clock") {
const lines = page.type === "clock" ? [DC.strftime(page.format, tzNow(page.tz, now))]
: (page.lines && page.lines.length ? page.lines : String(page.text || "").split("\n")).map((l) => DC.renderTemplate(l, f));
const size = page.size || (page.type === "clock" ? 16 : Math.max(8, Math.min(16, Math.floor(mh / Math.max(1, lines.length)) - 2)));
ctx.font = font(size); ctx.fillStyle = c.text;
const align = page.align || "center";
ctx.textAlign = align === "left" ? "left" : align === "right" ? "right" : "center";
const x = align === "left" ? 2 : align === "right" ? PANEL_W - 2 : PANEL_W / 2;
const lh = size + 2, total = lh * lines.length;
lines.forEach((ln, i) => ctx.fillText(ln, x, top + (mh - total) / 2 + lh * i + size));
ctx.textAlign = "left";
} else if (page.type === "marquee") {
const size = Math.min(24, page.size || 16);
const txt = DC.renderTemplate(page.text || page.title || "", f);
ctx.font = font(size); ctx.fillStyle = c.text;
const x = PANEL_W - elapsed * DC.scrollPxPerSec(page.speed);
ctx.fillText(txt, x, top + (mh + size) / 2 - 2);
} else if (page.type === "image") {
const img = imageCache(page.image);
if (img && img.complete && img.naturalWidth) {
const k = Math.min(PANEL_W / img.naturalWidth, mh / img.naturalHeight);
const w = img.naturalWidth * k, h = img.naturalHeight * k;
ctx.drawImage(img, (PANEL_W - w) / 2, top + (mh - h) / 2, w, h);
} else { ctx.font = font(8); ctx.fillStyle = c.dim; ctx.fillText("(no picture)", 30, top + mh / 2); }
}
ctx.restore();
// header
if (hdr.show !== false) {
ctx.fillStyle = c.header_bg; ctx.fillRect(0, 0, PANEL_W, HEADER_H);
ctx.font = font(9);
const title = hdr.show_page_title !== false ? (page.title || page.type.toUpperCase()) : "";
const tw = title ? ctx.measureText(title).width : 0;
const brand = hdr.title || (spec.theme && spec.theme.title) || "BLACKBOOK";
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, PANEL_W - tw - 6, HEADER_H); ctx.clip();
ctx.fillStyle = c.accent; ctx.fillText(brand, 2, 9); ctx.restore();
if (title) { ctx.fillStyle = c.page_title; ctx.fillText(title, PANEL_W - tw - 3, 9); }
ctx.fillStyle = c.separator; ctx.fillRect(0, HEADER_H, PANEL_W, 1);
}
// footer
if (ftr.show !== false) {
const fy = PANEL_H - FOOTER_H;
ctx.fillStyle = c.footer_bg; ctx.fillRect(0, fy, PANEL_W, FOOTER_H);
ctx.fillStyle = c.separator; ctx.fillRect(0, fy, PANEL_W, 1);
ctx.font = font(8);
const txt = ftr.text ? DC.renderTemplate(ftr.text, fieldsFor(spec, null, now))
: [ftr.reactions !== false ? (D.reactions || [])[0] || "" : "", ftr.movers !== false ? (D.movers || []).join(" ") : "", "BLACKBOOK"].filter(Boolean).join(" • ");
const w = ctx.measureText(txt + " ").width;
const mx = (t * DC.scrollPxPerSec(ftr.speed || 12)) % w;
ctx.fillStyle = c.accent;
ctx.fillText(txt, -mx, PANEL_H - 2); ctx.fillText(txt, -mx + w, PANEL_H - 2);
}
return { page, index, label: `Page ${index + 1}/${pages.length}: ${page.title || DC.PAGE_LABELS[page.type]}` };
}
const _imgs = {};
function imageCache(src) {
if (!src || !/^data:image\//.test(src)) return null;
if (!_imgs[src]) { const im = new Image(); im.src = src; _imgs[src] = im; }
return _imgs[src];
}
function tzNow(tz, now) {
if (!tz) return now;
try { return new Date(now.toLocaleString("en-US", { timeZone: tz })); } catch (e) { return now; }
}
function fieldsFor(spec, page, now) {
const user = (window.__dcHandle || "you");
return { user, user_upper: user.toUpperCase(), group: "", date: DC.strftime("%-m/%-d", now), time: DC.strftime("%-I:%M %p", now),
title: (page && page.title) || "" };
}
function PanelPreview({ spec, defaults, popupAt, live }) {
const ref = React.useRef(null);
const [label, setLabel] = React.useState("");
const specRef = React.useRef(spec);
specRef.current = spec;
const popRef = React.useRef(popupAt);
popRef.current = popupAt;
const dataRef = React.useRef(null);
dataRef.current = live && live.data;
React.useEffect(() => {
let raf, t0 = performance.now(), lastLabel = "";
const loop = (ts) => {
const ctx = ref.current && ref.current.getContext("2d");
if (ctx) {
const pop = popRef.current;
const showPop = pop && pop.until > Date.now() ? pop : null;
const r = drawPanel(ctx, specRef.current, defaults, (ts - t0) / 1000, new Date(), showPop, dataRef.current);
if (r.label !== lastLabel) { lastLabel = r.label; setLabel(r.label); }
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [defaults]);
return (
{live && live.data ? <>Your live data{live.at ? ` · ${live.at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` : ""}>
: live && live.err ? "Sample data (live data unavailable)" : "Sample data (loading live…)"}{label}
);
}
// ------------------------------------------------------------------ small controls
function Toggle({ label, sub, on, onChange }) {
return (
);
}
function Slider({ label, value, min, max, step, onChange, fmt }) {
return (
);
}
function ColorField({ label, value, onChange, onClear }) {
const v = /^#[0-9a-fA-F]{6}$/.test(value || "") ? value : "#000000";
return (
onChange(e.target.value)} aria-label={label} />
{label}{onClear && value ? : null}
);
}
function TextField({ label, value, onChange, max, placeholder }) {
return (
);
}
// ------------------------------------------------------------------ page editor
function PageEditor({ page, i, n, edit, open, setOpen }) {
const set = (k, v) => edit((s) => DC.setIn(s, ["pages", i, k], v));
const t = page.type;
const scrolls = ["wagers", "ticker", "balances", "results", "scores", "reactions", "marquee"].indexOf(t) >= 0;
const onImage = (file) => {
if (!file) return;
const fr = new FileReader();
fr.onload = () => {
const im = new Image();
im.onload = () => {
const cv = document.createElement("canvas");
const k = Math.min(128 / im.width, 40 / im.height, 1);
cv.width = Math.max(1, Math.round(im.width * k)); cv.height = Math.max(1, Math.round(im.height * k));
cv.getContext("2d").drawImage(im, 0, 0, cv.width, cv.height);
let url = cv.toDataURL("image/png");
if (url.length > 60000) url = cv.toDataURL("image/jpeg", 0.8);
if (url.length > 60000) { alert("That picture is too big; try a smaller one."); return; }
set("image", url);
};
im.src = fr.result;
};
fr.readAsDataURL(file);
};
return (
setOpen(open ? null : i)}>{i + 1}. {page.title || DC.PAGE_LABELS[t]} · {DC.PAGE_LABELS[t]} · {page.hold || 2.2}s
{open && (
set("enabled", v ? undefined : false)} />
set("title", v || undefined)} />
`${v}s`} onChange={(v) => set("hold", v)} />
{scrolls && set("speed", v)} />}
{t === "text" && (
<>
>
)}
{t === "marquee" && (
<>
set("text", v)} />
>
)}
{t === "clock" && (
)}
{t === "reactions" && set("limit", v)} />}
{t === "image" && (
)}
Page colors (blank = use theme)
{["background", "text", "title"].map((k) => (
edit((s) => DC.setIn(s, ["pages", i, "colors", k], v))}
onClear={() => edit((s) => { let x = DC.setIn(s, ["pages", i, "colors", k], undefined); if (x.pages[i].colors && !Object.keys(x.pages[i].colors).length) x = DC.setIn(x, ["pages", i, "colors"], undefined); return x; })} />
))}
)}
);
}
// ------------------------------------------------------------------ main
function DisplayControl({ apiBase, token: tokenProp, onAuth }) {
const base = dcApiBase(apiBase);
const [token, setToken] = React.useState(tokenProp || localStorage.getItem("bb_token") || "");
const [handle, setHandle] = React.useState("");
const [needLogin, setNeedLogin] = React.useState(false);
const [schema, setSchema] = React.useState(null);
const [server, setServer] = React.useState(null); // last GET response
const [draft, setDraft] = React.useState(null);
const [history, setHistory] = React.useState([]); // undo stack (previous drafts)
const [tab, setTab] = React.useState("easy");
const [autoSend, setAutoSend] = React.useState(() => localStorage.getItem("bb_dc_autosend") !== "0");
const [phase, setPhase] = React.useState("idle"); // idle | pending | saving | waiting | applied | error
const [savedRev, setSavedRev] = React.useState(null);
const [sentAt, setSentAt] = React.useState(null);
const [appliedInfo, setAppliedInfo] = React.useState(null);
const [errors, setErrors] = React.useState([]);
const [netErr, setNetErr] = React.useState("");
const [jsonText, setJsonText] = React.useState("");
const [jsonErr, setJsonErr] = React.useState([]);
const [openPage, setOpenPage] = React.useState(null);
const [clearPageColors, setClearPageColors] = React.useState(true);
const [popupAt, setPopupAt] = React.useState(null);
const [addType, setAddType] = React.useState("text");
const [group, setGroup] = React.useState("");
const [live, setLive] = React.useState({ data: null, at: null, err: "" });
const saveTimer = React.useRef(null);
const dirtyRef = React.useRef(false); // draft has edits the server hasn't accepted yet
const editGen = React.useRef(0); // bumps on every edit
const knownRev = React.useRef(null); // server revision the draft is based on
const draftRef = React.useRef(null);
draftRef.current = draft;
React.useEffect(() => { if (tokenProp && tokenProp !== token) setToken(tokenProp); }, [tokenProp]);
React.useEffect(() => { localStorage.setItem("bb_dc_autosend", autoSend ? "1" : "0"); }, [autoSend]);
const api = React.useCallback(async (method, path, body) => {
const r = await fetch(base + path, {
method, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store",
});
const j = await r.json().catch(() => ({}));
if (r.status === 401) { setNeedLogin(true); }
return { status: r.status, ok: r.ok, j };
}, [base, token]);
// schema + who am I
React.useEffect(() => {
fetch(base + "/display/v2/schema", { cache: "no-store" }).then((r) => r.json()).then(setSchema).catch(() => setSchema(null));
}, [base]);
React.useEffect(() => {
if (!token) { setNeedLogin(true); return; }
setNeedLogin(false);
api("GET", "/auth/me").then(({ j }) => {
if (j && j.ok && j.handle) { setGroup(j.group_code || j.group || ""); setHandle(j.handle); window.__dcHandle = j.handle; localStorage.setItem("bb_handle", j.handle); }
else setNeedLogin(true);
}).catch(() => setNetErr("Can't reach the server"));
}, [token]);
const refresh = React.useCallback(async () => {
if (!handle) return null;
try {
const { ok, j } = await api("GET", `/display/v2/spec/${encodeURIComponent(handle)}`);
if (!ok) return null;
setNetErr("");
setServer(j);
// first load, or someone else changed it and we have no unsent edits: follow the server
if (!draftRef.current || (!dirtyRef.current && j.revision !== knownRev.current)) {
knownRev.current = j.revision;
setDraft(DC.clone(j.spec));
}
return j;
} catch (e) { setNetErr("Can't reach the server — check your connection"); return null; }
}, [api, handle]);
React.useEffect(() => { refresh(); }, [refresh]);
// polling: every 2 s while waiting for the panel, else every 10 s
React.useEffect(() => {
if (!handle) return;
const fast = phase === "waiting";
const id = setInterval(async () => {
const j = await refresh();
if (!j || phase !== "waiting") return;
const st = DC.applyStatus(j, savedRev);
if (st.state === "applied") {
const secs = sentAt ? Math.round((Date.now() - sentAt) / 100) / 10 : null;
setAppliedInfo({ rev: savedRev, at: new Date(), secs });
setPhase("applied");
} else if (sentAt && Date.now() - sentAt > 180000) setPhase("slow");
}, fast ? 2000 : 10000);
return () => clearInterval(id);
}, [handle, phase, savedRev, sentAt, refresh]);
// live preview data: the same board payload the Pi renders (wagers, balances, results, reactions); every 20 s
React.useEffect(() => {
if (!handle) return;
let stop = false;
const load = async () => {
if (document.hidden) return;
try {
const q = group ? `?code=${encodeURIComponent(group)}` : "";
const r = await fetch(`${base}/display/devices/${encodeURIComponent(handle.toLowerCase())}/payload${q}`, { cache: "no-store" });
const j = await r.json();
if (stop) return;
if (r.ok && j && j.ok && j.board) setLive({ data: DC.boardToPreview(j.board), at: new Date(), err: "" });
else setLive((x) => ({ ...x, err: "unavailable" }));
} catch (e) { if (!stop) setLive((x) => ({ ...x, err: "unavailable" })); }
};
load();
const id = setInterval(load, 20000);
return () => { stop = true; clearInterval(id); };
}, [base, handle, group]);
const schemaDoc = schema && schema.schema;
const tfields = schema && schema.template_fields;
const defaults = schema && schema.default_spec;
const send = React.useCallback(async (spec) => {
spec = spec || draftRef.current;
if (!spec || !handle) return;
const errs = DC.validate(spec, schemaDoc, tfields);
setErrors(errs);
if (errs.length) { setPhase("error"); return; }
setPhase("saving");
const gen = editGen.current;
try {
const { status, j } = await api("PUT", `/display/v2/spec/${encodeURIComponent(handle)}`, JSON.parse(JSON.stringify(spec)));
if (status === 200 && j.ok) {
knownRev.current = j.revision;
if (editGen.current === gen) dirtyRef.current = false;
setSavedRev(j.revision); setSentAt(Date.now()); setAppliedInfo(null); setPhase("waiting");
setTimeout(refresh, 600);
} else if (status === 422) {
setErrors(j.errors || [j.error || "Invalid"]); setPhase("error");
} else { setErrors([j.detail || j.error || `Server error (HTTP ${status})`]); setPhase("error"); }
} catch (e) { setErrors(["Couldn't send — check your connection and try again"]); setPhase("error"); }
}, [api, handle, schemaDoc, tfields, refresh]);
const edit = React.useCallback((fn) => {
const cur = draftRef.current;
if (!cur) return;
const next = fn(cur);
if (JSON.stringify(next) === JSON.stringify(cur)) return;
setHistory((h) => [...h.slice(-49), cur]);
setDraft(next); draftRef.current = next; dirtyRef.current = true; editGen.current += 1;
const errs = DC.validate(next, schemaDoc, tfields);
setErrors(errs);
clearTimeout(saveTimer.current);
if (errs.length) { setPhase("error"); return; }
setPhase("pending");
if (autoSend) saveTimer.current = setTimeout(() => send(next), 700);
}, [autoSend, send, schemaDoc, tfields]);
const undo = () => {
if (!history.length) return;
const prev = history[history.length - 1];
setHistory((h) => h.slice(0, -1));
setDraft(prev); draftRef.current = prev; dirtyRef.current = true; editGen.current += 1;
clearTimeout(saveTimer.current);
const errs = DC.validate(prev, schemaDoc, tfields);
setErrors(errs);
if (errs.length) { setPhase("error"); return; }
setPhase("pending");
if (autoSend) saveTimer.current = setTimeout(() => send(prev), 300);
};
const resetDefault = () => {
if (!defaults || !confirm("Reset the display to the default Blackbook look?")) return;
edit(() => DC.clone(defaults));
};
// ---------- login ----------
if (needLogin) return { localStorage.setItem("bb_token", tok); setToken(tok); onAuth && onAuth(tok); }} />;
if (!draft) return (
{netErr || "Loading your display…"}
);
// ---------- status ----------
const st = server ? DC.applyStatus(server, savedRev) : { state: "unknown", text: "" };
const devs = (server && server.devices) || [];
const online = devs.some((d) => d.online);
let dot = online ? "var(--dc-ok)" : "var(--dc-bad)", line1, line2;
if (!devs.length) { dot = "#6b7280"; line1 = "No display linked"; line2 = "Sign in on the Pi's setup page to link it"; }
else line1 = `Display ${online ? "online" : "OFFLINE"} · ${devs.map((d) => d.name).join(", ")}`;
if (phase === "pending") line2 = autoSend ? "Change queued — sending in a moment…" : "Unsent changes — tap Send to display";
else if (phase === "saving") line2 = "Sending…";
else if (phase === "waiting") line2 = online ? `Sent rev ${savedRev} · waiting for panel… ${sentAt ? Math.round((Date.now() - sentAt) / 1000) + "s" : ""}` : `Saved rev ${savedRev} · panel offline, it will apply when it reconnects`;
else if (phase === "slow") line2 = `Saved rev ${savedRev}, panel hasn't confirmed yet (it may be restarting)`;
else if (phase === "error") line2 = "Not sent — fix the problem below";
else if (devs.length) line2 = st.state === "applied" ? `On the panel: revision ${server.revision} ✓` : st.text;
const rendErr = devs.map((d) => d.renderer_error).filter(Boolean)[0];
const spec = draft;
const theme = spec.theme || {};
const colors = DC.themeColors(spec, defaults);
const pages = spec.pages || [];
return (
{line1}
{line2 &&
{line2}
}
{phase === "applied" && appliedInfo && (
✓ Applied on the panel — revision {appliedInfo.rev} at {appliedInfo.at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit" })}{appliedInfo.secs != null ? ` (${appliedInfo.secs}s)` : ""}
)}
{rendErr &&
Panel reports a problem: {rendErr}
}
{netErr &&
{netErr}
}
{errors.length > 0 && (
Can't send this yet:
{errors.slice(0, 6).map((e, k) => - {DC.friendlyError(e, spec)}
)}
)}
{tab === "easy" && (
<>
edit((s) => DC.setIn(s, ["enabled"], v))} />
`${v}%`} onChange={(v) => edit((s) => DC.setIn(s, ["brightness"], v))} />
edit((s) => DC.setIn(s, ["wife_mode"], v))} />
Look
{DC.PRESETS.map((p) => (
))}
edit((s) => DC.setIn(s, ["theme", "title"], v || undefined))} />
{["background", "text", "accent", "header_bg", "footer_bg", "separator", "win", "loss", "pending", "dim", "row_tint"].map((k) => (
edit((s) => {
let x = DC.setIn(s, ["theme", "colors", k], v);
if (["accent", "background", "text"].indexOf(k) >= 0) x = DC.setIn(x, ["theme", k], undefined);
return x;
})} />
))}
Header & footer
edit((s) => DC.setIn(s, ["header", "show"], v))} />
{(spec.header || {}).show !== false && edit((s) => DC.setIn(s, ["header", "show_page_title"], v))} />}
edit((s) => DC.setIn(s, ["footer", "show"], v))} />
{(spec.footer || {}).show !== false && (
<>
edit((s) => DC.setIn(s, ["footer", "text"], v || undefined))} />
You can use {"{date} {time} {user}"}
edit((s) => DC.setIn(s, ["footer", "speed"], v))} />
{!(spec.footer || {}).text && (
edit((s) => DC.setIn(s, ["footer", "reactions"], v))} />
edit((s) => DC.setIn(s, ["footer", "movers"], v))} />
)}
>
)}
Pages ({pages.length})
They rotate top to bottom. Tap ▸ to edit; ↑ ↓ to reorder.
{pages.map((p, i) =>
)}
{addType === "image" && (
)}
Popups
edit((s) => DC.setIn(s, ["popups", "enabled"], v))} />
`${v}s`} onChange={(v) => edit((s) => DC.setIn(s, ["popups", "seconds"], v))} />
edit((s) => DC.setIn(s, ["popups", "score_alerts"], v))} />
edit((s) => DC.setIn(s, ["popups", "final_alerts"], v))} />
edit((s) => DC.setIn(s, ["popups", "messages"], v))} />
Sports for score / final alerts
{DC.SPORTS.map((sp) => {
const cur = Array.isArray((spec.popups || {}).sports) ? spec.popups.sports : DC.DEFAULT_ALERT_SPORTS;
const on = cur.indexOf(sp) >= 0;
return ;
})}
Sleep schedule
edit((s) => { let x = DC.setIn(s, ["sleep", "enabled"], v); if (v && !(x.sleep.start)) { x = DC.setIn(x, ["sleep", "start"], "23:00"); x = DC.setIn(x, ["sleep", "end"], "07:00"); x = DC.setIn(x, ["sleep", "tz"], "America/New_York"); } return x; })} />
{(spec.sleep || {}).enabled && (
)}
>
)}
{tab === "json" && (
Whole spec (JSON)
View, edit or paste a full display spec. It is checked before it is sent.
)}
{handle ? `Signed in as ${handle}` : ""}{server ? ` · server revision ${server.revision} (${server.source})` : ""}
);
}
function DcLogin({ base, onDone }) {
const [h, setH] = React.useState(localStorage.getItem("bb_handle") || "");
const [pin, setPin] = React.useState("");
const [code, setCode] = React.useState(localStorage.getItem("bb_group_code") || "");
const [err, setErr] = React.useState("");
const [busy, setBusy] = React.useState(false);
const go = async (e) => {
e.preventDefault(); setErr(""); setBusy(true);
try {
const r = await fetch(base + "/ticker/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ handle: h.trim(), pin, code: code.trim() }) });
const j = await r.json().catch(() => ({}));
if (!j.ok || !j.token) throw new Error(j.error || "Sign-in failed");
localStorage.setItem("bb_handle", j.handle || h.trim()); localStorage.setItem("bb_group_code", code.trim());
onDone(j.token);
} catch (e2) { setErr(e2.message); } finally { setBusy(false); }
};
return (
);
}
window.DisplayControl = DisplayControl;
window.__dcDrawPanel = drawPanel; // exposed for tests
})();