// 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 ( ); } // 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 ( ); })}
); } // 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"}
    {cur != null && } {canPlace && cur == null && }
  • ); } // 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) => (
  • ); return (

    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"}

    ); } 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.`} {" "}

    } {st.step === "loading" &&
    Loading…
    } {st.step === "error" && (
    {B.errorText(st.kind)} {st.kind === "signed_out" && navigate && }
    )} {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) => (
    • ))} {(isNew || !atMax) && (
    • )}
    )} {st.step === "ready" && draft && !picking && (
    {isNew && groups.length > 1 ? ( ) : (
    Group: {groupName(draft.group_code) || "None"}
    )}
    setSize({ ...size, rows: v })} testid="wall-rows" /> setSize({ ...size, cols: v })} testid="wall-cols" />
    {model.phrase} · {model.logical_width}x{model.logical_height}
    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}
    {!confirmDel && }
    {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.

    )}
    )}
    ); } // 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.

    ); } window.BBBoardWallUI = { WallSection, AddNextTile, useWallFlag }; })();