added in map view

This commit is contained in:
admin 2025-06-24 21:31:59 -06:00
parent 848f2b07d6
commit 728142d7cf
44 changed files with 4786 additions and 307 deletions

View File

@ -6,6 +6,12 @@ tunnel: fe0acd01-e83c-44a3-a8ff-c0d76eec8cc5 # e.g. 1234567890abcdef
credentials-file: /home/bunker-admin/.cloudflared/fe0acd01-e83c-44a3-a8ff-c0d76eec8cc5.json # e.g. /home/coder/.cloudflared/[insert tunnel number].json
ingress:
- hostname: map.lindalindsay.org
service: http://localhost:3000
- hostname: qr.lindalindsay.org
service: http://localhost:8089
- hostname: dashboard.lindalindsay.org
service: http://localhost:3010

View File

@ -3,86 +3,72 @@
- Essential Tools:
- Code Server:
href: "https://code.lindalindsay.org"
description: VS Code in the browser
icon: mdi-code-braces
widget:
type: docker
container: code-server-changemaker
server: my-docker
href: "https://code.lindalindsay.org"
description: VS Code in the browser - Platform Editor
container: code-server-changemaker
server: my-docker
- Listmonk:
icon: mdi-email-newsletter
href: "https://listmonk.lindalindsay.org"
description: Newsletter & mailing list manager
icon: mdi-email-newsletter
widget:
type: docker
container: listmonk_app
server: my-docker
container: listmonk_app
server: my-docker
- NocoDB:
icon: mdi-database
href: "https://db.lindalindsay.org"
description: No-code database platform
icon: mdi-database
widget:
type: docker
container: changemakerlite-nocodb-1
server: my-docker
container: changemakerlite-nocodb-1
server: my-docker
- Gitea:
icon: mdi-git
href: "https://git.lindalindsay.org"
description: Git repository hosting
icon: mdi-git
widget:
type: docker
container: gitea_changemaker
server: my-docker
container: gitea_changemaker
server: my-docker
- Content & Documentation:
- Main Site:
icon: mdi-web
href: "https://lindalindsay.org"
description: Linda Lindsay's campaign website
icon: mdi-web
widget:
type: docker
container: client-website-server-changemaker
server: my-docker
container: client-website-server-changemaker
server: my-docker
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "https://docs.lindalindsay.org"
description: Live documentation server with hot reload
icon: mdi-book-open-page-variant
widget:
type: docker
container: mkdocs-changemaker
server: my-docker
container: mkdocs-changemaker
server: my-docker
- Static Site:
icon: mdi-web
href: "https://blog.lindalindsay.org"
description: Built documentation hosting
icon: mdi-web
widget:
type: docker
container: mkdocs-site-server-changemaker
server: my-docker
container: mkdocs-site-server-changemaker
server: my-docker
- Mini QR:
icon: mdi-qrcode
href: "https://qr.lindalindsay.org"
description: QR code generator
container: mini-qr
server: my-docker
- Automation & Infrastructure:
- n8n:
icon: mdi-robot-industrial
href: "https://n8n.lindalindsay.org"
description: Workflow automation platform
icon: mdi-workflow
widget:
type: docker
container: n8n-changemaker
server: my-docker
container: n8n-changemaker
server: my-docker
- PostgreSQL (Listmonk):
icon: mdi-database-outline
href: "#"
description: Database for Listmonk
icon: mdi-database-outline
widget:
type: docker
container: listmonk_db
server: my-docker
container: listmonk_db
server: my-docker
- PostgreSQL (NocoDB):
icon: mdi-database-outline
href: "#"
description: Database for NocoDB
icon: mdi-database-outline
widget:
type: docker
container: changemakerlite-root_db-1
server: my-docker
container: changemakerlite-root_db-1
server: my-docker

View File

@ -1,87 +0,0 @@
---
# For public access, replace "http://localhost" with your subdomain URLs
- Essential Tools:
- Code Server:
href: "http://localhost:8888"
# href: "https://code.albertademocracytaskforce.org" # Uncomment for public access
description: VS Code in the browser
icon: mdi-code-braces
widget:
type: docker
container: code-server-changemaker
server: my-docker
- Listmonk:
href: "http://localhost:9000"
# href: "https://listmonk.albertademocracytaskforce.org" # Uncomment for public access
description: Newsletter & mailing list manager
icon: mdi-email-newsletter
widget:
type: docker
container: listmonk_app
server: my-docker
- NocoDB:
href: "http://localhost:8090"
# href: "https://db.albertademocracytaskforce.org" # Uncomment for public access
description: No-code database platform
icon: mdi-database
widget:
type: docker
container: changemakerlite-nocodb-1
server: my-docker
- Gitea:
href: "http://localhost:3030"
# href: "https://git.albertademocracytaskforce.org" # Uncomment for public access
description: Git repository hosting
icon: mdi-git
widget:
type: docker
container: gitea_changemaker
server: my-docker
- Content & Documentation:
- MkDocs (Live):
href: "http://localhost:4000"
# href: "https://docs.albertademocracytaskforce.org" # Uncomment for public access
description: Live documentation server with hot reload
icon: mdi-book-open-page-variant
widget:
type: docker
container: mkdocs-changemaker
server: my-docker
- Static Site:
href: "http://localhost:4001"
# href: "https://albertademocracytaskforce.org" # Uncomment for public access
description: Built documentation hosting
icon: mdi-web
widget:
type: docker
container: mkdocs-site-server-changemaker
server: my-docker
- Automation & Infrastructure:
- n8n:
href: "http://localhost:5678"
# href: "https://n8n.albertademocracytaskforce.org" # Uncomment for public access
description: Workflow automation platform
icon: mdi-workflow
widget:
type: docker
container: n8n-changemaker
server: my-docker
- PostgreSQL (Listmonk):
href: "#"
description: Database for Listmonk
icon: mdi-database-outline
widget:
type: docker
container: listmonk_db
server: my-docker
- PostgreSQL (NocoDB):
href: "#"
description: Database for NocoDB
icon: mdi-database-outline
widget:
type: docker
container: changemakerlite-root_db-1
server: my-docker

View File

@ -1,87 +0,0 @@
---
# For public access, replace "http://localhost" with your subdomain URLs
- Essential Tools:
- Code Server:
href: "http://localhost:8888"
# href: "https://code.albertademocracytaskforce.org" # Uncomment for public access
description: VS Code in the browser
icon: mdi-code-braces
widget:
type: docker
container: code-server-changemaker
server: my-docker
- Listmonk:
href: "http://localhost:9000"
# href: "https://listmonk.albertademocracytaskforce.org" # Uncomment for public access
description: Newsletter & mailing list manager
icon: mdi-email-newsletter
widget:
type: docker
container: listmonk_app
server: my-docker
- NocoDB:
href: "http://localhost:8090"
# href: "https://db.albertademocracytaskforce.org" # Uncomment for public access
description: No-code database platform
icon: mdi-database
widget:
type: docker
container: changemakerlite-nocodb-1
server: my-docker
- Gitea:
href: "http://localhost:3030"
# href: "https://git.albertademocracytaskforce.org" # Uncomment for public access
description: Git repository hosting
icon: mdi-git
widget:
type: docker
container: gitea_changemaker
server: my-docker
- Content & Documentation:
- MkDocs (Live):
href: "http://localhost:4000"
# href: "https://docs.albertademocracytaskforce.org" # Uncomment for public access
description: Live documentation server with hot reload
icon: mdi-book-open-page-variant
widget:
type: docker
container: mkdocs-changemaker
server: my-docker
- Static Site:
href: "http://localhost:4001"
# href: "https://albertademocracytaskforce.org" # Uncomment for public access
description: Built documentation hosting
icon: mdi-web
widget:
type: docker
container: mkdocs-site-server-changemaker
server: my-docker
- Automation & Infrastructure:
- n8n:
href: "http://localhost:5678"
# href: "https://n8n.albertademocracytaskforce.org" # Uncomment for public access
description: Workflow automation platform
icon: mdi-workflow
widget:
type: docker
container: n8n-changemaker
server: my-docker
- PostgreSQL (Listmonk):
href: "#"
description: Database for Listmonk
icon: mdi-database-outline
widget:
type: docker
container: listmonk_db
server: my-docker
- PostgreSQL (NocoDB):
href: "#"
description: Database for NocoDB
icon: mdi-database-outline
widget:
type: docker
container: changemakerlite-root_db-1
server: my-docker

