diff --git a/configs/homepage/services.yaml b/configs/homepage/services.yaml index 7fd26e6..bf16bce 100644 --- a/configs/homepage/services.yaml +++ b/configs/homepage/services.yaml @@ -5,25 +5,25 @@ - Code Server: icon: mdi-code-braces - href: "https://code.bnkserver.org" + href: "https://code.bnkserve.org" description: VS Code in the browser - Platform Editor container: code-server-changemaker - Listmonk: icon: mdi-email-newsletter - href: "https://listmonk.bnkserver.org" + href: "https://listmonk.bnkserve.org" description: Newsletter & mailing list manager container: listmonk_app - NocoDB: icon: mdi-database - href: "https://db.bnkserver.org" + href: "https://db.bnkserve.org" description: No-code database platform container: changemakerlite-nocodb-1 - Map Server: icon: mdi-map - href: "https://map.bnkserver.org" + href: "https://map.bnkserve.org" description: Map server for geospatial data container: nocodb-map-viewer @@ -31,19 +31,19 @@ - Content & Documentation: - Main Site: icon: mdi-web - href: "https://bnkserver.org" + href: "https://bnkserve.org" description: CM-lite campaign website container: mkdocs-site-server-changemaker - MkDocs (Live): icon: mdi-book-open-page-variant - href: "https://docs.bnkserver.org" + href: "https://docs.bnkserve.org" description: Live documentation server with hot reload container: mkdocs-changemaker - Mini QR: icon: mdi-qrcode - href: "https://qr.bnkserver.org" + href: "https://qr.bnkserve.org" description: QR code generator container: mini-qr @@ -51,7 +51,7 @@ - Automation & Infrastructure: - n8n: icon: mdi-robot-industrial - href: "https://n8n.bnkserver.org" + href: "https://n8n.bnkserve.org" description: Workflow automation platform container: n8n-changemaker @@ -69,6 +69,6 @@ - Gitea: icon: mdi-git - href: "https://git.bnkserver.org" + href: "https://git.bnkserve.org" description: Git repository hosting container: gitea_changemaker \ No newline at end of file diff --git a/map/ADMIN_IMPLEMENTATION.md b/map/ADMIN_IMPLEMENTATION.md new file mode 100644 index 0000000..c612b3b --- /dev/null +++ b/map/ADMIN_IMPLEMENTATION.md @@ -0,0 +1,129 @@ +# Admin Panel Implementation Summary + +## Overview +Successfully implemented a complete admin panel with start location management feature for the NocoDB Map Viewer application. + +## Files Created/Modified + +### Backend Changes +- **server.js**: + - Added `SETTINGS_SHEET_ID` parsing + - Updated login endpoint to include admin status + - Updated auth check endpoint to return admin status + - Added `requireAdmin` middleware + - Added admin routes for start location management + - Added public config endpoint for start location + +### Frontend Changes +- **map.js**: + - Added `loadStartLocation()` function + - Updated initialization to load start location first + - Updated `displayUserInfo()` to show admin link for admin users + +### New Files Created +- **admin.html**: Admin panel interface with interactive map +- **admin.css**: Styling for the admin panel +- **admin.js**: JavaScript functionality for admin panel + +### Configuration +- **.env**: Added `NOCODB_SETTINGS_SHEET` environment variable +- **README.md**: Updated with admin panel documentation + +## Database Schema + +### Settings Table (New) +Required columns for NocoDB Settings table: +- `key` (Single Line Text): Setting identifier +- `title` (Single Line Text): Display name +- `Geo-Location` (Text): Format "latitude;longitude" +- `latitude` (Decimal): Precision 10, Scale 8 +- `longitude` (Decimal): Precision 11, Scale 8 +- `zoom` (Number): Map zoom level +- `category` (Single Select): "system_setting" +- `updated_by` (Single Line Text): Last updater email +- `updated_at` (DateTime): Last update time + +### Login Table (Existing - Updated) +Ensure the existing login table has: +- `Admin` (Checkbox): Admin privileges column + +## Features Implemented + +### Admin Authentication +- Admin status determined by `Admin` checkbox in login table +- Session-based authentication with admin flag +- Protected admin routes with `requireAdmin` middleware +- Automatic redirect to login for non-admin users + +### Start Location Management +- Interactive map interface for setting coordinates +- Manual coordinate input with validation +- "Use Current Map View" button for easy positioning +- Real-time map updates when coordinates change +- Draggable marker for precise positioning + +### Data Persistence +- Start location stored in NocoDB Settings table +- Same geographic data format as main locations table +- Automatic creation/update of settings records +- Audit trail with `updated_by` and `updated_at` fields + +### Cascading Fallback System +1. **Database** (highest priority): Admin-configured location +2. **Environment** (medium priority): .env file defaults +3. **Hardcoded** (lowest priority): Edmonton coordinates + +### User Experience +- All users automatically see admin-configured start location +- Admin users see ⚙️ Admin button in header +- Seamless navigation between main map and admin panel +- Real-time validation and feedback + +## API Endpoints + +### Admin Endpoints (require admin auth) +- `GET /admin.html` - Serve admin panel page +- `GET /api/admin/start-location` - Get start location with source info +- `POST /api/admin/start-location` - Save new start location + +### Public Endpoints +- `GET /api/config/start-location` - Get start location for all users + +## Security Features +- Admin-only access to configuration endpoints +- Input validation for coordinates and zoom levels +- Session-based authentication +- CSRF protection through proper HTTP methods +- HTML escaping to prevent XSS + +## Next Steps + +1. **Setup Database Tables**: + - Create the Settings table in NocoDB with required columns + - Ensure Login table has Admin checkbox column + +2. **Configure Environment**: + - Add `NOCODB_SETTINGS_SHEET` URL to .env file + +3. **Test Admin Functionality**: + - Login with admin user + - Access `/admin.html` + - Set start location and verify it appears for all users + +4. **Future Enhancements** (ready for implementation): + - Additional admin settings (map themes, marker styles, etc.) + - Bulk location management + - User management interface + - System monitoring dashboard + +## Benefits Achieved + +✅ **Centralized Control**: Admins can change default map view for all users +✅ **Persistent Storage**: Settings survive server restarts and deployments +✅ **User-Friendly Interface**: Interactive map for easy configuration +✅ **Data Consistency**: Uses same format as main location data +✅ **Security**: Proper authentication and authorization +✅ **Scalability**: Easy to extend with additional admin features +✅ **Reliability**: Multiple fallback options ensure map always loads + +The implementation provides a robust foundation for administrative control while maintaining the existing user experience and security standards. diff --git a/map/README.md b/map/README.md index 37521ea..a924dc0 100644 --- a/map/README.md +++ b/map/README.md @@ -10,6 +10,9 @@ A containerized web application that visualizes geographic data from NocoDB on a - 🔄 Auto-refresh every 30 seconds - 📱 Responsive design for mobile devices - 🔒 Secure API proxy to protect credentials +- 👤 User authentication with login system +- ⚙️ Admin panel for system configuration +- 🎯 Configurable map start location - 🐳 Docker containerization for easy deployment - 🆓 100% open source (no proprietary dependencies) @@ -23,16 +26,29 @@ A containerized web application that visualizes geographic data from NocoDB on a ### NocoDB Table Setup -1. Create a table in NocoDB with these required columns: - - `geodata` (Text): Format "latitude;longitude" +1. **Main Locations Table** - Create a table with these required columns: + - `Geo-Location` (Text): Format "latitude;longitude" - `latitude` (Decimal): Precision 10, Scale 8 - `longitude` (Decimal): Precision 11, Scale 8 - -2. Optional recommended columns: - `title` (Text): Location name - - `description` (Long Text): Details - `category` (Single Select): Classification +2. **Login Table** - Create a table for user authentication: + - `Email` (Email): User email address + - `Name` (Single Line Text): User display name + - `Admin` (Checkbox): Admin privileges + +3. **Settings Table** - Create a table for admin configuration: + - `key` (Single Line Text): Setting identifier + - `title` (Single Line Text): Display name + - `Geo-Location` (Text): Format "latitude;longitude" + - `latitude` (Decimal): Precision 10, Scale 8 + - `longitude` (Decimal): Precision 11, Scale 8 + - `zoom` (Number): Map zoom level + - `category` (Single Select): "system_setting" + - `updated_by` (Single Line Text): Last updater email + - `updated_at` (DateTime): Last update time + ### Installation 1. Clone this repository or create the file structure as shown @@ -47,6 +63,8 @@ A containerized web application that visualizes geographic data from NocoDB on a NOCODB_API_URL=https://db.lindalindsay.org/api/v1 NOCODB_API_TOKEN=your-token-here NOCODB_VIEW_URL=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/mvtryxrvze6td79 + NOCODB_LOGIN_SHEET=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/login_sheet_id + NOCODB_SETTINGS_SHEET=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/settings_sheet_id ``` 4. Start the application: @@ -69,13 +87,45 @@ A containerized web application that visualizes geographic data from NocoDB on a ## API Endpoints -- `GET /api/locations` - Fetch all locations -- `POST /api/locations` - Create new location -- `GET /api/locations/:id` - Get single location -- `PUT /api/locations/:id` - Update location -- `DELETE /api/locations/:id` - Delete location +### Public Endpoints +- `GET /api/locations` - Fetch all locations (requires auth) +- `POST /api/locations` - Create new location (requires auth) +- `GET /api/locations/:id` - Get single location (requires auth) +- `PUT /api/locations/:id` - Update location (requires auth) +- `DELETE /api/locations/:id` - Delete location (requires auth) +- `GET /api/config/start-location` - Get map start location - `GET /health` - Health check +### Authentication Endpoints +- `POST /api/auth/login` - User login +- `GET /api/auth/check` - Check authentication status +- `POST /api/auth/logout` - User logout + +### Admin Endpoints (requires admin privileges) +- `GET /api/admin/start-location` - Get start location with source info +- `POST /api/admin/start-location` - Update map start location + +## Admin Panel + +Users with admin privileges can access the admin panel at `/admin.html` to configure system settings. + +### Features +- **Start Location Configuration**: Set the default map center and zoom level for all users +- **Interactive Map**: Visual interface for selecting coordinates +- **Real-time Preview**: See changes immediately on the admin map +- **Validation**: Built-in coordinate and zoom level validation + +### Access Control +- Admin access is controlled via the `Admin` checkbox in the Login table +- Only authenticated users with admin privileges can access `/admin.html` +- Admin status is checked on every request to admin endpoints + +### Start Location Priority +The system uses a cascading fallback system for map start location: +1. **Database**: Admin-configured location stored in Settings table (highest priority) +2. **Environment**: Default values from .env file (medium priority) +3. **Hardcoded**: Edmonton, Canada coordinates (lowest priority) + ## Configuration All configuration is done via environment variables: @@ -85,6 +135,8 @@ All configuration is done via environment variables: | `NOCODB_API_URL` | NocoDB API base URL | Required | | `NOCODB_API_TOKEN` | API authentication token | Required | | `NOCODB_VIEW_URL` | Full NocoDB view URL | Required | +| `NOCODB_LOGIN_SHEET` | Login table URL for authentication | Required | +| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Optional | | `PORT` | Server port | 3000 | | `DEFAULT_LAT` | Default map latitude | 53.5461 | | `DEFAULT_LNG` | Default map longitude | -113.4938 | diff --git a/map/app/public/admin.html b/map/app/public/admin.html new file mode 100644 index 0000000..b1b5b47 --- /dev/null +++ b/map/app/public/admin.html @@ -0,0 +1,94 @@ + + + + + + + Admin Panel - NocoDB Map Viewer + + + + + + + + + +
+ +
+

