375 lines
13 KiB
HTML
375 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>CoolingStations.org</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;
|
|
overflow: hidden; /* Prevent scrolling on mobile */
|
|
}
|
|
|
|
.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%;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
.header h1, #cityName {
|
|
width: 49%;
|
|
}
|
|
|
|
.legend {
|
|
bottom: 0.5rem;
|
|
left: 0.5rem;
|
|
right: 0.5rem;
|
|
padding: 0.8rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>💧 <span id="cityName">Loading...</span></h1>
|
|
</div>
|
|
|
|
<div id="authButtons">
|
|
<a href="/login" class="auth-button" id="loginBtn">Login</a>
|
|
<a href="/city-select" class="auth-button" style="top: 1rem; right: 6rem;" id="changeCityBtn">All Cities</a>
|
|
</div>
|
|
|
|
<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 = [];
|
|
let currentCity = null;
|
|
let user = null;
|
|
|
|
async function checkAuth() {
|
|
try {
|
|
const response = await fetch('/api/user');
|
|
const data = await response.json();
|
|
user = data.user;
|
|
updateAuthButtons();
|
|
} catch (error) {
|
|
console.error('Auth check failed:', error);
|
|
updateAuthButtons();
|
|
}
|
|
}
|
|
|
|
function updateAuthButtons() {
|
|
const loginBtn = document.getElementById('loginBtn');
|
|
const changeCityBtn = document.getElementById('changeCityBtn');
|
|
|
|
if (user) {
|
|
loginBtn.textContent = 'Dashboard';
|
|
loginBtn.href = `/city/${currentCity}/dashboard`;
|
|
changeCityBtn.textContent = 'Change City';
|
|
changeCityBtn.href = '/city-select';
|
|
} else {
|
|
loginBtn.textContent = 'Login';
|
|
loginBtn.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
|
|
changeCityBtn.textContent = 'All Cities';
|
|
changeCityBtn.href = '/city-select';
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
checkAuth();
|
|
loadStations();
|
|
}
|
|
|
|
function loadStations() {
|
|
fetch(`/api/cities/${currentCity}/stations`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
stations = data;
|
|
if (data.length > 0) {
|
|
document.getElementById('cityName').textContent = data[0].city_name;
|
|
}
|
|
displayStations();
|
|
fitMapToStations();
|
|
updateAuthButtons();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading stations:', error);
|
|
document.getElementById('cityName').textContent = 'City Not Found';
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Helper function to format dates in a human-readable way
|
|
function formatTimeAgo(dateString) {
|
|
if (!dateString) return 'Never';
|
|
|
|
// Parse UTC timestamp correctly
|
|
const date = new Date(dateString + (dateString.includes('Z') ? '' : 'Z'));
|
|
const now = new Date();
|
|
const diffInSeconds = Math.floor((now - date) / 1000);
|
|
|
|
if (diffInSeconds < 60) {
|
|
return 'Just now';
|
|
} else if (diffInSeconds < 3600) {
|
|
const minutes = Math.round(diffInSeconds / 60);
|
|
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
} else if (diffInSeconds < 86400) {
|
|
const hours = Math.round(diffInSeconds / 3600);
|
|
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
} else if (diffInSeconds < 604800) {
|
|
const days = Math.round(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';
|
|
|
|
// Parse UTC timestamp correctly
|
|
const date = new Date(dateString + (dateString.includes('Z') ? '' : 'Z'));
|
|
const now = new Date();
|
|
const diffInSeconds = Math.floor((date - now) / 1000);
|
|
|
|
if (diffInSeconds <= 0) {
|
|
return 'Already empty';
|
|
} else if (diffInSeconds < 3600) {
|
|
const minutes = Math.round(diffInSeconds / 60);
|
|
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
|
} else if (diffInSeconds < 86400) {
|
|
const hours = Math.round(diffInSeconds / 3600);
|
|
return `${hours} hour${hours !== 1 ? 's' : ''}`;
|
|
} else {
|
|
const days = Math.round(diffInSeconds / 86400);
|
|
return `${days} day${days !== 1 ? 's' : ''}`;
|
|
}
|
|
}
|
|
|
|
function createPopupContent(station) {
|
|
const refillTime = formatTimeAgo(station.last_refill_time);
|
|
const estimatedEmpty = formatTimeUntil(station.estimated_empty_time);
|
|
|
|
return `
|
|
<div class="station-popup">
|
|
<h3>${station.name}</h3>
|
|
<p style="color: #666; font-size: 0.9rem; margin-bottom: 10px;">${station.description || 'No description'}</p>
|
|
<div class="station-info">
|
|
<p><strong>Status:</strong> ${station.latest_description || 'No status update'}</p>
|
|
<p><strong>Status As Of:</strong> ${refillTime}</p>
|
|
<p><strong>Estimated Empty:</strong> ${estimatedEmpty}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function fitMapToStations() {
|
|
if (stations.length === 0) return;
|
|
|
|
const bounds = L.latLngBounds();
|
|
stations.forEach(station => {
|
|
bounds.extend([station.latitude, station.longitude]);
|
|
});
|
|
|
|
// Add more padding and max zoom to ensure all stations are visible
|
|
map.fitBounds(bounds, {
|
|
padding: [50, 50],
|
|
maxZoom: 15 // Zoom out one more level by default
|
|
});
|
|
}
|
|
|
|
// Initialize map when page loads
|
|
document.addEventListener('DOMContentLoaded', initMap);
|
|
</script>
|
|
</body>
|
|
</html> |