// You > Display > Wall (owner wall editor) and the "Add next tile" step on the pairing success screen.
// Rules, copy and the API client: utils/board-wall.js (window.BBBoardWall); grid model: shared/wall-grid.js wallGridModel (shared with api/esp32/client.js).
// Behind the esp32Walls flag (GET /api/config, off by default; the server's BB_ESP32_WALLS): with it off, nothing here renders and no wall request is made.
// Pages / inline only, no modals. The grid map looks like the physical wall (one numbered cell per 128x64 board) and
// always fits the screen width, whatever the column count (1..16): nothing scrolls sideways, nothing is cut off.
// Tapping a cell (or its "Pick board" button in the cell list below) opens the picker page in place of the editor:
// full board names, wrapped. A board can be in only one cell: picking one already placed moves it, with a note.
// Read-only for everything else: no grading, ledger, balances, picks or wagers.
(function () {
const { useState, useEffect, useRef } = React;
const W = () => window.BBBoardWall;
const WRAP = { overflowWrap: "anywhere", wordBreak: "break-word", minWidth: 0 };
const BTN = { maxWidth: "100%", whiteSpace: "normal" };
const cellNo = (cell, cols) => cell.row * cols + cell.col + 1;
// true / false once /api/config answers; false while checking (fail closed).
function useWallFlag() {
const [on, setOn] = useState(false);
useEffect(() => {
let live = true;
if (!W() || !window.BBWallGrid) return undefined;
W().loadFlag().then((v) => { if (live) setOn(v === true); });
return () => { live = false; };
}, []);
return on;
}
function Note({ kind, children, testid }) {
if (!children) return null;
return (
{children}
);
}
function SideInput({ label, value, onChange, testid, disabled, max }) {
return (
{label}
onChange(e.target.value)} aria-label={label}
style={{ width: "100%", maxWidth: "7rem", boxSizing: "border-box" }} />
);
}
// The grid map: one numbered square per board, shrinking to fit the width (16 columns still fit at 320px).
function GridMap({ cells, rows, cols, names, onOpen }) {
const fs = cols <= 4 ? 13 : cols <= 8 ? 11 : 9;
const gap = cols > 10 ? 2 : 4;
// Narrowest cell at 360px (card content ~260px): a number only shows when every digit fits; otherwise the cell is
// a plain square (its number, row and column are in its label and in the list below), so nothing is ever cut off.
const minCellW = (260 - gap * (cols - 1)) / cols;
const fits = (n) => String(n).length * fs * 0.62 + 2 <= minCellW;
return (
{cells.map((c) => {
const name = c.device_id != null ? (names[String(c.device_id)] || "Board " + c.device_id) : "";
const filled = c.device_id != null;
return (
onOpen(c)}
style={{ minWidth: 0, width: "100%", aspectRatio: "2 / 1", padding: 0, margin: 0, boxSizing: "border-box", overflow: "hidden", fontSize: fs, lineHeight: 1,
fontWeight: 600, cursor: "pointer", border: "1px solid var(--bb-border, rgba(127,127,127,.45))",
background: filled ? "var(--bb-accent, #10b981)" : "transparent", color: filled ? "#04130d" : "inherit" }}>
{fits(cellNo(c, cols)) ? cellNo(c, cols) : null}
);
})}
);
}
// The cell list under the map: every cell's full board name (wrapped, never cut), Pick board, Identify, Place here.
function CellRow({ cell, cols, names, onOpen, onIdentify, identifyBusy, canPlace, onPlace }) {
const cur = cell.device_id;
const name = cur != null ? (names[String(cur)] || "Board " + cur) : "";
return (
Cell {cellNo(cell, cols)} · row {cell.row + 1}, column {cell.col + 1}
{name || "Empty"}
onOpen(cell)}>{cur == null ? "Pick board" : "Change"}
{cur != null && onIdentify(cur)}>Identify }
{canPlace && cur == null && onPlace(cell)}>Place here }
);
}
// The picker page for one cell (shown in place of the editor, no modal): Empty + the owner's boards in the wall's
// group, full names wrapped. A board on another of the owner's walls is greyed out (one wall per board); a board
// elsewhere on THIS wall says where it is and moves here when picked.
function Picker({ cell, cols, boards, names, cells, walls, wallId, wallName, onPick, onBack }) {
const B = W();
const cur = cell.device_id;
const listed = boards.some((b) => String(b.id) === String(cur));
const opt = (testid, value, title, sub, disabled, selected) => (
onPick(cell, value)}
style={{ ...BTN, display: "block", width: "100%", textAlign: "left", padding: "8px 12px", boxSizing: "border-box", ...WRAP,
border: selected ? "2px solid var(--brand)" : undefined, opacity: disabled ? 0.6 : 1 }}>
{selected ? "✓ " : ""}{title}
{sub && {sub} }
);
return (
‹ Back to the wall
Pick a board for cell {cellNo(cell, cols)}
Row {cell.row + 1}, column {cell.col + 1}{wallName ? ` of ${wallName}` : ""}. Now: {cur != null ? (names[String(cur)] || "Board " + cur) : "Empty"}
{opt("wall-option-empty", "", "Empty", "No board in this cell", false, cur == null)}
{cur != null && !listed && opt(`wall-option-${cur}`, String(cur), names[String(cur)] || "Board " + cur, "In this cell now", false, true)}
{boards.map((b) => {
const at = B.whereIs(cells, b.id);
const here = at && at.row === cell.row && at.col === cell.col;
const other = B.otherWallOf(walls, wallId, b.id);
const sub = other ? `On ${other.name || "another wall"}` : here ? "In this cell now" : at ? `Now in cell ${cellNo(at, cols)} (row ${at.row + 1}, column ${at.col + 1}); picking moves it here` : "";
return opt(`wall-option-${b.id}`, String(b.id), B.boardName(b), sub, !!other, !!here);
})}
);
}
function WallSection({ apiBase, token, navigate, currentGroupCode }) {
const on = useWallFlag();
if (!on) return null;
return ;
}
function WallEditor({ apiBase, token, navigate, currentGroupCode }) {
const B = W();
const client = () => B.createClient({ apiBase, token: () => token });
const curGroup = currentGroupCode || (() => { try { return localStorage.getItem("bb_group_code") || ""; } catch (e) { return ""; } })();
const [st, setSt] = useState({ step: "loading" }); // loading | ready | error
const [walls, setWalls] = useState([]);
const [caps, setCaps] = useState(B.CAPS);
const [boards, setBoards] = useState([]);
const [groups, setGroups] = useState([]);
const [sel, setSel] = useState(-1); // index into walls; -1 = new (unsaved) wall
const [draft, setDraft] = useState(null);
const [size, setSize] = useState({ rows: "1", cols: "2" });
const [note, setNote] = useState(null); // {kind, text}
const [busy, setBusy] = useState(""); // save | delete
const [confirmDel, setConfirmDel] = useState(false);
const [dirty, setDirty] = useState(false);
const [ident, setIdent] = useState(null); // {kind, text}
const [identBusy, setIdentBusy] = useState(false);
const [placeId, setPlaceId] = useState(() => B.peekPlace(sessionStorage));
const [picking, setPicking] = useState(null); // the cell whose picker page is open, else null
const live = useRef(true);
const timer = useRef(null);
useEffect(() => () => { live.current = false; clearTimeout(timer.current); }, []);
const showWall = (w, i) => { setPicking(null); setSel(i); setDraft(w); setSize({ rows: String(w.rows), cols: String(w.cols) }); setDirty(false); setConfirmDel(false); };
const newWall = (gs) => {
const g = (gs || groups)[0];
const w = { id: null, name: "", group_code: g ? g.code : "", rows: 1, cols: 2, cells: B.cellsOf({ rows: 1, cols: 2 }), names: {} };
setPicking(null); setSel(-1); setDraft(w); setSize({ rows: "1", cols: "2" }); setDirty(true); setConfirmDel(false);
};
useEffect(() => {
let ok = true;
setSt({ step: "loading" });
Promise.all([client().list(), client().boards(), client().groups()]).then(([l, b, g]) => {
if (!ok || !live.current) return;
if (!l.ok) { setSt({ step: "error", kind: l.kind }); return; }
const bs = b.ok ? b.boards : [];
const gs = B.groupChoices(g.groups, bs, curGroup);
setBoards(bs); setGroups(gs); setWalls(l.walls); setCaps(l.caps);
if (l.walls[0]) showWall(l.walls[0], 0); else newWall(gs);
setSt({ step: "ready" });
});
return () => { ok = false; };
}, [apiBase, token]); // eslint-disable-line react-hooks/exhaustive-deps
const names = {};
walls.concat(draft ? [draft] : []).forEach((w) => Object.assign(names, w.names || {}));
boards.forEach((b) => { names[String(b.id)] = B.boardName(b); });
const groupBoards = draft ? B.boardsInGroup(boards, draft.group_code) : [];
const groupName = (code) => { const g = groups.find((x) => String(x.code).toLowerCase() === String(code || "").toLowerCase()); return g ? g.name : code; };
const placeName = placeId != null ? (names[String(placeId)] || "your new board") : "";
const canPlace = placeId != null && groupBoards.some((b) => String(b.id) === String(placeId)) && !B.otherWallOf(walls, draft && draft.id, placeId);
const hold = (secs, msg) => {
setIdentBusy(true); setIdent(msg);
clearTimeout(timer.current);
timer.current = setTimeout(() => { if (live.current) { setIdentBusy(false); setIdent(null); } }, secs * 1000);
};
const identifyDone = (r, which, count) => {
if (!live.current) return;
if (r.ok) { hold(r.seconds || B.IDENTIFY_SECONDS, { kind: "ok", text: B.identifyStatus(count) }); return; }
if (r.kind === "rate_limited" && r.retryAfter > 0) { hold(r.retryAfter, { kind: "error", text: B.errorText(r.kind, which, r.retryAfter) }); return; }
setIdentBusy(false);
setIdent({ kind: "error", text: B.errorText(r.kind, which, r.retryAfter) });
};
const identifyAll = async () => {
if (identBusy) return;
setIdentBusy(true); setIdent(null);
const r = await client().identifyAll(draft.group_code);
identifyDone(r, "identify_all", r.ok ? r.boards.length : null);
};
const identifyOne = async (id) => {
if (identBusy) return;
setIdentBusy(true); setIdent(null);
identifyDone(await client().identifyBoard(id), "identify", 1);
};
const backToCell = (cell) => {
setPicking(null);
setTimeout(() => {
const el = cell && document.querySelector(`[data-testid=wall-cellrow-${cell.row}-${cell.col}]`);
if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
}, 0);
};
const pick = (cell, value) => {
const r = B.assignCell(draft.cells, cell.row, cell.col, value === "" ? null : value);
setDraft({ ...draft, cells: r.cells }); setDirty(true);
setNote(r.moved ? { kind: "ok", text: B.movedText(names[String(value)] || "That board", r.moved) } : null);
backToCell(cell);
};
const place = (cell) => {
const r = B.assignCell(draft.cells, cell.row, cell.col, placeId);
setDraft({ ...draft, cells: r.cells }); setDirty(true);
B.clearPlace(sessionStorage); setPlaceId(null);
setNote({ kind: "ok", text: (r.moved ? B.movedText(placeName, r.moved) + " " : `Placed ${placeName} at ${B.cellLabel(cell)}. `) + "Tap Save wall to keep it." });
};
const applySize = () => {
const r = B.resize(draft, size.rows, size.cols, caps);
setDraft(r.wall); setSize({ rows: String(r.wall.rows), cols: String(r.wall.cols) }); setDirty(true);
const c = B.clearedText(r.cleared, names);
setNote({ kind: "ok", text: (c ? c + " " : "") + `Now ${r.wall.rows} × ${r.wall.cols}. Tap Save wall to keep it.` });
};
const setGroup = (code) => {
// Boards must show the wall's group: switching groups clears the cells.
setDraft({ ...draft, group_code: code, cells: B.cellsOf({ rows: draft.rows, cols: draft.cols }) }); setDirty(true);
setNote(draft.cells.some((c) => c.device_id != null) ? { kind: "ok", text: "Cells cleared: a wall only uses boards that show its group." } : null);
};
const save = async () => {
setBusy("save"); setNote(null);
const isNew = draft.id == null;
const r = isNew ? await client().create(draft, caps) : await client().save(draft, caps);
if (!live.current) return;
setBusy("");
if (!r.ok) { setNote({ kind: "error", text: B.errorText(r.kind, "wall", r.retryAfter) }); return; }
const w = r.wall;
const next = walls.slice();
const i = isNew ? next.length : sel;
next[i] = w; setWalls(next); showWall(w, i);
setNote({ kind: "ok", text: isNew ? "Wall created." : "Wall saved." });
};
const del = async () => {
if (draft.id == null) { setConfirmDel(false); if (walls[0]) showWall(walls[0], 0); else newWall(); setNote(null); return; }
setBusy("delete"); setNote(null);
const r = await client().remove(draft.id);
if (!live.current) return;
setBusy("");
if (!r.ok && r.kind !== "not_found") { setConfirmDel(false); setNote({ kind: "error", text: B.errorText(r.kind) }); return; }
const next = walls.filter((_, i) => i !== sel);
setWalls(next);
if (next[0]) showWall(next[0], 0); else newWall();
setConfirmDel(false);
setNote({ kind: "ok", text: "Wall deleted." });
};
const model = draft ? B.gridModel(draft) : null;
const placed = draft ? draft.cells.filter((c) => c.device_id != null).length : 0;
const sizeChanged = draft && (String(size.rows) !== String(draft.rows) || String(size.cols) !== String(draft.cols));
const isNew = draft && draft.id == null;
const atMax = walls.length >= caps.max_walls;
return (
Wall
Each board is one 128x64 panel. Put boards side by side in a grid to make one bigger display.
{placeId != null && st.step === "ready" &&
{canPlace ? `Pick an empty cell for ${placeName}, then tap Save wall.` : `${placeName} isn't in this wall's group. Pick a wall for its group.`}
{" "} { B.clearPlace(sessionStorage); setPlaceId(null); }}>Not now
}
{st.step === "loading" && Loading…
}
{st.step === "error" && (
{B.errorText(st.kind)}
{st.kind === "signed_out" && navigate && navigate(window.BBRouter ? window.BBRouter.build("signin", null, { next: "/settings/display" }) : "/signin")}>Sign in }
)}
{st.step === "ready" && !boards.length && Link a Bragwire Board first. Then you can add it to a wall.
}
{st.step === "ready" && draft && picking && (
c.row === picking.row && c.col === picking.col) || picking} cols={draft.cols} boards={groupBoards}
names={names} cells={draft.cells} walls={walls} wallId={draft.id} wallName={draft.name} onPick={pick} onBack={() => backToCell(picking)} />
)}
{st.step === "ready" && !picking && walls.length > 0 && (
Wall
{/* A wrapping list of full names, not a select: a long wall name is never cut off. */}
{walls.map((w, i) => (
{ showWall(walls[i], i); setNote(null); }}>{w.name || `Wall ${i + 1}`}
))}
{(isNew || !atMax) && (
{ newWall(); setNote(null); }}>+ New wall
)}
)}
{st.step === "ready" && draft && !picking && (
Wall name
{ setDraft({ ...draft, name: e.target.value }); setDirty(true); }} style={{ width: "100%", boxSizing: "border-box" }} />
{isNew && groups.length > 1 ? (
Group
setGroup(e.target.value)}>
{groups.map((g) => {g.name} )}
) : (
Group: {groupName(draft.group_code) || "None"}
)}
setSize({ ...size, rows: v })} testid="wall-rows" />
setSize({ ...size, cols: v })} testid="wall-cols" />
Resize
{model.phrase} · {model.logical_width}x{model.logical_height}
{identBusy ? "Showing names…" : "Identify screens"}
Every board in this group shows its name for {caps.identify_s} seconds.
{ident && ident.text}
setPicking({ row: c.row, col: c.col })} />
{draft.cells.map((c) => (
setPicking({ row: x.row, col: x.col })}
onIdentify={identifyOne} identifyBusy={identBusy} canPlace={canPlace} onPlace={place} />
))}
{placed} of {draft.rows * draft.cols} cells have a board.{draft.group_code && !groupBoards.length ? " None of your boards show this group yet." : ""}
{note && note.text}
{busy === "save" ? "Saving…" : isNew ? "Create wall" : "Save wall"}
{!confirmDel && (isNew ? del() : setConfirmDel(true))}>{isNew ? "Cancel" : "Delete wall"} }
{isNew && atMax && You have the most walls allowed ({caps.max_walls}).
}
{confirmDel && (
Delete {draft.name || "this wall"}? Each board goes back to showing on its own.
{busy === "delete" ? "Deleting…" : "Delete wall"}
setConfirmDel(false)}>Cancel
)}
)}
);
}
// Pairing success (components/BoardPair.jsx), flag on: place the just-linked board in a wall cell, or link another.
function AddNextTile({ boardId, navigate }) {
const on = useWallFlag();
if (!on) return null;
return (
Add next tile
Each board is one 128x64 panel. Place this one in a Wall cell, or link another board.
{ W().setPlace(sessionStorage, boardId); navigate("/settings/display"); }}>Place in a wall
navigate("/pair/enter")}>Link another board
);
}
window.BBBoardWallUI = { WallSection, AddNextTile, useWallFlag };
})();