View File

@ -28,8 +28,6 @@ quicklaunch:
hideVisitURL: true
provider: duckduckgo
showStats: true
bookmarks:
showCategories: true
showIcons: true

View File

@ -6,6 +6,16 @@
cpu: true
memory: true
disk: /
- openmeteo:
label: Edmonton # optional
latitude: 53.5461
longitude: -113.4938
timezone: America/Edmonton # optional
units: metric # or imperial
cache: 5 # Time in minutes to cache API responses, to stay within limits
format: # optional, Intl.NumberFormat options
maximumFractionDigits: 1
- greeting:
text_size: xl

View File

@ -16,7 +16,9 @@ services:
volumes:
- ./configs/code-server/.config:/home/coder/.config
- ./configs/code-server/.local:/home/coder/.local
- ./configs/homepage:/home/coder/homepage
- ./mkdocs:/home/coder/mkdocs/
- ./site:/home/coder/site
ports:
- "${CODE_SERVER_PORT:-8888}:8080"
restart: unless-stopped
@ -247,6 +249,15 @@ services:
networks:
- changemaker-lite
mini-qr:
image: ghcr.io/lyqht/mini-qr:latest
container_name: mini-qr
ports:
- "${MINI_QR_PORT:-8089}:8080"
restart: unless-stopped
networks:
- changemaker-lite
networks:
changemaker-lite:
driver: bridge

View File

@ -0,0 +1,4 @@
# Linda Lindsay Blog
Welcome my Blog! I will be writing here about the campaign, work, and everytthing school board related. I hope you enjoy it as much as I do making this blog.

View File

@ -0,0 +1,17 @@
---
date:
created: 2025-06-19
title: About Me
---
# About Me
I am a proud Edmontonian, born and raised. As a child my family moved frequently. These fresh starts at new schools often felt a bit scary, but no matter where I landed, I was welcomed and supported by caring and dedicated teachers and support staff. The care and concern of my teachers and the love and support of my family helped me to build resilience, gain confidence and push through challenges. This is what I wish for all children.
My husband and I have lived in Ward B for twenty-eight years. It is here that we raised and educated our children. In fact, they attended a junior high school where I was also a student. We are now blessed with grandchildren. The time we have with them is precious; we often get the giggles just watching them play. They are a joy, and we are grateful.
I love public education. From grade one to graduation, I was mentored by strong dedicated teachers. Teachers who supported me academically, musically, and athletically. These three components helped me move through university days and remain important in my life. In grade nine I decided to be a teacher. I revered my teachers and wanted to be like them. My dream became a reality.
I am excited to be running for school board trustee in Ward B. I am passionate about a strong and progressive public school system that serves and supports all students in all neighbourhoods. Classrooms today have diverse populations, and students often have complex needs. I understand this as an educator and a parent. One of our grown children lives with special needs; I understand the challenge of securing supports. As an educator, I had the privilege of working for the district when there was a strong and reliable funding model. I want to see a return to the level of support for students. I support and stand by the Edmonton Public School mission statement “a commitment to high quality public education that serves the community and empowers each student to live a life of dignity, fulfillment, empathy, and possibility.”
VOTE FOR ME, LINDA LINDSAY!

View File

@ -0,0 +1,8 @@
---
date:
created: 2025-06-18
---
# Welcome!
Hi friend! This is actually the system adminstrator Reed Larsen. I am the one who is responsible for keeping this website up to date and running smoothly. If you have any questions or concerns, please do not hesitate to contact me at [admin@thebunkerops.ca](mailto:admin@thebunkerops.ca). Thank you so much for visiting my site today!

View File

@ -1,8 +1,6 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://dashboard.lindalindsay.org" class="login-button">Login</a>
<a href="https://dashboard.lindalindsay.org" class="login-button">Login</a> -
<a href="https://lindalindsay.org" class="home-button">Home</a>
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://homepage.albertademocracytaskforce.org" class="login-button">Login</a>
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://homepage.albertademocracytaskforce.org" class="login-button">Login</a>
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
{% endblock %}

View File

@ -8,7 +8,7 @@ site_dir: site
# Repository
repo_name: Campaign Resources
repo_url: https://git.lindalindsay.org/admin/linda.lindsay.changemaker
repo_type: gitea
# Theme Configuration
theme:
name: material
@ -169,7 +169,7 @@ plugins:
- social:
enabled: !ENV [CI, false]
- tags:
tags_file: tags.md
copyright: |
Copyright &copy; 2025 Linda Lindsay for Ward B School Trustee<br>
Copyright &copy; 2025 Linda Lindsay for Ward B School Trustee |
<a href="https://dashboard.lindalindsay.org">login</a>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://homepage.albertademocracytaskforce.org" class="login-button">Login</a>
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://homepage.albertademocracytaskforce.org" class="login-button">Login</a>
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
{% endblock %}

View File

@ -1,8 +1,6 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://dashboard.lindalindsay.org" class="login-button">Login</a>
<a href="https://dashboard.lindalindsay.org" class="login-button">Login</a> -
<a href="https://lindalindsay.org" class="home-button">Home</a>
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://homepage.albertademocracytaskforce.org" class="login-button">Login</a>
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block extrahead %}
{% endblock %}
{% block announce %}
<a href="https://homepage.albertademocracytaskforce.org" class="login-button">Login</a>
Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@ -2,10 +2,22 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://blog.lindalindsay.org/</loc>
<lastmod>2025-06-18</lastmod>
<lastmod>2025-06-19</lastmod>
</url>
<url>
<loc>https://blog.lindalindsay.org/blog/</loc>
<lastmod>2025-06-18</lastmod>
<lastmod>2025-06-19</lastmod>
</url>
<url>
<loc>https://blog.lindalindsay.org/blog/2025/06/19/about_me/</loc>
<lastmod>2025-06-19</lastmod>
</url>
<url>
<loc>https://blog.lindalindsay.org/blog/2025/06/18/welcome/</loc>
<lastmod>2025-06-19</lastmod>
</url>
<url>
<loc>https://blog.lindalindsay.org/blog/archive/2025/</loc>
<lastmod>2025-06-19</lastmod>
</url>
</urlset>

Binary file not shown.

29
nocodb-map-viewer/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment files
.env
.env.local
.env.*.local
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
logs/
*.log
# Build outputs
dist/
build/

142
nocodb-map-viewer/README.md Normal file
View File