Admin Panel

+ +
+ + +
+
+

Settings

+ +
+ +
+ +
+

Map Start Location

+

Set the default center point and zoom level for the map when users first load the application.

+ +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+

💡 Tip: Navigate the map to your desired location and zoom level, then click "Use Current Map View" to capture the coordinates.

+
+
+
+
+
+
+ + +
+
+ + + + + + + + diff --git a/map/app/public/css/admin.css b/map/app/public/css/admin.css new file mode 100644 index 0000000..4b80991 --- /dev/null +++ b/map/app/public/css/admin.css @@ -0,0 +1,268 @@ +/* Admin Panel Specific Styles */ +.admin-container { + display: flex; + height: calc(100vh - var(--header-height)); + background-color: #f5f5f5; +} + +.admin-sidebar { + width: 250px; + background-color: white; + border-right: 1px solid #e0e0e0; + padding: 20px; +} + +.admin-sidebar h2 { + font-size: 18px; + margin-bottom: 20px; + color: var(--dark-color); +} + +.admin-nav { + display: flex; + flex-direction: column; + gap: 5px; +} + +.admin-nav a { + padding: 10px 15px; + color: var(--dark-color); + text-decoration: none; + border-radius: var(--border-radius); + transition: var(--transition); +} + +.admin-nav a:hover { + background-color: var(--light-color); +} + +.admin-nav a.active { + background-color: var(--primary-color); + color: white; +} + +.admin-content { + flex: 1; + padding: 30px; + overflow-y: auto; +} + +.admin-section { + background-color: white; + border-radius: var(--border-radius); + padding: 30px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.admin-section h2 { + margin-bottom: 15px; + color: var(--dark-color); +} + +.admin-section p { + color: #666; + margin-bottom: 25px; +} + +.admin-map-container { + display: grid; + grid-template-columns: 1fr 300px; + gap: 20px; + margin-top: 20px; +} + +.admin-map { + height: 500px; + border-radius: var(--border-radius); + border: 1px solid #ddd; +} + +.location-controls { + padding: 20px; + background-color: #f9f9f9; + border-radius: var(--border-radius); +} + +.location-controls .form-group { + margin-bottom: 15px; +} + +.location-controls .form-actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; +} + +.help-text { + margin-top: 20px; + padding: 15px; + background-color: #e3f2fd; + border-radius: var(--border-radius); + font-size: 14px; +} + +.help-text p { + margin: 0; + color: #1976d2; +} + +.admin-info { + display: flex; + align-items: center; + gap: 10px; + color: rgba(255,255,255,0.9); + font-size: 14px; +} + +/* Form styles */ +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: var(--dark-color); +} + +.form-group input { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: var(--border-radius); + font-size: 14px; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); +} + +/* Button styles */ +.btn { + padding: 8px 16px; + border: none; + border-radius: var(--border-radius); + font-size: 14px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 5px; + transition: all 0.2s; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: #45a049; + transform: translateY(-1px); +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; + transform: translateY(-1px); +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; +} + +/* Status messages */ +.status-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 10000; + max-width: 400px; +} + +.status-message { + padding: 12px 16px; + margin-bottom: 10px; + border-radius: var(--border-radius); + font-size: 14px; + animation: slideIn 0.3s ease-out; +} + +.status-message.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status-message.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.status-message.info { + background-color: #cce7ff; + color: #004085; + border: 1px solid #b3d7ff; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-container { + flex-direction: column; + } + + .admin-sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid #e0e0e0; + } + + .admin-map-container { + grid-template-columns: 1fr; + } + + .admin-map { + height: 300px; + } + + .admin-content { + padding: 15px; + } + + .admin-section { + padding: 20px; + } +} + +/* CSS Variables (define these in style.css if not already defined) */ +:root { + --primary-color: #4CAF50; + --dark-color: #333; + --light-color: #f5f5f5; + --border-radius: 6px; + --transition: all 0.2s ease; + --header-height: 60px; +} diff --git a/map/app/public/css/style.css b/map/app/public/css/style.css index 99f4755..7261a2d 100644 --- a/map/app/public/css/style.css +++ b/map/app/public/css/style.css @@ -549,6 +549,79 @@ body { border-top: 1px solid #eee; } +/* Distinctive start location marker styles */ +.start-location-custom-marker { + z-index: 2000 !important; +} + +.start-location-marker-wrapper { + position: relative; + width: 48px; + height: 48px; +} + +.start-location-marker-pin { + position: absolute; + width: 48px; + height: 48px; + background: #ff4444; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + border: 3px solid white; + animation: bounce-marker 2s ease-in-out infinite; +} + +.start-location-marker-inner { + transform: rotate(45deg); + width: 24px; + height: 24px; +} + +.start-location-marker-pulse { + position: absolute; + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(255, 68, 68, 0.3); + animation: pulse-ring 2s ease-out infinite; +} + +@keyframes bounce-marker { + 0%, 100% { + transform: rotate(-45deg) translateY(0); + } + 50% { + transform: rotate(-45deg) translateY(-5px); + } +} + +@keyframes pulse-ring { + 0% { + transform: scale(0.5); + opacity: 1; + } + 100% { + transform: scale(2); + opacity: 0; + } +} + +/* Enhanced popup for start location */ +.start-location-popup-enhanced .leaflet-popup-content-wrapper { + padding: 0; + overflow: hidden; + border: none; + box-shadow: 0 5px 20px rgba(0,0,0,0.3); +} + +.start-location-popup-enhanced .leaflet-popup-content { + margin: 0; +} + /* Responsive design */ @media (max-width: 768px) { .header h1 { diff --git a/map/app/public/index.html b/map/app/public/index.html index dba9c02..9a08870 100644 --- a/map/app/public/index.html +++ b/map/app/public/index.html @@ -36,6 +36,9 @@ + diff --git a/map/app/public/js/admin.js b/map/app/public/js/admin.js new file mode 100644 index 0000000..37b3095 --- /dev/null +++ b/map/app/public/js/admin.js @@ -0,0 +1,259 @@ +// Admin panel JavaScript +let adminMap = null; +let startMarker = null; + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + checkAdminAuth(); + initializeAdminMap(); + loadCurrentStartLocation(); + setupEventListeners(); +}); + +// Check if user is authenticated as admin +async function checkAdminAuth() { + try { + const response = await fetch('/api/auth/check'); + const data = await response.json(); + + if (!data.authenticated || !data.user?.isAdmin) { + window.location.href = '/login.html'; + return; + } + + // Display admin info + document.getElementById('admin-info').innerHTML = ` + 👤 ${escapeHtml(data.user.email)} + + `; + + document.getElementById('logout-btn').addEventListener('click', handleLogout); + + } catch (error) { + console.error('Auth check failed:', error); + window.location.href = '/login.html'; + } +} + +// Initialize the admin map +function initializeAdminMap() { + adminMap = L.map('admin-map').setView([53.5461, -113.4938], 11); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + minZoom: 2 + }).addTo(adminMap); + + // Add click handler to set location + adminMap.on('click', handleMapClick); + + // Update coordinates when map moves + adminMap.on('moveend', updateCoordinatesFromMap); +} + +// Load current start location +async function loadCurrentStartLocation() { + try { + const response = await fetch('/api/admin/start-location'); + const data = await response.json(); + + if (data.success) { + const { latitude, longitude, zoom } = data.location; + + // Update form fields + document.getElementById('start-lat').value = latitude; + document.getElementById('start-lng').value = longitude; + document.getElementById('start-zoom').value = zoom; + + // Update map + adminMap.setView([latitude, longitude], zoom); + updateStartMarker(latitude, longitude); + + // Show source info + if (data.source) { + const sourceText = data.source === 'database' ? 'Loaded from database' : + data.source === 'environment' ? 'Using environment defaults' : + 'Using system defaults'; + showStatus(sourceText, 'info'); + } + } + + } catch (error) { + console.error('Failed to load start location:', error); + showStatus('Failed to load current start location', 'error'); + } +} + +// Handle map click +function handleMapClick(e) { + const { lat, lng } = e.latlng; + + document.getElementById('start-lat').value = lat.toFixed(6); + document.getElementById('start-lng').value = lng.toFixed(6); + + updateStartMarker(lat, lng); +} + +// Update marker position +function updateStartMarker(lat, lng) { + if (startMarker) { + startMarker.setLatLng([lat, lng]); + } else { + startMarker = L.marker([lat, lng], { + draggable: true, + title: 'Start Location' + }).addTo(adminMap); + + // Update coordinates when marker is dragged + startMarker.on('dragend', (e) => { + const position = e.target.getLatLng(); + document.getElementById('start-lat').value = position.lat.toFixed(6); + document.getElementById('start-lng').value = position.lng.toFixed(6); + }); + } +} + +// Update coordinates from current map view +function updateCoordinatesFromMap() { + const center = adminMap.getCenter(); + const zoom = adminMap.getZoom(); + + document.getElementById('start-zoom').value = zoom; +} + +// Setup event listeners +function setupEventListeners() { + // Use current view button + document.getElementById('use-current-view').addEventListener('click', () => { + const center = adminMap.getCenter(); + const zoom = adminMap.getZoom(); + + document.getElementById('start-lat').value = center.lat.toFixed(6); + document.getElementById('start-lng').value = center.lng.toFixed(6); + document.getElementById('start-zoom').value = zoom; + + updateStartMarker(center.lat, center.lng); + showStatus('Captured current map view', 'success'); + }); + + // Save button + document.getElementById('save-start-location').addEventListener('click', saveStartLocation); + + // Coordinate input changes + document.getElementById('start-lat').addEventListener('change', updateMapFromInputs); + document.getElementById('start-lng').addEventListener('change', updateMapFromInputs); + document.getElementById('start-zoom').addEventListener('change', updateMapFromInputs); +} + +// Update map from input fields +function updateMapFromInputs() { + const lat = parseFloat(document.getElementById('start-lat').value); + const lng = parseFloat(document.getElementById('start-lng').value); + const zoom = parseInt(document.getElementById('start-zoom').value); + + if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) { + adminMap.setView([lat, lng], zoom); + updateStartMarker(lat, lng); + } +} + +// Save start location +async function saveStartLocation() { + const lat = parseFloat(document.getElementById('start-lat').value); + const lng = parseFloat(document.getElementById('start-lng').value); + const zoom = parseInt(document.getElementById('start-zoom').value); + + // Validate + if (isNaN(lat) || isNaN(lng) || isNaN(zoom)) { + showStatus('Please enter valid coordinates and zoom level', 'error'); + return; + } + + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) { + showStatus('Coordinates out of valid range', 'error'); + return; + } + + if (zoom < 2 || zoom > 19) { + showStatus('Zoom level must be between 2 and 19', 'error'); + return; + } + + try { + const response = await fetch('/api/admin/start-location', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + latitude: lat, + longitude: lng, + zoom: zoom + }) + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Start location saved successfully!', 'success'); + } else { + throw new Error(data.error || 'Failed to save'); + } + + } catch (error) { + console.error('Save error:', error); + showStatus(error.message || 'Failed to save start location', 'error'); + } +} + +// Handle logout +async function handleLogout() { + if (!confirm('Are you sure you want to logout?')) { + return; + } + + try { + const response = await fetch('/api/auth/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + window.location.href = '/login.html'; + } else { + showStatus('Logout failed. Please try again.', 'error'); + } + } catch (error) { + console.error('Logout error:', error); + showStatus('Logout failed. Please try again.', 'error'); + } +} + +// Show status message +function showStatus(message, type = 'info') { + const container = document.getElementById('status-container'); + + const messageDiv = document.createElement('div'); + messageDiv.className = `status-message ${type}`; + messageDiv.textContent = message; + + container.appendChild(messageDiv); + + // Auto-remove after 5 seconds + setTimeout(() => { + messageDiv.remove(); + }, 5000); +} + +// Escape HTML +function escapeHtml(text) { + if (text === null || text === undefined) { + return ''; + } + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; +} diff --git a/map/app/public/js/map.js b/map/app/public/js/map.js index 7460317..20efa3a 100644 --- a/map/app/public/js/map.js +++ b/map/app/public/js/map.js @@ -15,317 +15,173 @@ let userLocationMarker = null; let isAddingLocation = false; let refreshInterval = null; let currentEditingLocation = null; -let currentUser = null; // Add current user state +let currentUser = null; +let startLocationMarker = null; +let isStartLocationVisible = true; -// Initialize application when DOM is loaded +// Initialize the application document.addEventListener('DOMContentLoaded', () => { + console.log('DOM loaded, initializing application...'); + checkAuth(); initializeMap(); - checkAuthentication(); // Add authentication check loadLocations(); setupEventListeners(); - checkConfiguration(); - - // Set up auto-refresh - refreshInterval = setInterval(loadLocations, CONFIG.REFRESH_INTERVAL); - - // Add event delegation for dynamically created edit buttons - document.addEventListener('click', function(e) { - if (e.target.classList.contains('edit-location-btn')) { - const locationId = e.target.getAttribute('data-location-id'); - editLocation(locationId); - } - }); + setupAutoRefresh(); + hideLoading(); }); -// Initialize Leaflet map -function initializeMap() { - // Create map instance - map = L.map('map', { - center: [CONFIG.DEFAULT_LAT, CONFIG.DEFAULT_LNG], - zoom: CONFIG.DEFAULT_ZOOM, - zoomControl: true, - attributionControl: true - }); - - // Add OpenStreetMap tiles - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - maxZoom: CONFIG.MAX_ZOOM, - minZoom: CONFIG.MIN_ZOOM - }).addTo(map); - - // Add scale control - L.control.scale({ - position: 'bottomleft', - metric: true, - imperial: false - }).addTo(map); - - // Hide loading overlay - document.getElementById('loading').classList.add('hidden'); -} - -// Set up event listeners -function setupEventListeners() { - // Geolocation button - document.getElementById('geolocate-btn').addEventListener('click', handleGeolocation); - - // Add location button - document.getElementById('add-location-btn').addEventListener('click', toggleAddLocation); - - // Refresh button - document.getElementById('refresh-btn').addEventListener('click', () => { - showStatus('Refreshing locations...', 'info'); - loadLocations(); - }); - - // Fullscreen button - document.getElementById('fullscreen-btn').addEventListener('click', toggleFullscreen); - - // Form submission - document.getElementById('location-form').addEventListener('submit', handleLocationSubmit); - - // Edit form submission - document.getElementById('edit-location-form').addEventListener('submit', handleEditLocationSubmit); - - // Map click handler for adding locations - map.on('click', handleMapClick); - - // Set up geo field synchronization - setupGeoFieldSync(); - - // Add event listeners for buttons that were using inline onclick - document.getElementById('close-edit-footer-btn').addEventListener('click', closeEditFooter); - document.getElementById('lookup-address-edit-btn').addEventListener('click', lookupAddressForEdit); - document.getElementById('delete-location-btn').addEventListener('click', deleteLocation); - document.getElementById('close-modal-btn').addEventListener('click', closeModal); - document.getElementById('lookup-address-add-btn').addEventListener('click', lookupAddressForAdd); - document.getElementById('cancel-modal-btn').addEventListener('click', closeModal); -} - -// Helper function to get color based on support level -function getSupportColor(supportLevel) { - const level = parseInt(supportLevel); - switch(level) { - case 1: return '#27ae60'; // Green - Strong support - case 2: return '#f1c40f'; // Yellow - Moderate support - case 3: return '#e67e22'; // Orange - Low support - case 4: return '#e74c3c'; // Red - No support - default: return '#95a5a6'; // Grey - Unknown/null - } -} - -// Helper function to get support level text -function getSupportLevelText(level) { - const levelNum = parseInt(level); - switch(levelNum) { - case 1: return '1 - Strong Support'; - case 2: return '2 - Moderate Support'; - case 3: return '3 - Low Support'; - case 4: return '4 - No Support'; - default: return 'Not Specified'; - } -} - -// Set up geo field synchronization -function setupGeoFieldSync() { - const latInput = document.getElementById('location-lat'); - const lngInput = document.getElementById('location-lng'); - const geoLocationInput = document.getElementById('geo-location'); - - // Validate geo-location format - function validateGeoLocation(value) { - if (!value) return false; - - // Check both formats - const patterns = [ - /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/, // comma-separated - /^-?\d+\.?\d*\s*;\s*-?\d+\.?\d*$/ // semicolon-separated - ]; - - return patterns.some(pattern => pattern.test(value)); - } - - // When lat/lng change, update geo-location - function updateGeoLocation() { - const lat = parseFloat(latInput.value); - const lng = parseFloat(lngInput.value); - - if (!isNaN(lat) && !isNaN(lng)) { - geoLocationInput.value = `${lat};${lng}`; // Use semicolon format for NocoDB - geoLocationInput.classList.remove('invalid'); - geoLocationInput.classList.add('valid'); - } - } - - // When geo-location changes, parse and update lat/lng - function parseGeoLocation() { - const geoValue = geoLocationInput.value.trim(); - - if (!geoValue) { - geoLocationInput.classList.remove('valid', 'invalid'); - return; - } - - if (!validateGeoLocation(geoValue)) { - geoLocationInput.classList.add('invalid'); - geoLocationInput.classList.remove('valid'); - return; - } - - // Try semicolon-separated first - let parts = geoValue.split(';'); - if (parts.length === 2) { - const lat = parseFloat(parts[0].trim()); - const lng = parseFloat(parts[1].trim()); - if (!isNaN(lat) && !isNaN(lng)) { - latInput.value = lat.toFixed(8); - lngInput.value = lng.toFixed(8); - // Keep semicolon format for NocoDB GeoData - geoLocationInput.value = `${lat};${lng}`; - geoLocationInput.classList.add('valid'); - geoLocationInput.classList.remove('invalid'); - return; - } - } - - // Try comma-separated - parts = geoValue.split(','); - if (parts.length === 2) { - const lat = parseFloat(parts[0].trim()); - const lng = parseFloat(parts[1].trim()); - if (!isNaN(lat) && !isNaN(lng)) { - latInput.value = lat.toFixed(8); - lngInput.value = lng.toFixed(8); - // Normalize to semicolon format for NocoDB GeoData - geoLocationInput.value = `${lat};${lng}`; - geoLocationInput.classList.add('valid'); - geoLocationInput.classList.remove('invalid'); - } - } - } - - // Add event listeners - latInput.addEventListener('input', updateGeoLocation); - lngInput.addEventListener('input', updateGeoLocation); - geoLocationInput.addEventListener('blur', parseGeoLocation); - geoLocationInput.addEventListener('input', () => { - // Clear validation classes on input to allow real-time feedback - const geoValue = geoLocationInput.value.trim(); - if (geoValue && validateGeoLocation(geoValue)) { - geoLocationInput.classList.add('valid'); - geoLocationInput.classList.remove('invalid'); - } else if (geoValue) { - geoLocationInput.classList.add('invalid'); - geoLocationInput.classList.remove('valid'); - } else { - geoLocationInput.classList.remove('valid', 'invalid'); - } - }); -} - -// Check authentication and display user info -async function checkAuthentication() { +// Check authentication +async function checkAuth() { try { const response = await fetch('/api/auth/check'); const data = await response.json(); - if (data.authenticated && data.user) { - currentUser = data.user; - displayUserInfo(); - } - } catch (error) { - console.error('Failed to check authentication:', error); - } -} - -// Display user info in header -function displayUserInfo() { - const headerActions = document.querySelector('.header-actions'); - - // Create user info element - const userInfo = document.createElement('div'); - userInfo.className = 'user-info'; - userInfo.innerHTML = ` - ${escapeHtml(currentUser.email)} - - `; - - // Insert before the location count - const locationCount = document.getElementById('location-count'); - headerActions.insertBefore(userInfo, locationCount); - - // Add logout event listener - document.getElementById('logout-btn').addEventListener('click', handleLogout); -} - -// Handle logout -async function handleLogout() { - if (!confirm('Are you sure you want to logout?')) { - return; - } - - try { - const response = await fetch('/api/auth/logout', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (response.ok) { + if (!data.authenticated) { window.location.href = '/login.html'; - } else { - showStatus('Logout failed. Please try again.', 'error'); + return; } + + currentUser = data.user; + updateUserInterface(); + } catch (error) { - console.error('Logout error:', error); - showStatus('Logout failed. Please try again.', 'error'); + console.error('Auth check failed:', error); + window.location.href = '/login.html'; } } -// Check API configuration -async function checkConfiguration() { +// Update UI based on user +function updateUserInterface() { + if (!currentUser) return; + + // Add user info and admin link to header if admin + const headerActions = document.querySelector('.header-actions'); + if (currentUser.isAdmin && headerActions) { + const adminLink = document.createElement('a'); + adminLink.href = '/admin.html'; + adminLink.className = 'btn btn-secondary'; + adminLink.textContent = '⚙️ Admin'; + headerActions.insertBefore(adminLink, headerActions.firstChild); + } +} + +// Initialize the map +async function initializeMap() { try { - const response = await fetch('/api/config-check'); + // Get start location from server + const response = await fetch('/api/admin/start-location'); const data = await response.json(); - if (!data.configured) { - showStatus('Warning: API not fully configured. Check your .env file.', 'warning'); + let startLat = CONFIG.DEFAULT_LAT; + let startLng = CONFIG.DEFAULT_LNG; + let startZoom = CONFIG.DEFAULT_ZOOM; + + if (data.success && data.location) { + startLat = data.location.latitude; + startLng = data.location.longitude; + startZoom = data.location.zoom; } + + // Initialize map + map = L.map('map').setView([startLat, startLng], startZoom); + + // Add tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: CONFIG.MAX_ZOOM, + minZoom: CONFIG.MIN_ZOOM + }).addTo(map); + + // Add start location marker + addStartLocationMarker(startLat, startLng); + + console.log('Map initialized successfully'); + } catch (error) { - console.error('Configuration check failed:', error); + console.error('Failed to initialize map:', error); + showStatus('Failed to initialize map', 'error'); } } -// Load locations from API +// Add start location marker function +function addStartLocationMarker(lat, lng) { + console.log(`Adding start location marker at: ${lat}, ${lng}`); + + // Remove existing start location marker if it exists + if (startLocationMarker) { + map.removeLayer(startLocationMarker); + } + + // Create a very distinctive custom icon + const startIcon = L.divIcon({ + html: ` +
+
+
+ + + +
+
+
+
+ `, + className: 'start-location-custom-marker', + iconSize: [48, 48], + iconAnchor: [24, 48], + popupAnchor: [0, -48] + }); + + // Create the marker + startLocationMarker = L.marker([lat, lng], { + icon: startIcon, + zIndexOffset: 1000 + }).addTo(map); + + // Add popup + startLocationMarker.bindPopup(` + + `); +} + +// Toggle start location visibility +function toggleStartLocationVisibility() { + if (!startLocationMarker) return; + + isStartLocationVisible = !isStartLocationVisible; + + if (isStartLocationVisible) { + map.addLayer(startLocationMarker); + document.querySelector('#toggle-start-location-btn .btn-text').textContent = 'Hide Start Location'; + } else { + map.removeLayer(startLocationMarker); + document.querySelector('#toggle-start-location-btn .btn-text').textContent = 'Show Start Location'; + } +} + +// Load locations from the API async function loadLocations() { try { const response = await fetch('/api/locations'); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); if (data.success) { displayLocations(data.locations); - updateLocationCount(data.count); + updateLocationCount(data.locations.length); } else { throw new Error(data.error || 'Failed to load locations'); } - } catch (error) { console.error('Error loading locations:', error); - showStatus('Failed to load locations. Check your connection.', 'error'); - updateLocationCount(0); + showStatus('Failed to load locations', 'error'); } } -// Display locations on map +// Display locations on the map function displayLocations(locations) { // Clear existing markers markers.forEach(marker => map.removeLayer(marker)); @@ -339,135 +195,196 @@ function displayLocations(locations) { } }); - // Fit map to show all markers if there are any - if (markers.length > 0) { - const group = new L.featureGroup(markers); - map.fitBounds(group.getBounds().pad(0.1)); - } + console.log(`Displayed ${markers.length} locations`); } -// Create marker for location (updated to use circle markers) +// Create a location marker function createLocationMarker(location) { - console.log('Creating marker for location:', location); + const lat = parseFloat(location.latitude); + const lng = parseFloat(location.longitude); - // Get color based on support level - const supportColor = getSupportColor(location['Support Level']); + // Determine marker color based on support level + let markerColor = 'blue'; + if (location['Support Level']) { + const level = parseInt(location['Support Level']); + switch(level) { + case 1: markerColor = 'green'; break; + case 2: markerColor = 'yellow'; break; + case 3: markerColor = 'orange'; break; + case 4: markerColor = 'red'; break; + } + } - // Create circle marker instead of default marker - const marker = L.circleMarker([location.latitude, location.longitude], { + const marker = L.circleMarker([lat, lng], { radius: 8, - fillColor: supportColor, + fillColor: markerColor, color: '#fff', weight: 2, opacity: 1, - fillOpacity: 0.8, - title: location.title || 'Location', - riseOnHover: true, - locationData: location // Store location data in marker options + fillOpacity: 0.8 }).addTo(map); - // Add larger radius on hover - marker.on('mouseover', function() { - this.setRadius(10); - }); - - marker.on('mouseout', function() { - this.setRadius(8); - }); - // Create popup content const popupContent = createPopupContent(location); marker.bindPopup(popupContent); + // Add click handler for editing + marker.on('click', () => { + if (currentUser) { + setTimeout(() => openEditForm(location), 100); + } + }); + return marker; } -// Create popup content for marker +// Create popup content function createPopupContent(location) { - console.log('Creating popup for location:', location); + const name = [location['First Name'], location['Last Name']] + .filter(Boolean).join(' ') || 'Unknown'; + const address = location.Address || 'No address'; + const supportLevel = location['Support Level'] ? + `Level ${location['Support Level']}` : 'Not specified'; - let content = ''; - - return content; + return ` + + `; } -// Handle geolocation -function handleGeolocation() { +// Setup event listeners +function setupEventListeners() { + // Refresh button + document.getElementById('refresh-btn')?.addEventListener('click', () => { + loadLocations(); + showStatus('Locations refreshed', 'success'); + }); + + // Geolocate button + document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation); + + // Toggle start location button + document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility); + + // Add location button + document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode); + + // Fullscreen button + document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen); + + // Modal controls + document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal); + document.getElementById('cancel-modal-btn')?.addEventListener('click', closeAddModal); + + // Edit footer controls + document.getElementById('close-edit-footer-btn')?.addEventListener('click', closeEditForm); + + // Forms + document.getElementById('location-form')?.addEventListener('submit', handleAddLocation); + document.getElementById('edit-location-form')?.addEventListener('submit', handleEditLocation); + + // Delete button + document.getElementById('delete-location-btn')?.addEventListener('click', handleDeleteLocation); + + // Address lookup buttons + document.getElementById('lookup-address-add-btn')?.addEventListener('click', () => { + lookupAddress('add'); + }); + + document.getElementById('lookup-address-edit-btn')?.addEventListener('click', () => { + lookupAddress('edit'); + }); + + // Geo-location field sync + setupGeoLocationSync(); +} + +// Setup geo-location field synchronization +function setupGeoLocationSync() { + // For add form + const addLatInput = document.getElementById('location-lat'); + const addLngInput = document.getElementById('location-lng'); + const addGeoInput = document.getElementById('geo-location'); + + if (addLatInput && addLngInput && addGeoInput) { + [addLatInput, addLngInput].forEach(input => { + input.addEventListener('input', () => { + const lat = addLatInput.value; + const lng = addLngInput.value; + if (lat && lng) { + addGeoInput.value = `${lat};${lng}`; + } + }); + }); + + addGeoInput.addEventListener('input', () => { + const coords = parseGeoLocation(addGeoInput.value); + if (coords) { + addLatInput.value = coords.lat; + addLngInput.value = coords.lng; + } + }); + } + + // For edit form + const editLatInput = document.getElementById('edit-location-lat'); + const editLngInput = document.getElementById('edit-location-lng'); + const editGeoInput = document.getElementById('edit-geo-location'); + + if (editLatInput && editLngInput && editGeoInput) { + [editLatInput, editLngInput].forEach(input => { + input.addEventListener('input', () => { + const lat = editLatInput.value; + const lng = editLngInput.value; + if (lat && lng) { + editGeoInput.value = `${lat};${lng}`; + } + }); + }); + + editGeoInput.addEventListener('input', () => { + const coords = parseGeoLocation(editGeoInput.value); + if (coords) { + editLatInput.value = coords.lat; + editLngInput.value = coords.lng; + } + }); + } +} + +// Parse geo-location string +function parseGeoLocation(value) { + if (!value) return null; + + // Try semicolon separator first + let parts = value.split(';'); + if (parts.length !== 2) { + // Try comma separator + parts = value.split(','); + } + + if (parts.length === 2) { + const lat = parseFloat(parts[0].trim()); + const lng = parseFloat(parts[1].trim()); + + if (!isNaN(lat) && !isNaN(lng)) { + return { lat, lng }; + } + } + + return null; +} + +// Get user location +function getUserLocation() { if (!navigator.geolocation) { showStatus('Geolocation is not supported by your browser', 'error'); return; @@ -477,42 +394,33 @@ function handleGeolocation() { navigator.geolocation.getCurrentPosition( (position) => { - const { latitude, longitude, accuracy } = position.coords; + const lat = position.coords.latitude; + const lng = position.coords.longitude; // Center map on user location - map.setView([latitude, longitude], 15); + map.setView([lat, lng], 15); - // Remove existing user marker + // Add or update user location marker if (userLocationMarker) { - map.removeLayer(userLocationMarker); + userLocationMarker.setLatLng([lat, lng]); + } else { + userLocationMarker = L.circleMarker([lat, lng], { + radius: 10, + fillColor: '#2196F3', + color: '#fff', + weight: 3, + opacity: 1, + fillOpacity: 0.8 + }).addTo(map); + + userLocationMarker.bindPopup('Your Location'); } - // Add user location marker - userLocationMarker = L.marker([latitude, longitude], { - icon: L.divIcon({ - html: '
', - className: 'user-location-marker', - iconSize: [20, 20], - iconAnchor: [10, 10] - }), - title: 'Your location' - }).addTo(map); - - // Add accuracy circle - L.circle([latitude, longitude], { - radius: accuracy, - color: '#2c5aa0', - fillColor: '#2c5aa0', - fillOpacity: 0.1, - weight: 1 - }).addTo(map); - - showStatus(`Location found (±${Math.round(accuracy)}m accuracy)`, 'success'); + showStatus('Location found!', 'success'); }, (error) => { let message = 'Unable to get your location'; - - switch (error.code) { + switch(error.code) { case error.PERMISSION_DENIED: message = 'Location permission denied'; break; @@ -523,7 +431,6 @@ function handleGeolocation() { message = 'Location request timed out'; break; } - showStatus(message, 'error'); }, { @@ -535,324 +442,84 @@ function handleGeolocation() { } // Toggle add location mode -function toggleAddLocation() { +function toggleAddLocationMode() { isAddingLocation = !isAddingLocation; - const btn = document.getElementById('add-location-btn'); const crosshair = document.getElementById('crosshair'); + const addBtn = document.getElementById('add-location-btn'); if (isAddingLocation) { - btn.innerHTML = 'Cancel'; - btn.classList.remove('btn-success'); - btn.classList.add('btn-secondary'); crosshair.classList.remove('hidden'); - map.getContainer().style.cursor = 'crosshair'; + addBtn.classList.add('active'); + addBtn.innerHTML = 'Cancel'; + map.on('click', handleMapClick); } else { - btn.innerHTML = 'Add Location Here'; - btn.classList.remove('btn-secondary'); - btn.classList.add('btn-success'); crosshair.classList.add('hidden'); - map.getContainer().style.cursor = ''; + addBtn.classList.remove('active'); + addBtn.innerHTML = 'Add Location Here'; + map.off('click', handleMapClick); } } -// Handle map click +// Handle map click in add mode function handleMapClick(e) { if (!isAddingLocation) return; const { lat, lng } = e.latlng; - - // Toggle off add location mode - toggleAddLocation(); - - // Show modal with coordinates - showAddLocationModal(lat, lng); + openAddModal(lat, lng); + toggleAddLocationMode(); } -// Show add location modal -function showAddLocationModal(lat, lng) { +// Open add location modal +function openAddModal(lat, lng) { const modal = document.getElementById('add-modal'); const latInput = document.getElementById('location-lat'); const lngInput = document.getElementById('location-lng'); - const geoLocationInput = document.getElementById('geo-location'); + const geoInput = document.getElementById('geo-location'); // Set coordinates latInput.value = lat.toFixed(8); lngInput.value = lng.toFixed(8); - - // Set geo-location field - geoLocationInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`; // Use semicolon format for NocoDB - geoLocationInput.classList.add('valid'); - geoLocationInput.classList.remove('invalid'); + geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`; // Clear other fields - document.getElementById('first-name').value = ''; - document.getElementById('last-name').value = ''; - document.getElementById('location-email').value = ''; - document.getElementById('location-unit').value = ''; - document.getElementById('support-level').value = ''; - const addressInput = document.getElementById('location-address'); - addressInput.value = 'Looking up address...'; // Show loading message - document.getElementById('sign').checked = false; - document.getElementById('sign-size').value = ''; + document.getElementById('location-form').reset(); + latInput.value = lat.toFixed(8); + lngInput.value = lng.toFixed(8); + geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`; // Show modal modal.classList.remove('hidden'); +} + +// Close add modal +function closeAddModal() { + const modal = document.getElementById('add-modal'); + modal.classList.add('hidden'); + document.getElementById('location-form').reset(); +} + +// Handle add location form submission +async function handleAddLocation(e) { + e.preventDefault(); - // Fetch address asynchronously - reverseGeocode(lat, lng).then(result => { - if (result) { - addressInput.value = result.formattedAddress || result.fullAddress; - } else { - addressInput.value = ''; // Clear if lookup fails - // Don't show warning for automatic lookups + const formData = new FormData(e.target); + const data = {}; + + // Convert form data to object + for (let [key, value] of formData.entries()) { + if (value.trim() !== '') { + data[key] = value.trim(); } - }).catch(error => { - console.error('Address lookup failed:', error); - addressInput.value = ''; - }); - - // Focus on first name input - setTimeout(() => { - document.getElementById('first-name').focus(); - }, 100); -} - -// Close modal -function closeModal() { - document.getElementById('add-modal').classList.add('hidden'); -} - -// Edit location function -function editLocation(locationId) { - // Find the location in markers data - const location = markers.find(m => { - const data = m.options.locationData; - return String(data.id || data.Id) === String(locationId); - })?.options.locationData; - - if (!location) { - console.error('Location not found for ID:', locationId); - console.log('Available locations:', markers.map(m => ({ - id: m.options.locationData.id || m.options.locationData.Id, - name: m.options.locationData['First Name'] + ' ' + m.options.locationData['Last Name'] - }))); - showStatus('Location not found', 'error'); - return; } - currentEditingLocation = location; - - // Populate all the edit form fields - document.getElementById('edit-location-id').value = location.id || location.Id || ''; - document.getElementById('edit-first-name').value = location['First Name'] || ''; - document.getElementById('edit-last-name').value = location['Last Name'] || ''; - document.getElementById('edit-location-email').value = location['Email'] || ''; - document.getElementById('edit-location-unit').value = location['Unit Number'] || ''; - document.getElementById('edit-support-level').value = location['Support Level'] || ''; - - const addressInput = document.getElementById('edit-location-address'); - addressInput.value = location['Address'] || ''; - - // If no address exists, try to fetch it - if (!location['Address'] && location.latitude && location.longitude) { - addressInput.value = 'Looking up address...'; - reverseGeocode(location.latitude, location.longitude).then(result => { - if (result && !location['Address']) { - addressInput.value = result.formattedAddress || result.fullAddress; - } else if (!location['Address']) { - addressInput.value = ''; - // Don't show error - just silently fail - } - }).catch(error => { - // Handle any unexpected errors - console.error('Address lookup failed:', error); - addressInput.value = ''; - }); + // Ensure geo-location is set + if (data.latitude && data.longitude) { + data['Geo-Location'] = `${data.latitude};${data.longitude}`; } // Handle checkbox - document.getElementById('edit-sign').checked = location['Sign'] === true || location['Sign'] === 'true' || location['Sign'] === 1; - document.getElementById('edit-sign-size').value = location['Sign Size'] || ''; - - document.getElementById('edit-location-lat').value = location.latitude || ''; - document.getElementById('edit-location-lng').value = location.longitude || ''; - document.getElementById('edit-geo-location').value = location['Geo-Location'] || `${location.latitude};${location.longitude}`; - - // Show the edit footer - document.getElementById('edit-footer').classList.remove('hidden'); - document.getElementById('map-container').classList.add('edit-mode'); - - // Invalidate map size after showing footer - setTimeout(() => map.invalidateSize(), 300); - - // Setup geo field sync for edit form - setupEditGeoFieldSync(); -} - -// Close edit footer -function closeEditFooter() { - document.getElementById('edit-footer').classList.add('hidden'); - document.getElementById('map-container').classList.remove('edit-mode'); - currentEditingLocation = null; - - // Invalidate map size after hiding footer - setTimeout(() => map.invalidateSize(), 300); -} - -// Setup geo field sync for edit form -function setupEditGeoFieldSync() { - const latInput = document.getElementById('edit-location-lat'); - const lngInput = document.getElementById('edit-location-lng'); - const geoLocationInput = document.getElementById('edit-geo-location'); - - // Similar to setupGeoFieldSync but for edit form - function updateGeoLocation() { - const lat = parseFloat(latInput.value); - const lng = parseFloat(lngInput.value); - - if (!isNaN(lat) && !isNaN(lng)) { - geoLocationInput.value = `${lat};${lng}`; - geoLocationInput.classList.remove('invalid'); - geoLocationInput.classList.add('valid'); - } - } - - function parseGeoLocation() { - const geoValue = geoLocationInput.value.trim(); - - if (!geoValue) { - geoLocationInput.classList.remove('valid', 'invalid'); - return; - } - - // Try semicolon-separated first - let parts = geoValue.split(';'); - if (parts.length === 2) { - const lat = parseFloat(parts[0].trim()); - const lng = parseFloat(parts[1].trim()); - if (!isNaN(lat) && !isNaN(lng)) { - latInput.value = lat.toFixed(8); - lngInput.value = lng.toFixed(8); - geoLocationInput.classList.add('valid'); - geoLocationInput.classList.remove('invalid'); - return; - } - } - - // Try comma-separated - parts = geoValue.split(','); - if (parts.length === 2) { - const lat = parseFloat(parts[0].trim()); - const lng = parseFloat(parts[1].trim()); - if (!isNaN(lat) && !isNaN(lng)) { - latInput.value = lat.toFixed(8); - lngInput.value = lng.toFixed(8); - geoLocationInput.value = `${lat};${lng}`; - geoLocationInput.classList.add('valid'); - geoLocationInput.classList.remove('invalid'); - } - } - } - - latInput.addEventListener('input', updateGeoLocation); - lngInput.addEventListener('input', updateGeoLocation); - geoLocationInput.addEventListener('blur', parseGeoLocation); -} - -// Handle edit form submission -async function handleEditLocationSubmit(e) { - e.preventDefault(); - - const formData = new FormData(e.target); - const data = Object.fromEntries(formData); - const locationId = data.id; - - // Ensure Geo-Location field is included - const geoLocationInput = document.getElementById('edit-geo-location'); - if (geoLocationInput.value) { - data['Geo-Location'] = geoLocationInput.value; - } - - try { - const response = await fetch(`/api/locations/${locationId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - const result = await response.json(); - - if (response.ok && result.success) { - showStatus('Location updated successfully!', 'success'); - closeEditFooter(); - - // Reload locations - loadLocations(); - } else { - throw new Error(result.error || 'Failed to update location'); - } - - } catch (error) { - console.error('Error updating location:', error); - showStatus(error.message, 'error'); - } -} - -// Delete location -async function deleteLocation() { - if (!currentEditingLocation) return; - - const locationId = currentEditingLocation.id || currentEditingLocation.Id; - - if (!confirm('Are you sure you want to delete this location?')) { - return; - } - - try { - const response = await fetch(`/api/locations/${locationId}`, { - method: 'DELETE' - }); - - const result = await response.json(); - - if (response.ok && result.success) { - showStatus('Location deleted successfully!', 'success'); - closeEditFooter(); - - // Reload locations - loadLocations(); - } else { - throw new Error(result.error || 'Failed to delete location'); - } - - } catch (error) { - console.error('Error deleting location:', error); - showStatus(error.message, 'error'); - } -} - -// Handle location form submission -async function handleLocationSubmit(e) { - e.preventDefault(); - - const formData = new FormData(e.target); - const data = Object.fromEntries(formData); - - // Validate required fields - either first name or last name should be provided - if ((!data['First Name'] || !data['First Name'].trim()) && - (!data['Last Name'] || !data['Last Name'].trim())) { - showStatus('Either First Name or Last Name is required', 'error'); - return; - } - - // Ensure Geo-Location field is included - const geoLocationInput = document.getElementById('geo-location'); - if (geoLocationInput.value) { - data['Geo-Location'] = geoLocationInput.value; - } + data.Sign = document.getElementById('sign').checked; try { const response = await fetch('/api/locations', { @@ -865,22 +532,170 @@ async function handleLocationSubmit(e) { const result = await response.json(); - if (response.ok && result.success) { + if (result.success) { showStatus('Location added successfully!', 'success'); - closeModal(); - - // Reload locations + closeAddModal(); loadLocations(); - - // Center map on new location - map.setView([data.latitude, data.longitude], map.getZoom()); } else { throw new Error(result.error || 'Failed to add location'); } - } catch (error) { console.error('Error adding location:', error); - showStatus(error.message, 'error'); + showStatus(error.message || 'Failed to add location', 'error'); + } +} + +// Open edit form +function openEditForm(location) { + currentEditingLocation = location; + + // Populate form fields + document.getElementById('edit-location-id').value = location.Id || ''; + document.getElementById('edit-first-name').value = location['First Name'] || ''; + document.getElementById('edit-last-name').value = location['Last Name'] || ''; + document.getElementById('edit-location-email').value = location.Email || ''; + document.getElementById('edit-location-phone').value = location.Phone || ''; + document.getElementById('edit-location-unit').value = location['Unit Number'] || ''; + document.getElementById('edit-support-level').value = location['Support Level'] || ''; + document.getElementById('edit-location-address').value = location.Address || ''; + document.getElementById('edit-sign').checked = location.Sign === true || location.Sign === 'true'; + document.getElementById('edit-sign-size').value = location['Sign Size'] || ''; + document.getElementById('edit-location-notes').value = location.Notes || ''; + document.getElementById('edit-location-lat').value = location.latitude || ''; + document.getElementById('edit-location-lng').value = location.longitude || ''; + document.getElementById('edit-geo-location').value = location['Geo-Location'] || ''; + + // Show edit footer + document.getElementById('edit-footer').classList.remove('hidden'); +} + +// Close edit form +function closeEditForm() { + document.getElementById('edit-footer').classList.add('hidden'); + currentEditingLocation = null; +} + +// Handle edit location form submission +async function handleEditLocation(e) { + e.preventDefault(); + + if (!currentEditingLocation) return; + + const formData = new FormData(e.target); + const data = { Id: currentEditingLocation.Id }; + + // Convert form data to object + for (let [key, value] of formData.entries()) { + if (value.trim() !== '') { + data[key] = value.trim(); + } + } + + // Ensure geo-location is set + if (data.latitude && data.longitude) { + data['Geo-Location'] = `${data.latitude};${data.longitude}`; + } + + // Handle checkbox + data.Sign = document.getElementById('edit-sign').checked; + + try { + const response = await fetch(`/api/locations/${currentEditingLocation.Id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + showStatus('Location updated successfully!', 'success'); + closeEditForm(); + loadLocations(); + } else { + throw new Error(result.error || 'Failed to update location'); + } + } catch (error) { + console.error('Error updating location:', error); + showStatus(error.message || 'Failed to update location', 'error'); + } +} + +// Handle delete location +async function handleDeleteLocation() { + if (!currentEditingLocation) return; + + if (!confirm('Are you sure you want to delete this location?')) { + return; + } + + try { + const response = await fetch(`/api/locations/${currentEditingLocation.Id}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + showStatus('Location deleted successfully!', 'success'); + closeEditForm(); + loadLocations(); + } else { + throw new Error(result.error || 'Failed to delete location'); + } + } catch (error) { + console.error('Error deleting location:', error); + showStatus(error.message || 'Failed to delete location', 'error'); + } +} + +// Lookup address +async function lookupAddress(mode) { + const addressInput = mode === 'add' ? + document.getElementById('location-address') : + document.getElementById('edit-location-address'); + + const address = addressInput.value.trim(); + + if (!address) { + showStatus('Please enter an address to lookup', 'warning'); + return; + } + + try { + showStatus('Looking up address...', 'info'); + + const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`); + const results = await response.json(); + + if (results.length > 0) { + const result = results[0]; + const lat = parseFloat(result.lat); + const lng = parseFloat(result.lon); + + // Update form fields + if (mode === 'add') { + document.getElementById('location-lat').value = lat.toFixed(8); + document.getElementById('location-lng').value = lng.toFixed(8); + document.getElementById('geo-location').value = `${lat.toFixed(8)};${lng.toFixed(8)}`; + } else { + document.getElementById('edit-location-lat').value = lat.toFixed(8); + document.getElementById('edit-location-lng').value = lng.toFixed(8); + document.getElementById('edit-geo-location').value = `${lat.toFixed(8)};${lng.toFixed(8)}`; + } + + // Center map on location + map.setView([lat, lng], 16); + + showStatus('Address found!', 'success'); + } else { + showStatus('Address not found. Please try a different format.', 'warning'); + } + } catch (error) { + console.error('Address lookup error:', error); + showStatus('Failed to lookup address', 'error'); } } @@ -892,20 +707,15 @@ function toggleFullscreen() { if (!document.fullscreenElement) { app.requestFullscreen().then(() => { app.classList.add('fullscreen'); - btn.innerHTML = 'Exit Fullscreen'; - - // Invalidate map size after transition - setTimeout(() => map.invalidateSize(), 300); + btn.innerHTML = 'Exit Fullscreen'; }).catch(err => { + console.error('Error entering fullscreen:', err); showStatus('Unable to enter fullscreen', 'error'); }); } else { document.exitFullscreen().then(() => { app.classList.remove('fullscreen'); btn.innerHTML = 'Fullscreen'; - - // Invalidate map size after transition - setTimeout(() => map.invalidateSize(), 300); }); } } @@ -913,7 +723,16 @@ function toggleFullscreen() { // Update location count function updateLocationCount(count) { const countElement = document.getElementById('location-count'); - countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`; + if (countElement) { + countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`; + } +} + +// Setup auto-refresh +function setupAutoRefresh() { + refreshInterval = setInterval(() => { + loadLocations(); + }, CONFIG.REFRESH_INTERVAL); } // Show status message @@ -932,7 +751,15 @@ function showStatus(message, type = 'info') { }, 5000); } -// Escape HTML to prevent XSS +// Hide loading overlay +function hideLoading() { + const loading = document.getElementById('loading'); + if (loading) { + loading.classList.add('hidden'); + } +} + +// Escape HTML for security function escapeHtml(text) { if (text === null || text === undefined) { return ''; @@ -942,110 +769,9 @@ function escapeHtml(text) { return div.innerHTML; } -// Handle window resize -window.addEventListener('resize', () => { - map.invalidateSize(); -}); - // Clean up on page unload window.addEventListener('beforeunload', () => { if (refreshInterval) { clearInterval(refreshInterval); } }); - -// Reverse geocode to get address from coordinates -async function reverseGeocode(lat, lng) { - try { - const response = await fetch(`/api/geocode/reverse?lat=${lat}&lng=${lng}`); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Geocoding service unavailable'); - } - - const result = await response.json(); - - if (!result.success || !result.data) { - throw new Error('Geocoding failed'); - } - - return result.data; - - } catch (error) { - console.error('Reverse geocoding error:', error); - return null; - } -} - -// Add a new function for forward geocoding (address to coordinates) -async function forwardGeocode(address) { - try { - const response = await fetch(`/api/geocode/forward?address=${encodeURIComponent(address)}`); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Geocoding service unavailable'); - } - - const result = await response.json(); - - if (!result.success || !result.data) { - throw new Error('Geocoding failed'); - } - - return result.data; - - } catch (error) { - console.error('Forward geocoding error:', error); - return null; - } -} - -// Manual address lookup for add form -async function lookupAddressForAdd() { - const latInput = document.getElementById('location-lat'); - const lngInput = document.getElementById('location-lng'); - const addressInput = document.getElementById('location-address'); - - const lat = parseFloat(latInput.value); - const lng = parseFloat(lngInput.value); - - if (!isNaN(lat) && !isNaN(lng)) { - addressInput.value = 'Looking up address...'; - const result = await reverseGeocode(lat, lng); - if (result) { - addressInput.value = result.formattedAddress || result.fullAddress; - showStatus('Address found!', 'success'); - } else { - addressInput.value = ''; - showStatus('Could not find address for these coordinates', 'warning'); - } - } else { - showStatus('Please enter valid coordinates first', 'warning'); - } -} - -// Manual address lookup for edit form -async function lookupAddressForEdit() { - const latInput = document.getElementById('edit-location-lat'); - const lngInput = document.getElementById('edit-location-lng'); - const addressInput = document.getElementById('edit-location-address'); - - const lat = parseFloat(latInput.value); - const lng = parseFloat(lngInput.value); - - if (!isNaN(lat) && !isNaN(lng)) { - addressInput.value = 'Looking up address...'; - const result = await reverseGeocode(lat, lng); - if (result) { - addressInput.value = result.formattedAddress || result.fullAddress; - showStatus('Address found!', 'success'); - } else { - addressInput.value = ''; - showStatus('Could not find address for these coordinates', 'warning'); - } - } else { - showStatus('Please enter valid coordinates first', 'warning'); - } -} diff --git a/map/app/server.js b/map/app/server.js index dff2e8f..b94d033 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -43,9 +43,7 @@ function syncGeoFields(data) { const lat = parseFloat(data.latitude); const lng = parseFloat(data.longitude); if (!isNaN(lat) && !isNaN(lng)) { - data['Geo-Location'] = `${lat};${lng}`; // Use semicolon format for NocoDB GeoData - data.geodata = `${lat};${lng}`; // Also update geodata for compatibility - } + data['Geo-Location'] = `${lat};${lng}`; data.geodata = `${lat};${lng}`; } } // If we have Geo-Location but no lat/lng, parse it @@ -113,6 +111,25 @@ if (process.env.NOCODB_LOGIN_SHEET) { } } +// Auto-parse settings sheet ID if URL is provided +let SETTINGS_SHEET_ID = null; +if (process.env.NOCODB_SETTINGS_SHEET) { + // Check if it's a URL or just an ID + if (process.env.NOCODB_SETTINGS_SHEET.startsWith('http')) { + const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_SETTINGS_SHEET); + if (projectId && tableId) { + SETTINGS_SHEET_ID = tableId; + console.log(`Auto-parsed settings sheet ID from URL: ${SETTINGS_SHEET_ID}`); + } else { + console.error('Could not parse settings sheet URL'); + } + } else { + // Assume it's already just the ID + SETTINGS_SHEET_ID = process.env.NOCODB_SETTINGS_SHEET; + console.log(`Using settings sheet ID: ${SETTINGS_SHEET_ID}`); + } +} + // Configure logger const logger = winston.createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', @@ -153,10 +170,9 @@ const getCookieConfig = (req) => { // Only set domain and secure for production non-localhost access if (isProduction && !isLocalhost && process.env.COOKIE_DOMAIN) { // Check if the request is coming from a subdomain of COOKIE_DOMAIN - const cookieDomain = process.env.COOKIE_DOMAIN.replace(/^\./, ''); // Remove leading dot - if (host.includes(cookieDomain)) { + const cookieDomain = process.env.COOKIE_DOMAIN.replace(/^\./, ''); if (host.includes(cookieDomain)) { config.domain = process.env.COOKIE_DOMAIN; - config.secure = true; + config.secure = true; // Enable secure cookies for production } } @@ -182,7 +198,7 @@ app.use(helmet({ defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], - imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org"], + imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://unpkg.com"], connectSrc: ["'self'"] } } @@ -262,6 +278,19 @@ const requireAuth = (req, res, next) => { } }; +// Admin middleware +const requireAdmin = (req, res, next) => { + if (req.session && req.session.authenticated && req.session.isAdmin) { + next(); + } else { + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(403).json({ success: false, error: 'Admin access required' }); + } else { + res.redirect('/login.html'); + } + } +}; + // Serve login page without authentication app.get('/login.html', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'login.html')); @@ -330,10 +359,11 @@ app.post('/api/auth/login', authLimiter, async (req, res) => { ); if (authorizedUser) { - // Set session + // Set session including admin status req.session.authenticated = true; req.session.userEmail = email; req.session.userName = authorizedUser.Name || email; + req.session.isAdmin = authorizedUser.Admin === true || authorizedUser.Admin === 1; // Force session save before sending response req.session.save((err) => { @@ -345,14 +375,15 @@ app.post('/api/auth/login', authLimiter, async (req, res) => { }); } - logger.info(`User authenticated: ${email}`); + logger.info(`User authenticated: ${email}, Admin: ${req.session.isAdmin}`); res.json({ success: true, message: 'Login successful', user: { email: email, - name: req.session.userName + name: req.session.userName, + isAdmin: req.session.isAdmin } }); }); @@ -378,7 +409,8 @@ app.get('/api/auth/check', (req, res) => { authenticated: req.session?.authenticated || false, user: req.session?.authenticated ? { email: req.session.userEmail, - name: req.session.userName + name: req.session.userName, + isAdmin: req.session.isAdmin || false } : null }); }); @@ -399,7 +431,250 @@ app.post('/api/auth/logout', (req, res) => { }); }); -// Add this after the /api/auth/check route +// Admin routes +// Serve admin page (protected) +app.get('/admin.html', requireAdmin, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'admin.html')); +}); + +// Add admin API endpoint to update start location +app.post('/api/admin/start-location', requireAdmin, async (req, res) => { + try { + const { latitude, longitude, zoom } = req.body; + + // Validate input + if (!latitude || !longitude) { + return res.status(400).json({ + success: false, + error: 'Latitude and longitude are required' + }); + } + + const lat = parseFloat(latitude); + const lng = parseFloat(longitude); + const mapZoom = parseInt(zoom) || 11; + + if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { + return res.status(400).json({ + success: false, + error: 'Invalid coordinates' + }); + } + + if (!SETTINGS_SHEET_ID) { + return res.status(500).json({ + success: false, + error: 'Settings sheet not configured' + }); + } + + // Create a minimal setting record + const settingData = { + key: 'start_location', + title: 'Map Start Location', + 'Geo-Location': `${lat};${lng}`, + latitude: lat, + longitude: lng, + zoom: mapZoom, + category: 'system_setting' + }; + + const getUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + try { + // First, try to find existing setting + const searchResponse = await axios.get(getUrl, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + where: `(key,eq,start_location)` + } + }); + + const existingSettings = searchResponse.data.list || []; + + if (existingSettings.length > 0) { + // Update existing setting + const settingId = existingSettings[0].id || existingSettings[0].Id; + const updateUrl = `${getUrl}/${settingId}`; + + // Only include fields that exist in the table + const updateData = { + 'Geo-Location': `${lat};${lng}`, + latitude: lat, + longitude: lng, + zoom: mapZoom + }; + + await axios.patch(updateUrl, updateData, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + }); + + logger.info(`Admin ${req.session.userEmail} updated start location to: ${lat}, ${lng}, zoom: ${mapZoom}`); + } else { + // Create new setting + await axios.post(getUrl, settingData, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + }); + + logger.info(`Admin ${req.session.userEmail} created start location: ${lat}, ${lng}, zoom: ${mapZoom}`); + } + + res.json({ + success: true, + message: 'Start location saved successfully', + location: { latitude: lat, longitude: lng, zoom: mapZoom } + }); + + } catch (dbError) { + logger.error('Database error saving start location:', { + error: dbError.message, + response: dbError.response?.data, + status: dbError.response?.status + }); + + // Return more detailed error information + const errorMessage = dbError.response?.data?.message || dbError.message; + throw new Error(`Database error: ${errorMessage}`); + } + + } catch (error) { + logger.error('Error updating start location:', error); + res.status(500).json({ + success: false, + error: error.message || 'Failed to update start location' + }); + } +}); + +// Get current start location (admin) +app.get('/api/admin/start-location', requireAdmin, async (req, res) => { + try { + // First try to get from database + if (SETTINGS_SHEET_ID) { + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + where: `(key,eq,start_location)` + } + }); + + const settings = response.data.list || []; + + if (settings.length > 0) { + const setting = settings[0]; + + return res.json({ + success: true, + location: { + latitude: parseFloat(setting.latitude), + longitude: parseFloat(setting.longitude), + zoom: parseInt(setting.zoom) || 11 + }, + source: 'database' + }); + } + } + + // Fallback to environment variables + res.json({ + success: true, + location: { + latitude: 53.5461, + longitude: -113.4938, + zoom: 11 + }, + source: 'defaults' + }); + + } catch (error) { + logger.error('Error fetching start location:', error); + + // Return defaults on error + res.json({ + success: true, + location: { + latitude: 53.5461, + longitude: -113.4938, + zoom: 11 + }, + source: 'defaults' + }); + } +}); + +// Get start location for all users (public endpoint) +app.get('/api/config/start-location', async (req, res) => { + try { + // Try to get from database first + if (SETTINGS_SHEET_ID) { + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + logger.info(`Fetching start location from settings sheet: ${SETTINGS_SHEET_ID}`); + + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + where: `(key,eq,start_location)` + } + }); + + const settings = response.data.list || []; + + if (settings.length > 0) { + const setting = settings[0]; + const lat = parseFloat(setting.latitude); + const lng = parseFloat(setting.longitude); + const zoom = parseInt(setting.zoom) || 11; + + logger.info(`Start location loaded from database: ${lat}, ${lng}, zoom: ${zoom}`); + + return res.json({ + latitude: lat, + longitude: lng, + zoom: zoom + }); + } else { + logger.info('No start location found in database, using defaults'); + } + } else { + logger.info('Settings sheet not configured, using defaults'); + } + } catch (error) { + logger.error('Error fetching config start location:', error); + } + + // Return defaults + const defaultLat = parseFloat(process.env.DEFAULT_LAT) || 53.5461; + const defaultLng = parseFloat(process.env.DEFAULT_LNG) || -113.4938; + const defaultZoom = parseInt(process.env.DEFAULT_ZOOM) || 11; + + logger.info(`Using default start location: ${defaultLat}, ${defaultLng}, zoom: ${defaultZoom}`); + + res.json({ + latitude: defaultLat, + longitude: defaultLng, + zoom: defaultZoom + }); +}); + +// Debug session endpoint app.get('/api/debug/session', (req, res) => { res.json({ sessionID: req.sessionID, @@ -508,27 +783,6 @@ app.get('/api/locations', async (req, res) => { } } - // Try to parse from Geo-Location column (semicolon-separated first, then comma) - if (loc['Geo-Location'] && typeof loc['Geo-Location'] === 'string') { - // Try semicolon first (as we see in the data) - let parts = loc['Geo-Location'].split(';'); - if (parts.length === 2) { - loc.latitude = parseFloat(parts[0].trim()); - loc.longitude = parseFloat(parts[1].trim()); - if (!isNaN(loc.latitude) && !isNaN(loc.longitude)) { - return true; - } - } - - // Fallback to comma-separated - parts = loc['Geo-Location'].split(','); - if (parts.length === 2) { - loc.latitude = parseFloat(parts[0].trim()); - loc.longitude = parseFloat(parts[1].trim()); - return !isNaN(loc.latitude) && !isNaN(loc.longitude); - } - } - return false; }); @@ -711,8 +965,8 @@ app.put('/api/locations/:id', strictLimiter, async (req, res) => { // Sync geo fields updateData = syncGeoFields(updateData); - updateData.updated_at = new Date().toISOString(); - updateData.updated_by = req.session.userEmail; // Track who updated + updateData.last_updated_at = new Date().toISOString(); + updateData.last_updated_by = req.session.userEmail; // Track who updated const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; @@ -764,6 +1018,51 @@ app.delete('/api/locations/:id', strictLimiter, async (req, res) => { } }); +// Debug endpoint to check settings table structure +app.get('/api/debug/settings-table', requireAdmin, async (req, res) => { + try { + if (!SETTINGS_SHEET_ID) { + return res.json({ + error: 'Settings sheet not configured', + settingsSheetId: SETTINGS_SHEET_ID + }); + } + + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + // Try to get the table structure by fetching all records + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + limit: 1 + } + }); + + const records = response.data.list || []; + const sampleRecord = records.length > 0 ? records[0] : null; + + res.json({ + success: true, + settingsSheetId: SETTINGS_SHEET_ID, + tableUrl: url, + recordCount: response.data.pageInfo?.totalRows || 0, + sampleRecord: sampleRecord, + availableFields: sampleRecord ? Object.keys(sampleRecord) : [] + }); + + } catch (error) { + logger.error('Error checking settings table:', error); + res.json({ + error: error.message, + response: error.response?.data, + status: error.response?.status + }); + } +}); + // Error handling middleware app.use((err, req, res, next) => { logger.error('Unhandled error:', err); diff --git a/mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png b/mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png new file mode 100644 index 0000000..d605523 Binary files /dev/null and b/mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png differ diff --git a/mkdocs/docs/overrides/main.html b/mkdocs/docs/overrides/main.html index 0235017..01a3c7e 100644 --- a/mkdocs/docs/overrides/main.html +++ b/mkdocs/docs/overrides/main.html @@ -6,6 +6,6 @@ {% endblock %} {% block announce %} -Login +Login Changemaker Archive. Learn more {% endblock %} diff --git a/mkdocs/docs/test.md b/mkdocs/docs/test.md new file mode 100644 index 0000000..933f01f --- /dev/null +++ b/mkdocs/docs/test.md @@ -0,0 +1,4 @@ +# Test + +lololol + diff --git a/mkdocs/mkdocs.yml b/mkdocs/mkdocs.yml index 65c0861..85a4ea3 100644 --- a/mkdocs/mkdocs.yml +++ b/mkdocs/mkdocs.yml @@ -1,6 +1,6 @@ site_name: Changemaker Lite site_description: Build Power. Not Rent It. Own your digital infrastructure. -site_url: https://bnkserver.org +site_url: https://bnkserve.org site_author: Bunker Operations docs_dir: docs site_dir: site