Add multi-city support

This commit is contained in:
Will Bradley 2025-07-16 12:24:27 -07:00
parent f4039224be
commit 1a53f60b90
4 changed files with 518 additions and 12 deletions

322
public/city-select.html Normal file
View File

@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select City - Water Station Tracker</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.container {
background: white;
border-radius: 10px;
box-shadow: 0 15px 35px rgba(0,0,0,0.1);
padding: 2rem;
width: 100%;
max-width: 500px;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
color: #333;
font-size: 2rem;
margin-bottom: 0.5rem;
}
.header p {
color: #666;
font-size: 1rem;
}
.city-list {
max-height: 400px;
overflow-y: auto;
margin-bottom: 2rem;
}
.city-item {
padding: 1rem;
border: 2px solid #e1e1e1;
border-radius: 8px;
margin-bottom: 1rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.city-item:hover {
border-color: #667eea;
background: #f8f9ff;
}
.city-name {
font-size: 1.1rem;
font-weight: 500;
color: #333;
}
.city-stats {
font-size: 0.9rem;
color: #666;
}
.add-city-form {
border-top: 1px solid #e1e1e1;
padding-top: 2rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
}
input[type="text"] {
width: 100%;
padding: 0.8rem;
border: 2px solid #e1e1e1;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
width: 100%;
padding: 0.8rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
}
.btn-secondary {
background: #6c757d;
color: white;
margin-top: 1rem;
}
.btn-secondary:hover {
background: #5a6268;
}
.message {
padding: 0.8rem;
margin-bottom: 1rem;
border-radius: 6px;
font-size: 0.9rem;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.auth-links {
text-align: center;
margin-top: 1rem;
}
.auth-links a {
color: #667eea;
text-decoration: none;
margin: 0 0.5rem;
}
.auth-links a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>💧 Water Station Tracker</h1>
<p>Select a city to view water stations</p>
</div>
<div id="message"></div>
<div id="cityList" class="city-list">
<div class="loading">Loading cities...</div>
</div>
<div class="add-city-form">
<h3>Add New City</h3>
<form id="addCityForm">
<div class="form-group">
<label for="cityName">City Name</label>
<input type="text" id="cityName" name="display_name" required placeholder="e.g., San Francisco">
</div>
<button type="submit" class="btn btn-primary">Add City</button>
</form>
</div>
<div class="auth-links">
<a href="/login">Login</a>
<span>|</span>
<a href="/city/salem">View Salem (Default)</a>
</div>
</div>
<script>
let cities = [];
let user = null;
async function initPage() {
await checkAuth();
loadCities();
}
async function checkAuth() {
try {
const response = await fetch('/api/user');
const data = await response.json();
user = data.user;
if (!user) {
document.getElementById('addCityForm').style.display = 'none';
}
} catch (error) {
console.error('Auth check failed:', error);
document.getElementById('addCityForm').style.display = 'none';
}
}
async function loadCities() {
try {
const response = await fetch('/api/cities');
const data = await response.json();
cities = data;
displayCities();
} catch (error) {
console.error('Error loading cities:', error);
showMessage('Failed to load cities', 'error');
}
}
function displayCities() {
const cityList = document.getElementById('cityList');
if (cities.length === 0) {
cityList.innerHTML = '<p class="loading">No cities available</p>';
return;
}
cityList.innerHTML = cities.map(city => `
<div class="city-item" onclick="selectCity('${city.name}')">
<div class="city-name">${city.display_name}</div>
<div class="city-stats">Click to view</div>
</div>
`).join('');
}
function selectCity(cityName) {
window.location.href = `/city/${cityName}`;
}
function showMessage(message, type = 'success') {
const messageDiv = document.getElementById('message');
messageDiv.innerHTML = `<div class="message ${type}">${message}</div>`;
setTimeout(() => {
messageDiv.innerHTML = '';
}, 5000);
}
document.getElementById('addCityForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!user) {
showMessage('Please log in to add a city', 'error');
return;
}
const formData = new FormData(e.target);
const displayName = formData.get('display_name');
const data = {
name: displayName.toLowerCase().replace(/\s+/g, '-'),
display_name: displayName
};
try {
const response = await fetch('/api/cities', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
showMessage(`City "${displayName}" added successfully!`);
e.target.reset();
loadCities();
} else {
showMessage(result.error || 'Failed to add city', 'error');
}
} catch (error) {
showMessage('Failed to add city', 'error');
}
});
document.addEventListener('DOMContentLoaded', initPage);
</script>
</body>
</html>

View File

