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