445 lines
22 KiB
HTML
445 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Swap Station – Select a Station</title>
|
||
|
||
<script src="js/auth-guard.js"></script>
|
||
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
|
||
<script src="https://unpkg.com/lucide@latest" defer></script>
|
||
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
theme: {
|
||
extend: {
|
||
fontFamily: { sans: ["Inter", "ui-sans-serif", "system-ui"] },
|
||
keyframes: {
|
||
pulseDot: { '0%,100%': { transform:'scale(1)', opacity: 1 }, '50%': { transform:'scale(1.25)', opacity: .65 } },
|
||
fadeUp: { '0%': { opacity:0, transform:'translateY(8px)' }, '100%': { opacity:1, transform:'translateY(0)' } },
|
||
},
|
||
animation: { pulseDot: 'pulseDot 1.2s ease-in-out infinite', fadeUp: 'fadeUp .25s ease-out both' }
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
html, body { height: 100%; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen bg-[#0a0a0a] text-gray-100">
|
||
<div class="pointer-events-none fixed inset-0">
|
||
<div class="absolute -top-24 -left-24 w-[32rem] h-[32rem] rounded-full bg-emerald-500/10 blur-3xl"></div>
|
||
<div class="absolute -bottom-24 -right-24 w-[36rem] h-[36rem] rounded-full bg-sky-500/10 blur-3xl"></div>
|
||
</div>
|
||
|
||
<!-- Header with Logout and Add User -->
|
||
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
|
||
<div class="mx-auto max-w-7xl px-4 py-4 grid grid-cols-3 items-center gap-3">
|
||
|
||
<div>
|
||
<h1 class="text-xl md:text-2xl font-extrabold tracking-tight">Select a Station</h1>
|
||
</div>
|
||
|
||
<div class="flex justify-center">
|
||
<img src="./assets/vec_logo.png" alt="VECMOCON"
|
||
class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-end gap-4">
|
||
<div class="flex items-center gap-4 text-xs text-gray-400">
|
||
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-emerald-400"></span> Online</span>
|
||
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline</span>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<button id="addUserBtn" class="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold hover:border-emerald-400/30">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 20.25a7.5 7.5 0 0115 0"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v6m3-3H9"/></svg>
|
||
Add User
|
||
</button>
|
||
<button id="logoutBtn" class="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold hover:border-rose-400/30">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9l3 3m0 0l-3 3m3-3H3"/></svg>
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</header>
|
||
|
||
<main class="relative z-10 mx-auto max-w-7xl px-4 py-6">
|
||
<div class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<div class="md:col-span-2">
|
||
<input id="search" type="text" placeholder="Search by name, ID or location"
|
||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-gray-500 outline-none focus:ring-2 focus:ring-emerald-500/60" />
|
||
</div>
|
||
<!-- THEMED STATUS DROPDOWN -->
|
||
<div class="relative" id="statusFilterWrap">
|
||
<button id="statusBtn"
|
||
class="w-full flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm outline-none
|
||
focus:ring-2 focus:ring-emerald-500/60 hover:border-emerald-400/30 transition">
|
||
<span id="statusLabel">All statuses</span>
|
||
<svg class="h-4 w-4 opacity-80" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11l3.71-3.77a.75.75 0 011.08 1.04l-4.25 4.33a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"/></svg>
|
||
</button>
|
||
|
||
<div id="statusMenu"
|
||
class="hidden absolute z-20 mt-2 w-full rounded-xl border border-white/10 bg-black/85 backdrop-blur-xl shadow-2xl overflow-hidden">
|
||
<button data-value="all" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">All statuses</button>
|
||
<button data-value="online" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">Online</button>
|
||
<button data-value="offline" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">Offline</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Grid -->
|
||
<div id="stations-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start"></div>
|
||
<div id="empty-state" class="hidden mt-12 text-center text-gray-400">No stations match your filters.</div>
|
||
<div id="error-state" class="hidden mt-12 text-center text-rose-300"></div>
|
||
</main>
|
||
|
||
<!-- Station Card Template (with metrics) -->
|
||
<template id="station-card-template">
|
||
<div class="group rounded-2xl border border-white/10 bg-white/5 p-4 transition animate-fadeUp hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]">
|
||
<div class="flex items-start justify-between">
|
||
<div class="min-w-0">
|
||
<div class="flex items-center gap-2">
|
||
<span class="status-dot h-2.5 w-2.5 rounded-full"></span>
|
||
<p class="truncate text-sm text-gray-400"><span class="font-semibold text-gray-200 station-name">Station</span></p>
|
||
</div>
|
||
<!-- <p class="product-id mt-1 text-xs text-gray-400" title="Product ID">-</p> -->
|
||
<div class="product-id-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
|
||
<i data-lucide="hash" class="h-3 w-3 text-gray-500"></i>
|
||
<span class="product-id">—</span>
|
||
</div>
|
||
<!-- <p class="mt-1 text-xs text-gray-400 station-location">Location</p> -->
|
||
<div class="location-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
|
||
<i data-lucide="map-pin" class="h-3 w-3 text-gray-500"></i>
|
||
<span class="station-location">Location</span>
|
||
</div>
|
||
</div>
|
||
<span class="status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"></span>
|
||
</div>
|
||
|
||
<!-- ID + status row -->
|
||
<div class="mt-4 grid grid-cols-1 gap-2 text-xs">
|
||
<div class="rounded-lg border border-white/10 bg-black/20 p-2 min-w-0">
|
||
<p class="text-[10px] text-gray-400">Station ID</p>
|
||
<p class="station-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Station ID">—</p>
|
||
</div>
|
||
<!-- <div class="rounded-lg border border-white/10 bg-black/20 p-2 min-w-0">
|
||
<p class="text-[10px] text-gray-400">Product ID</p>
|
||
<p class="product-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Product ID">—</p>
|
||
</div> -->
|
||
</div>
|
||
|
||
<!-- Metrics Row -->
|
||
<div class="mt-3 grid grid-cols-3 gap-2">
|
||
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
|
||
<p class="text-[10px] text-gray-400">Total Starts</p>
|
||
<p class="metric-starts text-sm font-semibold">0</p>
|
||
</div>
|
||
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
|
||
<p class="text-[10px] text-gray-400">Completed</p>
|
||
<p class="metric-success text-sm font-semibold">0</p>
|
||
</div>
|
||
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
|
||
<p class="text-[10px] text-gray-400">Aborted</p>
|
||
<p class="metric-aborted text-sm font-semibold">0</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 flex items-center gap-2">
|
||
<button class="open-btn w-full flex-grow rounded-lg bg-gradient-to-r from-emerald-500 to-teal-500 px-3 py-2 text-xs font-semibold text-white transition hover:brightness-110">
|
||
Open
|
||
</button>
|
||
|
||
<button class="remove-btn flex-shrink-0 rounded-lg border border-white/10 bg-white/5 p-2 text-rose-400 transition hover:border-rose-400/60 hover:bg-rose-500/10">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2">
|
||
<path d="M3 6h18"/>
|
||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||
<line x1="10" x2="10" y1="11" y2="17"/>
|
||
<line x1="14" x2="14" y1="11" y2="17"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Add Station Card Template (goes LAST) -->
|
||
<template id="add-station-card-template">
|
||
<div class="flex h-full min-h-[160px] w-full items-center justify-center rounded-2xl border-2 border-dashed border-emerald-400/40 bg-emerald-500/5 p-6 text-emerald-300 hover:border-emerald-300 hover:text-emerald-200 cursor-pointer transition animate-fadeUp">
|
||
<div class="flex flex-col items-center gap-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
||
<span class="font-semibold">Add Station</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- User Modal -->
|
||
<div id="userModal" class="fixed inset-0 z-50 hidden bg-black/95">
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="w-full max-w-md rounded-2xl bg-white/10 p-6">
|
||
<h2 class="text-lg font-bold mb-4">Add User</h2>
|
||
<form id="userForm" class="space-y-4">
|
||
<input type="text" id="newUsername" placeholder="Username" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<input type="password" id="newPassword" placeholder="Password" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="isAdmin" class="h-4 w-4"> Is Admin</label>
|
||
<div class="flex justify-end gap-2">
|
||
<button type="button" id="cancelUserBtn" class="px-4 py-2 rounded bg-gray-600/40">Cancel</button>
|
||
<button type="submit" class="px-4 py-2 rounded bg-emerald-600">Save</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Station Modal -->
|
||
<div id="stationModal" class="fixed inset-0 z-50 hidden bg-black/95">
|
||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||
<div class="w-full max-w-lg rounded-2xl bg-white/10 p-6">
|
||
<h2 class="text-lg font-bold mb-4">Add Station</h2>
|
||
<form id="stationForm" class="space-y-3">
|
||
<input type="text" placeholder="Station ID" id="stationId" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<input type="text" placeholder="Product ID" id="stationProductId" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<input type="text" placeholder="Name" id="stationName" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<input type="text" placeholder="Location" id="stationLocation" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<input type="text" placeholder="MQTT Broker" id="mqttBroker" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<input type="number" placeholder="MQTT Port" id="mqttPort" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||
<input type="text" placeholder="MQTT Username" id="mqttUsername" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2">
|
||
<input type="password" placeholder="MQTT Password" id="mqttPassword" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2">
|
||
<div class="flex justify-end gap-2">
|
||
<button type="button" id="cancelStationBtn" class="px-4 py-2 rounded bg-gray-600/40">Cancel</button>
|
||
<button type="submit" class="px-4 py-2 rounded bg-emerald-600">Save</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API_BASE = 'http://192.168.1.12:5000/api';
|
||
|
||
const grid = document.getElementById('stations-grid');
|
||
const addStationCardTmpl = document.getElementById('add-station-card-template');
|
||
const stationCardTmpl = document.getElementById('station-card-template');
|
||
|
||
const searchEl = document.getElementById('search');
|
||
const emptyState = document.getElementById('empty-state');
|
||
const errorState = document.getElementById('error-state');
|
||
|
||
// THEMED STATUS DROPDOWN LOGIC
|
||
const statusBtn = document.getElementById('statusBtn');
|
||
const statusMenu = document.getElementById('statusMenu');
|
||
const statusLabel = document.getElementById('statusLabel');
|
||
let statusValue = 'all';
|
||
|
||
// Modals
|
||
const userModal = document.getElementById('userModal');
|
||
const stationModal = document.getElementById('stationModal');
|
||
|
||
const openModal = (el) => { el.classList.remove('hidden'); el.classList.add('block'); };
|
||
const closeModal = (el) => { el.classList.add('hidden'); el.classList.remove('block'); };
|
||
|
||
// Header buttons
|
||
document.getElementById('addUserBtn').onclick = () => openModal(userModal);
|
||
document.getElementById('cancelUserBtn').onclick = () => closeModal(userModal);
|
||
document.getElementById('logoutBtn').onclick = () => { localStorage.clear(); window.location.href = './index.html'; };
|
||
document.getElementById('cancelStationBtn').onclick = () => closeModal(stationModal);
|
||
|
||
// Forms
|
||
document.getElementById('userForm').onsubmit = async (e)=>{
|
||
e.preventDefault();
|
||
const payload = { username: newUsername.value.trim(), password: newPassword.value, is_admin: isAdmin.checked };
|
||
try {
|
||
const res = await fetch(`${API_BASE}/users`, {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload),
|
||
credentials: 'include'
|
||
});
|
||
if(!res.ok) throw new Error('Failed to add user');
|
||
closeModal(userModal); alert('User added');
|
||
} catch(err){ alert(err.message); }
|
||
}
|
||
|
||
document.getElementById('stationForm').onsubmit = async (e)=>{
|
||
e.preventDefault();
|
||
const payload = {
|
||
station_id: stationId.value.trim(),
|
||
product_id: stationProductId.value.trim(),
|
||
name: stationName.value.trim(),
|
||
location: stationLocation.value.trim(),
|
||
mqtt_broker: mqttBroker.value.trim(),
|
||
mqtt_port: Number(mqttPort.value),
|
||
mqtt_user: mqttUsername.value || null,
|
||
mqtt_password: mqttPassword.value || null,
|
||
};
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/stations`, {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload),
|
||
credentials: 'include'
|
||
});
|
||
if(!res.ok) throw new Error('Failed to add station');
|
||
closeModal(stationModal); await loadStations();
|
||
} catch(err){ alert(err.message); }
|
||
}
|
||
|
||
function statusStyles(status){
|
||
const online = { dot:'bg-emerald-400 animate-pulseDot', badge:'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', text:'Online' };
|
||
const offline = { dot:'bg-rose-500', badge:'bg-rose-500/15 text-rose-300 border border-rose-400/20', text:'Offline' };
|
||
return String(status).toLowerCase()==='online'?online:offline;
|
||
}
|
||
|
||
function setStatus(val, label) {
|
||
statusValue = val;
|
||
statusLabel.textContent = label;
|
||
statusMenu.classList.add('hidden');
|
||
applyFilters(); // reuse your existing function
|
||
}
|
||
|
||
let allStations = [];
|
||
|
||
function render(stations){
|
||
grid.innerHTML = '';
|
||
|
||
if(!stations || stations.length===0){
|
||
emptyState.classList.remove('hidden');
|
||
} else {
|
||
emptyState.classList.add('hidden');
|
||
for(const s of stations){
|
||
const node = stationCardTmpl.content.cloneNode(true);
|
||
const card = node.querySelector('div');
|
||
card.querySelector('.station-name').textContent = s.name ?? `Station ${s.id || s.station_id}`;
|
||
// const productIdVal = s.product_id || '—';
|
||
// const productIdEl = card.querySelector('.product-id');
|
||
// if (productIdEl) {
|
||
// // Use .innerHTML and add a styled <span> for the title
|
||
// productIdEl.innerHTML = `<span class="font-semibold text-white-500">Product ID: </span>${productIdVal}`;
|
||
// }
|
||
const productIdVal = s.product_id || '—';
|
||
const productIdEl = card.querySelector('.product-id');
|
||
if (productIdEl) {
|
||
productIdEl.textContent = productIdVal;
|
||
}
|
||
card.querySelector('.station-location').textContent = s.location ?? '—';
|
||
const idVal = s.id || s.station_id || '—';
|
||
const idEl = card.querySelector('.station-id');
|
||
idEl.textContent = idVal; idEl.setAttribute('title', idVal);
|
||
|
||
const styles = statusStyles(s.status);
|
||
const dot = card.querySelector('.status-dot');
|
||
dot.className = `status-dot h-2.5 w-2.5 rounded-full ${styles.dot}`;
|
||
const badge = card.querySelector('.status-badge');
|
||
badge.className = `status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${styles.badge}`;
|
||
badge.textContent = styles.text;
|
||
|
||
// Metrics
|
||
const starts = s.total_swaps_started ?? s.metrics?.total_starts ?? 0;
|
||
const success = s.total_swaps_success ?? s.metrics?.total_completed ?? 0;
|
||
const aborted = s.total_swaps_aborted ?? s.metrics?.total_aborted ?? 0;
|
||
card.querySelector('.metric-starts').textContent = starts;
|
||
card.querySelector('.metric-success').textContent = success;
|
||
card.querySelector('.metric-aborted').textContent = aborted;
|
||
|
||
// Open
|
||
card.querySelector('.open-btn').addEventListener('click', () => {
|
||
localStorage.setItem('selected_station', JSON.stringify(s));
|
||
const id = encodeURIComponent(s.id || s.station_id);
|
||
window.location.href = `./dashboard.html?stationId=${id}`;
|
||
});
|
||
// --- ADD THIS NEW BLOCK FOR THE REMOVE BUTTON ---
|
||
card.querySelector('.remove-btn').addEventListener('click', async () => {
|
||
const stationId = s.id || s.station_id;
|
||
const stationName = s.name;
|
||
|
||
// 1. Confirm with the user
|
||
if (!confirm(`Are you sure you want to permanently remove "${stationName}"?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 2. Call the DELETE API endpoint
|
||
const response = await fetch(`${API_BASE}/stations/${stationId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
if (response.ok) {
|
||
alert(`Station "${stationName}" removed successfully.`);
|
||
// 3. Refresh the entire list from the server
|
||
loadStations();
|
||
} else {
|
||
const error = await response.json();
|
||
alert(`Failed to remove station: ${error.message}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error removing station:', error);
|
||
alert('An error occurred while trying to remove the station.');
|
||
}
|
||
});
|
||
|
||
grid.appendChild(node);
|
||
}
|
||
}
|
||
|
||
// Finally, append the Add Station card LAST
|
||
const addNode = addStationCardTmpl.content.cloneNode(true);
|
||
const addCard = addNode.querySelector('div');
|
||
addCard.addEventListener('click', () => openModal(stationModal));
|
||
grid.appendChild(addNode);
|
||
|
||
if (window.lucide) {
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
statusBtn.addEventListener('click', () => {
|
||
statusMenu.classList.toggle('hidden');
|
||
});
|
||
statusMenu.querySelectorAll('button').forEach(b=>{
|
||
b.addEventListener('click', () => setStatus(b.dataset.value, b.textContent.trim()));
|
||
});
|
||
|
||
function applyFilters(){
|
||
const q = (searchEl.value||'').trim().toLowerCase();
|
||
const status = statusValue; // 'all' | 'online' | 'offline'
|
||
const filtered = allStations.filter(s=>{
|
||
const matchesQ = !q || [s.name, s.id, s.station_id, s.location].filter(Boolean).some(v=>String(v).toLowerCase().includes(q));
|
||
const matchesStatus = status==='all' || String(s.status).toLowerCase()===status;
|
||
return matchesQ && matchesStatus;
|
||
});
|
||
render(filtered);
|
||
}
|
||
|
||
searchEl.addEventListener('input', ()=> setTimeout(applyFilters,150));
|
||
|
||
async function loadStations(){
|
||
try{
|
||
const res = await fetch(`${API_BASE}/stations`);
|
||
const data = await res.json();
|
||
allStations = Array.isArray(data) ? data : (data.stations||[]);
|
||
applyFilters();
|
||
}catch(err){
|
||
errorState.textContent = 'Failed to load stations. Ensure API is running.';
|
||
errorState.classList.remove('hidden');
|
||
}
|
||
}
|
||
document.addEventListener('click', (e)=>{
|
||
if (!document.getElementById('statusFilterWrap').contains(e.target)) statusMenu.classList.add('hidden');
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', loadStations);
|
||
</script>
|
||
</body>
|
||
</html>
|