Add initial project files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
417
frontend/css/style.css
Normal file
417
frontend/css/style.css
Normal 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
149
frontend/dashboard.html
Normal 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,'&').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>
|
||||
91
frontend/index.html
Normal file
91
frontend/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
59
frontend/js/api.js
Normal file
59
frontend/js/api.js
Normal 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
72
frontend/js/auth.js
Normal 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
162
frontend/log.html
Normal 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
80
frontend/login.html
Normal 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
162
frontend/profile.html
Normal 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
101
frontend/register.html
Normal 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>
|
||||
Reference in New Issue
Block a user