// Mobile simplified console for BControl v0.5 // Shown after login when viewed on a phone-sized viewport. // Flow: pick a panel from a dropdown, fire one of three fixed commands. // The three actions are the same for every panel, but each panel/brand // resolves to its own command_id — matched by name below. // VAPID base64url → Uint8Array (applicationServerKey format). function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(base64); const arr = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); return arr; } // Subscribe this device to Web Push. Returns { ok, msg } for a hint banner. async function bcEnablePush() { const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent); const standalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; if (!('serviceWorker' in navigator) || !('PushManager' in window)) { return { ok: false, msg: isIOS && !standalone ? 'Primero instala la app en tu inicio' : 'Notificaciones no soportadas aquí' }; } try { const cfg = await fetch('/push/vapid-public-key').then((r) => r.json()); if (!cfg.enabled || !cfg.key) return { ok: false, msg: 'Push no está configurado en el servidor' }; const perm = await Notification.requestPermission(); if (perm !== 'granted') return { ok: false, msg: 'Permiso de notificaciones denegado' }; const reg = await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(cfg.key), }); } const res = await fetch('/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub.toJSON()), }); if (!res.ok) return { ok: false, msg: 'No se pudo registrar la suscripción' }; return { ok: true, msg: 'Notificaciones activadas' }; } catch (e) { if (isIOS && !standalone) return { ok: false, msg: 'Primero instala la app en tu inicio' }; return { ok: false, msg: 'Error al activar notificaciones' }; } } // Reactive "is this a phone?" hook. Mirrors the 767px CSS breakpoint. function useIsMobile() { const query = '(max-width: 767px)'; const [isMobile, setIsMobile] = React.useState(() => typeof window.matchMedia === 'function' ? window.matchMedia(query).matches : false ); React.useEffect(() => { if (typeof window.matchMedia !== 'function') return; const mq = window.matchMedia(query); const onChange = (e) => setIsMobile(e.matches); // Safari <14 uses addListener mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange); return () => { mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange); }; }, []); return isMobile; } // Resolve a panel's command list into the three canonical actions. // Payload/name/transport differ per brand, so the classification lives in the // DB (tcp_commands.ui_action). Keep the real command object (id) for each slot. function matchTriadCommands(commands) { const list = commands || []; const byAction = (a) => list.find((c) => c.ui_action === a); return { reset: byAction('reset'), silence: byAction('silence'), ack: byAction('ack'), }; } // Fixed action slots — same icon, colour and order on every panel. const MOBILE_ACTIONS = [ { key: 'reset', label: 'Reiniciar panel', icon: 'rotate-ccw', tone: 'fire', confirm: true }, { key: 'silence', label: 'Silenciar panel', icon: 'bell-off', tone: 'trouble', confirm: false, bg: '#F0A519' }, // brighter than the shared trouble token { key: 'ack', label: 'Reconocer panel', icon: 'check', tone: 'ok', confirm: false }, ]; // Compact read-only notification triad for the mobile banner. // Tapping a badge is a no-op here (filtering lives in the full view) — surface that via onTap. function MobileNotifTriad({ counts, onTap }) { const items = [ { icon: 'fire', count: counts.alarm, sev: BC.fire }, { icon: 'warning', count: counts.trouble, sev: BC.trouble }, { icon: 'shield', count: counts.supervisory, sev: BC.supervisory }, ]; return (
{items.map((it) => ( ))}
); } // Custom panel dropdown ("drop box") — shows status dot + alarm badge per panel. function MobilePanelPicker({ clients, selected, setSelected, newAlarmsByClient }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', handler); document.addEventListener('touchstart', handler); return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); }; }, [open]); const current = clients.find((c) => c.client_id === selected); const dotFor = (p) => p.alive_status === 'connected' ? BC.ok.dot : p.alive_status === 'pending' ? BC.pending.dot : BC.fire.dot; return (
{open && (
{clients.length === 0 && (
No hay paneles asignados.
)} {clients.map((p) => { const active = p.client_id === selected; const alarms = newAlarmsByClient[p.client_id] || 0; return ( ); })}
)}
); } function MobileView({ user, clients, events, counts, selected, setSelected, commandsByClient, onSendCommand, lastCmdResult, onLogout, onResetNotifications, liveState, newAlarmsByClient, onExitMobile, panelOrder }) { const sortedClients = React.useMemo(() => { const order = new Map((panelOrder || []).map((id, i) => [id, i])); return [...clients].sort((a, b) => { const ai = order.has(a.client_id) ? order.get(a.client_id) : Infinity; const bi = order.has(b.client_id) ? order.get(b.client_id) : Infinity; return ai - bi; }); }, [clients, panelOrder]); const [menuOpen, setMenuOpen] = React.useState(false); const [confirmKey, setConfirmKey] = React.useState(null); // action key awaiting a 2nd tap const [evtToasts, setEvtToasts] = React.useState([]); // stacked arriving events, newest first const [notifHint, setNotifHint] = React.useState(null); // transient hint banner message const [confirmExit, setConfirmExit] = React.useState(false); // "enter full view?" prompt const menuRef = React.useRef(null); const lastEvtId = React.useRef(null); const evtMounted = React.useRef(false); const notifHintTimer = React.useRef(null); function showHint(msg) { setNotifHint(msg); clearTimeout(notifHintTimer.current); notifHintTimer.current = setTimeout(() => setNotifHint(null), 3000); } React.useEffect(() => () => clearTimeout(notifHintTimer.current), []); async function handleEnablePush() { showHint('Activando notificaciones…'); const r = await bcEnablePush(); showHint(r.msg); } const panelName = (cid) => (clients.find((c) => c.client_id === cid)?.description) || cid; // Ask for OS notification permission once (best-effort, for background pushes). React.useEffect(() => { try { if (window.Notification && Notification.permission === 'default') Notification.requestPermission(); } catch (_) {} }, []); // Notify on every newly arriving event while on the simplified view. React.useEffect(() => { const top = (events || [])[0]; if (!evtMounted.current) { evtMounted.current = true; lastEvtId.current = top ? top.id : null; return; } if (!top || top.id === lastEvtId.current) return; lastEvtId.current = top.id; // Stack: new toast goes on top of any still showing (cap the stack). setEvtToasts((prev) => [top, ...prev.filter((e) => e.id !== top.id)].slice(0, 4)); try { if (document.hidden && window.Notification && Notification.permission === 'granted') { new Notification(`${classifyLabel(top.cls)} · ${panelName(top.client_id)}`, { body: top.message || '' }); } } catch (_) {} // Independent per-toast timer — must NOT be cleared on the next event, // otherwise stacked toasts would never auto-dismiss. setTimeout(() => setEvtToasts((prev) => prev.filter((e) => e.id !== top.id)), 15000); }, [events]); React.useEffect(() => { if (!menuOpen) return; const handler = (e) => { if (!menuRef.current?.contains(e.target)) setMenuOpen(false); }; document.addEventListener('mousedown', handler); document.addEventListener('touchstart', handler); return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); }; }, [menuOpen]); const panel = clients.find((c) => c.client_id === selected); const role = user.role; const rawCommands = panel ? commandsByClient[panel.client_id] : null; const loadingCommands = panel && rawCommands === undefined; const triad = matchTriadCommands(rawCommands); // Reset the pending confirm whenever the panel changes. React.useEffect(() => { setConfirmKey(null); }, [selected]); function gate(cmd) { if (!panel) return { disabled: true, reason: 'Elija un panel' }; if (loadingCommands) return { disabled: true, reason: 'Cargando…' }; if (!cmd) return { disabled: true, reason: 'No disponible' }; if (role === 'viewer') return { disabled: true, reason: 'Solo lectura' }; if (panel.status !== 'connected') return { disabled: true, reason: 'Panel desconectado' }; if (cmd.admin_only && role !== 'admin') return { disabled: true, reason: 'Solo administradores' }; return { disabled: false }; } function handleAction(action, cmd) { if (action.confirm && confirmKey !== action.key) { setConfirmKey(action.key); setTimeout(() => setConfirmKey((k) => (k === action.key ? null : k)), 4000); return; } setConfirmKey(null); onSendCommand(cmd); } return (
{/* ── Banner: logo + notifications badge + menu (padded for the status bar / notch) ── */}
Logo
showHint('Disponible solo en la vista completa')} />
{menuOpen && (
{user.username}
{user.role}
)}
{/* ── "Enter full view?" confirmation ── */} {confirmExit && (
setConfirmExit(false)}>
e.stopPropagation()} style={{ width: '100%', maxWidth: 340, background: BC.surface, borderRadius: 14, boxShadow: BC.shadowLg, padding: 20, color: BC.ink }}>
¿Entrar a la vista completa?
Podrás volver a la vista simplificada en cualquier momento desde el menú de usuario.
)} {/* ── Transient hint banner (badge tap / push activation) ── */} {notifHint && (
setNotifHint(null)} style={{ position: 'fixed', top: 'calc(64px + env(safe-area-inset-top))', left: 12, right: 12, zIndex: 360, display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', background: BC.ink, color: '#fff', borderRadius: 10, fontSize: 12, boxShadow: BC.shadowLg, cursor: 'pointer' }}> {notifHint}
)} {/* ── Incoming-event notifications (stacked, newest on top) ── */} {evtToasts.length > 0 && (
{evtToasts.map((ev) => { const c = classifyColor(ev.cls); const cat = classifyCategory(ev.cls); return (
setEvtToasts((prev) => prev.filter((e) => e.id !== ev.id))} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 14px', background: BC.surface, borderRadius: 12, border: '1px solid ' + c.bar, borderLeft: '4px solid ' + c.bar, boxShadow: BC.shadowLg, cursor: 'pointer' }}> {classifyLabel(ev.cls)} · {panelName(ev.client_id)} {ev.timestamp && {relTime(ev.timestamp)}} {ev.message}
); })}
)} {/* ── Body ── */}
Panel
Comando
{MOBILE_ACTIONS.map((action) => { const cmd = triad[action.key]; const g = gate(cmd); const tone = BC[action.tone]; const awaitingConfirm = confirmKey === action.key; return ( ); })}
{/* Result toast */} {lastCmdResult && (
{lastCmdResult.msg}
)}
{/* ── Status strip (padded for the home indicator) ── */}
{clients.filter((c) => c.alive_status === 'connected').length} de {clients.length} paneles conectados · BControl {APP_VERSION}
); } Object.assign(window, { useIsMobile, matchTriadCommands, MobileView });