feat: add page transitions

This commit is contained in:
2026-06-06 11:34:46 +05:30
parent 16c7a61032
commit afe10604c8
6 changed files with 139 additions and 31 deletions

View File

@@ -206,9 +206,10 @@ export default function CoffeeLogbook() {
{/* Content */}
<div className="px-5 pt-4 pb-36">
{/* ── Dashboard ── */}
{/* ── Dashboard ── */}
{view === "dashboard" && (
<>
<div className="animate-page-enter">
<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">
@@ -237,12 +238,12 @@ export default function CoffeeLogbook() {
) : brewLogs.sort((a, b) => b.createdAt - a.createdAt).slice(0, 5).map(log => (
<BrewCard key={log.id} log={log} beanName={getBeanName(log.beanId)} />
))}
</>
</div>
)}
{/* ── Bean Library ── */}
{view === "beans" && !selectedBean && (
<>
<div className="animate-page-enter">
<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]">
@@ -273,21 +274,23 @@ export default function CoffeeLogbook() {
</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); }}
/>
<div className="animate-page-enter">
<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); }}
/>
</div>
)}
{/* ── Brew Logs ── */}
{view === "brews" && (
<>
<div className="animate-page-enter">
<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 => (
@@ -305,12 +308,14 @@ export default function CoffeeLogbook() {
) : filteredLogs.map(log => (
<BrewCard key={log.id} log={log} beanName={getBeanName(log.beanId)} />
))}
</>
</div>
)}
{/* ── Profile ── */}
{view === "profile" && (
<ProfilePage user={user} isOnline={isOnline} syncing={syncing} showSyncedStatus={showSyncedStatus} />
<div className="animate-page-enter">
<ProfilePage user={user} isOnline={isOnline} syncing={syncing} showSyncedStatus={showSyncedStatus} />
</div>
)}
</div>

View File

@@ -1,11 +1,28 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { ROAST_TYPES, inputCls, labelCls } from "../constants";
export default function BeanForm({ onSave, onClose, initial }) {
const [form, setForm] = useState(initial || { name: "", roastery: "", roastDate: "", roastType: "", image: "", tastingNotes: "" });
const [active, setActive] = useState(false);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const canSave = form.name.trim().length > 0;
useEffect(() => {
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
if (typeof callback === "function") {
callback();
} else {
onClose();
}
}, 200);
};
const handleImage = (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -16,8 +33,14 @@ export default function BeanForm({ onSave, onClose, initial }) {
};
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={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8 ${active ? "translate-y-0" : "translate-y-full"}`}
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>
@@ -50,7 +73,7 @@ export default function BeanForm({ onSave, onClose, initial }) {
<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); }}>
onClick={() => { if (canSave) handleClose(() => onSave(form)); }}>
{initial ? "Save Changes" : "Add to Library"}
</button>
</div>

View File

@@ -1,15 +1,38 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { METHODS, METHOD_LABELS, METHOD_ICONS, METHOD_COLORS, inputCls, labelCls } from "../constants";
export default function BrewForm({ beans, onSave, onClose }) {
const [method, setMethod] = useState("pourover");
const [form, setForm] = useState({ beanId: beans[0]?.id || "" });
const [active, setActive] = useState(false);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
useEffect(() => {
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
if (typeof callback === "function") {
callback();
} else {
onClose();
}
}, 200);
};
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={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8 ${active ? "translate-y-0" : "translate-y-full"}`}
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]">
@@ -17,7 +40,7 @@ export default function BrewForm({ beans, onSave, onClose }) {
<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>
<button className="w-full py-3.5 border border-[#E8DFD3] rounded-xl text-sm font-semibold text-[#6B5744] bg-transparent cursor-pointer" onClick={() => handleClose()}>Close</button>
</div>
</div>
);
@@ -47,8 +70,14 @@ export default function BrewForm({ beans, onSave, onClose }) {
};
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={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8 ${active ? "translate-y-0" : "translate-y-full"}`}
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">
@@ -83,7 +112,7 @@ export default function BrewForm({ beans, onSave, onClose }) {
<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>
onClick={() => handleClose(() => onSave({ ...form, method }))}>Save Brew Log</button>
</div>
</div>
);

View File

@@ -1,15 +1,39 @@
import React from "react";
import React, { useState, useEffect } from "react";
export default function CreateModal({ onClose, onAddBean, onAddBrew }) {
const [active, setActive] = useState(false);
useEffect(() => {
// Trigger transition shortly after mounting
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
onClose();
if (typeof callback === "function") {
callback();
}
}, 200);
};
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={`fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] rounded-t-[28px] w-full max-w-[480px] px-5 pb-10 transition-transform duration-200 ease-in-out ${active ? "translate-y-0" : "translate-y-full"}`}
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(); }}>
onClick={() => handleClose(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>
@@ -18,7 +42,7 @@ export default function CreateModal({ onClose, onAddBean, onAddBrew }) {
</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(); }}>
onClick={() => handleClose(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>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
export default function IosPromptModal() {
const [show, setShow] = useState(false);
const [active, setActive] = useState(false);
useEffect(() => {
// Detect if user is on iOS or iPadOS
@@ -17,19 +18,30 @@ export default function IosPromptModal() {
if (isIOS && !isStandalone && !isDismissed) {
setShow(true);
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}
}, []);
if (!show) return null;
const handleClose = () => {
setActive(false);
sessionStorage.setItem("ios-prompt-dismissed", "true");
setShow(false);
setTimeout(() => {
setShow(false);
}, 200);
};
return (
<div className="fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[200] flex items-end justify-center animate-fade-in" onClick={handleClose}>
<div className="bg-[#FAF6F1] rounded-t-[28px] w-full max-w-[480px] px-6 pb-8 pt-4 animate-slide-up relative shadow-[0_-8px_30px_rgba(44,24,16,0.15)]" onClick={e => e.stopPropagation()}>
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[200] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={handleClose}
>
<div
className={`bg-[#FAF6F1] rounded-t-[28px] w-full max-w-[480px] px-6 pb-8 pt-4 relative shadow-[0_-8px_30px_rgba(44,24,16,0.15)] transition-transform duration-200 ease-in-out ${active ? "translate-y-0" : "translate-y-full"}`}
onClick={e => e.stopPropagation()}
>
{/* Handle bar */}
<div className="w-9 h-1 bg-[#E8DFD3] rounded mx-auto mb-6" />

View File

@@ -102,6 +102,21 @@
to { transform: translateY(0); }
}
.animate-page-enter {
animation: page-enter 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes page-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Brew method bar */
.brew-method-bar {
position: absolute;