Files
BrewJournal/src/App.jsx

694 lines
38 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, useRef } from "react";
import { AuthContext } from "./AuthContext";
import Login from "./Login";
import Register from "./Register";
// ─── Storage helpers ───
const STORAGE_KEY = "coffee-logbook-data";
const defaultData = { beans: [], brewLogs: [], lastSyncedAt: null };
const storage = {
get: async (key) => {
try {
const val = localStorage.getItem(key);
return val ? { value: val } : null;
} catch { return null; }
},
set: async (key, val) => {
try { 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" };
const inputCls = "w-full px-3.5 py-3 border border-[#E8DFD3] rounded-lg bg-white text-sm text-[#2C1810] transition-colors outline-none focus:border-[#8B6914]";
const labelCls = "block text-[10px] font-semibold uppercase tracking-wider text-[#6B5744] mb-1.5";
// ─── Create Modal ───
function CreateModal({ onClose, onAddBean, onAddBrew }) {
return (
<div className="fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[100] flex items-end justify-center animate-fade-in" onClick={onClose}>
<div className="bg-[#FAF6F1] rounded-t-[28px] w-full max-w-[480px] px-5 pb-10 animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="w-9 h-1 bg-[#E8DFD3] rounded mx-auto mt-3 mb-6" />
<div className="font-serif text-xl font-semibold text-[#2C1810] mb-6">What would you like to add?</div>
<div className="flex flex-col gap-3">
<button
className="flex items-center gap-4 px-5 py-4 bg-white border border-[#E8DFD3] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] transition-all active:scale-[0.98]"
onClick={() => { onClose(); onAddBrew(); }}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] flex items-center justify-center text-2xl flex-shrink-0"></div>
<div>
<div className="font-semibold text-[#2C1810] text-sm">Log a Brew</div>
<div className="text-xs text-[#9C8B7A] mt-0.5">Record a brewing session with recipe details</div>
</div>
</button>
<button
className="flex items-center gap-4 px-5 py-4 bg-white border border-[#E8DFD3] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] transition-all active:scale-[0.98]"
onClick={() => { onClose(); onAddBean(); }}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] flex items-center justify-center text-2xl flex-shrink-0">🫘</div>
<div>
<div className="font-semibold text-[#2C1810] text-sm">Add Bean</div>
<div className="text-xs text-[#9C8B7A] mt-0.5">Add a new coffee bean to your library</div>
</div>
</button>
</div>
</div>
</div>
);
}
// ─── Bean Form ───
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="fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center animate-fade-in" onClick={onClose}>
<div className="bg-[#FAF6F1] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto animate-slide-up px-5 pb-8" onClick={e => e.stopPropagation()}>
<div className="w-9 h-1 bg-[#E8DFD3] rounded mx-auto my-3" />
<div className="font-serif text-xl font-semibold text-[#2C1810] mb-5">{initial ? "Edit Bean" : "Add Bean"}</div>
<div className="mb-4"><label className={labelCls}>Bean Name *</label>
<input className={inputCls} placeholder="e.g. Ethiopia Yirgacheffe" value={form.name} onChange={e => set("name", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Roastery</label>
<input className={inputCls} placeholder="e.g. Blue Tokai" value={form.roastery} onChange={e => set("roastery", e.target.value)} /></div>
<div className="flex gap-2.5 mb-4">
<div className="flex-1"><label className={labelCls}>Roast Date</label>
<input className={inputCls} type="date" value={form.roastDate} onChange={e => set("roastDate", e.target.value)} /></div>
<div className="flex-1"><label className={labelCls}>Roast Type</label>
<select className={inputCls} 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="mb-4">
<label className={labelCls}>Bean Photo</label>
<div className={`relative border-2 rounded-lg text-center cursor-pointer transition-all overflow-hidden ${form.image ? "border-[#E8DFD3] p-0" : "border-dashed border-[#E8DFD3] p-5 hover:border-[#8B6914]"}`}>
{form.image ? (
<><img src={form.image} alt="Bean" className="w-full h-40 object-cover rounded-lg block" />
<button className="absolute top-2 right-2 w-7 h-7 rounded-full bg-[rgba(44,24,16,0.7)] text-white border-none text-sm cursor-pointer flex items-center justify-center"
onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button></>
) : (<><div className="text-3xl mb-1.5 opacity-40">📷</div><div className="text-xs text-[#9C8B7A]">Tap to upload a photo</div></>)}
<input type="file" accept="image/*" onChange={handleImage} className="absolute inset-0 opacity-0 cursor-pointer" />
</div>
<div className="text-[11px] text-[#9C8B7A] mt-1">Max 2 MB · JPG, PNG, or WebP</div>
</div>
<div className="mb-4"><label className={labelCls}>Tasting Notes</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`} placeholder="e.g. Citrus, dark chocolate, floral…" value={form.tastingNotes || ""} onChange={e => set("tastingNotes", e.target.value)} />
<div className="text-[11px] text-[#9C8B7A] mt-1">Flavour profile from the bag or your own impressions</div></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] text-[#FAF6F1] text-sm font-semibold cursor-pointer mt-2"
style={{ opacity: canSave ? 1 : 0.4, cursor: canSave ? "pointer" : "not-allowed" }} disabled={!canSave}
onClick={() => { if (canSave) onSave(form); }}>
{initial ? "Save Changes" : "Add to Library"}
</button>
</div>
</div>
);
}
// ─── Brew Form ───
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="fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center animate-fade-in" onClick={onClose}>
<div className="bg-[#FAF6F1] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto animate-slide-up px-5 pb-8" onClick={e => e.stopPropagation()}>
<div className="w-9 h-1 bg-[#E8DFD3] rounded mx-auto my-3" />
<div className="font-serif text-xl font-semibold text-[#2C1810] mb-5">Log a Brew</div>
<div className="text-center py-12 text-[#9C8B7A]">
<div className="text-4xl mb-3 opacity-50">🫘</div>
<h3 className="text-base mb-1.5 text-[#6B5744]">No beans yet</h3>
<p className="text-[13px] leading-relaxed">Add a bean to your library first.</p>
</div>
<button className="w-full py-3.5 border border-[#E8DFD3] rounded-xl text-sm font-semibold text-[#6B5744] bg-transparent cursor-pointer" 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="fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center animate-fade-in" onClick={onClose}>
<div className="bg-[#FAF6F1] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto animate-slide-up px-5 pb-8" onClick={e => e.stopPropagation()}>
<div className="w-9 h-1 bg-[#E8DFD3] rounded mx-auto my-3" />
<div className="font-serif text-xl font-semibold text-[#2C1810] mb-5">Log a Brew</div>
<div className="flex gap-1.5 mb-5">
{METHODS.map(m => (
<button key={m}
className={`flex-1 py-3 px-2 border-2 bg-white rounded-xl cursor-pointer text-center text-xs font-semibold transition-all ${method === m ? "border-current" : "border-[#E8DFD3] text-[#9C8B7A]"}`}
style={{ color: method === m ? METHOD_COLORS[m] : undefined }}
onClick={() => setMethod(m)}>
<div className="text-xl mb-1">{METHOD_ICONS[m]}</div>{METHOD_LABELS[m]}
</button>
))}
</div>
<div className="mb-4"><label className={labelCls}>Bean *</label>
<select className={inputCls} 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="flex flex-wrap gap-2.5 mb-4">
{fields[method].map(f => (
<div key={f.key} className="flex-1 min-w-[45%]">
<label className={labelCls}>{f.label}</label>
<input className={inputCls} type={f.type || "text"} placeholder={f.placeholder}
value={form[f.key] || ""} onChange={e => set(f.key, e.target.value)} />
{f.hint && <div className="text-[11px] text-[#9C8B7A] mt-1">{f.hint}</div>}
</div>
))}
</div>
<div className="mb-4"><label className={labelCls}>{method === "pourover" ? "Recipe Details" : "Notes"}</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`}
placeholder="Describe your recipe, technique, or anything notable…"
value={form.recipeDetails || ""} onChange={e => set("recipeDetails", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Taste Notes</label>
<input className={inputCls} placeholder="e.g. citrus, chocolate, floral"
value={form.tasteNotes || ""} onChange={e => set("tasteNotes", e.target.value)} /></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] text-[#FAF6F1] text-sm font-semibold cursor-pointer hover:opacity-90 mt-2"
onClick={() => onSave({ ...form, method })}>Save Brew Log</button>
</div>
</div>
);
}
// ─── Bean Detail ───
function BeanDetail({ bean, logs, onBack, onEdit, onDelete }) {
const beanLogs = logs.filter(l => l.beanId === bean.id).sort((a, b) => b.createdAt - a.createdAt);
const roastTagCls = bean.roastType?.toLowerCase().includes("dark") ? "bg-[#E0D0BD] text-[#4A3520]"
: bean.roastType?.toLowerCase().includes("medium") ? "bg-[#F0E0C8] text-[#6B4E2A]"
: "bg-[#FFF3D6] text-[#8B6914]";
return (
<div>
<button className="flex items-center gap-1.5 border-none bg-transparent text-[13px] text-[#6B5744] cursor-pointer p-0 mb-4" onClick={onBack}> Back</button>
{bean.image && <img src={bean.image} alt={bean.name} className="w-full h-44 object-cover rounded-2xl mb-4" />}
<h2 className="font-serif text-[22px] font-semibold text-[#2C1810] mb-1">{bean.name}</h2>
{bean.roastery && <div className="text-[#6B5744] text-sm mb-2">{bean.roastery}</div>}
{bean.tastingNotes && <div className="text-[13px] text-[#6B5744] italic mt-1.5 leading-snug mb-2.5">👅 {bean.tastingNotes}</div>}
<div className="flex gap-2 mt-2.5 flex-wrap">
{bean.roastType && <span className={`text-[11px] px-2.5 py-1 rounded-full font-medium ${roastTagCls}`}>{bean.roastType}</span>}
{bean.roastDate && <span className="text-[11px] px-2.5 py-1 rounded-full bg-[#F3EDE4] text-[#6B5744] font-medium">Roasted {bean.roastDate}</span>}
<span className="text-[11px] px-2.5 py-1 rounded-full bg-[#F3EDE4] text-[#6B5744] font-medium">{beanLogs.length} brew{beanLogs.length !== 1 ? "s" : ""}</span>
</div>
<div className="flex gap-2 mt-4">
<button className="flex-1 py-2.5 border border-[#E8DFD3] rounded-xl text-sm font-semibold text-[#6B5744] bg-transparent cursor-pointer hover:bg-[#F3EDE4] transition-colors" onClick={onEdit}>Edit</button>
<button className="flex-1 py-2.5 border border-[#B44040] rounded-xl text-xs font-semibold text-[#B44040] bg-transparent cursor-pointer" onClick={onDelete}>Delete</button>
</div>
<div className="mt-5">
<div className="text-[13px] font-semibold text-[#6B5744] uppercase tracking-widest mb-3">Brew History</div>
{beanLogs.length === 0 ? (
<div className="text-center py-6 text-[#9C8B7A]"><p className="text-[13px]">No brews logged with this bean yet.</p></div>
) : beanLogs.map(log => <BrewCard key={log.id} log={log} beanName={bean.name} />)}
</div>
</div>
);
}
// ─── Brew Card ───
function BrewCard({ log, beanName }) {
const color = METHOD_COLORS[log.method];
const allFields = Object.entries(log)
.filter(([k]) => !["id", "beanId", "method", "createdAt", "recipeDetails", "tasteNotes", "updatedAt", "isDeleted"].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="relative mb-3">
<div className="brew-method-bar" style={{ background: color }} />
<div className="bg-white rounded-xl shadow-[0_1px_3px_rgba(44,24,16,0.06),0_4px_12px_rgba(44,24,16,0.04)] p-[18px] pl-6 border border-[#E8DFD3]">
<div className="flex justify-between items-start">
<div>
<div className="text-[11px] text-[#9C8B7A] tracking-wide">{new Date(log.createdAt).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}</div>
{beanName && <div className="text-sm font-semibold text-[#2C1810] mt-0.5">{beanName}</div>}
</div>
<div className="text-[11px] font-semibold uppercase tracking-widest" style={{ color }}>{METHOD_ICONS[log.method]} {METHOD_LABELS[log.method]}</div>
</div>
{allFields.length > 0 && (
<div className="flex flex-wrap gap-3 mt-2.5">
{allFields.map(([k, v]) => (
<div key={k} className="text-xs">
<div className="text-[#9C8B7A] text-[10px] uppercase tracking-wide">{fieldLabels[k] || k}</div>
<div className="font-semibold mt-0.5">{v}</div>
</div>
))}
</div>
)}
{log.recipeDetails && <div className="text-[13px] text-[#6B5744] mt-2.5 italic leading-relaxed">📝 {log.recipeDetails}</div>}
{log.tasteNotes && <div className="text-[13px] text-[#6B5744] mt-1.5 italic leading-relaxed">👅 {log.tasteNotes}</div>}
</div>
</div>
);
}
// ─── Profile Page ───
function ProfilePage({ user, isOnline, syncing, showSyncedStatus }) {
const { logout } = useContext(AuthContext);
return (
<div>
{/* Avatar & Name */}
<div className="flex flex-col items-center pt-6 pb-8">
<div className="w-20 h-20 rounded-full bg-[#2C1810] flex items-center justify-center text-3xl text-[#FAF6F1] font-serif font-bold mb-3">
{user?.username?.[0]?.toUpperCase() || "☕"}
</div>
<div className="font-serif text-xl font-semibold text-[#2C1810]">{user?.username}</div>
<div className="text-sm text-[#9C8B7A] mt-0.5">{user?.email}</div>
{showSyncedStatus && (
<div className={`flex items-center gap-1.5 text-[11px] font-medium px-3 py-1.5 rounded-full mt-3 transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)]"}`}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing…" : "All data synced") : "Offline — saved locally"}</span>
</div>
)}
</div>
{/* Account section */}
<div className="mb-6">
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] mb-2 px-1">Account</div>
<div className="bg-white rounded-2xl border border-[#E8DFD3] overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-[#F3EDE4]">
<span className="text-lg">👤</span>
<div>
<div className="text-xs text-[#9C8B7A]">Username</div>
<div className="text-sm font-medium text-[#2C1810]">{user?.username}</div>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3.5">
<span className="text-lg"></span>
<div>
<div className="text-xs text-[#9C8B7A]">Email</div>
<div className="text-sm font-medium text-[#2C1810]">{user?.email}</div>
</div>
</div>
</div>
</div>
{/* App info */}
<div className="mb-6">
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] mb-2 px-1">App</div>
<div className="bg-white rounded-2xl border border-[#E8DFD3] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3.5 border-b border-[#F3EDE4]">
<div className="flex items-center gap-3">
<span className="text-lg"></span>
<div className="text-sm font-medium text-[#2C1810]">Brew Journal</div>
</div>
<div className="text-xs text-[#9C8B7A] font-mono">{__APP_VERSION__}</div>
</div>
<div className="flex items-center gap-3 px-4 py-3.5">
<span className="text-lg">🗄</span>
<div>
<div className="text-xs text-[#9C8B7A]">Storage</div>
<div className="text-sm font-medium text-[#2C1810]">Local + PostgreSQL</div>
</div>
</div>
</div>
</div>
{/* Sign out */}
<button
onClick={() => logout()}
className="w-full py-3.5 border border-[#B44040] rounded-2xl text-sm font-semibold text-[#B44040] bg-transparent cursor-pointer hover:bg-[rgba(180,64,64,0.05)] transition-colors">
Sign Out
</button>
</div>
);
}
// ─── Bottom Nav ───
function BottomNav({ view, setView, setSelectedBean, onCreatePress }) {
const items = [
{ id: "dashboard", label: "Home", icon: "🏠" },
{ id: "beans", label: "Recipes", icon: "🫘" },
{ id: "create", label: "Create", icon: "", isAction: true },
{ id: "brews", label: "Logs", icon: "📋" },
{ id: "profile", label: "Profile", icon: "👤" },
];
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 w-[calc(100%-32px)] max-w-[448px] z-[60]">
<div className="bg-white/90 backdrop-blur-md border border-[#E8DFD3] rounded-[28px] shadow-[0_8px_32px_rgba(44,24,16,0.12),0_2px_8px_rgba(44,24,16,0.08)] flex items-center px-2 py-2">
{items.map(item => {
const isActive = view === item.id;
if (item.isAction) {
return (
<button
key={item.id}
onClick={onCreatePress}
className="flex-1 flex flex-col items-center justify-center mx-1">
<div className="w-12 h-12 rounded-full bg-[#2C1810] flex items-center justify-center text-[#FAF6F1] text-2xl font-light shadow-[0_4px_12px_rgba(44,24,16,0.25)] transition-transform active:scale-90 hover:scale-105">
</div>
</button>
);
}
return (
<button
key={item.id}
onClick={() => { setView(item.id); setSelectedBean(null); }}
className={`flex-1 flex flex-col items-center gap-0.5 py-1.5 rounded-[20px] cursor-pointer border-none transition-all ${isActive ? "bg-[#FAF6F1]" : "bg-transparent"}`}>
<span className={`text-xl transition-transform ${isActive ? "scale-110" : "scale-100 opacity-50"}`}>{item.icon}</span>
<span className={`text-[10px] font-semibold tracking-wide transition-colors ${isActive ? "text-[#2C1810]" : "text-[#9C8B7A]"}`}>{item.label}</span>
</button>
);
})}
</div>
</div>
);
}
// ─── Main App ───
export default function CoffeeLogbook() {
const { token, user, loading, logout } = useContext(AuthContext);
const [authView, setAuthView] = useState("login");
const [data, setData] = useState(null);
const [view, setView] = useState("dashboard");
const [modal, setModal] = useState(null);
const [createOpen, setCreateOpen] = 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);
const [showSyncedStatus, setShowSyncedStatus] = useState(false);
useEffect(() => { loadData().then(setData); }, []);
const dataRef = useRef(data);
dataRef.current = data;
const syncData = useCallback(async (currentData = dataRef.current) => {
if (!token || !currentData) return;
if (!navigator.onLine) { setIsOnline(false); setShowSyncedStatus(true); return; }
setIsOnline(true); setSyncing(true); setShowSyncedStatus(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 { serverTime, beans: serverBeans, brewLogs: serverLogs } = await res.json();
const mergedBeans = [...beans];
serverBeans.forEach(sb => { const i = mergedBeans.findIndex(b => b.id === sb.id); i >= 0 ? (new Date(sb.updatedAt) > new Date(mergedBeans[i].updatedAt || 0) && (mergedBeans[i] = sb)) : mergedBeans.push(sb); });
const mergedLogs = [...brewLogs];
serverLogs.forEach(sl => { const i = mergedLogs.findIndex(l => l.id === sl.id); i >= 0 ? (new Date(sl.updatedAt) > new Date(mergedLogs[i].updatedAt || 0) && (mergedLogs[i] = sl)) : mergedLogs.push(sl); });
const nextData = {
beans: mergedBeans.filter(b => !(b.isDeleted && new Date(b.updatedAt) <= new Date(serverTime))),
brewLogs: mergedLogs.filter(l => !(l.isDeleted && new Date(l.updatedAt) <= new Date(serverTime))),
lastSyncedAt: serverTime
};
setData(nextData); await saveData(nextData);
} catch (err) { console.error('Failed to sync data:', err); }
finally {
setSyncing(false);
if (navigator.onLine) setTimeout(() => setShowSyncedStatus(false), 2500);
else setShowSyncedStatus(true);
}
}, [token]);
useEffect(() => {
const handleOnline = () => { setIsOnline(true); syncData(); };
const handleOffline = () => { setIsOnline(false); setShowSyncedStatus(true); };
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
if (token && data) syncData();
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]);
const LoadingScreen = () => (
<div className="w-full max-w-[480px] mx-auto min-h-screen border-x border-[#E8DFD3] bg-[#FAF6F1] max-sm:border-x-0 flex items-center justify-center">
<div className="text-center text-[#9C8B7A]"><div className="text-4xl"></div><div className="mt-2 text-sm">Loading</div></div>
</div>
);
if (loading) return <LoadingScreen />;
if (!token || !user) {
return (
<div className="w-full max-w-[480px] mx-auto min-h-screen border-x border-[#E8DFD3] bg-[#FAF6F1] max-sm:border-x-0">
{authView === "login" ? (
<>
<Login onLoginSuccess={() => {}} />
<div className="text-center mt-5 px-5">
<p className="text-[#9C8B7A] text-sm">Don't have an account?{' '}
<button onClick={() => setAuthView("register")} className="border-none bg-transparent text-[#8B6914] cursor-pointer underline text-sm">Register</button>
</p>
</div>
</>
) : (
<>
<Register onRegisterSuccess={() => setAuthView("login")} />
<div className="text-center mt-5 px-5">
<p className="text-[#9C8B7A] text-sm">Already have an account?{' '}
<button onClick={() => setAuthView("login")} className="border-none bg-transparent text-[#8B6914] cursor-pointer underline text-sm">Login</button>
</p>
</div>
</>
)}
</div>
);
}
if (!data) return <LoadingScreen />;
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) => { persist({ ...data, beans: [...allBeans, { id: uid(), ...form, updatedAt: new Date().toISOString(), isDeleted: false }] }); setModal(null); };
const updateBean = (form) => {
const now = new Date().toISOString();
persist({ ...data, beans: allBeans.map(b => b.id === editingBean.id ? { ...b, ...form, updatedAt: now } : b) });
setModal(null); setEditingBean(null);
if (selectedBean?.id === editingBean.id) setSelectedBean({ ...selectedBean, ...form, updatedAt: now });
};
const deleteBean = (id) => {
const now = new Date().toISOString();
persist({ ...data, beans: allBeans.map(b => b.id === id ? { ...b, isDeleted: true, updatedAt: now } : b), brewLogs: allLogs.map(l => l.beanId === id ? { ...l, isDeleted: true, updatedAt: now } : l) });
setSelectedBean(null);
};
const addBrew = (form) => { persist({ ...data, brewLogs: [...allLogs, { id: uid(), createdAt: Date.now(), ...form, updatedAt: new Date().toISOString(), isDeleted: false }] }); 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]++; });
const filterPillCls = (active) => `px-3.5 py-1.5 rounded-full border text-xs font-medium whitespace-nowrap cursor-pointer transition-all ${active ? "bg-[#2C1810] text-[#FAF6F1] border-[#2C1810]" : "bg-white border-[#E8DFD3] text-[#6B5744]"}`;
// Page title per view
const pageTitles = { dashboard: "Brew Journal", beans: "Bean Recipes", brews: "Brew Logs", profile: "Profile" };
const pageSubtitles = { dashboard: "Coffee Logbook", beans: "Your Collection", brews: "All Sessions", profile: "Account" };
return (
<div className="w-full max-w-[480px] mx-auto min-h-screen bg-[#FAF6F1] border-x border-[#E8DFD3] shadow-[0_0_30px_rgba(44,24,16,0.05)] max-sm:border-x-0 max-sm:shadow-none font-sans relative">
{/* Header */}
<div className="px-5 pt-6 pb-4 flex items-center justify-between sticky top-0 bg-[#FAF6F1]/95 backdrop-blur-sm z-50 border-b border-[#E8DFD3]">
<div>
<div className="text-[10px] text-[#9C8B7A] font-semibold tracking-[1.5px] uppercase">{pageSubtitles[view] || "Coffee Logbook"}</div>
<h1 className="font-serif text-[21px] font-semibold tracking-tight text-[#2C1810] mt-0.5">{pageTitles[view] || "Brew Journal"}</h1>
</div>
{showSyncedStatus && (
<div className={`flex items-center gap-1 text-[11px] font-medium px-2.5 py-1 rounded-full transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)]"}`}
title={isOnline ? "Synchronized" : "Offline"}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing..." : "Synced") : "Offline"}</span>
</div>
)}
</div>
{/* Content */}
<div className="px-5 pt-4 pb-36">
{/* ── Dashboard ── */}
{view === "dashboard" && (
<>
<div className="flex gap-2 mb-5">
{[{ num: beans.length, label: "Beans" }, { num: brewLogs.length, label: "Brews" }, { num: new Set(brewLogs.map(l => l.beanId)).size, label: "Tried" }].map(s => (
<div key={s.label} className="flex-1 bg-white border border-[#E8DFD3] rounded-2xl p-3.5 text-center">
<div className="font-serif text-2xl font-bold text-[#2C1810]">{s.num}</div>
<div className="text-[10px] text-[#9C8B7A] uppercase tracking-widest mt-0.5">{s.label}</div>
</div>
))}
</div>
<div className="text-[13px] font-semibold text-[#6B5744] uppercase tracking-widest mb-3">By Method</div>
<div className="flex gap-2 mb-6">
{METHODS.map(m => (
<div key={m} className="flex-1 bg-white border border-[#E8DFD3] rounded-2xl p-3 text-center" style={{ borderTop: `3px solid ${METHOD_COLORS[m]}` }}>
<div className="text-xl mb-1">{METHOD_ICONS[m]}</div>
<div className="font-serif text-xl font-bold text-[#2C1810]">{methodCounts[m]}</div>
<div className="text-[10px] text-[#9C8B7A] uppercase tracking-widest mt-0.5">{METHOD_LABELS[m]}</div>
</div>
))}
</div>
<div className="text-[13px] font-semibold text-[#6B5744] uppercase tracking-widest mb-3">Recent Brews</div>
{brewLogs.length === 0 ? (
<div className="text-center py-12 text-[#9C8B7A]">
<div className="text-4xl mb-3 opacity-50">📝</div>
<h3 className="text-base mb-1.5 font-serif text-[#6B5744]">No brews yet</h3>
<p className="text-[13px] leading-relaxed">Tap the + button to log your first brew.</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="text-[13px] font-semibold text-[#6B5744] uppercase tracking-widest mb-3">Your Beans ({beans.length})</div>
{beans.length === 0 ? (
<div className="text-center py-12 text-[#9C8B7A]">
<div className="text-4xl mb-3 opacity-50">🫘</div>
<h3 className="text-base mb-1.5 font-serif text-[#6B5744]">Library is empty</h3>
<p className="text-[13px] leading-relaxed">Tap + to add your first coffee bean.</p>
</div>
) : beans.map(bean => {
const count = brewLogs.filter(l => l.beanId === bean.id).length;
const roastTagCls = bean.roastType?.toLowerCase().includes("dark") ? "bg-[#E0D0BD] text-[#4A3520]" : bean.roastType?.toLowerCase().includes("medium") ? "bg-[#F0E0C8] text-[#6B4E2A]" : "bg-[#FFF3D6] text-[#8B6914]";
return (
<div key={bean.id}
className="bg-white rounded-2xl shadow-[0_1px_3px_rgba(44,24,16,0.06),0_4px_12px_rgba(44,24,16,0.04)] p-[18px] mb-3 border border-[#E8DFD3] cursor-pointer transition-all hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] active:scale-[0.99]"
onClick={() => setSelectedBean(bean)}>
<div className="flex justify-between items-start gap-3 mb-2">
{bean.image && <img src={bean.image} alt="" className="w-14 h-14 rounded-xl object-cover flex-shrink-0" />}
<div className="flex-1 min-w-0">
<div className="font-serif text-[17px] font-semibold text-[#2C1810]">{bean.name}</div>
{bean.roastery && <div className="text-[13px] text-[#6B5744] mt-0.5">{bean.roastery}</div>}
</div>
<span className="text-[11px] px-2.5 py-1 rounded-full bg-[#F3EDE4] text-[#6B5744] font-medium flex-shrink-0">{count} brew{count !== 1 ? "s" : ""}</span>
</div>
{bean.tastingNotes && <div className="text-[13px] text-[#6B5744] italic mt-1.5 leading-snug">👅 {bean.tastingNotes}</div>}
<div className="flex gap-2 mt-2.5 flex-wrap">
{bean.roastType && <span className={`text-[11px] px-2.5 py-1 rounded-full font-medium ${roastTagCls}`}>{bean.roastType}</span>}
{bean.roastDate && <span className="text-[11px] px-2.5 py-1 rounded-full bg-[#F3EDE4] text-[#6B5744] font-medium">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="flex gap-1.5 mb-4 overflow-x-auto pb-1">
<button className={filterPillCls(brewFilter === "all")} onClick={() => setBrewFilter("all")}>All</button>
{METHODS.map(m => (
<button key={m} className={filterPillCls(brewFilter === m)} onClick={() => setBrewFilter(m)}>
{METHOD_ICONS[m]} {METHOD_LABELS[m]}
</button>
))}
</div>
{filteredLogs.length === 0 ? (
<div className="text-center py-12 text-[#9C8B7A]">
<div className="text-4xl mb-3 opacity-50">📋</div>
<h3 className="text-base mb-1.5 font-serif text-[#6B5744]">No logs {brewFilter !== "all" ? `for ${METHOD_LABELS[brewFilter]}` : "yet"}</h3>
<p className="text-[13px] leading-relaxed">Start brewing and log your recipes here.</p>
</div>
) : filteredLogs.map(log => (
<BrewCard key={log.id} log={log} beanName={getBeanName(log.beanId)} />
))}
</>
)}
{/* ── Profile ── */}
{view === "profile" && (
<ProfilePage user={user} isOnline={isOnline} syncing={syncing} showSyncedStatus={showSyncedStatus} />
)}
</div>
{/* Floating Bottom Navigation */}
<BottomNav
view={view}
setView={setView}
setSelectedBean={setSelectedBean}
onCreatePress={() => setCreateOpen(true)}
/>
{/* Create Modal */}
{createOpen && (
<CreateModal
onClose={() => setCreateOpen(false)}
onAddBean={() => setModal("addBean")}
onAddBrew={() => setModal("addBrew")}
/>
)}
{/* 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>
);
}