test
This commit is contained in:
commit
ac7dd74f30
2579
nocodb-map-guide.md
Normal file
2579
nocodb-map-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
23
nocodb-map-viewer/.env.example
Normal file
23
nocodb-map-viewer/.env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# NocoDB API Configuration
|
||||
NOCODB_API_URL=https://db.lindalindsay.org/api/v1
|
||||
NOCODB_API_TOKEN=your-api-token-here
|
||||
NOCODB_VIEW_URL=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/mvtryxrvze6td79
|
||||
|
||||
# Auto-parsed from NOCODB_VIEW_URL (no need to set manually)
|
||||
# NOCODB_PROJECT_ID=p406kno3lbq4zmq
|
||||
# NOCODB_TABLE_ID=mvtryxrvze6td79
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# Map Defaults (Edmonton, Alberta, Canada)
|
||||
DEFAULT_LAT=53.5461
|
||||
DEFAULT_LNG=-113.4938
|
||||
DEFAULT_ZOOM=11
|
||||
|
||||
# Optional: Map Boundaries (prevents users from adding points outside area)
|
||||
# BOUND_NORTH=53.7
|
||||
# BOUND_SOUTH=53.4
|
||||
# BOUND_EAST=-113.3
|
||||
# BOUND_WEST=-113.7
|
||||
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
|
||||
39
nocodb-map-viewer/app/Dockerfile
Normal file
39
nocodb-map-viewer/app/Dockerfile
Normal file
@ -0,0 +1,39 @@
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
500
nocodb-map-viewer/app/public/css/style.css
Normal file
500
nocodb-map-viewer/app/public/css/style.css
Normal file
@ -0,0 +1,500 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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[readonly] {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
133
nocodb-map-viewer/app/public/index.html
Normal file
133
nocodb-map-viewer/app/public/index.html
Normal file
@ -0,0 +1,133 @@
|
||||
<!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">
|
||||
📍 My Location
|
||||
</button>
|
||||
<button id="add-location-btn" class="btn btn-success" title="Add location at map center">
|
||||
➕ Add Location Here
|
||||
</button>
|
||||
<button id="fullscreen-btn" class="btn btn-secondary" title="Toggle fullscreen">
|
||||
⛶ Fullscreen
|
||||
</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>
|
||||
|
||||
<!-- 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" onclick="closeModal()">×</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-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-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-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">
|
||||
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>
|
||||
448
nocodb-map-viewer/app/public/js/map.js
Normal file
448
nocodb-map-viewer/app/public/js/map.js
Normal file
@ -0,0 +1,448 @@
|
||||
// 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;
|
||||
|
||||
// Initialize application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeMap();
|
||||
loadLocations();
|
||||
setupEventListeners();
|
||||
checkConfiguration();
|
||||
|
||||
// Set up auto-refresh
|
||||
refreshInterval = setInterval(loadLocations, CONFIG.REFRESH_INTERVAL);
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Map click handler for adding locations
|
||||
map.on('click', handleMapClick);
|
||||
}
|
||||
|
||||
// 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
|
||||
function createLocationMarker(location) {
|
||||
const marker = L.marker([location.latitude, location.longitude], {
|
||||
title: location.title || 'Location',
|
||||
riseOnHover: true
|
||||
}).addTo(map);
|
||||
|
||||
// Create popup content
|
||||
const popupContent = createPopupContent(location);
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
// Create popup content for marker
|
||||
function createPopupContent(location) {
|
||||
let content = '<div class="popup-content">';
|
||||
|
||||
// Handle name from either 'title' field or 'First Name'/'Last Name' combination
|
||||
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>`;
|
||||
}
|
||||
|
||||
if (location.description) {
|
||||
content += `<p>${escapeHtml(location.description)}</p>`;
|
||||
}
|
||||
|
||||
if (location.category) {
|
||||
content += `<p><strong>Category:</strong> ${escapeHtml(location.category)}</p>`;
|
||||
}
|
||||
|
||||
if (location['Email']) {
|
||||
content += `<p><strong>Email:</strong> ${escapeHtml(location['Email'])}</p>`;
|
||||
}
|
||||
|
||||
if (location['Unit Number']) {
|
||||
content += `<p><strong>Unit:</strong> ${escapeHtml(location['Unit Number'])}</p>`;
|
||||
}
|
||||
|
||||
content += '<div class="popup-meta">';
|
||||
content += `<p><strong>Coordinates:</strong> ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}</p>`;
|
||||
|
||||
if (location.created_at) {
|
||||
const date = new Date(location.created_at);
|
||||
content += `<p><strong>Added:</strong> ${date.toLocaleDateString()}</p>`;
|
||||
}
|
||||
|
||||
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.textContent = '✕ Cancel';
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-secondary');
|
||||
crosshair.classList.remove('hidden');
|
||||
map.getContainer().style.cursor = 'crosshair';
|
||||
} else {
|
||||
btn.textContent = '➕ Add Location Here';
|
||||
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');
|
||||
|
||||
// Set coordinates
|
||||
latInput.value = lat.toFixed(8);
|
||||
lngInput.value = lng.toFixed(8);
|
||||
|
||||
// Clear other fields
|
||||
document.getElementById('first-name').value = '';
|
||||
document.getElementById('last-name').value = '';
|
||||
document.getElementById('location-email').value = '';
|
||||
document.getElementById('location-unit').value = '';
|
||||
|
||||
// Show modal
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Focus on first name input
|
||||
setTimeout(() => {
|
||||
document.getElementById('first-name').focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal() {
|
||||
document.getElementById('add-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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.textContent = '✕ Exit Fullscreen';
|
||||
|
||||
// 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.textContent = '⛶ Fullscreen';
|
||||
|
||||
// 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) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
map.invalidateSize();
|
||||
});
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
|
||||
// Make closeModal function global for onclick handler
|
||||
window.closeModal = closeModal;
|
||||
465
nocodb-map-viewer/app/server.js
Normal file
465
nocodb-map-viewer/app/server.js
Normal file
@ -0,0 +1,465 @@
|
||||
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();
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 => {
|
||||
// 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 {
|
||||
const { latitude, longitude, ...additionalData } = req.body;
|
||||
|
||||
// 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}`;
|
||||
|
||||
// Prepare data for NocoDB
|
||||
const locationData = {
|
||||
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, locationData, {
|
||||
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 {
|
||||
const { latitude, longitude, ...additionalData } = req.body;
|
||||
|
||||
const updateData = { ...additionalData };
|
||||
|
||||
// Update geodata if coordinates changed
|
||||
if (latitude !== undefined && longitude !== undefined) {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
updateData.latitude = lat;
|
||||
updateData.longitude = lng;
|
||||
updateData.geodata = `${lat};${lng}`;
|
||||
updateData['Geo-Location'] = `${lat}, ${lng}`;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
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"
|
||||
Loading…
x
Reference in New Issue
Block a user