Phase 13: web dashboard at /_bridge/dashboard

Single-page club management UI served by the bridge. The Core URL is
injected server-side so client JS calls Core directly without routing
through the proxy. Vanilla HTML/CSS/JS, dark theme matching admin.html.

Sections: Club (name/manager/coins/stats edit), Collection (filterable
card grid with stat bars + chemistry/training pills), Packs (open with
modal, history), Objectives (progress bars + one-click claim), Division
(W/D/L record, season progress, rivals weekly claim), FUT Champions
(current session with in-browser match simulation, claim, history),
Notifications (badge count in nav).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:18:10 -07:00
parent b7da8b6e83
commit eae3e72fd9
4 changed files with 817 additions and 1 deletions
+750
View File
@@ -0,0 +1,750 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenFUT — Club Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; }
/* ── Header ── */
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
header h1 { font-size: 1.1rem; color: #f5a623; }
.club-summary { margin-left: auto; display: flex; gap: 24px; align-items: center; font-size: 0.9rem; }
.coins { color: #f5a623; font-weight: 700; font-size: 1.1rem; }
.manager { color: #8b949e; }
.badge { font-size: 0.75rem; background: #238636; color: #fff; padding: 2px 8px; border-radius: 12px; }
/* ── Nav tabs ── */
nav { background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; gap: 0; overflow-x: auto; }
nav button { background: none; border: none; color: #8b949e; padding: 12px 18px; cursor: pointer;
font-size: 0.9rem; border-bottom: 2px solid transparent; white-space: nowrap; }
nav button:hover { color: #c9d1d9; }
nav button.active { color: #f5a623; border-bottom-color: #f5a623; }
/* ── Main ── */
main { padding: 24px; max-width: 1280px; margin: 0 auto; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── Cards ── */
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; }
.player-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px;
position: relative; cursor: default; transition: border-color 0.15s; }
.player-card:hover { border-color: #f5a623; }
.card-ovr { font-size: 1.6rem; font-weight: 800; color: #f5a623; line-height: 1; }
.card-pos { font-size: 0.7rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; }
.card-name { font-size: 0.85rem; font-weight: 600; margin: 4px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-meta { font-size: 0.72rem; color: #8b949e; }
.card-rarity { position: absolute; top: 8px; right: 8px; font-size: 0.65rem;
padding: 1px 5px; border-radius: 10px; }
.rarity-gold, .rarity-raregold { background: #78350f; color: #fcd34d; }
.rarity-silver { background: #374151; color: #d1d5db; }
.rarity-bronze { background: #451a03; color: #fbbf24; }
.rarity-totw, .rarity-icon, .rarity-hero { background: #312e81; color: #a5b4fc; }
.card-upgrades { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
.upgrade-pill { font-size: 0.65rem; padding: 1px 6px; border-radius: 10px; }
.chem-pill { background: #1c4532; color: #6ee7b7; }
.train-pill { background: #1e3a5f; color: #93c5fd; }
.loan-pill { background: #451a03; color: #fbbf24; }
/* ── Stat bars ── */
.stat-bar-row { display: flex; gap: 6px; align-items: center; font-size: 0.7rem; margin: 2px 0; }
.stat-bar-label { width: 28px; color: #8b949e; text-align: right; }
.stat-bar-track { flex: 1; height: 4px; background: #21262d; border-radius: 2px; }
.stat-bar-fill { height: 100%; border-radius: 2px; background: #3fb950; }
/* ── Filters ── */
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.filters input, .filters select { background: #161b22; border: 1px solid #30363d;
color: #c9d1d9; padding: 6px 10px; border-radius: 6px; font-size: 0.85rem; }
.filters input:focus, .filters select:focus { outline: none; border-color: #f5a623; }
/* ── Packs ── */
.pack-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 16px; }
.pack-item { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
.pack-item h3 { font-size: 0.95rem; color: #f5a623; margin-bottom: 4px; }
.pack-item .pack-id { font-size: 0.7rem; color: #8b949e; word-break: break-all; margin-bottom: 12px; }
/* ── Objectives ── */
.obj-list { display: grid; gap: 10px; margin-top: 16px; }
.obj-item { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; }
.obj-item.completed { border-color: #238636; }
.obj-item.claimed { border-color: #30363d; opacity: 0.6; }
.obj-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.obj-title { font-size: 0.9rem; font-weight: 600; }
.obj-reward { font-size: 0.8rem; color: #f5a623; }
.progress-track { height: 6px; background: #21262d; border-radius: 3px; margin-top: 8px; }
.progress-fill { height: 100%; border-radius: 3px; background: #1f6feb; }
.progress-fill.done { background: #238636; }
.obj-meta { font-size: 0.75rem; color: #8b949e; margin-top: 4px;
display: flex; justify-content: space-between; }
/* ── Division ── */
.division-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 24px; max-width: 480px; }
.division-num { font-size: 3rem; font-weight: 800; color: #f5a623; line-height: 1; }
.division-label { color: #8b949e; font-size: 0.85rem; margin-bottom: 16px; }
.record-row { display: flex; gap: 24px; margin: 12px 0; }
.record-cell { text-align: center; }
.record-cell .val { font-size: 1.4rem; font-weight: 700; }
.record-cell.w .val { color: #3fb950; }
.record-cell.d .val { color: #8b949e; }
.record-cell.l .val { color: #f85149; }
.record-cell .lbl { font-size: 0.7rem; color: #8b949e; }
/* ── FUT Champs ── */
.champs-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 24px; max-width: 560px; }
.tier-badge { display: inline-block; padding: 4px 14px; border-radius: 20px; font-weight: 700; font-size: 0.85rem;
background: #312e81; color: #a5b4fc; margin: 8px 0 16px; }
.tier-Elite { background: #78350f; color: #fcd34d; }
.tier-Gold { background: #713f12; color: #fcd34d; }
.tier-Silver { background: #374151; color: #d1d5db; }
.tier-Bronze { background: #451a03; color: #d97706; }
/* ── Notifications ── */
.notif-list { display: grid; gap: 8px; margin-top: 16px; }
.notif-item { background: #161b22; border-left: 3px solid #f5a623; border-radius: 0 6px 6px 0; padding: 12px 16px; }
.notif-item.loan_expiring { border-color: #d97706; }
.notif-item.season_ending { border-color: #1f6feb; }
.notif-item.objective_complete { border-color: #238636; }
.notif-title { font-size: 0.9rem; font-weight: 600; }
.notif-msg { font-size: 0.8rem; color: #8b949e; margin-top: 2px; }
/* ── Buttons ── */
.btn { display: inline-block; padding: 7px 14px; border-radius: 6px; border: none;
cursor: pointer; font-size: 0.85rem; font-weight: 500; }
.btn-primary { background: #f5a623; color: #000; }
.btn-primary:hover { background: #d4851a; }
.btn-secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
.btn-danger { background: #b91c1c; color: #fff; }
.btn-danger:hover { background: #991b1b; }
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
/* ── Misc ── */
.empty { color: #8b949e; font-size: 0.9rem; padding: 24px 0; }
.loading { color: #8b949e; font-size: 0.9rem; }
.error-msg { color: #f85149; font-size: 0.85rem; padding: 8px; background: #2d1515; border-radius: 4px; }
.row-actions { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
color: #8b949e; border-bottom: 1px solid #30363d; padding-bottom: 8px; margin-bottom: 16px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media (max-width: 640px) { .two-col { grid-template-columns: 1fr; } }
.section { margin-bottom: 32px; }
.info-row { display: flex; gap: 8px; align-items: baseline; margin: 4px 0; font-size: 0.85rem; }
.info-row .lbl { color: #8b949e; width: 110px; flex-shrink: 0; }
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
.modal-backdrop.open { display: flex; }
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px;
max-width: 480px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal h3 { color: #f5a623; margin-bottom: 16px; }
.opened-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; margin-top: 12px; }
</style>
</head>
<body>
<header>
<h1>⚽ OpenFUT Dashboard</h1>
<span class="badge">Offline</span>
<div class="club-summary">
<span class="manager" id="hdr-manager"></span>
<span id="hdr-club"></span>
<span class="coins" id="hdr-coins">— coins</span>
</div>
</header>
<nav>
<button class="active" onclick="showTab('club')">Club</button>
<button onclick="showTab('collection')">Collection</button>
<button onclick="showTab('packs')">Packs</button>
<button onclick="showTab('objectives')">Objectives</button>
<button onclick="showTab('division')">Division</button>
<button onclick="showTab('champs')">FUT Champs</button>
<button onclick="showTab('notifications')">Notifications <span id="notif-badge"></span></button>
</nav>
<main>
<!-- ── Club tab ────────────────────────────────────────────────────────────── -->
<div id="tab-club" class="tab-panel active">
<div class="two-col">
<div class="section">
<h2>Club Info</h2>
<div id="club-info"><span class="loading">Loading…</span></div>
<div class="row-actions" style="margin-top:16px">
<input id="edit-name" placeholder="Club name" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem">
<input id="edit-manager" placeholder="Manager name" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem">
<button class="btn btn-primary btn-sm" onclick="saveClub()">Save</button>
</div>
</div>
<div class="section">
<h2>Statistics</h2>
<div id="stats-info"><span class="loading">Loading…</span></div>
</div>
</div>
</div>
<!-- ── Collection tab ─────────────────────────────────────────────────────── -->
<div id="tab-collection" class="tab-panel">
<div class="filters">
<input id="f-name" placeholder="Name…" oninput="filterCards()">
<select id="f-pos" onchange="filterCards()">
<option value="">All positions</option>
<option>GK</option><option>CB</option><option>RB</option><option>LB</option>
<option>CDM</option><option>CM</option><option>CAM</option>
<option>RM</option><option>LM</option><option>RW</option><option>LW</option>
<option>CF</option><option>ST</option>
</select>
<select id="f-rarity" onchange="filterCards()">
<option value="">All rarities</option>
<option value="gold">Gold</option><option value="raregold">Rare Gold</option>
<option value="silver">Silver</option><option value="bronze">Bronze</option>
<option value="totw">TOTW</option><option value="icon">Icon</option>
</select>
<input id="f-club" placeholder="Club…" oninput="filterCards()">
<input id="f-nation" placeholder="Nation…" oninput="filterCards()">
<select id="f-sort" onchange="filterCards()">
<option value="ovr">Sort: OVR ↓</option>
<option value="name">Sort: Name</option>
<option value="pos">Sort: Position</option>
</select>
</div>
<div id="collection-count" style="font-size:0.8rem;color:#8b949e;margin-bottom:8px"></div>
<div id="collection-grid" class="card-grid"><span class="loading">Loading…</span></div>
</div>
<!-- ── Packs tab ─────────────────────────────────────────────────────────── -->
<div id="tab-packs" class="tab-panel">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px">
<h2 style="border:none;margin:0">Unopened Packs</h2>
<button class="btn btn-secondary btn-sm" onclick="loadPacks()">Refresh</button>
</div>
<div id="packs-grid" class="pack-grid"><span class="loading">Loading…</span></div>
<div class="section" style="margin-top:32px">
<h2>Pack History</h2>
<div id="pack-history"><span class="loading">Loading…</span></div>
</div>
</div>
<!-- ── Objectives tab ────────────────────────────────────────────────────── -->
<div id="tab-objectives" class="tab-panel">
<div id="objectives-list" class="obj-list"><span class="loading">Loading…</span></div>
</div>
<!-- ── Division tab ──────────────────────────────────────────────────────── -->
<div id="tab-division" class="tab-panel">
<div class="two-col">
<div>
<h2>Division Rivals</h2>
<div id="division-card"><span class="loading">Loading…</span></div>
<div style="margin-top:16px">
<button class="btn btn-primary" onclick="claimRivalsReward()">Claim Weekly Reward</button>
<p style="font-size:0.75rem;color:#8b949e;margin-top:6px">Offline mode: claim whenever you're ready to end your rivals week.</p>
</div>
</div>
<div>
<h2>Season Progress</h2>
<div id="season-progress"><span class="loading">Loading…</span></div>
</div>
</div>
</div>
<!-- ── FUT Champions tab ─────────────────────────────────────────────────── -->
<div id="tab-champs" class="tab-panel">
<div class="two-col">
<div>
<h2>Current Session</h2>
<div id="champs-current"><span class="loading">Loading…</span></div>
</div>
<div>
<h2>Past Sessions</h2>
<div id="champs-history"><span class="loading">Loading…</span></div>
</div>
</div>
</div>
<!-- ── Notifications tab ─────────────────────────────────────────────────── -->
<div id="tab-notifications" class="tab-panel">
<div id="notif-list" class="notif-list"><span class="loading">Loading…</span></div>
</div>
</main>
<!-- Pack open result modal -->
<div class="modal-backdrop" id="pack-modal">
<div class="modal">
<h3>Pack Opened!</h3>
<div id="modal-cards" class="opened-cards"></div>
<div style="margin-top:16px;text-align:right">
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
<script>
const CORE = '{{CORE_URL}}';
let allCards = [];
// ── Utilities ────────────────────────────────────────────────────────────────
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(CORE + path, opts);
const j = await r.json().catch(() => ({ error: 'invalid JSON' }));
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
}
function el(id) { return document.getElementById(id); }
function showToast(msg, isError = false) {
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText = `position:fixed;bottom:24px;right:24px;padding:10px 18px;border-radius:6px;
background:${isError ? '#7f1d1d' : '#14532d'};color:#fff;font-size:0.85rem;z-index:200;
box-shadow:0 4px 12px rgba(0,0,0,0.4)`;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
function showTab(name) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
el('tab-' + name).classList.add('active');
event.target.classList.add('active');
({ club: loadClub, collection: loadCollection, packs: loadPacks,
objectives: loadObjectives, division: loadDivision,
champs: loadChamps, notifications: loadNotifications })[name]?.();
}
function rarityClass(r) {
return 'rarity-' + (r || 'bronze').toLowerCase().replace(' ', '');
}
function statBar(label, val) {
const pct = Math.min(100, val);
const color = val >= 80 ? '#3fb950' : val >= 65 ? '#d29922' : '#f85149';
return `<div class="stat-bar-row">
<span class="stat-bar-label">${label}</span>
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:${pct}%;background:${color}"></div></div>
<span style="font-size:0.7rem;width:22px;text-align:right">${val}</span>
</div>`;
}
function renderPlayerCard(item) {
const c = item.card;
const ovr = item.effective_overall ?? c.overall;
const pos = item.effective_position ?? c.position;
const trainBonus = item.training_bonus > 0 ? `+${item.training_bonus}` : '';
const chemStyle = item.chemistry_style && item.chemistry_style !== 'basic' ? item.chemistry_style : '';
const isLoan = item.is_loan ? `<span class="upgrade-pill loan-pill">LOAN${item.loan_matches_remaining != null ? ' ' + item.loan_matches_remaining : ''}</span>` : '';
const pills = [
chemStyle ? `<span class="upgrade-pill chem-pill">${chemStyle}</span>` : '',
trainBonus ? `<span class="upgrade-pill train-pill">OVR${trainBonus}</span>` : '',
isLoan,
].join('');
return `<div class="player-card" title="${c.club} · ${c.league} · ${c.nation}">
<span class="card-rarity ${rarityClass(c.rarity)}">${(c.rarity||'').toUpperCase()}</span>
<div class="card-ovr">${ovr}</div>
<div class="card-pos">${pos}</div>
<div class="card-name">${c.name}</div>
<div class="card-meta">${c.club}</div>
<div style="margin:6px 0 4px">
${statBar('PAC', c.pace)}${statBar('SHO', c.shooting)}${statBar('PAS', c.passing)}
${statBar('DRI', c.dribbling)}${statBar('DEF', c.defending)}${statBar('PHY', c.physical)}
</div>
${pills ? `<div class="card-upgrades">${pills}</div>` : ''}
</div>`;
}
// ── Club ─────────────────────────────────────────────────────────────────────
async function loadClub() {
try {
const club = await api('GET', '/club');
const profile = await api('GET', '/profile').catch(() => ({}));
const name = club.name || '—';
const mgr = club.manager_name || 'Player Manager';
el('hdr-club').textContent = name;
el('hdr-manager').textContent = mgr;
el('hdr-coins').textContent = (club.coins ?? 0).toLocaleString() + ' coins';
el('edit-name').value = name;
el('edit-manager').value = mgr;
el('club-info').innerHTML = `
<div class="info-row"><span class="lbl">Club name</span><strong>${name}</strong></div>
<div class="info-row"><span class="lbl">Manager</span>${mgr}</div>
<div class="info-row"><span class="lbl">Coins</span><strong style="color:#f5a623">${(club.coins ?? 0).toLocaleString()}</strong></div>
<div class="info-row"><span class="lbl">Level</span>${club.level ?? 1}</div>
<div class="info-row"><span class="lbl">Club ID</span><code style="font-size:0.75rem;color:#8b949e">${club.id}</code></div>
`;
} catch (e) { el('club-info').innerHTML = `<div class="error-msg">${e.message}</div>`; }
try {
const s = await api('GET', '/statistics');
el('stats-info').innerHTML = `
<div class="info-row"><span class="lbl">Matches</span>${s.matches_played ?? 0}</div>
<div class="info-row"><span class="lbl">Wins</span><span style="color:#3fb950">${s.wins ?? 0}</span></div>
<div class="info-row"><span class="lbl">Draws</span>${s.draws ?? 0}</div>
<div class="info-row"><span class="lbl">Losses</span><span style="color:#f85149">${s.losses ?? 0}</span></div>
<div class="info-row"><span class="lbl">Goals scored</span>${s.goals_scored ?? 0}</div>
<div class="info-row"><span class="lbl">Goals conceded</span>${s.goals_conceded ?? 0}</div>
<div class="info-row"><span class="lbl">Packs opened</span>${s.packs_opened ?? 0}</div>
<div class="info-row"><span class="lbl">Win streak</span>${s.current_win_streak ?? 0}</div>
`;
} catch (e) { el('stats-info').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
async function saveClub() {
const name = el('edit-name').value.trim();
const mgr = el('edit-manager').value.trim();
if (!name && !mgr) return;
try {
await api('PUT', '/club', { name: name || undefined, manager_name: mgr || undefined });
showToast('Club updated');
loadClub();
} catch (e) { showToast(e.message, true); }
}
// ── Collection ───────────────────────────────────────────────────────────────
async function loadCollection() {
try {
const data = await api('GET', '/collection');
allCards = data.collection || [];
filterCards();
} catch (e) { el('collection-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
function filterCards() {
const name = el('f-name').value.toLowerCase();
const pos = el('f-pos').value;
const rarity = el('f-rarity').value;
const club = el('f-club').value.toLowerCase();
const nation = el('f-nation').value.toLowerCase();
const sort = el('f-sort').value;
let cards = allCards.filter(item => {
const c = item.card;
if (!c) return false;
if (name && !c.name.toLowerCase().includes(name)) return false;
if (pos && (item.effective_position || c.position) !== pos) return false;
if (rarity && (c.rarity || '').toLowerCase() !== rarity) return false;
if (club && !c.club.toLowerCase().includes(club)) return false;
if (nation && !c.nation.toLowerCase().includes(nation)) return false;
return true;
});
if (sort === 'name') cards.sort((a, b) => a.card.name.localeCompare(b.card.name));
else if (sort === 'pos') cards.sort((a, b) => a.card.position.localeCompare(b.card.position));
else cards.sort((a, b) => (b.effective_overall ?? b.card.overall) - (a.effective_overall ?? a.card.overall));
el('collection-count').textContent = `Showing ${cards.length} of ${allCards.length} cards`;
el('collection-grid').innerHTML = cards.length
? cards.map(renderPlayerCard).join('')
: '<span class="empty">No cards match the current filters.</span>';
}
// ── Packs ────────────────────────────────────────────────────────────────────
async function loadPacks() {
try {
const data = await api('GET', '/packs');
const packs = data.packs || [];
if (!packs.length) {
el('packs-grid').innerHTML = '<span class="empty">No unopened packs.</span>';
} else {
el('packs-grid').innerHTML = packs.map(p => `
<div class="pack-item">
<h3>${p.name || p.definition_id}</h3>
<div class="pack-id">${p.pack_id}</div>
<button class="btn btn-primary btn-sm" onclick="openPack('${p.pack_id}', this)">Open Pack</button>
</div>
`).join('');
}
} catch (e) { el('packs-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
try {
const hist = await api('GET', '/packs/history');
const history = hist.history || [];
if (!history.length) {
el('pack-history').innerHTML = '<span class="empty">No packs opened yet.</span>';
} else {
el('pack-history').innerHTML = history.slice(0, 10).map(h => `
<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;font-size:0.85rem">
<strong>${h.name || h.definition_id}</strong>
<span style="color:#8b949e;font-size:0.75rem">${h.opened_at ? new Date(h.opened_at).toLocaleString() : '—'}</span>
</div>
<div style="font-size:0.75rem;color:#8b949e;margin-top:4px">${(h.cards||[]).length} cards received</div>
</div>
`).join('');
}
} catch (e) { el('pack-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
async function openPack(packId, btn) {
btn.disabled = true;
btn.textContent = 'Opening…';
try {
const result = await api('POST', `/packs/open/${packId}`);
const cards = result.cards || [];
el('modal-cards').innerHTML = cards.map(c => `
<div class="player-card" style="font-size:0.8rem">
<span class="card-rarity ${rarityClass(c.rarity)}">${(c.rarity||'').toUpperCase()}</span>
<div class="card-ovr" style="font-size:1.2rem">${c.overall}</div>
<div class="card-pos" style="font-size:0.65rem">${c.position}</div>
<div class="card-name">${c.name}</div>
<div class="card-meta">${c.club}</div>
</div>
`).join('');
el('pack-modal').classList.add('open');
showToast(`Opened ${cards.length} cards!`);
loadPacks();
// Refresh header coins
api('GET', '/club').then(c => {
el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins';
}).catch(() => {});
} catch (e) {
showToast(e.message, true);
btn.disabled = false;
btn.textContent = 'Open Pack';
}
}
function closeModal() {
el('pack-modal').classList.remove('open');
}
// ── Objectives ───────────────────────────────────────────────────────────────
async function loadObjectives() {
try {
const data = await api('GET', '/objectives');
const objs = data.objectives || [];
if (!objs.length) {
el('objectives-list').innerHTML = '<span class="empty">No objectives found.</span>';
return;
}
el('objectives-list').innerHTML = objs.map(obj => {
const pct = obj.target > 0 ? Math.min(100, Math.round((obj.current / obj.target) * 100)) : 0;
const cls = obj.claimed ? 'claimed' : obj.completed ? 'completed' : '';
const reward = [
obj.reward_coins ? `${obj.reward_coins.toLocaleString()} coins` : '',
obj.reward_pack_id ? `+ ${obj.reward_pack_id}` : '',
].filter(Boolean).join(' ');
const claimBtn = obj.completed && !obj.claimed
? `<button class="btn btn-primary btn-sm" onclick="claimObjective('${obj.id}', this)">Claim</button>` : '';
const statusLabel = obj.claimed ? '✓ Claimed' : obj.completed ? '✓ Complete' : `${obj.current}/${obj.target}`;
return `<div class="obj-item ${cls}">
<div class="obj-header">
<span class="obj-title">${obj.title}</span>
${reward ? `<span class="obj-reward">${reward}</span>` : ''}
${claimBtn}
</div>
<div class="progress-track"><div class="progress-fill${obj.completed ? ' done' : ''}" style="width:${pct}%"></div></div>
<div class="obj-meta"><span>${obj.description || ''}</span><span>${statusLabel}</span></div>
</div>`;
}).join('');
} catch (e) { el('objectives-list').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
async function claimObjective(id, btn) {
btn.disabled = true;
try {
const r = await api('POST', `/objectives/${id}/claim`);
showToast(`Claimed! +${(r.coins_awarded || 0).toLocaleString()} coins`);
loadObjectives();
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
} catch (e) { showToast(e.message, true); btn.disabled = false; }
}
// ── Division ─────────────────────────────────────────────────────────────────
async function loadDivision() {
try {
const d = await api('GET', '/division');
const rec = d.record || {};
const ptsMax = 30; // 10 matches × 3 pts
const ptsPct = Math.min(100, Math.round((d.season_points / ptsMax) * 100));
const promoPct = d.pts_for_promotion > 0
? Math.min(100, Math.round((d.season_points / d.pts_for_promotion) * 100)) : 100;
el('division-card').innerHTML = `
<div class="division-num">${d.division}</div>
<div class="division-label">Division · Season ${d.season_number}</div>
<div class="record-row">
<div class="record-cell w"><div class="val">${rec.wins ?? 0}</div><div class="lbl">W</div></div>
<div class="record-cell d"><div class="val">${rec.draws ?? 0}</div><div class="lbl">D</div></div>
<div class="record-cell l"><div class="val">${rec.losses ?? 0}</div><div class="lbl">L</div></div>
</div>
`;
el('season-progress').innerHTML = `
<div class="info-row"><span class="lbl">Points</span><strong>${d.season_points}</strong></div>
<div class="info-row"><span class="lbl">For promotion</span>${d.pts_for_promotion} pts needed</div>
<div class="progress-track" style="margin:8px 0 16px">
<div class="progress-fill${promoPct >= 100 ? ' done' : ''}" style="width:${promoPct}%"></div>
</div>
<div class="info-row"><span class="lbl">Matches played</span>${d.matches_played} / 10</div>
<div class="info-row"><span class="lbl">Remaining</span>${d.matches_remaining}</div>
<div class="info-row"><span class="lbl">Started</span>${d.started_at ? new Date(d.started_at).toLocaleDateString() : '—'}</div>
`;
} catch (e) {
el('division-card').innerHTML = `<div class="error-msg">${e.message}</div>`;
el('season-progress').innerHTML = '';
}
}
async function claimRivalsReward() {
try {
const r = await api('POST', '/rivals/claim-weekly');
showToast(`Week ${r.week_number} reward: +${(r.coins_awarded || 0).toLocaleString()} coins${r.pack_granted ? ' + pack' : ''}`);
loadDivision();
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
} catch (e) { showToast(e.message, true); }
}
// ── FUT Champions ─────────────────────────────────────────────────────────────
let currentChampsSession = null;
async function loadChamps() {
try {
const data = await api('GET', '/fut-champs');
currentChampsSession = data.session;
renderChampsSession(data.session, data.max_matches);
} catch (e) { el('champs-current').innerHTML = `<div class="error-msg">${e.message}</div>`; }
try {
const hist = await api('GET', '/fut-champs/history');
const sessions = hist.history || [];
if (!sessions.length) {
el('champs-history').innerHTML = '<span class="empty">No past sessions.</span>';
} else {
el('champs-history').innerHTML = sessions.map(s => {
const tierClass = s.reward_tier ? 'tier-' + s.reward_tier.split(' ')[0] : '';
return `<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:center">
<strong>Week ${s.week_number}</strong>
${s.reward_tier ? `<span class="tier-badge ${tierClass}">${s.reward_tier}</span>` : ''}
</div>
<div style="font-size:0.8rem;color:#8b949e;margin-top:4px">
${s.wins}W ${s.draws}D ${s.losses}L · ${s.matches_played}/30 matches
${s.reward_coins ? ` · ${s.reward_coins.toLocaleString()} coins` : ''}
${s.reward_claimed ? ' · <span style="color:#3fb950">✓ claimed</span>' : ''}
</div>
</div>`;
}).join('');
}
} catch (e) { el('champs-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
function renderChampsSession(session, maxMatches) {
if (!session) {
el('champs-current').innerHTML = `
<div class="champs-card">
<p style="color:#8b949e;margin-bottom:16px">No active FUT Champions session.</p>
<button class="btn btn-primary" onclick="startChampsSession()">Start New Week</button>
</div>`;
return;
}
const pct = Math.round((session.matches_played / (maxMatches || 30)) * 100);
const tierClass = session.reward_tier ? 'tier-' + session.reward_tier.split(' ')[0] : '';
const isComplete = session.status === 'completed';
el('champs-current').innerHTML = `
<div class="champs-card">
<div style="display:flex;justify-content:space-between;align-items:center">
<strong>Week ${session.week_number}</strong>
<span style="font-size:0.8rem;color:${isComplete ? '#3fb950' : '#8b949e'}">${isComplete ? 'Completed' : 'Active'}</span>
</div>
${session.reward_tier ? `<div class="tier-badge ${tierClass}">${session.reward_tier}</div>` : ''}
<div class="record-row" style="margin:12px 0">
<div class="record-cell w"><div class="val">${session.wins}</div><div class="lbl">W</div></div>
<div class="record-cell d"><div class="val">${session.draws}</div><div class="lbl">D</div></div>
<div class="record-cell l"><div class="val">${session.losses}</div><div class="lbl">L</div></div>
</div>
<div style="font-size:0.8rem;color:#8b949e;margin-bottom:6px">${session.matches_played} / ${maxMatches || 30} matches played</div>
<div class="progress-track" style="margin-bottom:16px">
<div class="progress-fill${isComplete ? ' done' : ''}" style="width:${pct}%"></div>
</div>
${!isComplete ? `
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px">
<span style="font-size:0.8rem;color:#8b949e">Simulate result:</span>
<button class="btn btn-secondary btn-sm" onclick="simulateChampsMatch(3,0)">Win (3-0)</button>
<button class="btn btn-secondary btn-sm" onclick="simulateChampsMatch(1,1)">Draw (1-1)</button>
<button class="btn btn-secondary btn-sm" onclick="simulateChampsMatch(0,2)">Loss (0-2)</button>
</div>` : ''}
${isComplete && !session.reward_claimed ? `
<div style="background:#0d2818;border-radius:6px;padding:12px;margin-bottom:12px">
<div style="font-size:0.85rem"><strong>Reward ready!</strong> ${(session.reward_coins || 0).toLocaleString()} coins${session.reward_pack_id ? ' + ' + session.reward_pack_id : ''}</div>
</div>
<button class="btn btn-primary" onclick="claimChampsReward('${session.id}')">Claim Reward</button>` : ''}
${session.reward_claimed ? '<div style="color:#3fb950;font-size:0.85rem">✓ Reward claimed</div>' : ''}
</div>`;
}
async function startChampsSession() {
try {
const data = await api('POST', '/fut-champs/start');
currentChampsSession = data.session;
showToast('FUT Champions week started!');
loadChamps();
} catch (e) { showToast(e.message, true); }
}
async function simulateChampsMatch(gf, ga) {
if (!currentChampsSession) return;
try {
const r = await api('POST', `/fut-champs/${currentChampsSession.id}/result`, { goals_for: gf, goals_against: ga });
currentChampsSession = r.session;
showToast(`Match recorded: ${r.outcome}`);
loadChamps();
} catch (e) { showToast(e.message, true); }
}
async function claimChampsReward(sessionId) {
try {
const r = await api('POST', `/fut-champs/${sessionId}/claim`);
showToast(`Claimed ${r.tier}: +${(r.coins_awarded || 0).toLocaleString()} coins!`);
currentChampsSession = null;
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
loadChamps();
} catch (e) { showToast(e.message, true); }
}
// ── Notifications ─────────────────────────────────────────────────────────────
async function loadNotifications() {
try {
const data = await api('GET', '/notifications');
const notifs = data.notifications || [];
el('notif-badge').textContent = notifs.length > 0 ? ` (${notifs.length})` : '';
if (!notifs.length) {
el('notif-list').innerHTML = '<span class="empty">No notifications right now.</span>';
return;
}
el('notif-list').innerHTML = notifs.map(n => `
<div class="notif-item ${n.type}">
<div class="notif-title">${n.title}</div>
<div class="notif-msg">${n.message}</div>
</div>`).join('');
} catch (e) { el('notif-list').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
// ── Init ─────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
loadClub();
// Load notification badge count silently
api('GET', '/notifications').then(d => {
const n = (d.notifications || []).length;
el('notif-badge').textContent = n > 0 ? ` (${n})` : '';
}).catch(() => {});
});
</script>
</body>
</html>
+1
View File
@@ -56,6 +56,7 @@ fn build_router(state: ProxyState) -> Router {
.route("/_bridge/health", get(routes::health::get_health))
.route("/_bridge/cert.pem", get(routes::health::get_cert))
.route("/_bridge/guide", get(routes::health::get_guide))
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
.route("/_bridge/admin", get(routes::admin::get_admin_dashboard))
.route("/_bridge/captures", get(routes::admin::get_captures))
.route("/_bridge/captures", delete(routes::admin::delete_captures))
+13
View File
@@ -46,6 +46,19 @@ pub async fn get_cert(State(state): State<ProxyState>) -> Response {
}
}
/// Club management dashboard served at `/_bridge/dashboard`.
///
/// Injects the Core URL so client-side JS can call Core directly without going through the proxy.
pub async fn get_dashboard(State(state): State<ProxyState>) -> Response {
let template = include_str!("../dashboard.html");
let html = template.replace("{{CORE_URL}}", &state.config.core_url);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(axum::body::Body::from(html))
.unwrap()
}
/// HTML setup guide for connecting FIFA 23 to OpenFUT Bridge.
pub async fn get_guide(State(state): State<ProxyState>) -> Html<String> {
let host = state
+53 -1
View File
@@ -9,7 +9,7 @@ use openfut_bridge::{
proxy::ProxyState,
routes,
};
use axum::routing::{any, delete, get};
use axum::routing::{any, delete, get, post};
use tower::ServiceExt;
// ── Unit tests ────────────────────────────────────────────────────────────────
@@ -93,10 +93,12 @@ fn build_test_app() -> axum::Router {
axum::Router::new()
.route("/_bridge/health", get(routes::health::get_health))
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
.route("/_bridge/captures", get(routes::admin::get_captures))
.route("/_bridge/captures", delete(routes::admin::delete_captures))
.route("/_bridge/unknown", get(routes::admin::get_unknown_endpoints))
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
.route("/_bridge/captures/:id/replay", post(routes::admin::post_replay_capture))
.fallback(any(openfut_bridge::proxy::catch_all_handler))
.with_state(state)
}
@@ -191,6 +193,56 @@ async fn test_bridge_status_endpoint() {
assert!(json["endpoints"].is_array());
}
// ── Phase 13 — Dashboard ──────────────────────────────────────────────────────
#[tokio::test]
async fn test_dashboard_returns_html() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/dashboard")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp.headers().get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("text/html"), "expected text/html, got {ct}");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("OpenFUT Dashboard"), "title missing");
assert!(html.contains("const CORE ="), "core URL injection missing");
// Placeholder URL injected in test mode
assert!(html.contains("127.0.0.1:9999"), "core URL not injected");
assert!(!html.contains("{{CORE_URL}}"), "template placeholder was not replaced");
}
#[tokio::test]
async fn test_dashboard_contains_key_sections() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/dashboard")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
// Verify all major tab sections are present
assert!(html.contains("tab-club"), "club tab missing");
assert!(html.contains("tab-collection"), "collection tab missing");
assert!(html.contains("tab-packs"), "packs tab missing");
assert!(html.contains("tab-objectives"), "objectives tab missing");
assert!(html.contains("tab-division"), "division tab missing");
assert!(html.contains("tab-champs"), "fut champs tab missing");
assert!(html.contains("tab-notifications"), "notifications tab missing");
}
#[tokio::test]
async fn test_tls_cert_generation() {
let result = openfut_bridge::tls::generate_self_signed_cert();