@ -0,0 +1,142 @@
# NocoDB Map Viewer
A containerized web application that visualizes geographic data from NocoDB on an interactive map using Leaflet.js.
## Features
- 🗺️ Interactive map visualization with OpenStreetMap
- 📍 Real-time geolocation support
- Add new locations directly from the map
- 🔄 Auto-refresh every 30 seconds
- 📱 Responsive design for mobile devices
- 🔒 Secure API proxy to protect credentials
- 🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
## Quick Start
### Prerequisites
- Docker and Docker Compose
- NocoDB instance with a table containing location data
- NocoDB API token
### NocoDB Table Setup
1. Create a table in NocoDB with these required columns:
- `geodata` (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
### Installation
1. Clone this repository or create the file structure as shown
2. Copy the environment template:
```bash
cp .env.example .env
```
3. Edit `.env` with your NocoDB details:
```env
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
```
4. Start the application:
```bash
docker-compose up -d
```
5. Access the map at: http://localhost:3000
## Finding NocoDB IDs
### API Token
1. Click user icon → Account Settings
2. Go to "API Tokens" tab
3. Create new token with read/write permissions
### Project and Table IDs
- Simply provide the full NocoDB view URL in `NOCODB_VIEW_URL`
- The system will automatically extract the project and table IDs
## 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
- `GET /health` - Health check
## Configuration
All configuration is done via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `NOCODB_API_URL` | NocoDB API base URL | Required |
| `NOCODB_API_TOKEN` | API authentication token | Required |
| `NOCODB_VIEW_URL` | Full NocoDB view URL | Required |
| `PORT` | Server port | 3000 |
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
| `DEFAULT_ZOOM` | Default map zoom level | 11 |
## Development
To run in development mode:
1. Install dependencies:
```bash
cd app
npm install
```
2. Start with hot reload:
```bash
npm run dev
```
## Security Considerations
- API tokens are kept server-side only
- CORS is configured for security
- Rate limiting prevents abuse
- Input validation on all endpoints
- Helmet.js for security headers
## Troubleshooting
### Locations not showing
- Verify table has `geodata`, `latitude`, and `longitude` columns
- Check that coordinates are valid numbers
- Ensure API token has read permissions
### Cannot add locations
- Verify API token has write permissions
- Check browser console for errors
- Ensure coordinates are within valid ranges
### Connection errors
- Verify NocoDB instance is accessible
- Check API URL format
- Confirm network connectivity
## License
MIT License - See LICENSE file for details
## Support
For issues or questions:
1. Check the troubleshooting section
2. Review NocoDB documentation
3. Open an issue on GitHub

View File

@ -0,0 +1,41 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
# Runtime stage
FROM node:18-alpine
# Install wget for healthcheck
RUN apk add --no-cache wget
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server.js ./
COPY public ./public
COPY routes ./routes
COPY services ./services
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Change ownership
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]

