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,140 @@
|
||||
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 { SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import {
|
||||
conflictSeedLot,
|
||||
LOT_UNITS,
|
||||
safeExternalUrl,
|
||||
useCreateSeedLot,
|
||||
useUpdateSeedLot,
|
||||
type LotUnit,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
|
||||
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||
|
||||
/**
|
||||
* Record a purchase, or correct one. Everything except quantity and unit is
|
||||
* optional — the point is to make writing it down cheap enough to bother with.
|
||||
*/
|
||||
export function SeedLotDialog({ plant, lot, onClose }: { plant: Plant; lot?: SeedLot; onClose: () => void }) {
|
||||
const isEdit = !!lot
|
||||
const create = useCreateSeedLot()
|
||||
const update = useUpdateSeedLot()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
||||
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
||||
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
||||
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
||||
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
||||
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
||||
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
||||
const [sku, setSku] = useState(lot?.sku ?? '')
|
||||
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
||||
const [notes, setNotes] = useState(lot?.notes ?? '')
|
||||
const [version, setVersion] = useState(lot?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setConflict(null)
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim()))
|
||||
return setError('The source link needs to be a full http:// or https:// address.')
|
||||
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
|
||||
}
|
||||
let costCents: number | null = null
|
||||
if (cost.trim()) {
|
||||
const c = Number(cost)
|
||||
if (!Number.isFinite(c) || c < 0) return setError('Cost must be an amount, or blank.')
|
||||
costCents = Math.round(c * 100)
|
||||
}
|
||||
let germinationPct: number | null = null
|
||||
if (germination.trim()) {
|
||||
const g = Number(germination)
|
||||
if (!Number.isFinite(g) || g < 0 || g > 100) return setError('Germination is a percentage between 0 and 100.')
|
||||
germinationPct = g
|
||||
}
|
||||
const input = {
|
||||
plantId: plant.id,
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit,
|
||||
costCents,
|
||||
germinationPct,
|
||||
notes: notes.trim(),
|
||||
}
|
||||
try {
|
||||
if (isEdit) await update.mutateAsync({ id: lot.id, version, ...input })
|
||||
else await create.mutateAsync(input)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictSeedLot(err)
|
||||
if (current) {
|
||||
setVersion(current.version)
|
||||
setVendor(current.vendor)
|
||||
setSourceUrl(current.sourceUrl)
|
||||
setQuantity(String(current.quantity))
|
||||
setUnit(current.unit)
|
||||
setPurchasedAt(current.purchasedAt ?? '')
|
||||
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
||||
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
||||
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
||||
setSku(current.sku)
|
||||
setLotCode(current.lotCode)
|
||||
setNotes(current.notes)
|
||||
setConflict('This lot changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
return
|
||||
}
|
||||
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={isEdit ? 'Edit the lot' : `A lot of ${plant.name}`} onClose={onClose} busy={pending} width={460}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<TextField label="Quantity" name="quantity" type="number" inputMode="decimal" step="any" min="0" autoFocus value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
<SelectField label="Unit" name="unit" value={unit} onChange={(e) => setUnit(e.target.value as LotUnit)} options={unitOptions} />
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField label="Packed for" name="packedForYear" type="number" inputMode="numeric" placeholder="2026" value={packedForYear} onChange={(e) => setPackedForYear(e.target.value)} />
|
||||
<TextField label="Purchased" name="purchasedAt" type="date" value={purchasedAt} onChange={(e) => setPurchasedAt(e.target.value)} />
|
||||
<TextField label="Cost" name="cost" type="number" inputMode="decimal" step="0.01" min="0" placeholder="4.99" value={cost} onChange={(e) => setCost(e.target.value)} />
|
||||
<TextField label="Germination %" name="germination" type="number" inputMode="decimal" step="any" min="0" max="100" value={germination} onChange={(e) => setGermination(e.target.value)} />
|
||||
<TextField label="Source link" name="sourceUrl" type="url" inputMode="url" placeholder="https://…" value={sourceUrl} onChange={(e) => setSourceUrl(e.target.value)} />
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
||||
</div>
|
||||
<TextAreaField label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
{error && <Alert>{error}</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' : 'Record the lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user