feat(ux): toast system, accessible modal wrapper, safer dialogs
- Global toast stack (success/error/info) replacing hidden notice/syncError - Shared Modal: focus trap, Escape, focus restore, scroll lock; all dialogs migrated - ConfirmDialog tone prop; limit-override now warning-styled - Single-entry delete now requires confirmation, per-row busy spinner - Onboarding no longer closes on failed save; inline error + skip option
This commit is contained in:
+39
-7
@@ -49,8 +49,9 @@ import {
|
|||||||
parseUserLimits,
|
parseUserLimits,
|
||||||
} from "./lib/userLimits";
|
} from "./lib/userLimits";
|
||||||
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
|
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 { exportPayload, parseImport } from "./lib/storage";
|
||||||
|
import { useToasts } from "./lib/toasts";
|
||||||
import type {
|
import type {
|
||||||
AppView,
|
AppView,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
@@ -103,6 +104,8 @@ function App() {
|
|||||||
const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
|
const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
|
||||||
const [pendingLimitAction, setPendingLimitAction] = useState<PendingLimitAction | null>(null);
|
const [pendingLimitAction, setPendingLimitAction] = useState<PendingLimitAction | null>(null);
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
|
const { pushToast } = useToasts();
|
||||||
const excelFileInputRef = useRef<HTMLInputElement>(null);
|
const excelFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const jsonFileInputRef = useRef<HTMLInputElement>(null);
|
const jsonFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -210,6 +213,14 @@ function App() {
|
|||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [refreshEntries, user]);
|
}, [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(
|
const allFlavours = useMemo(
|
||||||
() => mergedFlavours(entries.map((entry) => entry.flavour)),
|
() => mergedFlavours(entries.map((entry) => entry.flavour)),
|
||||||
[entries],
|
[entries],
|
||||||
@@ -225,6 +236,8 @@ function App() {
|
|||||||
const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]);
|
const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]);
|
||||||
const insights = useMemo(() => buildInsights(entries), [entries]);
|
const insights = useMemo(() => buildInsights(entries), [entries]);
|
||||||
const recentEntries = useMemo(() => entries.slice(0, 5), [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) {
|
async function login(email: string, password: string) {
|
||||||
setBusyAction("auth");
|
setBusyAction("auth");
|
||||||
@@ -328,8 +341,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) {
|
async function saveOnboarding(limits: UserLimits, onboardingThemeId: string): Promise<boolean> {
|
||||||
if (!user) return;
|
if (!user) return false;
|
||||||
setBusyAction("save-onboarding");
|
setBusyAction("save-onboarding");
|
||||||
setSyncError("");
|
setSyncError("");
|
||||||
try {
|
try {
|
||||||
@@ -347,8 +360,10 @@ function App() {
|
|||||||
setThemeId(onboardingThemeId);
|
setThemeId(onboardingThemeId);
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
setNotice("Setup saved.");
|
setNotice("Setup saved.");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSyncError(appwriteErrorMessage(error));
|
setSyncError(appwriteErrorMessage(error));
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusyAction(null);
|
setBusyAction(null);
|
||||||
}
|
}
|
||||||
@@ -435,6 +450,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await deleteEntryDocument(id);
|
await deleteEntryDocument(id);
|
||||||
setEntries((current) => current.filter((entry) => entry.id !== id));
|
setEntries((current) => current.filter((entry) => entry.id !== id));
|
||||||
|
setPendingDeleteId(null);
|
||||||
setNotice("Entry deleted from Appwrite.");
|
setNotice("Entry deleted from Appwrite.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSyncError(appwriteErrorMessage(error));
|
setSyncError(appwriteErrorMessage(error));
|
||||||
@@ -599,7 +615,6 @@ function App() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
dataLoading={dataLoading}
|
dataLoading={dataLoading}
|
||||||
notice={notice}
|
|
||||||
setupStatus={setupStatus}
|
setupStatus={setupStatus}
|
||||||
user={user}
|
user={user}
|
||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
@@ -617,7 +632,7 @@ function App() {
|
|||||||
className={activeView === "overview" ? "top-app-bar--overview" : ""}
|
className={activeView === "overview" ? "top-app-bar--overview" : ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusRail busyAction={busyAction} syncError={syncError} setupStatus={setupStatus} />
|
<StatusRail busyAction={busyAction} setupStatus={setupStatus} />
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.main
|
<motion.main
|
||||||
@@ -654,13 +669,14 @@ function App() {
|
|||||||
totalEntries={entries.length}
|
totalEntries={entries.length}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
flavours={allFlavours}
|
flavours={allFlavours}
|
||||||
|
deletingId={deletingId}
|
||||||
onFilterChange={setFilters}
|
onFilterChange={setFilters}
|
||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
onEdit={(entry) => {
|
onEdit={(entry) => {
|
||||||
setEditingEntry(entry);
|
setEditingEntry(entry);
|
||||||
setIsEntryModalOpen(true);
|
setIsEntryModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => void deleteEntry(id)}
|
onDelete={(id) => setPendingDeleteId(id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -685,7 +701,6 @@ function App() {
|
|||||||
summary={summary}
|
summary={summary}
|
||||||
dataLoading={dataLoading}
|
dataLoading={dataLoading}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
notice={notice}
|
|
||||||
setupStatus={setupStatus}
|
setupStatus={setupStatus}
|
||||||
themeId={themeId}
|
themeId={themeId}
|
||||||
themeMode={themeMode}
|
themeMode={themeMode}
|
||||||
@@ -753,12 +768,29 @@ function App() {
|
|||||||
onConfirm={() => void resetAll()}
|
onConfirm={() => void resetAll()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
busy={pendingDeleteId !== null && busyAction === `delete-${pendingDeleteId}`}
|
||||||
|
open={pendingDeleteId !== null}
|
||||||
|
title="Delete this entry?"
|
||||||
|
body={
|
||||||
|
pendingDeleteEntry
|
||||||
|
? `${pendingDeleteEntry.flavour} · ${pendingDeleteEntry.cans} can${pendingDeleteEntry.cans === 1 ? "" : "s"} · ${pendingDeleteEntry.sizeMl}ml · ${humanDateTime(pendingDeleteEntry.dateTime)}`
|
||||||
|
: "This entry will be permanently removed."
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setPendingDeleteId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingDeleteId) void deleteEntry(pendingDeleteId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
busy={Boolean(busyAction && pendingLimitAction)}
|
busy={Boolean(busyAction && pendingLimitAction)}
|
||||||
open={limitConfirmOpen}
|
open={limitConfirmOpen}
|
||||||
title="Over your limit?"
|
title="Over your limit?"
|
||||||
body={limitConfirmMessage || "This intake goes past one of your daily limits."}
|
body={limitConfirmMessage || "This intake goes past one of your daily limits."}
|
||||||
confirmLabel="Log anyway"
|
confirmLabel="Log anyway"
|
||||||
|
tone="warning"
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setLimitConfirmOpen(false);
|
setLimitConfirmOpen(false);
|
||||||
setPendingLimitAction(null);
|
setPendingLimitAction(null);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react";
|
import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
UserBarcodeMapping,
|
UserBarcodeMapping,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { BarcodeProductPreview } from "./BarcodeProductPreview";
|
import { BarcodeProductPreview } from "./BarcodeProductPreview";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error";
|
type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error";
|
||||||
|
|
||||||
@@ -188,7 +189,6 @@ export function BarcodeScannerModal({
|
|||||||
setManualMessage("");
|
setManualMessage("");
|
||||||
setMappingSaving(false);
|
setMappingSaving(false);
|
||||||
applyManualDefaults();
|
applyManualDefaults();
|
||||||
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
@@ -237,15 +237,6 @@ export function BarcodeScannerModal({
|
|||||||
};
|
};
|
||||||
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
}, [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<HTMLFormElement>) {
|
function submitTypedBarcode(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
resolveBarcodeValue(typedBarcode);
|
resolveBarcodeValue(typedBarcode);
|
||||||
@@ -307,230 +298,226 @@ export function BarcodeScannerModal({
|
|||||||
: "Scanner paused";
|
: "Scanner paused";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy="barcode-scanner-title"
|
||||||
|
initialFocusRef={closeButtonRef}
|
||||||
|
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl"
|
||||||
|
>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl"
|
className="modal-panel max-w-4xl"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||||
role="dialog"
|
transition={{ duration: 0.22 }}
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="barcode-scanner-title"
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
className="modal-panel max-w-4xl"
|
<div>
|
||||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
<p className="section-kicker">Camera scan</p>
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
<h2 id="barcode-scanner-title" className="app-card-title mt-1 text-3xl">
|
||||||
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
Scan barcode
|
||||||
transition={{ duration: 0.22 }}
|
</h2>
|
||||||
>
|
<p className="app-card-subtitle mt-2">Point your camera at the barcode on the can.</p>
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="section-kicker">Camera scan</p>
|
|
||||||
<h2 id="barcode-scanner-title" className="app-card-title mt-1 text-3xl">
|
|
||||||
Scan barcode
|
|
||||||
</h2>
|
|
||||||
<p className="app-card-subtitle mt-2">Point your camera at the barcode on the can.</p>
|
|
||||||
</div>
|
|
||||||
<button ref={closeButtonRef} className="icon-button" type="button" onClick={onClose} aria-label="Close barcode scanner">
|
|
||||||
<X size={18} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button ref={closeButtonRef} className="icon-button" type="button" onClick={onClose} aria-label="Close barcode scanner">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<section className="grid gap-3">
|
<section className="grid gap-3">
|
||||||
<div className="relative overflow-hidden rounded-3xl border border-cyan-200/20 bg-black shadow-2xl">
|
<div className="relative overflow-hidden rounded-3xl border border-cyan-200/20 bg-black shadow-2xl">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
aria-label="Live camera preview"
|
aria-label="Live camera preview"
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
<div className="h-28 w-[78%] max-w-sm rounded-2xl border-2 border-cyan-200/90 shadow-[0_0_0_999px_rgba(0,0,0,0.28),0_0_32px_rgba(125,231,255,0.35)]" />
|
<div className="h-28 w-[78%] max-w-sm rounded-2xl border-2 border-cyan-200/90 shadow-[0_0_0_999px_rgba(0,0,0,0.28),0_0_32px_rgba(125,231,255,0.35)]" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/60 px-3 py-2 text-sm text-white backdrop-blur">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{phase === "starting" ? (
|
||||||
|
<Loader2 className="animate-spin text-cyan-100" size={16} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ScanLine className="text-cyan-100" size={16} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{scannerStatus}
|
||||||
|
</span>
|
||||||
|
<span className="hidden text-xs text-slate-300 sm:inline">EAN/UPC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-3" onSubmit={submitTypedBarcode}>
|
||||||
|
<label className="field-label">
|
||||||
|
Type barcode instead
|
||||||
|
<span className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input
|
||||||
|
className="field-control"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
placeholder="EAN or UPC number"
|
||||||
|
value={typedBarcode}
|
||||||
|
onChange={(event) => setTypedBarcode(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="secondary-button shrink-0 justify-center" type="submit">
|
||||||
|
<Keyboard size={17} aria-hidden="true" />
|
||||||
|
Lookup
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid content-start gap-3">
|
||||||
|
{phase === "starting" || phase === "scanning" ? (
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-white/[0.05] p-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-cyan-200/20 bg-cyan-200/10 text-cyan-100">
|
||||||
|
<Camera size={22} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/60 px-3 py-2 text-sm text-white backdrop-blur">
|
<h3 className="mt-4 text-lg font-semibold text-white">Searching for a retail barcode</h3>
|
||||||
<span className="inline-flex items-center gap-2">
|
<p className="mt-2 text-sm leading-6 text-slate-300">
|
||||||
{phase === "starting" ? (
|
Hold the can steady inside the frame. The camera will stop automatically after a match.
|
||||||
<Loader2 className="animate-spin text-cyan-100" size={16} aria-hidden="true" />
|
</p>
|
||||||
) : (
|
</div>
|
||||||
<ScanLine className="text-cyan-100" size={16} aria-hidden="true" />
|
) : null}
|
||||||
)}
|
|
||||||
{scannerStatus}
|
{phase === "error" && (
|
||||||
</span>
|
<div className="rounded-3xl border border-amber-300/30 bg-amber-300/10 p-4 text-amber-50">
|
||||||
<span className="hidden text-xs text-slate-300 sm:inline">EAN/UPC</span>
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Scanner unavailable</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6">{scannerError?.message ?? scannerErrorMessage("unknown")}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-3" onSubmit={submitTypedBarcode}>
|
{phase === "manual" && (
|
||||||
<label className="field-label">
|
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-4" onSubmit={saveManualProduct}>
|
||||||
Type barcode instead
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-100">Unknown barcode</p>
|
||||||
<span className="flex flex-col gap-2 sm:flex-row">
|
<h3 className="mt-1 break-all text-xl font-semibold text-white">{activeBarcode || "No barcode entered"}</h3>
|
||||||
<input
|
<p className="mt-2 text-sm leading-6 text-slate-300">{manualMessage}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="field-label">
|
||||||
|
Flavour
|
||||||
|
<select
|
||||||
className="field-control"
|
className="field-control"
|
||||||
inputMode="numeric"
|
value={selectedFlavour}
|
||||||
pattern="[0-9]*"
|
onChange={(event) => {
|
||||||
placeholder="EAN or UPC number"
|
const flavour = event.target.value;
|
||||||
value={typedBarcode}
|
setSelectedFlavour(flavour);
|
||||||
onChange={(event) => setTypedBarcode(event.target.value)}
|
setSugarFree(Boolean(flavourMeta(flavour).sugarFree));
|
||||||
/>
|
}}
|
||||||
<button className="secondary-button shrink-0 justify-center" type="submit">
|
>
|
||||||
<Keyboard size={17} aria-hidden="true" />
|
{flavours.map((flavour) => (
|
||||||
Lookup
|
<option key={flavour.name} value={flavour.name}>
|
||||||
</button>
|
{flavour.name}
|
||||||
</span>
|
</option>
|
||||||
</label>
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field-label">
|
||||||
|
Can size
|
||||||
|
<select
|
||||||
|
className="field-control"
|
||||||
|
value={sizePreset}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setSizePreset(next);
|
||||||
|
if (next !== "custom") {
|
||||||
|
const size = Number(next);
|
||||||
|
setCustomSize(next);
|
||||||
|
setPricePerCan(defaultPriceForSize(size).toFixed(2));
|
||||||
|
setCaffeineOverride("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="250">250ml</option>
|
||||||
|
<option value="355">355ml</option>
|
||||||
|
<option value="473">473ml</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{sizePreset === "custom" && (
|
||||||
|
<>
|
||||||
|
<label className="field-label">
|
||||||
|
Custom size in ml
|
||||||
|
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
Caffeine mg/can
|
||||||
|
<input
|
||||||
|
className="field-control"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
type="number"
|
||||||
|
value={caffeineOverride}
|
||||||
|
onChange={(event) => setCaffeineOverride(event.target.value)}
|
||||||
|
placeholder={wholeNumber.format(caffeinePerCan(numericSize))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="field-label">
|
||||||
|
Price
|
||||||
|
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-cyan-200/20 bg-cyan-200/10 px-3 py-3 text-sm text-cyan-50">
|
||||||
|
Estimated caffeine: {wholeNumber.format(manualCaffeine)}mg
|
||||||
|
<br />
|
||||||
|
Price: {currency.format(manualProduct.pricePerCan)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
||||||
|
<input className="h-4 w-4" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
||||||
|
Count this product as sugar-free / zero sugar
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
||||||
|
<input className="h-4 w-4" type="checkbox" checked={saveMapping} onChange={(event) => setSaveMapping(event.target.checked)} />
|
||||||
|
Save this barcode mapping locally for future scans
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button justify-center" type="submit" disabled={mappingSaving}>
|
||||||
|
{mappingSaving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : null}
|
||||||
|
Save mapping preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
<section className="grid content-start gap-3">
|
{phase === "found" && product && (
|
||||||
{phase === "starting" || phase === "scanning" ? (
|
<BarcodeProductPreview
|
||||||
<div className="rounded-3xl border border-white/10 bg-white/[0.05] p-4">
|
barcode={activeBarcode}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-cyan-200/20 bg-cyan-200/10 text-cyan-100">
|
busy={busy}
|
||||||
<Camera size={22} aria-hidden="true" />
|
product={product}
|
||||||
</div>
|
onAddNow={() => addProductNow(product)}
|
||||||
<h3 className="mt-4 text-lg font-semibold text-white">Searching for a retail barcode</h3>
|
onCancel={onClose}
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-300">
|
onEdit={() => editProductBeforeAdding(product)}
|
||||||
Hold the can steady inside the frame. The camera will stop automatically after a match.
|
/>
|
||||||
</p>
|
)}
|
||||||
</div>
|
</section>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
{phase === "error" && (
|
|
||||||
<div className="rounded-3xl border border-amber-300/30 bg-amber-300/10 p-4 text-amber-50">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" />
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-white">Scanner unavailable</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6">{scannerError?.message ?? scannerErrorMessage("unknown")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "manual" && (
|
|
||||||
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-4" onSubmit={saveManualProduct}>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-100">Unknown barcode</p>
|
|
||||||
<h3 className="mt-1 break-all text-xl font-semibold text-white">{activeBarcode || "No barcode entered"}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-300">{manualMessage}</p>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="field-label">
|
|
||||||
Flavour
|
|
||||||
<select
|
|
||||||
className="field-control"
|
|
||||||
value={selectedFlavour}
|
|
||||||
onChange={(event) => {
|
|
||||||
const flavour = event.target.value;
|
|
||||||
setSelectedFlavour(flavour);
|
|
||||||
setSugarFree(Boolean(flavourMeta(flavour).sugarFree));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flavours.map((flavour) => (
|
|
||||||
<option key={flavour.name} value={flavour.name}>
|
|
||||||
{flavour.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field-label">
|
|
||||||
Can size
|
|
||||||
<select
|
|
||||||
className="field-control"
|
|
||||||
value={sizePreset}
|
|
||||||
onChange={(event) => {
|
|
||||||
const next = event.target.value;
|
|
||||||
setSizePreset(next);
|
|
||||||
if (next !== "custom") {
|
|
||||||
const size = Number(next);
|
|
||||||
setCustomSize(next);
|
|
||||||
setPricePerCan(defaultPriceForSize(size).toFixed(2));
|
|
||||||
setCaffeineOverride("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="250">250ml</option>
|
|
||||||
<option value="355">355ml</option>
|
|
||||||
<option value="473">473ml</option>
|
|
||||||
<option value="custom">Custom</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{sizePreset === "custom" && (
|
|
||||||
<>
|
|
||||||
<label className="field-label">
|
|
||||||
Custom size in ml
|
|
||||||
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="field-label">
|
|
||||||
Caffeine mg/can
|
|
||||||
<input
|
|
||||||
className="field-control"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
type="number"
|
|
||||||
value={caffeineOverride}
|
|
||||||
onChange={(event) => setCaffeineOverride(event.target.value)}
|
|
||||||
placeholder={wholeNumber.format(caffeinePerCan(numericSize))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="field-label">
|
|
||||||
Price
|
|
||||||
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-cyan-200/20 bg-cyan-200/10 px-3 py-3 text-sm text-cyan-50">
|
|
||||||
Estimated caffeine: {wholeNumber.format(manualCaffeine)}mg
|
|
||||||
<br />
|
|
||||||
Price: {currency.format(manualProduct.pricePerCan)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
|
||||||
<input className="h-4 w-4" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
|
||||||
Count this product as sugar-free / zero sugar
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
|
||||||
<input className="h-4 w-4" type="checkbox" checked={saveMapping} onChange={(event) => setSaveMapping(event.target.checked)} />
|
|
||||||
Save this barcode mapping locally for future scans
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
||||||
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="primary-button justify-center" type="submit" disabled={mappingSaving}>
|
|
||||||
{mappingSaving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : null}
|
|
||||||
Save mapping preview
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "found" && product && (
|
|
||||||
<BarcodeProductPreview
|
|
||||||
barcode={activeBarcode}
|
|
||||||
busy={busy}
|
|
||||||
product={product}
|
|
||||||
onAddNow={() => addProductNow(product)}
|
|
||||||
onCancel={onClose}
|
|
||||||
onEdit={() => editProductBeforeAdding(product)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Loader2, Trash2 } from "lucide-react";
|
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
type ConfirmDialogProps = {
|
type ConfirmDialogProps = {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
@@ -7,6 +8,7 @@ type ConfirmDialogProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
|
tone?: "danger" | "warning";
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
};
|
};
|
||||||
@@ -17,43 +19,38 @@ export function ConfirmDialog({
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
|
tone = "danger",
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
|
const ConfirmIcon = tone === "warning" ? AlertTriangle : Trash2;
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal open={open} onClose={onCancel} labelledBy="confirm-title">
|
||||||
{open && (
|
<motion.div
|
||||||
<motion.div
|
className="glass-panel w-full max-w-md p-5"
|
||||||
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl"
|
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||||
initial={{ opacity: 0 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
animate={{ opacity: 1 }}
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
exit={{ opacity: 0 }}
|
>
|
||||||
role="dialog"
|
<h2 id="confirm-title" className="text-2xl font-semibold tracking-tight text-white">
|
||||||
aria-modal="true"
|
{title}
|
||||||
aria-labelledby="confirm-title"
|
</h2>
|
||||||
>
|
<p className="mt-3 text-slate-400">{body}</p>
|
||||||
<motion.div
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
className="glass-panel w-full max-w-md p-5"
|
<button className="secondary-button justify-center" type="button" onClick={onCancel}>
|
||||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
Cancel
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
</button>
|
||||||
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
<button
|
||||||
|
className={`${tone === "warning" ? "warning-button" : "danger-button"} justify-center`}
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<h2 id="confirm-title" className="text-2xl font-semibold tracking-tight text-white">
|
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <ConfirmIcon size={17} aria-hidden="true" />}
|
||||||
{title}
|
{confirmLabel}
|
||||||
</h2>
|
</button>
|
||||||
<p className="mt-3 text-slate-400">{body}</p>
|
</div>
|
||||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
</motion.div>
|
||||||
<button className="secondary-button justify-center" type="button" onClick={onCancel}>
|
</Modal>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="danger-button justify-center" type="button" onClick={onConfirm} disabled={busy}>
|
|
||||||
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Trash2 size={17} aria-hidden="true" />}
|
|
||||||
{confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Edit3, Trash2 } from "lucide-react";
|
import { Edit3, Loader2, Trash2 } from "lucide-react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { caffeineFor, currency, humanDateTime, oneDecimal, spendFor, sugarFor, wholeNumber } from "../lib/metrics";
|
import { caffeineFor, currency, humanDateTime, oneDecimal, spendFor, sugarFor, wholeNumber } from "../lib/metrics";
|
||||||
import { AppCard } from "./AppCard";
|
import { AppCard } from "./AppCard";
|
||||||
@@ -8,18 +8,19 @@ import type { RedBullEntry } from "../types";
|
|||||||
type EntryLedgerProps = {
|
type EntryLedgerProps = {
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
totalEntries: number;
|
totalEntries: number;
|
||||||
|
deletingId: string | null;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (entry: RedBullEntry) => void;
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
onDelete: (id: string) => 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 (
|
return (
|
||||||
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
|
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
|
||||||
{entries.length ? (
|
{entries.length ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<EntryRow key={entry.id} entry={entry} onEdit={onEdit} onDelete={onDelete} />
|
<EntryRow key={entry.id} entry={entry} deleting={deletingId === entry.id} onEdit={onEdit} onDelete={onDelete} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -31,10 +32,12 @@ export function EntryLedger({ entries, totalEntries, onAdd, onEdit, onDelete }:
|
|||||||
|
|
||||||
function EntryRow({
|
function EntryRow({
|
||||||
entry,
|
entry,
|
||||||
|
deleting,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
entry: RedBullEntry;
|
entry: RedBullEntry;
|
||||||
|
deleting: boolean;
|
||||||
onEdit: (entry: RedBullEntry) => void;
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -67,8 +70,15 @@ function EntryRow({
|
|||||||
<button className="icon-button" type="button" onClick={() => onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}>
|
<button className="icon-button" type="button" onClick={() => onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}>
|
||||||
<Edit3 size={17} aria-hidden="true" />
|
<Edit3 size={17} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button className="icon-button" type="button" style={{ color: "#9f1c16" }} onClick={() => onDelete(entry.id)} aria-label={`Delete ${entry.flavour} entry`}>
|
<button
|
||||||
<Trash2 size={17} aria-hidden="true" />
|
className="icon-button"
|
||||||
|
type="button"
|
||||||
|
style={{ color: "#9f1c16" }}
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => onDelete(entry.id)}
|
||||||
|
aria-label={`Delete ${entry.flavour} entry`}
|
||||||
|
>
|
||||||
|
{deleting ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Trash2 size={17} aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+124
-145
@@ -1,9 +1,10 @@
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Loader2, Plus, X } from "lucide-react";
|
import { Loader2, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react";
|
||||||
import { MATERIAL_ACCENTS } from "../data/accents";
|
import { MATERIAL_ACCENTS } from "../data/accents";
|
||||||
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
|
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
|
||||||
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
|
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
import type { EntryDraft, Flavour, RedBullEntry } from "../types";
|
import type { EntryDraft, Flavour, RedBullEntry } from "../types";
|
||||||
|
|
||||||
type EntryModalProps = {
|
type EntryModalProps = {
|
||||||
@@ -57,18 +58,8 @@ export function EntryModal({
|
|||||||
setNotes(draft?.notes ?? "");
|
setNotes(draft?.notes ?? "");
|
||||||
setSugarFree(draft?.sugarFree ?? false);
|
setSugarFree(draft?.sugarFree ?? false);
|
||||||
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
|
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
|
||||||
window.setTimeout(() => firstFieldRef.current?.focus(), 80);
|
|
||||||
}, [entry, initialDraft, open]);
|
}, [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 selectedMeta = flavourMeta(selectedFlavour);
|
||||||
const isOther = selectedFlavour === "Other";
|
const isOther = selectedFlavour === "Other";
|
||||||
const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset));
|
const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset));
|
||||||
@@ -126,156 +117,144 @@ export function EntryModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal open={open} onClose={onClose} labelledBy="entry-modal-title" initialFocusRef={firstFieldRef}>
|
||||||
{open && (
|
<motion.form
|
||||||
<motion.div
|
className="modal-panel"
|
||||||
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl"
|
onSubmit={submit}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||||
role="dialog"
|
transition={{ duration: 0.22 }}
|
||||||
aria-modal="true"
|
>
|
||||||
aria-labelledby="entry-modal-title"
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
>
|
<div>
|
||||||
<motion.form
|
<p className="section-kicker">Intake details</p>
|
||||||
className="modal-panel"
|
<h2 id="entry-modal-title" className="app-card-title mt-1 text-3xl">
|
||||||
onSubmit={submit}
|
{entry ? "Edit entry" : "Add intake"}
|
||||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
</h2>
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
</div>
|
||||||
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
<button className="icon-button" type="button" onClick={onClose} aria-label="Close entry modal">
|
||||||
transition={{ duration: 0.22 }}
|
<X size={18} aria-hidden="true" />
|
||||||
>
|
</button>
|
||||||
<div className="mb-6 flex items-start justify-between gap-4">
|
</div>
|
||||||
<div>
|
|
||||||
<p className="section-kicker">Intake details</p>
|
|
||||||
<h2 id="entry-modal-title" className="app-card-title mt-1 text-3xl">
|
|
||||||
{entry ? "Edit entry" : "Add intake"}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button className="icon-button" type="button" onClick={onClose} aria-label="Close entry modal">
|
|
||||||
<X size={18} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="field-label">
|
||||||
|
Number of cans
|
||||||
|
<input ref={firstFieldRef} className="field-control" min="0.25" step="0.25" type="number" value={cans} onChange={(event) => setCans(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field-label">
|
||||||
|
Price per can
|
||||||
|
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field-label">
|
||||||
|
Flavour
|
||||||
|
<select
|
||||||
|
className="field-control"
|
||||||
|
value={selectedFlavour}
|
||||||
|
onChange={(event) => {
|
||||||
|
const flavour = event.target.value;
|
||||||
|
setSelectedFlavour(flavour);
|
||||||
|
const meta = flavourMeta(flavour);
|
||||||
|
setSugarFree(Boolean(meta.sugarFree));
|
||||||
|
if (flavour !== "Other") setCustomAccent(meta.accent);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flavours.map((flavour) => (
|
||||||
|
<option key={flavour.name} value={flavour.name}>
|
||||||
|
{flavour.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field-label">
|
||||||
|
Can size
|
||||||
|
<select
|
||||||
|
className="field-control"
|
||||||
|
value={sizePreset}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setSizePreset(next);
|
||||||
|
if (next !== "custom") {
|
||||||
|
const size = Number(next);
|
||||||
|
setCustomSize(next);
|
||||||
|
setPricePerCan(defaultPriceForSize(size).toFixed(2));
|
||||||
|
setCaffeineOverride("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="250">250ml</option>
|
||||||
|
<option value="355">355ml</option>
|
||||||
|
<option value="473">473ml</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isOther && (
|
||||||
|
<>
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Number of cans
|
Custom flavour
|
||||||
<input ref={firstFieldRef} className="field-control" min="0.25" step="0.25" type="number" value={cans} onChange={(event) => setCans(event.target.value)} required />
|
<input className="field-control" type="text" value={customFlavour} onChange={(event) => setCustomFlavour(event.target.value)} placeholder="Fig Apple, Sea Blue..." />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Price per can
|
Accent colour
|
||||||
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
<input className="field-control h-12 p-1" type="color" value={customAccent} onChange={(event) => setCustomAccent(event.target.value)} aria-label="Custom flavour accent colour" />
|
||||||
</label>
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sizePreset === "custom" && (
|
||||||
|
<>
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Flavour
|
Custom size in ml
|
||||||
<select
|
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
||||||
className="field-control"
|
|
||||||
value={selectedFlavour}
|
|
||||||
onChange={(event) => {
|
|
||||||
const flavour = event.target.value;
|
|
||||||
setSelectedFlavour(flavour);
|
|
||||||
const meta = flavourMeta(flavour);
|
|
||||||
setSugarFree(Boolean(meta.sugarFree));
|
|
||||||
if (flavour !== "Other") setCustomAccent(meta.accent);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flavours.map((flavour) => (
|
|
||||||
<option key={flavour.name} value={flavour.name}>
|
|
||||||
{flavour.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Can size
|
Caffeine override mg/can
|
||||||
<select
|
<input className="field-control" min="0" step="1" type="number" value={caffeineOverride} onChange={(event) => setCaffeineOverride(event.target.value)} placeholder={wholeNumber.format(caffeinePerCan(numericSize))} />
|
||||||
className="field-control"
|
|
||||||
value={sizePreset}
|
|
||||||
onChange={(event) => {
|
|
||||||
const next = event.target.value;
|
|
||||||
setSizePreset(next);
|
|
||||||
if (next !== "custom") {
|
|
||||||
const size = Number(next);
|
|
||||||
setCustomSize(next);
|
|
||||||
setPricePerCan(defaultPriceForSize(size).toFixed(2));
|
|
||||||
setCaffeineOverride("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="250">250ml</option>
|
|
||||||
<option value="355">355ml</option>
|
|
||||||
<option value="473">473ml</option>
|
|
||||||
<option value="custom">Custom</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOther && (
|
<label className="field-label">
|
||||||
<>
|
Date and time
|
||||||
<label className="field-label">
|
<input className="field-control" type="datetime-local" value={dateTime} onChange={(event) => setDateTime(event.target.value)} required />
|
||||||
Custom flavour
|
</label>
|
||||||
<input className="field-control" type="text" value={customFlavour} onChange={(event) => setCustomFlavour(event.target.value)} placeholder="Fig Apple, Sea Blue..." />
|
|
||||||
</label>
|
|
||||||
<label className="field-label">
|
|
||||||
Accent colour
|
|
||||||
<input className="field-control h-12 p-1" type="color" value={customAccent} onChange={(event) => setCustomAccent(event.target.value)} aria-label="Custom flavour accent colour" />
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sizePreset === "custom" && (
|
<label className="field-label">
|
||||||
<>
|
Location or store
|
||||||
<label className="field-label">
|
<input className="field-control" type="text" value={store} onChange={(event) => setStore(event.target.value)} placeholder="BP, Tesco, airport..." />
|
||||||
Custom size in ml
|
</label>
|
||||||
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="field-label">
|
|
||||||
Caffeine override mg/can
|
|
||||||
<input className="field-control" min="0" step="1" type="number" value={caffeineOverride} onChange={(event) => setCaffeineOverride(event.target.value)} placeholder={wholeNumber.format(caffeinePerCan(numericSize))} />
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="field-label">
|
<label className="field-label sm:col-span-2">
|
||||||
Date and time
|
Notes
|
||||||
<input className="field-control" type="datetime-local" value={dateTime} onChange={(event) => setDateTime(event.target.value)} required />
|
<textarea className="field-control min-h-24 resize-y" value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Late drive, gym stop, exam fuel..." />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field-label">
|
<div className="rounded-lg px-3 py-3 text-sm sm:col-span-2" style={{ border: "1px solid #d8e1ee", background: "#f7faff", color: "#3c4043" }}>
|
||||||
Location or store
|
Estimated caffeine per can: {wholeNumber.format(caffeinePreview)}mg
|
||||||
<input className="field-control" type="text" value={store} onChange={(event) => setStore(event.target.value)} placeholder="BP, Tesco, airport..." />
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field-label sm:col-span-2">
|
<label className="field-label flex-row items-center gap-3 rounded-lg border px-3 py-3 sm:col-span-2" style={{ borderColor: "#d8e1ee", background: "#ffffff" }}>
|
||||||
Notes
|
<input className="h-4 w-4 accent-cyan-300" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
||||||
<textarea className="field-control min-h-24 resize-y" value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Late drive, gym stop, exam fuel..." />
|
Count this entry as sugar-free / zero sugar
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg px-3 py-3 text-sm sm:col-span-2" style={{ border: "1px solid #d8e1ee", background: "#f7faff", color: "#3c4043" }}>
|
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
Estimated caffeine per can: {wholeNumber.format(caffeinePreview)}mg
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||||
</div>
|
Cancel
|
||||||
|
</button>
|
||||||
<label className="field-label flex-row items-center gap-3 rounded-lg border px-3 py-3 sm:col-span-2" style={{ borderColor: "#d8e1ee", background: "#ffffff" }}>
|
<button className="primary-button justify-center" type="submit" disabled={saving} style={{ "--accent": finalAccent } as CSSProperties}>
|
||||||
<input className="h-4 w-4 accent-cyan-300" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
{saving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Plus size={17} aria-hidden="true" />}
|
||||||
Count this entry as sugar-free / zero sugar
|
{entry ? "Save changes" : "Log intake"}
|
||||||
</label>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.form>
|
||||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
</Modal>
|
||||||
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="primary-button justify-center" type="submit" disabled={saving} style={{ "--accent": finalAccent } as CSSProperties}>
|
|
||||||
{saving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Plus size={17} aria-hidden="true" />}
|
|
||||||
{entry ? "Save changes" : "Log intake"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.form>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { FileSpreadsheet, Loader2, X } from "lucide-react";
|
import { FileSpreadsheet, Loader2, X } from "lucide-react";
|
||||||
import { humanDateTime } from "../lib/metrics";
|
import { humanDateTime } from "../lib/metrics";
|
||||||
import { MiniMetric } from "./MetricTiles";
|
import { MiniMetric } from "./MetricTiles";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
import type { ImportPreview } from "../types";
|
import type { ImportPreview } from "../types";
|
||||||
|
|
||||||
type ImportPreviewModalProps = {
|
type ImportPreviewModalProps = {
|
||||||
@@ -22,87 +23,77 @@ export function ImportPreviewModal({
|
|||||||
const duplicateRows = preview?.rows.filter((row) => row.duplicate) ?? [];
|
const duplicateRows = preview?.rows.filter((row) => row.duplicate) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal open={Boolean(preview)} onClose={onClose} labelledBy="import-preview-title">
|
||||||
{preview && (
|
{preview && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl"
|
className="modal-panel max-w-5xl"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="import-preview-title"
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
className="modal-panel max-w-5xl"
|
<div>
|
||||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
<p className="text-sm font-medium uppercase tracking-[0.18em] text-pink-100">Excel import</p>
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
<h2 id="import-preview-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
||||||
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
Preview rows
|
||||||
>
|
</h2>
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
<p className="mt-2 text-sm text-slate-400">{preview.fileName}</p>
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-pink-100">Excel import</p>
|
|
||||||
<h2 id="import-preview-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
|
||||||
Preview rows
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">{preview.fileName}</p>
|
|
||||||
</div>
|
|
||||||
<button className="icon-button" type="button" onClick={onClose} aria-label="Close import preview">
|
|
||||||
<X size={18} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button className="icon-button" type="button" onClick={onClose} aria-label="Close import preview">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 grid gap-3 sm:grid-cols-3">
|
<div className="mb-4 grid gap-3 sm:grid-cols-3">
|
||||||
<MiniMetric label="Ready" value={`${validRows.length}`} accent="#39d5ff" />
|
<MiniMetric label="Ready" value={`${validRows.length}`} accent="#39d5ff" />
|
||||||
<MiniMetric label="Duplicates" value={`${duplicateRows.length}`} accent="#ffd84d" />
|
<MiniMetric label="Duplicates" value={`${duplicateRows.length}`} accent="#ffd84d" />
|
||||||
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
|
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[48vh] overflow-auto rounded-lg border border-white/10">
|
<div className="max-h-[48vh] overflow-auto rounded-lg border border-white/10">
|
||||||
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
|
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
|
||||||
<thead className="sticky top-0 bg-[#0d142c] text-xs uppercase tracking-[0.14em] text-slate-400">
|
<thead className="sticky top-0 bg-[#0d142c] text-xs uppercase tracking-[0.14em] text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-3">Row</th>
|
<th className="px-3 py-3">Row</th>
|
||||||
<th className="px-3 py-3">Status</th>
|
<th className="px-3 py-3">Status</th>
|
||||||
<th className="px-3 py-3">Date</th>
|
<th className="px-3 py-3">Date</th>
|
||||||
<th className="px-3 py-3">Flavour</th>
|
<th className="px-3 py-3">Flavour</th>
|
||||||
<th className="px-3 py-3">Size</th>
|
<th className="px-3 py-3">Size</th>
|
||||||
<th className="px-3 py-3">Cans</th>
|
<th className="px-3 py-3">Cans</th>
|
||||||
<th className="px-3 py-3">Message</th>
|
<th className="px-3 py-3">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.rows.map((row) => (
|
||||||
|
<tr key={row.rowNumber} className="border-t border-white/10">
|
||||||
|
<td className="px-3 py-3 text-slate-400">{row.rowNumber}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<span className={`rounded px-2 py-1 text-xs font-semibold ${row.errors.length ? "bg-red-500/15 text-red-100" : row.duplicate ? "bg-amber-300/15 text-amber-100" : "bg-cyan-300/15 text-cyan-100"}`}>
|
||||||
|
{row.errors.length ? "Invalid" : row.duplicate ? "Duplicate" : "Ready"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-slate-300">{row.entry ? humanDateTime(row.entry.dateTime) : "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-white">{row.entry?.flavour ?? "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-300">{row.entry ? `${row.entry.sizeMl}ml` : "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-300">{row.entry?.cans ?? "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-400">{row.errors.join(" ") || row.duplicateReason || "Looks good."}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{preview.rows.map((row) => (
|
</table>
|
||||||
<tr key={row.rowNumber} className="border-t border-white/10">
|
</div>
|
||||||
<td className="px-3 py-3 text-slate-400">{row.rowNumber}</td>
|
|
||||||
<td className="px-3 py-3">
|
|
||||||
<span className={`rounded px-2 py-1 text-xs font-semibold ${row.errors.length ? "bg-red-500/15 text-red-100" : row.duplicate ? "bg-amber-300/15 text-amber-100" : "bg-cyan-300/15 text-cyan-100"}`}>
|
|
||||||
{row.errors.length ? "Invalid" : row.duplicate ? "Duplicate" : "Ready"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 text-slate-300">{row.entry ? humanDateTime(row.entry.dateTime) : "-"}</td>
|
|
||||||
<td className="px-3 py-3 text-white">{row.entry?.flavour ?? "-"}</td>
|
|
||||||
<td className="px-3 py-3 text-slate-300">{row.entry ? `${row.entry.sizeMl}ml` : "-"}</td>
|
|
||||||
<td className="px-3 py-3 text-slate-300">{row.entry?.cans ?? "-"}</td>
|
|
||||||
<td className="px-3 py-3 text-slate-400">{row.errors.join(" ") || row.duplicateReason || "Looks good."}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className="excel-button justify-center" type="button" disabled={!validRows.length || busy} onClick={onConfirm}>
|
<button className="excel-button justify-center" type="button" disabled={!validRows.length || busy} onClick={onConfirm}>
|
||||||
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <FileSpreadsheet size={17} aria-hidden="true" />}
|
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <FileSpreadsheet size={17} aria-hidden="true" />}
|
||||||
Import {validRows.length} row{validRows.length === 1 ? "" : "s"}
|
Import {validRows.length} row{validRows.length === 1 ? "" : "s"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
const DEFAULT_BACKDROP_CLASS =
|
||||||
|
"modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl";
|
||||||
|
|
||||||
|
// Tracks open modals in stacking order so only the topmost one answers
|
||||||
|
// keyboard events when dialogs are layered (e.g. limit confirm over EntryModal).
|
||||||
|
const openModalIds: number[] = [];
|
||||||
|
let modalIdCounter = 0;
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
labelledBy?: string;
|
||||||
|
label?: string;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
initialFocusRef?: RefObject<HTMLElement>;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
labelledBy,
|
||||||
|
label,
|
||||||
|
closeOnEscape = true,
|
||||||
|
closeOnBackdrop = false,
|
||||||
|
initialFocusRef,
|
||||||
|
className = DEFAULT_BACKDROP_CLASS,
|
||||||
|
children,
|
||||||
|
}: ModalProps) {
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
const modalIdRef = useRef<number | null>(null);
|
||||||
|
if (modalIdRef.current === null) modalIdRef.current = ++modalIdCounter;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
|
||||||
|
const modalId = modalIdRef.current as number;
|
||||||
|
openModalIds.push(modalId);
|
||||||
|
const isTopmost = () => openModalIds[openModalIds.length - 1] === modalId;
|
||||||
|
const backdrop = backdropRef.current;
|
||||||
|
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
const focusTimer = window.setTimeout(() => {
|
||||||
|
if (!isTopmost()) return;
|
||||||
|
if (!backdrop) return;
|
||||||
|
const target = initialFocusRef?.current ?? backdrop.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||||
|
if (target) target.focus();
|
||||||
|
else backdrop.focus();
|
||||||
|
}, 80);
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!isTopmost()) return;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (closeOnEscape) onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
if (!backdrop) return;
|
||||||
|
const focusable = Array.from(backdrop.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||||
|
if (!focusable.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (active === first || !backdrop.contains(active)) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (active === last || !backdrop.contains(active)) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
const stackIndex = openModalIds.indexOf(modalId);
|
||||||
|
if (stackIndex !== -1) openModalIds.splice(stackIndex, 1);
|
||||||
|
window.clearTimeout(focusTimer);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (
|
||||||
|
previouslyFocused &&
|
||||||
|
(!active || active === document.body || (backdrop?.contains(active) ?? false))
|
||||||
|
) {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open, closeOnEscape, initialFocusRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
ref={backdropRef}
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
aria-label={label}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (closeOnBackdrop && event.target === event.currentTarget) onCloseRef.current();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { BuiltInSize, UserLimits } from "../types";
|
import type { BuiltInSize, UserLimits } from "../types";
|
||||||
|
|
||||||
type OnboardingScreenProps = {
|
type OnboardingScreenProps = {
|
||||||
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
|
onSave: (limits: UserLimits, themeId: string) => Promise<boolean>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
activeThemeId: string;
|
activeThemeId: string;
|
||||||
onThemeChange: (themeId: string) => void;
|
onThemeChange: (themeId: string) => void;
|
||||||
@@ -44,6 +44,7 @@ export function OnboardingScreen({
|
|||||||
);
|
);
|
||||||
const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00");
|
const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
const activeTheme = useMemo(() => {
|
const activeTheme = useMemo(() => {
|
||||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
||||||
}, [activeThemeId]);
|
}, [activeThemeId]);
|
||||||
@@ -56,6 +57,7 @@ export function OnboardingScreen({
|
|||||||
|
|
||||||
async function handleFinish() {
|
async function handleFinish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setSaveError("");
|
||||||
try {
|
try {
|
||||||
const limits: UserLimits = {};
|
const limits: UserLimits = {};
|
||||||
if (dailyCanLimit !== "none") {
|
if (dailyCanLimit !== "none") {
|
||||||
@@ -65,10 +67,14 @@ export function OnboardingScreen({
|
|||||||
}
|
}
|
||||||
if (stopTime !== "none") limits.stopTime = stopTime;
|
if (stopTime !== "none") limits.stopTime = stopTime;
|
||||||
|
|
||||||
await onSave(limits, activeThemeId);
|
const saved = await onSave(limits, activeThemeId);
|
||||||
onClose();
|
if (saved) {
|
||||||
} catch (err) {
|
onClose();
|
||||||
console.error("setup save failed", err);
|
} else {
|
||||||
|
setSaveError("Couldn't save your setup — check your connection and try again.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSaveError("Couldn't save your setup — check your connection and try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -404,16 +410,33 @@ export function OnboardingScreen({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{saveError && (
|
||||||
type="button"
|
<p className="max-w-xl text-sm font-normal" style={{ color: "var(--error)" }} role="alert">
|
||||||
onClick={() => void handleFinish()}
|
{saveError}
|
||||||
disabled={saving}
|
</p>
|
||||||
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
)}
|
||||||
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
|
||||||
>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{saving ? "Saving..." : "Start tracking"}
|
<button
|
||||||
{!saving && <ArrowRight size={16} />}
|
type="button"
|
||||||
</button>
|
onClick={() => void handleFinish()}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Start tracking"}
|
||||||
|
{!saving && <ArrowRight size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex min-h-12 w-fit items-center rounded-full border px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
style={{ borderColor: "var(--outline-variant)", color: "var(--muted)" }}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { AppView, AuthUser, SetupStatus } from "../types";
|
|||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
activeView: AppView;
|
activeView: AppView;
|
||||||
dataLoading: boolean;
|
dataLoading: boolean;
|
||||||
notice: string;
|
|
||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
@@ -18,7 +17,6 @@ type SidebarProps = {
|
|||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
activeView,
|
activeView,
|
||||||
dataLoading,
|
dataLoading,
|
||||||
notice,
|
|
||||||
setupStatus,
|
setupStatus,
|
||||||
user,
|
user,
|
||||||
onAdd,
|
onAdd,
|
||||||
@@ -72,8 +70,7 @@ export function Sidebar({
|
|||||||
{dataLoading ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Cloud size={15} aria-hidden="true" />}
|
{dataLoading ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Cloud size={15} aria-hidden="true" />}
|
||||||
Sync
|
Sync
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-5 text-slate-700">{notice}</p>
|
<p className={`text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
|
||||||
<p className={`mt-2 text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="account-pill" type="button" onClick={onOpenSettings}>
|
<button className="account-pill" type="button" onClick={onOpenSettings}>
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import type { SetupStatus } from "../types";
|
|||||||
|
|
||||||
type StatusRailProps = {
|
type StatusRailProps = {
|
||||||
busyAction: string | null;
|
busyAction: string | null;
|
||||||
syncError: string;
|
|
||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusRail({ busyAction, syncError, setupStatus }: StatusRailProps) {
|
export function StatusRail({ busyAction, setupStatus }: StatusRailProps) {
|
||||||
if (!busyAction && !syncError && setupStatus.state === "ok") return null;
|
if (!busyAction && setupStatus.state === "ok") return null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 grid gap-2">
|
<div className="mt-3 grid gap-2">
|
||||||
{busyAction && (
|
{busyAction && (
|
||||||
@@ -17,12 +16,6 @@ export function StatusRail({ busyAction, syncError, setupStatus }: StatusRailPro
|
|||||||
Working on {actionLabel(busyAction)}...
|
Working on {actionLabel(busyAction)}...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{syncError && (
|
|
||||||
<div className="status-card status-card--error">
|
|
||||||
<AlertTriangle size={17} aria-hidden="true" />
|
|
||||||
{syncError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{setupStatus.state === "error" && (
|
{setupStatus.state === "error" && (
|
||||||
<div className="status-card status-card--warning">
|
<div className="status-card status-card--warning">
|
||||||
<AlertTriangle size={17} aria-hidden="true" />
|
<AlertTriangle size={17} aria-hidden="true" />
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { ToastContext, type ToastInput, type ToastTone } from "../lib/toasts";
|
||||||
|
|
||||||
|
const TOAST_DURATION_MS = 4500;
|
||||||
|
const MAX_TOASTS = 4;
|
||||||
|
// Identical tone+message repeats inside this window are swallowed so
|
||||||
|
// StrictMode double effects and rapid state re-sets never double-fire.
|
||||||
|
const DEDUPE_WINDOW_MS = 1200;
|
||||||
|
|
||||||
|
type Toast = ToastInput & { id: number };
|
||||||
|
|
||||||
|
const TONE_ICONS: Record<ToastTone, typeof Info> = {
|
||||||
|
success: CheckCircle2,
|
||||||
|
error: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
const nextIdRef = useRef(0);
|
||||||
|
const lastToastRef = useRef<(ToastInput & { at: number }) | null>(null);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: number) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pushToast = useCallback(
|
||||||
|
({ tone, message }: ToastInput) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = lastToastRef.current;
|
||||||
|
if (last && last.tone === tone && last.message === message && now - last.at < DEDUPE_WINDOW_MS) return;
|
||||||
|
lastToastRef.current = { tone, message, at: now };
|
||||||
|
const id = ++nextIdRef.current;
|
||||||
|
setToasts((current) => [...current, { id, tone, message }].slice(-MAX_TOASTS));
|
||||||
|
window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS);
|
||||||
|
},
|
||||||
|
[dismissToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ pushToast }), [pushToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack" role="status" aria-live="polite">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const ToneIcon = TONE_ICONS[toast.tone];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={toast.id}
|
||||||
|
className={`toast toast--${toast.tone}`}
|
||||||
|
initial={{ opacity: 0, y: -12, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
>
|
||||||
|
<ToneIcon className="toast-icon" size={18} aria-hidden="true" />
|
||||||
|
<p className="toast-message">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
className="toast-dismiss"
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissToast(toast.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ type LogbookViewProps = {
|
|||||||
totalEntries: number;
|
totalEntries: number;
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
flavours: Flavour[];
|
flavours: Flavour[];
|
||||||
|
deletingId: string | null;
|
||||||
onFilterChange: (filters: Filters) => void;
|
onFilterChange: (filters: Filters) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (entry: RedBullEntry) => void;
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
@@ -19,6 +20,7 @@ export function LogbookView({
|
|||||||
totalEntries,
|
totalEntries,
|
||||||
filters,
|
filters,
|
||||||
flavours,
|
flavours,
|
||||||
|
deletingId,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -28,7 +30,7 @@ export function LogbookView({
|
|||||||
<section className="grid gap-4">
|
<section className="grid gap-4">
|
||||||
<div className="logbook-layout grid gap-4">
|
<div className="logbook-layout grid gap-4">
|
||||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
|
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
|
||||||
<EntryLedger entries={entries} totalEntries={totalEntries} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />
|
<EntryLedger entries={entries} totalEntries={totalEntries} deletingId={deletingId} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />
|
||||||
</div>
|
</div>
|
||||||
<LegalFootnote />
|
<LegalFootnote />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type SettingsViewProps = {
|
|||||||
summary: Dashboard;
|
summary: Dashboard;
|
||||||
dataLoading: boolean;
|
dataLoading: boolean;
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
notice: string;
|
|
||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
themeId: string;
|
themeId: string;
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -41,7 +40,6 @@ export function SettingsView({
|
|||||||
summary,
|
summary,
|
||||||
dataLoading,
|
dataLoading,
|
||||||
entries,
|
entries,
|
||||||
notice,
|
|
||||||
setupStatus,
|
setupStatus,
|
||||||
themeId,
|
themeId,
|
||||||
themeMode,
|
themeMode,
|
||||||
@@ -145,9 +143,8 @@ export function SettingsView({
|
|||||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.05] p-4">
|
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.05] p-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
{dataLoading ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Cloud size={16} aria-hidden="true" />}
|
{dataLoading ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Cloud size={16} aria-hidden="true" />}
|
||||||
{notice}
|
{setupStatus.message}
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-2 text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary-button mt-4 justify-center" type="button" onClick={onLogout}>
|
<button className="secondary-button mt-4 justify-center" type="button" onClick={onLogout}>
|
||||||
<LogOut size={17} aria-hidden="true" />
|
<LogOut size={17} aria-hidden="true" />
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ textarea:focus-visible {
|
|||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-button,
|
.secondary-button,
|
||||||
.danger-button,
|
.danger-button,
|
||||||
|
.warning-button,
|
||||||
.excel-button,
|
.excel-button,
|
||||||
.list-button,
|
.list-button,
|
||||||
.icon-button,
|
.icon-button,
|
||||||
@@ -232,6 +233,12 @@ textarea:focus-visible {
|
|||||||
border-color: color-mix(in srgb, var(--error, #ba1a1a) 32%, transparent);
|
border-color: color-mix(in srgb, var(--error, #ba1a1a) 32%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warning-button {
|
||||||
|
color: var(--on-warning-container, #291800);
|
||||||
|
background: var(--warning-container, #ffddb0);
|
||||||
|
border-color: color-mix(in srgb, var(--warning, #8d5700) 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-nav,
|
.drawer-nav,
|
||||||
.drawer-footer {
|
.drawer-footer {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -603,6 +610,95 @@ textarea:focus-visible {
|
|||||||
border-color: color-mix(in srgb, var(--warning, #8d5700) 30%, transparent);
|
border-color: color-mix(in srgb, var(--warning, #8d5700) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(12px + env(safe-area-inset-top, 0px));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 110;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(420px, calc(100vw - 24px));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--outline-variant, #d8e1ee);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--surface-container-lowest, #ffffff);
|
||||||
|
color: var(--text, #202124);
|
||||||
|
box-shadow: var(--elevation-2, 0 12px 28px rgba(60, 64, 67, 0.14));
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--success {
|
||||||
|
border-color: color-mix(in srgb, var(--success, #0d652d) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--success .toast-icon {
|
||||||
|
color: var(--success, #0d652d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--error {
|
||||||
|
border-color: color-mix(in srgb, var(--error, #ba1a1a) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--error .toast-icon {
|
||||||
|
color: var(--error, #ba1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--info {
|
||||||
|
border-color: color-mix(in srgb, var(--primary, #2563c7) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--info .toast-icon {
|
||||||
|
color: var(--primary, #2563c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-dismiss {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: -4px -6px -4px 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted, #68747c);
|
||||||
|
transition: background 0.16s ease, color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-dismiss:hover {
|
||||||
|
background: color-mix(in srgb, var(--text, #202124) 8%, transparent);
|
||||||
|
color: var(--text, #202124);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.toast-stack {
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
right: 20px;
|
||||||
|
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.summary-panel,
|
.summary-panel,
|
||||||
.can-panel {
|
.can-panel {
|
||||||
background: var(--surface-container-lowest, #ffffff);
|
background: var(--surface-container-lowest, #ffffff);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type ToastTone = "success" | "error" | "info";
|
||||||
|
|
||||||
|
export type ToastInput = {
|
||||||
|
tone: ToastTone;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToastContextValue = {
|
||||||
|
pushToast: (toast: ToastInput) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useToasts(): ToastContextValue {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) throw new Error("useToasts must be used within a ToastProvider.");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ToastProvider } from "./components/Toasts";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user