water-station-tracker/public/dashboard.html
Will Bradley 275c91f4e1 Allow users to edit the station itself when clicking a station (description, etc). Humanize refill/empty date/time formats. Remove last
updated line on the popup. On the select city page only show the add new city section and heading when logged in, adjust the login link
  based on logged-in status, and say click to update on each city button when logged in.
2025-07-16 13:16:16 -07:00

868 lines
30 KiB
HTML

<!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;
}
.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 {
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 {
height: calc(100vh - 80px);
}
#map {
height: 100%;
width: 100%;
}
.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;
}
.context-menu {
position: absolute;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1000;
display: none;
min-width: 150px;
}
.context-menu-item {
padding: 0.75rem 1rem;
cursor: pointer;
font-size: 0.9rem;
color: #333;
border-bottom: 1px solid #eee;
}
.context-menu-item:last-child {
border-bottom: none;
}
.context-menu-item:hover {
background: #f5f5f5;
}
.context-menu-item:before {
content: '+ ';
font-weight: bold;
color: #667eea;
}
.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;
}
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 10% auto;
padding: 2rem;
border-radius: 8px;
width: 90%;
max-width: 500px;
position: relative;
}
.close {
position: absolute;
right: 1rem;
top: 1rem;
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.close:hover {
color: #000;
}
.modal h2 {
margin-bottom: 1rem;
color: #333;
}
.popup-btn {
background: #667eea;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.popup-btn:hover {
background: #5a6fd8;
}
@media (max-width: 768px) {
.modal-content {
margin: 5% auto;
width: 95%;
padding: 1.5rem;
}
}
</style>
</head>
<body>
<div class="header">
<h1>💧 <span id="cityName">Loading...</span></h1>
<div class="header-right">
<a href="/city-select?from=dashboard" class="city-selector">Change City</a>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</div>
<div class="main-content">
<div id="map"></div>
<div id="contextMenu" class="context-menu">
<div class="context-menu-item" id="addStationMenuItem">Add Station Here</div>
</div>
</div>
<!-- Update Status Modal -->
<div id="updateModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeUpdateModal()">&times;</span>
<h2>Update Station Status</h2>
<div id="update-message"></div>
<form id="updateStationForm">
<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="closeUpdateModal()">Cancel</button>
</form>
</div>
</div>
<!-- Add Station Modal -->
<div id="addModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeAddModal()">&times;</span>
<h2>Add New Station</h2>
<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>
<input type="text" id="coordinates" readonly placeholder="Right-click or long-press map to select location">
</div>
<button type="submit" class="btn btn-primary">Add Station</button>
</form>
</div>
</div>
<!-- Edit Station Modal -->
<div id="editModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeEditModal()">&times;</span>
<h2>Edit Station</h2>
<div id="edit-message"></div>
<form id="editStationForm">
<div class="form-group">
<label for="editStationName">Station Name</label>
<input type="text" id="editStationName" name="name" required>
</div>
<div class="form-group">
<label for="editStationDescription">Description</label>
<textarea id="editStationDescription" name="description" placeholder="e.g., Public fountain in park"></textarea>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" id="editCoordinates" readonly>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn btn-secondary" onclick="closeEditModal()">Cancel</button>
</form>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let stations = [];
let selectedStation = null;
let tempMarker = null;
let user = null;
let currentCity = null;
let contextMenuPosition = null;
let longPressTimer = null;
// Helper function to format dates in a human-readable way
function formatTimeAgo(dateString) {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return 'Just now';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
} else if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400);
return `${days} day${days !== 1 ? 's' : ''} ago`;
} else {
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
}
// Helper function to format estimated empty time
function formatTimeUntil(dateString) {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((date - now) / 1000);
if (diffInSeconds <= 0) {
return 'Already empty';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} hour${hours !== 1 ? 's' : ''}`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `${days} day${days !== 1 ? 's' : ''}`;
}
}
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();
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;
} 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);
// Handle right-click context menu
map.on('contextmenu', function(e) {
e.originalEvent.preventDefault();
const clientX = e.originalEvent.clientX || e.originalEvent.pageX;
const clientY = e.originalEvent.clientY || e.originalEvent.pageY;
showContextMenu(clientX, clientY, e.latlng);
});
// Handle long-press for mobile
let longPressTimer = null;
let longPressStarted = false;
map.on('mousedown', function(e) {
if (e.originalEvent.button === 0) { // Left click only
longPressStarted = true;
longPressTimer = setTimeout(() => {
if (longPressStarted) {
showContextMenu(e.originalEvent.clientX, e.originalEvent.clientY, e.latlng);
}
}, 500); // 500ms for long press
}
});
map.on('mouseup', function(e) {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
longPressStarted = false;
});
map.on('mousemove', function(e) {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
longPressStarted = false;
});
// Hide context menu on map click
map.on('click', function(e) {
hideContextMenu();
});
}
// Context menu functions
function showContextMenu(x, y, latlng) {
hideContextMenu();
const contextMenu = document.getElementById('contextMenu');
contextMenuPosition = latlng;
contextMenu.style.display = 'block';
contextMenu.style.left = x + 'px';
contextMenu.style.top = y + 'px';
// Adjust position if menu goes off screen
const rect = contextMenu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (rect.right > viewportWidth) {
contextMenu.style.left = (x - rect.width) + 'px';
}
if (rect.bottom > viewportHeight) {
contextMenu.style.top = (y - rect.height) + 'px';
}
}
function hideContextMenu() {
document.getElementById('contextMenu').style.display = 'none';
contextMenuPosition = null;
}
function contextAddStation() {
const savedPosition = contextMenuPosition;
hideContextMenu();
if (savedPosition) {
openAddStationModal(savedPosition);
}
}
// Expose functions to global scope for onclick handlers (for modal close buttons)
window.closeUpdateModal = closeUpdateModal;
window.closeAddModal = closeAddModal;
window.closeEditModal = closeEditModal;
window.openEditModal = openEditModal;
async function loadStations() {
try {
const response = await fetch(`/api/cities/${currentCity}/stations`);
const data = await response.json();
stations = data;
if (data.length > 0) {
document.getElementById('cityName').textContent = data[0].city_name;
}
displayStations();
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 = formatTimeAgo(station.last_refill_time);
const estimatedEmpty = formatTimeUntil(station.estimated_empty_time);
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 By:</strong> ${station.updated_by_name || 'Unknown'}</p>
<div style="margin-top: 10px;">
<button class="popup-btn" onclick="openEditModal(${station.id})">Edit Station</button>
<button class="popup-btn" onclick="openUpdateModal(${station.id})">Update Status</button>
</div>
</div>
`;
}
// Modal functions
function openUpdateModal(stationId) {
const station = stations.find(s => s.id === stationId);
if (!station) return;
selectedStation = station;
document.getElementById('updateModal').style.display = 'block';
// Center map on selected station
map.setView([station.latitude, station.longitude], 16);
}
function closeUpdateModal() {
selectedStation = null;
document.getElementById('updateModal').style.display = 'none';
document.getElementById('updateStationForm').reset();
document.getElementById('update-message').innerHTML = '';
}
function openAddStationModal(latlng = null) {
document.getElementById('addModal').style.display = 'block';
if (latlng) {
document.getElementById('coordinates').value = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
if (tempMarker) {
map.removeLayer(tempMarker);
}
tempMarker = L.marker(latlng).addTo(map);
}
}
function closeAddModal() {
document.getElementById('addModal').style.display = 'none';
document.getElementById('addStationForm').reset();
document.getElementById('add-message').innerHTML = '';
document.getElementById('coordinates').value = '';
if (tempMarker) {
map.removeLayer(tempMarker);
tempMarker = null;
}
}
function openEditModal(stationId) {
const station = stations.find(s => s.id === stationId);
if (!station) return;
selectedStation = station;
document.getElementById('editModal').style.display = 'block';
document.getElementById('editStationName').value = station.name;
document.getElementById('editStationDescription').value = station.description || '';
document.getElementById('editCoordinates').value = `${station.latitude.toFixed(6)}, ${station.longitude.toFixed(6)}`;
// Center map on selected station
map.setView([station.latitude, station.longitude], 16);
}
function closeEditModal() {
selectedStation = null;
document.getElementById('editModal').style.display = 'none';
document.getElementById('editStationForm').reset();
document.getElementById('edit-message').innerHTML = '';
}
// Close modal when clicking outside
window.onclick = function(event) {
const updateModal = document.getElementById('updateModal');
const addModal = document.getElementById('addModal');
const editModal = document.getElementById('editModal');
const contextMenu = document.getElementById('contextMenu');
if (event.target === updateModal) {
closeUpdateModal();
}
if (event.target === addModal) {
closeAddModal();
}
if (event.target === editModal) {
closeEditModal();
}
if (!contextMenu.contains(event.target)) {
hideContextMenu();
}
}
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 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 right-click or long-press on the map to select a location', '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/cities/${currentCity}/stations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
showMessage('add-message', 'Station added successfully!');
setTimeout(() => {
closeAddModal();
loadStations();
}, 1500);
} 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!');
setTimeout(() => {
closeUpdateModal();
loadStations();
}, 1500);
} 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');
}
});
document.getElementById('editStationForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedStation) {
showMessage('edit-message', 'Please select a station first', 'error');
return;
}
const formData = new FormData(e.target);
const data = {
name: formData.get('name'),
description: formData.get('description')
};
try {
const response = await fetch(`/api/stations/${selectedStation.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
showMessage('edit-message', 'Station updated successfully!');
setTimeout(() => {
closeEditModal();
loadStations();
}, 1500);
} else {
const result = await response.json();
showMessage('edit-message', result.error || 'Failed to update station', 'error');
}
} catch (error) {
showMessage('edit-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', function() {
initDashboard();
// Add event listener for context menu item
const menuItem = document.getElementById('addStationMenuItem');
if (menuItem) {
menuItem.addEventListener('click', contextAddStation);
}
});
</script>
</body>
</html>