SwapStation_WebApp/frontend/station_selection.html

369 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!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>
<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://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="mt-1 text-xs text-gray-400 station-location">Location</p>
</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>
<!-- 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>
<button
class="open-btn mt-4 w-full rounded-xl bg-gradient-to-r from-emerald-500 via-teal-500 to-cyan-500 px-3 py-2 text-xs font-semibold text-white transition group-hover:brightness-110 group-hover:-translate-y-px"> Open
</button>
</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="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://localhost: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(),
name: stationName.value.trim(),
location: stationLocation.value.trim(),
mqtt_broker: mqttBroker.value.trim(),
mqtt_port: Number(mqttPort.value),
mqtt_username: 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}`;
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}`;
});
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);
}
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>