257 lines
12 KiB
HTML
257 lines
12 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Dashboard – ShooterHub</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||
<link rel="stylesheet" href="/css/app.css">
|
||
<style>
|
||
.quick-link { display:flex; align-items:center; gap:.45rem; padding:.35rem .75rem;
|
||
border-radius:20px; font-size:.85rem; font-weight:500;
|
||
text-decoration:none; color:#495057; background:#f8f9fa;
|
||
border:1px solid #e9ecef; transition:background .15s,color .15s; white-space:nowrap; }
|
||
.quick-link:hover { background:#0d6efd; color:#fff; border-color:#0d6efd; }
|
||
.gear-card { text-decoration:none; color:inherit; }
|
||
.gear-card:hover .card { box-shadow:0 0 0 2px #0d6efd44 !important; }
|
||
.dash-box { min-height:260px; }
|
||
.dash-list-item { padding:.45rem .6rem; border-radius:5px; font-size:.85rem; }
|
||
.dash-list-item:hover { background:#f0f4ff; }
|
||
.dash-list-item + .dash-list-item { border-top:1px solid #f0f0f0; }
|
||
.photo-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:6px; }
|
||
.photo-grid img { width:100%; aspect-ratio:1; object-fit:cover; border-radius:5px; cursor:pointer; }
|
||
.type-badge { font-size:.65rem; padding:.15em .45em; vertical-align:middle; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="navbar"></div>
|
||
|
||
<div class="container py-4">
|
||
<div class="mb-4">
|
||
<h2 class="fw-bold mb-0" id="greeting" data-i18n="dash.title">Dashboard</h2>
|
||
<p class="text-muted small mb-0" id="welcomeMsg" data-i18n="dash.welcome">Welcome back!</p>
|
||
</div>
|
||
|
||
<!-- ── Row 1: Compact quick links ─────────────────────────────────────── -->
|
||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||
<a href="/gears.html" class="quick-link"><i class="bi bi-tools"></i><span data-i18n="dash.quicklink.gears">Gears & rigs</span></a>
|
||
<a href="/reloads.html" class="quick-link"><i class="bi bi-gear-wide-connected"></i><span data-i18n="dash.quicklink.reloads">Reloading</span></a>
|
||
<a href="/sessions.html" class="quick-link"><i class="bi bi-activity"></i><span data-i18n="dash.quicklink.sessions">Sessions</span></a>
|
||
<a href="/chrono.html" class="quick-link"><i class="bi bi-speedometer2"></i><span data-i18n="dash.quicklink.chrono">Chronograph</span></a>
|
||
<a href="/photos.html" class="quick-link"><i class="bi bi-images"></i><span data-i18n="nav.photos">Photos</span></a>
|
||
<a href="/tools.html" class="quick-link"><i class="bi bi-wrench-adjustable"></i><span data-i18n="dash.quicklink.tools">Tools</span></a>
|
||
<a href="/profile.html" class="quick-link"><i class="bi bi-person-circle"></i><span data-i18n="dash.quicklink.profile">Profile</span></a>
|
||
</div>
|
||
|
||
<!-- ── Row 2: Gear stat buttons ───────────────────────────────────────── -->
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-6 col-sm-3">
|
||
<a href="/gears.html" class="gear-card">
|
||
<div class="card border-0 shadow-sm h-100">
|
||
<div class="card-body d-flex align-items-center gap-3 py-3">
|
||
<div class="feature-icon text-primary"><i class="bi bi-archive"></i></div>
|
||
<div>
|
||
<div class="fs-3 fw-bold lh-1 mb-1" id="statGear">–</div>
|
||
<div class="text-muted small" data-i18n="dash.stat.gear">Gear items</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
<div class="col-6 col-sm-3">
|
||
<a href="/gears.html" class="gear-card">
|
||
<div class="card border-0 shadow-sm h-100">
|
||
<div class="card-body d-flex align-items-center gap-3 py-3">
|
||
<div class="feature-icon text-primary"><i class="bi bi-diagram-3"></i></div>
|
||
<div>
|
||
<div class="fs-3 fw-bold lh-1 mb-1" id="statRigs">–</div>
|
||
<div class="text-muted small" data-i18n="dash.stat.rigs">Rigs</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
<div class="col-6 col-sm-3">
|
||
<a href="/reloads.html" class="gear-card">
|
||
<div class="card border-0 shadow-sm h-100">
|
||
<div class="card-body d-flex align-items-center gap-3 py-3">
|
||
<div class="feature-icon text-primary"><i class="bi bi-gear-wide-connected"></i></div>
|
||
<div>
|
||
<div class="fs-3 fw-bold lh-1 mb-1" id="statRecipes">–</div>
|
||
<div class="text-muted small" data-i18n="dash.stat.recipes">Load recipes</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
<div class="col-6 col-sm-3">
|
||
<a href="/reloads.html" class="gear-card">
|
||
<div class="card border-0 shadow-sm h-100">
|
||
<div class="card-body d-flex align-items-center gap-3 py-3">
|
||
<div class="feature-icon text-primary"><i class="bi bi-stack"></i></div>
|
||
<div>
|
||
<div class="fs-3 fw-bold lh-1 mb-1" id="statBatches">–</div>
|
||
<div class="text-muted small" data-i18n="dash.stat.batches">Ammo batches</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Row 3: Live content boxes ──────────────────────────────────────── -->
|
||
<div class="row g-3">
|
||
|
||
<!-- Sessions -->
|
||
<div class="col-md-4">
|
||
<div class="card border-0 shadow-sm dash-box">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||
<h6 class="fw-semibold mb-0"><i class="bi bi-activity me-2 text-primary"></i>Sessions</h6>
|
||
<a href="/sessions.html" class="small text-primary text-decoration-none">View all →</a>
|
||
</div>
|
||
<div id="dashSessions">
|
||
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Analyses -->
|
||
<div class="col-md-4">
|
||
<div class="card border-0 shadow-sm dash-box">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||
<h6 class="fw-semibold mb-0"><i class="bi bi-speedometer2 me-2 text-primary"></i>Analyses</h6>
|
||
<a href="/chrono.html" class="small text-primary text-decoration-none">View all →</a>
|
||
</div>
|
||
<div id="dashAnalyses">
|
||
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Photos -->
|
||
<div class="col-md-4">
|
||
<div class="card border-0 shadow-sm dash-box">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||
<h6 class="fw-semibold mb-0"><i class="bi bi-images me-2 text-primary"></i>Recent photos</h6>
|
||
<a href="/photos.html" class="small text-primary text-decoration-none">View all →</a>
|
||
</div>
|
||
<div id="dashPhotos">
|
||
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toastContainer"></div>
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/utils.js"></script>
|
||
<script src="/js/i18n.js"></script>
|
||
<script src="/js/nav.js"></script>
|
||
<script>
|
||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||
|
||
async function loadDashboard() {
|
||
try {
|
||
const [profile, inventory, rigs, recipes] = await Promise.all([
|
||
apiGet('/users/profile/'),
|
||
apiGet('/inventory/'),
|
||
apiGet('/rigs/'),
|
||
apiGet('/reloading/recipes/'),
|
||
]);
|
||
|
||
const name = profile.first_name || profile.username || '';
|
||
if (name) document.getElementById('welcomeMsg').textContent =
|
||
(t('dash.welcome.name') || 'Welcome back, {name}!').replace('{name}', name);
|
||
|
||
const inv = Array.isArray(inventory) ? inventory : (inventory.results || []);
|
||
const rig = Array.isArray(rigs) ? rigs : (rigs.results || []);
|
||
const rec = Array.isArray(recipes) ? recipes : (recipes.results || []);
|
||
|
||
document.getElementById('statGear').textContent = inv.length;
|
||
document.getElementById('statRigs').textContent = rig.length;
|
||
document.getElementById('statRecipes').textContent = rec.length;
|
||
document.getElementById('statBatches').textContent = rec.reduce((n, r) => n + (r.batches?.length || 0), 0);
|
||
} catch(e) { console.error(e); }
|
||
|
||
loadSessions();
|
||
loadAnalyses();
|
||
loadPhotos();
|
||
}
|
||
|
||
async function loadSessions() {
|
||
const el = document.getElementById('dashSessions');
|
||
try {
|
||
const [prs, fp, ss] = await Promise.all([
|
||
apiGet('/sessions/prs/?page_size=5').then(d => (Array.isArray(d) ? d : (d.results||[])).map(s => ({...s, _type:'PRS'}))).catch(()=>[]),
|
||
apiGet('/sessions/free-practice/?page_size=5').then(d => (Array.isArray(d) ? d : (d.results||[])).map(s => ({...s, _type:'Practice'}))).catch(()=>[]),
|
||
apiGet('/sessions/speed-shooting/?page_size=5').then(d => (Array.isArray(d) ? d : (d.results||[])).map(s => ({...s, _type:'Speed'}))).catch(()=>[]),
|
||
]);
|
||
const all = [...prs, ...fp, ...ss]
|
||
.sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
||
.slice(0, 7);
|
||
|
||
if (!all.length) { el.innerHTML = '<p class="text-muted small">No sessions yet.</p>'; return; }
|
||
|
||
const typeColor = { PRS:'primary', Practice:'success', Speed:'warning' };
|
||
el.innerHTML = all.map(s => {
|
||
const label = s.competition_name || s.name || '(unnamed)';
|
||
const color = typeColor[s._type] || 'secondary';
|
||
return `<div class="dash-list-item d-flex align-items-center gap-2">
|
||
<span class="badge bg-${color} type-badge">${s._type}</span>
|
||
<span class="flex-grow-1 text-truncate">${esc(label)}</span>
|
||
<span class="text-muted small">${s.date || ''}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
el.innerHTML = '<p class="text-danger small">Failed to load.</p>';
|
||
}
|
||
}
|
||
|
||
async function loadAnalyses() {
|
||
const el = document.getElementById('dashAnalyses');
|
||
try {
|
||
const data = await apiGet('/tools/chronograph/?page_size=7');
|
||
const items = Array.isArray(data) ? data : (data.results || []);
|
||
if (!items.length) { el.innerHTML = '<p class="text-muted small">No analyses yet.</p>'; return; }
|
||
el.innerHTML = items.map(a => `
|
||
<a href="/chrono.html?id=${a.id}" class="dash-list-item d-flex align-items-center gap-2 text-decoration-none text-reset">
|
||
<i class="bi bi-speedometer2 text-muted small"></i>
|
||
<span class="flex-grow-1 text-truncate">${esc(a.name || '(unnamed)')}</span>
|
||
<span class="text-muted small">${a.date || ''}</span>
|
||
</a>`).join('');
|
||
} catch(e) {
|
||
el.innerHTML = '<p class="text-danger small">Failed to load.</p>';
|
||
}
|
||
}
|
||
|
||
async function loadPhotos() {
|
||
const el = document.getElementById('dashPhotos');
|
||
try {
|
||
const data = await apiGet('/photos/group-photos/?page_size=9');
|
||
const items = Array.isArray(data) ? data : (data.results || []);
|
||
if (!items.length) { el.innerHTML = '<p class="text-muted small">No photos yet.</p>'; return; }
|
||
el.innerHTML = `<div class="photo-grid">${
|
||
items.map(gp => `<img src="/api/photos/${gp.photo.id}/data/"
|
||
onclick="window.open('/api/photos/${gp.photo.id}/data/','_blank')"
|
||
title="${esc(gp.caption || '')}">`).join('')
|
||
}</div>`;
|
||
} catch(e) {
|
||
el.innerHTML = '<p class="text-danger small">Failed to load.</p>';
|
||
}
|
||
}
|
||
|
||
loadDashboard();
|
||
</script>
|
||
</body>
|
||
</html>
|