Initial commit

This commit is contained in:
Will Bradley 2025-07-16 12:00:50 -07:00
commit f4039224be
9 changed files with 4399 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
node_modules
water_stations.db

94
README.md Normal file
View File

@ -0,0 +1,94 @@
# Water Station Tracker
A mobile-first web application for tracking water refill stations with social login, real-time status updates, and interactive mapping.
## Features
- **Multi-platform Authentication**: Login via Google, Instagram, or traditional username/password
- **Interactive Map**: View all water stations with color-coded status indicators
- **Mobile-First Design**: Optimized for smartphones and tablets
- **Real-time Updates**: Track refill times and estimated empty times
- **Status Color Coding**:
- 🟢 Green: Recently refilled
- 🟡 Yellow: Needs refill soon
- 🔴 Red: Empty
- ⚫ Black: Not updated in 7+ days
## Installation
1. Clone the repository
2. Install dependencies:
```bash
npm install
```
3. Set up environment variables in `.env`:
```
SESSION_SECRET=your-secret-key
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
INSTAGRAM_CLIENT_ID=your-instagram-client-id
INSTAGRAM_CLIENT_SECRET=your-instagram-client-secret
```
4. Run the application:
```bash
npm start
```
## OAuth Setup
### Google OAuth
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable Google+ API
4. Create OAuth 2.0 credentials
5. Add authorized redirect URI: `http://localhost:3000/auth/google/callback`
### Instagram OAuth
1. Go to [Instagram Developers](https://developers.facebook.com/apps/)
2. Create a new app
3. Add Instagram Basic Display product
4. Create Instagram App
5. Add redirect URI: `http://localhost:3000/auth/instagram/callback`
## Database Schema
The app uses SQLite with three main tables:
- `users`: User authentication and profile data
- `water_stations`: Station locations and basic info
- `station_updates`: Status updates with timestamps
## API Endpoints
- `GET /`: Public map view
- `GET /login`: Login page
- `GET /dashboard`: User dashboard (requires auth)
- `GET /api/stations`: Get all stations
- `POST /api/stations`: Add new station (requires auth)
- `POST /api/stations/:id/update`: Update station status (requires auth)
## Usage
1. **Public Users**: Can view the map and station statuses
2. **Registered Users**: Can add new stations and update existing ones
3. **Station Updates**: Include description, refill time, and estimated empty time
4. **Mobile Interface**: Responsive design works on all screen sizes
## Development
For development with auto-reload:
```bash
npm run dev
```
## Production Deployment
1. Set `NODE_ENV=production`
2. Configure HTTPS and update session cookie settings
3. Set up proper database backup
4. Configure reverse proxy (nginx recommended)
## License
MIT License

16
env.dist Normal file
View File

@ -0,0 +1,16 @@
# Session secret for cookie encryption
SESSION_SECRET=your-super-secret-session-key-change-this-in-production
# Google OAuth credentials
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# Instagram OAuth credentials
INSTAGRAM_CLIENT_ID=your-instagram-client-id
INSTAGRAM_CLIENT_SECRET=your-instagram-client-secret
# Database configuration
DATABASE_URL=./water_stations.db
# Server configuration
PORT=3000

2630
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "water-station-tracker",
"version": "1.0.0",
"description": "Mobile-first water station tracking app with social login",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"express-session": "^1.17.3",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"passport-instagram": "^1.0.0",
"passport-local": "^1.0.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

733
public/dashboard.html Normal file
View File

@ -0,0 +1,733 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Water Station Tracker</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.3rem;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
font-size: 0.9rem;
}
.logout-btn {
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;
cursor: pointer;
font-size: 0.9rem;
}
.main-content {
display: flex;
height: calc(100vh - 80px);
}
.sidebar {
width: 100%;
max-width: 400px;
background: white;
border-right: 1px solid #e1e1e1;
overflow-y: auto;
}
.map-container {
flex: 1;
position: relative;
}
#map {
height: 100%;
width: 100%;
}
.tab-buttons {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #e1e1e1;
}
.tab-button {
flex: 1;
padding: 1rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
color: #666;
border-bottom: 2px solid transparent;
}
.tab-button.active {
color: #667eea;
border-bottom-color: #667eea;
background: white;
}
.tab-content {
padding: 1rem;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
font-size: 0.9rem;
}
input[type="text"],
input[type="number"],
textarea,
select {
width: 100%;
padding: 0.6rem;
border: 2px solid #e1e1e1;
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.3s;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 2em;
}
.btn {
width: 49%;
padding: 0.7rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
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;
}
.btn-secondary:hover {
background: #5a6268;
}
.station-list {
max-height: 300px;
overflow-y: auto;
}
.station-item {
padding: 0.8rem;
border-bottom: 1px solid #e1e1e1;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
}
.station-item:hover {
background: #f8f9fa;
}
.station-item.selected {
background: #e3f2fd;
border-left: 4px solid #667eea;
}
.station-name {
font-weight: 500;
color: #333;
margin-bottom: 0.3rem;
}
.station-status {
font-size: 0.8rem;
color: #666;
display: flex;
align-items: center;
margin-left: 2em;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.5rem;
}
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
}
.add-pin-btn {
background: white;
border: 1px solid #ccc;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 4px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-size: 0.8rem;
display: inline-block;
width: 40%;
}
input#coordinates {
display: inline-block;
width: 58%;
}
.map-control-btn {
background: white;
border: 1px solid #ccc;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 4px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-size: 0.8rem;
}
.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;
}
@media (max-width: 768px) {
.main-content {
flex-direction: column;
}
.sidebar {
max-width: none;
height: 55vh;
border-right: none;
border-bottom: 1px solid #e1e1e1;
}
.map-container {
height: 40vh;
}
.header {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.header-right {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-right">
<div class="user-info">
<h1>💧 Water Stations</h1>
Welcome, <span id="username"></span>
</div>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</div>
<div class="main-content">
<div class="sidebar">
<div class="tab-buttons">
<button class="tab-button active" onclick="switchTab('update')">Update Status</button>
<button class="tab-button" onclick="switchTab('add')">Add Station</button>
</div>
<div class="tab-content">
<div id="update-tab" class="tab-panel active">
<div id="update-message"></div>
<div class="form-group">
<div class="station-list" id="stationList">
<!-- Stations will be populated here -->
</div>
</div>
<form id="updateStationForm" style="display: none;">
<div class="form-group">
<label for="updateDescription">Status Description</label>
<textarea id="updateDescription" name="description" placeholder="e.g., Refilled and working well"></textarea>
</div>
<div class="form-group">
<label for="estimatedHours">Estimated hours until empty</label>
<input type="number" id="estimatedHours" name="estimatedHours" min="1" max="48" value="6">
</div>
<button type="submit" class="btn btn-primary">Update Status</button>
<button type="button" class="btn btn-secondary" onclick="cancelUpdate()">Cancel</button>
</form>
</div>
<div id="add-tab" class="tab-panel">
<div id="add-message"></div>
<form id="addStationForm">
<div class="form-group">
<label for="stationName">Station Name</label>
<input type="text" id="stationName" name="name" required>
</div>
<div class="form-group">
<label for="stationDescription">Description</label>
<textarea id="stationDescription" name="description" placeholder="e.g., Public fountain in park"></textarea>
</div>
<div class="form-group">
<label>Location</label>
<!--<p style="font-size: 0.8rem; color: #666; margin-bottom: 0.5rem;">
Click on the map to set location
</p>-->
<button class="add-pin-btn" onclick="startAddMode()">
<span id="addModeText">Select Location</span>
</button>
<input type="text" id="coordinates" readonly>
</div>
<button type="submit" class="btn btn-primary">Add Station</button>
</form>
</div>
</div>
</div>
<div class="map-container">
<div id="map"></div>
<!--<div class="map-controls">
<button class="map-control-btn" onclick="centerMap()">Center Map</button>
</div>-->
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let stations = [];
let selectedStation = null;
let addMode = false;
let tempMarker = null;
let user = null;
function initDashboard() {
checkAuth();
initMap();
loadStations();
}
async function checkAuth() {
try {
const response = await fetch('/api/user');
const data = await response.json();
if (!data.user) {
window.location.href = '/login';
return;
}
user = data.user;
document.getElementById('username').textContent = user.display_name || user.username || 'User';
} catch (error) {
window.location.href = '/login';
}
}
function initMap() {
map = L.map('map').setView([37.7749, -122.4194], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
map.on('click', function(e) {
if (addMode) {
setStationLocation(e.latlng);
}
});
}
function setStationLocation(latlng) {
if (tempMarker) {
map.removeLayer(tempMarker);
}
tempMarker = L.marker(latlng).addTo(map);
document.getElementById('coordinates').value = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
stopAddMode();
}
function startAddMode() {
addMode = true;
map.getContainer().style.cursor = 'crosshair';
}
function stopAddMode() {
addMode = false;
map.getContainer().style.cursor = '';
}
async function loadStations() {
try {
const response = await fetch('/api/stations');
const data = await response.json();
stations = data;
displayStations();
populateStationList();
fitMapToStations();
} catch (error) {
console.error('Error loading stations:', error);
}
}
function getStationColor(station) {
if (!station.last_refill_time) {
return '#333';
}
const now = new Date();
const refillTime = new Date(station.last_refill_time);
const timeSinceRefill = now - refillTime;
const daysSinceRefill = timeSinceRefill / (1000 * 60 * 60 * 24);
if (daysSinceRefill > 7) {
return '#333';
}
if (!station.estimated_empty_time) {
return '#4CAF50';
}
const emptyTime = new Date(station.estimated_empty_time);
const timeUntilEmpty = emptyTime - now;
const hoursUntilEmpty = timeUntilEmpty / (1000 * 60 * 60);
if (hoursUntilEmpty <= 0) {
return '#f44336';
} else if (hoursUntilEmpty <= 3) {
return '#FFC107';
} else {
return '#4CAF50';
}
}
function displayStations() {
stations.forEach(station => {
const color = getStationColor(station);
const marker = L.circleMarker([station.latitude, station.longitude], {
color: color,
fillColor: color,
fillOpacity: 0.8,
radius: 10,
weight: 2
}).addTo(map);
const popupContent = createPopupContent(station);
marker.bindPopup(popupContent);
});
}
function createPopupContent(station) {
const refillTime = station.last_refill_time ?
new Date(station.last_refill_time).toLocaleString() : 'Never';
const estimatedEmpty = station.estimated_empty_time ?
new Date(station.estimated_empty_time).toLocaleString() : 'Unknown';
return `
<div style="min-width: 200px;">
<h3>${station.name}</h3>
<p><strong>Description:</strong> ${station.latest_description || 'No description'}</p>
<p><strong>Last Refill:</strong> ${refillTime}</p>
<p><strong>Estimated Empty:</strong> ${estimatedEmpty}</p>
<p><strong>Last Updated:</strong> ${station.last_updated ? new Date(station.last_updated).toLocaleString() : 'Never'}</p>
<p><strong>Last Updated By:</strong> ${station.updated_by_name || 'Unknown'}</p>
</div>
`;
}
function populateStationList() {
const stationList = document.getElementById('stationList');
stationList.innerHTML = '';
stations.forEach(station => {
const item = document.createElement('div');
item.className = 'station-item';
item.onclick = () => selectStation(station);
const color = getStationColor(station);
const statusText = getStatusText(station);
item.innerHTML = `
<span class="station-name">${station.name}</span>
<span class="station-status">
<span class="status-dot" style="background-color: ${color}"></span>
${statusText}
</span>
`;
stationList.appendChild(item);
});
}
function getStatusText(station) {
if (!station.last_refill_time) return 'Never updated';
const now = new Date();
const refillTime = new Date(station.last_refill_time);
const daysSinceRefill = (now - refillTime) / (1000 * 60 * 60 * 24);
if (daysSinceRefill > 7) return 'Old data (7+ days)';
if (!station.estimated_empty_time) return 'Recently refilled';
const emptyTime = new Date(station.estimated_empty_time);
const hoursUntilEmpty = (emptyTime - now) / (1000 * 60 * 60);
if (hoursUntilEmpty <= 0) return 'Empty';
if (hoursUntilEmpty <= 3) return 'Needs refill soon';
return 'Recently refilled';
}
function selectStation(station) {
selectedStation = station;
// Update UI
document.querySelectorAll('.station-item').forEach(item => {
item.classList.remove('selected');
});
event.target.closest('.station-item').classList.add('selected');
// Show update form
document.getElementById('updateStationForm').style.display = 'block';
// Center map on selected station
map.setView([station.latitude, station.longitude], 16);
}
function cancelUpdate() {
selectedStation = null;
document.getElementById('updateStationForm').style.display = 'none';
document.querySelectorAll('.station-item').forEach(item => {
item.classList.remove('selected');
});
}
function fitMapToStations() {
if (stations.length === 0) return;
const bounds = L.latLngBounds();
stations.forEach(station => {
bounds.extend([station.latitude, station.longitude]);
});
map.fitBounds(bounds, { padding: [20, 20] });
}
function centerMap() {
fitMapToStations();
}
function switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.classList.remove('active');
});
event.target.classList.add('active');
document.getElementById(tabName + '-tab').classList.add('active');
}
function showMessage(elementId, message, type = 'success') {
const messageDiv = document.getElementById(elementId);
messageDiv.innerHTML = `<div class="message ${type}">${message}</div>`;
setTimeout(() => {
messageDiv.innerHTML = '';
}, 5000);
}
// Form handlers
document.getElementById('addStationForm').addEventListener('submit', async (e) => {
e.preventDefault();
const coordinates = document.getElementById('coordinates').value;
if (!coordinates) {
showMessage('add-message', 'Please select a location on the map', 'error');
return;
}
const [lat, lng] = coordinates.split(',').map(coord => parseFloat(coord.trim()));
const formData = new FormData(e.target);
const data = {
name: formData.get('name'),
description: formData.get('description'),
latitude: lat,
longitude: lng
};
try {
const response = await fetch('/api/stations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
showMessage('add-message', 'Station added successfully!');
e.target.reset();
document.getElementById('coordinates').value = '';
if (tempMarker) {
map.removeLayer(tempMarker);
tempMarker = null;
}
loadStations();
} else {
const result = await response.json();
showMessage('add-message', result.error || 'Failed to add station', 'error');
}
} catch (error) {
showMessage('add-message', 'Failed to add station', 'error');
}
});
document.getElementById('updateStationForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedStation) {
showMessage('update-message', 'Please select a station first', 'error');
return;
}
const formData = new FormData(e.target);
const data = {
description: formData.get('description'),
estimatedHours: formData.get('estimatedHours')
};
try {
const response = await fetch(`/api/stations/${selectedStation.id}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
showMessage('update-message', 'Station updated successfully!');
e.target.reset();
cancelUpdate();
loadStations();
} else {
const result = await response.json();
showMessage('update-message', result.error || 'Failed to update station', 'error');
}
} catch (error) {
showMessage('update-message', 'Failed to update station', 'error');
}
});
async function logout() {
try {
await fetch('/auth/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
console.error('Logout failed:', error);
}
}
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', initDashboard);
</script>
</body>
</html>

266
public/index.html Normal file
View 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>Water Station</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.header p {
opacity: 0.9;
font-size: 0.9rem;
}
.auth-button {
position: fixed;
top: 1rem;
right: 1rem;
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;
text-decoration: none;
font-size: 0.9rem;
z-index: 1000;
}
#map {
height: calc(100vh - 120px);
width: 100%;
}
.station-popup {
min-width: 250px;
max-width: 300px;
}
.station-popup h3 {
color: #333;
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.station-info {
font-size: 0.9rem;
line-height: 1.4;
}
.station-info p {
margin-bottom: 0.3rem;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 0.5rem;
}
.status-green { background-color: #4CAF50; }
.status-yellow { background-color: #FFC107; }
.status-red { background-color: #f44336; }
.status-black { background-color: #333; }
.legend {
position: fixed;
bottom: 1rem;
left: 1rem;
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
font-size: 0.8rem;
z-index: 1000;
}
.legend h4 {
margin-bottom: 0.5rem;
color: #333;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 0.3rem;
}
@media (max-width: 480px) {
.header h1 {
font-size: 1.2rem;
}
.legend {
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
padding: 0.8rem;
}
}
</style>
</head>
<body>
<div class="header">
<h1>💧 Water Stations</h1>
<p>Salem</p>
</div>
<a href="/login" class="auth-button">Login</a>
<div id="map"></div>
<div class="legend">
<h4>Station Status</h4>
<div class="legend-item">
<span class="status-indicator status-green"></span>
Recently refilled
</div>
<div class="legend-item">
<span class="status-indicator status-yellow"></span>
Needs refill soon
</div>
<div class="legend-item">
<span class="status-indicator status-red"></span>
Empty
</div>
<div class="legend-item">
<span class="status-indicator status-black"></span>
Not updated (7+ days)
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let stations = [];
function initMap() {
map = L.map('map').setView([37.7749, -122.4194], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
loadStations();
}
function loadStations() {
fetch('/api/stations')
.then(response => response.json())
.then(data => {
stations = data;
displayStations();
fitMapToStations();
})
.catch(error => console.error('Error loading stations:', error));
}
function getStationColor(station) {
if (!station.last_refill_time) {
return '#333'; // Black for no updates
}
const now = new Date();
const refillTime = new Date(station.last_refill_time);
const timeSinceRefill = now - refillTime;
const daysSinceRefill = timeSinceRefill / (1000 * 60 * 60 * 24);
if (daysSinceRefill > 7) {
return '#333'; // Black for old data
}
if (!station.estimated_empty_time) {
return '#4CAF50'; // Green if no empty time estimate
}
const emptyTime = new Date(station.estimated_empty_time);
const timeUntilEmpty = emptyTime - now;
const hoursUntilEmpty = timeUntilEmpty / (1000 * 60 * 60);
if (hoursUntilEmpty <= 0) {
return '#f44336'; // Red for empty
} else if (hoursUntilEmpty <= 3) {
return '#FFC107'; // Yellow for needs refill soon
} else {
return '#4CAF50'; // Green for good
}
}
function displayStations() {
stations.forEach(station => {
const color = getStationColor(station);
const marker = L.circleMarker([station.latitude, station.longitude], {
color: color,
fillColor: color,
fillOpacity: 0.8,
radius: 8,
weight: 2
}).addTo(map);
const popupContent = createPopupContent(station);
marker.bindPopup(popupContent);
});
}
function createPopupContent(station) {
const refillTime = station.last_refill_time ?
new Date(station.last_refill_time).toLocaleString() : 'Never';
const estimatedEmpty = station.estimated_empty_time ?
new Date(station.estimated_empty_time).toLocaleString() : 'Unknown';
return `
<div class="station-popup">
<h3>${station.name}</h3>
<div class="station-info">
<p><strong>Description:</strong> ${station.latest_description || 'No description'}</p>
<p><strong>Last Refill:</strong> ${refillTime}</p>
<p><strong>Estimated Empty:</strong> ${estimatedEmpty}</p>
<p><strong>Last Updated:</strong> ${station.last_updated ? new Date(station.last_updated).toLocaleString() : 'Never'}</p>
</div>
</div>
`;
}
function fitMapToStations() {
if (stations.length === 0) return;
const bounds = L.latLngBounds();
stations.forEach(station => {
bounds.extend([station.latitude, station.longitude]);
});
map.fitBounds(bounds, { padding: [20, 20] });
}
// Initialize map when page loads
document.addEventListener('DOMContentLoaded', initMap);
</script>
</body>
</html>

335
public/login.html Normal file
View File

@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - 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;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
color: #333;
font-size: 1.8rem;
margin-bottom: 0.5rem;
}
.login-header p {
color: #666;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
}
input[type="text"],
input[type="email"],
input[type="password"] {
width: 100%;
padding: 0.8rem;
border: 2px solid #e1e1e1;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]: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;
margin-bottom: 1rem;
display: inline-block;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
}
.btn-google {
background: #db4437;
color: white;
width: 49%;
}
.btn-google:hover {
background: #c23321;
}
.btn-instagram {
background: #e4405f;
color: white;
width: 49%;
}
.btn-instagram:hover {
background: #d62d46;
}
.divider {
text-align: center;
margin: 1.5rem 0;
color: #666;
position: relative;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #e1e1e1;
}
.divider span {
background: white;
padding: 0 1rem;
}
.toggle-form {
text-align: center;
margin-top: 1rem;
}
.toggle-form a {
color: #667eea;
text-decoration: none;
}
.toggle-form a:hover {
text-decoration: underline;
}
.back-link {
text-align: center;
margin-top: 1rem;
}
.back-link a {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
.error {
color: #f44336;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.success {
color: #4CAF50;
font-size: 0.9rem;
margin-top: 0.5rem;
}
@media (max-width: 480px) {
.login-container {
padding: 1.5rem;
}
.login-header h1 {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>💧 Water Station Tracker</h1>
<p>Login to manage water stations</p>
</div>
<div id="login-form">
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
<div class="divider">
<span>or</span>
</div>
<a href="/auth/google" class="btn btn-google">Google</a>
<a href="/auth/instagram" class="btn btn-instagram">Instagram</a>
<div class="toggle-form">
<a href="#" onclick="toggleForm()">Don't have an account? Register</a>
</div>
</div>
<div id="register-form" style="display: none;">
<form id="registerForm">
<div class="form-group">
<label for="reg-username">Username</label>
<input type="text" id="reg-username" name="username" required>
</div>
<div class="form-group">
<label for="reg-email">Email</label>
<input type="email" id="reg-email" name="email" required>
</div>
<div class="form-group">
<label for="reg-password">Password</label>
<input type="password" id="reg-password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<div class="divider">
<span>or</span>
</div>
<a href="/auth/google" class="btn btn-google">Sign up with Google</a>
<a href="/auth/instagram" class="btn btn-instagram">Sign up with Instagram</a>
<div class="toggle-form">
<a href="#" onclick="toggleForm()">Already have an account? Login</a>
</div>
</div>
<div id="message"></div>
<div class="back-link">
<a href="/">← Back to Map</a>
</div>
</div>
<script>
function toggleForm() {
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
if (loginForm.style.display === 'none') {
loginForm.style.display = 'block';
registerForm.style.display = 'none';
} else {
loginForm.style.display = 'none';
registerForm.style.display = 'block';
}
document.getElementById('message').innerHTML = '';
}
function showMessage(message, type = 'error') {
const messageDiv = document.getElementById('message');
messageDiv.innerHTML = `<div class="${type}">${message}</div>`;
}
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
window.location.href = '/dashboard';
} else {
showMessage('Invalid username or password');
}
} catch (error) {
showMessage('Login failed. Please try again.');
}
});
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
showMessage('Registration successful! Redirecting...', 'success');
setTimeout(() => {
window.location.href = '/dashboard';
}, 1500);
} else {
showMessage(result.error || 'Registration failed');
}
} catch (error) {
showMessage('Registration failed. Please try again.');
}
});
</script>
</body>
</html>

296
server.js Normal file
View File

@ -0,0 +1,296 @@
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const session = require('express-session');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const InstagramStrategy = require('passport-instagram').Strategy;
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcryptjs');
const bodyParser = require('body-parser');
const cors = require('cors');
const path = require('path');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
// Database setup
const db = new sqlite3.Database('./water_stations.db');
// Initialize database tables
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
google_id TEXT,
instagram_id TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS water_stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
description TEXT,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users (id)
)`);
db.run(`CREATE TABLE IF NOT EXISTS station_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
station_id INTEGER,
description TEXT,
last_refill_time DATETIME DEFAULT CURRENT_TIMESTAMP,
estimated_empty_time DATETIME,
updated_by INTEGER,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (station_id) REFERENCES water_stations (id),
FOREIGN KEY (updated_by) REFERENCES users (id)
)`);
});
// Middleware
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: { secure: false } // Set to true in production with HTTPS
}));
app.use(passport.initialize());
app.use(passport.session());
// Passport configuration
passport.use(new LocalStrategy(
{ usernameField: 'username' },
async (username, password, done) => {
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
if (err) return done(err);
if (!user) return done(null, false);
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) return done(null, false);
return done(null, user);
});
}
));
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback"
}, (accessToken, refreshToken, profile, done) => {
db.get('SELECT * FROM users WHERE google_id = ?', [profile.id], (err, user) => {
if (err) return done(err);
if (user) {
return done(null, user);
} else {
db.run('INSERT INTO users (google_id, display_name, email) VALUES (?, ?, ?)',
[profile.id, profile.displayName, profile.emails[0].value],
function(err) {
if (err) return done(err);
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (err, user) => {
return done(err, user);
});
}
);
}
});
}));
passport.use(new InstagramStrategy({
clientID: process.env.INSTAGRAM_CLIENT_ID,
clientSecret: process.env.INSTAGRAM_CLIENT_SECRET,
callbackURL: "/auth/instagram/callback"
}, (accessToken, refreshToken, profile, done) => {
db.get('SELECT * FROM users WHERE instagram_id = ?', [profile.id], (err, user) => {
if (err) return done(err);
if (user) {
return done(null, user);
} else {
db.run('INSERT INTO users (instagram_id, display_name) VALUES (?, ?)',
[profile.id, profile.displayName],
function(err) {
if (err) return done(err);
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (err, user) => {
return done(err, user);
});
}
);
}
});
}));
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
db.get('SELECT * FROM users WHERE id = ?', [id], (err, user) => {
done(err, user);
});
});
// Routes
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Authentication routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
app.get('/auth/instagram',
passport.authenticate('instagram')
);
app.get('/auth/instagram/callback',
passport.authenticate('instagram', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
app.post('/auth/login', passport.authenticate('local'), (req, res) => {
res.json({ success: true, user: req.user });
});
app.post('/auth/register', async (req, res) => {
const { username, email, password } = req.body;
try {
const hashedPassword = await bcrypt.hash(password, 10);
db.run('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)',
[username, email, hashedPassword],
function(err) {
if (err) {
return res.status(400).json({ error: 'Username or email already exists' });
}
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (err, user) => {
if (err) return res.status(500).json({ error: 'Database error' });
req.login(user, (err) => {
if (err) return res.status(500).json({ error: 'Login error' });
res.json({ success: true, user: user });
});
});
}
);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
app.post('/auth/logout', (req, res) => {
req.logout(() => {
res.json({ success: true });
});
});
// API routes
app.get('/api/user', (req, res) => {
res.json({ user: req.user || null });
});
app.get('/api/stations', (req, res) => {
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
FROM water_stations ws
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
`;
db.all(query, [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/stations', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, latitude, longitude, description } = req.body;
db.run('INSERT INTO water_stations (name, latitude, longitude, description, created_by) VALUES (?, ?, ?, ?, ?)',
[name, latitude, longitude, description, req.user.id],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ id: this.lastID, success: true });
}
);
});
app.post('/api/stations/:id/update', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { description, estimatedHours } = req.body;
const stationId = req.params.id;
const estimatedEmptyTime = new Date();
estimatedEmptyTime.setHours(estimatedEmptyTime.getHours() + parseInt(estimatedHours));
console.log("Updated by",req.user);
db.run('INSERT INTO station_updates (station_id, description, estimated_empty_time, updated_by) VALUES (?, ?, ?, ?)',
[stationId, description, estimatedEmptyTime.toISOString(), req.user.id],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ success: true });
}
);
});
app.get('/dashboard', (req, res) => {
if (!req.user) {
return res.redirect('/login');
}
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
app.get('/login', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});