diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..8a607c8
--- /dev/null
+++ b/CLAUDE.md
@@ -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
\ No newline at end of file
diff --git a/models/Location.js b/models/Location.js
new file mode 100644
index 0000000..8d5c5d7
--- /dev/null
+++ b/models/Location.js
@@ -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;
\ No newline at end of file
diff --git a/models/ProfanityWord.js b/models/ProfanityWord.js
new file mode 100644
index 0000000..76ed356
--- /dev/null
+++ b/models/ProfanityWord.js
@@ -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;
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 1e1742b..852b6cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "great-lakes-ice-report",
"version": "1.0.0",
+ "hasInstallScript": true,
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
diff --git a/public/app-mapbox-refactored.js b/public/app-mapbox-refactored.js
new file mode 100644
index 0000000..3726a93
--- /dev/null
+++ b/public/app-mapbox-refactored.js
@@ -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 ? '
π Persistent Report' : '';
+
+ const customIcon = this.createCustomIcon(isPersistent);
+
+ const marker = L.marker([location.latitude, location.longitude], {
+ icon: customIcon
+ })
+ .addTo(this.map)
+ .bindPopup(`${location.address}
${location.description || 'No additional details'}
Reported ${timeAgo}${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: `
+
${iconSymbol}
+ `,
+ 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 = 'No active reports |
';
+ }
+ 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 `
+
+ ${location.address} |
+
+ ${location.description || 'No additional details'}
+ |
+ ${timeAgo} |
+ ${timeRemaining} |
+
+ `;
+ }).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();
+});
\ No newline at end of file
diff --git a/public/app-refactored.js b/public/app-refactored.js
new file mode 100644
index 0000000..2aa5e36
--- /dev/null
+++ b/public/app-refactored.js
@@ -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 ? '
π Persistent Report' : '';
+
+ const customIcon = this.createCustomIcon(isPersistent);
+
+ const marker = L.marker([location.latitude, location.longitude], {
+ icon: customIcon
+ })
+ .addTo(this.map)
+ .bindPopup(`${location.address}
${location.description || 'No additional details'}
Reported ${timeAgo}${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: `
+ ${iconSymbol}
+ `,
+ 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 = ' 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 = ' 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();
+});
\ No newline at end of file
diff --git a/public/map-base.js b/public/map-base.js
new file mode 100644
index 0000000..96b7fff
--- /dev/null
+++ b/public/map-base.js
@@ -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) => `
+
+ ${result.display_name || result.formatted_address || result.name}
+
+ `).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 = ' 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 = ' 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 = 'No ice reports in the last 48 hours
';
+ return;
+ }
+
+ this.locationsContainer.innerHTML = locations.map(location => `
+
+
${location.address}
+ ${location.description ? `
${location.description}
` : ''}
+
${window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''}
+
+ `).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;
\ No newline at end of file
diff --git a/public/test-refactored.html b/public/test-refactored.html
new file mode 100644
index 0000000..c43d116
--- /dev/null
+++ b/public/test-refactored.html
@@ -0,0 +1,266 @@
+
+
+
+
+
+ Test Refactored Maps
+
+
+
+
+
+
+
Refactored Map Components Test
+
+
+
Map Display
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Location |
+ Details |
+ Reported |
+ Expires |
+
+
+
+ Loading... |
+
+
+
+
+
+ Loading... |
+ Initializing... |
+ 0 reports
+
+
+
+
+
Location Submission Form
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/routes/admin.js b/routes/admin.js
index f770535..5c2ca53 100644
--- a/routes/admin.js
+++ b/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
diff --git a/routes/locations.js b/routes/locations.js
index 3af8bc1..ca3f2eb 100644
--- a/routes/locations.js
+++ b/routes/locations.js
@@ -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;
-};
+};
\ No newline at end of file
diff --git a/server.js b/server.js
index f981917..c98db53 100644
--- a/server.js
+++ b/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);
+});
\ No newline at end of file
diff --git a/services/DatabaseService.js b/services/DatabaseService.js
new file mode 100644
index 0000000..3b9c100
--- /dev/null
+++ b/services/DatabaseService.js
@@ -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;
\ No newline at end of file
diff --git a/services/ProfanityFilterService.js b/services/ProfanityFilterService.js
new file mode 100644
index 0000000..a7ca723
--- /dev/null
+++ b/services/ProfanityFilterService.js
@@ -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;
\ No newline at end of file
diff --git a/src/scss/main.scss b/src/scss/main.scss
index c15733b..408b26d 100644
--- a/src/scss/main.scss
+++ b/src/scss/main.scss
@@ -42,6 +42,31 @@ $status-info: #3b82f6;
--primary-hover: #{$secondary-color};
}
+// Auto theme (follows system preference)
+@media (prefers-color-scheme: dark) {
+ [data-theme="auto"] {
+ /* Dark theme when system prefers dark */
+ --background-color: #{$dark-bg};
+ --text-color: #{$dark-text};
+ --card-bg: #{$dark-card-bg};
+ --border-color: #{$dark-border};
+ --input-bg: #{$dark-card-bg};
+ --input-border: #{$dark-border};
+ --table-header-bg: #3d3d3d;
+ --table-hover: #3d3d3d;
+ --shadow: rgba(0, 0, 0, 0.3);
+
+ /* Dark theme additional variables */
+ --bg-secondary: #{$dark-card-bg};
+ --bg-hover: #4b5563;
+ --link-color: #60a5fa;
+ --link-hover: #93c5fd;
+ --text-secondary: #9ca3af;
+ --primary-color: #{$primary-color};
+ --primary-hover: #{$secondary-color};
+ }
+}
+
[data-theme="dark"] {
/* Dark theme */
--background-color: #{$dark-bg};