/**
* Main Application Entry Point
*
* Demonstrates integration of:
* - Bootstrap 5.3 for UI components
* - SQLocal (SQLite in browser via OPFS)
* - BroadcastChannel for cross-tab sync
* - OpenLayers map with ol-ext LayerSwitcher
* - PWA features (Service Worker, install prompt, offline detection)
*/
// Bootstrap CSS and JS
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import { Modal, Offcanvas } from 'bootstrap';
// Database module (uses SQLocal directly, BroadcastChannel for tab sync)
import {
sql,
dbReady,
initSchema,
addLocation,
getLocations,
getLocationCount,
getDatabaseStatus,
downloadDatabase,
onDatabaseChange,
exportToGeoJSON,
saveRemoteData,
getRemoteData,
saveCollectorZones,
getLocalCollectorZones,
saveParcels,
getLocalParcels,
updateParcel,
insertNewParcel,
saveBuildingFootprints,
getLocalBuildingFootprints,
saveOSMRoads,
getLocalOSMRoads,
isCachedLayerTable,
clearTable,
clearAllCachedLayers,
getTableStats,
getTableContent
} from './src/database.js';
// Map component with OpenLayers and ol-ext LayerSwitcher
import { MapView } from './src/components/MapView.js';
// OpenLayers GeoJSON format (for updating layer sources directly)
import GeoJSON from 'ol/format/GeoJSON';
// OpenLayers WKT format (for writing drawn polygon geometries to database)
import WKT from 'ol/format/WKT';
// OpenLayers KML format (for KML file import)
import KML from 'ol/format/KML';
// Shapefile parser (reads .zip containing .shp/.dbf/.shx/.prj)
// Lazy-loaded β only fetched the first time the user imports a shapefile.
let _shpModule = null;
async function getShp() {
if (!_shpModule) {
const mod = await import('shpjs');
_shpModule = mod.default || mod;
}
return _shpModule;
}
// Map measurement and drawing tools
import { MapTools } from './src/components/MapTools.js';
// PWA module (registers Service Worker, handles install/offline)
import { initPWA, isOnline, onOfflineChange, getTileCacheStats, clearTileCaches, clearTileCacheForProvider, getStorageEstimate, onServiceWorkerControllerChange } from './src/pwa.js';
import {
BASEMAP_TEMPLATES,
GHANA_EXTENT_3857,
countTiles,
estimatedSizeBytes,
OfflineTileDownloader,
} from './src/offlineTiles.js';
// Remote database API (PostgreSQL backend)
import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads, getSession } from './src/remotedb.js';
// GPS live-position + trail recording (reusable engine + LUPMIS wiring)
import { geoTracker } from './src/geotracker-lupmis.js';
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js';
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
import { createEmbedBridge } from './src/embed-bridge.js';
// Map instance (global for access across functions)
let mapView = null;
let mapTools = null;
// Module-level reference so the embed bridge can access the parcels layer
// once loadParcels() has created it.
let parcelsLayer = null;
let embedBridge = null;
// Iframe embed mode. Set by public/embed.php when serving the /embed route;
// undefined for the normal /index.php entry point.
const EMBED_CONFIG = (typeof window !== 'undefined' && window.LUPMIS_EMBED) || null;
const IS_EMBED_PERMIT = !!(EMBED_CONFIG && EMBED_CONFIG.mode === 'permit');
// Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea'
// In embed permit mode we don't want the default Add-Location click to fire,
// so start the mode in a neutral state.
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'addLocation';
// ============================================================================
// Application Initialization
// ============================================================================
/**
* Pre-flight: when an SSO session is present but the user has no district
* assigned, the app cannot function (every API call is scoped to a district).
* Show a blocking message and halt initialisation so we never silently fall
* back to a default district.
*
* Local dev (no window.LUPMIS_SESSION at all) is *not* affected β that path
* still uses the remotedb FALLBACK_DISTRICT_ID for testing.
*
* @returns {boolean} true if the user is blocked (init should abort)
*/
function showNoDistrictBlockerIfNeeded() {
const session = (typeof window !== 'undefined') ? window.LUPMIS_SESSION : null;
if (!session || typeof session !== 'object') return false; // dev mode
const id = session.district_id;
if (id !== null && id !== undefined && String(id).length > 0) return false;
// Authenticated but no district β render an overlay and abort init.
console.warn('[App] Authenticated user has no district assigned; halting init.');
const overlay = document.createElement('div');
overlay.id = 'no-district-overlay';
overlay.setAttribute('role', 'alertdialog');
overlay.setAttribute('aria-modal', 'true');
overlay.style.cssText =
'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;' +
'justify-content:center;background:rgba(255,255,255,0.98);padding:24px;';
const name = session.full_name || session.username || 'You';
overlay.innerHTML = `
π
No district assigned
${escapeHtml(name)}, your user profile is not associated with any
district. LUPMIS2 cannot load the relevant map data without one.
Please contact the system administrator to have a district assigned
to your account.
`;
document.body.appendChild(overlay);
overlay.querySelector('#no-district-portal-btn')?.addEventListener('click', () => {
window.location.href = 'https://lupmis4luspa.org/';
});
return true;
}
async function initApp() {
console.log('[App] Initializing...');
// Pre-flight: authenticated user must have a district assigned.
if (showNoDistrictBlockerIfNeeded()) return;
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
await initPWA({
installButton: '#install-btn',
offlineIndicator: '#offline-indicator',
autoRegisterSW: true
});
// 2. Initialize the map
// Restore the user's preferred default base map from localStorage
const savedBasemap = localStorage.getItem('default-basemap') || 'topo';
mapView = new MapView('map', {
center: [-1.5, 7.5], // Ghana
zoom: 7,
basemap: savedBasemap,
});
// Initialize map measurement tools
mapTools = new MapTools(mapView.getMap());
// Wire up GPS live-position + trail recording
initGpsTracking();
// Handle measurement results
mapTools.onMeasureComplete((result) => {
console.log('[MapTools] Measurement complete:', result);
// Only show the Polygon Attributes popup for polygons drawn with the
// Draw tool β NOT for area measurements (which have _layerType = 'measure_area').
if (result.type === 'polygon' && result.coordinate) {
const lt = result.feature?.get('_layerType');
if (lt !== 'measure_area') {
mapView?.showDrawnPolygonPopup(result.feature, result.coordinate);
}
}
});
// Category emojis are set up in MapView:
// 'water': 'π§', 'school': 'π«', 'health': 'π₯',
// 'market': 'πͺ', 'default': 'π', 'other': 'π'
// In iframe embed permit mode, install the postMessage bridge BEFORE the
// regular handlers so its outbound parcel:select / parcel:cleared events
// are wired up; the regular click/dblclick handlers below short-circuit in
// that mode (the bridge owns map interaction in the embed).
if (IS_EMBED_PERMIT) {
embedBridge = createEmbedBridge({ mapView, embedConfig: EMBED_CONFIG });
}
// Set up map click handler immediately after map creation
mapView.onClick((lon, lat, feature, evt) => {
// Embed permit mode: the bridge handles parcel selection itself; the
// normal popup/add-location behaviour does not apply.
if (IS_EMBED_PERMIT) return;
console.log('[MapClick] Clicked at:', lon.toFixed(4), lat.toFixed(4));
console.log('[MapClick] currentMode =', currentMode);
// In draw or measurement modes, clicks drive the tool β don't
// open popups or select features.
if (currentMode === 'draw' || currentMode.startsWith('measure')) {
return;
}
// Check if a parcel feature was clicked
let parcelFeature = null;
mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => {
if (f.get('_layerType') === 'parcel') {
parcelFeature = f;
return true; // stop at first parcel hit
}
});
// Parcel click: open Edit Attributes form in ANY non-draw mode.
// The feature is NOT selected β only the popup is shown.
if (parcelFeature) {
console.log('[MapClick] Clicked on parcel β Edit Attributes');
mapView.showParcelEditPopup(parcelFeature, evt.coordinate);
return;
}
// Non-parcel clicks (markers, empty space) only in addLocation mode
if (currentMode !== 'addLocation') {
return;
}
if (feature) {
// Clicked on existing marker - select it and show details
console.log('[MapClick] Clicked on marker:', feature.getId());
mapView.selectMarker(feature);
showLocationDetails(feature);
} else {
// Clicked on empty space - show add location popup at click position
console.log('[MapClick] Empty space β Add Location popup');
mapView.clearSelection();
mapView.showAddLocationPopup(evt.coordinate);
}
});
// Set up double-click handler for overlay feature info
// Uses '_layerType' property to distinguish zone features from other layers
mapView.onDblClick((lon, lat, feature, evt) => {
// Embed permit mode shows no info popups (the host owns the UI).
if (IS_EMBED_PERMIT) return;
if (!feature) return;
const layerType = feature.get('_layerType');
console.log('[App] Double-click on feature, _layerType:', layerType || 'none');
if (layerType === 'measure_circle') {
// Circle measurement: show intersection analysis with other layers
mapView.showCircleIntersectionPopup(feature, evt.coordinate);
} else if (layerType === 'measure_circle_radius') {
// Clicked on the radius line β ignore
return;
} else if (layerType === 'measure_area') {
// Area measurement polygon: show intersection analysis
mapView.showAreaIntersectionPopup(feature, evt.coordinate);
} else if (layerType === 'collector_zone') {
mapView.showInfoPopup(feature, evt.coordinate, {
title: 'Zone Info',
color: '#7c3aed',
});
} else if (layerType === 'parcel') {
mapView.showInfoPopup(feature, evt.coordinate, {
title: 'Parcel Info',
color: '#0ea5e9',
});
} else {
mapView.showInfoPopup(feature, evt.coordinate, {
title: 'Feature Info',
color: '#e11d48',
});
}
});
// Set up handler for the map add location popup form
mapView.onAddLocation(async (data) => {
console.log('[App] Add location from map popup:', data);
try {
const result = await addLocation(data.name, data.lon, data.lat, {
description: data.description || null,
category: data.category || 'default'
});
console.log('[App] Location added:', data.name, 'id:', result.id);
await loadLocations();
// Zoom to the new location on the map
mapView?.zoomTo(data.lon, data.lat, 14);
// Select the new marker
if (result.id) {
mapView?.selectMarker(result.id);
}
showSuccess('Location added successfully');
} catch (error) {
console.error('[App] Failed to add location:', error);
showError('Failed to add location: ' + error.message);
}
});
// Set up parcel edit save handler
mapView.onParcelEdit(async (feature, updatedProps) => {
const parcelId = updatedProps.id || updatedProps.parcelid || updatedProps.parcel_id;
console.log('[App] Parcel edit saved:', parcelId, updatedProps);
if (!parcelId) {
console.warn('[App] No parcel ID found in updated properties β skipping local save');
return;
}
try {
await updateParcel(parcelId, updatedProps);
showSuccess('Parcel updated locally');
} catch (error) {
console.error('[App] Failed to save parcel update:', error);
showError('Failed to save parcel: ' + error.message);
}
});
// Set up drawn polygon attribute save handler
const wktFormat = new WKT();
mapView.onDrawnPolygonSave(async (feature, props) => {
console.log('[App] Drawn polygon attributes saved:', props);
try {
// Convert the OL geometry (EPSG:3857) to WKT in EPSG:4326 for storage
const wktString = wktFormat.writeGeometry(feature.getGeometry(), {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
const result = await insertNewParcel(wktString, props);
console.log('[App] New parcel inserted with id:', result.id);
showSuccess('New parcel saved (pending verification)');
} catch (error) {
console.error('[App] Failed to save new parcel:', error);
showError('Failed to save parcel: ' + error.message);
}
});
// 3. Initialize database
try {
console.log('[App] Initializing database...');
// Initialize schema (creates tables if they don't exist)
// This also resolves dbReady when complete
await initSchema();
// Now dbReady should be resolved
console.log('[App] Database ready');
// Show database status
const status = await getDatabaseStatus();
console.log('[App] Database status:', status);
// Quick server reachability check (5 s timeout) β if the API server
// is down, all load functions will skip remote fetches and fall back
// to local cached data immediately, keeping the app responsive.
if (isOnline()) {
const reachable = await checkServerReachable();
if (!reachable) {
console.warn('[App] API server unreachable β using local data only');
showWarning('Server not responding β loading cached data.');
}
}
// Load remote overlays (needs remote_data table from initSchema)
// loadLayers must complete first so the layer groups exist
// before loadDistrictBoundary adds into the Administration group.
await loadLayers();
// Initialise EditBar with its own "Drawings" layer group
mapView?.initEditBar();
loadDistrictBoundary();
loadCollectorZones();
loadParcels();
// In embed permit mode the parcels layer is the user's working surface,
// so make it visible immediately and hand the layer to the bridge so it
// can emit `ready` (and resolve any pending `set:selected` UPN) once the
// features arrive. loadParcels() runs its synchronous prologue (creating
// the layer and assigning the module-level reference) before returning
// its promise, so `parcelsLayer` is already set here.
if (IS_EMBED_PERMIT && embedBridge && parcelsLayer) {
parcelsLayer.setVisible(true);
embedBridge.attachParcelsLayer(parcelsLayer);
}
loadBuildingFootprints();
loadContoursHillshade();
loadOSMRoads();
loadExternalWMSLayers();
} catch (error) {
console.error('[App] Database initialization failed:', error);
showError('Failed to initialize database. Please refresh the page.');
return;
}
// 4. Initialize UI
initUI();
// 5. Load initial data and display on map
await loadLocations();
// 6. Listen for database changes (local + other tabs)
onDatabaseChange((change) => {
console.log('[App] Database change:', change);
if (change.table === 'locations' && !change.local) {
// Reload locations when another tab makes changes
loadLocations();
}
if (change.table === 'parcels') {
// Refresh the Local Data stats panel if it is visible
const statsContainer = document.getElementById('local-data-stats');
if (statsContainer && !statsContainer.classList.contains('d-none')) {
refreshLocalDataStats();
}
}
});
// 7. Set up offline handling
onOfflineChange((offline) => {
if (offline) {
console.log('[App] Working offline - data will sync when back online');
} else {
console.log('[App] Back online - syncing data...');
syncData();
}
});
// 8. Fieldwork mode (high-contrast + large touch targets)
initFieldworkMode();
// 9. Measurement system toggle (metric / imperial)
initMeasurementSystem();
// 10. Dark mode
initDarkMode();
// 11. Default base map selector
initDefaultBasemap();
// 12. Offline tile-cache stats card
initOfflineTileCache();
// 13. Offline-download dialog
initOfflineDownloadDialog();
// 14. Account card (signed-in user + sign-out)
initAccountCard();
console.log('[App] Initialized successfully');
}
// ============================================================================
// UI Initialization
// ============================================================================
function initUI() {
console.log('[initUI] Starting UI initialization...');
// Message log (persistent stack in right panel)
initMessageLog();
// Export button
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', handleExport);
}
// Local Data button β shows tables and record counts
const localDataBtn = document.getElementById('local-data-btn');
if (localDataBtn) {
localDataBtn.addEventListener('click', () => refreshLocalDataStats());
}
// File import buttons (Shapefile, GeoJSON, KML)
const importShpBtn = document.getElementById('import-shp-btn');
const shpFileInput = document.getElementById('shp-file-input');
if (importShpBtn && shpFileInput) {
importShpBtn.addEventListener('click', () => shpFileInput.click());
shpFileInput.addEventListener('change', handleShapefileImport);
}
const importGeoJSONBtn = document.getElementById('import-geojson-btn');
const geojsonFileInput = document.getElementById('geojson-file-input');
if (importGeoJSONBtn && geojsonFileInput) {
importGeoJSONBtn.addEventListener('click', () => geojsonFileInput.click());
geojsonFileInput.addEventListener('change', handleGeoJSONImport);
}
const importKMLBtn = document.getElementById('import-kml-btn');
const kmlFileInput = document.getElementById('kml-file-input');
if (importKMLBtn && kmlFileInput) {
importKMLBtn.addEventListener('click', () => kmlFileInput.click());
kmlFileInput.addEventListener('change', handleKMLImport);
}
// Drag-and-drop file import on the map
initMapDropZone();
// GeoJSON Export button
const exportGeoJSONBtn = document.getElementById('exportGeoJSON-btn');
if (exportGeoJSONBtn) {
exportGeoJSONBtn.addEventListener('click', handleExportGeoJSON);
}
// Status button
const statusBtn = document.getElementById('status-btn');
if (statusBtn) {
statusBtn.addEventListener('click', handleShowStatus);
}
// Fit to markers button
const fitBtn = document.getElementById('fit-btn');
if (fitBtn) {
fitBtn.addEventListener('click', () => mapView?.fitToMarkers());
}
// ============================================
// Mode Selector & Measurement Tools (Bottom Dock)
// ============================================
const addLocationBtn = document.getElementById('dock-btn-add-location');
const measureCircleBtn = document.getElementById('dock-btn-measure-circle');
const measureLineBtn = document.getElementById('dock-btn-measure-line');
const measureAreaBtn = document.getElementById('dock-btn-measure-area');
const drawBtn = document.getElementById('dock-btn-draw');
const clearBtn = document.getElementById('dock-btn-clear');
// Debug: Check if buttons are found
console.log('[initUI] Buttons found:', {
addLocation: !!addLocationBtn,
measureCircle: !!measureCircleBtn,
measureLine: !!measureLineBtn,
measureArea: !!measureAreaBtn,
draw: !!drawBtn,
clear: !!clearBtn
});
// All mode buttons (mutually exclusive)
const modeButtons = [addLocationBtn, measureCircleBtn, measureLineBtn, measureAreaBtn, drawBtn];
// Helper to set active mode and update button states
// Note: This updates the module-level currentMode variable
const setMode = (mode, activeBtn) => {
console.log('[setMode] Changing mode from', currentMode, 'to', mode);
currentMode = mode;
console.log('[setMode] currentMode is now:', currentMode);
// Update button active states
modeButtons.forEach(btn => {
if (btn) btn.classList.toggle('active', btn === activeBtn);
});
// Deactivate any measurement tool when switching modes
mapTools?.deactivate();
// Leave edit mode when switching away from draw
if (mode !== 'draw') {
mapView?.setEditMode(false);
}
// Hide add location popup when leaving addLocation mode
if (mode !== 'addLocation') {
mapView?.hideAddLocationPopup();
}
// Activate the appropriate tool for the new mode
switch (mode) {
case 'measureCircle':
mapTools?.startCircleMeasure();
break;
case 'measureLine':
mapTools?.startLineMeasure();
break;
case 'measureArea':
mapTools?.startAreaMeasure();
break;
case 'draw':
mapView?.setEditMode(true);
break;
// addLocation mode doesn't need tool activation
}
};
// Add Location mode button
if (addLocationBtn) {
addLocationBtn.addEventListener('click', () => {
console.log('[Button] Add Location clicked');
setMode('addLocation', addLocationBtn);
});
}
// Circle measurement button
if (measureCircleBtn) {
measureCircleBtn.addEventListener('click', () => {
console.log('[Button] Circle clicked, currentMode is:', currentMode);
if (currentMode === 'measureCircle') {
// Toggle off - return to addLocation mode
setMode('addLocation', addLocationBtn);
} else {
setMode('measureCircle', measureCircleBtn);
}
});
}
// Line measurement button
if (measureLineBtn) {
measureLineBtn.addEventListener('click', () => {
console.log('[Button] Line clicked, currentMode is:', currentMode);
if (currentMode === 'measureLine') {
setMode('addLocation', addLocationBtn);
} else {
setMode('measureLine', measureLineBtn);
}
});
}
// Area measurement button
if (measureAreaBtn) {
measureAreaBtn.addEventListener('click', () => {
console.log('[Button] Area clicked, currentMode is:', currentMode);
if (currentMode === 'measureArea') {
setMode('addLocation', addLocationBtn);
} else {
setMode('measureArea', measureAreaBtn);
}
});
}
// Draw / Edit button
if (drawBtn) {
drawBtn.addEventListener('click', () => {
console.log('[Button] Draw clicked, currentMode is:', currentMode);
if (currentMode === 'draw') {
setMode('addLocation', addLocationBtn);
} else {
setMode('draw', drawBtn);
}
});
}
// Clear button - clears measurements but stays in current mode
if (clearBtn) {
clearBtn.addEventListener('click', () => {
mapTools?.clearMeasurements();
// If in a measurement mode, restart the tool
if (currentMode.startsWith('measure')) {
mapTools?.deactivate();
switch (currentMode) {
case 'measureCircle':
mapTools?.startCircleMeasure();
break;
case 'measureLine':
mapTools?.startLineMeasure();
break;
case 'measureArea':
mapTools?.startAreaMeasure();
break;
}
}
});
}
}
// ============================================================================
// Location Handlers
// ============================================================================
async function handleAddLocation(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const name = formData.get('name');
const longitude = parseFloat(formData.get('longitude'));
const latitude = parseFloat(formData.get('latitude'));
const description = formData.get('description') || null;
const category = formData.get('category') || 'default';
if (!name || isNaN(longitude) || isNaN(latitude)) {
showError('Please fill in all required fields');
return;
}
try {
const result = await addLocation(name, longitude, latitude, { description, category });
console.log('[App] Location added:', name, 'id:', result.id);
form.reset();
await loadLocations();
// Zoom to the new location on the map
mapView?.zoomTo(longitude, latitude, 14);
// Select the new marker
if (result.id) {
mapView?.selectMarker(result.id);
}
showSuccess('Location added successfully');
} catch (error) {
console.error('[App] Failed to add location:', error);
showError('Failed to add location: ' + error.message);
}
}
async function loadLocations() {
try {
console.log('[App] Loading locations...');
const locations = await getLocations();
console.log('[App] Locations loaded:', locations);
// Update the list
renderLocations(locations);
// Update the map markers
if (mapView) {
mapView.clearMarkers();
if (locations.length > 0) {
mapView.addMarkers(locations);
console.log('[App] Added', locations.length, 'markers to map');
}
}
// Update count display
const countEl = document.getElementById('location-count');
if (countEl) {
countEl.textContent = locations.length;
}
} catch (error) {
console.error('[App] Failed to load locations:', error);
}
}
/**
* Show details for a selected location
*/
function showLocationDetails(feature) {
const name = feature.get('name');
const description = feature.get('description');
const category = feature.get('category');
const lon = feature.get('lon') || feature.get('longitude');
const lat = feature.get('lat') || feature.get('latitude');
// You could show a popup or info panel here
// For now, just log to console
console.log('[App] Selected location:', { name, description, category, lon, lat });
// Optionally zoom to the location
// mapView.zoomTo(lon, lat, 14);
}
function renderLocations(locations) {
const container = document.getElementById('locations-list');
if (!container) return;
// Also update mobile count
const mobileCount = document.getElementById('location-count-mobile');
if (mobileCount) {
mobileCount.textContent = locations.length;
}
if (locations.length === 0) {
container.innerHTML = `
`;
}).join('');
statsContainer.classList.remove('d-none');
// Table-name link β open content modal
tbody.querySelectorAll('.table-name-link').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
showTableContent(link.dataset.table);
});
});
// Per-row clear β confirm, clear that table, refresh stats
tbody.querySelectorAll('.table-clear-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const tableName = btn.dataset.table;
if (!confirm(`Clear local cache for "${tableName}"?\n\nThe data will be re-downloaded from the server on the next app start.`)) return;
try {
const removed = await clearTable(tableName);
showSuccess(`Cleared ${removed} row${removed === 1 ? '' : 's'} from "${tableName}". It will re-download on next start.`);
await refreshLocalDataStats();
} catch (err) {
console.error('[App] Per-table clear failed:', err);
showError(`Could not clear "${tableName}": ${err.message}`);
}
});
});
} catch (error) {
console.error('[App] Failed to load table stats:', error);
tbody.innerHTML = `
Failed to load
`;
statsContainer.classList.remove('d-none');
}
// Bulk-clear button β wire up once
if (clearAllBtn && !clearAllBtn._wired) {
clearAllBtn._wired = true;
clearAllBtn.addEventListener('click', handleClearAllCachedLayers);
}
}
/**
* Clear every cached layer table and offer to reload the app so the layers
* re-download immediately. If the user dismisses the reload prompt, the
* fresh fetch will happen on the next manual app start.
*/
async function handleClearAllCachedLayers() {
if (!confirm(
'Delete all cached map layers from this device?\n\n' +
'The next time the app starts (or after a reload), every layer will be ' +
're-downloaded from the server. Your locally drawn data is not affected.'
)) return;
try {
const results = await clearAllCachedLayers();
const total = results.reduce((s, r) => s + r.count, 0);
showSuccess(`Cleared ${total} row${total === 1 ? '' : 's'} across ${results.length} table${results.length === 1 ? '' : 's'}.`);
await refreshLocalDataStats();
if (confirm('Reload the app now to re-download the layers fresh from the server?')) {
window.location.reload();
}
} catch (err) {
console.error('[App] Clear-all failed:', err);
showError('Failed to clear cached layers: ' + err.message);
}
}
// ============================================================================
// Table Content Viewer
// ============================================================================
/**
* Load and display all rows of a table in a modal.
* @param {string} tableName - The table to show
*/
async function showTableContent(tableName) {
const modalTitle = document.getElementById('tableContentModalLabel');
const modalBody = document.getElementById('table-content-body');
const modalInfo = document.getElementById('table-content-info');
// Set title and show spinner
modalTitle.textContent = `Table: ${tableName}`;
modalBody.innerHTML = `
Loading...
`;
modalInfo.textContent = '';
// Open the modal
const modal = new Modal(document.getElementById('tableContentModal'));
modal.show();
try {
const { columns, rows } = await getTableContent(tableName);
if (rows.length === 0) {
modalBody.innerHTML = `