Add initial project files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 19:11:00 -07:00
parent bfab8f71fb
commit 72b23c18aa
32 changed files with 1870 additions and 0 deletions

417
frontend/css/style.css Normal file
View File

@@ -0,0 +1,417 @@
/* ============================================================
Bourbonacci — Bourbon-themed dark UI
============================================================ */
:root {
--bg: #0d0800;
--bg-card: #1c1100;
--bg-card-2: #261800;
--border: #3d2b00;
--amber: #c8860a;
--amber-light: #e6a020;
--amber-dim: #7a5206;
--cream: #f5e6c8;
--cream-dim: #b89d74;
--danger: #c0392b;
--danger-dim: #7d2318;
--success: #27ae60;
--radius: 8px;
--shadow: 0 4px 24px rgba(0,0,0,.6);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--cream);
font-family: 'Georgia', serif;
min-height: 100vh;
line-height: 1.6;
}
/* ---- Nav ---- */
nav {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
padding: 0 2rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand {
font-size: 1.4rem;
font-weight: bold;
color: var(--amber);
text-decoration: none;
letter-spacing: .05em;
}
.nav-links {
display: flex;
gap: 1.5rem;
align-items: center;
}
.nav-links a {
color: var(--cream-dim);
text-decoration: none;
font-size: .95rem;
transition: color .2s;
}
.nav-links a:hover,
.nav-links a.active {
color: var(--amber-light);
}
.nav-user {
color: var(--amber);
cursor: pointer;
font-size: .95rem;
text-decoration: none;
}
.nav-user:hover { color: var(--amber-light); }
/* ---- Layout ---- */
main {
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
/* ---- Cards ---- */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
}
.card + .card { margin-top: 1.5rem; }
.card-title {
font-size: 1.1rem;
color: var(--amber);
margin-bottom: 1rem;
letter-spacing: .04em;
text-transform: uppercase;
font-size: .85rem;
}
/* ---- Stats grid ---- */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-box {
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.2rem 1rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--amber-light);
display: block;
}
.stat-label {
font-size: .75rem;
color: var(--cream-dim);
text-transform: uppercase;
letter-spacing: .08em;
margin-top: .25rem;
}
/* ---- Page title ---- */
.page-title {
font-size: 1.8rem;
color: var(--amber-light);
margin-bottom: 1.5rem;
}
.page-subtitle {
color: var(--cream-dim);
margin-top: -.75rem;
margin-bottom: 1.5rem;
font-size: .9rem;
}
/* ---- Forms ---- */
.form-group {
margin-bottom: 1.1rem;
}
label {
display: block;
font-size: .85rem;
color: var(--cream-dim);
margin-bottom: .4rem;
text-transform: uppercase;
letter-spacing: .06em;
}
input, select, textarea {
width: 100%;
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--cream);
padding: .65rem .9rem;
font-size: .95rem;
font-family: inherit;
transition: border-color .2s;
outline: none;
}
input:focus, select:focus, textarea:focus {
border-color: var(--amber);
}
textarea { resize: vertical; min-height: 80px; }
select option { background: var(--bg-card); }
/* ---- Buttons ---- */
.btn {
display: inline-block;
padding: .6rem 1.4rem;
border-radius: var(--radius);
border: none;
cursor: pointer;
font-family: inherit;
font-size: .95rem;
font-weight: bold;
text-decoration: none;
transition: opacity .2s, background .2s;
}
.btn-primary {
background: var(--amber);
color: #0d0800;
}
.btn-primary:hover { background: var(--amber-light); }
.btn-danger {
background: var(--danger-dim);
color: var(--cream);
}
.btn-danger:hover { background: var(--danger); }
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--cream-dim);
}
.btn-ghost:hover { border-color: var(--amber); color: var(--amber); }
.btn-sm {
padding: .3rem .8rem;
font-size: .82rem;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* ---- Table ---- */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: .9rem;
}
thead th {
text-align: left;
padding: .65rem .75rem;
color: var(--amber);
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .07em;
border-bottom: 1px solid var(--border);
font-weight: normal;
}
tbody tr {
border-bottom: 1px solid var(--border);
transition: background .15s;
}
tbody tr:hover { background: var(--bg-card-2); }
tbody td {
padding: .65rem .75rem;
color: var(--cream);
vertical-align: top;
}
.badge {
display: inline-block;
padding: .2rem .55rem;
border-radius: 20px;
font-size: .72rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: .05em;
}
.badge-add { background: rgba(200,134,10,.2); color: var(--amber-light); border: 1px solid var(--amber-dim); }
.badge-remove { background: rgba(192,57,43,.2); color: #e07060; border: 1px solid var(--danger-dim); }
/* ---- Alert ---- */
.alert {
padding: .75rem 1rem;
border-radius: var(--radius);
font-size: .9rem;
margin-bottom: 1rem;
}
.alert-error { background: rgba(192,57,43,.2); border: 1px solid var(--danger-dim); color: #e07060; }
.alert-success { background: rgba(39,174,96,.15); border: 1px solid #1e6b3d; color: #5dd490; }
/* ---- Auth pages ---- */
.auth-wrap {
max-width: 420px;
margin: 4rem auto;
padding: 0 1rem;
}
.auth-logo {
text-align: center;
margin-bottom: 2rem;
}
.auth-logo h1 { color: var(--amber-light); font-size: 2rem; }
.auth-logo p { color: var(--cream-dim); font-size: .9rem; margin-top: .3rem; }
/* ---- Public dashboard user cards ---- */
.user-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.user-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
transition: border-color .2s;
}
.user-card:hover { border-color: var(--amber-dim); }
.user-card-name {
font-size: 1.1rem;
color: var(--amber-light);
margin-bottom: 1rem;
}
/* ---- Bottle visual ---- */
.bottle-bar-wrap {
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: 4px;
height: 10px;
overflow: hidden;
margin: .5rem 0 .25rem;
}
.bottle-bar {
height: 100%;
background: linear-gradient(90deg, var(--amber-dim), var(--amber-light));
transition: width .4s ease;
border-radius: 4px;
}
.bottle-label {
font-size: .72rem;
color: var(--cream-dim);
text-align: right;
}
/* ---- Landing hero ---- */
.hero {
text-align: center;
padding: 3rem 1rem 2rem;
}
.hero h1 { font-size: 2.8rem; color: var(--amber-light); }
.hero p { color: var(--cream-dim); max-width: 540px; margin: .75rem auto 1.5rem; font-size: 1rem; }
/* ---- Section header ---- */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.section-header h2 {
font-size: 1.1rem;
color: var(--amber);
text-transform: uppercase;
letter-spacing: .06em;
font-size: .85rem;
}
/* ---- Divider ---- */
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 1.5rem 0;
}
/* ---- Empty state ---- */
.empty {
text-align: center;
padding: 3rem 1rem;
color: var(--cream-dim);
}
.empty-icon { font-size: 2.5rem; margin-bottom: .75rem; }
/* ---- Tabs ---- */
.tabs {
display: flex;
gap: .5rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.tab {
padding: .6rem 1.2rem;
cursor: pointer;
color: var(--cream-dim);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: .9rem;
transition: color .2s, border-color .2s;
}
.tab.active, .tab:hover { color: var(--amber-light); }
.tab.active { border-bottom-color: var(--amber); }
/* ---- Responsive ---- */
@media (max-width: 640px) {
.hero h1 { font-size: 2rem; }
.stats-grid { grid-template-columns: 1fr 1fr; }
nav { padding: 0 1rem; }
main { padding: 1.25rem 1rem; }
}

149
frontend/dashboard.html Normal file
View File

@@ -0,0 +1,149 @@
<!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="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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>

91
frontend/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bourbonacci — Infinity Bottle Tracker</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<main>
<div class="hero">
<h1>The Infinity Bottle</h1>
<p>One pour from every bottle. An ever-evolving blend that grows richer with every addition.</p>
<a href="/login.html" class="btn btn-primary" id="hero-cta">Track Your Bottle</a>
</div>
<div class="section-header">
<h2>Community Bottles</h2>
</div>
<div id="user-cards" class="user-cards">
<div class="empty"><div class="empty-icon"></div><p>Loading...</p></div>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
await Auth.renderNav('home');
if (Auth.isLoggedIn()) {
document.getElementById('hero-cta').textContent = 'Go to My Bottle';
document.getElementById('hero-cta').href = '/dashboard.html';
}
const container = document.getElementById('user-cards');
try {
const stats = await API.public.stats();
if (stats.length === 0) {
container.innerHTML = `<div class="empty"><div class="empty-icon">🥃</div><p>No bottles yet — be the first to register!</p></div>`;
return;
}
container.innerHTML = stats.map(u => {
const maxShots = 25;
const pct = Math.min(100, (u.current_total_shots / maxShots) * 100);
const proof = u.estimated_proof != null ? `${u.estimated_proof}` : '—';
return `
<div class="user-card">
<div class="user-card-name">${escHtml(u.display_name)}</div>
<div class="stats-grid" style="margin-bottom:.75rem">
<div class="stat-box">
<span class="stat-value">${u.total_add_entries}</span>
<span class="stat-label">Bourbons</span>
</div>
<div class="stat-box">
<span class="stat-value">${proof}</span>
<span class="stat-label">Est. Proof</span>
</div>
<div class="stat-box">
<span class="stat-value">${u.current_total_shots}</span>
<span class="stat-label">Shots Left</span>
</div>
</div>
<div class="bottle-bar-wrap">
<div class="bottle-bar" style="width:${pct}%"></div>
</div>
<div class="bottle-label">${u.current_total_shots} shots remaining</div>
</div>`;
}).join('');
} catch (err) {
container.innerHTML = `<div class="alert alert-error">Could not load stats: ${err.message}</div>`;
}
});
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
</body>
</html>

