Files
terra-view/app/ui/static/mobile.js

598 lines
19 KiB
JavaScript

/* Mobile JavaScript for Seismo Fleet Manager */
/* Handles hamburger menu, modals, offline sync, and mobile interactions */
// ===== GLOBAL STATE =====
let currentUnitData = null;
let isOnline = navigator.onLine;
// ===== HAMBURGER MENU TOGGLE =====
function toggleMenu() {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('backdrop');
const hamburgerBtn = document.getElementById('hamburgerBtn');
if (sidebar && backdrop) {
const isOpen = sidebar.classList.contains('open');
if (isOpen) {
// Close menu
sidebar.classList.remove('open');
backdrop.classList.remove('show');
hamburgerBtn?.classList.remove('menu-open');
document.body.style.overflow = '';
document.body.classList.remove('menu-open');
} else {
// Open menu
sidebar.classList.add('open');
backdrop.classList.add('show');
hamburgerBtn?.classList.add('menu-open');
document.body.style.overflow = 'hidden';
document.body.classList.add('menu-open');
}
}
}
// Close menu when clicking backdrop
function closeMenuFromBackdrop() {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('backdrop');
const hamburgerBtn = document.getElementById('hamburgerBtn');
if (sidebar && backdrop) {
sidebar.classList.remove('open');
backdrop.classList.remove('show');
hamburgerBtn?.classList.remove('menu-open');
document.body.style.overflow = '';
document.body.classList.remove('menu-open');
}
}
// Close menu when window is resized to desktop
function handleResize() {
if (window.innerWidth >= 768) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('backdrop');
const hamburgerBtn = document.getElementById('hamburgerBtn');
if (sidebar && backdrop) {
sidebar.classList.remove('open');
backdrop.classList.remove('show');
hamburgerBtn?.classList.remove('menu-open');
document.body.style.overflow = '';
document.body.classList.remove('menu-open');
}
}
}
// ===== UNIT DETAIL MODAL =====
function openUnitModal(unitId, status = null, age = null) {
const modal = document.getElementById('unitModal');
if (!modal) return;
// Store the status info passed from the card
// Accept status if it's a non-empty string, use age if provided or default to '--'
const cardStatusInfo = (status && status !== '') ? {
status: status,
age: age || '--'
} : null;
console.log('openUnitModal:', { unitId, status, age, cardStatusInfo });
// Fetch unit data and populate modal
fetchUnitDetails(unitId).then(unit => {
if (unit) {
currentUnitData = unit;
// Pass the card status info to the populate function
populateUnitModal(unit, cardStatusInfo);
modal.classList.add('show');
document.body.style.overflow = 'hidden';
}
});
}
function closeUnitModal(event) {
// Only close if clicking backdrop or close button
if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) {
return;
}
const modal = document.getElementById('unitModal');
if (modal) {
modal.classList.remove('show');
document.body.style.overflow = '';
currentUnitData = null;
}
}
async function fetchUnitDetails(unitId) {
try {
// Try to fetch from network first
const response = await fetch(`/api/roster/${unitId}`);
if (response.ok) {
const unit = await response.json();
// Save to IndexedDB if offline support is available
if (window.offlineDB) {
await window.offlineDB.saveUnit(unit);
}
return unit;
}
} catch (error) {
console.log('Network fetch failed, trying offline storage:', error);
// Fall back to offline storage
if (window.offlineDB) {
return await window.offlineDB.getUnit(unitId);
}
}
return null;
}
function populateUnitModal(unit, cardStatusInfo = null) {
// Set unit ID in header
const modalUnitId = document.getElementById('modalUnitId');
if (modalUnitId) {
modalUnitId.textContent = unit.id;
}
// Populate modal content
const modalContent = document.getElementById('modalContent');
if (!modalContent) return;
// Use status from card if provided, otherwise get from snapshot or derive from unit
let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit);
console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo });
const statusColor = statusInfo.status === 'OK' ? 'green' :
statusInfo.status === 'Pending' ? 'yellow' :
statusInfo.status === 'Missing' ? 'red' : 'gray';
const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' :
statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' :
statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' :
'text-gray-600 dark:text-gray-400';
// Determine status label (show "Benched" instead of "Unknown" for non-deployed units)
let statusLabel = statusInfo.status;
if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) {
statusLabel = 'Benched';
}
// Create navigation URL for location
const createNavUrl = (address, coordinates) => {
if (address) {
// Use address for navigation
const encodedAddress = encodeURIComponent(address);
// Universal link that works on iOS and Android
return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`;
} else if (coordinates) {
// Use coordinates for navigation (format: lat,lon)
const encodedCoords = encodeURIComponent(coordinates);
return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`;
}
return null;
};
const navUrl = createNavUrl(unit.address, unit.coordinates);
modalContent.innerHTML = `
<!-- Status Section -->
<div class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded-full bg-${statusColor}-500"></span>
<span class="font-semibold ${statusTextColor}">${statusLabel}</span>
</div>
<span class="text-sm text-gray-500">${statusInfo.age || '--'}</span>
</div>
<!-- Device Info -->
<div class="space-y-3">
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Device Type</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.device_type || '--'}</p>
</div>
${unit.unit_type ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.unit_type}</p>
</div>
` : ''}
${unit.project_id ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Project ID</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.project_id}</p>
</div>
` : ''}
${unit.address ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Address</label>
${navUrl ? `
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="underline">${unit.address}</span>
</a>
` : `
<p class="mt-1 text-gray-900 dark:text-white">${unit.address}</p>
`}
</div>
` : ''}
${unit.coordinates && !unit.address ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
${navUrl ? `
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-mono text-sm underline">${unit.coordinates}</span>
</a>
` : `
<p class="mt-1 text-gray-900 dark:text-white font-mono text-sm">${unit.coordinates}</p>
`}
</div>
` : ''}
<!-- Seismograph-specific fields -->
${unit.device_type === 'seismograph' ? `
${unit.last_calibrated ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.last_calibrated}</p>
</div>
` : ''}
${unit.next_calibration_due ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.next_calibration_due}</p>
</div>
` : ''}
${unit.deployed_with_modem_id ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed_with_modem_id}</p>
</div>
` : ''}
` : ''}
<!-- Modem-specific fields -->
${unit.device_type === 'modem' ? `
${unit.ip_address ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">IP Address</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.ip_address}</p>
</div>
` : ''}
${unit.phone_number ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.phone_number}</p>
</div>
` : ''}
${unit.hardware_model ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.hardware_model}</p>
</div>
` : ''}
` : ''}
${unit.note ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Notes</label>
<p class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">${unit.note}</p>
</div>
` : ''}
<div class="grid grid-cols-2 gap-3 pt-2">
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed ? 'Yes' : 'No'}</p>
</div>
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Retired</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.retired ? 'Yes' : 'No'}</p>
</div>
</div>
</div>
`;
// Update action buttons
const editBtn = document.getElementById('modalEditBtn');
const deployBtn = document.getElementById('modalDeployBtn');
const deleteBtn = document.getElementById('modalDeleteBtn');
if (editBtn) {
editBtn.onclick = () => {
window.location.href = `/unit/${unit.id}`;
};
}
if (deployBtn) {
deployBtn.textContent = unit.deployed ? 'Bench Unit' : 'Deploy Unit';
deployBtn.onclick = () => toggleDeployStatus(unit.id, !unit.deployed);
}
if (deleteBtn) {
deleteBtn.onclick = () => deleteUnit(unit.id);
}
}
function getUnitStatus(unitId, unit = null) {
// Prefer roster table data if it was rendered with the current view
if (window.rosterStatusMap && window.rosterStatusMap[unitId]) {
return window.rosterStatusMap[unitId];
}
// Try to get status from dashboard snapshot if it exists
if (window.lastStatusSnapshot && window.lastStatusSnapshot.units && window.lastStatusSnapshot.units[unitId]) {
const unitStatus = window.lastStatusSnapshot.units[unitId];
return {
status: unitStatus.status,
age: unitStatus.age,
last: unitStatus.last
};
}
// Fallback: if unit data is provided, derive status from deployment state
if (unit) {
if (unit.deployed) {
// For deployed units without status data, default to "Unknown"
return { status: 'Unknown', age: '--', last: '--' };
} else {
// For benched units, use "N/A" which will be displayed as "Benched"
return { status: 'N/A', age: '--', last: '--' };
}
}
return { status: 'Unknown', age: '--', last: '--' };
}
async function toggleDeployStatus(unitId, deployed) {
try {
const formData = new FormData();
formData.append('deployed', deployed ? 'true' : 'false');
const response = await fetch(`/api/roster/edit/${unitId}`, {
method: 'POST',
body: formData
});
if (response.ok) {
showToast('✓ Unit updated successfully');
closeUnitModal();
// Trigger HTMX refresh if on roster page
const rosterTable = document.querySelector('[hx-get*="roster"]');
if (rosterTable) {
htmx.trigger(rosterTable, 'refresh');
}
} else {
showToast('❌ Failed to update unit', 'error');
}
} catch (error) {
console.error('Error toggling deploy status:', error);
showToast('❌ Failed to update unit', 'error');
}
}
async function deleteUnit(unitId) {
if (!confirm(`Are you sure you want to delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
return;
}
try {
const response = await fetch(`/api/roster/${unitId}`, {
method: 'DELETE'
});
if (response.ok) {
showToast('✓ Unit deleted successfully');
closeUnitModal();
// Refresh roster page if present
const rosterTable = document.querySelector('[hx-get*="roster"]');
if (rosterTable) {
htmx.trigger(rosterTable, 'refresh');
}
} else {
showToast('❌ Failed to delete unit', 'error');
}
} catch (error) {
console.error('Error deleting unit:', error);
showToast('❌ Failed to delete unit', 'error');
}
}
// ===== ONLINE/OFFLINE STATUS =====
function updateOnlineStatus() {
isOnline = navigator.onLine;
const offlineIndicator = document.getElementById('offlineIndicator');
if (offlineIndicator) {
if (isOnline) {
offlineIndicator.classList.remove('show');
// Trigger sync when coming back online
if (window.offlineDB) {
syncPendingEdits();
}
} else {
offlineIndicator.classList.add('show');
}
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// ===== SYNC FUNCTIONALITY =====
async function syncPendingEdits() {
if (!window.offlineDB) return;
try {
const pendingEdits = await window.offlineDB.getPendingEdits();
if (pendingEdits.length === 0) return;
console.log(`Syncing ${pendingEdits.length} pending edits...`);
for (const edit of pendingEdits) {
try {
const formData = new FormData();
for (const [key, value] of Object.entries(edit.changes)) {
formData.append(key, value);
}
const response = await fetch(`/api/roster/edit/${edit.unitId}`, {
method: 'POST',
body: formData
});
if (response.ok) {
await window.offlineDB.clearEdit(edit.id);
console.log(`Synced edit ${edit.id} for unit ${edit.unitId}`);
} else {
console.error(`Failed to sync edit ${edit.id}`);
}
} catch (error) {
console.error(`Error syncing edit ${edit.id}:`, error);
// Keep in queue for next sync attempt
}
}
// Show success toast
showToast('✓ Synced successfully');
} catch (error) {
console.error('Error in syncPendingEdits:', error);
}
}
// Manual sync button
function manualSync() {
if (!isOnline) {
showToast('⚠️ Cannot sync while offline', 'warning');
return;
}
syncPendingEdits();
}
// ===== TOAST NOTIFICATIONS =====
function showToast(message, type = 'success') {
const toast = document.getElementById('syncToast');
if (!toast) return;
// Update toast appearance based on type
toast.classList.remove('bg-green-500', 'bg-red-500', 'bg-yellow-500');
if (type === 'success') {
toast.classList.add('bg-green-500');
} else if (type === 'error') {
toast.classList.add('bg-red-500');
} else if (type === 'warning') {
toast.classList.add('bg-yellow-500');
}
toast.textContent = message;
toast.classList.add('show');
// Auto-hide after 3 seconds
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// ===== BOTTOM NAV ACTIVE STATE =====
function updateBottomNavActiveState() {
const currentPath = window.location.pathname;
const navButtons = document.querySelectorAll('.bottom-nav-btn');
navButtons.forEach(btn => {
const href = btn.getAttribute('data-href');
if (href && (currentPath === href || (href !== '/' && currentPath.startsWith(href)))) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
// Initialize online/offline status
updateOnlineStatus();
// Update bottom nav active state
updateBottomNavActiveState();
// Add resize listener
window.addEventListener('resize', handleResize);
// Close menu on navigation (for mobile)
document.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (link && link.closest('#sidebar')) {
// Delay to allow navigation to start
setTimeout(() => {
if (window.innerWidth < 768) {
closeMenuFromBackdrop();
}
}, 100);
}
});
// Prevent scroll when modals are open (iOS fix)
document.addEventListener('touchmove', (e) => {
const modal = document.querySelector('.unit-modal.show, #sidebar.open');
if (modal && !e.target.closest('.unit-modal-content, #sidebar')) {
e.preventDefault();
}
}, { passive: false });
console.log('Mobile.js initialized');
});
// ===== SERVICE WORKER REGISTRATION =====
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered:', registration);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Check every hour
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
// Listen for service worker updates
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Service Worker updated, reloading page...');
window.location.reload();
});
}
// Export functions for global use
window.toggleMenu = toggleMenu;
window.closeMenuFromBackdrop = closeMenuFromBackdrop;
window.openUnitModal = openUnitModal;
window.closeUnitModal = closeUnitModal;
window.manualSync = manualSync;
window.showToast = showToast;