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,292 @@
|
||||
import { useRef, useState, type ChangeEvent, type DragEvent, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { SelectField, TextField } from '@/components/ui/Field'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { CATEGORY_LABELS, PLANT_CATEGORIES, isBuiltin, type PlantCategory, type PlantInput } from '@/lib/plants'
|
||||
import { lotDefaults, newPlantDefaults, useCreateFromPacket, useScanPacket, type PacketProposal } from '@/lib/seedPacket'
|
||||
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
|
||||
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||
|
||||
// 'new' is "create a new plant"; a number selects that existing candidate.
|
||||
type Selection = number | 'new'
|
||||
|
||||
/**
|
||||
* Photograph a seed packet → the vision model reads it into fields → the user
|
||||
* matches it to the catalog and confirms (#81/#102). Two steps in one dialog.
|
||||
* The read never writes; a misread can't add anything on its own, and a wrong
|
||||
* auto-match would split a variety's history across duplicate rows — so the
|
||||
* match is always a human choice. Only offered where `capabilities.vision` is
|
||||
* on; a 503 is still handled in case the model is torn down in between.
|
||||
*/
|
||||
export function ScanPacketDialog({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||
const scan = useScanPacket()
|
||||
const create = useCreateFromPacket()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const scanAbort = useRef<AbortController | null>(null)
|
||||
|
||||
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const [selection, setSelection] = useState<Selection>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||
const [spacing, setSpacing] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [vendor, setVendor] = useState('')
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||
const [packedForYear, setPackedForYear] = useState('')
|
||||
|
||||
const unitLabel = spacingUnitLabel(unit)
|
||||
const busy = scan.isPending || create.isPending
|
||||
|
||||
function readFile(file: File | undefined) {
|
||||
if (!file) return
|
||||
setError(null)
|
||||
const controller = new AbortController()
|
||||
scanAbort.current = controller
|
||||
scan.mutate(
|
||||
{ file, signal: controller.signal },
|
||||
{
|
||||
onSuccess: (p) => {
|
||||
const plant = newPlantDefaults(p)
|
||||
const lot = lotDefaults(p.packet)
|
||||
setProposal(p)
|
||||
setSelection(p.candidates[0]?.plant.id ?? 'new')
|
||||
setName(plant.name)
|
||||
setCategory(plant.category)
|
||||
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
|
||||
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
|
||||
setVendor(lot.vendor)
|
||||
setQuantity(String(lot.quantity))
|
||||
setLotUnit(lot.unit)
|
||||
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
},
|
||||
onError: (err) => {
|
||||
if ((err as Error)?.name === 'AbortError') return
|
||||
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = '' // so re-picking the same file fires change again
|
||||
readFile(file)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
readFile(e.dataTransfer.files?.[0])
|
||||
}
|
||||
|
||||
async function onConfirm(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!proposal) return
|
||||
setError(null)
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.')
|
||||
year = y
|
||||
}
|
||||
const lot = {
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: '',
|
||||
sku: proposal.packet.sku,
|
||||
lotCode: proposal.packet.lotCode,
|
||||
purchasedAt: null,
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit: lotUnit,
|
||||
costCents: null,
|
||||
germinationPct: null,
|
||||
notes: '',
|
||||
}
|
||||
let newPlant: PlantInput | undefined
|
||||
let plantId: number | undefined
|
||||
if (selection === 'new') {
|
||||
if (!name.trim()) return setError('Name the new plant, or pick an existing one above.')
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) return setError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||
let daysToMaturity: number | null = null
|
||||
if (days.trim()) {
|
||||
const d = Number(days)
|
||||
if (!Number.isInteger(d) || d < 1) return setError('Days to maturity is a whole number of days, or blank.')
|
||||
daysToMaturity = d
|
||||
}
|
||||
newPlant = { ...newPlantDefaults(proposal), name: name.trim(), category, spacingCm, daysToMaturity, vendor: vendor.trim() }
|
||||
} else {
|
||||
plantId = selection
|
||||
}
|
||||
try {
|
||||
const res = await create.mutateAsync({ plantId, newPlant, lot })
|
||||
toast.info(res.plantIsNew ? `Added ${res.plant.name} and its seed lot.` : `Recorded a seed lot for ${res.plant.name}.`)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not save the packet.'))
|
||||
}
|
||||
}
|
||||
|
||||
const variety = proposal ? [proposal.packet.species, proposal.packet.variety].filter(Boolean).join(' — ') : ''
|
||||
|
||||
return (
|
||||
<Dialog title="Scan a seed packet" onClose={onClose} busy={create.isPending} width={560}>
|
||||
{!proposal ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={onFile}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2.5 rounded-lg border-2 border-dashed px-5 py-9 text-center',
|
||||
dragOver ? 'border-accent-400 bg-accent-100' : 'border-neutral-400',
|
||||
)}
|
||||
>
|
||||
<Icon name="camera" size={30} className="text-accent-2-600" />
|
||||
<div className="text-sm font-semibold">Photograph the packet — front is enough</div>
|
||||
<div className="max-w-[36ch] text-[12.5px] text-ink-mute">
|
||||
The vision model reads it into fields. It only reads; nothing is saved until you confirm.
|
||||
</div>
|
||||
{scan.isPending ? (
|
||||
<p className="mt-1 flex items-center gap-2 text-[13px] font-semibold text-ink-soft">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
Reading the packet… this can take a few seconds.
|
||||
</p>
|
||||
) : (
|
||||
<Button variant="primary" icon="camera" className="mt-1" onClick={() => fileInput.current?.click()}>
|
||||
Take or choose a photo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end">
|
||||
{/* Never disabled — this is the way out of a slow scan. */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
scanAbort.current?.abort()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={onConfirm} className="flex flex-col gap-3.5">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<TextField label="Variety" name="variety" value={selection === 'new' ? name : variety} readOnly={selection !== 'new'} onChange={(e) => setName(e.target.value)} />
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField label="Packed for" name="packedForYear" type="number" inputMode="numeric" value={packedForYear} onChange={(e) => setPackedForYear(e.target.value)} />
|
||||
<div className="field">
|
||||
<label htmlFor="scan-quantity">Quantity</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input id="scan-quantity" className="input min-w-0" type="number" inputMode="decimal" step="any" min="0" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
<select className="input w-auto flex-none" aria-label="Unit" value={lotUnit} onChange={(e) => setLotUnit(e.target.value as LotUnit)}>
|
||||
{unitOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 text-[12.5px] font-bold text-ink-soft">Match it to your catalog — nothing is auto-created:</div>
|
||||
<div role="radiogroup" aria-label="Which plant this packet is" className="flex flex-col gap-2">
|
||||
{proposal.candidates.map((c, i) => (
|
||||
<MatchOption
|
||||
key={c.plant.id}
|
||||
selected={selection === c.plant.id}
|
||||
onSelect={() => setSelection(c.plant.id)}
|
||||
label={`${c.plant.name} (${isBuiltin(c.plant) ? 'built-in' : 'yours'})`}
|
||||
sub={i === 0 ? `best match · ${c.reason}` : c.reason}
|
||||
/>
|
||||
))}
|
||||
<MatchOption
|
||||
selected={selection === 'new'}
|
||||
onSelect={() => setSelection('new')}
|
||||
label="Create a new plant"
|
||||
sub="from the extracted fields"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selection === 'new' && (
|
||||
<div className="grid grid-cols-2 gap-2.5 rounded-md border border-divider bg-bg p-3.5">
|
||||
<SelectField 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)} />
|
||||
<TextField label="Days to maturity" name="days" type="number" inputMode="numeric" step="1" min="1" value={days} onChange={(e) => setDays(e.target.value)} wrapperClassName="col-span-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="mt-0.5 flex justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setProposal(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<span className="flex gap-2">
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add the lot'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchOption({ selected, onSelect, label, sub }: { selected: boolean; onSelect: () => void; label: string; sub: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-full border px-4 py-2.5 text-left',
|
||||
selected ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13.5px] font-bold">{label}</span>
|
||||
<span className="ml-auto text-right text-xs text-ink-mute">{sub}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user