files for /src

Mapview.js must be placed into /src/components
This commit is contained in:
ekke 2026-01-27 09:51:21 +00:00
parent 923d353fec
commit 876e884509
3 changed files with 1705 additions and 0 deletions

837
MapView.js Normal file
View File

@ -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 = `
<div style="font-weight: 600; font-size: 14px; margin-bottom: 6px;">
${emoji} ${this.escapeHtml(name)}
</div>
`;
// 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 += `
<div style="margin-bottom: 6px;">
<span style="
background: ${catColor}20;
color: ${catColor};
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
">${category}</span>
</div>
`;
// Description if available
if (description) {
html += `
<div style="color: #555; font-size: 12px; margin-bottom: 6px; line-height: 1.4;">
${this.escapeHtml(description)}
</div>
`;
}
// Coordinates
if (lon !== undefined && lat !== undefined) {
html += `
<div style="color: #888; font-size: 11px; font-family: monospace;">
${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
</div>
`;
}
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 = `
<div class="add-location-popup-header">
<span> Add Location</span>
<button type="button" class="add-location-popup-close" aria-label="Close">&times;</button>
</div>
<form id="map-add-location-form">
<div class="add-location-popup-field">
<label for="map-location-name">Name <span class="text-danger">*</span></label>
<input type="text" id="map-location-name" name="name" required placeholder="e.g., Water Point A">
</div>
<div class="add-location-popup-field">
<label for="map-location-category">Category</label>
<select id="map-location-category" name="category">
<option value="default">📍 Default</option>
<option value="water">💧 Water Point</option>
<option value="school">🏫 School</option>
<option value="health">🏥 Health Facility</option>
<option value="market">🏪 Market</option>
<option value="other">📌 Other</option>
</select>
</div>
<div class="add-location-popup-field">
<label for="map-location-description">Description</label>
<textarea id="map-location-description" name="description" rows="2" placeholder="Optional notes..."></textarea>
</div>
<div class="add-location-popup-coords">
<small>📍 <span id="map-location-coords"></span></small>
</div>
<button type="submit" class="add-location-popup-submit"> Add Location</button>
</form>
`;
// 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;

551
database.js Normal file
View File

@ -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
};

317
pwa.js Normal file
View File

@ -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
};