Implement mobile-responsive expert mode for Spaxel dashboard
- Add hamburger menu for mobile panel navigation - Create bottom sheet panels that slide from bottom on mobile - Implement touch-optimized UI with 44x44px minimum tap targets - Add mobile-specific panel content for Fleet Status, Zones, Triggers, Settings - Support drag-to-close gesture on bottom sheets - Maintain existing desktop panel behavior - Integrate with existing systems (Viz3D, SpaxelPanels, SpatialQuickActions) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8cfd0699c6
commit
c7d37b834d
3 changed files with 1196 additions and 0 deletions
|
|
@ -28,6 +28,7 @@
|
|||
<link rel="stylesheet" href="css/quick-actions.css">
|
||||
<link rel="stylesheet" href="css/briefing.css">
|
||||
<link rel="stylesheet" href="css/simulator.css">
|
||||
<link rel="stylesheet" href="static/css/mobile.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
|
|
@ -4495,5 +4496,7 @@
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- Mobile Responsive Expert Mode -->
|
||||
<script type="module" src="static/js/mobile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
482
dashboard/static/css/mobile.css
Normal file
482
dashboard/static/css/mobile.css
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
/* ============================================
|
||||
Mobile-Responsive Expert Mode Styles
|
||||
============================================ */
|
||||
|
||||
/* Hamburger menu button in status bar */
|
||||
.mobile-hamburger {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.mobile-hamburger span {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--text-primary);
|
||||
margin: 5px 0;
|
||||
transition: background var(--transition-fast);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mobile-hamburger:hover span {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.mobile-hamburger:active span {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
/* Active state (X icon) */
|
||||
.mobile-hamburger.active span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(5px, 5px);
|
||||
}
|
||||
|
||||
.mobile-hamburger.active span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mobile-hamburger.active span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(7px, -6px);
|
||||
}
|
||||
|
||||
/* Mobile navigation overlay */
|
||||
.mobile-nav-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--overlay-strong);
|
||||
z-index: 998;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-nav-overlay.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile navigation panel */
|
||||
.mobile-nav-panel {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
background: var(--bg-card);
|
||||
z-index: 999;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-normal);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: 2px 0 16px var(--shadow-xl);
|
||||
}
|
||||
|
||||
.mobile-nav-panel.visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobile-nav-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.mobile-nav-panel-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--fw-heading);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-nav-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
border-radius: var(--radius-control);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-nav-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Mobile nav section headers */
|
||||
.mobile-nav-section {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.mobile-nav-section-header {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--fw-heading);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Mobile nav list items */
|
||||
.mobile-nav-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.mobile-nav-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
min-height: 48px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-body);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-nav-link:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mobile-nav-link:active {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.mobile-nav-link-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-nav-link-chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Bottom sheet panel */
|
||||
.mobile-bottom-sheet {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 70vh;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||
box-shadow: 0 -4px 24px var(--shadow-xl);
|
||||
z-index: 997;
|
||||
transform: translateY(100%);
|
||||
transition: transform var(--transition-normal);
|
||||
overflow: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Bottom sheet drag handle */
|
||||
.mobile-bottom-sheet-handle {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-handle-bar {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: var(--border-default);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Bottom sheet header */
|
||||
.mobile-bottom-sheet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--fw-heading);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border-radius: var(--radius-control);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Bottom sheet content */
|
||||
.mobile-bottom-sheet-content {
|
||||
padding: var(--space-3);
|
||||
overflow-y: auto;
|
||||
max-height: calc(70vh - 80px);
|
||||
}
|
||||
|
||||
/* Mobile list items */
|
||||
.mobile-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-list-item {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mobile-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-list-item-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-list-item-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mobile-list-item-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.mobile-list-item-badge--success {
|
||||
background: var(--green-9);
|
||||
color: var(--green-2);
|
||||
}
|
||||
|
||||
.mobile-list-item-badge--warning {
|
||||
background: var(--yellow-9);
|
||||
color: var(--yellow-2);
|
||||
}
|
||||
|
||||
.mobile-list-item-badge--error {
|
||||
background: var(--red-9);
|
||||
color: var(--red-2);
|
||||
}
|
||||
|
||||
.mobile-list-item-badge--info {
|
||||
background: var(--blue-9);
|
||||
color: var(--blue-2);
|
||||
}
|
||||
|
||||
/* Mobile zone items */
|
||||
.mobile-zone-item {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
border-left: 3px solid transparent;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-zone-item--armed {
|
||||
border-left-color: var(--color-arming-armed);
|
||||
background: rgba(var(--color-arming-armed-rgb), 0.1);
|
||||
}
|
||||
|
||||
.mobile-zone-item--disarmed {
|
||||
border-left-color: var(--color-arming-disarmed);
|
||||
}
|
||||
|
||||
.mobile-zone-item-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.mobile-zone-item-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Mobile trigger items */
|
||||
.mobile-trigger-item {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-trigger-item-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.mobile-trigger-item-condition {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Mobile settings items */
|
||||
.mobile-settings-item {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mobile-settings-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-settings-toggle {
|
||||
width: 52px;
|
||||
height: 32px;
|
||||
background: var(--slate-7);
|
||||
border-radius: var(--radius-pill);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-settings-toggle--on {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.mobile-settings-toggle-knob {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-fast);
|
||||
box-shadow: 0 2px 4px var(--shadow-md);
|
||||
}
|
||||
|
||||
.mobile-settings-toggle--on .mobile-settings-toggle-knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Mobile breakpoint: show hamburger and mobile nav */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-hamburger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-nav-overlay,
|
||||
.mobile-nav-panel,
|
||||
.mobile-bottom-sheet {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Adjust status bar to account for hamburger */
|
||||
.live-status-bar {
|
||||
padding-left: calc(var(--space-3) + 44px);
|
||||
}
|
||||
|
||||
/* Hide desktop panels on mobile */
|
||||
.live-panel--left,
|
||||
.live-panel--right,
|
||||
.live-panel--presence,
|
||||
#node-panel,
|
||||
#presence-panel {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small devices */
|
||||
@media (max-width: 374px) {
|
||||
.mobile-nav-panel {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area support for notched devices */
|
||||
@supports (padding: max(0px)) {
|
||||
.mobile-bottom-sheet {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.mobile-nav-panel {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
711
dashboard/static/js/mobile.js
Normal file
711
dashboard/static/js/mobile.js
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
/**
|
||||
* Spaxel Dashboard - Mobile Expert Mode
|
||||
*
|
||||
* Mobile-responsive enhancements for expert mode:
|
||||
* - Hamburger menu for panel navigation
|
||||
* - Bottom sheet panels for mobile
|
||||
* - Touch-optimized interactions
|
||||
* - No hover-dependent UI
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
let hamburgerMenu = null;
|
||||
let mobileNavOpen = false;
|
||||
let activeBottomSheet = null;
|
||||
|
||||
// ============================================
|
||||
// Configuration
|
||||
// ============================================
|
||||
const MOBILE_BREAKPOINT = 768; // px
|
||||
const BOTTOM_SHEET_MAX_HEIGHT = '70vh';
|
||||
const BOTTOM_SHEET_MIN_HEIGHT = '40vh';
|
||||
|
||||
// ============================================
|
||||
// Hamburger Menu
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create hamburger menu button
|
||||
*/
|
||||
function createHamburgerMenu() {
|
||||
if (document.getElementById('mobile-hamburger')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hamburger = document.createElement('button');
|
||||
hamburger.id = 'mobile-hamburger';
|
||||
hamburger.className = 'mobile-hamburger';
|
||||
hamburger.setAttribute('aria-label', 'Open menu');
|
||||
hamburger.setAttribute('aria-expanded', 'false');
|
||||
hamburger.innerHTML = `
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
`;
|
||||
|
||||
hamburger.addEventListener('click', toggleMobileNav);
|
||||
|
||||
// Insert into status bar
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
if (statusBar) {
|
||||
statusBar.insertBefore(hamburger, statusBar.firstChild);
|
||||
}
|
||||
|
||||
hamburgerMenu = hamburger;
|
||||
console.log('[Mobile] Hamburger menu created');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mobile navigation
|
||||
*/
|
||||
function toggleMobileNav() {
|
||||
mobileNavOpen = !mobileNavOpen;
|
||||
|
||||
if (mobileNavOpen) {
|
||||
openMobileNav();
|
||||
} else {
|
||||
closeMobileNav();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open mobile navigation
|
||||
*/
|
||||
function openMobileNav() {
|
||||
mobileNavOpen = true;
|
||||
|
||||
if (hamburgerMenu) {
|
||||
hamburgerMenu.classList.add('active');
|
||||
hamburgerMenu.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
// Close any open bottom sheets
|
||||
closeActiveBottomSheet();
|
||||
|
||||
// Create mobile nav overlay
|
||||
let navOverlay = document.getElementById('mobile-nav-overlay');
|
||||
if (!navOverlay) {
|
||||
navOverlay = document.createElement('div');
|
||||
navOverlay.id = 'mobile-nav-overlay';
|
||||
navOverlay.className = 'mobile-nav-overlay';
|
||||
navOverlay.addEventListener('click', closeMobileNav);
|
||||
document.body.appendChild(navOverlay);
|
||||
}
|
||||
|
||||
// Create mobile nav panel
|
||||
let navPanel = document.getElementById('mobile-nav-panel');
|
||||
if (!navPanel) {
|
||||
navPanel = createMobileNavPanel();
|
||||
document.body.appendChild(navPanel);
|
||||
}
|
||||
|
||||
// Show navigation
|
||||
requestAnimationFrame(() => {
|
||||
navOverlay.classList.add('visible');
|
||||
navPanel.classList.add('visible');
|
||||
});
|
||||
|
||||
// Disable OrbitControls
|
||||
if (window.Viz3D && window.Viz3D.controls) {
|
||||
const controls = window.Viz3D.controls();
|
||||
if (controls) controls.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close mobile navigation
|
||||
*/
|
||||
function closeMobileNav() {
|
||||
mobileNavOpen = false;
|
||||
|
||||
if (hamburgerMenu) {
|
||||
hamburgerMenu.classList.remove('active');
|
||||
hamburgerMenu.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
const navOverlay = document.getElementById('mobile-nav-overlay');
|
||||
const navPanel = document.getElementById('mobile-nav-panel');
|
||||
|
||||
if (navOverlay) navOverlay.classList.remove('visible');
|
||||
if (navPanel) navPanel.classList.remove('visible');
|
||||
|
||||
// Re-enable OrbitControls
|
||||
if (window.Viz3D && window.Viz3D.controls) {
|
||||
const controls = window.Viz3D.controls();
|
||||
if (controls) controls.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mobile navigation panel
|
||||
*/
|
||||
function createMobileNavPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'mobile-nav-panel';
|
||||
panel.className = 'mobile-nav-panel';
|
||||
|
||||
// Panel sections
|
||||
const sections = [
|
||||
{
|
||||
title: 'Panels',
|
||||
items: [
|
||||
{ id: 'fleet-status', label: 'Fleet Status', icon: '📡', action: openFleetStatus },
|
||||
{ id: 'zones', label: 'Zones', icon: '🏠', action: openZonesPanel },
|
||||
{ id: 'triggers', label: 'Automation', icon: '⚡', action: openTriggersPanel },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙️', action: openSettingsPanel }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'View',
|
||||
items: [
|
||||
{ id: 'reset-view', label: 'Reset View', icon: '🎯', action: resetView },
|
||||
{ id: 'toggle-layers', label: 'Toggle Layers', icon: '👁️', action: toggleLayers },
|
||||
{ id: 'simple-mode', label: 'Simple Mode', icon: '📱', action: switchToSimpleMode }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
let html = '<div class="mobile-nav-content">';
|
||||
|
||||
sections.forEach(section => {
|
||||
html += `
|
||||
<div class="mobile-nav-section">
|
||||
<h3 class="mobile-nav-section-title">${section.title}</h3>
|
||||
<div class="mobile-nav-items">
|
||||
`;
|
||||
|
||||
section.items.forEach(item => {
|
||||
html += `
|
||||
<button class="mobile-nav-item" data-action="${item.id}">
|
||||
<span class="mobile-nav-icon">${item.icon}</span>
|
||||
<span class="mobile-nav-label">${item.label}</span>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
panel.innerHTML = html;
|
||||
|
||||
// Add event listeners
|
||||
panel.querySelectorAll('.mobile-nav-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const actionId = e.currentTarget.dataset.action;
|
||||
handleMobileNavAction(actionId);
|
||||
});
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mobile navigation actions
|
||||
*/
|
||||
function handleMobileNavAction(actionId) {
|
||||
closeMobileNav();
|
||||
|
||||
// Small delay to allow nav to close before opening panel
|
||||
setTimeout(() => {
|
||||
switch (actionId) {
|
||||
case 'fleet-status':
|
||||
openFleetStatus();
|
||||
break;
|
||||
case 'zones':
|
||||
openZonesPanel();
|
||||
break;
|
||||
case 'triggers':
|
||||
openTriggersPanel();
|
||||
break;
|
||||
case 'settings':
|
||||
openSettingsPanel();
|
||||
break;
|
||||
case 'reset-view':
|
||||
resetView();
|
||||
break;
|
||||
case 'toggle-layers':
|
||||
toggleLayers();
|
||||
break;
|
||||
case 'simple-mode':
|
||||
if (window.SpaxelRouter) {
|
||||
window.SpaxelRouter.navigate('simple');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Panel Actions
|
||||
// ============================================
|
||||
|
||||
function openFleetStatus() {
|
||||
if (window.SpaxelPanels) {
|
||||
openBottomSheet('fleet-status', 'Fleet Status', createFleetStatusContent());
|
||||
}
|
||||
}
|
||||
|
||||
function openZonesPanel() {
|
||||
if (window.SpaxelPanels) {
|
||||
openBottomSheet('zones', 'Zones', createZonesContent());
|
||||
}
|
||||
}
|
||||
|
||||
function openTriggersPanel() {
|
||||
if (window.SpaxelPanels) {
|
||||
openBottomSheet('triggers', 'Automation Triggers', createTriggersContent());
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsPanel() {
|
||||
if (window.SpaxelPanels) {
|
||||
openBottomSheet('settings', 'Settings', createSettingsContent());
|
||||
}
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
if (window.Viz3D && window.Viz3D.resetView) {
|
||||
window.Viz3D.resetView();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLayers() {
|
||||
if (window.Viz3D && window.Viz3D.toggleLayers) {
|
||||
window.Viz3D.toggleLayers();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Bottom Sheet Panels (Mobile)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Open bottom sheet panel on mobile
|
||||
*/
|
||||
function openBottomSheet(id, title, content) {
|
||||
if (!isMobile()) {
|
||||
// On desktop, use regular sidebar
|
||||
window.SpaxelPanels.openSidebar({
|
||||
title: title,
|
||||
content: content,
|
||||
width: '360px',
|
||||
position: 'right'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close existing bottom sheet
|
||||
closeActiveBottomSheet();
|
||||
|
||||
// Create bottom sheet
|
||||
const sheet = document.createElement('div');
|
||||
sheet.id = `bottom-sheet-${id}`;
|
||||
sheet.className = 'mobile-bottom-sheet';
|
||||
|
||||
sheet.innerHTML = `
|
||||
<div class="bottom-sheet-handle"></div>
|
||||
<div class="bottom-sheet-header">
|
||||
<h2 class="bottom-sheet-title">${title}</h2>
|
||||
<button class="bottom-sheet-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="bottom-sheet-content"></div>
|
||||
`;
|
||||
|
||||
// Add content
|
||||
const contentEl = sheet.querySelector('.bottom-sheet-content');
|
||||
if (typeof content === 'string') {
|
||||
contentEl.innerHTML = content;
|
||||
} else if (content instanceof HTMLElement) {
|
||||
contentEl.appendChild(content);
|
||||
} else if (typeof content === 'function') {
|
||||
const result = content(contentEl);
|
||||
if (result instanceof HTMLElement) {
|
||||
contentEl.innerHTML = '';
|
||||
contentEl.appendChild(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Close button handler
|
||||
sheet.querySelector('.bottom-sheet-close').addEventListener('click', closeActiveBottomSheet);
|
||||
|
||||
// Handle drag to close
|
||||
setupBottomSheetDrag(sheet);
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(sheet);
|
||||
|
||||
// Disable OrbitControls
|
||||
if (window.Viz3D && window.Viz3D.controls) {
|
||||
const controls = window.Viz3D.controls();
|
||||
if (controls) controls.enabled = false;
|
||||
}
|
||||
|
||||
// Show with animation
|
||||
requestAnimationFrame(() => {
|
||||
sheet.classList.add('visible');
|
||||
});
|
||||
|
||||
activeBottomSheet = sheet;
|
||||
|
||||
console.log('[Mobile] Bottom sheet opened:', id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close active bottom sheet
|
||||
*/
|
||||
function closeActiveBottomSheet() {
|
||||
if (!activeBottomSheet) return;
|
||||
|
||||
const sheet = activeBottomSheet;
|
||||
|
||||
sheet.classList.remove('visible');
|
||||
|
||||
setTimeout(() => {
|
||||
if (sheet.parentNode) {
|
||||
sheet.parentNode.removeChild(sheet);
|
||||
}
|
||||
|
||||
// Re-enable OrbitControls
|
||||
if (window.Viz3D && window.Viz3D.controls) {
|
||||
const controls = window.Viz3D.controls();
|
||||
if (controls) controls.enabled = true;
|
||||
}
|
||||
|
||||
activeBottomSheet = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up bottom sheet drag to close
|
||||
*/
|
||||
function setupBottomSheetDrag(sheet) {
|
||||
const handle = sheet.querySelector('.bottom-sheet-handle');
|
||||
const content = sheet.querySelector('.bottom-sheet-content');
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
let isDragging = false;
|
||||
|
||||
handle.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
isDragging = true;
|
||||
}, { passive: true });
|
||||
|
||||
handle.addEventListener('touchmove', (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
currentY = e.touches[0].clientY;
|
||||
const deltaY = currentY - startY;
|
||||
|
||||
// Only allow dragging down
|
||||
if (deltaY > 0) {
|
||||
const translateY = Math.min(deltaY, window.innerHeight * 0.7);
|
||||
sheet.style.transform = `translateY(${translateY}px)`;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
handle.addEventListener('touchend', () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
const deltaY = currentY - startY;
|
||||
|
||||
// If dragged more than 100px down, close the sheet
|
||||
if (deltaY > 100) {
|
||||
closeActiveBottomSheet();
|
||||
} else {
|
||||
// Reset position
|
||||
sheet.style.transform = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Content Generators
|
||||
// ============================================
|
||||
|
||||
function createFleetStatusContent(container) {
|
||||
// Fetch fleet status
|
||||
fetch('/api/nodes')
|
||||
.then(response => response.json())
|
||||
.then(nodes => {
|
||||
if (nodes.length === 0) {
|
||||
container.innerHTML = '<div class="mobile-empty">No nodes found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="mobile-list">';
|
||||
|
||||
nodes.forEach(node => {
|
||||
const statusClass = node.status === 'online' ? 'status-online' :
|
||||
node.status === 'stale' ? 'status-stale' : 'status-offline';
|
||||
html += `
|
||||
<div class="mobile-list-item" data-mac="${node.mac}">
|
||||
<div class="mobile-list-status ${statusClass}"></div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">${node.name || node.mac}</div>
|
||||
<div class="mobile-list-subtitle">${node.role || 'Unknown'} • ${node.firmware_version || 'Unknown'}</div>
|
||||
</div>
|
||||
<button class="mobile-list-action" aria-label="Options">›</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
container.querySelectorAll('.mobile-list-item').forEach(item => {
|
||||
const mac = item.dataset.mac;
|
||||
item.addEventListener('click', () => {
|
||||
// Open node details
|
||||
if (window.SpatialQuickActions) {
|
||||
const node = nodes.find(n => n.mac === mac);
|
||||
if (node) {
|
||||
window.SpatialQuickActions.show(
|
||||
window.innerWidth / 2,
|
||||
window.innerHeight / 2,
|
||||
'node',
|
||||
node
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[Mobile] Error fetching fleet status:', error);
|
||||
container.innerHTML = '<div class="mobile-error">Failed to load fleet status</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function createZonesContent(container) {
|
||||
// Fetch zones
|
||||
fetch('/api/zones')
|
||||
.then(response => response.json())
|
||||
.then(zones => {
|
||||
if (zones.length === 0) {
|
||||
container.innerHTML = '<div class="mobile-empty">No zones defined</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="mobile-list">';
|
||||
|
||||
zones.forEach(zone => {
|
||||
const occupancy = zone.occupancy || 0;
|
||||
const people = zone.people || [];
|
||||
html += `
|
||||
<div class="mobile-list-item" data-zone-id="${zone.id}">
|
||||
<div class="mobile-list-icon">🏠</div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">${zone.name}</div>
|
||||
<div class="mobile-list-subtitle">${occupancy} person${occupancy !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<button class="mobile-list-action" aria-label="Options">›</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
container.querySelectorAll('.mobile-list-item').forEach(item => {
|
||||
const zoneId = item.dataset.zoneId;
|
||||
item.addEventListener('click', () => {
|
||||
const zone = zones.find(z => z.id == zoneId);
|
||||
if (zone && window.SpatialQuickActions) {
|
||||
window.SpatialQuickActions.show(
|
||||
window.innerWidth / 2,
|
||||
window.innerHeight / 2,
|
||||
'zone',
|
||||
zone
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[Mobile] Error fetching zones:', error);
|
||||
container.innerHTML = '<div class="mobile-error">Failed to load zones</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function createTriggersContent(container) {
|
||||
// Fetch triggers
|
||||
fetch('/api/triggers')
|
||||
.then(response => response.json())
|
||||
.then(triggers => {
|
||||
if (triggers.length === 0) {
|
||||
container.innerHTML = '<div class="mobile-empty">No automation triggers</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="mobile-list">';
|
||||
|
||||
triggers.forEach(trigger => {
|
||||
const enabledClass = trigger.enabled ? 'trigger-enabled' : 'trigger-disabled';
|
||||
html += `
|
||||
<div class="mobile-list-item" data-trigger-id="${trigger.id}">
|
||||
<div class="mobile-list-icon ${enabledClass}">⚡</div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">${trigger.name}</div>
|
||||
<div class="mobile-list-subtitle">${trigger.condition || 'Unknown condition'}</div>
|
||||
</div>
|
||||
<button class="mobile-list-action" aria-label="Options">›</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
container.querySelectorAll('.mobile-list-item').forEach(item => {
|
||||
const triggerId = item.dataset.triggerId;
|
||||
item.addEventListener('click', () => {
|
||||
const trigger = triggers.find(t => t.id == triggerId);
|
||||
if (trigger && window.SpatialQuickActions) {
|
||||
window.SpatialQuickActions.show(
|
||||
window.innerWidth / 2,
|
||||
window.innerHeight / 2,
|
||||
'trigger',
|
||||
trigger
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[Mobile] Error fetching triggers:', error);
|
||||
container.innerHTML = '<div class="mobile-error">Failed to load triggers</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function createSettingsContent() {
|
||||
return `
|
||||
<div class="mobile-list">
|
||||
<div class="mobile-list-item" data-setting="detection">
|
||||
<div class="mobile-list-icon">🎯</div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">Detection</div>
|
||||
<div class="mobile-list-subtitle">Thresholds & sensitivity</div>
|
||||
</div>
|
||||
<button class="mobile-list-action">›</button>
|
||||
</div>
|
||||
<div class="mobile-list-item" data-setting="display">
|
||||
<div class="mobile-list-icon">🎨</div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">Display</div>
|
||||
<div class="mobile-list-subtitle">Appearance & layers</div>
|
||||
</div>
|
||||
<button class="mobile-list-action">›</button>
|
||||
</div>
|
||||
<div class="mobile-list-item" data-setting="notifications">
|
||||
<div class="mobile-list-icon">🔔</div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">Notifications</div>
|
||||
<div class="mobile-list-subtitle">Alerts & push</div>
|
||||
</div>
|
||||
<button class="mobile-list-action">›</button>
|
||||
</div>
|
||||
<div class="mobile-list-item" data-setting="integrations">
|
||||
<div class="mobile-list-icon">🔗</div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">Integrations</div>
|
||||
<div class="mobile-list-subtitle">Home Assistant, MQTT</div>
|
||||
</div>
|
||||
<button class="mobile-list-action">›</button>
|
||||
</div>
|
||||
<div class="mobile-list-item" data-setting="help">
|
||||
<div class="mobile-list-icon">❓</div>
|
||||
<div class="mobile-list-content">
|
||||
<div class="mobile-list-title">Help & Troubleshooting</div>
|
||||
<div class="mobile-list-subtitle">Guides & diagnostics</div>
|
||||
</div>
|
||||
<button class="mobile-list-action">›</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utilities
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if on mobile device
|
||||
*/
|
||||
function isMobile() {
|
||||
return window.innerWidth < MOBILE_BREAKPOINT ||
|
||||
('ontouchstart' in window && navigator.maxTouchPoints > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hamburger menu visibility based on screen size
|
||||
*/
|
||||
function updateHamburgerVisibility() {
|
||||
if (!hamburgerMenu) return;
|
||||
|
||||
if (isMobile()) {
|
||||
hamburgerMenu.style.display = 'flex';
|
||||
} else {
|
||||
hamburgerMenu.style.display = 'none';
|
||||
closeMobileNav();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
window.MobileExpertMode = {
|
||||
init: init,
|
||||
openNav: openMobileNav,
|
||||
closeNav: closeMobileNav,
|
||||
openBottomSheet: openBottomSheet,
|
||||
closeBottomSheet: closeActiveBottomSheet,
|
||||
isMobile: isMobile
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
function init() {
|
||||
console.log('[Mobile] Initializing mobile expert mode...');
|
||||
|
||||
// Create hamburger menu
|
||||
createHamburgerMenu();
|
||||
|
||||
// Update visibility on resize
|
||||
window.addEventListener('resize', updateHamburgerVisibility);
|
||||
updateHamburgerVisibility();
|
||||
|
||||
// Handle orientation changes
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(updateHamburgerVisibility, 100);
|
||||
});
|
||||
|
||||
console.log('[Mobile] Mobile expert mode initialized');
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
console.log('[Mobile] Module loaded');
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue