- Replace nav user area with display name (non-clickable), gear settings modal, admin button (admins only), and logout button - Settings modal handles display name, timezone, and password change - Add admin.html + admin.js: user table with reset PW, disable/enable, login-as (impersonation), and delete; return-to-admin flow in nav - Add is_admin to UserResponse so frontend can gate the Admin button - Fix all db.begin() bugs in admin.py and users.py (transaction already active from get_current_user query; use commit() directly instead) - Add email-validator and pin bcrypt==4.0.1 for passlib compatibility - Add escHtml() to api.js and admin API namespace - Group nav brand + links in nav-left for left-aligned layout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
153 lines
4.8 KiB
HTML
153 lines
4.8 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>My Bottle — Bourbonacci</title>
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥃</text></svg>" />
|
|
<link rel="stylesheet" href="/css/style.css" />
|
|
</head>
|
|
<body>
|
|
|
|
<nav>
|
|
<div class="nav-left">
|
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
|
<div class="nav-links" id="nav-links"></div>
|
|
</div>
|
|
<div id="nav-user"></div>
|
|
</nav>
|
|
|
|
<main>
|
|
<h1 class="page-title" id="page-title">My Infinity Bottle</h1>
|
|
|
|
<!-- Stats -->
|
|
<div class="stats-grid" id="stats-grid">
|
|
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Bourbons Added</span></div>
|
|
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Est. Proof</span></div>
|
|
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Shots Remaining</span></div>
|
|
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Total Poured In</span></div>
|
|
</div>
|
|
|
|
<!-- Entries -->
|
|
<div class="card">
|
|
<div class="section-header">
|
|
<h2>Entry Log</h2>
|
|
<a href="/log.html" class="btn btn-primary btn-sm">+ Add Entry</a>
|
|
</div>
|
|
|
|
<div id="entries-wrap">
|
|
<div class="empty"><div class="empty-icon">⏳</div><p>Loading…</p></div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/auth.js"></script>
|
|
<script>
|
|
function escHtml(s) {
|
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function fmtDate(d) {
|
|
// d is YYYY-MM-DD
|
|
const [y,m,day] = d.split('-');
|
|
return `${m}/${day}/${y}`;
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
if (!Auth.requireAuth()) return;
|
|
const user = Auth.getUser();
|
|
await Auth.renderNav('dashboard');
|
|
|
|
if (user) {
|
|
document.getElementById('page-title').textContent = `${user.display_name || 'My'} Infinity Bottle`;
|
|
}
|
|
|
|
await Promise.all([loadStats(), loadEntries()]);
|
|
});
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const s = await API.entries.stats();
|
|
const grid = document.getElementById('stats-grid');
|
|
grid.innerHTML = `
|
|
<div class="stat-box">
|
|
<span class="stat-value">${s.total_add_entries}</span>
|
|
<span class="stat-label">Bourbons Added</span>
|
|
</div>
|
|
<div class="stat-box">
|
|
<span class="stat-value">${s.estimated_proof != null ? s.estimated_proof : '—'}</span>
|
|
<span class="stat-label">Est. Proof</span>
|
|
</div>
|
|
<div class="stat-box">
|
|
<span class="stat-value">${s.current_total_shots}</span>
|
|
<span class="stat-label">Shots Remaining</span>
|
|
</div>
|
|
<div class="stat-box">
|
|
<span class="stat-value">${(s.total_add_entries > 0 ? s.current_total_shots : 0)}</span>
|
|
<span class="stat-label">Net Volume (shots)</span>
|
|
</div>
|
|
`;
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function loadEntries() {
|
|
const wrap = document.getElementById('entries-wrap');
|
|
try {
|
|
const entries = await API.entries.list();
|
|
|
|
if (entries.length === 0) {
|
|
wrap.innerHTML = `<div class="empty"><div class="empty-icon">🥃</div><p>No entries yet. <a href="/log.html" style="color:var(--amber)">Add your first pour.</a></p></div>`;
|
|
return;
|
|
}
|
|
|
|
wrap.innerHTML = `
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Date</th>
|
|
<th>Bourbon</th>
|
|
<th>Proof</th>
|
|
<th>Shots</th>
|
|
<th>Notes</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="entries-body"></tbody>
|
|
</table>
|
|
</div>`;
|
|
|
|
const tbody = document.getElementById('entries-body');
|
|
tbody.innerHTML = entries.map(e => `
|
|
<tr>
|
|
<td><span class="badge badge-${e.entry_type}">${e.entry_type}</span></td>
|
|
<td>${fmtDate(e.date)}</td>
|
|
<td>${escHtml(e.bourbon_name ?? '—')}</td>
|
|
<td>${e.proof != null ? e.proof : '—'}</td>
|
|
<td>${e.amount_shots}</td>
|
|
<td style="max-width:200px;white-space:pre-wrap;word-break:break-word">${escHtml(e.notes ?? '')}</td>
|
|
<td>
|
|
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
|
|
</td>
|
|
</tr>`).join('');
|
|
|
|
} catch (err) {
|
|
wrap.innerHTML = `<div class="alert alert-error">Failed to load entries: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function deleteEntry(id) {
|
|
if (!confirm('Delete this entry?')) return;
|
|
try {
|
|
await API.entries.delete(id);
|
|
await Promise.all([loadStats(), loadEntries()]);
|
|
} catch (err) {
|
|
alert('Delete failed: ' + err.message);
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|