From 876e884509c4d408b6d78da00a84c0f1e0774a42 Mon Sep 17 00:00:00 2001 From: ekke Date: Tue, 27 Jan 2026 09:51:21 +0000 Subject: [PATCH] files for /src Mapview.js must be placed into /src/components --- MapView.js | 837 ++++++++++++++++++++++++++++++++++++++++++++++++++++ database.js | 551 ++++++++++++++++++++++++++++++++++ pwa.js | 317 ++++++++++++++++++++ 3 files changed, 1705 insertions(+) create mode 100644 MapView.js create mode 100644 database.js create mode 100644 pwa.js diff --git a/MapView.js b/MapView.js new file mode 100644 index 0000000..8a47118 --- /dev/null +++ b/MapView.js @@ -0,0 +1,837 @@ +/** + * MapView Component + * + * OpenLayers map with ol-ext LayerSwitcher for base map selection. + * + * Usage: + * import { MapView } from './components/MapView.js'; + * + * const map = new MapView('map', { + * center: [-1.5, 7.5], // Ghana + * zoom: 7, + * basemap: 'osm' + * }); + * + * map.onClick((lon, lat) => console.log('Clicked:', lon, lat)); + * map.addMarker(lon, lat, { name: 'Point A' }); + */ + +import Map from 'ol/Map'; +import View from 'ol/View'; +import Overlay from 'ol/Overlay'; +import TileLayer from 'ol/layer/Tile'; +import LayerGroup from 'ol/layer/Group'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import OSM from 'ol/source/OSM'; +import XYZ from 'ol/source/XYZ'; +import { fromLonLat, toLonLat } from 'ol/proj'; +import { Point } from 'ol/geom'; +import Feature from 'ol/Feature'; +import { Style, Circle, Fill, Stroke, Text } from 'ol/style'; + +// ol-ext LayerSwitcher +import LayerSwitcher from 'ol-ext/control/LayerSwitcher'; + +// ol-ext GeolocationButton +import GeolocationButton from 'ol-ext/control/GeolocationButton'; + +// CSS imports +import 'ol/ol.css'; +import 'ol-ext/dist/ol-ext.css'; + +export class MapView { + constructor(targetId, options = {}) { + this.options = options; + this.markerSource = new VectorSource(); + this.clickCallbacks = []; + + // Category emoji mapping + this.categoryEmojis = { + 'water': '💧', + 'school': '🏫', + 'health': '🏥', + 'market': '🏪', + 'hotel': '🏨', + 'restaurant': '🍽️', + 'default': '📍', + 'other': '📌' + }; + + // Create emoji style helper + this.createEmojiStyle = (emoji, fontSize = 24) => { + return new Style({ + text: new Text({ + text: emoji, + font: `${fontSize}px sans-serif`, + textBaseline: 'bottom', + textAlign: 'center', + offsetY: -5, + }), + }); + }; + + // Default marker style (pin emoji) + this.defaultStyle = this.createEmojiStyle('📍', 32); + + // Selected marker style (larger) + this.selectedStyle = this.createEmojiStyle('📍', 42); + + // Initialize category styles with emojis + this.categoryStyles = {}; + for (const [category, emoji] of Object.entries(this.categoryEmojis)) { + this.categoryStyles[category] = this.createEmojiStyle(emoji, 32); + } + + // Create base layers group + const baseLayers = this.createBaseLayers(options.basemap || 'osm'); + + // Markers layer + this.markersLayer = new VectorLayer({ + title: 'Markers', + source: this.markerSource, + style: (feature) => this.getFeatureStyle(feature), + }); + + // Create map + this.map = new Map({ + target: targetId, + layers: [ + baseLayers, + this.markersLayer, + ], + view: new View({ + center: fromLonLat(options.center || [0, 0]), + zoom: options.zoom || 2, + minZoom: options.minZoom || 2, + maxZoom: options.maxZoom || 19, + }), + }); + + // Add LayerSwitcher control + const layerSwitcher = new LayerSwitcher({ + collapsed: true, + mouseover: true, + extent: false, + trash: false, + oninfo: null, + }); + this.map.addControl(layerSwitcher); + + // Add GeolocationButton control + const geolocationButton = new GeolocationButton({ + title: 'My Location', + delay: 3000, // Auto-center duration + zoom: 16, // Zoom level when centering on location + }); + this.map.addControl(geolocationButton); + + // Store reference for external access + this.geolocationButton = geolocationButton; + + // Track selected feature + this.selectedFeature = null; + + // Create popup overlay for hover + this.createPopup(); + + // Create Add Location popup form + this.createAddLocationPopup(); + } + + /** + * Create the popup overlay element and add to map + */ + createPopup() { + // Create popup container element + this.popupElement = document.createElement('div'); + this.popupElement.className = 'map-popup'; + this.popupElement.style.cssText = ` + position: absolute; + background: white; + border-radius: 8px; + padding: 10px 14px; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + min-width: 150px; + max-width: 280px; + pointer-events: none; + z-index: 1000; + border: 1px solid #e0e0e0; + `; + + // Create the overlay + this.popup = new Overlay({ + element: this.popupElement, + positioning: 'bottom-center', + offset: [0, -15], + stopEvent: false, + }); + + this.map.addOverlay(this.popup); + + // Set up hover handler + this.setupHoverPopup(); + } + + /** + * Set up the hover popup behavior + */ + setupHoverPopup() { + let currentFeature = null; + + this.map.on('pointermove', (evt) => { + if (evt.dragging) { + this.hidePopup(); + return; + } + + const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f); + + if (feature && feature !== currentFeature) { + currentFeature = feature; + this.showPopup(feature, evt.coordinate); + } else if (!feature && currentFeature) { + currentFeature = null; + this.hidePopup(); + } + + // Update cursor + this.map.getTargetElement().style.cursor = feature ? 'pointer' : ''; + }); + + // Hide popup when mouse leaves the map + this.map.getTargetElement().addEventListener('mouseleave', () => { + this.hidePopup(); + currentFeature = null; + }); + } + + /** + * Show popup with feature attributes + */ + showPopup(feature, coordinate) { + const name = feature.get('name') || 'Unnamed'; + const category = feature.get('category') || 'default'; + const description = feature.get('description'); + const lon = feature.get('lon'); + const lat = feature.get('lat'); + const emoji = this.categoryEmojis[category] || '📍'; + + // Build popup content + let html = ` +
+ ${emoji} ${this.escapeHtml(name)} +
+ `; + + // Category badge + const categoryColors = { + 'water': '#3b82f6', + 'school': '#f59e0b', + 'health': '#ef4444', + 'market': '#8b5cf6', + 'hotel': '#8b5cf6', + 'restaurant': '#8b5cf6', + 'default': '#2d5016', + 'other': '#6b7280' + }; + const catColor = categoryColors[category] || '#6b7280'; + html += ` +
+ ${category} +
+ `; + + // Description if available + if (description) { + html += ` +
+ ${this.escapeHtml(description)} +
+ `; + } + + // Coordinates + if (lon !== undefined && lat !== undefined) { + html += ` +
+ ${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)} +
+ `; + } + + this.popupElement.innerHTML = html; + this.popup.setPosition(coordinate); + } + + /** + * Hide the popup + */ + hidePopup() { + this.popup.setPosition(undefined); + } + + /** + * Escape HTML to prevent XSS + */ + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * Create the Add Location popup form overlay + */ + createAddLocationPopup() { + // Create popup container element + this.addLocationPopupElement = document.createElement('div'); + this.addLocationPopupElement.className = 'map-add-location-popup'; + this.addLocationPopupElement.innerHTML = ` +
+ ➕ Add Location + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ 📍 +
+ +
+ `; + + // Create the overlay + this.addLocationPopup = new Overlay({ + element: this.addLocationPopupElement, + positioning: 'bottom-center', + offset: [0, -10], + stopEvent: true, // Prevent click from propagating + autoPan: true, + autoPanAnimation: { + duration: 250, + }, + }); + + this.map.addOverlay(this.addLocationPopup); + + // Store clicked coordinates + this.addLocationCoords = null; + + // Set up close button handler + const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close'); + closeBtn.addEventListener('click', () => { + this.hideAddLocationPopup(); + }); + + // Store form submit callbacks + this.addLocationCallbacks = []; + } + + /** + * Show the Add Location popup at the specified coordinate + */ + showAddLocationPopup(coordinate) { + const [lon, lat] = toLonLat(coordinate); + this.addLocationCoords = { lon, lat }; + + // Update coordinates display + const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords'); + coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`; + + // Reset form + const form = this.addLocationPopupElement.querySelector('#map-add-location-form'); + form.reset(); + + // Position and show popup + this.addLocationPopup.setPosition(coordinate); + } + + /** + * Hide the Add Location popup + */ + hideAddLocationPopup() { + this.addLocationPopup.setPosition(undefined); + this.addLocationCoords = null; + } + + /** + * Register a callback for when a location is submitted via the map popup + * Callback receives: { name, category, description, lon, lat } + */ + onAddLocation(callback) { + this.addLocationCallbacks.push(callback); + + // Set up form submit handler (only once) + if (this.addLocationCallbacks.length === 1) { + const form = this.addLocationPopupElement.querySelector('#map-add-location-form'); + form.addEventListener('submit', (e) => { + e.preventDefault(); + + if (!this.addLocationCoords) return; + + const formData = new FormData(form); + const data = { + name: formData.get('name'), + category: formData.get('category'), + description: formData.get('description'), + lon: this.addLocationCoords.lon, + lat: this.addLocationCoords.lat, + }; + + // Call all registered callbacks + this.addLocationCallbacks.forEach(cb => cb(data)); + + // Hide popup after submission + this.hideAddLocationPopup(); + }); + } + } + + /** + * Create base layers group for LayerSwitcher + */ + createBaseLayers(defaultBasemap) { + + + const topoLayer = new TileLayer({ + title: 'Topographic', + type: 'base', + visible: defaultBasemap === 'topo', + source: new XYZ({ + url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png', + attributions: 'Map data: © OpenTopoMap', + maxZoom: 17, + crossOrigin: 'anonymous', + }), + }); + + const cartoLightLayer = new TileLayer({ + title: 'Carto Light', + type: 'base', + visible: defaultBasemap === 'carto-light', + source: new XYZ({ + url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', + attributions: '© CARTO', + maxZoom: 19, + crossOrigin: 'anonymous', + }), + }); + + const cartoDarkLayer = new TileLayer({ + title: 'Carto Dark', + type: 'base', + visible: defaultBasemap === 'carto-dark', + source: new XYZ({ + url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + attributions: '© CARTO', + maxZoom: 19, + crossOrigin: 'anonymous', + }), + }); + const osmCycleLayer = new TileLayer({ + title: 'OSM Cycle map', + type: 'base', + visible: false, //defaultBasemap === 'osm', + source: new OSM({ + "url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760" + }), + }); + + const satelliteLayer = new TileLayer({ + title: 'Satellite', + type: 'base', + visible: defaultBasemap === 'satellite', + source: new XYZ({ + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attributions: 'Tiles © Esri', + maxZoom: 19, + crossOrigin: 'anonymous', + }), + }); + const googleLayer = new TileLayer({ + title: 'Google Sat', + type: 'base', + visible: defaultBasemap === 'googlesat', + source: new XYZ({ +// url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga', + attributions: 'Tiles © Google', + maxZoom: 19, + crossOrigin: 'anonymous', + }), + }); + const osmLayer = new TileLayer({ + title: 'OpenStreetMap', + type: 'base', + visible: defaultBasemap === 'osm', + source: new OSM(), + }); + + + // Return LayerGroup for LayerSwitcher + return new LayerGroup({ + title: 'Base Maps', + layers: [ + cartoLightLayer, + cartoDarkLayer, + satelliteLayer, + osmCycleLayer, + topoLayer, + googleLayer, + osmLayer, + ], + }); + } + + /** + * Get style for a feature (handles selection state) + */ + getFeatureStyle(feature) { + const category = feature.get('category') || 'default'; + const emoji = this.categoryEmojis[category] || '📍'; + + if (feature === this.selectedFeature) { + // Return selected style with the correct emoji and highlight + return [ + // Background highlight circle + new Style({ + image: new Circle({ + radius: 22, + fill: new Fill({ color: 'rgba(220, 38, 38, 0.25)' }), + stroke: new Stroke({ color: '#dc2626', width: 3 }), + }), + }), + // Emoji on top, larger + new Style({ + text: new Text({ + text: emoji, + font: '40px sans-serif', + textBaseline: 'bottom', + textAlign: 'center', + offsetY: -5, + }), + }), + ]; + } + + // Check for custom style + const customStyle = feature.get('style'); + if (customStyle) { + return customStyle; + } + + // Return category-based emoji style + if (this.categoryStyles[category]) { + return this.categoryStyles[category]; + } + + return this.defaultStyle; + } + + /** + * Set category-based styles with emojis + * @param {Object} styles - Map of category to config { emoji, fontSize } + */ + setCategoryStyles(styles) { + for (const [category, config] of Object.entries(styles)) { + // Update emoji mapping if provided + if (config.emoji) { + this.categoryEmojis[category] = config.emoji; + } + + // Create/update style + const emoji = this.categoryEmojis[category] || '📍'; + const fontSize = config.fontSize || 28; + + this.categoryStyles[category] = this.createEmojiStyle(emoji, fontSize); + } + + // Refresh markers + this.markerSource.changed(); + } + + /** + * Add a single marker + */ + addMarker(lon, lat, properties = {}) { + console.log('[MapView] Adding marker at', lon, lat, 'with properties:', properties); + + const feature = new Feature({ + geometry: new Point(fromLonLat([lon, lat])), + ...properties, + }); + + // Store original coordinates for easy access + feature.set('lon', lon); + feature.set('lat', lat); + + this.markerSource.addFeature(feature); + console.log('[MapView] Marker added, total features:', this.markerSource.getFeatures().length); + return feature; + } + + /** + * Add multiple markers from an array of location objects + */ + addMarkers(locations) { + console.log('[MapView] Adding', locations.length, 'markers'); + + const features = locations.map((loc) => { + const feature = new Feature({ + geometry: new Point(fromLonLat([loc.longitude, loc.latitude])), + id: loc.id, + name: loc.name, + description: loc.description, + category: loc.category, + lon: loc.longitude, + lat: loc.latitude, + }); + return feature; + }); + + this.markerSource.addFeatures(features); + console.log('[MapView] Markers added, total features:', this.markerSource.getFeatures().length); + return features; + } + + /** + * Clear all markers + */ + clearMarkers() { + this.markerSource.clear(); + this.selectedFeature = null; + } + + /** + * Remove a specific marker by feature or ID + */ + removeMarker(featureOrId) { + if (typeof featureOrId === 'object') { + this.markerSource.removeFeature(featureOrId); + } else { + const feature = this.markerSource.getFeatures().find( + f => f.get('id') === featureOrId + ); + if (feature) { + this.markerSource.removeFeature(feature); + } + } + } + + /** + * Get all markers + */ + getMarkers() { + return this.markerSource.getFeatures(); + } + + /** + * Find marker by ID + */ + findMarker(id) { + return this.markerSource.getFeatures().find(f => f.get('id') === id); + } + + /** + * Select a marker (highlights it) + */ + selectMarker(featureOrId) { + if (typeof featureOrId === 'object') { + this.selectedFeature = featureOrId; + } else { + this.selectedFeature = this.findMarker(featureOrId); + } + this.markerSource.changed(); + return this.selectedFeature; + } + + /** + * Clear selection + */ + clearSelection() { + this.selectedFeature = null; + this.markerSource.changed(); + } + + /** + * Zoom to a specific location + */ + zoomTo(lon, lat, zoom = 15) { + this.map.getView().animate({ + center: fromLonLat([lon, lat]), + zoom: zoom, + duration: 500, + }); + } + + /** + * Fit view to show all markers + */ + fitToMarkers(padding = 50) { + const extent = this.markerSource.getExtent(); + if (extent && extent[0] !== Infinity) { + this.map.getView().fit(extent, { + padding: [padding, padding, padding, padding], + duration: 500, + maxZoom: 16, + }); + } + } + + /** + * Get current map center in lon/lat + */ + getCenter() { + const center = this.map.getView().getCenter(); + return toLonLat(center); + } + + /** + * Get current zoom level + */ + getZoom() { + return this.map.getView().getZoom(); + } + + /** + * Set map center + */ + setCenter(lon, lat) { + this.map.getView().setCenter(fromLonLat([lon, lat])); + } + + /** + * Set zoom level + */ + setZoom(zoom) { + this.map.getView().setZoom(zoom); + } + + /** + * Register click callback + * Callback receives (lon, lat, feature, event) + */ + onClick(callback) { + this.clickCallbacks.push(callback); + + // Set up click handler if this is the first callback + if (this.clickCallbacks.length === 1) { + this.map.on('click', (evt) => { + const [lon, lat] = toLonLat(evt.coordinate); + + // Check if clicked on a feature + let clickedFeature = null; + this.map.forEachFeatureAtPixel(evt.pixel, (feature) => { + clickedFeature = feature; + return true; // Stop at first feature + }); + + // Call all registered callbacks + for (const cb of this.clickCallbacks) { + cb(lon, lat, clickedFeature, evt); + } + }); + } + + // Return unsubscribe function + return () => { + const index = this.clickCallbacks.indexOf(callback); + if (index > -1) { + this.clickCallbacks.splice(index, 1); + } + }; + } + + /** + * Register pointer move callback (for hover effects) + */ + onPointerMove(callback) { + this.map.on('pointermove', (evt) => { + if (evt.dragging) return; + + const [lon, lat] = toLonLat(evt.coordinate); + + let hoveredFeature = null; + this.map.forEachFeatureAtPixel(evt.pixel, (feature) => { + hoveredFeature = feature; + return true; + }); + + // Change cursor + this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : ''; + + callback(lon, lat, hoveredFeature, evt); + }); + } + + /** + * Enable cursor change on marker hover + * Note: This is now handled automatically by the popup system + */ + enableHoverCursor() { + // Cursor changes are now handled by setupHoverPopup() + // This method is kept for backwards compatibility + } + + /** + * Get the OpenLayers map instance for advanced usage + */ + getMap() { + return this.map; + } + + /** + * Get the marker source for advanced usage + */ + getMarkerSource() { + return this.markerSource; + } + + /** + * Get the markers layer for advanced usage + */ + getMarkersLayer() { + return this.markersLayer; + } + + /** + * Update map size (call after container resize) + */ + updateSize() { + this.map.updateSize(); + } +} + +// Export OpenLayers utilities for convenience +export { fromLonLat, toLonLat }; + +export default MapView; diff --git a/database.js b/database.js new file mode 100644 index 0000000..0d1d49d --- /dev/null +++ b/database.js @@ -0,0 +1,551 @@ +/** + * Database Module + * + * Uses SQLocal directly with BroadcastChannel for cross-tab coordination. + * + * Why this approach instead of SharedWorker? + * - SQLocal already uses its own internal worker for OPFS access + * - Wrapping it in another SharedWorker adds complexity and causes issues + * - BroadcastChannel provides simple cross-tab communication + * - Each tab has its own SQLocal instance but they share the same OPFS database file + * + * Usage: + * import { sql, dbReady, addLocation, getLocations } from './database.js'; + * + * await dbReady; + * await addLocation('Point A', -1.5, 7.5); + * const locations = await getLocations(); + */ + +import { SQLocal } from 'sqlocal'; + +// Database configuration +const DATABASE_PATH = 'lupmis.sqlite3'; +const BROADCAST_CHANNEL = 'lupmis-db-sync'; + +// Create SQLocal instance +const db = new SQLocal(DATABASE_PATH); + +// Get the sql tagged template function +const { sql } = db; + +console.log('[Database] SQLocal instance created for:', DATABASE_PATH); + +// Export sql for direct queries +export { sql }; + +// Create broadcast channel for cross-tab coordination +const channel = new BroadcastChannel(BROADCAST_CHANNEL); + +// Track if database is ready +let isReady = false; +let readyResolve; +let readyReject; + +export const dbReady = new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; +}); + +// Database change listeners +const changeListeners = new Set(); + +/** + * Subscribe to database changes (from any tab) + * @param {Function} listener - Called with { table, action, id } + * @returns {Function} Unsubscribe function + */ +export function onDatabaseChange(listener) { + changeListeners.add(listener); + return () => changeListeners.delete(listener); +} + +// Handle messages from other tabs +channel.onmessage = (event) => { + const { type, payload } = event.data; + if (type === 'DB_CHANGE') { + // Notify local listeners about changes from other tabs + for (const listener of changeListeners) { + try { + listener(payload); + } catch (e) { + console.error('[Database] Change listener error:', e); + } + } + } +}; + +/** + * Broadcast a database change to other tabs + */ +function broadcastChange(table, action, id = null) { + channel.postMessage({ + type: 'DB_CHANGE', + payload: { table, action, id, timestamp: Date.now() } + }); + + // Also notify local listeners + for (const listener of changeListeners) { + try { + listener({ table, action, id, timestamp: Date.now(), local: true }); + } catch (e) { + console.error('[Database] Change listener error:', e); + } + } +} + +// ============================================================================ +// Database Initialization +// ============================================================================ + +/** + * Initialize the database schema + */ +export async function initSchema() { + try { + console.log('[Database] Initializing schema...'); + + // Test connection + const testResult = await sql`SELECT sqlite_version() as version`; + console.log('[Database] SQLite version:', testResult[0]?.version); + + // Create locations table + console.log('[Database] Creating locations table...'); + await sql` + CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + longitude REAL NOT NULL, + latitude REAL NOT NULL, + description TEXT, + category TEXT DEFAULT 'default', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + synced INTEGER DEFAULT 0 + ) + `; + + // Verify table exists + const tablesAfterLocations = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`; + console.log('[Database] Locations table exists:', tablesAfterLocations.length > 0); + + // Create sync_log table + console.log('[Database] Creating sync_log table...'); + await sql` + CREATE TABLE IF NOT EXISTS sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT NOT NULL, + record_id INTEGER NOT NULL, + action TEXT NOT NULL, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + synced INTEGER DEFAULT 0 + ) + `; + + // Create indexes + await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`; + await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`; + + // Final verification + const allTables = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`; + console.log('[Database] All tables:', allTables.map(t => t.name)); + + isReady = true; + readyResolve(true); + console.log('[Database] ✓ Schema initialized'); + + } catch (error) { + console.error('[Database] ✗ Schema init failed:', error); + readyReject(error); + throw error; + } +} + +// ============================================================================ +// Location Operations +// ============================================================================ + +/** + * Add a new location + */ +export async function addLocation(name, longitude, latitude, options = {}) { + const { description = null, category = 'default' } = options; + + console.log('[Database] Adding location:', name, longitude, latitude, category); + + try { + // Check table exists first + const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`; + console.log('[Database] Table check before insert:', tableCheck); + + if (tableCheck.length === 0) { + console.error('[Database] ✗ locations table does not exist!'); + throw new Error('locations table does not exist'); + } + + // Insert - using explicit values + console.log('[Database] Executing INSERT...'); + await sql` + INSERT INTO locations (name, longitude, latitude, description, category) + VALUES (${name}, ${longitude}, ${latitude}, ${description}, ${category}) + `; + console.log('[Database] INSERT completed'); + + // Get the ID + const idResult = await sql`SELECT last_insert_rowid() as id`; + const newId = idResult[0]?.id; + console.log('[Database] New ID:', newId); + + // Verify it was actually inserted + const verifyResult = await sql`SELECT * FROM locations WHERE id = ${newId}`; + console.log('[Database] Verify insert:', verifyResult); + + if (verifyResult.length === 0) { + console.error('[Database] ✗ Insert verification failed - row not found!'); + throw new Error('Insert verification failed'); + } + + // Log for sync + await sql` + INSERT INTO sync_log (table_name, record_id, action) + VALUES ('locations', ${newId}, 'INSERT') + `; + + // Broadcast to other tabs + broadcastChange('locations', 'INSERT', newId); + + console.log('[Database] ✓ Location added:', newId); + return { id: newId }; + + } catch (error) { + console.error('[Database] ✗ Failed to add location:', error); + throw error; +} +} + +/** + * Get all locations + */ +export async function getLocations(options = {}) { + const { category = null, limit = 1000 } = options; + + try { + // First check if table exists + const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`; + console.log('[Database] getLocations - table exists:', tableCheck.length > 0); + + if (tableCheck.length === 0) { + console.warn('[Database] locations table does not exist yet'); + return []; + } + + let results; + if (category) { + results = await sql` + SELECT * FROM locations + WHERE category = ${category} + ORDER BY created_at DESC + LIMIT ${limit} + `; + } else { + results = await sql` + SELECT * FROM locations + ORDER BY created_at DESC + LIMIT ${limit} + `; +} + + console.log('[Database] getLocations returned', results.length, 'rows'); + return results; + + } catch (error) { + console.error('[Database] getLocations error:', error); + return []; + } +} + +export async function getLocation(id) { + try { + const results = await sql`SELECT * FROM locations WHERE id = ${id}`; + return results[0] || null; + } catch (error) { + console.error('[Database] getLocation error:', error); + return null; +} +} + +/** + * Update a location + */ +export async function updateLocation(id, updates) { + const { name, longitude, latitude, description, category } = updates; + + try { + const location = await getLocation(id); + if (!location) { + throw new Error(`Location ${id} not found`); + } + + await sql` + UPDATE locations + SET + name = ${name ?? location.name}, + longitude = ${longitude ?? location.longitude}, + latitude = ${latitude ?? location.latitude}, + description = ${description ?? location.description}, + category = ${category ?? location.category}, + updated_at = CURRENT_TIMESTAMP, + synced = 0 + WHERE id = ${id} + `; + + // Log for sync + await sql` + INSERT INTO sync_log (table_name, record_id, action) + VALUES ('locations', ${id}, 'UPDATE') + `; + + // Broadcast to other tabs + broadcastChange('locations', 'UPDATE', id); + console.log('[Database] ✓ Location updated:', id); + + } catch (error) { + console.error('[Database] ✗ updateLocation error:', error); + throw error; +} +} + +/** + * Delete a location + */ +export async function deleteLocation(id) { + try { + await sql` + INSERT INTO sync_log (table_name, record_id, action) + VALUES ('locations', ${id}, 'DELETE') + `; + + await sql`DELETE FROM locations WHERE id = ${id}`; + + // Broadcast to other tabs + broadcastChange('locations', 'DELETE', id); + console.log('[Database] ✓ Location deleted:', id); + + } catch (error) { + console.error('[Database] ✗ deleteLocation error:', error); + throw error; +} +} + +/** + * Get location count + */ +export async function getLocationCount() { + try { + const result = await sql`SELECT COUNT(*) as count FROM locations`; + return result[0]?.count ?? 0; + } catch (error) { + console.error('[Database] getLocationCount error:', error); + return 0; +} +} + +// ============================================================================ +// Sync Operations +// ============================================================================ + +/** + * Get unsynced changes + */ +export async function getUnsyncedChanges() { + return sql`SELECT * FROM sync_log WHERE synced = 0 ORDER BY timestamp ASC`; +} + +/** + * Mark changes as synced + */ +export async function markSynced(syncLogIds) { + if (!syncLogIds.length) return; + for (const id of syncLogIds) { + await sql`UPDATE sync_log SET synced = 1 WHERE id = ${id}`; +} +} + +/** + * Get locations that need syncing + */ +export async function getUnsyncedLocations() { + return sql`SELECT * FROM locations WHERE synced = 0`; +} + +/** + * Mark locations as synced + */ +export async function markLocationsSynced(ids) { + if (!ids.length) return; + for (const id of ids) { + await sql`UPDATE locations SET synced = 1 WHERE id = ${id}`; +} +} + +// ============================================================================ +// Export / Import +// ============================================================================ + +/** + * Export database for backup + */ +export async function exportDatabase() { + return db.getDatabaseFile(); +} + +/** + * Import database from backup + */ +export async function importDatabase(data) { + await db.overwriteDatabaseFile(data); + broadcastChange('*', 'IMPORT', null); +} + +/** + * Download database as file + */ +export async function downloadDatabase(filename = 'lupmis-backup.sqlite3') { + const data = await exportDatabase(); + const blob = new Blob([data], { type: 'application/x-sqlite3' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + + URL.revokeObjectURL(url); +} + +// Export to GeoJSON +export async function exportToGeoJSON() { + const locations = await getLocations(); + + return { + type: 'FeatureCollection', + features: locations.map((loc) => ({ + type: 'Feature', + properties: { + id: loc.id, + name: loc.name, + category: loc.category, + notes: loc.notes, + created_at: loc.created_at, + }, + geometry: { + type: 'Point', + coordinates: [loc.lon, loc.lat], + }, + })), + }; +} + +// ============================================================================ +// Utility & Debug +// ============================================================================ + +/** + * Get database status + */ +export async function getDatabaseStatus() { + try { + const tables = await sql` + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `; + + const locationCount = await getLocationCount(); + + return { + ready: isReady, + databasePath: DATABASE_PATH, + tables: tables.map(t => t.name), + locationCount + }; + } catch (error) { + return { + ready: false, + error: error.message + }; +} +} + +// Debug function - call from console to test +export async function testDatabase() { + console.log('=== DATABASE TEST ==='); + + try { + // 1. Check connection + const version = await sql`SELECT sqlite_version() as v`; + console.log('1. SQLite version:', version[0].v); + + // 2. Check tables + const tables = await sql`SELECT name FROM sqlite_master WHERE type='table'`; + console.log('2. Tables:', tables.map(t => t.name)); + + // 3. Try to insert a test row + console.log('3. Inserting test row...'); + await sql`INSERT INTO locations (name, longitude, latitude, category) VALUES ('TEST', -1.0, 7.0, 'test')`; + + // 4. Read it back + const rows = await sql`SELECT * FROM locations WHERE name = 'TEST'`; + console.log('4. Test row:', rows); + + // 5. Count all rows + const count = await sql`SELECT COUNT(*) as c FROM locations`; + console.log('5. Total rows:', count[0].c); + + // 6. Delete test row + await sql`DELETE FROM locations WHERE name = 'TEST'`; + console.log('6. Test row deleted'); + + console.log('=== TEST PASSED ==='); + return true; + } catch (error) { + console.error('=== TEST FAILED ===', error); + return false; +} +} + +// Expose to window for debugging +if (typeof window !== 'undefined') { + window.testDatabase = testDatabase; + window.dbStatus = getDatabaseStatus; +} + +export async function closeDatabase() { + channel.close(); + if (db.destroy) { + await db.destroy(); +} +} + +export default { + sql, + dbReady, + initSchema, + addLocation, + getLocations, + getLocation, + updateLocation, + deleteLocation, + getLocationCount, + getUnsyncedChanges, + getUnsyncedLocations, + markSynced, + markLocationsSynced, + exportDatabase, + importDatabase, + downloadDatabase, + getDatabaseStatus, + testDatabase, + onDatabaseChange, + closeDatabase +}; diff --git a/pwa.js b/pwa.js new file mode 100644 index 0000000..2cd206f --- /dev/null +++ b/pwa.js @@ -0,0 +1,317 @@ +/** + * PWA Module + * + * Handles Progressive Web App functionality: + * - Service Worker registration + * - Install prompt handling + * - Offline detection + * - Update notifications + * + * Note: The Service Worker (sw.js) handles caching. + * The SharedWorker (shared-db-worker.js) handles database. + * They are separate workers with different purposes. + */ + +// ============================================================================ +// Service Worker Registration +// ============================================================================ + +let swRegistration = null; + +export async function registerServiceWorker() { + if (!('serviceWorker' in navigator)) { + console.warn('[PWA] Service Workers not supported'); + return null; + } + + try { + swRegistration = await navigator.serviceWorker.register('/sw.js', { + scope: '/' + }); + + console.log('[PWA] Service Worker registered:', swRegistration.scope); + + // Handle updates + swRegistration.addEventListener('updatefound', () => { + const newWorker = swRegistration.installing; + + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // New version available + console.log('[PWA] New version available'); + showUpdateNotification(); + } + }); + }); + + return swRegistration; + + } catch (error) { + console.error('[PWA] Service Worker registration failed:', error); + return null; + } +} + +// ============================================================================ +// Install Prompt +// ============================================================================ + +let deferredPrompt = null; +let installButton = null; + +/** + * Initialize install prompt handling + * @param {string|HTMLElement} buttonSelector - Button element or selector + */ +export function initInstallPrompt(buttonSelector = '#install-btn') { + installButton = typeof buttonSelector === 'string' + ? document.querySelector(buttonSelector) + : buttonSelector; + + if (!installButton) { + console.warn('[PWA] Install button not found:', buttonSelector); + return; + } + + // Initially hide the button + installButton.style.display = 'none'; + + // Listen for the beforeinstallprompt event + window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredPrompt = e; + + // Show the install button + installButton.style.display = 'block'; + console.log('[PWA] Install prompt ready'); + }); + + // Handle install button click + installButton.addEventListener('click', async () => { + if (!deferredPrompt) { + // Show manual instructions for Safari + showManualInstallInstructions(); + return; + } + + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + + console.log('[PWA] Install prompt outcome:', outcome); + + deferredPrompt = null; + installButton.style.display = 'none'; + }); + + // Hide button if app is already installed + window.addEventListener('appinstalled', () => { + console.log('[PWA] App installed'); + deferredPrompt = null; + installButton.style.display = 'none'; + }); + + // Check if running as installed PWA + if (window.matchMedia('(display-mode: standalone)').matches) { + installButton.style.display = 'none'; + } +} + +function showManualInstallInstructions() { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + + let message = 'To install this app:\n\n'; + + if (isIOS) { + message += '1. Tap the Share button (square with arrow)\n'; + message += '2. Scroll down and tap "Add to Home Screen"'; + } else if (isSafari) { + message += '1. Click File menu\n'; + message += '2. Click "Add to Dock"'; + } else { + message += '1. Click the menu button (three dots)\n'; + message += '2. Click "Install" or "Add to Home Screen"'; + } + + alert(message); +} + +// ============================================================================ +// Offline Detection +// ============================================================================ + +let offlineIndicator = null; +const offlineListeners = new Set(); + +/** + * Initialize offline detection + * @param {string|HTMLElement} indicatorSelector - Element to show when offline + */ +export function initOfflineDetection(indicatorSelector = '#offline-indicator') { + offlineIndicator = typeof indicatorSelector === 'string' + ? document.querySelector(indicatorSelector) + : indicatorSelector; + + // Set initial state + updateOfflineUI(!navigator.onLine); + + // Listen for online/offline events + window.addEventListener('online', () => { + console.log('[PWA] Back online'); + updateOfflineUI(false); + notifyOfflineListeners(false); + }); + + window.addEventListener('offline', () => { + console.log('[PWA] Gone offline'); + updateOfflineUI(true); + notifyOfflineListeners(true); + }); +} + +function updateOfflineUI(isOffline) { + if (offlineIndicator) { + offlineIndicator.style.display = isOffline ? 'block' : 'none'; + } + + // Also toggle a class on body for CSS styling + document.body.classList.toggle('is-offline', isOffline); +} + +/** + * Subscribe to offline state changes + * @param {Function} listener - Callback(isOffline: boolean) + * @returns {Function} Unsubscribe function + */ +export function onOfflineChange(listener) { + offlineListeners.add(listener); + // Immediately call with current state + listener(!navigator.onLine); + return () => offlineListeners.delete(listener); +} + +function notifyOfflineListeners(isOffline) { + for (const listener of offlineListeners) { + try { + listener(isOffline); + } catch (e) { + console.error('[PWA] Offline listener error:', e); + } + } +} + +/** + * Check if currently online + */ +export function isOnline() { + return navigator.onLine; +} + +// ============================================================================ +// Update Handling +// ============================================================================ + +let updateCallback = null; + +/** + * Set callback for when updates are available + * @param {Function} callback - Called when new version is ready + */ +export function onUpdateAvailable(callback) { + updateCallback = callback; +} + +function showUpdateNotification() { + if (updateCallback) { + updateCallback(); + return; + } + + // Default behavior + if (confirm('A new version is available. Reload now?')) { + applyUpdate(); + } +} + +/** + * Apply pending update (reload with new version) + */ +export function applyUpdate() { + if (swRegistration?.waiting) { + swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }); + } + window.location.reload(); +} + +// ============================================================================ +// Communication with Service Worker +// ============================================================================ + +/** + * Send a message to the service worker + * @param {Object} message - Message to send + */ +export function postToServiceWorker(message) { + navigator.serviceWorker.controller?.postMessage(message); +} + +/** + * Request the service worker to cache specific modules + * @param {string[]} moduleNames - Array of module names to cache + */ +export function cacheModules(moduleNames) { + postToServiceWorker({ + type: 'CACHE_MODULES', + payload: { modules: moduleNames } + }); +} + +/** + * Request the service worker to clear user-specific caches + * (Call this on logout) + */ +export function clearUserCaches() { + postToServiceWorker({ + type: 'CLEAR_USER_CACHE' + }); +} + +// ============================================================================ +// Auto-initialization +// ============================================================================ + +/** + * Initialize all PWA features + * @param {Object} options + */ +export async function initPWA(options = {}) { + const { + installButton = '#install-btn', + offlineIndicator = '#offline-indicator', + autoRegisterSW = true + } = options; + + if (autoRegisterSW) { + await registerServiceWorker(); + } + + initInstallPrompt(installButton); + initOfflineDetection(offlineIndicator); + + console.log('[PWA] Initialized'); +} + +// Export for direct use +export default { + registerServiceWorker, + initInstallPrompt, + initOfflineDetection, + initPWA, + isOnline, + onOfflineChange, + onUpdateAvailable, + applyUpdate, + postToServiceWorker, + cacheModules, + clearUserCaches +};