diff --git a/web/src/components/gardens/DeleteGardenModal.tsx b/web/src/components/gardens/DeleteGardenModal.tsx new file mode 100644 index 0000000..195552b --- /dev/null +++ b/web/src/components/gardens/DeleteGardenModal.tsx @@ -0,0 +1,42 @@ +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 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}} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx new file mode 100644 index 0000000..61828f5 --- /dev/null +++ b/web/src/components/gardens/GardenCard.tsx @@ -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 ( +
+ +

{garden.name}

+

+ {formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)} +

+ {garden.notes &&

{garden.notes}

} + +
+ + +
+
+ ) +} diff --git a/web/src/components/gardens/GardenFormModal.tsx b/web/src/components/gardens/GardenFormModal.tsx new file mode 100644 index 0000000..14a7969 --- /dev/null +++ b/web/src/components/gardens/GardenFormModal.tsx @@ -0,0 +1,163 @@ +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, + isValidDimensionCm, + 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() + const pending = create.isPending || update.isPending + + const [name, setName] = useState(garden?.name ?? '') + const [unit, setUnit] = useState(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(null) + const [formError, setFormError] = useState(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) + + if (!name.trim()) { + setFormError('Enter a name for the garden.') + return + } + // Validate the converted centimeter values against the same bounds the + // server enforces, so sub-cm or over-100m sizes fail here with a clear + // message instead of a generic server error. + const widthCm = cmFromDisplay(parseFloat(width), unit) + const heightCm = cmFromDisplay(parseFloat(height), unit) + if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) { + setFormError('Width and height must be between 1 cm and 100 m.') + return + } + + const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim() } + + try { + if (isEdit) { + await update.mutateAsync({ id: garden.id, ...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(dimString(current.widthCm, current.unitPref)) + setHeight(dimString(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 ( + +
+ {conflict && {conflict}} + + setName(e.target.value)} /> + +