Add gardens UI: list page + create/edit/delete (#8)
The /gardens page is now home base, backed by the #7 API. - lib/units.ts: cm <-> meters/feet conversion + formatCm/formatDimensions (metric "m", imperial "ft/in"). Minimal for #8; #9's geometry lib consolidates. - lib/gardens.ts: zod gardenSchema + useGardens/useCreateGarden/ useUpdateGarden/useDeleteGarden (mutations invalidate the list) and conflictGarden() to pull the fresh row out of a 409. - GardensPage: loading/empty/error states; empty state prompts a first garden; responsive card grid (single column on phones). - GardenCard: name, dimensions formatted per the garden's unit, notes preview; body links into /gardens/:id, edit/delete kept out of the link. - GardenFormModal: create/edit with a unit selector; dimensions are entered in the chosen unit and converted to cm (switching units converts the current values). A 409 rebases the form onto the server's fresh row. - DeleteGardenModal: confirmation before removing. - ui/: Modal (Escape/backdrop close), TextArea, Select. Verified in a real browser against the embedded binary: create appears without reload; edit persists (version 2), form prefilled from stored cm converted back to the garden's unit; delete confirms then removes -> empty state; an imperial 4 ft x 8 ft garden stores 122 x 244 cm and shows "4' 0" x 8' 0"". tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
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 { 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 del = useDeleteGarden()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onConfirm() {
|
||||
setError(null)
|
||||
try {
|
||||
await del.mutateAsync(garden.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not delete the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Delete garden" onClose={onClose}>
|
||||
<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={del.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={del.isPending}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{del.isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { formatDimensions } from '@/lib/units'
|
||||
|
||||
/**
|
||||
* One garden as a card: the body links into the editor (/gardens/:id); the
|
||||
* footer has edit/delete actions (kept out of the link so they don't navigate).
|
||||
*/
|
||||
export function GardenCard({
|
||||
garden,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
garden: Garden
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
|
||||
<Link
|
||||
to="/gardens/$gardenId"
|
||||
params={{ gardenId: String(garden.id) }}
|
||||
className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||
</p>
|
||||
{garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>}
|
||||
</Link>
|
||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded-md px-2.5 py-1 text-sm font-medium text-muted transition-colors hover:bg-border/50 hover:text-fg"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded-md px-2.5 py-1 text-sm font-medium text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||
import { cmFromDisplay, dimensionUnitLabel, displayFromCm, type UnitPref } from '@/lib/units'
|
||||
|
||||
const DEFAULT_METERS = 10 // matches the server's 10 m default
|
||||
|
||||
const unitOptions = [
|
||||
{ value: 'metric', label: 'Metric (m)' },
|
||||
{ value: 'imperial', label: 'Imperial (ft)' },
|
||||
]
|
||||
|
||||
function dimString(cm: number | undefined, unit: UnitPref): string {
|
||||
return cm === undefined ? String(DEFAULT_METERS) : String(displayFromCm(cm, unit))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (no garden) or edit (garden given) form. Dimensions are entered in the
|
||||
* selected unit and converted to centimeters for the API; switching units
|
||||
* converts the current values so the physical size is preserved. A 409 rebases
|
||||
* the form onto the server's fresh row.
|
||||
*/
|
||||
export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
||||
const isEdit = !!garden
|
||||
const create = useCreateGarden()
|
||||
const update = useUpdateGarden(garden?.id ?? 0)
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
const [name, setName] = useState(garden?.name ?? '')
|
||||
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
|
||||
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
|
||||
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
|
||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||
const [version, setVersion] = useState(garden?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
function changeUnit(next: UnitPref) {
|
||||
const convert = (s: string) => {
|
||||
const v = parseFloat(s)
|
||||
return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : s
|
||||
}
|
||||
setWidth(convert(width))
|
||||
setHeight(convert(height))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
setConflict(null)
|
||||
|
||||
const w = parseFloat(width)
|
||||
const h = parseFloat(height)
|
||||
if (!name.trim() || !Number.isFinite(w) || w <= 0 || !Number.isFinite(h) || h <= 0) {
|
||||
setFormError('Enter a name and a positive width and height.')
|
||||
return
|
||||
}
|
||||
|
||||
const input = {
|
||||
name: name.trim(),
|
||||
widthCm: cmFromDisplay(w, unit),
|
||||
heightCm: cmFromDisplay(h, unit),
|
||||
unitPref: unit,
|
||||
notes: notes.trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({ ...input, version })
|
||||
} else {
|
||||
await create.mutateAsync(input)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictGarden(err)
|
||||
if (current) {
|
||||
// Someone else changed this garden: rebase the form onto the fresh row so
|
||||
// a re-save applies against the current version.
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setUnit(current.unitPref)
|
||||
setWidth(String(displayFromCm(current.widthCm, current.unitPref)))
|
||||
setHeight(String(displayFromCm(current.heightCm, current.unitPref)))
|
||||
setNotes(current.notes)
|
||||
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const unitLabel = dimensionUnitLabel(unit)
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
|
||||
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<Select
|
||||
label="Units"
|
||||
name="unitPref"
|
||||
value={unit}
|
||||
onChange={(e) => changeUnit(e.target.value as UnitPref)}
|
||||
options={unitOptions}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label={`Width (${unitLabel})`}
|
||||
name="width"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={`Height (${unitLabel})`}
|
||||
name="height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create garden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* A centered modal dialog rendered over a dimmed backdrop. Closes on Escape or a
|
||||
* backdrop click. Wraps content in a native <dialog>-like card; the caller owns
|
||||
* the open/closed state (render it only when open).
|
||||
*/
|
||||
export function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
cardRef.current?.focus()
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-lg outline-none"
|
||||
>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-fg">{title}</h2>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { forwardRef, useId, type SelectHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label: string
|
||||
options: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
// Labelled native <select>, styled to match the text inputs.
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ label, options, id, name, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const generatedId = useId()
|
||||
const fieldId = id ?? name ?? generatedId
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
name={name}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-surface px-3 py-2 text-base text-fg',
|
||||
'outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { forwardRef, useId, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label: string
|
||||
}
|
||||
|
||||
// Labelled multi-line input, matching TextField's styling (16px text avoids iOS
|
||||
// zoom). The id falls back to name then a generated id so the label always binds.
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
||||
{ label, id, name, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const generatedId = useId()
|
||||
const fieldId = id ?? name ?? generatedId
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
name={name}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-surface px-3 py-2 text-base text-fg',
|
||||
'placeholder:text-muted/70 outline-none transition-colors',
|
||||
'focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
// Gardens data layer: zod-validated shapes for /api/v1/gardens plus the
|
||||
// react-query hooks the /gardens page uses.
|
||||
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
import type { UnitPref } from './units'
|
||||
|
||||
const unitPrefSchema: z.ZodType<UnitPref> = z.enum(['metric', 'imperial'])
|
||||
|
||||
export const gardenSchema = z.object({
|
||||
id: z.number(),
|
||||
ownerId: z.number(),
|
||||
name: z.string(),
|
||||
widthCm: z.number(),
|
||||
heightCm: z.number(),
|
||||
unitPref: unitPrefSchema,
|
||||
notes: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type Garden = z.infer<typeof gardenSchema>
|
||||
|
||||
const gardensKey = ['gardens'] as const
|
||||
|
||||
export const gardensQueryOptions = queryOptions({
|
||||
queryKey: gardensKey,
|
||||
queryFn: async (): Promise<Garden[]> => z.array(gardenSchema).parse(await api.get('/gardens')),
|
||||
})
|
||||
|
||||
export function useGardens() {
|
||||
return useQuery(gardensQueryOptions)
|
||||
}
|
||||
|
||||
export interface GardenInput {
|
||||
name: string
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
unitPref: UnitPref
|
||||
notes: string
|
||||
}
|
||||
|
||||
export function useCreateGarden() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: GardenInput): Promise<Garden> =>
|
||||
gardenSchema.parse(await api.post('/gardens', input)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface GardenUpdate extends GardenInput {
|
||||
version: number
|
||||
}
|
||||
|
||||
export function useUpdateGarden(id: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: GardenUpdate): Promise<Garden> =>
|
||||
gardenSchema.parse(await api.patch(`/gardens/${id}`, input)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteGarden() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/gardens/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* If err is a 409 version conflict, return the fresh server row it carries
|
||||
* (under `current`), so a form can rebase onto it; otherwise null.
|
||||
*/
|
||||
export function conflictGarden(err: unknown): Garden | null {
|
||||
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
|
||||
const current = (err.body as { current?: unknown }).current
|
||||
const parsed = gardenSchema.safeParse(current)
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Unit conversion + display for garden dimensions. The API and DB are metric
|
||||
// only (centimeters); imperial is purely a display/entry concern here. A minimal
|
||||
// helper set for #8 — #9's geometry lib consolidates/extends it.
|
||||
|
||||
export type UnitPref = 'metric' | 'imperial'
|
||||
|
||||
const CM_PER_INCH = 2.54
|
||||
const CM_PER_METER = 100
|
||||
const INCHES_PER_FOOT = 12
|
||||
|
||||
/** Feet (+ optional inches) → whole centimeters. */
|
||||
export function cmFromFtIn(feet: number, inches = 0): number {
|
||||
return Math.round((feet * INCHES_PER_FOOT + inches) * CM_PER_INCH)
|
||||
}
|
||||
|
||||
/** Meters → whole centimeters. */
|
||||
export function cmFromMeters(meters: number): number {
|
||||
return Math.round(meters * CM_PER_METER)
|
||||
}
|
||||
|
||||
/** A value typed in the given unit (meters, or feet) → centimeters. */
|
||||
export function cmFromDisplay(value: number, unit: UnitPref): number {
|
||||
return unit === 'imperial' ? cmFromFtIn(value) : cmFromMeters(value)
|
||||
}
|
||||
|
||||
/** Centimeters → a number in the given unit, for prefilling an input field. */
|
||||
export function displayFromCm(cm: number, unit: UnitPref): number {
|
||||
const value = unit === 'imperial' ? cm / CM_PER_INCH / INCHES_PER_FOOT : cm / CM_PER_METER
|
||||
// Trim floating-point noise (e.g. 122cm → 4.0026ft → 4).
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
/** Centimeters → a human string in the given unit (e.g. "1.22 m" or "4′ 0″"). */
|
||||
export function formatCm(cm: number, unit: UnitPref): string {
|
||||
if (unit === 'imperial') {
|
||||
const totalInches = cm / CM_PER_INCH
|
||||
let feet = Math.floor(totalInches / INCHES_PER_FOOT)
|
||||
let inches = Math.round(totalInches - feet * INCHES_PER_FOOT)
|
||||
if (inches === INCHES_PER_FOOT) {
|
||||
feet += 1
|
||||
inches = 0
|
||||
}
|
||||
return `${feet}′ ${inches}″`
|
||||
}
|
||||
const meters = Math.round((cm / CM_PER_METER) * 100) / 100
|
||||
return `${meters} m`
|
||||
}
|
||||
|
||||
/** Centimeters width × height → a human string in the given unit. */
|
||||
export function formatDimensions(widthCm: number, heightCm: number, unit: UnitPref): string {
|
||||
return `${formatCm(widthCm, unit)} × ${formatCm(heightCm, unit)}`
|
||||
}
|
||||
|
||||
/** The input-field label for a single dimension in the given unit. */
|
||||
export function dimensionUnitLabel(unit: UnitPref): string {
|
||||
return unit === 'imperial' ? 'ft' : 'm'
|
||||
}
|
||||
@@ -1,5 +1,58 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal'
|
||||
import { GardenCard } from '@/components/gardens/GardenCard'
|
||||
import { GardenFormModal } from '@/components/gardens/GardenFormModal'
|
||||
import { useGardens, type Garden } from '@/lib/gardens'
|
||||
|
||||
// Which modal is open, if any. `edit`/`delete` carry the target garden.
|
||||
type Dialog = { kind: 'create' } | { kind: 'edit'; garden: Garden } | { kind: 'delete'; garden: Garden } | null
|
||||
|
||||
export function GardensPage() {
|
||||
return <PageStub title="Gardens">The garden list and create flow arrive in issue #8.</PageStub>
|
||||
const gardens = useGardens()
|
||||
const [dialog, setDialog] = useState<Dialog>(null)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Gardens</h1>
|
||||
{gardens.data && gardens.data.length > 0 && (
|
||||
<Button onClick={() => setDialog({ kind: 'create' })}>New garden</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{gardens.isPending && <p className="text-sm text-muted">Loading your gardens…</p>}
|
||||
{gardens.isError && <Alert>Could not load your gardens. Please refresh.</Alert>}
|
||||
|
||||
{gardens.isSuccess && gardens.data.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed border-border p-10 text-center">
|
||||
<p className="text-fg">No gardens yet.</p>
|
||||
<p className="mt-1 text-sm text-muted">Create your first garden to start planning.</p>
|
||||
<Button className="mt-4" onClick={() => setDialog({ kind: 'create' })}>
|
||||
Create your first garden
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gardens.isSuccess && gardens.data.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{gardens.data.map((g) => (
|
||||
<GardenCard
|
||||
key={g.id}
|
||||
garden={g}
|
||||
onEdit={() => setDialog({ kind: 'edit', garden: g })}
|
||||
onDelete={() => setDialog({ kind: 'delete', garden: g })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dialog?.kind === 'create' && <GardenFormModal onClose={() => setDialog(null)} />}
|
||||
{dialog?.kind === 'edit' && <GardenFormModal garden={dialog.garden} onClose={() => setDialog(null)} />}
|
||||
{dialog?.kind === 'delete' && <DeleteGardenModal garden={dialog.garden} onClose={() => setDialog(null)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user