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:
@@ -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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user