Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
The frontend is rebuilt screen by screen from the handoff: warm cream ground, terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. - Tokens: web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names; dark mode is those variables overridden on <html> by the handoff's pansy-theme.js, inlined in index.html so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit (Button, Dialog, Field, Seg, Toggle, Tag, toast). - Login / Register: the centered column over soft accent circles; OIDC button and signup footer still follow /auth/providers. - Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open + share/copy/edit/delete; New garden / Share / Plan-a-season dialogs. - Plants: monogram markers derived from the name (collision-resolved across the catalog — replaces emoji icons), category chips, expandable lot cards, the scan-packet flow as a two-step dialog that never auto-creates. - Settings: Appearance (theme seg), Who gets in (read-only sign-in config), Garden assistant (self-saving toggle + chat/vision model fields), You. - Editor: a new canvas with the prototype's pointer model (wheel-to-cursor, pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom monograms/labels), plus corner resize handles; desktop three-card workspace (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px of container width, the phone chrome (header, peek panel, tool strip, mode bar). Seasons as a segmented control over the years with data plus plan copies; Undo re-reads history before reverting the newest step. - Public read-only view and the register page restyled to match. - GET /settings gains a read-only `auth` view (registration mode, local auth, OIDC issuer) so the Settings page can show what's in force. - README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Field, SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
PLANT_CATEGORIES,
|
||||
conflictPlant,
|
||||
useCreatePlant,
|
||||
useUpdatePlant,
|
||||
type Plant,
|
||||
type PlantCategory,
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
|
||||
|
||||
// Markers are monograms now, but the API still carries an icon per plant; a
|
||||
// plant made here gets the neutral sprout so nothing downstream sees an empty one.
|
||||
const DEFAULT_ICON = '🌱'
|
||||
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||
|
||||
/**
|
||||
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another
|
||||
* (`template`, the Duplicate action; the only way to customize a built-in).
|
||||
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases
|
||||
* onto the server's current row.
|
||||
*/
|
||||
export function PlantDialog({
|
||||
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 ?? CURATED_SWATCHES[0]))
|
||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
||||
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||
const [notes, setNotes] = useState(source?.notes ?? '')
|
||||
const [more, setMore] = useState(!!(source?.vendor || source?.sourceUrl || 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('Give the plant a name.')
|
||||
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()) {
|
||||
const d = Number(days)
|
||||
if (!Number.isInteger(d) || d < 1) {
|
||||
setFormError('Days to maturity is a whole number of days, or blank.')
|
||||
return
|
||||
}
|
||||
daysToMaturity = d
|
||||
}
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||
setFormError('The source link needs to be a full http:// or https:// address.')
|
||||
return
|
||||
}
|
||||
const input: PlantInput = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
spacingCm,
|
||||
color,
|
||||
icon: source?.icon || DEFAULT_ICON,
|
||||
daysToMaturity,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
vendor: vendor.trim(),
|
||||
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) {
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setCategory(current.category)
|
||||
setSpacing(String(spacingFromCm(current.spacingCm, unit)))
|
||||
setColor(expandHex(current.color))
|
||||
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
|
||||
setVendor(current.vendor)
|
||||
setSourceUrl(current.sourceUrl)
|
||||
setNotes(current.notes)
|
||||
setConflict('This plant changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save the plant.' : 'Could not add the plant.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={isEdit ? `Edit ${plant.name}` : 'A new plant'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
<TextField label="Name" name="name" required autoFocus placeholder="Delicata squash" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="flex gap-2.5">
|
||||
<SelectField
|
||||
label="Category"
|
||||
name="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
||||
options={categoryOptions}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label={`Spacing (${unitLabel})`}
|
||||
name="spacing"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="1"
|
||||
required
|
||||
value={spacing}
|
||||
onChange={(e) => setSpacing(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Field label="Marker color">
|
||||
<ColorSwatches value={color} onChange={setColor} />
|
||||
</Field>
|
||||
<TextField
|
||||
label="Days to maturity (optional)"
|
||||
name="days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
|
||||
{!more ? (
|
||||
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
|
||||
Vendor, source link & notes…
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
|
||||
<div className="flex gap-2.5">
|
||||
<TextField label="Vendor" name="vendor" placeholder="Johnny's" value={vendor} onChange={(e) => setVendor(e.target.value)} wrapperClassName="flex-1" />
|
||||
<TextField
|
||||
label="Source link"
|
||||
name="sourceUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://…"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={pending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Add it'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user