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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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