@ -37,6 +37,21 @@
gap: 1rem; gap: 1rem;
} }
.city-selector {
background: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.3);
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
}
.city-selector:hover {
background: rgba(255,255,255,0.3);
}
.user-info { .user-info {
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -304,9 +319,10 @@
<div class="header"> <div class="header">
<div class="header-right"> <div class="header-right">
<div class="user-info"> <div class="user-info">
<h1>💧 Water Stations</h1> <h1>💧 Water Stations - <span id="cityName">Loading...</span></h1>
Welcome, <span id="username"></span> Welcome, <span id="username"></span>
</div> </div>
<a href="/city-select" class="city-selector">Change City</a>
<button class="logout-btn" onclick="logout()">Logout</button> <button class="logout-btn" onclick="logout()">Logout</button>
</div> </div>
</div> </div>
@ -389,8 +405,20 @@
let addMode = false; let addMode = false;
let tempMarker = null; let tempMarker = null;
let user = null; let user = null;
let currentCity = null;
function initDashboard() { function initDashboard() {
// Get city name from URL
const pathParts = window.location.pathname.split('/');
currentCity = pathParts[2];
if (!currentCity) {
window.location.href = '/city-select';
return;
}
document.getElementById('cityName').textContent = currentCity.charAt(0).toUpperCase() + currentCity.slice(1);
checkAuth(); checkAuth();
initMap(); initMap();
loadStations(); loadStations();
@ -449,9 +477,12 @@
async function loadStations() { async function loadStations() {
try { try {
const response = await fetch('/api/stations'); const response = await fetch(`/api/cities/${currentCity}/stations`);
const data = await response.json(); const data = await response.json();
stations = data; stations = data;
if (data.length > 0) {
document.getElementById('cityName').textContent = data[0].city_name;
}
displayStations(); displayStations();
populateStationList(); populateStationList();
fitMapToStations(); fitMapToStations();
@ -654,7 +685,7 @@
}; };
try { try {
const response = await fetch('/api/stations', { const response = await fetch(`/api/cities/${currentCity}/stations`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -127,10 +127,11 @@
<body> <body>
<div class="header"> <div class="header">
<h1>💧 Water Stations</h1> <h1>💧 Water Stations</h1>
<p>Salem</p> <p id="cityName">Loading...</p>
</div> </div>
<a href="/login" class="auth-button">Login</a> <a href="/login" class="auth-button">Login</a>
<a href="/city-select" class="auth-button" style="top: 1rem; right: 8rem;">All Cities</a>
<div id="map"></div> <div id="map"></div>
@ -158,8 +159,22 @@
<script> <script>
let map; let map;
let stations = []; let stations = [];
let currentCity = null;
function initMap() { function initMap() {
// Get city name from URL
const pathParts = window.location.pathname.split('/');
const cityName = pathParts[2];
if (!cityName) {
// If no city in URL, redirect to city selection
window.location.href = '/city-select';
return;
}
currentCity = cityName;
document.getElementById('cityName').textContent = cityName.charAt(0).toUpperCase() + cityName.slice(1);
map = L.map('map').setView([37.7749, -122.4194], 13); map = L.map('map').setView([37.7749, -122.4194], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@ -170,14 +185,20 @@
} }
function loadStations() { function loadStations() {
fetch('/api/stations') fetch(`/api/cities/${currentCity}/stations`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
stations = data; stations = data;
if (data.length > 0) {
document.getElementById('cityName').textContent = data[0].city_name;
}
displayStations(); displayStations();
fitMapToStations(); fitMapToStations();
}) })
.catch(error => console.error('Error loading stations:', error)); .catch(error => {
console.error('Error loading stations:', error);
document.getElementById('cityName').textContent = 'City Not Found';
});
} }
function getStationColor(station) { function getStationColor(station) {

144
server.js
View File

@ -30,14 +30,25 @@ db.serialize(() => {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`); )`);
db.run(`CREATE TABLE IF NOT EXISTS cities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users (id)
)`);
db.run(`CREATE TABLE IF NOT EXISTS water_stations ( db.run(`CREATE TABLE IF NOT EXISTS water_stations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
latitude REAL NOT NULL, latitude REAL NOT NULL,
longitude REAL NOT NULL, longitude REAL NOT NULL,
description TEXT, description TEXT,
city_id INTEGER NOT NULL,
created_by INTEGER, created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (city_id) REFERENCES cities (id),
FOREIGN KEY (created_by) REFERENCES users (id) FOREIGN KEY (created_by) REFERENCES users (id)
)`); )`);
@ -52,6 +63,24 @@ db.serialize(() => {
FOREIGN KEY (station_id) REFERENCES water_stations (id), FOREIGN KEY (station_id) REFERENCES water_stations (id),
FOREIGN KEY (updated_by) REFERENCES users (id) FOREIGN KEY (updated_by) REFERENCES users (id)
)`); )`);
// Create default city if none exists
db.get('SELECT COUNT(*) as count FROM cities', (err, row) => {
if (!err && row.count === 0) {
db.run('INSERT INTO cities (name, display_name, created_by) VALUES (?, ?, ?)',
['salem', 'Salem', null]);
}
});
// Add city_id column to existing water_stations if it doesn't exist
db.all("PRAGMA table_info(water_stations)", (err, columns) => {
if (!err) {
const hasCityId = columns.some(col => col.name === 'city_id');
if (!hasCityId) {
db.run('ALTER TABLE water_stations ADD COLUMN city_id INTEGER DEFAULT 1');
}
}
});
}); });
// Middleware // Middleware
@ -148,7 +177,11 @@ passport.deserializeUser((id, done) => {
// Routes // Routes
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html')); res.redirect('/city/salem');
});
app.get('/city-select', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'city-select.html'));
}); });
// Authentication routes // Authentication routes
@ -159,7 +192,7 @@ app.get('/auth/google',
app.get('/auth/google/callback', app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }), passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => { (req, res) => {
res.redirect('/dashboard'); res.redirect('/city/salem/dashboard');
} }
); );
@ -170,7 +203,7 @@ app.get('/auth/instagram',
app.get('/auth/instagram/callback', app.get('/auth/instagram/callback',
passport.authenticate('instagram', { failureRedirect: '/login' }), passport.authenticate('instagram', { failureRedirect: '/login' }),
(req, res) => { (req, res) => {
res.redirect('/dashboard'); res.redirect('/city/salem/dashboard');
} }
); );
@ -217,6 +250,94 @@ app.get('/api/user', (req, res) => {
res.json({ user: req.user || null }); res.json({ user: req.user || null });
}); });
app.get('/api/cities', (req, res) => {
db.all('SELECT * FROM cities ORDER BY display_name', [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/cities', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, display_name } = req.body;
if (!name || !display_name) {
return res.status(400).json({ error: 'Name and display name are required' });
}
const normalizedName = name.toLowerCase().replace(/\s+/g, '-');
db.run('INSERT INTO cities (name, display_name, created_by) VALUES (?, ?, ?)',
[normalizedName, display_name, req.user.id],
function(err) {
if (err) {
if (err.code === 'SQLITE_CONSTRAINT') {
return res.status(400).json({ error: 'City already exists' });
}
return res.status(500).json({ error: 'Database error' });
}
res.json({ id: this.lastID, success: true });
}
);
});
app.get('/api/cities/:cityName/stations', (req, res) => {
const cityName = req.params.cityName;
const query = `
SELECT
ws.*,
u.username as created_by_name,
su.description as latest_description,
su.last_refill_time,
su.estimated_empty_time,
su.updated_at as last_updated,
u2.username as updated_by_name,
c.display_name as city_name
FROM water_stations ws
JOIN cities c ON ws.city_id = c.id
LEFT JOIN users u ON ws.created_by = u.id
LEFT JOIN (
SELECT station_id, description, last_refill_time, estimated_empty_time, updated_by, updated_at,
ROW_NUMBER() OVER (PARTITION BY station_id ORDER BY updated_at DESC) as rn
FROM station_updates
) su ON ws.id = su.station_id AND su.rn = 1
LEFT JOIN users u2 ON su.updated_by = u2.id
WHERE c.name = ?
`;
db.all(query, [cityName], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/cities/:cityName/stations', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const cityName = req.params.cityName;
const { name, latitude, longitude, description } = req.body;
// First, get the city ID
db.get('SELECT id FROM cities WHERE name = ?', [cityName], (err, city) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!city) return res.status(404).json({ error: 'City not found' });
db.run('INSERT INTO water_stations (name, latitude, longitude, description, city_id, created_by) VALUES (?, ?, ?, ?, ?, ?)',
[name, latitude, longitude, description, city.id, req.user.id],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ id: this.lastID, success: true });
}
);
});
});
app.get('/api/stations', (req, res) => { app.get('/api/stations', (req, res) => {
const query = ` const query = `
SELECT SELECT
@ -248,10 +369,10 @@ app.post('/api/stations', (req, res) => {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: 'Authentication required' });
} }
const { name, latitude, longitude, description } = req.body; const { name, latitude, longitude, description, city_id } = req.body;
db.run('INSERT INTO water_stations (name, latitude, longitude, description, created_by) VALUES (?, ?, ?, ?, ?)', db.run('INSERT INTO water_stations (name, latitude, longitude, description, city_id, created_by) VALUES (?, ?, ?, ?, ?, ?)',
[name, latitude, longitude, description, req.user.id], [name, latitude, longitude, description, city_id || 1, req.user.id],
function(err) { function(err) {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
res.json({ id: this.lastID, success: true }); res.json({ id: this.lastID, success: true });
@ -281,6 +402,17 @@ app.post('/api/stations/:id/update', (req, res) => {
}); });
app.get('/dashboard', (req, res) => { app.get('/dashboard', (req, res) => {
if (!req.user) {
return res.redirect('/login');
}
res.redirect('/city/salem/dashboard');
});
app.get('/city/:cityName', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/city/:cityName/dashboard', (req, res) => {
if (!req.user) { if (!req.user) {
return res.redirect('/login'); return res.redirect('/login');
} }