456 lines
16 KiB
HTML
456 lines
16 KiB
HTML
<!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 }}" 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>
|