Files
BrewJournal/src/App.jsx
2026-06-06 09:49:43 +05:30

1179 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useContext } from "react";
import { AuthContext } from "./AuthContext";
import Login from "./Login";
import Register from "./Register";
// ─── Storage helpers with Browser/localStorage Fallback ───
const STORAGE_KEY = "coffee-logbook-data";
const defaultData = { beans: [], brewLogs: [], lastSyncedAt: null };
const storage = {
get: async (key) => {
try {
if (window.storage && typeof window.storage.get === "function") {
return await window.storage.get(key);
}
const val = localStorage.getItem(key);
return val ? { value: val } : null;
} catch {
return null;
}
},
set: async (key, val) => {
try {
if (window.storage && typeof window.storage.set === "function") {
await window.storage.set(key, val);
return;
}
localStorage.setItem(key, val);
} catch (e) {
console.error("Storage set failed:", e);
}
}
};
async function loadData() {
try {
const result = await storage.get(STORAGE_KEY);
if (result) {
const parsed = JSON.parse(result.value);
return {
beans: parsed.beans || [],
brewLogs: parsed.brewLogs || [],
lastSyncedAt: parsed.lastSyncedAt || null
};
}
return defaultData;
} catch {
return defaultData;
}
}
async function saveData(data) {
try {
await storage.set(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error("Save failed:", e);
}
}
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
const ROAST_TYPES = ["Light", "Light-Medium", "Medium", "Medium-Dark", "Dark"];
const METHODS = ["pourover", "espresso", "coldbrew"];
const METHOD_LABELS = { pourover: "Pour Over", espresso: "Espresso", coldbrew: "Cold Brew" };
const METHOD_ICONS = { pourover: "☕", espresso: "⚡", coldbrew: "❄️" };
const METHOD_COLORS = { pourover: "#8B6914", espresso: "#5C3317", coldbrew: "#2F4F6F" };
// ─── Styles ───
const styles = `
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@300;400;500;600&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #FAF6F1;
--bg2: #F3EDE4;
--card: #FFFFFF;
--text: #2C1810;
--text2: #6B5744;
--text3: #9C8B7A;
--accent: #8B6914;
--accent2: #C4941A;
--border: #E8DFD3;
--espresso: #5C3317;
--coldbrew: #2F4F6F;
--pourover: #8B6914;
--danger: #B44040;
--shadow: 0 1px 3px rgba(44,24,16,0.06), 0 4px 12px rgba(44,24,16,0.04);
--shadow-lg: 0 4px 16px rgba(44,24,16,0.08), 0 12px 32px rgba(44,24,16,0.06);
--radius: 12px;
--radius-sm: 8px;
}
.app {
font-family: 'Source Sans 3', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
max-width: 480px;
margin: 0 auto;
position: relative;
overflow-x: hidden;
}
h1, h2, h3, .display { font-family: 'Playfair Display', serif; }
/* ── Header ── */
.header {
padding: 24px 20px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
background: var(--bg);
z-index: 50;
border-bottom: 1px solid var(--border);
}
.header h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.3px; }
.header-sub { font-size: 12px; color: var(--text3); font-weight: 400; letter-spacing: 1.5px; text-transform: uppercase; }
/* ── Navigation ── */
.nav {
display: flex;
gap: 4px;
padding: 8px 20px;
background: var(--bg);
position: sticky;
top: 73px;
z-index: 49;
}
.nav-btn {
flex: 1;
padding: 10px 8px;
border: none;
background: transparent;
color: var(--text3);
font-family: 'Source Sans 3', sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s;
letter-spacing: 0.3px;
}
.nav-btn.active {
background: var(--text);
color: var(--bg);
font-weight: 600;
}
.nav-btn:hover:not(.active) { background: var(--bg2); color: var(--text); }
/* ── Content ── */
.content { padding: 16px 20px 100px; }
/* ── Cards ── */
.card {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px;
margin-bottom: 12px;
border: 1px solid var(--border);
transition: box-shadow 0.2s;
cursor: pointer;
}
.card:hover { box-shadow: var(--shadow-lg); }
.bean-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
.bean-name { font-family: 'Playfair Display', serif; font-size: 17px; font-weight: 600; }
.bean-roastery { font-size: 13px; color: var(--text2); margin-top: 2px; }
.bean-meta { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: 20px;
background: var(--bg2);
color: var(--text2);
font-weight: 500;
letter-spacing: 0.3px;
}
.tag.roast-light { background: #FFF3D6; color: #8B6914; }
.tag.roast-medium { background: #F0E0C8; color: #6B4E2A; }
.tag.roast-dark { background: #E0D0BD; color: #4A3520; }
.brew-card { position: relative; }
.brew-method-bar {
position: absolute;
left: 0;
top: 12px;
bottom: 12px;
width: 4px;
border-radius: 0 4px 4px 0;
}
.brew-card .card { padding-left: 24px; }
.brew-date { font-size: 11px; color: var(--text3); letter-spacing: 0.5px; }
.brew-bean { font-size: 14px; font-weight: 600; color: var(--text); margin-top: 2px; }
.brew-method-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-top: 6px; }
.brew-details { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }
.brew-detail { font-size: 12px; }
.brew-detail-label { color: var(--text3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.brew-detail-value { font-weight: 600; margin-top: 1px; }
.brew-notes { font-size: 13px; color: var(--text2); margin-top: 10px; font-style: italic; line-height: 1.5; }
/* ── Empty State ── */
.empty {
text-align: center;
padding: 48px 20px;
color: var(--text3);
}
.empty-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
.empty h3 { font-size: 16px; margin-bottom: 6px; color: var(--text2); }
.empty p { font-size: 13px; line-height: 1.5; }
/* ── Stats row ── */
.stats {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 12px;
text-align: center;
}
.stat-num { font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 700; }
.stat-label { font-size: 10px; color: var(--text3); text-transform: uppercase; letter-spacing: 1px; margin-top: 2px; }
/* ── Forms ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(44,24,16,0.4);
z-index: 100;
display: flex;
align-items: flex-end;
justify-content: center;
animation: fadeIn 0.2s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes pulse { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } }
.modal {
background: var(--bg);
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 480px;
max-height: 88vh;
overflow-y: auto;
animation: slideUp 0.3s ease-out;
padding: 0 20px 32px;
}
.modal-handle {
width: 36px;
height: 4px;
background: var(--border);
border-radius: 4px;
margin: 12px auto 16px;
}
.modal-title {
font-family: 'Playfair Display', serif;
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
}
.form-group { margin-bottom: 16px; }
.form-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text2);
margin-bottom: 6px;
display: block;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
color: var(--text);
transition: border-color 0.2s;
outline: none;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--accent);
}
.form-textarea { resize: vertical; min-height: 80px; }
.form-row { display: flex; gap: 10px; }
.form-row > .form-group { flex: 1; }
.form-hint { font-size: 11px; color: var(--text3); margin-top: 4px; }
.method-tabs {
display: flex;
gap: 6px;
margin-bottom: 20px;
}
.method-tab {
flex: 1;
padding: 12px 8px;
border: 2px solid var(--border);
background: var(--card);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: center;
font-family: 'Source Sans 3', sans-serif;
font-size: 12px;
font-weight: 600;
transition: all 0.2s;
letter-spacing: 0.3px;
}
.method-tab.active { border-color: currentColor; }
.method-tab-icon { font-size: 20px; margin-bottom: 4px; }
.btn {
width: 100%;
padding: 14px;
border: none;
border-radius: var(--radius-sm);
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.3px;
transition: opacity 0.2s;
margin-top: 8px;
}
.btn:hover { opacity: 0.9; }
.btn-primary { background: var(--text); color: var(--bg); }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text2); }
.btn-danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); font-size: 12px; padding: 10px; }
/* ── Image Upload ── */
.img-upload {
border: 2px dashed var(--border);
border-radius: var(--radius-sm);
padding: 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
position: relative;
overflow: hidden;
}
.img-upload:hover { border-color: var(--accent); background: rgba(139,105,20,0.03); }
.img-upload.has-image { padding: 0; border-style: solid; }
.img-upload-icon { font-size: 28px; margin-bottom: 6px; opacity: 0.4; }
.img-upload-text { font-size: 12px; color: var(--text3); }
.img-upload input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.img-preview {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: var(--radius-sm);
display: block;
}
.img-remove {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(44,24,16,0.7);
color: white;
border: none;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.bean-card-img {
width: 52px;
height: 52px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.bean-detail-img {
width: 100%;
height: 180px;
object-fit: cover;
border-radius: var(--radius);
margin-bottom: 16px;
}
.bean-tasting { font-size: 13px; color: var(--text2); font-style: italic; margin-top: 6px; line-height: 1.4; }
/* ── FAB ── */
.fab {
position: fixed;
bottom: 24px;
right: calc(50% - 220px);
width: 54px;
height: 54px;
border-radius: 50%;
background: var(--text);
color: var(--bg);
border: none;
font-size: 26px;
cursor: pointer;
box-shadow: var(--shadow-lg);
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.fab:hover { transform: scale(1.08); }
.fab-menu {
position: fixed;
bottom: 88px;
right: calc(50% - 220px);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 61;
animation: fadeIn 0.15s;
}
.fab-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 28px;
font-family: 'Source Sans 3', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow);
white-space: nowrap;
transition: all 0.2s;
}
.fab-option:hover { box-shadow: var(--shadow-lg); }
/* ── Section Header ── */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-title { font-size: 13px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 1px; }
/* ── Filter pills ── */
.filter-pills {
display: flex;
gap: 6px;
margin-bottom: 16px;
overflow-x: auto;
padding-bottom: 4px;
}
.filter-pill {
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--card);
font-family: 'Source Sans 3', sans-serif;
font-size: 12px;
font-weight: 500;
color: var(--text2);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.filter-pill.active {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
/* ── Detail view ── */
.detail-back {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
font-family: 'Source Sans 3', sans-serif;
font-size: 13px;
color: var(--text2);
cursor: pointer;
padding: 0;
margin-bottom: 16px;
}
.detail-section { margin-top: 20px; }
`;
// ─── Components ───
function BeanForm({ onSave, onClose, initial }) {
const [form, setForm] = useState(initial || { name: "", roastery: "", roastDate: "", roastType: "", image: "", tastingNotes: "" });
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const canSave = form.name.trim().length > 0;
const handleImage = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { alert("Image must be under 2 MB"); return; }
const reader = new FileReader();
reader.onload = () => set("image", reader.result);
reader.readAsDataURL(file);
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<div className="modal-title">{initial ? "Edit Bean" : "Add Bean"}</div>
<div className="form-group">
<label className="form-label">Bean Name *</label>
<input className="form-input" placeholder="e.g. Ethiopia Yirgacheffe" value={form.name} onChange={e => set("name", e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Roastery</label>
<input className="form-input" placeholder="e.g. Blue Tokai" value={form.roastery} onChange={e => set("roastery", e.target.value)} />
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Roast Date</label>
<input className="form-input" type="date" value={form.roastDate} onChange={e => set("roastDate", e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Roast Type</label>
<select className="form-select" value={form.roastType} onChange={e => set("roastType", e.target.value)}>
<option value="">Select</option>
{ROAST_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Bean Photo</label>
<div className={`img-upload ${form.image ? "has-image" : ""}`}>
{form.image ? (
<>
<img src={form.image} alt="Bean" className="img-preview" />
<button className="img-remove" onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button>
</>
) : (
<>
<div className="img-upload-icon">📷</div>
<div className="img-upload-text">Tap to upload a photo</div>
</>
)}
<input type="file" accept="image/*" onChange={handleImage} />
</div>
<div className="form-hint">Max 2 MB · JPG, PNG, or WebP</div>
</div>
<div className="form-group">
<label className="form-label">Tasting Notes</label>
<textarea className="form-textarea" placeholder="e.g. Citrus, dark chocolate, floral with a honey finish…" value={form.tastingNotes || ""} onChange={e => set("tastingNotes", e.target.value)} />
<div className="form-hint">Flavour profile from the bag or your own impressions</div>
</div>
<button className="btn btn-primary" disabled={!canSave} style={{ opacity: canSave ? 1 : 0.4 }} onClick={() => { if (canSave) onSave(form); }}>
{initial ? "Save Changes" : "Add to Library"}
</button>
</div>
</div>
);
}
function BrewForm({ beans, onSave, onClose }) {
const [method, setMethod] = useState("pourover");
const [form, setForm] = useState({ beanId: beans[0]?.id || "" });
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
if (beans.length === 0) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<div className="modal-title">Log a Brew</div>
<div className="empty">
<div className="empty-icon">🫘</div>
<h3>No beans yet</h3>
<p>Add a bean to your library first, then come back to log a brew.</p>
</div>
<button className="btn btn-outline" onClick={onClose}>Close</button>
</div>
</div>
);
}
const fields = {
pourover: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. 18 clicks", hint: "Relative to your grinder" },
{ key: "waterTemp", label: "Water Temp", placeholder: "e.g. 93°C" },
{ key: "beanWeight", label: "Bean Weight", placeholder: "e.g. 15g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:16" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 3:30" },
{ key: "numPours", label: "# of Pours", placeholder: "e.g. 4", type: "number" },
],
espresso: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. 8" },
{ key: "dose", label: "Dose", placeholder: "e.g. 18g" },
{ key: "yield", label: "Yield", placeholder: "e.g. 36g" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 28s" },
],
coldbrew: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. coarse" },
{ key: "beanWeight", label: "Bean Weight", placeholder: "e.g. 100g" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 700ml" },
{ key: "steepTime", label: "Steep Time", placeholder: "e.g. 18 hours" },
],
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<div className="modal-title">Log a Brew</div>
<div className="method-tabs">
{METHODS.map(m => (
<button key={m} className={`method-tab ${method === m ? "active" : ""}`}
style={{ color: method === m ? METHOD_COLORS[m] : undefined }}
onClick={() => setMethod(m)}>
<div className="method-tab-icon">{METHOD_ICONS[m]}</div>
{METHOD_LABELS[m]}
</button>
))}
</div>
<div className="form-group">
<label className="form-label">Bean *</label>
<select className="form-select" value={form.beanId} onChange={e => set("beanId", e.target.value)}>
{beans.map(b => <option key={b.id} value={b.id}>{b.name}{b.roastery ? `${b.roastery}` : ""}</option>)}
</select>
</div>
<div className="form-row" style={{ flexWrap: "wrap" }}>
{fields[method].map(f => (
<div key={f.key} className="form-group" style={{ flex: "1 1 45%", minWidth: 0 }}>
<label className="form-label">{f.label}</label>
<input className="form-input" type={f.type || "text"} placeholder={f.placeholder}
value={form[f.key] || ""} onChange={e => set(f.key, e.target.value)} />
{f.hint && <div className="form-hint">{f.hint}</div>}
</div>
))}
</div>
<div className="form-group">
<label className="form-label">{method === "pourover" ? "Recipe Details" : "Notes"}</label>
<textarea className="form-textarea" placeholder="Describe your recipe, technique, or anything notable…"
value={form.recipeDetails || ""} onChange={e => set("recipeDetails", e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">Taste Notes</label>
<input className="form-input" placeholder="e.g. citrus, chocolate, floral"
value={form.tasteNotes || ""} onChange={e => set("tasteNotes", e.target.value)} />
</div>
<button className="btn btn-primary" onClick={() => onSave({ ...form, method })}>
Save Brew Log
</button>
</div>
</div>
);
}
function BeanDetail({ bean, logs, onBack, onEdit, onDelete }) {
const beanLogs = logs.filter(l => l.beanId === bean.id).sort((a, b) => b.createdAt - a.createdAt);
const roastClass = bean.roastType?.toLowerCase().includes("dark") ? "roast-dark" : bean.roastType?.toLowerCase().includes("medium") ? "roast-medium" : "roast-light";
return (
<div>
<button className="detail-back" onClick={onBack}> Back</button>
{bean.image && <img src={bean.image} alt={bean.name} className="bean-detail-img" />}
<h2 style={{ fontFamily: "'Playfair Display', serif", fontSize: 22, marginBottom: 4 }}>{bean.name}</h2>
{bean.roastery && <div style={{ color: "var(--text2)", fontSize: 14, marginBottom: 8 }}>{bean.roastery}</div>}
{bean.tastingNotes && <div className="bean-tasting" style={{ marginBottom: 10 }}>👅 {bean.tastingNotes}</div>}
<div className="bean-meta">
{bean.roastType && <span className={`tag ${roastClass}`}>{bean.roastType}</span>}
{bean.roastDate && <span className="tag">Roasted {bean.roastDate}</span>}
<span className="tag">{beanLogs.length} brew{beanLogs.length !== 1 ? "s" : ""}</span>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button className="btn btn-outline" style={{ flex: 1 }} onClick={onEdit}>Edit</button>
<button className="btn btn-danger" style={{ flex: 1 }} onClick={onDelete}>Delete</button>
</div>
<div className="detail-section">
<div className="section-title" style={{ marginBottom: 12 }}>Brew History</div>
{beanLogs.length === 0 ? (
<div className="empty" style={{ padding: 24 }}>
<p>No brews logged with this bean yet.</p>
</div>
) : beanLogs.map(log => <BrewCard key={log.id} log={log} beanName={bean.name} />)}
</div>
</div>
);
}
function BrewCard({ log, beanName }) {
const color = METHOD_COLORS[log.method];
const allFields = Object.entries(log)
.filter(([k]) => !["id", "beanId", "method", "createdAt", "recipeDetails", "tasteNotes"].includes(k))
.filter(([, v]) => v !== "" && v != null);
const fieldLabels = {
grindSize: "Grind", waterTemp: "Temp", beanWeight: "Weight", brewRatio: "Ratio",
brewTime: "Time", numPours: "Pours", dose: "Dose", yield: "Yield",
waterVolume: "Water", steepTime: "Steep",
};
return (
<div className="brew-card" style={{ position: "relative" }}>
<div className="brew-method-bar" style={{ background: color }} />
<div className="card" style={{ cursor: "default" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div className="brew-date">{new Date(log.createdAt).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}</div>
{beanName && <div className="brew-bean">{beanName}</div>}
</div>
<div className="brew-method-label" style={{ color }}>{METHOD_ICONS[log.method]} {METHOD_LABELS[log.method]}</div>
</div>
{allFields.length > 0 && (
<div className="brew-details">
{allFields.map(([k, v]) => (
<div key={k} className="brew-detail">
<div className="brew-detail-label">{fieldLabels[k] || k}</div>
<div className="brew-detail-value">{v}</div>
</div>
))}
</div>
)}
{log.recipeDetails && <div className="brew-notes">📝 {log.recipeDetails}</div>}
{log.tasteNotes && <div className="brew-notes">👅 {log.tasteNotes}</div>}
</div>
</div>
);
}
// ─── Main App ───
export default function CoffeeLogbook() {
const { token, user, loading, logout } = useContext(AuthContext);
const [authView, setAuthView] = useState("login"); // login | register
const [data, setData] = useState(null);
const [view, setView] = useState("dashboard"); // dashboard | beans | brews
const [modal, setModal] = useState(null); // null | "addBean" | "editBean" | "addBrew"
const [fabOpen, setFabOpen] = useState(false);
const [selectedBean, setSelectedBean] = useState(null);
const [brewFilter, setBrewFilter] = useState("all");
const [editingBean, setEditingBean] = useState(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [syncing, setSyncing] = useState(false);
// Load local data on mount
useEffect(() => {
loadData().then(setData);
}, []);
// Sync logic
const syncData = useCallback(async (currentData = data) => {
if (!token || !currentData) return;
if (!navigator.onLine) {
setIsOnline(false);
return;
}
setIsOnline(true);
setSyncing(true);
try {
const { beans = [], brewLogs = [], lastSyncedAt = null } = currentData;
const res = await fetch('/api/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
lastSyncedAt,
beans,
brewLogs
})
});
if (!res.ok) {
throw new Error('Sync failed');
}
const responseData = await res.json();
const { serverTime, beans: serverBeans, brewLogs: serverLogs } = responseData;
// Merge server beans
const mergedBeans = [...beans];
serverBeans.forEach(sb => {
const idx = mergedBeans.findIndex(b => b.id === sb.id);
if (idx >= 0) {
const localBean = mergedBeans[idx];
if (new Date(sb.updatedAt) > new Date(localBean.updatedAt || 0)) {
mergedBeans[idx] = sb;
}
} else {
mergedBeans.push(sb);
}
});
// Merge server logs
const mergedLogs = [...brewLogs];
serverLogs.forEach(sl => {
const idx = mergedLogs.findIndex(l => l.id === sl.id);
if (idx >= 0) {
const localLog = mergedLogs[idx];
if (new Date(sl.updatedAt) > new Date(localLog.updatedAt || 0)) {
mergedLogs[idx] = sl;
}
} else {
mergedLogs.push(sl);
}
});
// Purge soft-deleted items that are successfully synced to the database
const finalBeans = mergedBeans.filter(b => !(b.isDeleted && new Date(b.updatedAt) <= new Date(serverTime)));
const finalLogs = mergedLogs.filter(l => !(l.isDeleted && new Date(l.updatedAt) <= new Date(serverTime)));
const nextData = {
beans: finalBeans,
brewLogs: finalLogs,
lastSyncedAt: serverTime
};
setData(nextData);
await saveData(nextData);
} catch (err) {
console.error('Failed to sync data:', err);
} finally {
setSyncing(false);
}
}, [token, data]);
// Periodic and connectivity event-driven sync triggers
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
syncData();
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Initial sync once token and data are loaded
if (token && data) {
syncData();
}
// Sync every 30 seconds
const interval = setInterval(() => {
if (token && data) {
syncData();
}
}, 30000);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
clearInterval(interval);
};
}, [token, data === null, syncData]);
const persist = useCallback(async (newData) => {
setData(newData);
await saveData(newData);
syncData(newData);
}, [syncData]);
if (loading) {
return (
<div className="app" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh" }}>
<style>{styles}</style>
<div style={{ textAlign: "center", color: "var(--text3)" }}>
<div style={{ fontSize: 32 }}></div>
<div style={{ marginTop: 8 }}>Loading</div>
</div>
</div>
);
}
// If not authenticated, show login/register
if (!token || !user) {
return (
<div className="app">
<style>{styles}</style>
{authView === "login" ? (
<>
<Login onLoginSuccess={() => {}} />
<div style={{ textAlign: "center", marginTop: "20px" }}>
<p style={{ color: "var(--text3)" }}>Don't have an account?{' '}
<button
onClick={() => setAuthView("register")}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', textDecoration: 'underline' }}
>
Register
</button>
</p>
</div>
</>
) : (
<>
<Register onRegisterSuccess={() => setAuthView("login")} />
<div style={{ textAlign: "center", marginTop: "20px" }}>
<p style={{ color: "var(--text3)" }}>Already have an account?{' '}
<button
onClick={() => setAuthView("login")}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', textDecoration: 'underline' }}
>
Login
</button>
</p>
</div>
</>
)}
</div>
);
}
if (!data) return <div className="app" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh" }}><div style={{ textAlign: "center", color: "var(--text3)" }}><div style={{ fontSize: 32 }}>☕</div><div style={{ marginTop: 8 }}>Loading…</div></div></div>;
// Filter out soft-deleted items for UI rendering
const allBeans = data.beans || [];
const allLogs = data.brewLogs || [];
const beans = allBeans.filter(b => !b.isDeleted);
const brewLogs = allLogs.filter(l => !l.isDeleted);
const addBean = (form) => {
const now = new Date().toISOString();
const bean = { id: uid(), ...form, updatedAt: now, isDeleted: false };
persist({ ...data, beans: [...allBeans, bean] });
setModal(null);
};
const updateBean = (form) => {
const now = new Date().toISOString();
const nextBeans = allBeans.map(b => b.id === editingBean.id ? { ...b, ...form, updatedAt: now } : b);
persist({ ...data, beans: nextBeans });
setModal(null);
setEditingBean(null);
if (selectedBean?.id === editingBean.id) setSelectedBean({ ...selectedBean, ...form, updatedAt: now });
};
const deleteBean = (id) => {
const now = new Date().toISOString();
const nextBeans = allBeans.map(b => b.id === id ? { ...b, isDeleted: true, updatedAt: now } : b);
const nextLogs = allLogs.map(l => l.beanId === id ? { ...l, isDeleted: true, updatedAt: now } : l);
persist({ ...data, beans: nextBeans, brewLogs: nextLogs });
setSelectedBean(null);
};
const addBrew = (form) => {
const now = new Date().toISOString();
const log = { id: uid(), createdAt: Date.now(), ...form, updatedAt: now, isDeleted: false };
persist({ ...data, brewLogs: [...allLogs, log] });
setModal(null);
};
const getBeanName = (id) => allBeans.find(b => b.id === id)?.name || "Unknown Bean";
const filteredLogs = (brewFilter === "all" ? brewLogs : brewLogs.filter(l => l.method === brewFilter))
.sort((a, b) => b.createdAt - a.createdAt);
const methodCounts = { pourover: 0, espresso: 0, coldbrew: 0 };
brewLogs.forEach(l => { if (methodCounts[l.method] !== undefined) methodCounts[l.method]++; });
return (
<div className="app">
<style>{styles}</style>
{/* Header */}
<div className="header">
<div>
<div className="header-sub">Coffee Logbook</div>
<h1>Brew Journal</h1>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
fontSize: "11px",
fontWeight: "500",
color: isOnline ? "#4A7C59" : "#B44040",
background: isOnline ? "rgba(74,124,89,0.08)" : "rgba(180,64,64,0.08)",
padding: "4px 8px",
borderRadius: "12px",
transition: "all 0.3s"
}}
title={isOnline ? "All local logs are synchronized with PostgreSQL database" : "No internet connection. Saved locally."}
>
<span style={{ fontSize: "10px", animation: syncing ? "pulse 1.5s infinite" : "none" }}>●</span>
<span>{isOnline ? (syncing ? "Syncing..." : "Synced") : "Offline"}</span>
</div>
<span style={{ fontSize: "12px", color: "var(--text2)" }}>{user?.username}</span>
<button
onClick={() => logout()}
style={{
fontSize: 28,
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px'
}}
title="Logout"
>
🚪
</button>
</div>
</div>
{/* Nav */}
<div className="nav">
<button className={`nav-btn ${view === "dashboard" ? "active" : ""}`} onClick={() => { setView("dashboard"); setSelectedBean(null); }}>Dashboard</button>
<button className={`nav-btn ${view === "beans" ? "active" : ""}`} onClick={() => { setView("beans"); setSelectedBean(null); }}>Bean Library</button>
<button className={`nav-btn ${view === "brews" ? "active" : ""}`} onClick={() => { setView("brews"); setSelectedBean(null); }}>Brew Logs</button>
</div>
{/* Content */}
<div className="content">
{/* ── Dashboard ── */}
{view === "dashboard" && (
<>
<div className="stats">
<div className="stat-card">
<div className="stat-num">{beans.length}</div>
<div className="stat-label">Beans</div>
</div>
<div className="stat-card">
<div className="stat-num">{brewLogs.length}</div>
<div className="stat-label">Brews</div>
</div>
<div className="stat-card">
<div className="stat-num">{new Set(brewLogs.map(l => l.beanId)).size}</div>
<div className="stat-label">Tried</div>
</div>
</div>
<div className="section-header">
<div className="section-title">By Method</div>
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
{METHODS.map(m => (
<div key={m} className="stat-card" style={{ borderTop: `3px solid ${METHOD_COLORS[m]}` }}>
<div style={{ fontSize: 20, marginBottom: 4 }}>{METHOD_ICONS[m]}</div>
<div className="stat-num" style={{ fontSize: 20 }}>{methodCounts[m]}</div>
<div className="stat-label">{METHOD_LABELS[m]}</div>
</div>
))}
</div>
<div className="section-header">
<div className="section-title">Recent Brews</div>
</div>
{brewLogs.length === 0 ? (
<div className="empty">
<div className="empty-icon">📝</div>
<h3>No brews yet</h3>
<p>Tap + to add a bean and start logging your brews.</p>
</div>
) : (
brewLogs.sort((a, b) => b.createdAt - a.createdAt).slice(0, 5).map(log => (
<BrewCard key={log.id} log={log} beanName={getBeanName(log.beanId)} />
))
)}
</>
)}
{/* ── Bean Library ── */}
{view === "beans" && !selectedBean && (
<>
<div className="section-header">
<div className="section-title">Your Beans ({beans.length})</div>
</div>
{beans.length === 0 ? (
<div className="empty">
<div className="empty-icon">🫘</div>
<h3>Library is empty</h3>
<p>Add your first bean to start tracking your coffee journey.</p>
</div>
) : beans.map(bean => {
const count = brewLogs.filter(l => l.beanId === bean.id).length;
const roastClass = bean.roastType?.toLowerCase().includes("dark") ? "roast-dark" : bean.roastType?.toLowerCase().includes("medium") ? "roast-medium" : "roast-light";
return (
<div key={bean.id} className="card" onClick={() => setSelectedBean(bean)}>
<div className="bean-card-header" style={{ gap: 12 }}>
{bean.image && <img src={bean.image} alt="" className="bean-card-img" />}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="bean-name">{bean.name}</div>
{bean.roastery && <div className="bean-roastery">{bean.roastery}</div>}
</div>
<span className="tag">{count} brew{count !== 1 ? "s" : ""}</span>
</div>
{bean.tastingNotes && <div className="bean-tasting">👅 {bean.tastingNotes}</div>}
<div className="bean-meta">
{bean.roastType && <span className={`tag ${roastClass}`}>{bean.roastType}</span>}
{bean.roastDate && <span className="tag">Roasted {bean.roastDate}</span>}
</div>
</div>
);
})}
</>
)}
{/* ── Bean Detail ── */}
{view === "beans" && selectedBean && (
<BeanDetail
bean={selectedBean}
logs={brewLogs}
onBack={() => setSelectedBean(null)}
onEdit={() => { setEditingBean(selectedBean); setModal("editBean"); }}
onDelete={() => { if (confirm("Delete this bean and all its brew logs?")) deleteBean(selectedBean.id); }}
/>
)}
{/* ── Brew Logs ── */}
{view === "brews" && (
<>
<div className="filter-pills">
<button className={`filter-pill ${brewFilter === "all" ? "active" : ""}`} onClick={() => setBrewFilter("all")}>All</button>
{METHODS.map(m => (
<button key={m} className={`filter-pill ${brewFilter === m ? "active" : ""}`} onClick={() => setBrewFilter(m)}>
{METHOD_ICONS[m]} {METHOD_LABELS[m]}
</button>
))}
</div>
{filteredLogs.length === 0 ? (
<div className="empty">
<div className="empty-icon">📋</div>
<h3>No logs {brewFilter !== "all" ? `for ${METHOD_LABELS[brewFilter]}` : "yet"}</h3>
<p>Start brewing and log your recipes here.</p>
</div>
) : filteredLogs.map(log => (
<BrewCard key={log.id} log={log} beanName={getBeanName(log.beanId)} />
))}
</>
)}
</div>
{/* FAB */}
<button className="fab" onClick={() => setFabOpen(!fabOpen)}>{fabOpen ? "×" : "+"}</button>
{fabOpen && (
<div className="fab-menu">
<button className="fab-option" onClick={() => { setFabOpen(false); setModal("addBrew"); }}> Log a Brew</button>
<button className="fab-option" onClick={() => { setFabOpen(false); setModal("addBean"); }}>🫘 Add Bean</button>
</div>
)}
{/* Modals */}
{modal === "addBean" && <BeanForm onSave={addBean} onClose={() => setModal(null)} />}
{modal === "editBean" && editingBean && <BeanForm initial={editingBean} onSave={updateBean} onClose={() => { setModal(null); setEditingBean(null); }} />}
{modal === "addBrew" && <BrewForm beans={beans} onSave={addBrew} onClose={() => setModal(null)} />}
</div>
);
}