1644
nocodb-map-viewer/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
{
"name": "nocodb-map-viewer",
"version": "1.0.0",
"description": "Interactive map viewer for NocoDB geographic data",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"nocodb",
"map",
"leaflet",
"gis",
"location"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"helmet": "^7.1.0",
"express-rate-limit": "^7.1.4",
"winston": "^3.11.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -0,0 +1,608 @@
/* CSS Variables for theming */
:root {
--primary-color: #2c5aa0;
--success-color: #27ae60;
--danger-color: #e74c3c;
--warning-color: #f39c12;
--secondary-color: #95a5a6;
--dark-color: #2c3e50;
--light-color: #ecf0f1;
--border-radius: 4px;
--transition: all 0.3s ease;
--header-height: 60px;
}
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: var(--dark-color);
background-color: var(--light-color);
}
/* App container */
#app {
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
}
/* Header */
.header {
height: var(--header-height);
background-color: var(--dark-color);
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 1000;
}
.header h1 {
font-size: 24px;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 15px;
}
.location-count {
background-color: rgba(255,255,255,0.1);
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
}
/* Map container */
#map-container {
flex: 1;
position: relative;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
/* Map controls */
.map-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
/* Buttons */
.btn {
padding: 10px 16px;
border: none;
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
outline: none;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #2471a3;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover {
background-color: #229954;
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
/* Crosshair for location selection */
.crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 999;
}
.crosshair.hidden {
display: none;
}
.crosshair-x,
.crosshair-y {
position: absolute;
background-color: rgba(44, 90, 160, 0.8);
}
.crosshair-x {
width: 40px;
height: 2px;
left: -20px;
top: -1px;
}
.crosshair-y {
width: 2px;
height: 40px;
left: -1px;
top: -20px;
}
.crosshair-info {
position: absolute;
top: 30px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(44, 62, 80, 0.9);
color: white;
padding: 5px 10px;
border-radius: var(--border-radius);
font-size: 12px;
white-space: nowrap;
}
/* Status messages */
.status-container {
position: fixed;
top: calc(var(--header-height) + 20px);
left: 50%;
transform: translateX(-50%);
z-index: 2000;
max-width: 400px;
width: 90%;
}
.status-message {
padding: 12px 20px;
border-radius: var(--border-radius);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
animation: slideIn 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.status-message.success {
background-color: var(--success-color);
color: white;
}
.status-message.error {
background-color: var(--danger-color);
color: white;
}
.status-message.warning {
background-color: var(--warning-color);
color: white;
}
.status-message.info {
background-color: var(--primary-color);
color: white;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
animation: fadeIn 0.3s ease;
}
.modal.hidden {
display: none;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background-color: white;
border-radius: var(--border-radius);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 20px;
font-weight: 600;
color: var(--dark-color);
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--secondary-color);
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: var(--transition);
}
.modal-close:hover {
background-color: var(--light-color);
color: var(--dark-color);
}
.modal-body {
padding: 20px;
}
/* Edit Footer Form */
.edit-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: white;
border-top: 2px solid var(--primary-color);
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 1500;
transition: transform 0.3s ease;
max-height: 60vh;
overflow-y: auto;
}
.edit-footer.hidden {
transform: translateY(100%);
}
.edit-footer-content {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.edit-footer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0;
}
.edit-footer-header h2 {
margin: 0;
font-size: 20px;
color: var(--dark-color);
}
/* Form styles */
.form-group {
margin-bottom: 15px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: var(--dark-color);
font-size: 14px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
transition: var(--transition);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(44, 90, 160, 0.1);
}
.form-group input.valid {
border-color: var(--success-color);
}
.form-group input.invalid {
border-color: var(--danger-color);
}
.form-group input[readonly] {
background-color: #f5f5f5;
cursor: not-allowed;
}
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
transition: var(--transition);
background-color: white;
cursor: pointer;
}
.form-group select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(44, 90, 160, 0.1);
}
.form-group input[type="checkbox"] {
width: auto;
margin-right: 8px;
cursor: pointer;
}
.form-group label input[type="checkbox"] {
vertical-align: middle;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255,255,255,0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 4000;
}
.loading-overlay.hidden {
display: none;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid var(--light-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-overlay p {
margin-top: 20px;
color: var(--dark-color);
font-size: 16px;
}
/* Leaflet customizations */
.leaflet-popup-content-wrapper {
border-radius: var(--border-radius);
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
}
.leaflet-popup-content {
margin: 13px 19px;
line-height: 1.5;
}
.popup-content h3 {
margin: 0 0 10px 0;
color: var(--dark-color);
font-size: 16px;
}
.popup-content p {
margin: 5px 0;
color: #666;
font-size: 14px;
}
.popup-content .popup-meta {
font-size: 12px;
color: #999;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
/* Responsive design */
@media (max-width: 768px) {
.header h1 {
font-size: 20px;
}
.map-controls {
top: 10px;
right: 10px;
}
.btn {
padding: 8px 12px;
font-size: 13px;
}
/* Hide button text on mobile, show only icons */
.btn span.btn-text {
display: none;
}
.btn {
padding: 10px;
min-width: 40px;
min-height: 40px;
justify-content: center;
}
.modal-content {
width: 95%;
margin: 10px;
}
.form-row {
grid-template-columns: 1fr;
}
}
/* Add text spans for desktop that can be hidden on mobile */
@media (min-width: 769px) {
.btn span.btn-icon {
margin-right: 5px;
}
}
/* Fullscreen styles */
.fullscreen #map-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5000;
}
.fullscreen .header {
display: none;
}
/* Print styles */
@media print {
.header,
.map-controls,
.status-container,
.modal {
display: none !important;
}
#map-container {
height: 100vh;
}
}

View File

@ -0,0 +1,276 @@
<!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="Interactive map viewer for NocoDB location data">
<title>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">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="header">
<h1>Location Map Viewer</h1>
<div class="header-actions">
<button id="refresh-btn" class="btn btn-secondary" title="Refresh locations">
🔄 Refresh
</button>
<span id="location-count" class="location-count">Loading...</span>
</div>
</header>
<!-- Map Container -->
<div id="map-container">
<div id="map"></div>
<!-- Map Controls -->
<div class="map-controls">
<button id="geolocate-btn" class="btn btn-primary" title="Find my location">
<span class="btn-icon">📍</span><span class="btn-text">My Location</span>
</button>
<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>
</button>
<button id="fullscreen-btn" class="btn btn-secondary" title="Toggle fullscreen">
<span class="btn-icon"></span><span class="btn-text">Fullscreen</span>
</button>
</div>
<!-- Crosshair for adding locations -->
<div id="crosshair" class="crosshair hidden">
<div class="crosshair-x"></div>
<div class="crosshair-y"></div>
<div class="crosshair-info">Click "Add Location Here" to save this point</div>
</div>
</div>
<!-- Status Messages -->
<div id="status-container" class="status-container"></div>
<!-- Edit Location Footer Form -->
<div id="edit-footer" class="edit-footer hidden">
<div class="edit-footer-content">
<div class="edit-footer-header">
<h2>Edit Location</h2>
<button class="btn btn-secondary btn-sm" id="close-edit-footer-btn">✕ Close</button>
</div>
<form id="edit-location-form">
<input type="hidden" id="edit-location-id" name="id">
<div class="form-row">
<div class="form-group">
<label for="edit-first-name">First Name</label>
<input type="text" id="edit-first-name" name="First Name">
</div>
<div class="form-group">
<label for="edit-last-name">Last Name</label>
<input type="text" id="edit-last-name" name="Last Name">
</div>
</div>
<div class="form-group">
<label for="edit-location-email">Email</label>
<input type="email" id="edit-location-email" name="Email">
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-location-unit">Unit Number</label>
<input type="text" id="edit-location-unit" name="Unit Number">
</div>
<div class="form-group">
<label for="edit-support-level">Support Level</label>
<select id="edit-support-level" name="Support Level">
<option value="">-- Select --</option>
<option value="1">1 - Strong Support (Green)</option>
<option value="2">2 - Moderate Support (Yellow)</option>
<option value="3">3 - Low Support (Orange)</option>
<option value="4">4 - No Support (Red)</option>
</select>
</div>
</div>
<div class="form-group">
<label for="edit-location-address">Address</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="edit-location-address" name="Address" style="flex: 1;">
<button type="button" class="btn btn-secondary btn-sm" id="lookup-address-edit-btn">
🔍 Lookup
</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="edit-sign" name="Sign" value="true">
Has Campaign Sign
</label>
</div>
<div class="form-group">
<label for="edit-sign-size">Sign Size</label>
<select id="edit-sign-size" name="Sign Size">
<option value="">-- Select --</option>
<option value="Small">Small</option>
<option value="Medium">Medium</option>
<option value="Large">Large</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-location-lat">Latitude</label>
<input type="number" id="edit-location-lat" name="latitude" step="0.00000001">
</div>
<div class="form-group">
<label for="edit-location-lng">Longitude</label>
<input type="number" id="edit-location-lng" name="longitude" step="0.00000001">
</div>
</div>
<div class="form-group">
<label for="edit-geo-location">Geo-Location</label>
<input type="text" id="edit-geo-location" name="Geo-Location"
placeholder="e.g., 53.5461;-113.4938">
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" id="delete-location-btn">Delete</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Add Location Modal -->
<div id="add-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Add New Location</h2>
<button class="modal-close" id="close-modal-btn">&times;</button>
</div>
<div class="modal-body">
<form id="location-form">
<div class="form-row">
<div class="form-group">
<label for="first-name">First Name</label>
<input type="text" id="first-name" name="First Name"
placeholder="Enter first name">
</div>
<div class="form-group">
<label for="last-name">Last Name</label>
<input type="text" id="last-name" name="Last Name"
placeholder="Enter last name">
</div>
</div>
<div class="form-group">
<label for="location-email">Email</label>
<input type="email" id="location-email" name="Email"
placeholder="Enter email address">
</div>
<div class="form-row">
<div class="form-group">
<label for="location-unit">Unit Number</label>
<input type="text" id="location-unit" name="Unit Number"
placeholder="Enter unit number">
</div>
<div class="form-group">
<label for="support-level">Support Level</label>
<select id="support-level" name="Support Level">
<option value="">-- Select --</option>
<option value="1">1 - Strong Support (Green)</option>
<option value="2">2 - Moderate Support (Yellow)</option>
<option value="3">3 - Low Support (Orange)</option>
<option value="4">4 - No Support (Red)</option>
</select>
</div>
</div>
<div class="form-group">
<label for="location-address">Address</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="location-address" name="Address"
placeholder="Enter address" style="flex: 1;">
<button type="button" class="btn btn-secondary btn-sm" id="lookup-address-add-btn">
🔍 Lookup
</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="sign" name="Sign" value="true">
Has Campaign Sign
</label>
</div>
<div class="form-group">
<label for="sign-size">Sign Size</label>
<select id="sign-size" name="Sign Size">
<option value="">-- Select --</option>
<option value="Small">Small</option>
<option value="Medium">Medium</option>
<option value="Large">Large</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="location-lat">Latitude</label>
<input type="number" id="location-lat" name="latitude"
step="0.00000001" readonly>
</div>
<div class="form-group">
<label for="location-lng">Longitude</label>
<input type="number" id="location-lng" name="longitude"
step="0.00000001" readonly>
</div>
</div>
<div class="form-group">
<label for="geo-location">Geo-Location</label>
<input type="text" id="geo-location" name="Geo-Location"
placeholder="e.g., 53.5461;-113.4938"
title="Enter as 'latitude;longitude' or 'latitude, longitude'">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-modal-btn">
Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Location
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading" class="loading-overlay">
<div class="spinner"></div>
<p>Loading map...</p>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Application JavaScript -->
<script src="js/map.js"></script>
</body>
</html>

View File

@ -0,0 +1,987 @@
// Global configuration
const CONFIG = {
DEFAULT_LAT: parseFloat(document.querySelector('meta[name="default-lat"]')?.content) || 53.5461,
DEFAULT_LNG: parseFloat(document.querySelector('meta[name="default-lng"]')?.content) || -113.4938,
DEFAULT_ZOOM: parseInt(document.querySelector('meta[name="default-zoom"]')?.content) || 11,
REFRESH_INTERVAL: 30000, // 30 seconds
MAX_ZOOM: 19,
MIN_ZOOM: 2
};
// Application state
let map = null;
let markers = [];
let userLocationMarker = null;
let isAddingLocation = false;
let refreshInterval = null;
let currentEditingLocation = null; // Add this line
// Initialize application when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initializeMap();
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);
}
});
});
// 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 API configuration
async function checkConfiguration() {
try {
const response = await fetch('/api/config-check');
const data = await response.json();
if (!data.configured) {
showStatus('Warning: API not fully configured. Check your .env file.', 'warning');
}
} catch (error) {
console.error('Configuration check failed:', error);
}
}
// Load locations from 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);
} 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);
}
}
// Display locations on map
function displayLocations(locations) {
// Clear existing markers
markers.forEach(marker => map.removeLayer(marker));
markers = [];
// Add new markers
locations.forEach(location => {
if (location.latitude && location.longitude) {
const marker = createLocationMarker(location);
markers.push(marker);
}
});
// 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));
}
}
// Create marker for location (updated to use circle markers)
function createLocationMarker(location) {
console.log('Creating marker for location:', location);
// Get color based on support level
const supportColor = getSupportColor(location['Support Level']);
// Create circle marker instead of default marker
const marker = L.circleMarker([location.latitude, location.longitude], {
radius: 8,
fillColor: supportColor,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8,
title: location.title || 'Location',
riseOnHover: true,
locationData: location // Store location data in marker options
}).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);
return marker;
}
// Create popup content for marker
function createPopupContent(location) {
console.log('Creating popup for location:', location);
let content = '<div class="popup-content">';
// Handle name
let displayName = '';
if (location.title) {
displayName = location.title;
} else if (location['First Name'] || location['Last Name']) {
const firstName = location['First Name'] || '';
const lastName = location['Last Name'] || '';
displayName = `${firstName} ${lastName}`.trim();
}
if (displayName) {
content += `<h3>${escapeHtml(displayName)}</h3>`;
}
// Support Level with color indicator
const supportColor = getSupportColor(location['Support Level']);
const supportText = getSupportLevelText(location['Support Level']);
content += `<p><strong>Support Level:</strong> <span style="display: inline-block; width: 12px; height: 12px; border-radius: 50%; background-color: ${supportColor}; margin-right: 5px;"></span>${escapeHtml(supportText)}</p>`;
// Display all available fields
if (location['Email']) {
content += `<p><strong>Email:</strong> ${escapeHtml(location['Email'])}</p>`;
}
if (location['Unit Number']) {
content += `<p><strong>Unit Number:</strong> ${escapeHtml(location['Unit Number'])}</p>`;
}
if (location['Address']) {
content += `<p><strong>Address:</strong> ${escapeHtml(location['Address'])}</p>`;
}
// Sign information
if (location['Sign']) {
content += `<p><strong>Has Sign:</strong> Yes`;
if (location['Sign Size']) {
content += ` (${escapeHtml(location['Sign Size'])})`;
}
content += '</p>';
}
if (location.description) {
content += `<p><strong>Description:</strong> ${escapeHtml(location.description)}</p>`;
}
if (location.category) {
content += `<p><strong>Category:</strong> ${escapeHtml(location.category)}</p>`;
}
content += '<div class="popup-meta">';
content += `<p><strong>Coordinates:</strong> ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}</p>`;
if (location['Geo-Location']) {
content += `<p><strong>Geo-Location:</strong> ${escapeHtml(location['Geo-Location'])}</p>`;
}
if (location.created_at) {
const date = new Date(location.created_at);
content += `<p><strong>Added:</strong> ${date.toLocaleDateString()}</p>`;
}
if (location.updated_at) {
const date = new Date(location.updated_at);
content += `<p><strong>Updated:</strong> ${date.toLocaleDateString()}</p>`;
}
content += '</div>';
// Add edit button with data attribute instead of onclick
content += `<div style="margin-top: 10px; text-align: center;">`;
content += `<button class="btn btn-primary btn-sm edit-location-btn" data-location-id="${location.id || location.Id}">✏️ Edit</button>`;
content += '</div>';
content += '</div>';
return content;
}
// Handle geolocation
function handleGeolocation() {
if (!navigator.geolocation) {
showStatus('Geolocation is not supported by your browser', 'error');
return;
}
showStatus('Getting your location...', 'info');
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords;
// Center map on user location
map.setView([latitude, longitude], 15);
// Remove existing user marker
if (userLocationMarker) {
map.removeLayer(userLocationMarker);
}
// Add user location marker
userLocationMarker = L.marker([latitude, longitude], {
icon: L.divIcon({
html: '<div style="background-color: #2c5aa0; width: 20px; height: 20px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3);"></div>',
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');
},
(error) => {
let message = 'Unable to get your location';
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'Location permission denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information unavailable';
break;
case error.TIMEOUT:
message = 'Location request timed out';
break;
}
showStatus(message, 'error');
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
}
// Toggle add location mode
function toggleAddLocation() {
isAddingLocation = !isAddingLocation;
const btn = document.getElementById('add-location-btn');
const crosshair = document.getElementById('crosshair');
if (isAddingLocation) {
btn.innerHTML = '<span class="btn-icon">✕</span><span class="btn-text">Cancel</span>';
btn.classList.remove('btn-success');
btn.classList.add('btn-secondary');
crosshair.classList.remove('hidden');
map.getContainer().style.cursor = 'crosshair';
} else {
btn.innerHTML = '<span class="btn-icon"></span><span class="btn-text">Add Location Here</span>';
btn.classList.remove('btn-secondary');
btn.classList.add('btn-success');
crosshair.classList.add('hidden');
map.getContainer().style.cursor = '';
}
}
// Handle map click
function handleMapClick(e) {
if (!isAddingLocation) return;
const { lat, lng } = e.latlng;
// Toggle off add location mode
toggleAddLocation();
// Show modal with coordinates
showAddLocationModal(lat, lng);
}
// Show add location modal
function showAddLocationModal(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');
// 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');
// 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 = '';
// Show modal
modal.classList.remove('hidden');
// 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
}
}).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 = '';
});
}
// 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;
}
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showStatus('Location added successfully!', 'success');
closeModal();
// Reload locations
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');
}
}
// Toggle fullscreen
function toggleFullscreen() {
const app = document.getElementById('app');
const btn = document.getElementById('fullscreen-btn');
if (!document.fullscreenElement) {
app.requestFullscreen().then(() => {
app.classList.add('fullscreen');
btn.innerHTML = '<span class="btn-icon">✕</span><span class="btn-text">Exit Fullscreen</span>';
// Invalidate map size after transition
setTimeout(() => map.invalidateSize(), 300);
}).catch(err => {
showStatus('Unable to enter fullscreen', 'error');
});
} else {
document.exitFullscreen().then(() => {
app.classList.remove('fullscreen');
btn.innerHTML = '<span class="btn-icon">⛶</span><span class="btn-text">Fullscreen</span>';
// Invalidate map size after transition
setTimeout(() => map.invalidateSize(), 300);
});
}
}
// Update location count
function updateLocationCount(count) {
const countElement = document.getElementById('location-count');
countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`;
}
// 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 to prevent XSS
function escapeHtml(text) {
if (text === null || text === undefined) {
return '';
}
const div = document.createElement('div');
div.textContent = String(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');
}
}

View File

@ -0,0 +1,113 @@
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');
const { reverseGeocode, forwardGeocode, getCacheStats } = require('../services/geocoding');
// Rate limiter specifically for geocoding endpoints
const geocodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30, // limit each IP to 30 requests per windowMs
message: 'Too many geocoding requests, please try again later.'
});
/**
* Reverse geocode endpoint
* GET /api/geocode/reverse?lat=<latitude>&lng=<longitude>
*/
router.get('/reverse', geocodeLimiter, async (req, res) => {
try {
const { lat, lng } = req.query;
// Validate input
if (!lat || !lng) {
return res.status(400).json({
success: false,
error: 'Latitude and longitude are required'
});
}
const latitude = parseFloat(lat);
const longitude = parseFloat(lng);
if (isNaN(latitude) || isNaN(longitude)) {
return res.status(400).json({
success: false,
error: 'Invalid latitude or longitude'
});
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return res.status(400).json({
success: false,
error: 'Coordinates out of range'
});
}
// Perform reverse geocoding
const result = await reverseGeocode(latitude, longitude);
res.json({
success: true,
data: result
});
} catch (error) {
console.error('Reverse geocoding error:', error);
const statusCode = error.message.includes('Rate limit') ? 429 : 500;
res.status(statusCode).json({
success: false,
error: error.message
});
}
});
/**
* Forward geocode endpoint
* GET /api/geocode/forward?address=<address>
*/
router.get('/forward', geocodeLimiter, async (req, res) => {
try {
const { address } = req.query;
// Validate input
if (!address || address.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'Address is required'
});
}
// Perform forward geocoding
const result = await forwardGeocode(address);
res.json({
success: true,
data: result
});
} catch (error) {
console.error('Forward geocoding error:', error);
const statusCode = error.message.includes('Rate limit') ? 429 : 500;
res.status(statusCode).json({
success: false,
error: error.message
});
}
});
/**
* Get geocoding cache statistics (admin endpoint)
* GET /api/geocode/cache/stats
*/
router.get('/cache/stats', (req, res) => {
res.json({
success: true,
data: getCacheStats()
});
});
module.exports = router;

View File

@ -0,0 +1,515 @@
const express = require('express');
const axios = require('axios');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const winston = require('winston');
const path = require('path');
require('dotenv').config();
// Import geocoding routes
const geocodingRoutes = require('./routes/geocoding');
// Parse project and table IDs from view URL
function parseNocoDBUrl(url) {
if (!url) return { projectId: null, tableId: null };
// Pattern to match NocoDB URLs
const patterns = [
/#\/nc\/([^\/]+)\/([^\/\?#]+)/, // matches #/nc/PROJECT_ID/TABLE_ID (dashboard URLs)
/\/nc\/([^\/]+)\/([^\/\?#]+)/, // matches /nc/PROJECT_ID/TABLE_ID
/project\/([^\/]+)\/table\/([^\/\?#]+)/, // alternative pattern
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return {
projectId: match[1],
tableId: match[2]
};
}
}
return { projectId: null, tableId: null };
}
// Add this helper function near the top of the file after the parseNocoDBUrl function
function syncGeoFields(data) {
// If we have latitude and longitude but no Geo-Location, create it
if (data.latitude && data.longitude && !data['Geo-Location']) {
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
}
}
// If we have Geo-Location but no lat/lng, parse it
else if (data['Geo-Location'] && (!data.latitude || !data.longitude)) {
const geoLocation = data['Geo-Location'].toString();
// Try semicolon-separated first
let parts = geoLocation.split(';');
if (parts.length === 2) {
const lat = parseFloat(parts[0].trim());
const lng = parseFloat(parts[1].trim());
if (!isNaN(lat) && !isNaN(lng)) {
data.latitude = lat;
data.longitude = lng;
data.geodata = `${lat};${lng}`;
return data;
}
}
// Try comma-separated
parts = geoLocation.split(',');
if (parts.length === 2) {
const lat = parseFloat(parts[0].trim());
const lng = parseFloat(parts[1].trim());
if (!isNaN(lat) && !isNaN(lng)) {
data.latitude = lat;
data.longitude = lng;
data.geodata = `${lat};${lng}`;
// Normalize Geo-Location to semicolon format for NocoDB GeoData
data['Geo-Location'] = `${lat};${lng}`;
}
}
}
return data;
}
// Auto-parse IDs if view URL is provided
if (process.env.NOCODB_VIEW_URL && (!process.env.NOCODB_PROJECT_ID || !process.env.NOCODB_TABLE_ID)) {
const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_VIEW_URL);
if (projectId && tableId) {
process.env.NOCODB_PROJECT_ID = projectId;
process.env.NOCODB_TABLE_ID = tableId;
console.log(`Auto-parsed from URL - Project ID: ${projectId}, Table ID: ${tableId}`);
}
}
// Configure logger
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.simple()
})
]
});
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org"],
connectSrc: ["'self'"]
}
}
}));
// CORS configuration
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20 // limit location creation to 20 per 15 minutes
});
// Middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api/', limiter);
// Add geocoding routes
app.use('/api/geocode', geocodingRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '1.0.0'
});
});
// Configuration validation endpoint (for debugging)
app.get('/api/config-check', (req, res) => {
const config = {
hasApiUrl: !!process.env.NOCODB_API_URL,
hasApiToken: !!process.env.NOCODB_API_TOKEN,
hasProjectId: !!process.env.NOCODB_PROJECT_ID,
hasTableId: !!process.env.NOCODB_TABLE_ID,
projectId: process.env.NOCODB_PROJECT_ID,
tableId: process.env.NOCODB_TABLE_ID,
nodeEnv: process.env.NODE_ENV
};
const isConfigured = config.hasApiUrl && config.hasApiToken && config.hasProjectId && config.hasTableId;
res.json({
configured: isConfigured,
...config
});
});
// Get all locations from NocoDB
app.get('/api/locations', async (req, res) => {
try {
const { limit = 1000, offset = 0, where } = req.query;
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`;
const params = new URLSearchParams({
limit,
offset
});
if (where) {
params.append('where', where);
}
logger.info(`Fetching locations from NocoDB: ${url}`);
const response = await axios.get(`${url}?${params}`, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN,
'Content-Type': 'application/json'
},
timeout: 10000 // 10 second timeout
});
// Process locations to ensure they have required fields
const locations = response.data.list || [];
const validLocations = locations.filter(loc => {
// Apply geo field synchronization to each location
loc = syncGeoFields(loc);
// Check if location has valid coordinates
if (loc.latitude && loc.longitude) {
return true;
}
// Try to parse from geodata column (semicolon-separated)
if (loc.geodata && typeof loc.geodata === 'string') {
const parts = loc.geodata.split(';');
if (parts.length === 2) {
loc.latitude = parseFloat(parts[0]);
loc.longitude = parseFloat(parts[1]);
return !isNaN(loc.latitude) && !isNaN(loc.longitude);
}
}
// 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;
});
logger.info(`Retrieved ${validLocations.length} valid locations out of ${locations.length} total`);
res.json({
success: true,
count: validLocations.length,
total: response.data.pageInfo?.totalRows || validLocations.length,
locations: validLocations
});
} catch (error) {
logger.error('Error fetching locations:', error.message);
if (error.response) {
// NocoDB API error
res.status(error.response.status).json({
success: false,
error: 'Failed to fetch data from NocoDB',
details: error.response.data
});
} else if (error.code === 'ECONNABORTED') {
// Timeout
res.status(504).json({
success: false,
error: 'Request timeout'
});
} else {
// Other errors
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
});
// Get single location by ID
app.get('/api/locations/:id', async (req, res) => {
try {
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`;
const response = await axios.get(url, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN
}
});
res.json({
success: true,
location: response.data
});
} catch (error) {
logger.error(`Error fetching location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to fetch location'
});
}
});
// Create new location
app.post('/api/locations', strictLimiter, async (req, res) => {
try {
let locationData = { ...req.body };
// Sync geo fields before validation
locationData = syncGeoFields(locationData);
const { latitude, longitude, ...additionalData } = locationData;
// Validate coordinates
if (!latitude || !longitude) {
return res.status(400).json({
success: false,
error: 'Latitude and longitude are required'
});
}
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng)) {
return res.status(400).json({
success: false,
error: 'Invalid coordinate values'
});
}
if (lat < -90 || lat > 90) {
return res.status(400).json({
success: false,
error: 'Latitude must be between -90 and 90'
});
}
if (lng < -180 || lng > 180) {
return res.status(400).json({
success: false,
error: 'Longitude must be between -180 and 180'
});
}
// Check bounds if configured
if (process.env.BOUND_NORTH) {
const bounds = {
north: parseFloat(process.env.BOUND_NORTH),
south: parseFloat(process.env.BOUND_SOUTH),
east: parseFloat(process.env.BOUND_EAST),
west: parseFloat(process.env.BOUND_WEST)
};
if (lat > bounds.north || lat < bounds.south ||
lng > bounds.east || lng < bounds.west) {
return res.status(400).json({
success: false,
error: 'Location is outside allowed bounds'
});
}
}
// Format geodata in both formats for compatibility
const geodata = `${lat};${lng}`;
const geoLocation = `${lat};${lng}`; // Use semicolon format for NocoDB GeoData column
// Prepare data for NocoDB
const finalData = {
geodata,
'Geo-Location': geoLocation,
latitude: lat,
longitude: lng,
...additionalData,
created_at: new Date().toISOString()
};
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`;
logger.info('Creating new location:', { lat, lng });
const response = await axios.post(url, finalData, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN,
'Content-Type': 'application/json'
}
});
logger.info('Location created successfully:', response.data.id);
res.status(201).json({
success: true,
location: response.data
});
} catch (error) {
logger.error('Error creating location:', error.message);
if (error.response) {
res.status(error.response.status).json({
success: false,
error: 'Failed to save location to NocoDB',
details: error.response.data
});
} else {
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
});
// Update location
app.put('/api/locations/:id', strictLimiter, async (req, res) => {
try {
let updateData = { ...req.body };
// Sync geo fields
updateData = syncGeoFields(updateData);
updateData.updated_at = new Date().toISOString();
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`;
const response = await axios.patch(url, updateData, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN,
'Content-Type': 'application/json'
}
});
res.json({
success: true,
location: response.data
});
} catch (error) {
logger.error(`Error updating location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to update location'
});
}
});
// Delete location
app.delete('/api/locations/:id', strictLimiter, async (req, res) => {
try {
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`;
await axios.delete(url, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN
}
});
logger.info(`Location ${req.params.id} deleted`);
res.json({
success: true,
message: 'Location deleted successfully'
});
} catch (error) {
logger.error(`Error deleting location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to delete location'
});
}
});
// Error handling middleware
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
res.status(500).json({
success: false,
error: 'Internal server error'
});
});
// Start server
app.listen(PORT, () => {
logger.info(`
NocoDB Map Viewer Server
Status: Running
Port: ${PORT}
Environment: ${process.env.NODE_ENV || 'development'}
Project ID: ${process.env.NOCODB_PROJECT_ID}
Table ID: ${process.env.NOCODB_TABLE_ID}
Time: ${new Date().toISOString()}
`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
app.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
});

View File

@ -0,0 +1,231 @@
const axios = require('axios');
const winston = require('winston');
// Configure logger
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.simple()
})
]
});
// Cache for geocoding results (simple in-memory cache)
const geocodeCache = new Map();
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
// Clean up old cache entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, value] of geocodeCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
geocodeCache.delete(key);
}
}
}, 60 * 60 * 1000); // Run every hour
/**
* Reverse geocode coordinates to get address
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @returns {Promise<Object>} Geocoding result
*/
async function reverseGeocode(lat, lng) {
// Create cache key
const cacheKey = `${lat.toFixed(6)},${lng.toFixed(6)}`;
// Check cache first
const cached = geocodeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
logger.debug(`Geocoding cache hit for ${cacheKey}`);
return cached.data;
}
try {
// Add delay to respect Nominatim rate limits (max 1 request per second)
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info(`Reverse geocoding: ${lat}, ${lng}`);
const response = await axios.get('https://nominatim.openstreetmap.org/reverse', {
params: {
format: 'json',
lat: lat,
lon: lng,
zoom: 18,
addressdetails: 1,
'accept-language': 'en'
},
headers: {
'User-Agent': 'NocoDB Map Viewer 1.0 (https://github.com/yourusername/nocodb-map-viewer)'
},
timeout: 10000
});
if (response.data.error) {
throw new Error(response.data.error);
}
// Process the response
const result = processGeocodeResponse(response.data);
// Cache the result
geocodeCache.set(cacheKey, {
data: result,
timestamp: Date.now()
});
return result;
} catch (error) {
logger.error('Reverse geocoding error:', error.message);
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.');
} else if (error.code === 'ECONNABORTED') {
throw new Error('Geocoding service timeout');
} else {
throw new Error('Geocoding service unavailable');
}
}
}
/**
* Forward geocode address to get coordinates
* @param {string} address - Address to geocode
* @returns {Promise<Object>} Geocoding result
*/
async function forwardGeocode(address) {
// Create cache key
const cacheKey = `addr:${address.toLowerCase()}`;
// Check cache first
const cached = geocodeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
logger.debug(`Geocoding cache hit for ${cacheKey}`);
return cached.data;
}
try {
// Add delay to respect rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info(`Forward geocoding: ${address}`);
const response = await axios.get('https://nominatim.openstreetmap.org/search', {
params: {
format: 'json',
q: address,
limit: 1,
addressdetails: 1,
'accept-language': 'en'
},
headers: {
'User-Agent': 'NocoDB Map Viewer 1.0 (https://github.com/yourusername/nocodb-map-viewer)'
},
timeout: 10000
});
if (!response.data || response.data.length === 0) {
throw new Error('No results found');
}
// Process the first result
const result = processGeocodeResponse(response.data[0]);
// Cache the result
geocodeCache.set(cacheKey, {
data: result,
timestamp: Date.now()
});
return result;
} catch (error) {
logger.error('Forward geocoding error:', error.message);
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.');
} else if (error.code === 'ECONNABORTED') {
throw new Error('Geocoding service timeout');
} else {
throw new Error('Geocoding service unavailable');
}
}
}
/**
* Process geocoding response into standardized format
* @param {Object} data - Raw geocoding response
* @returns {Object} Processed geocoding data
*/
function processGeocodeResponse(data) {
// Extract address components
const addressComponents = {
house_number: data.address?.house_number || '',
road: data.address?.road || '',
suburb: data.address?.suburb || data.address?.neighbourhood || '',
city: data.address?.city || data.address?.town || data.address?.village || '',
state: data.address?.state || data.address?.province || '',
postcode: data.address?.postcode || '',
country: data.address?.country || ''
};
// Create formatted address string
let formattedAddress = '';
if (addressComponents.house_number) formattedAddress += addressComponents.house_number + ' ';
if (addressComponents.road) formattedAddress += addressComponents.road + ', ';
if (addressComponents.suburb) formattedAddress += addressComponents.suburb + ', ';
if (addressComponents.city) formattedAddress += addressComponents.city + ', ';
if (addressComponents.state) formattedAddress += addressComponents.state + ' ';
if (addressComponents.postcode) formattedAddress += addressComponents.postcode;
// Clean up formatting
formattedAddress = formattedAddress.trim().replace(/,$/, '');
return {
fullAddress: data.display_name || '',
formattedAddress: formattedAddress,
components: addressComponents,
coordinates: {
lat: parseFloat(data.lat),
lng: parseFloat(data.lon)
},
boundingBox: data.boundingbox || null,
placeId: data.place_id || null,
osmType: data.osm_type || null,
osmId: data.osm_id || null
};
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
function getCacheStats() {
return {
size: geocodeCache.size,
maxSize: 1000, // Could be made configurable
ttl: CACHE_TTL
};
}
/**
* Clear the geocoding cache
*/
function clearCache() {
geocodeCache.clear();
logger.info('Geocoding cache cleared');
}
module.exports = {
reverseGeocode,
forwardGeocode,
getCacheStats,
clearCache
};

View File

@ -0,0 +1,27 @@
version: '3.8'
services:
map-viewer:
build:
context: ./app
dockerfile: Dockerfile
container_name: nocodb-map-viewer
ports:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=${PORT:-3000}
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View File

@ -183,9 +183,12 @@
<p>Linda is committed to defending and supporting public education. Join her campaign to strengthen Ward B schools and ensure every student has access to quality education.</p>
<div class="btn-group">
<a href="mailto:lindalindsaypublictrustee@gmail.com" class="btn btn-primary" data-track="about_email_campaign">Email the Campaign</a>
<a href="/get_involved.html" class="btn btn-secondary" data-track="about_get_involved">Get Involved</a>
<a href="/election_info.html" class="btn btn-secondary" data-track="about_election_info">Election Information</a>
<a href="/get_involved.html" class="btn btn-primary" data-track="about_get_involved">Get Involved</a>
<a href="/election_info.html" class="btn btn-primary" data-track="about_election_info">Election Information</a>
</div>
<div class="mt-4">
<a href="https://db.lindalindsay.org/dashboard/#/nc/form/dce481a9-a4b1-4766-8a98-63f25a65dc2b/survey" target="_blank" rel="noopener" class="btn btn-primary" data-track="about_volunteer">Volunteer for the Campaign</a>
</div>
<div class="mt-4">

View File

@ -240,7 +240,7 @@ window._wpemojiSettings = {"baseUrl":"https:\/\/s.w.org\/images\/core\/emoji\/15
<p>By e-transfer: <a href="https://docs.google.com/forms/d/e/1FAIpQLSfRTVEqkozbM077guvcI_a7aHUH0XfE5Mkybbdk3x_oI9PzsA/viewform?usp=header" target="_blank" rel="noopener">Fill out this form</a>.</p>
<p>By e-transfer: <a href="https://db.lindalindsay.org/dashboard/#/nc/form/dce481a9-a4b1-4766-8a98-63f25a65dc2b/survey" target="_blank" rel="noopener">Fill out this form</a>.</p>
@ -256,7 +256,7 @@ window._wpemojiSettings = {"baseUrl":"https:\/\/s.w.org\/images\/core\/emoji\/15
<p>If you live in <a href="https://lindalindsay.ca/election-info/#map">Ward B</a>, show your support for Linda Lindsay by putting a sign on your lawn. <a href="https://docs.google.com/forms/d/e/1FAIpQLSfPAY8FuM0o5f0IYIX7-bjQ_OmY_pEL33wEIVo9zjWqLPWl0Q/viewform?usp=preview" target="_blank" rel="noopener">Fill out the form to get a lawn sign</a>.</p>
<p>If you live in <a href="https://lindalindsay.ca/election-info/#map">Ward B</a>, show your support for Linda Lindsay by putting a sign on your lawn. <a href="https://db.lindalindsay.org/dashboard/#/nc/form/dce481a9-a4b1-4766-8a98-63f25a65dc2b/survey" target="_blank" rel="noopener">Fill out the form to get a lawn sign</a>.</p>

View File

@ -77,6 +77,10 @@
<h1>Get Involved</h1>
<p class="tagline">Join the Movement for Strong Public Education</p>
<p>Can you help Linda? Here are some of the ways you can support the campaign and make a difference for Ward B students</p>
<div class="btn-group">
<a href="https://db.lindalindsay.org/dashboard/#/nc/form/bc4ca5a7-20b5-4497-b8d7-0ad5cecb62b8/survey" target="_blank" rel="noopener" class="btn btn-primary" data-track="hero_contact">Volunteer</a>
<a href="https://www.paypal.com/donate/?hosted_button_id=6TC7F6R86YP84" target="_blank" rel="noopener" class="btn btn-primary" data-track="hero_donate">Donate</a>
</div>
</div>
</div>
</section>
@ -115,7 +119,7 @@
<div>
<h4>E-Transfer</h4>
<p><strong>By e-transfer:</strong> Use this secure form to arrange an electronic transfer.</p>
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfRTVEqkozbM077guvcI_a7aHUH0XfE5Mkybbdk3x_oI9PzsA/viewform?usp=header" target="_blank" rel="noopener" class="btn btn-primary" data-track="donate_etransfer">
<a href="https://db.lindalindsay.org/dashboard/#/nc/form/dce481a9-a4b1-4766-8a98-63f25a65dc2b/survey" target="_blank" rel="noopener" class="btn btn-primary" data-track="donate_etransfer">
E-Transfer Form →
</a>
</div>
@ -140,7 +144,7 @@
<h4>How to Get Your Sign</h4>
<p>Fill out our quick form and we'll arrange to get a sign to you. We'll coordinate delivery or pickup based on your preference.</p>
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfPAY8FuM0o5f0IYIX7-bjQ_OmY_pEL33wEIVo9zjWqLPWl0Q/viewform?usp=preview" target="_blank" rel="noopener" class="btn btn-primary" data-track="lawn_sign_request">
<a href="https://db.lindalindsay.org/dashboard/#/nc/form/dce481a9-a4b1-4766-8a98-63f25a65dc2b/survey" target="_blank" rel="noopener" class="btn btn-primary" data-track="lawn_sign_request">
Request a Lawn Sign →
</a>
</div>

View File

@ -97,8 +97,9 @@
<p>Experienced educator with over 30 years in public education. Passionate about strong, progressive public schools that serve and support all students in all neighbourhoods.</p>
<div class="btn-group">
<a href="/get_involved.html" class="btn btn-primary" data-track="hero_get_involved">Get Involved</a>
<a href="/about.html" class="btn btn-secondary" data-track="hero_learn_more">Learn More</a>
<a href="/about.html" class="btn btn-primary" data-track="hero_learn_more">Learn More</a>
<a href="https://db.lindalindsay.org/dashboard/#/nc/form/bc4ca5a7-20b5-4497-b8d7-0ad5cecb62b8/survey" target="_blank" rel="noopener" class="btn btn-secondary" data-track="hero_contact">Volunteer</a>
<a href="https://www.paypal.com/donate/?hosted_button_id=6TC7F6R86YP84" target="_blank" rel="noopener" class="btn btn-secondary" data-track="hero_donate">Donate</a>
</div>
</div>
</div>