Build image / build-and-push (push) Successful in 15s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
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 {
|
|
CATEGORY_LABELS,
|
|
PLANT_CATEGORIES,
|
|
conflictPlant,
|
|
useCreatePlant,
|
|
useUpdatePlant,
|
|
type Plant,
|
|
type PlantCategory,
|
|
type PlantInput,
|
|
} from '@/lib/plants'
|
|
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
|
|
|
const DEFAULT_COLOR = '#4a7c3f'
|
|
const DEFAULT_ICON = '🌱'
|
|
|
|
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
|
|
|
/** Expand a #rgb shorthand to #rrggbb so the native color input renders it. */
|
|
function expandHex(color: string): string {
|
|
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
|
const [, r, g, b] = color
|
|
return `#${r}${r}${g}${g}${b}${b}`
|
|
}
|
|
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : DEFAULT_COLOR
|
|
}
|
|
|
|
/**
|
|
* Create or edit a custom plant. `plant` puts it in edit mode; `template` (a
|
|
* built-in or another plant, used by "Duplicate") pre-fills a fresh create. A
|
|
* 409 rebases the form onto the server's current row. Spacing is entered in the
|
|
* page's unit and converted to centimeters for the API.
|
|
*/
|
|
export function PlantFormModal({
|
|
plant,
|
|
template,
|
|
unit,
|
|
onClose,
|
|
}: {
|
|
plant?: Plant
|
|
template?: Plant
|
|
unit: UnitPref
|
|
onClose: () => void
|
|
}) {
|
|
const isEdit = !!plant
|
|
const source = plant ?? template
|
|
const create = useCreatePlant()
|
|
const update = useUpdatePlant()
|
|
const pending = create.isPending || update.isPending
|
|
|
|
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
|
|
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
|
|
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit)))
|
|
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
|
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
|
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
|
const [notes, setNotes] = useState(source?.notes ?? '')
|
|
const [version, setVersion] = useState(plant?.version ?? 0)
|
|
const [conflict, setConflict] = useState<string | null>(null)
|
|
const [formError, setFormError] = useState<string | null>(null)
|
|
const unitLabel = spacingUnitLabel(unit)
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault()
|
|
setFormError(null)
|
|
setConflict(null)
|
|
|
|
if (!name.trim()) {
|
|
setFormError('Enter a name for the plant.')
|
|
return
|
|
}
|
|
if (!icon.trim()) {
|
|
setFormError('Pick an emoji icon.')
|
|
return
|
|
}
|
|
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
|
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
|
setFormError(`Spacing must be at least 1 ${unitLabel}.`)
|
|
return
|
|
}
|
|
let daysToMaturity: number | null = null
|
|
if (days.trim()) {
|
|
// Number() (not parseInt) so "1.5" is rejected, not silently truncated to 1.
|
|
const d = Number(days)
|
|
if (!Number.isInteger(d) || d < 1) {
|
|
setFormError('Days to maturity must be a whole number of days, or left blank.')
|
|
return
|
|
}
|
|
daysToMaturity = d
|
|
}
|
|
|
|
const input: PlantInput = {
|
|
name: name.trim(),
|
|
category,
|
|
spacingCm,
|
|
color,
|
|
icon: icon.trim(),
|
|
daysToMaturity,
|
|
notes: notes.trim(),
|
|
}
|
|
|
|
try {
|
|
if (isEdit) {
|
|
await update.mutateAsync({ id: plant.id, ...input, version })
|
|
} else {
|
|
await create.mutateAsync(input)
|
|
}
|
|
onClose()
|
|
} catch (err) {
|
|
const current = conflictPlant(err)
|
|
if (current) {
|
|
// Someone else changed this plant: rebase onto the fresh row so a re-save
|
|
// applies against the current version.
|
|
setVersion(current.version)
|
|
setName(current.name)
|
|
setCategory(current.category)
|
|
setSpacing(String(spacingFromCm(current.spacingCm, unit)))
|
|
setColor(expandHex(current.color))
|
|
setIcon(current.icon)
|
|
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
|
|
setNotes(current.notes)
|
|
setConflict('This plant changed elsewhere. The latest values are shown — review and save again.')
|
|
return
|
|
}
|
|
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the plant.'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal title={isEdit ? 'Edit plant' : 'New plant'} onClose={onClose} busy={pending}>
|
|
<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)} />
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Select
|
|
label="Category"
|
|
name="category"
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
|
options={categoryOptions}
|
|
/>
|
|
<TextField
|
|
label={`Spacing (${unitLabel})`}
|
|
name="spacing"
|
|
type="number"
|
|
inputMode="decimal"
|
|
step="any"
|
|
min="1"
|
|
required
|
|
value={spacing}
|
|
onChange={(e) => setSpacing(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="plant-color" className="text-sm font-medium text-fg">
|
|
Color
|
|
</label>
|
|
<input
|
|
id="plant-color"
|
|
type="color"
|
|
value={color}
|
|
onChange={(e) => setColor(e.target.value)}
|
|
className="h-10 w-full cursor-pointer rounded-md border border-border bg-surface"
|
|
/>
|
|
</div>
|
|
<TextField
|
|
label="Icon (emoji)"
|
|
name="icon"
|
|
value={icon}
|
|
onChange={(e) => setIcon(e.target.value)}
|
|
hint="A single emoji, e.g. 🍅"
|
|
/>
|
|
</div>
|
|
|
|
<TextField
|
|
label="Days to maturity (optional)"
|
|
name="days"
|
|
type="number"
|
|
inputMode="numeric"
|
|
step="1"
|
|
min="1"
|
|
value={days}
|
|
onChange={(e) => setDays(e.target.value)}
|
|
/>
|
|
|
|
<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 plant'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|