live tracker

This commit is contained in:
2026-05-03 03:04:47 +03:00
parent 76b991a62b
commit 47381a9304
8 changed files with 869 additions and 6 deletions

455
templates/tracker.html Normal file
View File

@@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Tracker — Grepolis Remote</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f1a;
--surface: #181824;
--border: #2a2a40;
--text: #e0e0e0;
--muted: #666;
--gold: #c8a44a;
--red: #e05555;
--green: #4acc64;
--blue: #6fcfcf;
--yellow: #f0c040;
--purple: #a07adf;
}
body {
min-height: 100vh;
background: var(--bg);
font-family: 'Inter', 'Segoe UI', sans-serif;
color: var(--text);
padding: 1.5rem 2rem;
}
/* ---- Header ---- */
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.page-header h1 {
font-size: 1.7rem;
font-weight: 800;
background: linear-gradient(135deg, #c8a44a, #f0c96e, #c8a44a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
flex: 1;
}
.back-link {
color: var(--muted);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.back-link:hover { color: var(--text); }
/* ---- Connection status pill ---- */
#conn-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid;
transition: all 0.3s;
}
#conn-status.live { background: rgba(74,204,100,0.12); border-color: rgba(74,204,100,0.4); color: var(--green); }
#conn-status.wait { background: rgba(240,192,64,0.12); border-color: rgba(240,192,64,0.4); color: var(--yellow); }
#conn-status.error { background: rgba(224,85,85,0.12); border-color: rgba(224,85,85,0.4); color: var(--red); }
.status-dot {
width: 7px; height: 7px; border-radius: 50%;
background: currentColor;
animation: pulse 1.5s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* ---- Summary badges ---- */
.summary-bar {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.summary-badge {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px 16px;
font-size: 0.85rem;
font-weight: 600;
min-width: 130px;
}
.summary-badge .badge-icon { font-size: 1.2rem; }
.summary-badge .badge-count { font-size: 1.5rem; font-weight: 800; margin-left: auto; }
.badge-incoming { border-color: rgba(224,85,85,0.4); }
.badge-incoming .badge-count { color: var(--red); }
.badge-outgoing { border-color: rgba(74,204,100,0.4); }
.badge-outgoing .badge-count { color: var(--green); }
.badge-support { border-color: rgba(111,207,207,0.4);}
.badge-support .badge-count { color: var(--blue); }
.badge-other { border-color: rgba(160,122,223,0.4);}
.badge-other .badge-count { color: var(--purple); }
/* ---- Section card ---- */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
margin-bottom: 1.5rem;
overflow: hidden;
transition: border-color 0.3s;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: rgba(255,255,255,0.03);
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
font-weight: 700;
}
.section.incoming { border-color: rgba(224,85,85,0.35); }
.section.incoming .section-header { color: var(--red); }
.section.outgoing { border-color: rgba(74,204,100,0.35); }
.section.outgoing .section-header { color: var(--green); }
.section.support { border-color: rgba(111,207,207,0.35); }
.section.support .section-header { color: var(--blue); }
.section.other { border-color: rgba(160,122,223,0.35); }
.section.other .section-header { color: var(--purple); }
/* ---- Table ---- */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th {
text-align: left;
padding: 10px 20px;
color: var(--muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
}
td {
padding: 11px 20px;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.025); }
.type-chip {
display: inline-block;
padding: 2px 9px;
border-radius: 8px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chip-attack_land { background: rgba(224,85,85,0.2); color: var(--red); border: 1px solid rgba(224,85,85,0.4); }
.chip-attack_sea { background: rgba(224,85,85,0.2); color: #f08080; border: 1px solid rgba(224,85,85,0.4); }
.chip-own_attack_land,
.chip-own_attack_sea { background: rgba(74,204,100,0.15); color: var(--green); border: 1px solid rgba(74,204,100,0.4); }
.chip-support { background: rgba(111,207,207,0.15);color: var(--blue); border: 1px solid rgba(111,207,207,0.4); }
.chip-own_support { background: rgba(111,207,207,0.15);color: #a0e8e8; border: 1px solid rgba(111,207,207,0.4); }
.chip-farming { background: rgba(160,122,223,0.15);color: var(--purple); border: 1px solid rgba(160,122,223,0.4); }
.chip-espionage { background: rgba(240,192,64,0.15); color: var(--yellow); border: 1px solid rgba(240,192,64,0.4); }
.chip-unknown { background: rgba(255,255,255,0.08);color: var(--muted); border: 1px solid var(--border); }
.countdown {
font-family: 'Courier New', monospace;
font-weight: 700;
font-size: 0.9rem;
}
.countdown.urgent { color: var(--red); animation: pulse 1s infinite; }
.countdown.soon { color: var(--yellow); }
.countdown.ok { color: var(--green); }
.empty-state {
text-align: center;
padding: 2rem;
color: var(--muted);
font-size: 0.9rem;
}
.empty-state span { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
/* ---- Attack flash on new incoming ---- */
@keyframes flash-row {
0% { background: rgba(224,85,85,0.25); }
100% { background: transparent; }
}
.flash { animation: flash-row 1.5s ease-out; }
/* ---- Last updated ---- */
#last-updated {
text-align: right;
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="page-header">
<a href="/player/{{ player_id }}/{{ world_id }}/hub" class="back-link">← Πίσω στο Hub</a>
<h1>🛡️ Live Tracker — {{ world_id }}</h1>
<div id="conn-status" class="wait">
<span class="status-dot"></span>
<span id="conn-label">Σύνδεση...</span>
</div>
</div>
<div class="summary-bar">
<div class="summary-badge badge-incoming">
<span class="badge-icon">⚔️</span> Εισερχόμενες
<span class="badge-count" id="count-incoming">0</span>
</div>
<div class="summary-badge badge-outgoing">
<span class="badge-icon">🏹</span> Δικές μου
<span class="badge-count" id="count-outgoing">0</span>
</div>
<div class="summary-badge badge-support">
<span class="badge-icon">🛡️</span> Ενισχύσεις
<span class="badge-count" id="count-support">0</span>
</div>
<div class="summary-badge badge-other">
<span class="badge-icon">🔮</span> Άλλο
<span class="badge-count" id="count-other">0</span>
</div>
</div>
<div id="last-updated"></div>
<!-- Incoming attacks -->
<div class="section incoming" id="sec-incoming">
<div class="section-header">⚔️ Εισερχόμενες Επιθέσεις</div>
<div id="tbl-incoming"><div class="empty-state"><span></span>Δεν υπάρχουν εισερχόμενες επιθέσεις</div></div>
</div>
<!-- Own attacks -->
<div class="section outgoing" id="sec-outgoing">
<div class="section-header">🏹 Δικές μου Επιθέσεις</div>
<div id="tbl-outgoing"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενεργές επιθέσεις</div></div>
</div>
<!-- Support -->
<div class="section support" id="sec-support">
<div class="section-header">🛡️ Ενισχύσεις</div>
<div id="tbl-support"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενισχύσεις</div></div>
</div>
<!-- Other (farming, espionage, colonization) -->
<div class="section other" id="sec-other">
<div class="section-header">🔮 Άλλες Κινήσεις</div>
<div id="tbl-other"><div class="empty-state"><span>💤</span>Δεν υπάρχουν άλλες κινήσεις</div></div>
</div>
<script>
(function() {
const PLAYER_ID = '{{ player_id }}';
const WORLD_ID = '{{ world_id }}';
const BASE = '';
// ---- Classify movement type into a display group ----
function classify(type) {
const t = (type || '').toLowerCase();
if (t === 'attack_land' || t === 'attack_sea') return 'incoming';
if (t === 'own_attack_land' || t === 'own_attack_sea') return 'outgoing';
if (t === 'support' || t === 'own_support') return 'support';
return 'other';
}
// ---- Format arrival_at (unix seconds) as HH:MM:SS countdown ----
function formatCountdown(arrivalAt) {
if (!arrivalAt) return { text: '', cls: 'ok' };
const secsLeft = Math.floor(arrivalAt - Date.now() / 1000);
if (secsLeft <= 0) return { text: 'Έφτασε', cls: 'ok' };
const h = Math.floor(secsLeft / 3600);
const m = Math.floor((secsLeft % 3600) / 60);
const s = secsLeft % 60;
const text = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
const cls = secsLeft < 120 ? 'urgent' : secsLeft < 600 ? 'soon' : 'ok';
return { text, cls };
}
// ---- Friendly label for type chip ----
function typeLabel(type) {
const map = {
'attack_land': '⚔️ Χερσαία',
'attack_sea': '⚓ Ναυτική',
'own_attack_land': '🏹 Επίθεση',
'own_attack_sea': '⛵ Ναυτ. Επίθ.',
'support': '🛡️ Ενίσχυση',
'own_support': '🛡️ Ενίσχυσα',
'farming': '🌾 Λεηλασία',
'espionage': '🔍 Κατασκοπεία',
'colonization': '🏛️ Αποίκιση',
'unknown': '❓ Άγνωστο',
};
return map[type] || type;
}
// ---- Build a table from a list of movements ----
function buildTable(movements) {
if (!movements.length) return null;
let html = `<table>
<thead><tr>
<th>Τύπος</th>
<th>Από</th>
<th>Πόλη Αφετηρίας</th>
<th>Προς</th>
<th>Πόλη Στόχου</th>
<th>Άφιξη</th>
<th>Αντίστροφη Μέτρηση</th>
</tr></thead><tbody>`;
for (const m of movements) {
const cd = formatCountdown(m.arrival_at);
const dt = m.arrival_at
? new Date(m.arrival_at * 1000).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '';
html += `<tr data-cmd="${m.command_id}">
<td><span class="type-chip chip-${m.cmd_type}">${typeLabel(m.cmd_type)}</span></td>
<td>${m.origin_player || ''}</td>
<td>${m.origin_town || ''}</td>
<td>${m.target_player || ''}</td>
<td>${m.target_town || ''}</td>
<td>${dt}</td>
<td><span class="countdown ${cd.cls}" data-arrival="${m.arrival_at || 0}">${cd.text}</span></td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
// ---- Render all sections from the full movements array ----
let _prevIncomingIds = new Set();
function render(movements) {
const groups = { incoming: [], outgoing: [], support: [], other: [] };
for (const m of movements) {
const g = classify(m.cmd_type);
groups[g].push(m);
}
// Sort each group by arrival_at
for (const g of Object.values(groups)) {
g.sort((a, b) => (a.arrival_at || 0) - (b.arrival_at || 0));
}
// Update summary badges
document.getElementById('count-incoming').textContent = groups.incoming.length;
document.getElementById('count-outgoing').textContent = groups.outgoing.length;
document.getElementById('count-support').textContent = groups.support.length;
document.getElementById('count-other').textContent = groups.other.length;
// Render each table
const sections = ['incoming', 'outgoing', 'support', 'other'];
const emptyMsgs = {
incoming: { icon: '✅', msg: 'Δεν υπάρχουν εισερχόμενες επιθέσεις' },
outgoing: { icon: '💤', msg: 'Δεν υπάρχουν ενεργές επιθέσεις' },
support: { icon: '💤', msg: 'Δεν υπάρχουν ενισχύσεις' },
other: { icon: '💤', msg: 'Δεν υπάρχουν άλλες κινήσεις' },
};
for (const g of sections) {
const container = document.getElementById(`tbl-${g}`);
const tbl = buildTable(groups[g]);
container.innerHTML = tbl || `<div class="empty-state"><span>${emptyMsgs[g].icon}</span>${emptyMsgs[g].msg}</div>`;
}
// Flash rows that are new incoming attacks
const newIncomingIds = new Set(groups.incoming.map(m => m.command_id));
for (const id of newIncomingIds) {
if (!_prevIncomingIds.has(id)) {
const row = document.querySelector(`tr[data-cmd="${id}"]`);
if (row) { row.classList.remove('flash'); void row.offsetWidth; row.classList.add('flash'); }
}
}
_prevIncomingIds = newIncomingIds;
// Update last-updated timestamp
document.getElementById('last-updated').textContent =
`Τελευταία ενημέρωση: ${new Date().toLocaleTimeString('el-GR')}`;
}
// ---- Countdown ticker — updates every second client-side ----
function tickCountdowns() {
document.querySelectorAll('.countdown[data-arrival]').forEach(el => {
const arrival = parseInt(el.dataset.arrival, 10);
if (!arrival) return;
const cd = formatCountdown(arrival);
el.textContent = cd.text;
el.className = `countdown ${cd.cls}`;
});
}
setInterval(tickCountdowns, 1000);
// ---- Connection status helpers ----
function setStatus(state, label) {
const el = document.getElementById('conn-status');
el.className = `${state}`;
document.getElementById('conn-label').textContent = label;
// Re-add the inner dot
if (!el.querySelector('.status-dot')) {
el.insertAdjacentHTML('afterbegin', '<span class="status-dot"></span>');
}
}
// ---- 1. Initial load ----
fetch(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}`)
.then(r => r.json())
.then(d => { if (d.movements) render(d.movements); })
.catch(() => {});
// ---- 2. SSE stream for real-time updates ----
function connectSSE() {
setStatus('wait', 'Σύνδεση...');
const es = new EventSource(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}/stream`);
es.onopen = () => setStatus('live', 'Live ●');
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.movements) render(data.movements);
} catch(e) {}
};
es.onerror = () => {
setStatus('error', 'Αποσυνδέθηκε — επανασύνδεση...');
es.close();
// Auto-reconnect after 5 seconds
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
</script>
</body>
</html>