maps updates
This commit is contained in:
parent
7e65665ad6
commit
949be0bc6a
@ -5,25 +5,25 @@
|
|||||||
|
|
||||||
- Code Server:
|
- Code Server:
|
||||||
icon: mdi-code-braces
|
icon: mdi-code-braces
|
||||||
href: "https://code.bnkserver.org"
|
href: "https://code.bnkserve.org"
|
||||||
description: VS Code in the browser - Platform Editor
|
description: VS Code in the browser - Platform Editor
|
||||||
container: code-server-changemaker
|
container: code-server-changemaker
|
||||||
|
|
||||||
- Listmonk:
|
- Listmonk:
|
||||||
icon: mdi-email-newsletter
|
icon: mdi-email-newsletter
|
||||||
href: "https://listmonk.bnkserver.org"
|
href: "https://listmonk.bnkserve.org"
|
||||||
description: Newsletter & mailing list manager
|
description: Newsletter & mailing list manager
|
||||||
container: listmonk_app
|
container: listmonk_app
|
||||||
|
|
||||||
- NocoDB:
|
- NocoDB:
|
||||||
icon: mdi-database
|
icon: mdi-database
|
||||||
href: "https://db.bnkserver.org"
|
href: "https://db.bnkserve.org"
|
||||||
description: No-code database platform
|
description: No-code database platform
|
||||||
container: changemakerlite-nocodb-1
|
container: changemakerlite-nocodb-1
|
||||||
|
|
||||||
- Map Server:
|
- Map Server:
|
||||||
icon: mdi-map
|
icon: mdi-map
|
||||||
href: "https://map.bnkserver.org"
|
href: "https://map.bnkserve.org"
|
||||||
description: Map server for geospatial data
|
description: Map server for geospatial data
|
||||||
container: nocodb-map-viewer
|
container: nocodb-map-viewer
|
||||||
|
|
||||||
@ -31,19 +31,19 @@
|
|||||||
- Content & Documentation:
|
- Content & Documentation:
|
||||||
- Main Site:
|
- Main Site:
|
||||||
icon: mdi-web
|
icon: mdi-web
|
||||||
href: "https://bnkserver.org"
|
href: "https://bnkserve.org"
|
||||||
description: CM-lite campaign website
|
description: CM-lite campaign website
|
||||||
container: mkdocs-site-server-changemaker
|
container: mkdocs-site-server-changemaker
|
||||||
|
|
||||||
- MkDocs (Live):
|
- MkDocs (Live):
|
||||||
icon: mdi-book-open-page-variant
|
icon: mdi-book-open-page-variant
|
||||||
href: "https://docs.bnkserver.org"
|
href: "https://docs.bnkserve.org"
|
||||||
description: Live documentation server with hot reload
|
description: Live documentation server with hot reload
|
||||||
container: mkdocs-changemaker
|
container: mkdocs-changemaker
|
||||||
|
|
||||||
- Mini QR:
|
- Mini QR:
|
||||||
icon: mdi-qrcode
|
icon: mdi-qrcode
|
||||||
href: "https://qr.bnkserver.org"
|
href: "https://qr.bnkserve.org"
|
||||||
description: QR code generator
|
description: QR code generator
|
||||||
container: mini-qr
|
container: mini-qr
|
||||||
|
|
||||||
@ -51,7 +51,7 @@
|
|||||||
- Automation & Infrastructure:
|
- Automation & Infrastructure:
|
||||||
- n8n:
|
- n8n:
|
||||||
icon: mdi-robot-industrial
|
icon: mdi-robot-industrial
|
||||||
href: "https://n8n.bnkserver.org"
|
href: "https://n8n.bnkserve.org"
|
||||||
description: Workflow automation platform
|
description: Workflow automation platform
|
||||||
container: n8n-changemaker
|
container: n8n-changemaker
|
||||||
|
|
||||||
@ -69,6 +69,6 @@
|
|||||||
|
|
||||||
- Gitea:
|
- Gitea:
|
||||||
icon: mdi-git
|
icon: mdi-git
|
||||||
href: "https://git.bnkserver.org"
|
href: "https://git.bnkserve.org"
|
||||||
description: Git repository hosting
|
description: Git repository hosting
|
||||||
container: gitea_changemaker
|
container: gitea_changemaker
|
||||||
129
map/ADMIN_IMPLEMENTATION.md
Normal file
129
map/ADMIN_IMPLEMENTATION.md
Normal file
@ -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.
|
||||||
@ -10,6 +10,9 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
- 🔄 Auto-refresh every 30 seconds
|
- 🔄 Auto-refresh every 30 seconds
|
||||||
- 📱 Responsive design for mobile devices
|
- 📱 Responsive design for mobile devices
|
||||||
- 🔒 Secure API proxy to protect credentials
|
- 🔒 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
|
- 🐳 Docker containerization for easy deployment
|
||||||
- 🆓 100% open source (no proprietary dependencies)
|
- 🆓 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
|
### NocoDB Table Setup
|
||||||
|
|
||||||
1. Create a table in NocoDB with these required columns:
|
1. **Main Locations Table** - Create a table with these required columns:
|
||||||
- `geodata` (Text): Format "latitude;longitude"
|
- `Geo-Location` (Text): Format "latitude;longitude"
|
||||||
- `latitude` (Decimal): Precision 10, Scale 8
|
- `latitude` (Decimal): Precision 10, Scale 8
|
||||||
- `longitude` (Decimal): Precision 11, Scale 8
|
- `longitude` (Decimal): Precision 11, Scale 8
|
||||||
|
|
||||||
2. Optional recommended columns:
|
|
||||||
- `title` (Text): Location name
|
- `title` (Text): Location name
|
||||||
- `description` (Long Text): Details
|
|
||||||
- `category` (Single Select): Classification
|
- `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
|
### Installation
|
||||||
|
|
||||||
1. Clone this repository or create the file structure as shown
|
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_URL=https://db.lindalindsay.org/api/v1
|
||||||
NOCODB_API_TOKEN=your-token-here
|
NOCODB_API_TOKEN=your-token-here
|
||||||
NOCODB_VIEW_URL=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/mvtryxrvze6td79
|
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:
|
4. Start the application:
|
||||||
@ -69,13 +87,45 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
- `GET /api/locations` - Fetch all locations
|
### Public Endpoints
|
||||||
- `POST /api/locations` - Create new location
|
- `GET /api/locations` - Fetch all locations (requires auth)
|
||||||
- `GET /api/locations/:id` - Get single location
|
- `POST /api/locations` - Create new location (requires auth)
|
||||||
- `PUT /api/locations/:id` - Update location
|
- `GET /api/locations/:id` - Get single location (requires auth)
|
||||||
- `DELETE /api/locations/:id` - Delete location
|
- `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
|
- `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
|
## Configuration
|
||||||
|
|
||||||
All configuration is done via environment variables:
|
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_URL` | NocoDB API base URL | Required |
|
||||||
| `NOCODB_API_TOKEN` | API authentication token | Required |
|
| `NOCODB_API_TOKEN` | API authentication token | Required |
|
||||||
| `NOCODB_VIEW_URL` | Full NocoDB view URL | 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 |
|
| `PORT` | Server port | 3000 |
|
||||||
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
|
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
|
||||||
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
|
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
|
||||||
|
|||||||
94
map/app/public/admin.html
Normal file
94
map/app/public/admin.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Admin Panel - NocoDB Map Viewer">
|
||||||
|
<title>Admin Panel - NocoDB Map Viewer</title>
|
||||||
|
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin="" />
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="/" class="btn btn-secondary">← Back to Map</a>
|
||||||
|
<span id="admin-info" class="admin-info"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="admin-container">
|
||||||
|
<div class="admin-sidebar">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a href="#start-location" class="active">Start Location</a>
|
||||||
|
<!-- Future menu items can go here -->
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-content">
|
||||||
|
<!-- Start Location Section -->
|
||||||
|
<section id="start-location" class="admin-section">
|
||||||
|
<h2>Map Start Location</h2>
|
||||||
|
<p>Set the default center point and zoom level for the map when users first load the application.</p>
|
||||||
|
|
||||||
|
<div class="admin-map-container">
|
||||||
|
<div id="admin-map" class="admin-map"></div>
|
||||||
|
|
||||||
|
<div class="location-controls">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start-lat">Latitude</label>
|
||||||
|
<input type="number" id="start-lat" step="0.000001" min="-90" max="90">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start-lng">Longitude</label>
|
||||||
|
<input type="number" id="start-lng" step="0.000001" min="-180" max="180">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start-zoom">Zoom Level</label>
|
||||||
|
<input type="number" id="start-zoom" min="2" max="19" step="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="use-current-view" class="btn btn-secondary">
|
||||||
|
Use Current Map View
|
||||||
|
</button>
|
||||||
|
<button id="save-start-location" class="btn btn-primary">
|
||||||
|
Save Start Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-text">
|
||||||
|
<p>💡 Tip: Navigate the map to your desired location and zoom level, then click "Use Current Map View" to capture the coordinates.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Messages -->
|
||||||
|
<div id="status-container" class="status-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaflet JS -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
|
crossorigin=""></script>
|
||||||
|
|
||||||
|
<!-- Admin JavaScript -->
|
||||||
|
<script src="js/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
268
map/app/public/css/admin.css
Normal file
268
map/app/public/css/admin.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -549,6 +549,79 @@ body {
|
|||||||
border-top: 1px solid #eee;
|
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 */
|
/* Responsive design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header h1 {
|
.header h1 {
|
||||||
|
|||||||
@ -36,6 +36,9 @@
|
|||||||
<button id="geolocate-btn" class="btn btn-primary" title="Find my location">
|
<button id="geolocate-btn" class="btn btn-primary" title="Find my location">
|
||||||
<span class="btn-icon">📍</span><span class="btn-text">My Location</span>
|
<span class="btn-icon">📍</span><span class="btn-text">My Location</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="toggle-start-location-btn" class="btn btn-secondary" title="Toggle start location marker">
|
||||||
|
<span class="btn-icon">📍</span><span class="btn-text">Hide Start Location</span>
|
||||||
|
</button>
|
||||||
<button id="add-location-btn" class="btn btn-success" title="Add location at map center">
|
<button id="add-location-btn" class="btn btn-success" title="Add location at map center">
|
||||||
<span class="btn-icon">➕</span><span class="btn-text">Add Location Here</span>
|
<span class="btn-icon">➕</span><span class="btn-text">Add Location Here</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
259
map/app/public/js/admin.js
Normal file
259
map/app/public/js/admin.js
Normal file
@ -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 = `
|
||||||
|
<span>👤 ${escapeHtml(data.user.email)}</span>
|
||||||
|
<button id="logout-btn" class="btn btn-secondary btn-sm">Logout</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -43,9 +43,7 @@ function syncGeoFields(data) {
|
|||||||
const lat = parseFloat(data.latitude);
|
const lat = parseFloat(data.latitude);
|
||||||
const lng = parseFloat(data.longitude);
|
const lng = parseFloat(data.longitude);
|
||||||
if (!isNaN(lat) && !isNaN(lng)) {
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
data['Geo-Location'] = `${lat};${lng}`; // Use semicolon format for NocoDB GeoData
|
data['Geo-Location'] = `${lat};${lng}`; data.geodata = `${lat};${lng}`; }
|
||||||
data.geodata = `${lat};${lng}`; // Also update geodata for compatibility
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have Geo-Location but no lat/lng, parse it
|
// 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
|
// Configure logger
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
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
|
// Only set domain and secure for production non-localhost access
|
||||||
if (isProduction && !isLocalhost && process.env.COOKIE_DOMAIN) {
|
if (isProduction && !isLocalhost && process.env.COOKIE_DOMAIN) {
|
||||||
// Check if the request is coming from a subdomain of COOKIE_DOMAIN
|
// Check if the request is coming from a subdomain of COOKIE_DOMAIN
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN.replace(/^\./, ''); // Remove leading dot
|
const cookieDomain = process.env.COOKIE_DOMAIN.replace(/^\./, ''); if (host.includes(cookieDomain)) {
|
||||||
if (host.includes(cookieDomain)) {
|
|
||||||
config.domain = process.env.COOKIE_DOMAIN;
|
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'"],
|
defaultSrc: ["'self'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
||||||
scriptSrc: ["'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'"]
|
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
|
// Serve login page without authentication
|
||||||
app.get('/login.html', (req, res) => {
|
app.get('/login.html', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
||||||
@ -330,10 +359,11 @@ app.post('/api/auth/login', authLimiter, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (authorizedUser) {
|
if (authorizedUser) {
|
||||||
// Set session
|
// Set session including admin status
|
||||||
req.session.authenticated = true;
|
req.session.authenticated = true;
|
||||||
req.session.userEmail = email;
|
req.session.userEmail = email;
|
||||||
req.session.userName = authorizedUser.Name || email;
|
req.session.userName = authorizedUser.Name || email;
|
||||||
|
req.session.isAdmin = authorizedUser.Admin === true || authorizedUser.Admin === 1;
|
||||||
|
|
||||||
// Force session save before sending response
|
// Force session save before sending response
|
||||||
req.session.save((err) => {
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
user: {
|
user: {
|
||||||
email: email,
|
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,
|
authenticated: req.session?.authenticated || false,
|
||||||
user: req.session?.authenticated ? {
|
user: req.session?.authenticated ? {
|
||||||
email: req.session.userEmail,
|
email: req.session.userEmail,
|
||||||
name: req.session.userName
|
name: req.session.userName,
|
||||||
|
isAdmin: req.session.isAdmin || false
|
||||||
} : null
|
} : 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) => {
|
app.get('/api/debug/session', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
sessionID: req.sessionID,
|
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;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -711,8 +965,8 @@ app.put('/api/locations/:id', strictLimiter, async (req, res) => {
|
|||||||
// Sync geo fields
|
// Sync geo fields
|
||||||
updateData = syncGeoFields(updateData);
|
updateData = syncGeoFields(updateData);
|
||||||
|
|
||||||
updateData.updated_at = new Date().toISOString();
|
updateData.last_updated_at = new Date().toISOString();
|
||||||
updateData.updated_by = req.session.userEmail; // Track who updated
|
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}`;
|
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
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
logger.error('Unhandled error:', err);
|
logger.error('Unhandled error:', err);
|
||||||
|
|||||||
BIN
mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png
Normal file
BIN
mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@ -6,6 +6,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block announce %}
|
{% block announce %}
|
||||||
<a href="https://homepage.bnkserver.org" class="login-button">Login</a>
|
<a href="https://homepage.bnkserve.org" class="login-button">Login</a>
|
||||||
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
|
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
4
mkdocs/docs/test.md
Normal file
4
mkdocs/docs/test.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Test
|
||||||
|
|
||||||
|
lololol
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
site_name: Changemaker Lite
|
site_name: Changemaker Lite
|
||||||
site_description: Build Power. Not Rent It. Own your digital infrastructure.
|
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
|
site_author: Bunker Operations
|
||||||
docs_dir: docs
|
docs_dir: docs
|
||||||
site_dir: site
|
site_dir: site
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user