// 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" && ( <>