feat: add page transitions
This commit is contained in:
29
src/App.jsx
29
src/App.jsx
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user