Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107)
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) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -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 { 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<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 (
|
||||
<Modal title="Delete garden" onClose={onClose} busy={deletion.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
|
||||
This can't be undone.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<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>
|
||||
<ConfirmModal
|
||||
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">
|
||||
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
|
||||
This can't be undone.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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 (
|
||||
<Modal title="Leave garden" onClose={onClose} busy={remove.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
|
||||
shares it with you again.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<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>
|
||||
<ConfirmModal
|
||||
title="Leave garden"
|
||||
confirmLabel="Leave"
|
||||
busyLabel="Leaving…"
|
||||
confirmDisabled={!me.data}
|
||||
errorFallback="Could not leave the garden."
|
||||
onConfirm={async () => {
|
||||
if (me.data) await remove.mutateAsync(me.data.id)
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
|
||||
shares it with you again.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<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">
|
||||
🌱 pansy
|
||||
</Link>
|
||||
@@ -87,7 +92,10 @@ export function AppShell() {
|
||||
|
||||
<main
|
||||
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
|
||||
// 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',
|
||||
|
||||
@@ -1,46 +1,26 @@
|
||||
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 { useDeletePlant, type Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* message inline rather than pretending it worked.
|
||||
* plantings is refused by the server (409 PLANT_IN_USE); ConfirmModal surfaces
|
||||
* that message inline rather than pretending it worked.
|
||||
*/
|
||||
export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
|
||||
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 (
|
||||
<Modal title="Delete plant" onClose={onClose} busy={deletion.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
|
||||
undone.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<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>
|
||||
<ConfirmModal
|
||||
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">
|
||||
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
|
||||
undone.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
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 }) {
|
||||
const del = useDeleteSeedLot()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-fg">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<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>
|
||||
<ConfirmModal
|
||||
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">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useClearObject } from '@/lib/objects'
|
||||
|
||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||
@@ -19,27 +18,20 @@ export function ClearBedModal({
|
||||
}) {
|
||||
const clear = useClearObject(gardenId)
|
||||
return (
|
||||
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<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>
|
||||
<ConfirmModal
|
||||
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">
|
||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user