mirror of
https://git.lindalindsay.org/admin/linda.lindsay.changemaker.git
synced 2026-04-29 03:46:42 -06:00
added in map view
This commit is contained in:
parent
848f2b07d6
commit
728142d7cf
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -28,8 +28,6 @@ quicklaunch:
|
||||
hideVisitURL: true
|
||||
provider: duckduckgo
|
||||
|
||||
showStats: true
|
||||
|
||||
bookmarks:
|
||||
showCategories: true
|
||||
showIcons: true
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
17
mkdocs/docs/blog/posts/linda-about.md
Normal file
17
mkdocs/docs/blog/posts/linda-about.md
Normal 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!
|
||||
8
mkdocs/docs/blog/posts/update-1.md
Normal file
8
mkdocs/docs/blog/posts/update-1.md
Normal 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!
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
@ -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 © 2025 Linda Lindsay for Ward B School Trustee<br>
|
||||
Copyright © 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
1
mkdocs/site/blog/2025/06/18/welcome/index.html
Normal file
1
mkdocs/site/blog/2025/06/18/welcome/index.html
Normal file
File diff suppressed because one or more lines are too long
1
mkdocs/site/blog/2025/06/19/about_me/index.html
Normal file
1
mkdocs/site/blog/2025/06/19/about_me/index.html
Normal file
File diff suppressed because one or more lines are too long
1
mkdocs/site/blog/archive/2025/index.html
Normal file
1
mkdocs/site/blog/archive/2025/index.html
Normal file
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
@ -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 %}
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
@ -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
@ -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
29
nocodb-map-viewer/.gitignore
vendored
Normal 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
142
nocodb-map-viewer/README.md
Normal 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
|
||||
41
nocodb-map-viewer/app/Dockerfile
Normal file
41
nocodb-map-viewer/app/Dockerfile
Normal 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
1644
nocodb-map-viewer/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
nocodb-map-viewer/app/package.json
Normal file
35
nocodb-map-viewer/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
608
nocodb-map-viewer/app/public/css/style.css
Normal file
608
nocodb-map-viewer/app/public/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
276
nocodb-map-viewer/app/public/index.html
Normal file
276
nocodb-map-viewer/app/public/index.html
Normal 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">×</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>
|
||||
987
nocodb-map-viewer/app/public/js/map.js
Normal file
987
nocodb-map-viewer/app/public/js/map.js
Normal 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: '© <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');
|
||||
}
|
||||
}
|
||||
113
nocodb-map-viewer/app/routes/geocoding.js
Normal file
113
nocodb-map-viewer/app/routes/geocoding.js
Normal 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;
|
||||
515
nocodb-map-viewer/app/server.js
Normal file
515
nocodb-map-viewer/app/server.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
231
nocodb-map-viewer/app/services/geocoding.js
Normal file
231
nocodb-map-viewer/app/services/geocoding.js
Normal 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
|
||||
};
|
||||
27
nocodb-map-viewer/docker-compose.yml
Normal file
27
nocodb-map-viewer/docker-compose.yml
Normal 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"
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user