59
frontend/js/api.js Normal file
View File

@@ -0,0 +1,59 @@
/* Central API client — all fetch calls go through here */
const API = (() => {
const base = '/api';
function token() {
return localStorage.getItem('bb_token');
}
async function request(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
const tok = token();
if (tok) headers['Authorization'] = `Bearer ${tok}`;
const res = await fetch(base + path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json().catch(() => null);
if (!res.ok) {
const msg = data?.detail || `HTTP ${res.status}`;
throw new Error(Array.isArray(msg) ? msg.map(e => e.msg).join(', ') : msg);
}
return data;
}
return {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
auth: {
login: (email, password) => request('POST', '/auth/login', { email, password }),
register: (email, password, display_name) =>
request('POST', '/auth/register', { email, password, display_name }),
},
users: {
me: () => request('GET', '/users/me'),
update: (body) => request('PUT', '/users/me', body),
changePassword: (body) => request('PUT', '/users/me/password', body),
},
entries: {
list: () => request('GET', '/entries'),
stats: () => request('GET', '/entries/stats'),
create: (body) => request('POST', '/entries', body),
delete: (id) => request('DELETE', `/entries/${id}`),
},
public: {
stats: () => request('GET', '/public/stats'),
},
};
})();

72
frontend/js/auth.js Normal file
View File

@@ -0,0 +1,72 @@
/* Auth state helpers shared across all pages */
const Auth = (() => {
const KEY = 'bb_token';
const USER_KEY = 'bb_user';
function getToken() { return localStorage.getItem(KEY); }
function saveToken(token) { localStorage.setItem(KEY, token); }
function getUser() {
const raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
}
function saveUser(user) { localStorage.setItem(USER_KEY, JSON.stringify(user)); }
function logout() {
localStorage.removeItem(KEY);
localStorage.removeItem(USER_KEY);
window.location.href = '/index.html';
}
function isLoggedIn() { return !!getToken(); }
/* Redirect to login if not authenticated */
function requireAuth() {
if (!isLoggedIn()) {
window.location.href = '/login.html';
return false;
}
return true;
}
/* Redirect away from auth pages if already logged in */
function redirectIfLoggedIn() {
if (isLoggedIn()) {
window.location.href = '/dashboard.html';
}
}
/* Render the nav user area; call after DOM ready */
async function renderNav(activePage) {
const navLinksEl = document.getElementById('nav-links');
const navUserEl = document.getElementById('nav-user');
if (!navLinksEl || !navUserEl) return;
if (isLoggedIn()) {
let user = getUser();
if (!user) {
try { user = await API.users.me(); saveUser(user); } catch (_) {}
}
navLinksEl.innerHTML = `
<a href="/dashboard.html" class="${activePage === 'dashboard' ? 'active' : ''}">My Bottle</a>
<a href="/log.html" class="${activePage === 'log' ? 'active' : ''}">Log Entry</a>
`;
navUserEl.innerHTML = `
<a href="/profile.html" class="nav-user">${user?.display_name || user?.email || 'Account'}</a>
<a href="#" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
`;
document.getElementById('logout-btn')?.addEventListener('click', (e) => {
e.preventDefault();
logout();
});
} else {
navLinksEl.innerHTML = '';
navUserEl.innerHTML = `<a href="/login.html" class="btn btn-primary btn-sm">Login</a>`;
}
}
return { getToken, saveToken, getUser, saveUser, logout, isLoggedIn, requireAuth, redirectIfLoggedIn, renderNav };
})();

162
frontend/log.html Normal file
View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Entry — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<main style="max-width:640px">
<h1 class="page-title">Log Entry</h1>
<div class="tabs">
<div class="tab active" id="tab-add" onclick="switchTab('add')">Add to Bottle</div>
<div class="tab" id="tab-remove" onclick="switchTab('remove')">Remove (Drink)</div>
</div>
<!-- ADD FORM -->
<div id="pane-add">
<div class="card">
<div id="alert-add"></div>
<form id="form-add">
<div class="form-group">
<label for="add-date">Date</label>
<input type="date" id="add-date" required />
</div>
<div class="form-group">
<label for="bourbon-name">Bourbon Name</label>
<input type="text" id="bourbon-name" required placeholder="e.g. Buffalo Trace" />
</div>
<div class="form-group">
<label for="proof">Proof</label>
<input type="number" id="proof" min="0" max="200" step="0.1" placeholder="e.g. 90" />
</div>
<div class="form-group">
<label for="add-shots">Amount (shots)</label>
<input type="number" id="add-shots" min="0.25" step="0.25" value="1" required />
</div>
<div class="form-group">
<label for="add-notes">Notes</label>
<textarea id="add-notes" placeholder="Tasting notes, batch info, anything worth remembering…"></textarea>
</div>
<div style="display:flex;gap:1rem;margin-top:.5rem">
<button type="submit" class="btn btn-primary" id="btn-add">Add to Bottle</button>
<a href="/dashboard.html" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
<!-- REMOVE FORM -->
<div id="pane-remove" style="display:none">
<div class="card">
<p style="color:var(--cream-dim);margin-bottom:1rem;font-size:.9rem">
Log shots you've poured and consumed from the infinity bottle.
</p>
<div id="alert-remove"></div>
<form id="form-remove">
<div class="form-group">
<label for="remove-date">Date</label>
<input type="date" id="remove-date" required />
</div>
<div class="form-group">
<label for="remove-shots">Shots Consumed</label>
<input type="number" id="remove-shots" min="0.25" step="0.25" value="1" required />
</div>
<div class="form-group">
<label for="remove-notes">Notes</label>
<textarea id="remove-notes" placeholder="Occasion, tasting notes…"></textarea>
</div>
<div style="display:flex;gap:1rem;margin-top:.5rem">
<button type="submit" class="btn btn-danger" id="btn-remove">Log Removal</button>
<a href="/dashboard.html" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
function today() {
return new Date().toISOString().split('T')[0];
}
document.addEventListener('DOMContentLoaded', () => {
if (!Auth.requireAuth()) return;
Auth.renderNav('log');
document.getElementById('add-date').value = today();
document.getElementById('remove-date').value = today();
});
function switchTab(tab) {
document.getElementById('pane-add').style.display = tab === 'add' ? '' : 'none';
document.getElementById('pane-remove').style.display = tab === 'remove' ? '' : 'none';
document.getElementById('tab-add').className = 'tab' + (tab === 'add' ? ' active' : '');
document.getElementById('tab-remove').className = 'tab' + (tab === 'remove' ? ' active' : '');
}
document.getElementById('form-add').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-add');
const btn = document.getElementById('btn-add');
alert.innerHTML = '';
btn.disabled = true;
try {
await API.entries.create({
entry_type: 'add',
date: document.getElementById('add-date').value,
bourbon_name: document.getElementById('bourbon-name').value.trim(),
proof: parseFloat(document.getElementById('proof').value) || null,
amount_shots: parseFloat(document.getElementById('add-shots').value),
notes: document.getElementById('add-notes').value.trim() || null,
});
alert.innerHTML = `<div class="alert alert-success">Added to the bottle!</div>`;
e.target.reset();
document.getElementById('add-date').value = today();
document.getElementById('add-shots').value = '1';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
document.getElementById('form-remove').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-remove');
const btn = document.getElementById('btn-remove');
alert.innerHTML = '';
btn.disabled = true;
try {
await API.entries.create({
entry_type: 'remove',
date: document.getElementById('remove-date').value,
amount_shots: parseFloat(document.getElementById('remove-shots').value),
notes: document.getElementById('remove-notes').value.trim() || null,
});
alert.innerHTML = `<div class="alert alert-success">Removal logged. Cheers!</div>`;
e.target.reset();
document.getElementById('remove-date').value = today();
document.getElementById('remove-shots').value = '1';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

80
frontend/login.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<div class="auth-wrap">
<div class="auth-logo">
<h1>Welcome Back</h1>
<p>Sign in to manage your infinity bottle</p>
</div>
<div class="card">
<div id="alert"></div>
<form id="login-form">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" required placeholder="you@example.com" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required placeholder="••••••••" />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:.5rem" id="submit-btn">Sign In</button>
</form>
<hr class="divider" />
<p style="text-align:center;color:var(--cream-dim);font-size:.9rem">
Don't have an account? <a href="/register.html" style="color:var(--amber)">Register</a>
</p>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
Auth.redirectIfLoggedIn();
Auth.renderNav();
const form = document.getElementById('login-form');
const alert = document.getElementById('alert');
const btn = document.getElementById('submit-btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
alert.innerHTML = '';
btn.disabled = true;
btn.textContent = 'Signing in…';
try {
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const data = await API.auth.login(email, password);
Auth.saveToken(data.access_token);
// Pre-fetch user info so nav renders immediately
const user = await API.users.me();
Auth.saveUser(user);
window.location.href = '/dashboard.html';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
});
</script>
</body>
</html>

162
frontend/profile.html Normal file
View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Profile — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<main style="max-width:560px">
<h1 class="page-title">Profile Settings</h1>
<!-- Account info -->
<div class="card">
<div class="card-title">Account Info</div>
<div id="alert-profile"></div>
<form id="form-profile">
<div class="form-group">
<label for="email-display">Email</label>
<input type="text" id="email-display" disabled style="opacity:.5;cursor:not-allowed" />
</div>
<div class="form-group">
<label for="display-name">Display Name</label>
<input type="text" id="display-name" placeholder="How you appear publicly" maxlength="100" />
</div>
<div class="form-group">
<label for="timezone">Time Zone</label>
<select id="timezone"></select>
</div>
<button type="submit" class="btn btn-primary" id="btn-profile">Save Changes</button>
</form>
</div>
<!-- Change password -->
<div class="card">
<div class="card-title">Change Password</div>
<div id="alert-pw"></div>
<form id="form-pw">
<div class="form-group">
<label for="cur-pw">Current Password</label>
<input type="password" id="cur-pw" required autocomplete="current-password" />
</div>
<div class="form-group">
<label for="new-pw">New Password</label>
<input type="password" id="new-pw" required autocomplete="new-password" placeholder="Min 8 characters" />
</div>
<div class="form-group">
<label for="conf-pw">Confirm New Password</label>
<input type="password" id="conf-pw" required autocomplete="new-password" />
</div>
<button type="submit" class="btn btn-primary" id="btn-pw">Update Password</button>
</form>
</div>
<!-- Danger -->
<div class="card" style="border-color:var(--danger-dim)">
<div class="card-title" style="color:#e07060">Danger Zone</div>
<p style="color:var(--cream-dim);font-size:.9rem;margin-bottom:1rem">Sign out of your account on this device.</p>
<button class="btn btn-danger" onclick="Auth.logout()">Logout</button>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
const TIMEZONES = [
'UTC',
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
'America/Anchorage','Pacific/Honolulu',
'America/Toronto','America/Vancouver','America/Winnipeg',
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Moscow',
'Asia/Tokyo','Asia/Shanghai','Asia/Kolkata','Asia/Dubai',
'Australia/Sydney','Australia/Perth',
'Pacific/Auckland',
];
document.addEventListener('DOMContentLoaded', async () => {
if (!Auth.requireAuth()) return;
await Auth.renderNav();
// Populate timezone select
const tzSel = document.getElementById('timezone');
TIMEZONES.forEach(tz => {
const opt = document.createElement('option');
opt.value = tz;
opt.textContent = tz.replace('_', ' ');
tzSel.appendChild(opt);
});
try {
const user = await API.users.me();
Auth.saveUser(user);
document.getElementById('email-display').value = user.email;
document.getElementById('display-name').value = user.display_name || '';
tzSel.value = user.timezone || 'UTC';
} catch (err) {
document.getElementById('alert-profile').innerHTML = `<div class="alert alert-error">Failed to load profile: ${err.message}</div>`;
}
});
document.getElementById('form-profile').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-profile');
const btn = document.getElementById('btn-profile');
alert.innerHTML = '';
btn.disabled = true;
try {
const user = await API.users.update({
display_name: document.getElementById('display-name').value.trim() || null,
timezone: document.getElementById('timezone').value,
});
Auth.saveUser(user);
alert.innerHTML = `<div class="alert alert-success">Profile updated.</div>`;
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
document.getElementById('form-pw').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-pw');
const btn = document.getElementById('btn-pw');
const newPw = document.getElementById('new-pw').value;
const confPw = document.getElementById('conf-pw').value;
alert.innerHTML = '';
if (newPw !== confPw) {
alert.innerHTML = `<div class="alert alert-error">New passwords do not match.</div>`;
return;
}
if (newPw.length < 8) {
alert.innerHTML = `<div class="alert alert-error">Password must be at least 8 characters.</div>`;
return;
}
btn.disabled = true;
try {
await API.users.changePassword({
current_password: document.getElementById('cur-pw').value,
new_password: newPw,
});
alert.innerHTML = `<div class="alert alert-success">Password updated.</div>`;
e.target.reset();
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

101
frontend/register.html Normal file
View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Register — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<div class="auth-wrap">
<div class="auth-logo">
<h1>Start Your Bottle</h1>
<p>Create an account to track your infinity bottle</p>
</div>
<div class="card">
<div id="alert"></div>
<form id="register-form">
<div class="form-group">
<label for="display_name">Display Name</label>
<input type="text" id="display_name" placeholder="Your name (public)" autocomplete="nickname" />
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" required placeholder="you@example.com" autocomplete="email" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" required placeholder="Min 8 characters" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="confirm">Confirm Password</label>
<input type="password" id="confirm" required placeholder="••••••••" autocomplete="new-password" />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:.5rem" id="submit-btn">Create Account</button>
</form>
<hr class="divider" />
<p style="text-align:center;color:var(--cream-dim);font-size:.9rem">
Already have an account? <a href="/login.html" style="color:var(--amber)">Sign in</a>
</p>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
Auth.redirectIfLoggedIn();
Auth.renderNav();
const form = document.getElementById('register-form');
const alert = document.getElementById('alert');
const btn = document.getElementById('submit-btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
alert.innerHTML = '';
const displayName = document.getElementById('display_name').value.trim();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const confirm = document.getElementById('confirm').value;
if (password !== confirm) {
alert.innerHTML = `<div class="alert alert-error">Passwords do not match.</div>`;
return;
}
if (password.length < 8) {
alert.innerHTML = `<div class="alert alert-error">Password must be at least 8 characters.</div>`;
return;
}
btn.disabled = true;
btn.textContent = 'Creating account…';
try {
const data = await API.auth.register(email, password, displayName || undefined);
Auth.saveToken(data.access_token);
const user = await API.users.me();
Auth.saveUser(user);
window.location.href = '/dashboard.html';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
});
</script>
</body>
</html>