Merge pull request 'Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107)' (#116) from feat/cleanup into main
Build image / build-and-push (push) Successful in 18s

This commit was merged in pull request #116.
This commit is contained in:
2026-07-22 14:05:20 +00:00
9 changed files with 178 additions and 184 deletions
-13
View File
@@ -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 (
<section>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-2 text-sm text-muted">
{children ?? 'Placeholder — this page arrives in a later issue.'}
</p>
</section>
)
}
@@ -1,42 +1,22 @@
import { useState } from 'react' import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { useDeleteGarden, type Garden } from '@/lib/gardens' import { useDeleteGarden, type Garden } from '@/lib/gardens'
/** Confirmation dialog for deleting a garden (and everything in it). */ /** Confirmation dialog for deleting a garden (and everything in it). */
export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const deletion = useDeleteGarden() const deletion = useDeleteGarden()
const [error, setError] = useState<string | null>(null)
async function onConfirm() {
setError(null)
try {
await deletion.mutateAsync(garden.id)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not delete the garden.'))
}
}
return ( return (
<Modal title="Delete garden" onClose={onClose} busy={deletion.isPending}> <ConfirmModal
<div className="flex flex-col gap-4"> title="Delete garden"
<p className="text-sm text-muted"> confirmLabel="Delete"
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it? busyLabel="Deleting…"
This can't be undone. errorFallback="Could not delete the garden."
</p> onConfirm={() => deletion.mutateAsync(garden.id)}
{error && <Alert>{error}</Alert>} onClose={onClose}
<div className="flex justify-end gap-2"> >
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}> <p className="text-sm text-muted">
Cancel Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
</Button> This can't be undone.
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}> </p>
{deletion.isPending ? 'Deleting' : 'Delete'} </ConfirmModal>
</Button>
</div>
</div>
</Modal>
) )
} }
+21 -35
View File
@@ -1,8 +1,4 @@
import { useState } from 'react' import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { useMe } from '@/lib/auth' import { useMe } from '@/lib/auth'
import type { Garden } from '@/lib/gardens' import type { Garden } from '@/lib/gardens'
import { useRemoveShare } from '@/lib/shares' import { useRemoveShare } from '@/lib/shares'
@@ -12,36 +8,26 @@ import { useRemoveShare } from '@/lib/shares'
export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const me = useMe() const me = useMe()
const remove = useRemoveShare(garden.id) const remove = useRemoveShare(garden.id)
const [error, setError] = useState<string | null>(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 ( return (
<Modal title="Leave garden" onClose={onClose} busy={remove.isPending}> <ConfirmModal
<div className="flex flex-col gap-4"> title="Leave garden"
<p className="text-sm text-muted"> confirmLabel="Leave"
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner busyLabel="Leaving…"
shares it with you again. confirmDisabled={!me.data}
</p> errorFallback="Could not leave the garden."
{error && <Alert>{error}</Alert>} onConfirm={async () => {
<div className="flex justify-end gap-2"> // The button is disabled without a current user; throw rather than
<Button type="button" variant="ghost" onClick={onClose} disabled={remove.isPending}> // silently resolve (which would close the dialog as if it had worked) if
Cancel // that guard ever drifts.
</Button> if (!me.data) throw new Error('Not signed in.')
<Button type="button" variant="danger" onClick={onConfirm} disabled={remove.isPending || !me.data}> await remove.mutateAsync(me.data.id)
{remove.isPending ? 'Leaving' : 'Leave'} }}
</Button> onClose={onClose}
</div> >
</div> <p className="text-sm text-muted">
</Modal> Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
shares it with you again.
</p>
</ConfirmModal>
) )
} }
+11 -3
View File
@@ -39,14 +39,19 @@ export function AppShell() {
// so the '/gardens' list itself still shows the bar. // so the '/gardens' list itself still shows the bar.
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false }) const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
const inPublicGarden = !!matchRoute({ to: '/g/$token', 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) const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
return ( return (
<div className="flex min-h-full flex-col"> <div className="flex min-h-full flex-col">
<header className="sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur"> <header className="sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur">
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3"> {/* The bar matches the content width below: constrained on reading pages,
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
<Link to="/gardens" className="text-lg font-semibold text-accent-strong"> <Link to="/gardens" className="text-lg font-semibold text-accent-strong">
🌱 pansy 🌱 pansy
</Link> </Link>
@@ -87,7 +92,10 @@ export function AppShell() {
<main <main
className={cn( className={cn(
'mx-auto w-full max-w-5xl flex-1 px-4 py-6', 'w-full flex-1 px-4 py-6',
// Constrain the reading pages to a comfortable measure; the canvas
// routes go edge-to-edge so the garden gets the whole screen.
!canvasRoute && 'mx-auto max-w-5xl',
// Clear the fixed bottom bar on mobile so content isn't hidden behind // Clear the fixed bottom bar on mobile so content isn't hidden behind
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below). // it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6', showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
+16 -36
View File
@@ -1,46 +1,26 @@
import { useState } from 'react' import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { useDeletePlant, type Plant } from '@/lib/plants' import { useDeletePlant, type Plant } from '@/lib/plants'
/** /**
* Confirmation dialog for deleting a custom plant. A plant still used by * Confirmation dialog for deleting a custom plant. A plant still used by
* plantings is refused by the server (409 PLANT_IN_USE); we surface that * plantings is refused by the server (409 PLANT_IN_USE); ConfirmModal surfaces
* message inline rather than pretending it worked. * that message inline rather than pretending it worked.
*/ */
export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) { export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
const deletion = useDeletePlant() const deletion = useDeletePlant()
const [error, setError] = useState<string | null>(null)
async function onConfirm() {
setError(null)
try {
await deletion.mutateAsync(plant.id)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not delete the plant.'))
}
}
return ( return (
<Modal title="Delete plant" onClose={onClose} busy={deletion.isPending}> <ConfirmModal
<div className="flex flex-col gap-4"> title="Delete plant"
<p className="text-sm text-muted"> confirmLabel="Delete"
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be busyLabel="Deleting…"
undone. errorFallback="Could not delete the plant."
</p> onConfirm={() => deletion.mutateAsync(plant.id)}
{error && <Alert>{error}</Alert>} onClose={onClose}
<div className="flex justify-end gap-2"> >
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}> <p className="text-sm text-muted">
Cancel Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
</Button> undone.
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}> </p>
{deletion.isPending ? 'Deleting' : 'Delete'} </ConfirmModal>
</Button>
</div>
</div>
</Modal>
) )
} }
@@ -1,8 +1,4 @@
import { useState } from 'react' import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { errorMessage } from '@/lib/api'
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots' import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
/** /**
@@ -12,39 +8,23 @@ import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
*/ */
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) { export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
const del = useDeleteSeedLot() const del = useDeleteSeedLot()
const [error, setError] = useState<string | null>(null)
return ( return (
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}> <ConfirmModal
<div className="flex flex-col gap-3"> title="Retire this seed lot?"
<p className="text-sm text-fg"> confirmLabel="Retire lot"
{formatQuantity(lot.quantity)} {lot.unit} busyLabel="Retiring…"
{lot.vendor ? ` from ${lot.vendor}` : ''} errorFallback="Could not retire the lot."
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}. onConfirm={() => del.mutateAsync(lot.id)}
</p> onClose={onClose}
<p className="text-sm text-muted"> >
Anything planted from it stays exactly where it is it just stops being attributed to this purchase. <p className="text-sm text-fg">
</p> {formatQuantity(lot.quantity)} {lot.unit}
{error && <Alert>{error}</Alert>} {lot.vendor ? ` from ${lot.vendor}` : ''}
<div className="mt-1 flex justify-end gap-2"> {lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}> </p>
Cancel <p className="text-sm text-muted">
</Button> Anything planted from it stays exactly where it is it just stops being attributed to this purchase.
<Button </p>
type="button" </ConfirmModal>
variant="danger"
disabled={del.isPending}
onClick={() =>
del.mutate(lot.id, {
onSuccess: onClose,
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
})
}
>
{del.isPending ? 'Retiring…' : 'Retire lot'}
</Button>
</div>
</div>
</Modal>
) )
} }
+79
View File
@@ -0,0 +1,79 @@
import { useState, type ReactNode } 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'
/**
* A confirm-and-act dialog: a message, then Cancel / Confirm. It owns the shared
* shape every confirmation repeated by hand — the busy lock, the inline error on
* failure (so a 409 like PLANT_IN_USE is shown, not swallowed), and the footer —
* so each caller supplies only its message, its action, and its labels.
*
* onConfirm runs the action; it resolving closes the dialog, it throwing keeps
* the dialog open with the error and re-enables the button for a retry. This is
* confirmations only — dialogs with their own inputs (a rename, a form) keep
* using Modal directly.
*/
export function ConfirmModal({
title,
children,
confirmLabel,
busyLabel,
confirmVariant = 'danger',
confirmDisabled = false,
errorFallback,
onConfirm,
onClose,
}: {
title: string
/** The body — what's being confirmed. */
children: ReactNode
confirmLabel: string
/** Label while the action is in flight (e.g. "Deleting…"). */
busyLabel: string
confirmVariant?: 'danger' | 'primary'
/** Extra guard beyond busy (e.g. nothing to clear, no current user). */
confirmDisabled?: boolean
/** Message if the action throws something without its own user-facing text. */
errorFallback: string
onConfirm: () => Promise<unknown>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleConfirm() {
setError(null)
setBusy(true)
try {
await onConfirm()
onClose()
} catch (err) {
setError(errorMessage(err, errorFallback))
setBusy(false) // keep the dialog open so the message shows and retry works
}
}
return (
<Modal title={title} onClose={onClose} busy={busy}>
<div className="flex flex-col gap-4">
{children}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
type="button"
variant={confirmVariant}
onClick={handleConfirm}
disabled={busy || confirmDisabled}
>
{busy ? busyLabel : confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}
+16 -24
View File
@@ -1,5 +1,4 @@
import { Modal } from '@/components/ui/Modal' import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { Button } from '@/components/ui/Button'
import { useClearObject } from '@/lib/objects' import { useClearObject } from '@/lib/objects'
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows /** Confirm clearing every active plop from a focused bed (soft-remove — the rows
@@ -19,27 +18,20 @@ export function ClearBedModal({
}) { }) {
const clear = useClearObject(gardenId) const clear = useClearObject(gardenId)
return ( return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}> <ConfirmModal
<div className="flex flex-col gap-4"> title="Clear bed"
<p className="text-sm text-muted"> confirmLabel="Clear bed"
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '} busyLabel="Clearing…"
{plopCount === 1 ? 'plant' : 'plants'} from{' '} confirmDisabled={plopCount === 0}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history. errorFallback="Could not clear the bed."
</p> onConfirm={() => clear.mutateAsync(objectId)}
<div className="flex justify-end gap-2"> onClose={onClose}
<Button type="button" variant="ghost" onClick={onClose} disabled={clear.isPending}> >
Cancel <p className="text-sm text-muted">
</Button> Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
<Button {plopCount === 1 ? 'plant' : 'plants'} from{' '}
type="button" <span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
variant="danger" </p>
disabled={clear.isPending || plopCount === 0} </ConfirmModal>
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
>
{clear.isPending ? 'Clearing' : 'Clear bed'}
</Button>
</div>
</div>
</Modal>
) )
} }
+3 -1
View File
@@ -381,7 +381,9 @@ export function useClearObject(gardenId: number) {
return res.cleared return res.cleared
}, },
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }), onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')), // No toast: the only caller (ClearBedModal → ConfirmModal) shows the failure
// inline in the dialog, which is more contextual than a detached toast — and
// two of them for one failure is worse than one.
}) })
} }