diff --git a/src/App.tsx b/src/App.tsx index 096f1c1..aeaeb91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,8 +49,9 @@ import { parseUserLimits, } from "./lib/userLimits"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; -import { groupByDay, groupByFlavour, groupByWeek, makeId } from "./lib/metrics"; +import { groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; +import { useToasts } from "./lib/toasts"; import type { AppView, AuthUser, @@ -103,6 +104,8 @@ function App() { const [limitConfirmMessage, setLimitConfirmMessage] = useState(""); const [pendingLimitAction, setPendingLimitAction] = useState(null); const [setupOpen, setSetupOpen] = useState(false); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + const { pushToast } = useToasts(); const excelFileInputRef = useRef(null); const jsonFileInputRef = useRef(null); @@ -210,6 +213,14 @@ function App() { return () => unsubscribe(); }, [refreshEntries, user]); + useEffect(() => { + if (notice) pushToast({ tone: "success", message: notice }); + }, [notice, pushToast]); + + useEffect(() => { + if (syncError) pushToast({ tone: "error", message: syncError }); + }, [syncError, pushToast]); + const allFlavours = useMemo( () => mergedFlavours(entries.map((entry) => entry.flavour)), [entries], @@ -225,6 +236,8 @@ function App() { const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]); const insights = useMemo(() => buildInsights(entries), [entries]); const recentEntries = useMemo(() => entries.slice(0, 5), [entries]); + const pendingDeleteEntry = pendingDeleteId ? entries.find((entry) => entry.id === pendingDeleteId) ?? null : null; + const deletingId = busyAction?.startsWith("delete-") ? busyAction.slice("delete-".length) : null; async function login(email: string, password: string) { setBusyAction("auth"); @@ -328,8 +341,8 @@ function App() { } } - async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) { - if (!user) return; + async function saveOnboarding(limits: UserLimits, onboardingThemeId: string): Promise { + if (!user) return false; setBusyAction("save-onboarding"); setSyncError(""); try { @@ -347,8 +360,10 @@ function App() { setThemeId(onboardingThemeId); setSetupOpen(false); setNotice("Setup saved."); + return true; } catch (error) { setSyncError(appwriteErrorMessage(error)); + return false; } finally { setBusyAction(null); } @@ -435,6 +450,7 @@ function App() { try { await deleteEntryDocument(id); setEntries((current) => current.filter((entry) => entry.id !== id)); + setPendingDeleteId(null); setNotice("Entry deleted from Appwrite."); } catch (error) { setSyncError(appwriteErrorMessage(error)); @@ -599,7 +615,6 @@ function App() { - + { setEditingEntry(entry); setIsEntryModalOpen(true); }} - onDelete={(id) => void deleteEntry(id)} + onDelete={(id) => setPendingDeleteId(id)} /> )} @@ -685,7 +701,6 @@ function App() { summary={summary} dataLoading={dataLoading} entries={entries} - notice={notice} setupStatus={setupStatus} themeId={themeId} themeMode={themeMode} @@ -753,12 +768,29 @@ function App() { onConfirm={() => void resetAll()} /> + setPendingDeleteId(null)} + onConfirm={() => { + if (pendingDeleteId) void deleteEntry(pendingDeleteId); + }} + /> + { setLimitConfirmOpen(false); setPendingLimitAction(null); diff --git a/src/components/BarcodeScannerModal.tsx b/src/components/BarcodeScannerModal.tsx index 7caab25..7fc1312 100644 --- a/src/components/BarcodeScannerModal.tsx +++ b/src/components/BarcodeScannerModal.tsx @@ -1,5 +1,5 @@ import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react"; -import { AnimatePresence, motion } from "framer-motion"; +import { motion } from "framer-motion"; import { useCallback, useEffect, @@ -39,6 +39,7 @@ import type { UserBarcodeMapping, } from "../types"; import { BarcodeProductPreview } from "./BarcodeProductPreview"; +import { Modal } from "./Modal"; type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error"; @@ -188,7 +189,6 @@ export function BarcodeScannerModal({ setManualMessage(""); setMappingSaving(false); applyManualDefaults(); - window.setTimeout(() => closeButtonRef.current?.focus(), 80); let active = true; let frameId = 0; @@ -237,15 +237,6 @@ export function BarcodeScannerModal({ }; }, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]); - useEffect(() => { - if (!open) return undefined; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [onClose, open]); - function submitTypedBarcode(event: FormEvent) { event.preventDefault(); resolveBarcodeValue(typedBarcode); @@ -307,230 +298,226 @@ export function BarcodeScannerModal({ : "Scanner paused"; return ( - + {open && ( - -
-
-

Camera scan

-

- Scan barcode -

-

Point your camera at the barcode on the can.

-
- +
+
+

Camera scan

+

+ Scan barcode +

+

Point your camera at the barcode on the can.

+ +
-
-
-
-
+
)} - + ); } diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 4b8ccff..d6720fb 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,5 +1,6 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { Loader2, Trash2 } from "lucide-react"; +import { motion } from "framer-motion"; +import { AlertTriangle, Loader2, Trash2 } from "lucide-react"; +import { Modal } from "./Modal"; type ConfirmDialogProps = { busy: boolean; @@ -7,6 +8,7 @@ type ConfirmDialogProps = { title: string; body: string; confirmLabel: string; + tone?: "danger" | "warning"; onCancel: () => void; onConfirm: () => void; }; @@ -17,43 +19,38 @@ export function ConfirmDialog({ title, body, confirmLabel, + tone = "danger", onCancel, onConfirm, }: ConfirmDialogProps) { + const ConfirmIcon = tone === "warning" ? AlertTriangle : Trash2; return ( - - {open && ( - - + +

+ {title} +

+

{body}

+
+ + - -
-
-
- )} -
+ {busy ?
+
+
); } diff --git a/src/components/EntryLedger.tsx b/src/components/EntryLedger.tsx index 6cd6817..4691766 100644 --- a/src/components/EntryLedger.tsx +++ b/src/components/EntryLedger.tsx @@ -1,4 +1,4 @@ -import { Edit3, Trash2 } from "lucide-react"; +import { Edit3, Loader2, Trash2 } from "lucide-react"; import type { CSSProperties } from "react"; import { caffeineFor, currency, humanDateTime, oneDecimal, spendFor, sugarFor, wholeNumber } from "../lib/metrics"; import { AppCard } from "./AppCard"; @@ -8,18 +8,19 @@ import type { RedBullEntry } from "../types"; type EntryLedgerProps = { entries: RedBullEntry[]; totalEntries: number; + deletingId: string | null; onAdd: () => void; onEdit: (entry: RedBullEntry) => void; onDelete: (id: string) => void; }; -export function EntryLedger({ entries, totalEntries, onAdd, onEdit, onDelete }: EntryLedgerProps) { +export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit, onDelete }: EntryLedgerProps) { return ( {entries.length ? (
{entries.map((entry) => ( - + ))}
) : ( @@ -31,10 +32,12 @@ export function EntryLedger({ entries, totalEntries, onAdd, onEdit, onDelete }: function EntryRow({ entry, + deleting, onEdit, onDelete, }: { entry: RedBullEntry; + deleting: boolean; onEdit: (entry: RedBullEntry) => void; onDelete: (id: string) => void; }) { @@ -67,8 +70,15 @@ function EntryRow({ - diff --git a/src/components/EntryModal.tsx b/src/components/EntryModal.tsx index d09cab3..c86f751 100644 --- a/src/components/EntryModal.tsx +++ b/src/components/EntryModal.tsx @@ -1,9 +1,10 @@ -import { AnimatePresence, motion } from "framer-motion"; +import { motion } from "framer-motion"; import { Loader2, Plus, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react"; import { MATERIAL_ACCENTS } from "../data/accents"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours"; import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics"; +import { Modal } from "./Modal"; import type { EntryDraft, Flavour, RedBullEntry } from "../types"; type EntryModalProps = { @@ -57,18 +58,8 @@ export function EntryModal({ setNotes(draft?.notes ?? ""); setSugarFree(draft?.sugarFree ?? false); setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? ""); - window.setTimeout(() => firstFieldRef.current?.focus(), 80); }, [entry, initialDraft, open]); - useEffect(() => { - if (!open) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [onClose, open]); - const selectedMeta = flavourMeta(selectedFlavour); const isOther = selectedFlavour === "Other"; const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset)); @@ -126,156 +117,144 @@ export function EntryModal({ } return ( - - {open && ( - - -
-
-

Intake details

-

- {entry ? "Edit entry" : "Add intake"} -

-
- -
+ + +
+
+

Intake details

+

+ {entry ? "Edit entry" : "Add intake"} +

+
+ +
-
+
+ + + + + + + + + {isOther && ( + <> - + + )} + {sizePreset === "custom" && ( + <> - + + )} - {isOther && ( - <> - - - - )} + - {sizePreset === "custom" && ( - <> - - - - )} + - +