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"
confirmLabel="Delete"
busyLabel="Deleting…"
errorFallback="Could not delete the garden."
onConfirm={() => deletion.mutateAsync(garden.id)}
onClose={onClose}
>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it? Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
This can't be undone. This can't be undone.
</p> </p>
{error && <Alert>{error}</Alert>} </ConfirmModal>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
{deletion.isPending ? 'Deleting' : 'Delete'}
</Button>
</div>
</div>
</Modal>
) )
} }
+17 -31
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"
confirmLabel="Leave"
busyLabel="Leaving…"
confirmDisabled={!me.data}
errorFallback="Could not leave the garden."
onConfirm={async () => {
// The button is disabled without a current user; throw rather than
// silently resolve (which would close the dialog as if it had worked) if
// that guard ever drifts.
if (!me.data) throw new Error('Not signed in.')
await remove.mutateAsync(me.data.id)
}}
onClose={onClose}
>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
shares it with you again. shares it with you again.
</p> </p>
{error && <Alert>{error}</Alert>} </ConfirmModal>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={remove.isPending}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={onConfirm} disabled={remove.isPending || !me.data}>
{remove.isPending ? 'Leaving' : 'Leave'}
</Button>
</div>
</div>
</Modal>
) )
} }
+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',
+12 -32
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"
confirmLabel="Delete"
busyLabel="Deleting…"
errorFallback="Could not delete the plant."
onConfirm={() => deletion.mutateAsync(plant.id)}
onClose={onClose}
>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
undone. undone.
</p> </p>
{error && <Alert>{error}</Alert>} </ConfirmModal>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
{deletion.isPending ? 'Deleting' : 'Delete'}
</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,11 +8,15 @@ 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?"
confirmLabel="Retire lot"
busyLabel="Retiring…"
errorFallback="Could not retire the lot."
onConfirm={() => del.mutateAsync(lot.id)}
onClose={onClose}
>
<p className="text-sm text-fg"> <p className="text-sm text-fg">
{formatQuantity(lot.quantity)} {lot.unit} {formatQuantity(lot.quantity)} {lot.unit}
{lot.vendor ? ` from ${lot.vendor}` : ''} {lot.vendor ? ` from ${lot.vendor}` : ''}
@@ -25,26 +25,6 @@ export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: ()
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Anything planted from it stays exactly where it is it just stops being attributed to this purchase. Anything planted from it stays exactly where it is it just stops being attributed to this purchase.
</p> </p>
{error && <Alert>{error}</Alert>} </ConfirmModal>
<div className="mt-1 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
Cancel
</Button>
<Button
type="button"
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>
)
}
+11 -19
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"
confirmLabel="Clear bed"
busyLabel="Clearing…"
confirmDisabled={plopCount === 0}
errorFallback="Could not clear the bed."
onConfirm={() => clear.mutateAsync(objectId)}
onClose={onClose}
>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '} Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
{plopCount === 1 ? 'plant' : 'plants'} from{' '} {plopCount === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history. <span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p> </p>
<div className="flex justify-end gap-2"> </ConfirmModal>
<Button type="button" variant="ghost" onClick={onClose} disabled={clear.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={clear.isPending || plopCount === 0}
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.
}) })
} }