Refactor architecture: Add models/services layer and refactor frontend
Major architectural improvements: - Created models/services layer for better separation of concerns - Location model with async methods for database operations - ProfanityWord model for content moderation - DatabaseService for centralized database management - ProfanityFilterService refactored to use models - Refactored frontend map implementations to share common code - MapBase class extracts 60-70% of duplicate functionality - Refactored implementations extend MapBase for specific features - Maintained unique geocoding capabilities per implementation - Updated server.js to use new service architecture - All routes now use async/await with models instead of raw queries - Enhanced error handling and maintainability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6c90430ff6
commit
a0fffcf4f0
13 changed files with 2170 additions and 184 deletions
143
CLAUDE.md
Normal file
143
CLAUDE.md
Normal file
|
@ -0,0 +1,143 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Running the Application
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start the server (production mode)
|
||||
npm start
|
||||
|
||||
# Start with auto-reload (development mode)
|
||||
npm run dev
|
||||
|
||||
# Development with CSS watching (recommended for frontend work)
|
||||
npm run dev-with-css
|
||||
```
|
||||
|
||||
The application runs on port 3000 by default. Visit http://localhost:3000 to view the website.
|
||||
|
||||
### CSS Development
|
||||
CSS is generated from SCSS and should NOT be committed to git.
|
||||
```bash
|
||||
# Build CSS once (compressed for production)
|
||||
npm run build-css
|
||||
|
||||
# Build CSS with source maps (for development)
|
||||
npm run build-css:dev
|
||||
|
||||
# Watch SCSS files and auto-compile changes
|
||||
npm run watch-css
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run tests with coverage report
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
Before running the application, you must configure environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 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/)
|
||||
- `ADMIN_PASSWORD`: Password for admin panel access at /admin
|
||||
- `PORT`: Server port (default: 3000)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Backend (Node.js/Express)
|
||||
- **server.js**: Main Express server with modular route architecture
|
||||
- Uses two SQLite databases: `icewatch.db` (locations) and `profanity.db` (content moderation)
|
||||
- Automatic cleanup of reports older than 48 hours via node-cron
|
||||
- Bearer token authentication for admin endpoints
|
||||
- Environment variable configuration via dotenv
|
||||
|
||||
### Route Architecture
|
||||
Routes are organized as factory functions accepting dependencies:
|
||||
|
||||
- **routes/config.js**: Public API configuration endpoints
|
||||
- **routes/locations.js**: Location submission and retrieval with profanity filtering
|
||||
- **routes/admin.js**: Admin panel functionality with authentication middleware
|
||||
|
||||
### Database Schema
|
||||
**Main Database (`icewatch.db`)**:
|
||||
```sql
|
||||
CREATE TABLE locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT,
|
||||
persistent INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**Profanity Database (`profanity.db`)**:
|
||||
Managed by the `ProfanityFilter` class for content moderation.
|
||||
|
||||
### Frontend (Vanilla JavaScript)
|
||||
Multiple map implementations for flexibility:
|
||||
|
||||
- **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
|
||||
|
||||
- **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
|
||||
|
||||
### API Endpoints
|
||||
|
||||
Public endpoints:
|
||||
- `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 (with profanity filtering)
|
||||
|
||||
Admin endpoints (require Bearer token):
|
||||
- `POST /api/admin/login`: Authenticate and receive token
|
||||
- `GET /api/admin/locations`: All locations including expired
|
||||
- `PUT /api/admin/locations/:id`: Update location details
|
||||
- `PATCH /api/admin/locations/:id/persistent`: Toggle persistent status
|
||||
- `DELETE /api/admin/locations/:id`: Delete location
|
||||
- Profanity management: `/api/admin/profanity-words` (GET, POST, PUT, DELETE)
|
||||
|
||||
### SCSS Organization
|
||||
SCSS files are in `src/scss/`:
|
||||
- `main.scss`: Entry point importing all other files
|
||||
- `_variables.scss`: Theme colors and configuration
|
||||
- `_mixins.scss`: Reusable style patterns
|
||||
- `pages/`: Page-specific styles (home, admin, privacy)
|
||||
- `components/`: Component styles (navbar, map, cards, forms)
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
1. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
|
||||
2. **Dual Database Design**: Separate databases for application data and content moderation
|
||||
3. **Graceful Degradation**: Fallback geocoding providers and error handling
|
||||
4. **Automated Maintenance**: Cron-based cleanup of expired reports
|
||||
5. **Security**: Server-side content filtering, environment-based configuration
|
||||
|
||||
### Deployment
|
||||
- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh`
|
||||
- Caddy reverse proxy configuration in `scripts/Caddyfile`
|
||||
- Systemd service files for process management
|
131
models/Location.js
Normal file
131
models/Location.js
Normal file
|
@ -0,0 +1,131 @@
|
|||
class Location {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async getActive(hoursThreshold = 48) {
|
||||
const cutoffTime = new Date(Date.now() - hoursThreshold * 60 * 60 * 1000).toISOString();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT * FROM locations WHERE created_at > ? OR persistent = 1 ORDER BY created_at DESC',
|
||||
[cutoffTime],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT id, address, description, latitude, longitude, persistent, created_at FROM locations ORDER BY created_at DESC',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async create(location) {
|
||||
const { address, latitude, longitude, description } = location;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)',
|
||||
[address, latitude, longitude, description],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({ id: this.lastID, ...location });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async update(id, location) {
|
||||
const { address, latitude, longitude, description } = location;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?',
|
||||
[address, latitude, longitude, description, id],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async togglePersistent(id, persistent) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE locations SET persistent = ? WHERE id = ?',
|
||||
[persistent ? 1 : 0, id],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'DELETE FROM locations WHERE id = ?',
|
||||
[id],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async cleanupExpired(hoursThreshold = 48) {
|
||||
const cutoffTime = new Date(Date.now() - hoursThreshold * 60 * 60 * 1000).toISOString();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'DELETE FROM locations WHERE created_at < ? AND persistent = 0',
|
||||
[cutoffTime],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async initializeTable() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.serialize(() => {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
this.db.run(`
|
||||
ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0
|
||||
`, (err) => {
|
||||
if (err && !err.message.includes('duplicate column name')) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Location;
|
96
models/ProfanityWord.js
Normal file
96
models/ProfanityWord.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
class ProfanityWord {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT id, word, severity, category, created_at, created_by FROM profanity_words ORDER BY created_at DESC',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async loadWords() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT word, severity, category FROM profanity_words',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async create(word, severity, category, createdBy = 'admin') {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'INSERT INTO profanity_words (word, severity, category, created_by) VALUES (?, ?, ?, ?)',
|
||||
[word.toLowerCase(), severity, category, createdBy],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({
|
||||
id: this.lastID,
|
||||
word: word.toLowerCase(),
|
||||
severity,
|
||||
category,
|
||||
created_by: createdBy
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async update(id, word, severity, category) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE profanity_words SET word = ?, severity = ?, category = ? WHERE id = ?',
|
||||
[word.toLowerCase(), severity, category, id],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'DELETE FROM profanity_words WHERE id = ?',
|
||||
[id],
|
||||
function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async initializeTable() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS profanity_words (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
word TEXT NOT NULL UNIQUE,
|
||||
severity TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT DEFAULT 'system'
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProfanityWord;
|
1
package-lock.json
generated
1
package-lock.json
generated
|
@ -7,6 +7,7 @@
|
|||
"": {
|
||||
"name": "great-lakes-ice-report",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
|
|
350
public/app-mapbox-refactored.js
Normal file
350
public/app-mapbox-refactored.js
Normal file
|
@ -0,0 +1,350 @@
|
|||
/**
|
||||
* MapBox implementation with table view and theme toggle using MapBase
|
||||
*/
|
||||
class MapBoxApp extends MapBase {
|
||||
constructor() {
|
||||
super();
|
||||
this.config = { hasMapbox: false, mapboxAccessToken: null };
|
||||
this.currentView = 'map'; // 'map' or 'table'
|
||||
this.initializeConfig();
|
||||
this.initializeMapBoxFeatures();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize API configuration
|
||||
*/
|
||||
async initializeConfig() {
|
||||
try {
|
||||
const configResponse = await fetch('/api/config');
|
||||
this.config = await configResponse.json();
|
||||
console.log('🔧 API Configuration:', this.config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback');
|
||||
} catch (error) {
|
||||
console.warn('Failed to load API configuration, using fallback');
|
||||
}
|
||||
|
||||
this.initializeMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MapBox-specific features
|
||||
*/
|
||||
initializeMapBoxFeatures() {
|
||||
this.initializeViewToggle();
|
||||
this.initializeThemeToggle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Leaflet map
|
||||
*/
|
||||
initializeMap() {
|
||||
this.map = L.map('map').setView([42.9634, -85.6681], 10);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize view toggle functionality
|
||||
*/
|
||||
initializeViewToggle() {
|
||||
const mapViewBtn = document.getElementById('map-view-btn');
|
||||
const tableViewBtn = document.getElementById('table-view-btn');
|
||||
|
||||
if (mapViewBtn && tableViewBtn) {
|
||||
mapViewBtn.addEventListener('click', () => this.switchView('map'));
|
||||
tableViewBtn.addEventListener('click', () => this.switchView('table'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize theme toggle functionality
|
||||
*/
|
||||
initializeThemeToggle() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => this.toggleTheme());
|
||||
}
|
||||
|
||||
// Apply saved theme on load
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
document.body.dataset.theme = savedTheme;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark theme
|
||||
*/
|
||||
toggleTheme() {
|
||||
const currentTheme = document.body.dataset.theme || 'light';
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
|
||||
document.body.dataset.theme = newTheme;
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between map and table view
|
||||
*/
|
||||
switchView(viewType) {
|
||||
this.currentView = viewType;
|
||||
|
||||
const mapView = document.getElementById('map-view');
|
||||
const tableView = document.getElementById('table-view');
|
||||
const mapViewBtn = document.getElementById('map-view-btn');
|
||||
const tableViewBtn = document.getElementById('table-view-btn');
|
||||
|
||||
if (viewType === 'map') {
|
||||
mapView.style.display = 'block';
|
||||
tableView.style.display = 'none';
|
||||
mapViewBtn.classList.add('active');
|
||||
tableViewBtn.classList.remove('active');
|
||||
|
||||
// Invalidate map size after showing
|
||||
setTimeout(() => this.map.invalidateSize(), 100);
|
||||
} else {
|
||||
mapView.style.display = 'none';
|
||||
tableView.style.display = 'block';
|
||||
mapViewBtn.classList.remove('active');
|
||||
tableViewBtn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom marker for location
|
||||
*/
|
||||
createMarker(location) {
|
||||
const isPersistent = !!location.persistent;
|
||||
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
|
||||
const persistentText = isPersistent ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
|
||||
|
||||
const customIcon = this.createCustomIcon(isPersistent);
|
||||
|
||||
const marker = L.marker([location.latitude, location.longitude], {
|
||||
icon: customIcon
|
||||
})
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>${location.address}</strong><br>${location.description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${persistentText}`);
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom icon for markers
|
||||
*/
|
||||
createCustomIcon(isPersistent = false) {
|
||||
const iconColor = isPersistent ? '#28a745' : '#dc3545';
|
||||
const iconSymbol = isPersistent ? '🔒' : '⚠️';
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `
|
||||
<div style="
|
||||
background-color: ${iconColor};
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
">${iconSymbol}</div>
|
||||
`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -15]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display locations (override to handle table view)
|
||||
*/
|
||||
displayLocations(locations) {
|
||||
super.displayLocations(locations);
|
||||
|
||||
// Update table view if currently active
|
||||
if (this.currentView === 'table') {
|
||||
this.renderTable(locations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render table view
|
||||
*/
|
||||
renderTable(locations) {
|
||||
const reportsTableBody = document.getElementById('reports-tbody');
|
||||
const tableLocationCount = document.getElementById('table-location-count');
|
||||
|
||||
if (!reportsTableBody || !locations || locations.length === 0) {
|
||||
if (reportsTableBody) {
|
||||
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">No active reports</td></tr>';
|
||||
}
|
||||
if (tableLocationCount) {
|
||||
tableLocationCount.textContent = '0 active reports';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
const sortedLocations = [...locations].sort((a, b) =>
|
||||
new Date(b.created_at) - new Date(a.created_at)
|
||||
);
|
||||
|
||||
const tableHTML = sortedLocations.map(location => {
|
||||
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
|
||||
const timeRemaining = this.getTimeRemaining(location.created_at, location.persistent);
|
||||
const remainingClass = this.getRemainingClass(location.created_at, location.persistent);
|
||||
const reportedTime = new Date(location.created_at).toLocaleString();
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="location-cell" title="${location.address}">${location.address}</td>
|
||||
<td class="details-cell" title="${location.description || 'No additional details'}">
|
||||
${location.description || '<em>No additional details</em>'}
|
||||
</td>
|
||||
<td class="time-cell" title="${reportedTime}">${timeAgo}</td>
|
||||
<td class="remaining-cell ${remainingClass}">${timeRemaining}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
reportsTableBody.innerHTML = tableHTML;
|
||||
tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time remaining for a report
|
||||
*/
|
||||
getTimeRemaining(createdAt, isPersistent) {
|
||||
if (isPersistent) {
|
||||
return '♾️ Persistent';
|
||||
}
|
||||
|
||||
const created = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const elapsed = now - created;
|
||||
const remaining = (48 * 60 * 60 * 1000) - elapsed; // 48 hours in ms
|
||||
|
||||
if (remaining <= 0) {
|
||||
return '⏰ Expired';
|
||||
}
|
||||
|
||||
const hours = Math.floor(remaining / (60 * 60 * 1000));
|
||||
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
|
||||
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
const remainingHours = hours % 24;
|
||||
return `${days}d ${remainingHours}h`;
|
||||
}
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for time remaining
|
||||
*/
|
||||
getRemainingClass(createdAt, isPersistent) {
|
||||
if (isPersistent) {
|
||||
return 'persistent';
|
||||
}
|
||||
|
||||
const created = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const elapsed = now - created;
|
||||
const remaining = (48 * 60 * 60 * 1000) - elapsed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
return 'expired';
|
||||
} else if (remaining < 6 * 60 * 60 * 1000) { // Less than 6 hours
|
||||
return 'expiring-soon';
|
||||
} else if (remaining < 24 * 60 * 60 * 1000) { // Less than 24 hours
|
||||
return 'expiring-today';
|
||||
}
|
||||
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for addresses using MapBox or Nominatim fallback
|
||||
*/
|
||||
async searchAddress(query) {
|
||||
try {
|
||||
let results = [];
|
||||
|
||||
// Try MapBox first if available
|
||||
if (this.config.hasMapbox && this.config.mapboxAccessToken) {
|
||||
try {
|
||||
results = await this.searchMapBox(query);
|
||||
if (results.length > 0) {
|
||||
this.showAutocomplete(results);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('MapBox search failed, falling back to Nominatim:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Nominatim
|
||||
results = await this.searchNominatim(query);
|
||||
this.showAutocomplete(results);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error searching addresses:', error);
|
||||
this.showAutocomplete([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using MapBox API
|
||||
*/
|
||||
async searchMapBox(query) {
|
||||
const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${this.config.mapboxAccessToken}&country=us&proximity=-85.6681,42.9634&limit=5`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.features && data.features.length > 0) {
|
||||
return data.features.map(feature => ({
|
||||
display_name: feature.place_name,
|
||||
lat: feature.center[1],
|
||||
lon: feature.center[0]
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using Nominatim as fallback
|
||||
*/
|
||||
async searchNominatim(query) {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(query + ', Michigan')}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
return data.slice(0, 5);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle submission errors (override to handle profanity rejection)
|
||||
*/
|
||||
handleSubmitError(data) {
|
||||
if (data.error === 'Submission rejected' && data.message) {
|
||||
// Enhanced profanity rejection handling
|
||||
this.showMessage(data.message, 'error', 10000);
|
||||
} else {
|
||||
super.handleSubmitError(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the app when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new MapBoxApp();
|
||||
});
|
221
public/app-refactored.js
Normal file
221
public/app-refactored.js
Normal file
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Nominatim-only implementation using MapBase
|
||||
*/
|
||||
class NominatimMapApp extends MapBase {
|
||||
constructor() {
|
||||
super();
|
||||
this.initializeMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Leaflet map
|
||||
*/
|
||||
initializeMap() {
|
||||
this.map = L.map('map').setView([42.9634, -85.6681], 10);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom marker for location
|
||||
*/
|
||||
createMarker(location) {
|
||||
const isPersistent = !!location.persistent;
|
||||
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
|
||||
const persistentText = isPersistent ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
|
||||
|
||||
const customIcon = this.createCustomIcon(isPersistent);
|
||||
|
||||
const marker = L.marker([location.latitude, location.longitude], {
|
||||
icon: customIcon
|
||||
})
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>${location.address}</strong><br>${location.description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${persistentText}`);
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom icon for markers
|
||||
*/
|
||||
createCustomIcon(isPersistent = false) {
|
||||
const iconColor = isPersistent ? '#28a745' : '#dc3545'; // Green for persistent, red for temporary
|
||||
const iconSymbol = isPersistent ? '🔒' : '⚠️'; // Lock for persistent, warning for temporary
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `
|
||||
<div style="
|
||||
background-color: ${iconColor};
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
">${iconSymbol}</div>
|
||||
`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -15]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for addresses using Nominatim
|
||||
*/
|
||||
async searchAddress(query) {
|
||||
try {
|
||||
// Generate multiple search variations for better results
|
||||
const searches = [
|
||||
query, // Original query
|
||||
query.replace(' & ', ' and '), // Replace & with 'and'
|
||||
query.replace(' and ', ' & '), // Replace 'and' with &
|
||||
query.replace(' at ', ' & '), // Replace 'at' with &
|
||||
`${query}, Michigan`, // Add Michigan if not present
|
||||
`${query}, MI`, // Add MI if not present
|
||||
];
|
||||
|
||||
// Add street type variations
|
||||
const streetVariations = [];
|
||||
searches.forEach(search => {
|
||||
streetVariations.push(search);
|
||||
streetVariations.push(search.replace(' St ', ' Street '));
|
||||
streetVariations.push(search.replace(' Street ', ' St '));
|
||||
streetVariations.push(search.replace(' Ave ', ' Avenue '));
|
||||
streetVariations.push(search.replace(' Avenue ', ' Ave '));
|
||||
streetVariations.push(search.replace(' Rd ', ' Road '));
|
||||
streetVariations.push(search.replace(' Road ', ' Rd '));
|
||||
streetVariations.push(search.replace(' Blvd ', ' Boulevard '));
|
||||
streetVariations.push(search.replace(' Boulevard ', ' Blvd '));
|
||||
});
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueSearches = [...new Set(streetVariations)];
|
||||
|
||||
// Try each search variation until we get results
|
||||
for (const searchQuery of uniqueSearches) {
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(searchQuery)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
// Prefer results that are in Michigan/Grand Rapids
|
||||
const michiganResults = data.filter(item =>
|
||||
item.display_name.toLowerCase().includes('michigan') ||
|
||||
item.display_name.toLowerCase().includes('grand rapids') ||
|
||||
item.display_name.toLowerCase().includes(', mi')
|
||||
);
|
||||
|
||||
const results = michiganResults.length > 0 ? michiganResults : data;
|
||||
this.showAutocomplete(results.slice(0, 5));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Geocoding failed for: "${searchQuery}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// If no results found, show empty autocomplete
|
||||
this.showAutocomplete([]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error searching addresses:', error);
|
||||
this.showAutocomplete([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission with enhanced geocoding
|
||||
*/
|
||||
async handleSubmit() {
|
||||
const address = this.addressInput.value.trim();
|
||||
const description = this.descriptionInput.value.trim();
|
||||
|
||||
if (!address) {
|
||||
this.showMessage('Please enter an address', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// If coordinates are not set, try to geocode the address
|
||||
let lat = parseFloat(this.addressInput.dataset.lat);
|
||||
let lon = parseFloat(this.addressInput.dataset.lon);
|
||||
|
||||
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
|
||||
// Try to geocode the current address value
|
||||
const geocodeResult = await this.geocodeAddress(address);
|
||||
if (geocodeResult) {
|
||||
lat = parseFloat(geocodeResult.lat);
|
||||
lon = parseFloat(geocodeResult.lon);
|
||||
} else {
|
||||
this.showMessage('Please select a valid address from the suggestions', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable submit button
|
||||
this.submitButton.disabled = true;
|
||||
this.submitButton.innerHTML = '<span class="loading-spinner"></span> Submitting...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/locations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
address,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
description
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
this.showMessage('Location reported successfully!', 'success');
|
||||
this.resetForm();
|
||||
this.refreshLocations();
|
||||
} else {
|
||||
this.handleSubmitError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting location:', error);
|
||||
this.showMessage('Error submitting location. Please try again.', 'error');
|
||||
} finally {
|
||||
this.submitButton.disabled = false;
|
||||
this.submitButton.innerHTML = '<i class="fas fa-flag"></i> Report Ice';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocode an address using Nominatim
|
||||
*/
|
||||
async geocodeAddress(address) {
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(address + ', Michigan')}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
return data[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error geocoding address:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the app when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new NominatimMapApp();
|
||||
});
|
399
public/map-base.js
Normal file
399
public/map-base.js
Normal file
|
@ -0,0 +1,399 @@
|
|||
/**
|
||||
* Base class for all map implementations
|
||||
* Provides common functionality for map display, location management, and UI interactions
|
||||
*/
|
||||
class MapBase {
|
||||
constructor() {
|
||||
// Map instance (to be initialized by subclasses)
|
||||
this.map = null;
|
||||
this.currentMarkers = [];
|
||||
|
||||
// Autocomplete state
|
||||
this.autocompleteTimeout = null;
|
||||
this.selectedIndex = -1;
|
||||
this.autocompleteResults = [];
|
||||
|
||||
// Update interval
|
||||
this.updateInterval = null;
|
||||
|
||||
// DOM elements
|
||||
this.addressInput = document.getElementById('address');
|
||||
this.descriptionInput = document.getElementById('description');
|
||||
this.submitButton = document.querySelector('.submit-btn');
|
||||
this.messageElement = document.getElementById('message');
|
||||
this.locationCountElement = document.getElementById('location-count');
|
||||
this.lastUpdateElement = document.getElementById('last-update');
|
||||
this.autocompleteContainer = document.getElementById('autocomplete');
|
||||
this.locationsContainer = document.querySelector('.locations-container');
|
||||
|
||||
// Initialize event listeners
|
||||
this.initializeEventListeners();
|
||||
|
||||
// Start automatic refresh
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize common event listeners
|
||||
*/
|
||||
initializeEventListeners() {
|
||||
// Form submission
|
||||
document.getElementById('location-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleSubmit();
|
||||
});
|
||||
|
||||
// Address input with autocomplete
|
||||
this.addressInput.addEventListener('input', (e) => {
|
||||
this.handleAddressInput(e);
|
||||
});
|
||||
|
||||
// Keyboard navigation for autocomplete
|
||||
this.addressInput.addEventListener('keydown', (e) => {
|
||||
this.handleAutocompleteKeydown(e);
|
||||
});
|
||||
|
||||
// Click outside to close autocomplete
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.search-container')) {
|
||||
this.hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
const refreshButton = document.getElementById('refresh-button');
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', () => {
|
||||
this.refreshLocations();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle address input for autocomplete
|
||||
*/
|
||||
handleAddressInput(e) {
|
||||
clearTimeout(this.autocompleteTimeout);
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length < 3) {
|
||||
this.hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
this.autocompleteTimeout = setTimeout(() => {
|
||||
this.searchAddress(query);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in autocomplete
|
||||
*/
|
||||
handleAutocompleteKeydown(e) {
|
||||
if (!this.autocompleteContainer.classList.contains('show')) return;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.autocompleteResults.length - 1);
|
||||
this.updateSelection();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||||
this.updateSelection();
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (this.selectedIndex >= 0) {
|
||||
this.selectAddress(this.selectedIndex);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.hideAutocomplete();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for addresses - to be implemented by subclasses
|
||||
*/
|
||||
async searchAddress(query) {
|
||||
// To be implemented by subclasses
|
||||
throw new Error('searchAddress must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show autocomplete results
|
||||
*/
|
||||
showAutocomplete(results) {
|
||||
this.autocompleteResults = results;
|
||||
this.selectedIndex = -1;
|
||||
|
||||
if (results.length === 0) {
|
||||
this.hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
this.autocompleteContainer.innerHTML = results.map((result, index) => `
|
||||
<div class="autocomplete-item" data-index="${index}">
|
||||
${result.display_name || result.formatted_address || result.name}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
this.autocompleteContainer.querySelectorAll('.autocomplete-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
this.selectAddress(parseInt(item.dataset.index));
|
||||
});
|
||||
});
|
||||
|
||||
this.autocompleteContainer.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide autocomplete dropdown
|
||||
*/
|
||||
hideAutocomplete() {
|
||||
this.autocompleteContainer.classList.remove('show');
|
||||
this.autocompleteContainer.innerHTML = '';
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual selection in autocomplete
|
||||
*/
|
||||
updateSelection() {
|
||||
const items = this.autocompleteContainer.querySelectorAll('.autocomplete-item');
|
||||
items.forEach((item, index) => {
|
||||
item.classList.toggle('selected', index === this.selectedIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an address from autocomplete
|
||||
*/
|
||||
selectAddress(index) {
|
||||
const result = this.autocompleteResults[index];
|
||||
if (result) {
|
||||
this.addressInput.value = result.display_name || result.formatted_address || result.name;
|
||||
this.addressInput.dataset.lat = result.lat || result.latitude;
|
||||
this.addressInput.dataset.lon = result.lon || result.longitude;
|
||||
this.hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
async handleSubmit() {
|
||||
const address = this.addressInput.value.trim();
|
||||
const description = this.descriptionInput.value.trim();
|
||||
const lat = parseFloat(this.addressInput.dataset.lat);
|
||||
const lon = parseFloat(this.addressInput.dataset.lon);
|
||||
|
||||
if (!address) {
|
||||
this.showMessage('Please enter an address', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
|
||||
this.showMessage('Please select a valid address from the suggestions', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable submit button
|
||||
this.submitButton.disabled = true;
|
||||
this.submitButton.innerHTML = '<span class="loading-spinner"></span> Submitting...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/locations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
address,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
description
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
this.showMessage('Location reported successfully!', 'success');
|
||||
this.resetForm();
|
||||
this.refreshLocations();
|
||||
} else {
|
||||
this.handleSubmitError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting location:', error);
|
||||
this.showMessage('Error submitting location. Please try again.', 'error');
|
||||
} finally {
|
||||
this.submitButton.disabled = false;
|
||||
this.submitButton.innerHTML = '<i class="fas fa-flag"></i> Report Ice';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle submission errors
|
||||
*/
|
||||
handleSubmitError(data) {
|
||||
if (data.error === 'Submission rejected' && data.message) {
|
||||
// Profanity rejection
|
||||
this.showMessage(data.message, 'error', 10000);
|
||||
} else {
|
||||
this.showMessage(data.error || 'Error submitting location', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form after successful submission
|
||||
*/
|
||||
resetForm() {
|
||||
this.addressInput.value = '';
|
||||
this.addressInput.dataset.lat = '';
|
||||
this.addressInput.dataset.lon = '';
|
||||
this.descriptionInput.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a message to the user
|
||||
*/
|
||||
showMessage(text, type = 'info', duration = 5000) {
|
||||
this.messageElement.textContent = text;
|
||||
this.messageElement.className = `message ${type} show`;
|
||||
|
||||
setTimeout(() => {
|
||||
this.messageElement.classList.remove('show');
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all markers from the map
|
||||
*/
|
||||
clearMarkers() {
|
||||
this.currentMarkers.forEach(marker => {
|
||||
if (this.map && marker) {
|
||||
this.map.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
this.currentMarkers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a marker - to be implemented by subclasses
|
||||
*/
|
||||
createMarker(location) {
|
||||
// To be implemented by subclasses
|
||||
throw new Error('createMarker must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh locations from the server
|
||||
*/
|
||||
async refreshLocations() {
|
||||
try {
|
||||
const response = await fetch('/api/locations');
|
||||
const locations = await response.json();
|
||||
|
||||
this.displayLocations(locations);
|
||||
this.updateLocationCount(locations.length);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching locations:', error);
|
||||
this.showMessage('Error loading locations', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display locations on the map
|
||||
*/
|
||||
displayLocations(locations) {
|
||||
this.clearMarkers();
|
||||
|
||||
locations.forEach(location => {
|
||||
if (location.latitude && location.longitude) {
|
||||
const marker = this.createMarker(location);
|
||||
if (marker) {
|
||||
this.currentMarkers.push(marker);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update locations list if container exists
|
||||
if (this.locationsContainer) {
|
||||
this.updateLocationsList(locations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the locations list (for table view)
|
||||
*/
|
||||
updateLocationsList(locations) {
|
||||
if (!this.locationsContainer) return;
|
||||
|
||||
if (locations.length === 0) {
|
||||
this.locationsContainer.innerHTML = '<p class="no-reports">No ice reports in the last 48 hours</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.locationsContainer.innerHTML = locations.map(location => `
|
||||
<div class="location-card">
|
||||
<h3>${location.address}</h3>
|
||||
${location.description ? `<p class="description">${location.description}</p>` : ''}
|
||||
<p class="time-ago">${window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update location count display
|
||||
*/
|
||||
updateLocationCount(count) {
|
||||
if (this.locationCountElement) {
|
||||
const plural = count !== 1 ? 's' : '';
|
||||
this.locationCountElement.textContent = `${count} report${plural}`;
|
||||
}
|
||||
|
||||
if (this.lastUpdateElement) {
|
||||
this.lastUpdateElement.textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start automatic refresh of locations
|
||||
*/
|
||||
startAutoRefresh() {
|
||||
// Initial load
|
||||
this.refreshLocations();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.refreshLocations();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic refresh
|
||||
*/
|
||||
stopAutoRefresh() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the map - to be implemented by subclasses
|
||||
*/
|
||||
initializeMap() {
|
||||
throw new Error('initializeMap must be implemented by subclass');
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other files
|
||||
window.MapBase = MapBase;
|
266
public/test-refactored.html
Normal file
266
public/test-refactored.html
Normal file
|
@ -0,0 +1,266 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Refactored Maps</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.test-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.test-section {
|
||||
margin-bottom: 40px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.test-section h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
#map {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.map-controls {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn.active {
|
||||
background: #0056b3;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-group input, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.search-container {
|
||||
position: relative;
|
||||
}
|
||||
#autocomplete {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
#autocomplete.show {
|
||||
display: block;
|
||||
}
|
||||
.autocomplete-item {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.autocomplete-item:hover,
|
||||
.autocomplete-item.selected {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
.message.show {
|
||||
display: block;
|
||||
}
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.location-stats {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<h1>Refactored Map Components Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Map Display</h2>
|
||||
<div class="map-controls">
|
||||
<button id="map-view-btn" class="btn active">Map View</button>
|
||||
<button id="table-view-btn" class="btn">Table View</button>
|
||||
<button id="theme-toggle" class="btn">Toggle Theme</button>
|
||||
<button id="refresh-button" class="btn">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="map-view">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
|
||||
<div id="table-view" style="display: none;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa;">
|
||||
<th style="padding: 10px; border: 1px solid #ddd;">Location</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd;">Details</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd;">Reported</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd;">Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reports-tbody">
|
||||
<tr><td colspan="4" style="padding: 20px; text-align: center;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="location-stats">
|
||||
<span id="location-count">Loading...</span> |
|
||||
<span id="last-update">Initializing...</span> |
|
||||
<span id="table-location-count">0 reports</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Location Submission Form</h2>
|
||||
<form id="location-form">
|
||||
<div class="form-group">
|
||||
<label for="address">Address:</label>
|
||||
<div class="search-container">
|
||||
<input type="text" id="address" placeholder="Enter address or intersection..." required>
|
||||
<div id="autocomplete"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional):</label>
|
||||
<textarea id="description" placeholder="Describe road conditions..." rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn btn">
|
||||
<i class="fas fa-flag"></i> Report Ice
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="utils.js"></script>
|
||||
<script src="map-base.js"></script>
|
||||
<script>
|
||||
// Test both implementations
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Create a simple test implementation
|
||||
class TestMapApp extends MapBase {
|
||||
constructor() {
|
||||
super();
|
||||
this.initializeMap();
|
||||
this.initializeTestFeatures();
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
this.map = L.map('map').setView([42.9634, -85.6681], 10);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
initializeTestFeatures() {
|
||||
// View toggle
|
||||
const mapViewBtn = document.getElementById('map-view-btn');
|
||||
const tableViewBtn = document.getElementById('table-view-btn');
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
|
||||
if (mapViewBtn) mapViewBtn.addEventListener('click', () => this.switchView('map'));
|
||||
if (tableViewBtn) tableViewBtn.addEventListener('click', () => this.switchView('table'));
|
||||
if (themeToggle) themeToggle.addEventListener('click', () => this.toggleTheme());
|
||||
}
|
||||
|
||||
switchView(viewType) {
|
||||
const mapView = document.getElementById('map-view');
|
||||
const tableView = document.getElementById('table-view');
|
||||
const mapViewBtn = document.getElementById('map-view-btn');
|
||||
const tableViewBtn = document.getElementById('table-view-btn');
|
||||
|
||||
if (viewType === 'map') {
|
||||
mapView.style.display = 'block';
|
||||
tableView.style.display = 'none';
|
||||
mapViewBtn.classList.add('active');
|
||||
tableViewBtn.classList.remove('active');
|
||||
setTimeout(() => this.map.invalidateSize(), 100);
|
||||
} else {
|
||||
mapView.style.display = 'none';
|
||||
tableView.style.display = 'block';
|
||||
mapViewBtn.classList.remove('active');
|
||||
tableViewBtn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const currentTheme = document.body.dataset.theme || 'light';
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
document.body.dataset.theme = newTheme;
|
||||
}
|
||||
|
||||
createMarker(location) {
|
||||
const isPersistent = !!location.persistent;
|
||||
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
|
||||
|
||||
const marker = L.marker([location.latitude, location.longitude])
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>${location.address}</strong><br>${location.description || 'No details'}<br><small>${timeAgo}</small>`);
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
async searchAddress(query) {
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=5&q=${encodeURIComponent(query + ', Michigan')}`);
|
||||
const data = await response.json();
|
||||
this.showAutocomplete(data.slice(0, 5));
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
this.showAutocomplete([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new TestMapApp();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
143
routes/admin.js
143
routes/admin.js
|
@ -1,7 +1,7 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = (db, profanityFilter, authenticateAdmin) => {
|
||||
module.exports = (locationModel, profanityWordModel, profanityFilter, authenticateAdmin) => {
|
||||
// Admin login
|
||||
router.post('/login', (req, res) => {
|
||||
console.log('Admin login attempt');
|
||||
|
@ -18,36 +18,31 @@ module.exports = (db, profanityFilter, authenticateAdmin) => {
|
|||
});
|
||||
|
||||
// Get all locations for admin (including expired ones)
|
||||
router.get('/locations', authenticateAdmin, (req, res) => {
|
||||
db.all(
|
||||
'SELECT id, address, description, latitude, longitude, persistent, created_at FROM locations ORDER BY created_at DESC',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching all locations:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Process and clean data before sending
|
||||
const locations = rows.map(row => ({
|
||||
id: row.id,
|
||||
address: row.address,
|
||||
description: row.description || '',
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
persistent: !!row.persistent,
|
||||
created_at: row.created_at,
|
||||
isActive: new Date(row.created_at) > new Date(Date.now() - 48 * 60 * 60 * 1000)
|
||||
}));
|
||||
|
||||
res.json(locations);
|
||||
}
|
||||
);
|
||||
router.get('/locations', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const rows = await locationModel.getAll();
|
||||
|
||||
// Process and clean data before sending
|
||||
const locations = rows.map(row => ({
|
||||
id: row.id,
|
||||
address: row.address,
|
||||
description: row.description || '',
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
persistent: !!row.persistent,
|
||||
created_at: row.created_at,
|
||||
isActive: new Date(row.created_at) > new Date(Date.now() - 48 * 60 * 60 * 1000)
|
||||
}));
|
||||
|
||||
res.json(locations);
|
||||
} catch (err) {
|
||||
console.error('Error fetching all locations:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a location (admin only)
|
||||
router.put('/locations/:id', authenticateAdmin, (req, res) => {
|
||||
router.put('/locations/:id', authenticateAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { address, latitude, longitude, description } = req.body;
|
||||
|
||||
|
@ -56,28 +51,28 @@ module.exports = (db, profanityFilter, authenticateAdmin) => {
|
|||
return;
|
||||
}
|
||||
|
||||
db.run(
|
||||
'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?',
|
||||
[address, latitude, longitude, description, id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error updating location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'Location updated successfully' });
|
||||
try {
|
||||
const result = await locationModel.update(id, {
|
||||
address,
|
||||
latitude,
|
||||
longitude,
|
||||
description
|
||||
});
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
res.json({ message: 'Location updated successfully' });
|
||||
} catch (err) {
|
||||
console.error('Error updating location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle persistent status of a location (admin only)
|
||||
router.patch('/locations/:id/persistent', authenticateAdmin, (req, res) => {
|
||||
router.patch('/locations/:id/persistent', authenticateAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { persistent } = req.body;
|
||||
|
||||
|
@ -86,45 +81,39 @@ module.exports = (db, profanityFilter, authenticateAdmin) => {
|
|||
return;
|
||||
}
|
||||
|
||||
db.run(
|
||||
'UPDATE locations SET persistent = ? WHERE id = ?',
|
||||
[persistent ? 1 : 0, id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error updating persistent status:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Location ${id} persistent status set to ${persistent}`);
|
||||
res.json({ message: 'Persistent status updated successfully', persistent });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Delete a location (admin authentication required)
|
||||
router.delete('/locations/:id', authenticateAdmin, (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error('Error deleting location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
try {
|
||||
const result = await locationModel.togglePersistent(id, persistent);
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.changes === 0) {
|
||||
console.log(`Location ${id} persistent status set to ${persistent}`);
|
||||
res.json({ message: 'Persistent status updated successfully', persistent });
|
||||
} catch (err) {
|
||||
console.error('Error updating persistent status:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a location (admin authentication required)
|
||||
router.delete('/locations/:id', authenticateAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await locationModel.delete(id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'Location deleted successfully' });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error deleting location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Profanity Management Routes
|
||||
|
|
|
@ -1,29 +1,23 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = (db, profanityFilter) => {
|
||||
module.exports = (locationModel, profanityFilter) => {
|
||||
// Get all active locations (within 48 hours OR persistent)
|
||||
router.get('/', (req, res) => {
|
||||
router.get('/', async (req, res) => {
|
||||
console.log('Fetching active locations');
|
||||
const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
db.all(
|
||||
'SELECT * FROM locations WHERE created_at > ? OR persistent = 1 ORDER BY created_at DESC',
|
||||
[fortyEightHoursAgo],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching locations:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
return;
|
||||
}
|
||||
console.log(`Fetched ${rows.length} active locations (including persistent)`);
|
||||
res.json(rows);
|
||||
}
|
||||
);
|
||||
try {
|
||||
const locations = await locationModel.getActive();
|
||||
console.log(`Fetched ${locations.length} active locations (including persistent)`);
|
||||
res.json(locations);
|
||||
} catch (err) {
|
||||
console.error('Error fetching locations:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a new location
|
||||
router.post('/', (req, res) => {
|
||||
router.post('/', async (req, res) => {
|
||||
const { address, latitude, longitude } = req.body;
|
||||
let { description } = req.body;
|
||||
console.log(`Attempt to add new location: ${address}`);
|
||||
|
@ -61,48 +55,43 @@ module.exports = (db, profanityFilter) => {
|
|||
}
|
||||
}
|
||||
|
||||
db.run(
|
||||
'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)',
|
||||
[address, latitude, longitude, description],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error inserting location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Location added successfully: ${address}`);
|
||||
res.json({
|
||||
id: this.lastID,
|
||||
address,
|
||||
latitude,
|
||||
longitude,
|
||||
description,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
);
|
||||
try {
|
||||
const newLocation = await locationModel.create({
|
||||
address,
|
||||
latitude,
|
||||
longitude,
|
||||
description
|
||||
});
|
||||
|
||||
console.log(`Location added successfully: ${address}`);
|
||||
res.json({
|
||||
...newLocation,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error inserting location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy delete route (keeping for backwards compatibility)
|
||||
router.delete('/:id', (req, res) => {
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error('Error deleting location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await locationModel.delete(id);
|
||||
|
||||
if (this.changes === 0) {
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'Location deleted successfully' });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error deleting location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
};
|
91
server.js
91
server.js
|
@ -2,10 +2,10 @@ require('dotenv').config({ path: '.env.local' });
|
|||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const cron = require('node-cron');
|
||||
const ProfanityFilter = require('./profanity-filter');
|
||||
const DatabaseService = require('./services/DatabaseService');
|
||||
const ProfanityFilterService = require('./services/ProfanityFilterService');
|
||||
|
||||
// Import route modules
|
||||
const configRoutes = require('./routes/config');
|
||||
|
@ -20,12 +20,8 @@ app.use(cors());
|
|||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Database setup
|
||||
const db = new sqlite3.Database('icewatch.db');
|
||||
|
||||
console.log('Database connection established');
|
||||
|
||||
// Initialize profanity filter with its own database (async)
|
||||
// Database and services setup
|
||||
const databaseService = new DatabaseService();
|
||||
let profanityFilter;
|
||||
|
||||
// Create fallback filter function
|
||||
|
@ -82,8 +78,10 @@ function createFallbackFilter() {
|
|||
// Initialize profanity filter asynchronously
|
||||
async function initializeProfanityFilter() {
|
||||
try {
|
||||
profanityFilter = await ProfanityFilter.create();
|
||||
console.log('Profanity filter initialized successfully with separate database');
|
||||
const profanityWordModel = databaseService.getProfanityWordModel();
|
||||
profanityFilter = new ProfanityFilterService(profanityWordModel);
|
||||
await profanityFilter.initialize();
|
||||
console.log('Profanity filter initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('WARNING: Failed to initialize profanity filter:', error);
|
||||
console.error('Creating fallback no-op profanity filter. ALL CONTENT WILL BE ALLOWED!');
|
||||
|
@ -94,45 +92,18 @@ async function initializeProfanityFilter() {
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
db.serialize(() => {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT,
|
||||
persistent INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error initializing database:', err);
|
||||
} else {
|
||||
console.log('Database initialized successfully');
|
||||
// Add persistent column to existing tables if it doesn't exist
|
||||
db.run(`ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0`, (alterErr) => {
|
||||
if (alterErr && !alterErr.message.includes('duplicate column name')) {
|
||||
console.error('Error adding persistent column:', alterErr);
|
||||
} else if (!alterErr) {
|
||||
console.log('Added persistent column to existing table');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
// Database initialization is now handled by DatabaseService
|
||||
|
||||
// Clean up expired locations (older than 48 hours, but not persistent ones)
|
||||
const cleanupExpiredLocations = () => {
|
||||
const cleanupExpiredLocations = async () => {
|
||||
console.log('Running cleanup of expired locations');
|
||||
const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
||||
db.run('DELETE FROM locations WHERE created_at < ? AND persistent = 0', [fortyEightHoursAgo], function(err) {
|
||||
if (err) {
|
||||
console.error('Error cleaning up expired locations:', err);
|
||||
} else {
|
||||
console.log(`Cleaned up ${this.changes} expired locations (persistent reports preserved)`);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const locationModel = databaseService.getLocationModel();
|
||||
const result = await locationModel.cleanupExpired();
|
||||
console.log(`Cleaned up ${result.changes} expired locations (persistent reports preserved)`);
|
||||
} catch (err) {
|
||||
console.error('Error cleaning up expired locations:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Run cleanup every hour
|
||||
|
@ -159,10 +130,13 @@ const authenticateAdmin = (req, res, next) => {
|
|||
|
||||
// Setup routes after database and profanity filter are initialized
|
||||
function setupRoutes() {
|
||||
const locationModel = databaseService.getLocationModel();
|
||||
const profanityWordModel = databaseService.getProfanityWordModel();
|
||||
|
||||
// API Routes
|
||||
app.use('/api/config', configRoutes());
|
||||
app.use('/api/locations', locationRoutes(db, profanityFilter));
|
||||
app.use('/api/admin', adminRoutes(db, profanityFilter, authenticateAdmin));
|
||||
app.use('/api/locations', locationRoutes(locationModel, profanityFilter));
|
||||
app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin));
|
||||
|
||||
// Static page routes
|
||||
app.get('/', (req, res) => {
|
||||
|
@ -184,7 +158,11 @@ function setupRoutes() {
|
|||
// Async server startup function
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize profanity filter first
|
||||
// Initialize database service first
|
||||
await databaseService.initialize();
|
||||
console.log('Database service initialized successfully');
|
||||
|
||||
// Initialize profanity filter
|
||||
await initializeProfanityFilter();
|
||||
|
||||
// Validate profanity filter is properly initialized
|
||||
|
@ -238,13 +216,8 @@ process.on('SIGINT', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Close main database
|
||||
db.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error closing database:', err);
|
||||
} else {
|
||||
console.log('Main database connection closed.');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
// Close database service
|
||||
databaseService.close();
|
||||
console.log('Database connections closed.');
|
||||
process.exit(0);
|
||||
});
|
93
services/DatabaseService.js
Normal file
93
services/DatabaseService.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const Location = require('../models/Location');
|
||||
const ProfanityWord = require('../models/ProfanityWord');
|
||||
|
||||
class DatabaseService {
|
||||
constructor() {
|
||||
this.mainDb = null;
|
||||
this.profanityDb = null;
|
||||
this.locationModel = null;
|
||||
this.profanityWordModel = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.initializeMainDatabase();
|
||||
await this.initializeProfanityDatabase();
|
||||
}
|
||||
|
||||
async initializeMainDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dbPath = path.join(__dirname, '..', 'icewatch.db');
|
||||
this.mainDb = new sqlite3.Database(dbPath, async (err) => {
|
||||
if (err) {
|
||||
console.error('Could not connect to main database', err);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Connected to main SQLite database.');
|
||||
|
||||
this.locationModel = new Location(this.mainDb);
|
||||
try {
|
||||
await this.locationModel.initializeTable();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async initializeProfanityDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dbPath = path.join(__dirname, '..', 'profanity.db');
|
||||
this.profanityDb = new sqlite3.Database(dbPath, async (err) => {
|
||||
if (err) {
|
||||
console.error('Could not connect to profanity database', err);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Connected to profanity SQLite database.');
|
||||
|
||||
this.profanityWordModel = new ProfanityWord(this.profanityDb);
|
||||
try {
|
||||
await this.profanityWordModel.initializeTable();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getLocationModel() {
|
||||
if (!this.locationModel) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.locationModel;
|
||||
}
|
||||
|
||||
getProfanityWordModel() {
|
||||
if (!this.profanityWordModel) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.profanityWordModel;
|
||||
}
|
||||
|
||||
getMainDb() {
|
||||
return this.mainDb;
|
||||
}
|
||||
|
||||
getProfanityDb() {
|
||||
return this.profanityDb;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.mainDb) {
|
||||
this.mainDb.close();
|
||||
}
|
||||
if (this.profanityDb) {
|
||||
this.profanityDb.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DatabaseService;
|
335
services/ProfanityFilterService.js
Normal file
335
services/ProfanityFilterService.js
Normal file
|
@ -0,0 +1,335 @@
|
|||
/**
|
||||
* Refactored Profanity Filter Service that uses the ProfanityWord model
|
||||
*/
|
||||
|
||||
class ProfanityFilterService {
|
||||
constructor(profanityWordModel) {
|
||||
this.profanityWordModel = profanityWordModel;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Base profanity words - comprehensive list
|
||||
this.baseProfanityWords = [
|
||||
// Common profanity
|
||||
'damn', 'hell', 'crap', 'shit', 'fuck', 'ass', 'bitch', 'bastard',
|
||||
'piss', 'whore', 'slut', 'retard', 'fag', 'gay', 'homo', 'tranny',
|
||||
'dickhead', 'asshole', 'motherfucker', 'cocksucker', 'twat', 'cunt',
|
||||
|
||||
// Racial slurs and hate speech
|
||||
'nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike',
|
||||
'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey',
|
||||
'kyke', 'jigaboo', 'coon', 'darkie', 'mammy', 'pickaninny',
|
||||
|
||||
// Sexual content
|
||||
'penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum',
|
||||
'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate', 'orgasm',
|
||||
'blowjob', 'handjob', 'anal', 'penetration', 'erection', 'climax',
|
||||
|
||||
// Violence and threats
|
||||
'kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape',
|
||||
'violence', 'assault', 'attack', 'threat', 'harm', 'hurt', 'pain',
|
||||
'stab', 'strangle', 'torture', 'execute', 'assassinate', 'slaughter',
|
||||
|
||||
// Drugs and substances
|
||||
'weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high',
|
||||
'stoned', 'drunk', 'alcohol', 'beer', 'liquor', 'vodka', 'whiskey',
|
||||
'ecstasy', 'lsd', 'crack', 'dope', 'pot', 'joint', 'bong',
|
||||
|
||||
// Religious/cultural insults
|
||||
'jesus christ', 'goddamn', 'christ almighty', 'holy shit', 'god damn',
|
||||
'for christ sake', 'jesus fucking christ', 'holy fuck',
|
||||
|
||||
// Body parts (inappropriate context)
|
||||
'testicles', 'balls', 'scrotum', 'clitoris', 'labia', 'anus',
|
||||
'rectum', 'butthole', 'nipples', 'breasts',
|
||||
|
||||
// Misc inappropriate
|
||||
'wtf', 'omfg', 'stfu', 'gtfo', 'milf', 'dilf', 'thot', 'simp',
|
||||
'incel', 'chad', 'beta', 'alpha male', 'mansplain', 'karen'
|
||||
];
|
||||
|
||||
// Leetspeak and common substitutions
|
||||
this.leetMap = {
|
||||
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '6': 'g', '7': 't',
|
||||
'8': 'b', '9': 'g', '@': 'a', '$': 's', '!': 'i', '+': 't', '*': 'a',
|
||||
'%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/'
|
||||
};
|
||||
|
||||
// Initialize custom words array
|
||||
this.customWords = [];
|
||||
|
||||
// Initialize patterns to null; will be built during async initialization
|
||||
this.patterns = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the filter by loading custom words
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadCustomWords();
|
||||
this.isInitialized = true;
|
||||
console.log('ProfanityFilterService initialization completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during ProfanityFilterService initialization:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load custom words from database using the model
|
||||
*/
|
||||
async loadCustomWords() {
|
||||
try {
|
||||
const rows = await this.profanityWordModel.loadWords();
|
||||
|
||||
this.customWords = rows.map(row => ({
|
||||
word: row.word.toLowerCase(),
|
||||
severity: row.severity,
|
||||
category: row.category
|
||||
}));
|
||||
|
||||
console.log(`Loaded ${this.customWords.length} custom profanity words`);
|
||||
this.patterns = this.buildPatterns(); // Rebuild patterns with custom words
|
||||
} catch (err) {
|
||||
console.error('Error loading custom profanity words:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build regex patterns for all profanity words
|
||||
*/
|
||||
buildPatterns() {
|
||||
const allWords = [...this.baseProfanityWords, ...this.customWords.map(w => w.word)];
|
||||
|
||||
// Sort by length (longest first) to catch longer variations before shorter ones
|
||||
allWords.sort((a, b) => b.length - a.length);
|
||||
|
||||
// Create patterns with word boundaries and common variations
|
||||
return allWords.map(word => {
|
||||
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = escaped
|
||||
.split('')
|
||||
.map(char => {
|
||||
const leetChars = Object.entries(this.leetMap)
|
||||
.filter(([_, v]) => v === char.toLowerCase())
|
||||
.map(([k, _]) => k);
|
||||
|
||||
if (leetChars.length > 0) {
|
||||
const allChars = [char, ...leetChars].map(c =>
|
||||
c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
);
|
||||
return `[${allChars.join('')}]`;
|
||||
}
|
||||
return char;
|
||||
})
|
||||
.join('[\\s\\-\\_\\*\\.]*');
|
||||
|
||||
return {
|
||||
word: word,
|
||||
pattern: new RegExp(`\\b${pattern}\\b`, 'gi'),
|
||||
severity: this.getSeverity(word),
|
||||
category: this.getCategory(word)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for a word
|
||||
*/
|
||||
getSeverity(word) {
|
||||
// Check custom words first
|
||||
const customWord = this.customWords.find(w => w.word === word.toLowerCase());
|
||||
if (customWord) {
|
||||
return customWord.severity;
|
||||
}
|
||||
|
||||
// Categorize severity based on type
|
||||
const highSeverity = ['nigger', 'nigga', 'cunt', 'fag', 'retard', 'kike', 'spic', 'gook', 'chink'];
|
||||
const lowSeverity = ['damn', 'hell', 'crap', 'wtf', 'omfg'];
|
||||
|
||||
if (highSeverity.includes(word.toLowerCase())) return 'high';
|
||||
if (lowSeverity.includes(word.toLowerCase())) return 'low';
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category for a word
|
||||
*/
|
||||
getCategory(word) {
|
||||
// Check custom words first
|
||||
const customWord = this.customWords.find(w => w.word === word.toLowerCase());
|
||||
if (customWord) {
|
||||
return customWord.category;
|
||||
}
|
||||
|
||||
// Categorize based on type
|
||||
const categories = {
|
||||
racial: ['nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey'],
|
||||
sexual: ['penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum', 'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate'],
|
||||
violence: ['kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape', 'violence', 'assault', 'attack'],
|
||||
substance: ['weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'stoned', 'drunk', 'alcohol'],
|
||||
general: ['shit', 'fuck', 'ass', 'bitch', 'bastard', 'damn', 'hell', 'crap']
|
||||
};
|
||||
|
||||
for (const [category, words] of Object.entries(categories)) {
|
||||
if (words.includes(word.toLowerCase())) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize text for checking
|
||||
*/
|
||||
normalizeText(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Convert to lowercase and handle basic substitutions
|
||||
let normalized = text.toLowerCase();
|
||||
|
||||
// Replace multiple spaces/special chars with single space
|
||||
normalized = normalized.replace(/[\s\-\_\*\.]+/g, ' ');
|
||||
|
||||
// Apply leet speak conversions
|
||||
normalized = normalized.split('').map(char =>
|
||||
this.leetMap[char] || char
|
||||
).join('');
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains profanity
|
||||
*/
|
||||
containsProfanity(text) {
|
||||
if (!text || !this.patterns) return false;
|
||||
|
||||
const normalized = this.normalizeText(text);
|
||||
return this.patterns.some(({ pattern }) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze text for profanity with detailed results
|
||||
*/
|
||||
analyzeProfanity(text) {
|
||||
if (!text || !this.patterns) {
|
||||
return {
|
||||
hasProfanity: false,
|
||||
matches: [],
|
||||
severity: 'none',
|
||||
count: 0,
|
||||
filtered: text || ''
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = this.normalizeText(text);
|
||||
const matches = [];
|
||||
let filteredText = text;
|
||||
|
||||
this.patterns.forEach(({ word, pattern, severity, category }) => {
|
||||
const regex = new RegExp(pattern.source, 'gi');
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(normalized)) !== null) {
|
||||
matches.push({
|
||||
word: word,
|
||||
found: match[0],
|
||||
index: match.index,
|
||||
severity: severity,
|
||||
category: category
|
||||
});
|
||||
|
||||
// Replace in filtered text
|
||||
const replacement = '*'.repeat(match[0].length);
|
||||
filteredText = filteredText.substring(0, match.index) +
|
||||
replacement +
|
||||
filteredText.substring(match.index + match[0].length);
|
||||
}
|
||||
});
|
||||
|
||||
// Determine overall severity
|
||||
let overallSeverity = 'none';
|
||||
if (matches.length > 0) {
|
||||
if (matches.some(m => m.severity === 'high')) {
|
||||
overallSeverity = 'high';
|
||||
} else if (matches.some(m => m.severity === 'medium')) {
|
||||
overallSeverity = 'medium';
|
||||
} else {
|
||||
overallSeverity = 'low';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasProfanity: matches.length > 0,
|
||||
matches: matches,
|
||||
severity: overallSeverity,
|
||||
count: matches.length,
|
||||
filtered: filteredText
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter profanity from text
|
||||
*/
|
||||
filterProfanity(text, replacementChar = '*') {
|
||||
const analysis = this.analyzeProfanity(text);
|
||||
return analysis.filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom word using the model
|
||||
*/
|
||||
async addCustomWord(word, severity = 'medium', category = 'custom', createdBy = 'admin') {
|
||||
try {
|
||||
const result = await this.profanityWordModel.create(word, severity, category, createdBy);
|
||||
await this.loadCustomWords(); // Reload to update patterns
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error('Word already exists in the filter');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom word using the model
|
||||
*/
|
||||
async removeCustomWord(wordId) {
|
||||
const result = await this.profanityWordModel.delete(wordId);
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Word not found');
|
||||
}
|
||||
await this.loadCustomWords(); // Reload to update patterns
|
||||
return { deleted: true, changes: result.changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom words using the model
|
||||
*/
|
||||
async getCustomWords() {
|
||||
return await this.profanityWordModel.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom word using the model
|
||||
*/
|
||||
async updateCustomWord(wordId, updates) {
|
||||
const { word, severity, category } = updates;
|
||||
const result = await this.profanityWordModel.update(wordId, word, severity, category);
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Word not found');
|
||||
}
|
||||
await this.loadCustomWords(); // Reload to update patterns
|
||||
return { updated: true, changes: result.changes };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProfanityFilterService;
|
Loading…
Add table
Add a link
Reference in a new issue