Merge pull request #14 from derekslenk/feature/mapbox-static-maps
Add Mapbox static map generation for non-JavaScript users
This commit is contained in:
commit
10c6e54062
12 changed files with 1043 additions and 53 deletions
58
CLAUDE.md
58
CLAUDE.md
|
@ -86,11 +86,12 @@ npm run test:coverage
|
|||
Before running the application, you must configure environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env to add your MapBox token and admin password
|
||||
# Edit .env to add your Mapbox token and admin password
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
- `MAPBOX_ACCESS_TOKEN`: MapBox API token for geocoding (get free token at https://account.mapbox.com/access-tokens/)
|
||||
- `MAPBOX_ACCESS_TOKEN`: Mapbox API token for geocoding and static map generation (get free token at https://account.mapbox.com/access-tokens/)
|
||||
- **Important**: For server-side static map generation, use an unrestricted token (no URL restrictions)
|
||||
- `ADMIN_PASSWORD`: Password for admin panel access at /admin
|
||||
- `PORT`: Server port (default: 3000)
|
||||
|
||||
|
@ -116,6 +117,7 @@ Routes are organized as factory functions accepting dependencies with full TypeS
|
|||
- **src/models/ProfanityWord.ts**: Type-safe database operations for profanity words
|
||||
- **src/services/DatabaseService.ts**: Centralized database connection management
|
||||
- **src/services/ProfanityFilterService.ts**: Content moderation with type safety
|
||||
- **src/services/MapImageService.ts**: Server-side static map generation using Mapbox Static Images API
|
||||
- **src/types/index.ts**: Shared TypeScript interfaces and type definitions
|
||||
|
||||
### Database Schema
|
||||
|
@ -136,33 +138,45 @@ CREATE TABLE locations (
|
|||
**Profanity Database (`profanity.db`)**:
|
||||
Managed by the `ProfanityFilter` class for content moderation.
|
||||
|
||||
### Frontend (Vanilla JavaScript)
|
||||
Multiple map implementations for flexibility:
|
||||
### Frontend (Progressive Enhancement)
|
||||
The application uses progressive enhancement to work with and without JavaScript:
|
||||
|
||||
**JavaScript-Enhanced Experience:**
|
||||
- **public/app.js**: Main implementation using Leaflet.js
|
||||
- Auto-detects available geocoding services (MapBox preferred, Nominatim fallback)
|
||||
|
||||
- **public/app-mapbox.js**: MapBox GL JS implementation for enhanced features
|
||||
- Auto-detects available geocoding services (Mapbox preferred, Nominatim fallback)
|
||||
- Interactive map with real-time updates
|
||||
- Autocomplete and form validation
|
||||
|
||||
- **public/app-mapbox.js**: Mapbox GL JS implementation for enhanced features
|
||||
- **public/app-google.js**: Google Maps implementation (alternative)
|
||||
|
||||
- **public/admin.js**: Admin panel functionality
|
||||
- Location management (view, edit, delete)
|
||||
- Persistent location toggle
|
||||
- Profanity word management
|
||||
|
||||
- **public/utils.js**: Shared utilities across implementations
|
||||
|
||||
**Non-JavaScript Fallback:**
|
||||
- **Server-side /table route**: Complete HTML table view of all locations
|
||||
- **HTML form submission**: Works via POST to /submit-report endpoint
|
||||
- **Static map generation**: Auto-fitted Mapbox static images showing all report locations
|
||||
- **Progressive enhancement**: noscript tags and "Basic View" button for accessibility
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**Public endpoints:**
|
||||
- `GET /api/config`: Returns MapBox token for frontend geocoding
|
||||
- `GET /api/config`: Returns Mapbox token for frontend geocoding
|
||||
- `GET /api/locations`: Active locations (< 48 hours old or persistent)
|
||||
- `POST /api/locations`: Submit new location report (rate limited: 10/15min per IP)
|
||||
- **Input Validation:** Address ≤500 chars, Description ≤1000 chars
|
||||
- **Input Validation:** Address ≤500 chars, Description ≤1000 chars, coordinate validation
|
||||
- **Profanity Filtering:** Automatic content moderation with rejection
|
||||
- **Security:** Rate limiting prevents spam and DoS attacks
|
||||
|
||||
**Server-side routes (Progressive Enhancement):**
|
||||
- `GET /`: Main application page with JavaScript-enhanced features
|
||||
- `GET /table`: Non-JavaScript table view with static map and HTML forms
|
||||
- `POST /submit-report`: Server-side form submission for non-JavaScript users
|
||||
- `GET /map-image.png`: Dynamic static map generation using Mapbox Static Images API
|
||||
- **Auto-fit positioning:** Centers on actual location coordinates
|
||||
- **Numbered pins:** Color-coded markers (red=regular, orange=persistent)
|
||||
- **Query parameters:** `?width=800&height=600&padding=50` for customization
|
||||
|
||||
**Admin endpoints (require Bearer token):**
|
||||
- `POST /api/admin/login`: Authenticate and receive token
|
||||
- `GET /api/admin/locations`: All locations including expired
|
||||
|
@ -188,13 +202,15 @@ SCSS files are in `src/scss/`:
|
|||
### Key Design Patterns
|
||||
|
||||
1. **TypeScript-First Architecture**: Full type safety with strict type checking
|
||||
2. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
|
||||
3. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
|
||||
4. **Dual Database Design**: Separate databases for application data and content moderation
|
||||
5. **Type-Safe Database Operations**: All database interactions use typed models
|
||||
6. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
|
||||
7. **Graceful Degradation**: Fallback geocoding providers and error handling
|
||||
8. **Automated Maintenance**: Cron-based cleanup of expired reports
|
||||
2. **Progressive Enhancement**: Works completely without JavaScript via server-side rendering
|
||||
3. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
|
||||
4. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
|
||||
5. **Dual Database Design**: Separate databases for application data and content moderation
|
||||
6. **Type-Safe Database Operations**: All database interactions use typed models
|
||||
7. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
|
||||
8. **Graceful Degradation**: Fallback geocoding providers and error handling
|
||||
9. **Automated Maintenance**: Cron-based cleanup of expired reports
|
||||
10. **Accessibility-First**: noscript fallbacks and server-side static map generation
|
||||
|
||||
### Deployment
|
||||
- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh`
|
||||
|
|
34
README.md
34
README.md
|
@ -5,10 +5,13 @@ A community-driven web application for tracking winter road conditions and icy h
|
|||
## Features
|
||||
|
||||
- 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids
|
||||
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with MapBox API
|
||||
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with Mapbox API
|
||||
- 🚫 **JavaScript-Free Mode** - Complete functionality without JavaScript via server-side rendering
|
||||
- 🖼️ **Static Maps** - Auto-generated Mapbox static images for non-JS users
|
||||
- 🔄 **Auto-Expiration** - Reports automatically removed after 48 hours
|
||||
- 👨💼 **Admin Panel** - Manage and moderate location reports
|
||||
- 📱 **Responsive Design** - Works on desktop and mobile devices
|
||||
- ♿ **Accessibility First** - Progressive enhancement with noscript fallbacks
|
||||
- 🔒 **Privacy-Focused** - No user tracking, community safety oriented
|
||||
- 🛡️ **Enhanced Security** - Coordinate validation, rate limiting, and content filtering
|
||||
|
||||
|
@ -16,7 +19,8 @@ A community-driven web application for tracking winter road conditions and icy h
|
|||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- MapBox API token (free tier available)
|
||||
- Mapbox API token (free tier available)
|
||||
- **Important**: For server-side static maps, use an unrestricted token (no URL restrictions)
|
||||
|
||||
### Local Development
|
||||
|
||||
|
@ -35,7 +39,7 @@ A community-driven web application for tracking winter road conditions and icy h
|
|||
3. **Configure environment variables:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your MapBox token
|
||||
# Edit .env with your Mapbox token
|
||||
```
|
||||
|
||||
4. **Start the server:**
|
||||
|
@ -89,7 +93,7 @@ SCSS files are organized in `src/scss/`:
|
|||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Required for fast geocoding
|
||||
# Required for geocoding and static map generation
|
||||
MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
|
||||
|
||||
# Admin panel access
|
||||
|
@ -99,6 +103,11 @@ ADMIN_PASSWORD=your_secure_password
|
|||
PORT=3000
|
||||
```
|
||||
|
||||
**Mapbox Token Requirements:**
|
||||
- For interactive geocoding: Token can have URL restrictions
|
||||
- For server-side static maps: Must use unrestricted token (no URL restrictions)
|
||||
- Recommended: Use one unrestricted token for both features
|
||||
|
||||
## Deployment
|
||||
|
||||
### Automated Deployment (Debian 12 ARM64)
|
||||
|
@ -140,6 +149,15 @@ See `docs/deployment.md` for detailed manual deployment instructions.
|
|||
- `POST /api/locations` - Submit new location report (rate limited: 10/15min per IP)
|
||||
- `GET /api/config` - Get API configuration
|
||||
|
||||
### Progressive Enhancement Routes
|
||||
- `GET /` - Main application page with JavaScript-enhanced features
|
||||
- `GET /table` - Non-JavaScript table view with static map and HTML forms
|
||||
- `POST /submit-report` - Server-side form submission for non-JavaScript users
|
||||
- `GET /map-image.png` - Dynamic static map generation using Mapbox Static Images API
|
||||
- Query parameters: `?width=800&height=600&padding=50` for customization
|
||||
- Auto-fit positioning centers on actual location coordinates
|
||||
- Color-coded pins: red for regular reports, orange for persistent
|
||||
|
||||
### Admin Endpoints (Authentication Required)
|
||||
- `GET /admin` - Admin panel (password protected)
|
||||
- `GET /api/admin/locations` - Get all location reports
|
||||
|
@ -157,12 +175,16 @@ Interactive API documentation available at `/api-docs` when running the server.
|
|||
## Technology Stack
|
||||
|
||||
- **Backend:** Node.js, Express.js, SQLite, TypeScript
|
||||
- **Frontend:** Vanilla JavaScript, Leaflet.js
|
||||
- **Geocoding:** MapBox API (with Nominatim fallback)
|
||||
- **Frontend:** Progressive Enhancement (Vanilla JavaScript + Server-side rendering)
|
||||
- **Enhanced:** Leaflet.js interactive maps with real-time updates
|
||||
- **Fallback:** Server-side HTML tables with static Mapbox images
|
||||
- **Geocoding:** Mapbox API (with Nominatim fallback)
|
||||
- **Maps:** Leaflet.js (interactive) + Mapbox Static Images API (server-side)
|
||||
- **Security:** Rate limiting, input validation, authentication
|
||||
- **Testing:** Jest, TypeScript, 128+ tests with 76% coverage
|
||||
- **Reverse Proxy:** Caddy (automatic HTTPS)
|
||||
- **Database:** SQLite (lightweight, serverless)
|
||||
- **Accessibility:** Full progressive enhancement with noscript support
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
505
package-lock.json
generated
505
package-lock.json
generated
|
@ -10,11 +10,13 @@
|
|||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"canvas": "^3.1.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"sharp": "^0.34.2",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
|
@ -727,6 +729,16 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
|
||||
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
|
@ -1047,6 +1059,402 @@
|
|||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz",
|
||||
"integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz",
|
||||
"integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz",
|
||||
"integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz",
|
||||
"integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz",
|
||||
"integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz",
|
||||
"integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz",
|
||||
"integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||
|
@ -3532,6 +3940,20 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.2.tgz",
|
||||
"integrity": "sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.0.0",
|
||||
"prebuild-install": "^7.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.12.0 || >= 20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
@ -3682,11 +4104,23 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
@ -3699,9 +4133,18 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
|
@ -8220,6 +8663,47 @@
|
|||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz",
|
||||
"integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
"detect-libc": "^2.0.4",
|
||||
"semver": "^7.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.2",
|
||||
"@img/sharp-darwin-x64": "0.34.2",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.1.0",
|
||||
"@img/sharp-libvips-darwin-x64": "1.1.0",
|
||||
"@img/sharp-libvips-linux-arm": "1.1.0",
|
||||
"@img/sharp-libvips-linux-arm64": "1.1.0",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.1.0",
|
||||
"@img/sharp-libvips-linux-s390x": "1.1.0",
|
||||
"@img/sharp-libvips-linux-x64": "1.1.0",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.1.0",
|
||||
"@img/sharp-linux-arm": "0.34.2",
|
||||
"@img/sharp-linux-arm64": "0.34.2",
|
||||
"@img/sharp-linux-s390x": "0.34.2",
|
||||
"@img/sharp-linux-x64": "0.34.2",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.2",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.2",
|
||||
"@img/sharp-wasm32": "0.34.2",
|
||||
"@img/sharp-win32-arm64": "0.34.2",
|
||||
"@img/sharp-win32-ia32": "0.34.2",
|
||||
"@img/sharp-win32-x64": "0.34.2"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
@ -8380,6 +8864,21 @@
|
|||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle/node_modules/is-arrayish": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
|
||||
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
|
@ -9082,7 +9581,7 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
|
|
|
@ -22,11 +22,13 @@
|
|||
"postinstall": "npm run build-css"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"node-cron": "^3.0.3",
|
||||
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = L.map('map').setView([42.9634, -85.6681], 10);
|
||||
const map = L.map('map').setView([42.96008, -85.67403], 10);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
|
|
|
@ -20,6 +20,12 @@
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<noscript>
|
||||
<style>
|
||||
.js-only { display: none !important; }
|
||||
.nojs-fallback { display: block !important; }
|
||||
</style>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
@ -29,23 +35,31 @@
|
|||
<h1>❄️ Great Lakes Ice Report</h1>
|
||||
<p>Community-reported ICEy road conditions and winter hazards (auto-expire after 48 hours)</p>
|
||||
</div>
|
||||
<button id="theme-toggle" class="theme-toggle" title="Toggle dark mode">
|
||||
<button id="theme-toggle" class="theme-toggle js-only" title="Toggle dark mode">
|
||||
<span class="theme-icon">🌙</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Non-JavaScript fallback notice -->
|
||||
<noscript>
|
||||
<div class="nojs-notice">
|
||||
<p><strong>JavaScript is disabled.</strong> For the best experience with interactive maps, please enable JavaScript.</p>
|
||||
<p><a href="/table" style="color: #007bff; text-decoration: underline;">Click here to view the table-only version →</a></p>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Report ICEy Conditions</h2>
|
||||
<form id="location-form">
|
||||
<form id="location-form" method="POST" action="/submit-report">
|
||||
<div class="form-group">
|
||||
<label for="address">Address or Location *</label>
|
||||
<div class="autocomplete-container">
|
||||
<input type="text" id="address" name="address" required
|
||||
placeholder="Enter address, intersection (e.g., Main St & Second St, City), or landmark"
|
||||
autocomplete="off">
|
||||
<div id="autocomplete-list" class="autocomplete-list"></div>
|
||||
<div id="autocomplete-list" class="autocomplete-list js-only"></div>
|
||||
</div>
|
||||
<small class="input-help">Examples: "123 Main St, City" or "Main St & Oak Ave, City" or "CVS Pharmacy, City"</small>
|
||||
</div>
|
||||
|
@ -59,23 +73,38 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
|
|||
|
||||
<button type="submit" id="submit-btn">
|
||||
<span id="submit-text">Report Location</span>
|
||||
<span id="submit-loading" style="display: none;">Submitting...</span>
|
||||
<span id="submit-loading" style="display: none;" class="js-only">Submitting...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
<div id="message" class="message js-only"></div>
|
||||
</div>
|
||||
|
||||
<div class="map-section">
|
||||
<div class="reports-header">
|
||||
<h2>Current Reports</h2>
|
||||
<div class="view-toggle">
|
||||
<div class="view-toggle js-only">
|
||||
<button id="map-view-btn" class="toggle-btn active">📍 Map View</button>
|
||||
<button id="table-view-btn" class="toggle-btn">📋 Table View</button>
|
||||
<a href="/table" class="toggle-btn" style="text-decoration: none; line-height: normal;" title="Server-side view that works without JavaScript">📊 Basic View</a>
|
||||
</div>
|
||||
<noscript>
|
||||
<div class="nojs-fallback" style="display: block;">
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<h3>Static Map Overview</h3>
|
||||
<img src="/map-image.png?width=600&height=400"
|
||||
alt="Static map showing ice report locations"
|
||||
style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<p style="font-size: 14px; color: #666; margin-top: 8px;">
|
||||
Red markers: Regular reports | Orange markers: Persistent reports
|
||||
</p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="/table" style="color: #007bff; text-decoration: underline;">📋 View Detailed Table Format →</a></p>
|
||||
</div>
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
<div id="map-view" class="view-container">
|
||||
<div id="map-view" class="view-container js-only">
|
||||
<div id="map"></div>
|
||||
<div class="map-info">
|
||||
<p><strong>🔴 Red markers:</strong> Icy conditions reported</p>
|
||||
|
@ -84,7 +113,7 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="table-view" class="view-container" style="display: none;">
|
||||
<div id="table-view" class="view-container js-only" style="display: none;">
|
||||
<div class="table-controls">
|
||||
<div class="table-info">
|
||||
<p id="table-location-count">Loading locations...</p>
|
||||
|
|
|
@ -10,7 +10,7 @@ export default (): Router => {
|
|||
* tags:
|
||||
* - Public API
|
||||
* summary: Get API configuration
|
||||
* description: Returns public API configuration including MapBox access token for geocoding
|
||||
* description: Returns public API configuration including Mapbox access token for geocoding
|
||||
* responses:
|
||||
* 200:
|
||||
* description: API configuration retrieved successfully
|
||||
|
@ -20,12 +20,12 @@ export default (): Router => {
|
|||
* $ref: '#/components/schemas/ApiConfig'
|
||||
* examples:
|
||||
* with_mapbox:
|
||||
* summary: Configuration with MapBox token
|
||||
* summary: Configuration with Mapbox token
|
||||
* value:
|
||||
* mapboxAccessToken: "pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJhYmNkZWZnIn0.example"
|
||||
* hasMapbox: true
|
||||
* without_mapbox:
|
||||
* summary: Configuration without MapBox token
|
||||
* summary: Configuration without Mapbox token
|
||||
* value:
|
||||
* mapboxAccessToken: null
|
||||
* hasMapbox: false
|
||||
|
@ -40,11 +40,11 @@ export default (): Router => {
|
|||
console.log('📡 API Config requested');
|
||||
const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined;
|
||||
|
||||
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
|
||||
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
|
||||
console.log('Mapbox token present:', !!MAPBOX_ACCESS_TOKEN);
|
||||
console.log('Mapbox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
|
||||
|
||||
res.json({
|
||||
// MapBox tokens are designed to be public (they have domain restrictions)
|
||||
// Mapbox tokens are designed to be public (they have domain restrictions)
|
||||
mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null,
|
||||
hasMapbox: !!MAPBOX_ACCESS_TOKEN
|
||||
// SECURITY: Google Maps API key is kept server-side only
|
||||
|
|
|
@ -187,3 +187,23 @@ button {
|
|||
|
||||
.w-100 { width: 100%; }
|
||||
.h-100 { height: 100%; }
|
||||
|
||||
// Progressive enhancement styles
|
||||
.js-only {
|
||||
// Show by default (when JavaScript is available)
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nojs-fallback {
|
||||
// Hide by default (only show when JavaScript is disabled via noscript)
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nojs-notice {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
@ -124,7 +124,7 @@
|
|||
|
||||
// Theme Toggle Mixin (consolidates duplicated theme toggle styles)
|
||||
@mixin theme-toggle-styles($width: 40px, $height: 40px) {
|
||||
@include button($bg-color: transparent);
|
||||
@include button($bg-color: var(--card-bg), $text-color: var(--text-color));
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: $border-radius-full;
|
||||
width: $width;
|
||||
|
@ -132,11 +132,27 @@
|
|||
@include flex-center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
padding: 0; // Remove padding to ensure centering
|
||||
|
||||
// Ensure the icon is centered
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--table-hover);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-toggle:hover & {
|
||||
background-color: var(--table-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// Back-link button mixin (shared component)
|
||||
|
|
264
src/server.ts
264
src/server.ts
|
@ -13,6 +13,7 @@ dotenv.config();
|
|||
// Import services and models
|
||||
import DatabaseService from './services/DatabaseService';
|
||||
import ProfanityFilterService from './services/ProfanityFilterService';
|
||||
import MapImageService from './services/MapImageService';
|
||||
|
||||
// Import route modules
|
||||
import configRoutes from './routes/config';
|
||||
|
@ -26,11 +27,12 @@ const PORT: number = parseInt(process.env.PORT || '3000', 10);
|
|||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Database and services setup
|
||||
const databaseService = new DatabaseService();
|
||||
let profanityFilter: ProfanityFilterService | FallbackFilter;
|
||||
const mapImageService = new MapImageService();
|
||||
|
||||
// Fallback filter interface for type safety
|
||||
interface FallbackFilter {
|
||||
|
@ -181,17 +183,271 @@ function setupRoutes(): void {
|
|||
// Static page routes
|
||||
app.get('/', (req: Request, res: Response): void => {
|
||||
console.log('Serving the main page');
|
||||
res.sendFile(path.join(__dirname, '../../public', 'index.html'));
|
||||
res.sendFile(path.join(__dirname, '../public', 'index.html'));
|
||||
});
|
||||
|
||||
// Non-JavaScript table view route
|
||||
app.get('/table', async (req: Request, res: Response): Promise<void> => {
|
||||
console.log('Serving table view for non-JS users');
|
||||
try {
|
||||
const locations = await locationModel.getActive();
|
||||
|
||||
const formatTimeRemaining = (createdAt: string): string => {
|
||||
const created = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const diffMs = (48 * 60 * 60 * 1000) - (now.getTime() - created.getTime());
|
||||
|
||||
if (diffMs <= 0) return 'Expired';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const escapeHtml = (text: string): string => {
|
||||
return text.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
const tableRows = locations.map((location, index) => `
|
||||
<tr>
|
||||
<td style="text-align: center; font-weight: bold;">${index + 1}</td>
|
||||
<td>${escapeHtml(location.address)}</td>
|
||||
<td>${escapeHtml(location.description || 'No additional details')}</td>
|
||||
<td>${location.created_at ? formatDate(location.created_at) : 'Unknown'}</td>
|
||||
<td>${location.persistent ? 'Persistent' : (location.created_at ? formatTimeRemaining(location.created_at) : 'Unknown')}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Great Lakes Ice Report - Table View</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
<h1>❄️ Great Lakes Ice Report</h1>
|
||||
<p>Community-reported ICEy road conditions and winter hazards</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="form-section">
|
||||
<h2>Report ICEy Conditions</h2>
|
||||
<form method="POST" action="/submit-report">
|
||||
<div class="form-group">
|
||||
<label for="address">Address or Location *</label>
|
||||
<input type="text" id="address" name="address" required
|
||||
placeholder="Enter address, intersection, or landmark">
|
||||
<small class="input-help">Examples: "123 Main St, City" or "Main St & Oak Ave, City"</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Additional Details (Optional)</label>
|
||||
<textarea id="description" name="description" rows="3"
|
||||
placeholder="Number of vehicles, time observed, etc."></textarea>
|
||||
<small class="input-help">Keep descriptions appropriate and relevant to road conditions.</small>
|
||||
</div>
|
||||
|
||||
<button type="submit">Report Location</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="map-section">
|
||||
<div class="reports-header">
|
||||
<h2>Current Reports (${locations.length} active)</h2>
|
||||
<p><a href="/">← Back to Interactive Map</a></p>
|
||||
</div>
|
||||
|
||||
${locations.length > 0 ? `
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<h3>Static Map Overview</h3>
|
||||
<img src="/map-image.png?width=800&height=400"
|
||||
alt="Static map showing ice report locations"
|
||||
style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<p style="font-size: 14px; color: #666; margin-top: 8px;">
|
||||
Red markers: Regular reports | Orange markers: Persistent reports
|
||||
</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="table-container">
|
||||
<table class="reports-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Location</th>
|
||||
<th>Details</th>
|
||||
<th>Reported</th>
|
||||
<th>Time Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows || '<tr><td colspan="5">No reports currently available</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p><strong>Safety Notice:</strong> This is a community tool for awareness. Stay safe and verify information independently.</p>
|
||||
<div class="disclaimer">
|
||||
<small>Reports are automatically deleted after 48 hours. • <a href="/privacy">Privacy Policy</a></small>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.send(html);
|
||||
} catch (err) {
|
||||
console.error('Error serving table view:', err);
|
||||
res.status(500).send('Internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission for non-JS users
|
||||
app.post('/submit-report', async (req: Request, res: Response): Promise<void> => {
|
||||
console.log('Handling form submission for non-JS users');
|
||||
|
||||
const { address, description } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!address || typeof address !== 'string' || address.trim().length === 0) {
|
||||
res.status(400).send(`
|
||||
<html>
|
||||
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error</h1>
|
||||
<p>Address is required.</p>
|
||||
<p><a href="/table">← Go Back</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for profanity if description provided
|
||||
if (description && profanityFilter) {
|
||||
try {
|
||||
const analysis = profanityFilter.analyzeProfanity(description);
|
||||
if (analysis.hasProfanity) {
|
||||
res.status(400).send(`
|
||||
<html>
|
||||
<head><title>Submission Rejected</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Submission Rejected</h1>
|
||||
<p>Your description contains inappropriate language and cannot be posted.</p>
|
||||
<p>Please revise your description to focus on road conditions and keep it professional.</p>
|
||||
<p><a href="/table">← Go Back</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
} catch (filterError) {
|
||||
console.error('Error checking profanity:', filterError);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await locationModel.create({
|
||||
address: address.trim(),
|
||||
description: description?.trim() || null
|
||||
});
|
||||
|
||||
res.send(`
|
||||
<html>
|
||||
<head><title>Report Submitted</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✅ Report Submitted Successfully</h1>
|
||||
<p>Your ice condition report has been added to the system.</p>
|
||||
<p><a href="/table">← View All Reports</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} catch (err) {
|
||||
console.error('Error creating location:', err);
|
||||
res.status(500).send(`
|
||||
<html>
|
||||
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error</h1>
|
||||
<p>Failed to submit report. Please try again.</p>
|
||||
<p><a href="/table">← Go Back</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// Static map image generation route
|
||||
app.get('/map-image.png', async (req: Request, res: Response): Promise<void> => {
|
||||
console.log('Generating static map image');
|
||||
try {
|
||||
const locations = await locationModel.getActive();
|
||||
|
||||
// Parse query parameters for customization
|
||||
const width = parseInt(req.query.width as string) || 800;
|
||||
const height = parseInt(req.query.height as string) || 600;
|
||||
const padding = parseInt(req.query.padding as string) || 50;
|
||||
|
||||
const imageBuffer = await mapImageService.generateMapImage(locations, {
|
||||
width: Math.min(Math.max(width, 400), 1200), // Clamp between 400-1200
|
||||
height: Math.min(Math.max(height, 300), 900), // Clamp between 300-900
|
||||
padding: Math.min(Math.max(padding, 20), 100) // Clamp between 20-100
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.send(imageBuffer);
|
||||
} catch (err) {
|
||||
console.error('Error generating map image:', err);
|
||||
res.status(500).send('Error generating map image');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin', (req: Request, res: Response): void => {
|
||||
console.log('Serving the admin page');
|
||||
res.sendFile(path.join(__dirname, '../../public', 'admin.html'));
|
||||
res.sendFile(path.join(__dirname, '../public', 'admin.html'));
|
||||
});
|
||||
|
||||
app.get('/privacy', (req: Request, res: Response): void => {
|
||||
console.log('Serving the privacy policy page');
|
||||
res.sendFile(path.join(__dirname, '../../public', 'privacy.html'));
|
||||
res.sendFile(path.join(__dirname, '../public', 'privacy.html'));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
130
src/services/MapImageService.ts
Normal file
130
src/services/MapImageService.ts
Normal file
|
@ -0,0 +1,130 @@
|
|||
import https from 'https';
|
||||
import { Location } from '../types';
|
||||
|
||||
interface MapOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
padding?: number; // Reintroduced padding property
|
||||
}
|
||||
|
||||
export class MapImageService {
|
||||
private defaultOptions: MapOptions = {
|
||||
width: 800,
|
||||
height: 600
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a static map image using Mapbox Static Maps API
|
||||
*/
|
||||
async generateMapImage(locations: Location[], options: Partial<MapOptions> = {}): Promise<Buffer> {
|
||||
const opts = { ...this.defaultOptions, ...options };
|
||||
|
||||
console.info('Generating Mapbox static map focused on location data');
|
||||
console.info('Canvas size:', opts.width, 'x', opts.height);
|
||||
console.info('Number of locations:', locations.length);
|
||||
|
||||
const mapboxBuffer = await this.fetchMapboxStaticMapAutoFit(opts, locations);
|
||||
|
||||
if (mapboxBuffer) {
|
||||
return mapboxBuffer;
|
||||
} else {
|
||||
// Return a simple error image if Mapbox fails
|
||||
return this.generateErrorImage(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch map using Mapbox Static Maps API with auto-fit to location data
|
||||
*/
|
||||
private async fetchMapboxStaticMapAutoFit(options: MapOptions, locations: Location[]): Promise<Buffer | null> {
|
||||
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
|
||||
if (!mapboxToken) {
|
||||
console.error('No Mapbox token available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build overlay string for all locations with correct format
|
||||
let overlays = '';
|
||||
locations.forEach((location, index) => {
|
||||
if (location.latitude && location.longitude) {
|
||||
console.info(`Location ${index + 1}: ${location.latitude}, ${location.longitude} (${location.address})`);
|
||||
// Correct format: pin-s-label+color(lng,lat)
|
||||
const color = location.persistent ? 'ff9800' : 'ff0000'; // Orange for persistent, red for regular
|
||||
const label = (index + 1).toString();
|
||||
overlays += `pin-s-${label}+${color}(${location.longitude},${location.latitude}),`;
|
||||
}
|
||||
});
|
||||
|
||||
// Remove trailing comma
|
||||
overlays = overlays.replace(/,$/, '');
|
||||
|
||||
console.info('Generated overlays string:', overlays);
|
||||
|
||||
// Build Mapbox Static Maps URL with auto-fit
|
||||
let mapboxUrl;
|
||||
if (overlays) {
|
||||
// Use auto-fit to center on all pins
|
||||
mapboxUrl = `https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/${overlays}/auto/${options.width}x${options.height}?access_token=${mapboxToken}`;
|
||||
} else {
|
||||
// No locations, use Grand Rapids as fallback
|
||||
const fallbackLat = 42.960081464833195;
|
||||
const fallbackLng = -85.67402711517647;
|
||||
mapboxUrl = `https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/${fallbackLng},${fallbackLat},10/${options.width}x${options.height}?access_token=${mapboxToken}`;
|
||||
}
|
||||
|
||||
console.info('Fetching Mapbox static map with auto-fit...');
|
||||
console.info('URL:', mapboxUrl.replace(mapboxToken, 'TOKEN_HIDDEN'));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = https.get(mapboxUrl, { timeout: 10000 }, (response) => {
|
||||
if (response.statusCode === 200) {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
console.info('Mapbox static map fetched successfully');
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
} else {
|
||||
console.error('Mapbox API error:', response.statusCode);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
console.error('Error fetching Mapbox map:', err.message);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
request.on('timeout', () => {
|
||||
console.error('Mapbox request timeout');
|
||||
request.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a simple error image when Mapbox fails
|
||||
*/
|
||||
private generateErrorImage(options: MapOptions): Buffer {
|
||||
// Generate a simple 1x1 transparent PNG as fallback
|
||||
// This is a valid PNG header + IHDR + IDAT + IEND for a 1x1 transparent pixel
|
||||
const transparentPng = Buffer.from([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, // RGBA, no compression
|
||||
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk
|
||||
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, // Compressed data
|
||||
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, // (transparent pixel)
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, // IEND chunk
|
||||
0x42, 0x60, 0x82
|
||||
]);
|
||||
|
||||
console.info('Generated transparent PNG fallback due to Mapbox failure');
|
||||
return transparentPng;
|
||||
}
|
||||
}
|
||||
|
||||
export default MapImageService;
|
|
@ -17,7 +17,7 @@ const options: swaggerJsdoc.Options = {
|
|||
- Retrieve active ice reports (< 48 hours or persistent)
|
||||
- Admin panel for content moderation and management
|
||||
- Profanity filtering for content safety
|
||||
- Geographic data with MapBox integration
|
||||
- Geographic data with Mapbox integration
|
||||
`,
|
||||
contact: {
|
||||
name: 'Great Lakes Ice Report',
|
||||
|
@ -222,13 +222,13 @@ const options: swaggerJsdoc.Options = {
|
|||
properties: {
|
||||
mapboxAccessToken: {
|
||||
type: 'string',
|
||||
description: 'MapBox API token for geocoding (null if not configured)',
|
||||
description: 'Mapbox API token for geocoding (null if not configured)',
|
||||
example: 'pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJhYmNkZWZnIn0.example',
|
||||
nullable: true
|
||||
},
|
||||
hasMapbox: {
|
||||
type: 'boolean',
|
||||
description: 'Whether MapBox token is configured',
|
||||
description: 'Whether Mapbox token is configured',
|
||||
example: true
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue