From 9a8382c4a23333f84c5d1b26e5b7098b40c4bc89 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 10:00:07 -0400 Subject: [PATCH] Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tidy-ups from the audit's deferred list: - Extracted a ConfirmModal primitive (message + Cancel/Confirm, owning the busy lock + inline error) and folded the five hand-rolled confirm dialogs onto it: DeleteGarden, LeaveGarden, DeletePlant, DeleteSeedLot, ClearBed. Each is now just its message + mutation + labels. Bonus: ClearBed now shows a failure inline instead of swallowing it. (CopyGarden stays on Modal — it has a name field, not a plain confirm.) - Removed components/PageStub.tsx — dead scaffolding, imported nowhere. - The garden editor was squeezed into the max-w-5xl reading measure the other pages use; the canvas routes (editor + public garden) now go edge-to-edge on desktop, and the top bar matches so the brand aligns with the editor's left edge. Mobile was already full-width, so it's unchanged. Verified live: the Delete-garden confirm renders/cancels; the desktop editor now uses the full viewport width. tsc + vitest + build green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/components/PageStub.tsx | 13 --- .../components/gardens/DeleteGardenModal.tsx | 48 ++++------- .../components/gardens/LeaveGardenModal.tsx | 52 ++++-------- web/src/components/layout/AppShell.tsx | 14 +++- .../components/plants/DeletePlantModal.tsx | 52 ++++-------- .../components/plants/DeleteSeedLotModal.tsx | 56 +++++-------- web/src/components/ui/ConfirmModal.tsx | 79 +++++++++++++++++++ web/src/editor/ClearBedModal.tsx | 40 ++++------ 8 files changed, 171 insertions(+), 183 deletions(-) delete mode 100644 web/src/components/PageStub.tsx create mode 100644 web/src/components/ui/ConfirmModal.tsx diff --git a/web/src/components/PageStub.tsx b/web/src/components/PageStub.tsx deleted file mode 100644 index 6ce21ba..0000000 --- a/web/src/components/PageStub.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReactNode } from 'react' - -/** Placeholder page scaffolding used until each feature issue fills these in. */ -export function PageStub({ title, children }: { title: string; children?: ReactNode }) { - return ( -
-

{title}

-

- {children ?? 'Placeholder — this page arrives in a later issue.'} -

-
- ) -} diff --git a/web/src/components/gardens/DeleteGardenModal.tsx b/web/src/components/gardens/DeleteGardenModal.tsx index 195552b..c187c89 100644 --- a/web/src/components/gardens/DeleteGardenModal.tsx +++ b/web/src/components/gardens/DeleteGardenModal.tsx @@ -1,42 +1,22 @@ -import { useState } from 'react' -import { Modal } from '@/components/ui/Modal' -import { Alert } from '@/components/ui/Alert' -import { Button } from '@/components/ui/Button' -import { errorMessage } from '@/lib/api' +import { ConfirmModal } from '@/components/ui/ConfirmModal' import { useDeleteGarden, type Garden } from '@/lib/gardens' /** Confirmation dialog for deleting a garden (and everything in it). */ export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { const deletion = useDeleteGarden() - const [error, setError] = useState(null) - - async function onConfirm() { - setError(null) - try { - await deletion.mutateAsync(garden.id) - onClose() - } catch (err) { - setError(errorMessage(err, 'Could not delete the garden.')) - } - } - return ( - -
-

- Delete {garden.name} and everything planned in it? - This can't be undone. -

- {error && {error}} -
- - -
-
-
+ deletion.mutateAsync(garden.id)} + onClose={onClose} + > +

+ Delete {garden.name} and everything planned in it? + This can't be undone. +

+
) } diff --git a/web/src/components/gardens/LeaveGardenModal.tsx b/web/src/components/gardens/LeaveGardenModal.tsx index 85c9dce..2b82e26 100644 --- a/web/src/components/gardens/LeaveGardenModal.tsx +++ b/web/src/components/gardens/LeaveGardenModal.tsx @@ -1,8 +1,4 @@ -import { useState } from 'react' -import { Modal } from '@/components/ui/Modal' -import { Alert } from '@/components/ui/Alert' -import { Button } from '@/components/ui/Button' -import { errorMessage } from '@/lib/api' +import { ConfirmModal } from '@/components/ui/ConfirmModal' import { useMe } from '@/lib/auth' import type { Garden } from '@/lib/gardens' import { useRemoveShare } from '@/lib/shares' @@ -12,36 +8,22 @@ import { useRemoveShare } from '@/lib/shares' export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { const me = useMe() const remove = useRemoveShare(garden.id) - const [error, setError] = useState(null) - - async function onConfirm() { - if (!me.data) return - setError(null) - try { - await remove.mutateAsync(me.data.id) - onClose() - } catch (err) { - setError(errorMessage(err, 'Could not leave the garden.')) - } - } - return ( - -
-

- Leave {garden.name}? You'll lose access until the owner - shares it with you again. -

- {error && {error}} -
- - -
-
-
+ { + if (me.data) await remove.mutateAsync(me.data.id) + }} + onClose={onClose} + > +

+ Leave {garden.name}? You'll lose access until the owner + shares it with you again. +

+
) } diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 7e525df..76973e9 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -39,14 +39,19 @@ export function AppShell() { // so the '/gardens' list itself still shows the bar. const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false }) const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false }) - const showBottomNav = !!user && !inEditor && !inPublicGarden + // A canvas route wants the whole width — the garden editor is squeezed by the + // max-w-5xl reading measure the other pages use (#107). + const canvasRoute = inEditor || inPublicGarden + const showBottomNav = !!user && !canvasRoute const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin) return (
-