diff --git a/src/components/BeanForm.jsx b/src/components/BeanForm.jsx
index c096b74..58bb864 100644
--- a/src/components/BeanForm.jsx
+++ b/src/components/BeanForm.jsx
@@ -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 (
-
handleClose()}
- >
-
e.stopPropagation()}
- style={{
- transform: isDragging
- ? `translateY(${dragY}px)`
- : active
- ? `translateY(${dragY}px)`
- : "translateY(100%)",
- transition: isDragging ? "none" : "transform 0.2s ease-out"
- }}
- >
-
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}
- >
-
-
-
{initial ? "Edit Bean" : "Add Bean"}
-
- set("name", e.target.value)} />
-
- set("roastery", e.target.value)} />
-
-
- set("roastDate", e.target.value)} />
-
-
-
-
-
-
- {form.image ? (
- <>

-
>
- ) : (<>
📷
Tap to upload a photo
>)}
-
+
+ {(close) => (
+ <>
+ {initial ? "Edit Bean" : "Add Bean"}
+
+ set("name", e.target.value)} />
+
+ set("roastery", e.target.value)} />
+
+
+ set("roastDate", e.target.value)} />
+
+
- Max 2 MB · JPG, PNG, or WebP
-
-
-
-
-
+
+
+
+ {form.image ? (
+ <>

+
>
+ ) : (<>
📷
Tap to upload a photo
>)}
+
+
+
Max 2 MB · JPG, PNG, or WebP
+
+
+
+ >
+ )}
+
);
}
diff --git a/src/components/BrewForm.jsx b/src/components/BrewForm.jsx
index 6973b1d..3370a97 100644
--- a/src/components/BrewForm.jsx
+++ b/src/components/BrewForm.jsx
@@ -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 (
-
handleClose()}
- >
-
e.stopPropagation()}
- style={{
- transform: isDragging
- ? `translateY(${dragY}px)`
- : active
- ? `translateY(${dragY}px)`
- : "translateY(100%)",
- transition: isDragging ? "none" : "transform 0.2s ease-out"
- }}
- >
-
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}
- >
-
-
-
Log a Brew
-
-
🫘
-
No beans yet
-
Add a bean to your library first.
-
-
-
-
+
+ {(close) => (
+ <>
+ Log a Brew
+
+
🫘
+
No beans yet
+
Add a bean to your library first.
+
+
+ >
+ )}
+
);
}
@@ -122,71 +49,45 @@ export default function BrewForm({ beans, onSave, onClose }) {
};
return (
-
handleClose()}
- >
-
e.stopPropagation()}
- style={{
- transform: isDragging
- ? `translateY(${dragY}px)`
- : active
- ? `translateY(${dragY}px)`
- : "translateY(100%)",
- transition: isDragging ? "none" : "transform 0.2s ease-out"
- }}
- >
-
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}
- >
-
-
-
Log a Brew
-
- {METHODS.map(m => (
-
- ))}
-
-
-
-
- {fields[method].map(f => (
-
-
-
set(f.key, e.target.value)} />
- {f.hint &&
{f.hint}
}
-
- ))}
-
-
-
-
- set("tasteNotes", e.target.value)} />
-
-
-
+
+ {(close) => (
+ <>
+ Log a Brew
+
+ {METHODS.map(m => (
+
+ ))}
+
+
+
+
+ {fields[method].map(f => (
+
+
+
set(f.key, e.target.value)} />
+ {f.hint &&
{f.hint}
}
+
+ ))}
+
+
+
+
+ set("tasteNotes", e.target.value)} />
+
+ >
+ )}
+
);
}
diff --git a/src/components/CreateModal.jsx b/src/components/CreateModal.jsx
index 504f958..bbf18bf 100644
--- a/src/components/CreateModal.jsx
+++ b/src/components/CreateModal.jsx
@@ -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 (
-
handleClose()}
- >
-
e.stopPropagation()}
- style={{
- transform: isDragging
- ? `translateY(${dragY}px)`
- : active
- ? `translateY(${dragY}px)`
- : "translateY(100%)",
- transition: isDragging ? "none" : "transform 0.2s ease-out"
- }}
- >
-
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}
- >
-
-
-
What would you like to add?
-
-
-
-
-
-
+
+ {(close) => (
+ <>
+ What would you like to add?
+
+
+
+
+ >
+ )}
+
);
}
diff --git a/src/components/IosPromptModal.jsx b/src/components/IosPromptModal.jsx
index 4edf751..54ba04c 100644
--- a/src/components/IosPromptModal.jsx
+++ b/src/components/IosPromptModal.jsx
@@ -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 (
-
-
e.stopPropagation()}
- style={{
- transform: isDragging
- ? `translateY(${dragY}px)`
- : active
- ? `translateY(${dragY}px)`
- : "translateY(100%)",
- transition: isDragging ? "none" : "transform 0.2s ease-out"
- }}
- >
- {/* Handle bar */}
-
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}
- >
-
-
-
- {/* Close Button */}
-
+ {(close) => (
+ <>
+ {/* Close Button */}
+
- {/* Content */}
-
- {/* Brew Logo */}
-
-

+ {/* Content */}
+
+ {/* Brew Logo */}
+
+

+
+
+ {/* Brew Text */}
+
Brew
+
+ {/* Description */}
+
+ Open this page on safari and then install this app to your homescreen for a better experience.
+
+
+ {/* Instruction */}
+
+
+ Tap
+
+ then "Add to Home Screen"
+
+
-
- {/* Brew Text */}
-
Brew
-
- {/* Description */}
-
- Open this page on safari and then install this app to your homescreen for a better experience.
-
-
- {/* Instruction */}
-
-
- Tap
-
- then "Add to Home Screen"
-
-
-
-
-
+ >
+ )}
+
);
}
diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx
new file mode 100644
index 0000000..28371b6
--- /dev/null
+++ b/src/components/Modal.jsx
@@ -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 (
+
handleClose()}
+ >
+
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 */}
+
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}
+ >
+
+
+
+ {/* Content */}
+ {typeof children === "function" ? children(handleClose) : children}
+
+
+ );
+}