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,
|
||||
} 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<PendingLimitAction | null>(null);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const { pushToast } = useToasts();
|
||||
const excelFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const jsonFileInputRef = useRef<HTMLInputElement>(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<boolean> {
|
||||
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() {
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
dataLoading={dataLoading}
|
||||
notice={notice}
|
||||
setupStatus={setupStatus}
|
||||
user={user}
|
||||
onAdd={openNewEntry}
|
||||
@@ -617,7 +632,7 @@ function App() {
|
||||
className={activeView === "overview" ? "top-app-bar--overview" : ""}
|
||||
/>
|
||||
|
||||
<StatusRail busyAction={busyAction} syncError={syncError} setupStatus={setupStatus} />
|
||||
<StatusRail busyAction={busyAction} setupStatus={setupStatus} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
@@ -654,13 +669,14 @@ function App() {
|
||||
totalEntries={entries.length}
|
||||
filters={filters}
|
||||
flavours={allFlavours}
|
||||
deletingId={deletingId}
|
||||
onFilterChange={setFilters}
|
||||
onAdd={openNewEntry}
|
||||
onEdit={(entry) => {
|
||||
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()}
|
||||
/>
|
||||
|
||||
<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
|
||||
busy={Boolean(busyAction && pendingLimitAction)}
|
||||
open={limitConfirmOpen}
|
||||
title="Over your limit?"
|
||||
body={limitConfirmMessage || "This intake goes past one of your daily limits."}
|
||||
confirmLabel="Log anyway"
|
||||
tone="warning"
|
||||
onCancel={() => {
|
||||
setLimitConfirmOpen(false);
|
||||
setPendingLimitAction(null);
|
||||
|
||||
Reference in New Issue
Block a user