refactor: Made modal a reusable component
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
This commit is contained in:
@@ -1,59 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { ROAST_TYPES, inputCls, labelCls } from "../constants";
|
||||
import Modal from "./Modal";
|
||||
|
||||
export default function BeanForm({ onSave, onClose, initial }) {
|
||||
const [form, setForm] = useState(initial || { name: "", roastery: "", roastDate: "", roastType: "", image: "", tastingNotes: "" });
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startY, setStartY] = useState(0);
|
||||
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
|
||||
const canSave = form.name.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent background scroll when mounted
|
||||
document.body.style.overflow = "hidden";
|
||||
const raf = requestAnimationFrame(() => setActive(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = (callback) => {
|
||||
setActive(false);
|
||||
setTimeout(() => {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleDragStart = (clientY) => {
|
||||
setIsDragging(true);
|
||||
setStartY(clientY);
|
||||
};
|
||||
|
||||
const handleDragMove = (clientY) => {
|
||||
if (!isDragging) return;
|
||||
const deltaY = clientY - startY;
|
||||
if (deltaY > 0) {
|
||||
setDragY(deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
if (dragY > 100) {
|
||||
handleClose();
|
||||
} else {
|
||||
setDragY(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImage = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -64,71 +17,45 @@ 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 transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => handleClose()}
|
||||
>
|
||||
<div
|
||||
className={`bg-[#FAF6F1] dark:bg-[#150B07] 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`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
transform: isDragging
|
||||
? `translateY(${dragY}px)`
|
||||
: active
|
||||
? `translateY(${dragY}px)`
|
||||
: "translateY(100%)",
|
||||
transition: isDragging ? "none" : "transform 0.2s ease-out"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
|
||||
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
|
||||
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onMouseDown={(e) => handleDragStart(e.clientY)}
|
||||
onMouseMove={(e) => {
|
||||
if (e.buttons === 1) handleDragMove(e.clientY);
|
||||
}}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragEnd}
|
||||
>
|
||||
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
|
||||
</div>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] 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] dark:border-[#3B2217] p-0" : "border-dashed border-[#E8DFD3] dark:border-[#3B2217] p-5 hover:border-[#8B6914] dark:hover:border-[#D4A325]"}`}>
|
||||
{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)] dark:bg-[rgba(250,246,241,0.8)] text-white dark:text-[#2C1810] border-none text-sm cursor-pointer flex items-center justify-center font-bold"
|
||||
onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button></>
|
||||
) : (<><div className="text-3xl mb-1.5 opacity-40 dark:opacity-75">📷</div><div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Tap to upload a photo</div></>)}
|
||||
<input type="file" accept="image/*" onChange={handleImage} className="absolute inset-0 opacity-0 cursor-pointer" />
|
||||
<Modal onClose={onClose}>
|
||||
{(close) => (
|
||||
<>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] 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="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] 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] dark:text-[#C8B9A6] 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] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer mt-2"
|
||||
style={{ opacity: canSave ? 1 : 0.4, cursor: canSave ? "pointer" : "not-allowed" }} disabled={!canSave}
|
||||
onClick={() => { if (canSave) handleClose(() => onSave(form)); }}>
|
||||
{initial ? "Save Changes" : "Add to Library"}
|
||||
</button>
|
||||
</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] dark:border-[#3B2217] p-0" : "border-dashed border-[#E8DFD3] dark:border-[#3B2217] p-5 hover:border-[#8B6914] dark:hover:border-[#D4A325]"}`}>
|
||||
{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)] dark:bg-[rgba(250,246,241,0.8)] text-white dark:text-[#2C1810] border-none text-sm cursor-pointer flex items-center justify-center font-bold"
|
||||
onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button></>
|
||||
) : (<><div className="text-3xl mb-1.5 opacity-40 dark:opacity-75">📷</div><div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">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] dark:text-[#C8B9A6] 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] dark:text-[#C8B9A6] 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] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer mt-2"
|
||||
style={{ opacity: canSave ? 1 : 0.4, cursor: canSave ? "pointer" : "not-allowed" }} disabled={!canSave}
|
||||
onClick={() => { if (canSave) close(() => onSave(form)); }}>
|
||||
{initial ? "Save Changes" : "Add to Library"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +1,27 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { METHODS, METHOD_LABELS, METHOD_ICONS, METHOD_COLORS, inputCls, labelCls } from "../constants";
|
||||
import Modal from "./Modal";
|
||||
|
||||
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 [dragY, setDragY] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startY, setStartY] = useState(0);
|
||||
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent background scroll when mounted
|
||||
document.body.style.overflow = "hidden";
|
||||
const raf = requestAnimationFrame(() => setActive(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = (callback) => {
|
||||
setActive(false);
|
||||
setTimeout(() => {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleDragStart = (clientY) => {
|
||||
setIsDragging(true);
|
||||
setStartY(clientY);
|
||||
};
|
||||
|
||||
const handleDragMove = (clientY) => {
|
||||
if (!isDragging) return;
|
||||
const deltaY = clientY - startY;
|
||||
if (deltaY > 0) {
|
||||
setDragY(deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
if (dragY > 100) {
|
||||
handleClose();
|
||||
} else {
|
||||
setDragY(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (beans.length === 0) {
|
||||
return (
|
||||
<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] dark:bg-[#150B07] 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`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
transform: isDragging
|
||||
? `translateY(${dragY}px)`
|
||||
: active
|
||||
? `translateY(${dragY}px)`
|
||||
: "translateY(100%)",
|
||||
transition: isDragging ? "none" : "transform 0.2s ease-out"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
|
||||
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
|
||||
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onMouseDown={(e) => handleDragStart(e.clientY)}
|
||||
onMouseMove={(e) => {
|
||||
if (e.buttons === 1) handleDragMove(e.clientY);
|
||||
}}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragEnd}
|
||||
>
|
||||
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
|
||||
</div>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
|
||||
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
|
||||
<div className="text-4xl mb-3 opacity-50 dark:opacity-80">🫘</div>
|
||||
<h3 className="text-base mb-1.5 text-[#6B5744] dark:text-[#FAF6F1]">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] dark:border-[#3B2217] rounded-xl text-sm font-semibold text-[#6B5744] dark:text-[#C8B9A6] bg-transparent cursor-pointer hover:bg-[#F3EDE4] dark:hover:bg-[#2C1810]" onClick={() => handleClose()}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal onClose={onClose}>
|
||||
{(close) => (
|
||||
<>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
|
||||
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
|
||||
<div className="text-4xl mb-3 opacity-50 dark:opacity-80">🫘</div>
|
||||
<h3 className="text-base mb-1.5 text-[#6B5744] dark:text-[#FAF6F1]">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] dark:border-[#3B2217] rounded-xl text-sm font-semibold text-[#6B5744] dark:text-[#C8B9A6] bg-transparent cursor-pointer hover:bg-[#F3EDE4] dark:hover:bg-[#2C1810]" onClick={() => close()}>Close</button>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,71 +49,45 @@ 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 transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => handleClose()}
|
||||
>
|
||||
<div
|
||||
className={`bg-[#FAF6F1] dark:bg-[#150B07] 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`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
transform: isDragging
|
||||
? `translateY(${dragY}px)`
|
||||
: active
|
||||
? `translateY(${dragY}px)`
|
||||
: "translateY(100%)",
|
||||
transition: isDragging ? "none" : "transform 0.2s ease-out"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
|
||||
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
|
||||
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onMouseDown={(e) => handleDragStart(e.clientY)}
|
||||
onMouseMove={(e) => {
|
||||
if (e.buttons === 1) handleDragMove(e.clientY);
|
||||
}}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragEnd}
|
||||
>
|
||||
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
|
||||
</div>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] 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 dark:bg-[#22120B] rounded-xl cursor-pointer text-center text-xs font-semibold transition-all ${method === m ? "border-current" : "border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6]"}`}
|
||||
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} className="dark:bg-[#150B07]">{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] dark:text-[#C8B9A6] 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] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer hover:opacity-90 mt-2"
|
||||
onClick={() => handleClose(() => onSave({ ...form, method }))}>Save Brew Log</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal onClose={onClose}>
|
||||
{(close) => (
|
||||
<>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] 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 dark:bg-[#22120B] rounded-xl cursor-pointer text-center text-xs font-semibold transition-all ${method === m ? "border-current" : "border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6]"}`}
|
||||
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} className="dark:bg-[#150B07]">{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] dark:text-[#C8B9A6] 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] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer hover:opacity-90 mt-2"
|
||||
onClick={() => close(() => onSave({ ...form, method }))}>Save Brew Log</button>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,108 +1,33 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Modal from "./Modal";
|
||||
|
||||
export default function CreateModal({ onClose, onAddBean, onAddBrew }) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startY, setStartY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent background scroll when mounted
|
||||
document.body.style.overflow = "hidden";
|
||||
// Trigger transition shortly after mounting
|
||||
const raf = requestAnimationFrame(() => setActive(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = (callback) => {
|
||||
setActive(false);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleDragStart = (clientY) => {
|
||||
setIsDragging(true);
|
||||
setStartY(clientY);
|
||||
};
|
||||
|
||||
const handleDragMove = (clientY) => {
|
||||
if (!isDragging) return;
|
||||
const deltaY = clientY - startY;
|
||||
if (deltaY > 0) {
|
||||
setDragY(deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
if (dragY > 100) {
|
||||
handleClose();
|
||||
} else {
|
||||
setDragY(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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] dark:bg-[#150B07] rounded-t-[28px] w-full max-w-[480px] px-5 pb-10 transition-transform duration-200 ease-in-out`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
transform: isDragging
|
||||
? `translateY(${dragY}px)`
|
||||
: active
|
||||
? `translateY(${dragY}px)`
|
||||
: "translateY(100%)",
|
||||
transition: isDragging ? "none" : "transform 0.2s ease-out"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
|
||||
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
|
||||
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onMouseDown={(e) => handleDragStart(e.clientY)}
|
||||
onMouseMove={(e) => {
|
||||
if (e.buttons === 1) handleDragMove(e.clientY);
|
||||
}}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragEnd}
|
||||
>
|
||||
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
|
||||
</div>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] 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 dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
|
||||
onClick={() => handleClose(onAddBrew)}>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">☕</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Log a Brew</div>
|
||||
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] 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 dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
|
||||
onClick={() => handleClose(onAddBean)}>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">🫘</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Add Bean</div>
|
||||
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Add a new coffee bean to your library</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal onClose={onClose} padding="px-5 pb-10">
|
||||
{(close) => (
|
||||
<>
|
||||
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] 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 dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
|
||||
onClick={() => close(onAddBrew)}>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">☕</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Log a Brew</div>
|
||||
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] 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 dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
|
||||
onClick={() => close(onAddBean)}>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">🫘</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Add Bean</div>
|
||||
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Add a new coffee bean to your library</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Modal from "./Modal";
|
||||
|
||||
export default function IosPromptModal() {
|
||||
const [show, setShow] = useState(false);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startY, setStartY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Detect if user is on iOS or iPadOS
|
||||
@@ -21,124 +18,64 @@ export default function IosPromptModal() {
|
||||
|
||||
if (isIOS && !isStandalone && !isDismissed) {
|
||||
setShow(true);
|
||||
const raf = requestAnimationFrame(() => setActive(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setActive(false);
|
||||
sessionStorage.setItem("ios-prompt-dismissed", "true");
|
||||
setTimeout(() => {
|
||||
setShow(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleDragStart = (clientY) => {
|
||||
setIsDragging(true);
|
||||
setStartY(clientY);
|
||||
};
|
||||
|
||||
const handleDragMove = (clientY) => {
|
||||
if (!isDragging) return;
|
||||
const deltaY = clientY - startY;
|
||||
if (deltaY > 0) {
|
||||
setDragY(deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
if (dragY > 100) {
|
||||
handleClose();
|
||||
} else {
|
||||
setDragY(0);
|
||||
}
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
zIndex="z-[200]"
|
||||
className="relative shadow-[0_-8px_30px_rgba(44,24,16,0.15)] dark:shadow-none"
|
||||
padding="px-6 pb-8 pt-4"
|
||||
>
|
||||
<div
|
||||
className={`bg-[#FAF6F1] dark:bg-[#150B07] 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)] dark:shadow-none transition-transform duration-200 ease-in-out`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
transform: isDragging
|
||||
? `translateY(${dragY}px)`
|
||||
: active
|
||||
? `translateY(${dragY}px)`
|
||||
: "translateY(100%)",
|
||||
transition: isDragging ? "none" : "transform 0.2s ease-out"
|
||||
}}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<div
|
||||
className="w-full py-4 -mt-4 cursor-grab active:cursor-grabbing flex justify-center select-none"
|
||||
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
|
||||
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onMouseDown={(e) => handleDragStart(e.clientY)}
|
||||
onMouseMove={(e) => {
|
||||
if (e.buttons === 1) handleDragMove(e.clientY);
|
||||
}}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragEnd}
|
||||
>
|
||||
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded" />
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-5 top-5 w-8 h-8 flex items-center justify-center rounded-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6] hover:text-[#2C1810] dark:hover:text-[#FAF6F1] transition-colors cursor-pointer text-lg font-semibold"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{(close) => (
|
||||
<>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={() => close()}
|
||||
className="absolute right-5 top-5 w-8 h-8 flex items-center justify-center rounded-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6] hover:text-[#2C1810] dark:hover:text-[#FAF6F1] transition-colors cursor-pointer text-lg font-semibold"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Brew Logo */}
|
||||
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-[22%] flex items-center justify-center overflow-hidden shadow-sm mb-3">
|
||||
<img src="/icon-192.png" alt="Brew Logo" className="w-full h-full object-cover" />
|
||||
{/* Content */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Brew Logo */}
|
||||
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-[22%] flex items-center justify-center overflow-hidden shadow-sm mb-3">
|
||||
<img src="/icon-192.png" alt="Brew Logo" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
{/* Brew Text */}
|
||||
<div className="font-serif text-2xl font-bold text-[#2C1810] dark:text-[#FAF6F1] mb-3">Brew</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[#6B5744] dark:text-[#C8B9A6] leading-relaxed mb-6 px-4">
|
||||
Open this page on safari and then install this app to your homescreen for a better experience.
|
||||
</p>
|
||||
|
||||
{/* Instruction */}
|
||||
<div className="w-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-4 flex items-center justify-center gap-3">
|
||||
<span className="text-sm text-[#2C1810] dark:text-[#FAF6F1] font-medium flex items-center justify-center flex-wrap">
|
||||
Tap
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D4A325" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="inline-block mx-1.5 align-middle">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
then <span className="font-bold text-[#8B6914] dark:text-[#D4A325] ml-1">"Add to Home Screen"</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brew Text */}
|
||||
<div className="font-serif text-2xl font-bold text-[#2C1810] dark:text-[#FAF6F1] mb-3">Brew</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[#6B5744] dark:text-[#C8B9A6] leading-relaxed mb-6 px-4">
|
||||
Open this page on safari and then install this app to your homescreen for a better experience.
|
||||
</p>
|
||||
|
||||
{/* Instruction */}
|
||||
<div className="w-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-4 flex items-center justify-center gap-3">
|
||||
<span className="text-sm text-[#2C1810] dark:text-[#FAF6F1] font-medium flex items-center justify-center flex-wrap">
|
||||
Tap
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D4A325" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="inline-block mx-1.5 align-middle">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
then <span className="font-bold text-[#8B6914] dark:text-[#D4A325] ml-1">"Add to Home Screen"</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
101
src/components/Modal.jsx
Normal file
101
src/components/Modal.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function Modal({
|
||||
onClose,
|
||||
children,
|
||||
zIndex = "z-[100]",
|
||||
maxWidth = "480px",
|
||||
maxHeight = "88vh",
|
||||
padding = "px-5 pb-8",
|
||||
className = ""
|
||||
}) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startY, setStartY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent background scroll when mounted
|
||||
document.body.style.overflow = "hidden";
|
||||
// Trigger transition shortly after mounting
|
||||
const raf = requestAnimationFrame(() => setActive(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = (callback) => {
|
||||
setActive(false);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleDragStart = (clientY) => {
|
||||
setIsDragging(true);
|
||||
setStartY(clientY);
|
||||
};
|
||||
|
||||
const handleDragMove = (clientY) => {
|
||||
if (!isDragging) return;
|
||||
const deltaY = clientY - startY;
|
||||
if (deltaY > 0) {
|
||||
setDragY(deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
if (dragY > 100) {
|
||||
handleClose();
|
||||
} else {
|
||||
setDragY(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] flex items-end justify-center transition-opacity duration-200 ease-in-out ${zIndex} ${active ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => handleClose()}
|
||||
>
|
||||
<div
|
||||
className={`bg-brew-bg dark:bg-[#150B07] rounded-t-3xl w-full transition-transform duration-200 ease-in-out ${padding} ${className}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
transform: isDragging
|
||||
? `translateY(${dragY}px)`
|
||||
: active
|
||||
? `translateY(${dragY}px)`
|
||||
: "translateY(100%)",
|
||||
transition: isDragging ? "none" : "transform 0.2s ease-out"
|
||||
}}
|
||||
>
|
||||
{/* Grab Handle */}
|
||||
<div
|
||||
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
|
||||
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
|
||||
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onMouseDown={(e) => handleDragStart(e.clientY)}
|
||||
onMouseMove={(e) => {
|
||||
if (e.buttons === 1) handleDragMove(e.clientY);
|
||||
}}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragEnd}
|
||||
>
|
||||
<div className="w-9 h-1 bg-brew-border dark:bg-[#3B2217] rounded" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{typeof children === "function" ? children(handleClose) : children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user