Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
Build image / build-and-push (push) Successful in 31s
Gadfly review (reusable) / review (pull_request) Failing after 1s
Adversarial Review (Gadfly) / review (pull_request) Failing after 1s

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:
2026-08-22 19:12:29 -04:00
co-authored by Claude Fable 5
parent 18b36870d4
commit 52b2c09a9e
111 changed files with 6621 additions and 7215 deletions
+62
View File
@@ -0,0 +1,62 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { TextField } from '@/components/ui/Field'
import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api'
import { useCopyGarden, type Garden } from '@/lib/gardens'
import { parsePlanName, planNameFor } from '@/lib/plan'
/**
* Duplicate a garden — the way to scheme a season: the copy is a separate
* garden you rearrange freely while this one stays put. Beds and everything
* currently planted come along; the share link and shares don't. The name is
* prefilled as "<name> — <next year>", which is what the editor's season
* control and the `plan` tag read back (see lib/plan.ts). On success we land in
* the copy, since the point of copying is to start editing it.
*/
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const copy = useCopyGarden()
const navigate = useNavigate()
const base = parsePlanName(garden.name)?.base ?? garden.name
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
const [name, setName] = useState(() => planNameFor(base, year))
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
try {
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
toast.info(`Copied to “${created.name}”.`)
onClose()
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
} catch (err) {
setError(errorMessage(err, 'Could not copy the garden.'))
}
}
return (
<Dialog title="Plan a season" onClose={onClose} busy={copy.isPending}>
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
<p className="text-[13px] leading-relaxed text-ink-soft">
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in rearrange freely, the
original stays put. Beds and what's planted come along; shares and the public link don't.
</p>
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} />
<p className="text-xs text-ink-mute">Keep the {year} and it shows up as that season's plan in the editor.</p>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} disabled={copy.isPending}>
Never mind
</Button>
<Button type="submit" variant="primary" disabled={copy.isPending || name.trim() === ''}>
{copy.isPending ? 'Copying' : 'Make the copy'}
</Button>
</div>
</form>
</Dialog>
)
}
@@ -1,64 +0,0 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api'
import { defaultCopyName, useCopyGarden, type Garden } from '@/lib/gardens'
/**
* Duplicate a garden under a new name. The name is prefilled with the server's
* own default so what you see is what you get; the beds and everything currently
* planted in them come along, while the source's share link and shares do not.
* On success we land in the copy — the point of copying is to start editing it.
*/
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const copy = useCopyGarden()
const navigate = useNavigate()
const [name, setName] = useState(() => defaultCopyName(garden.name))
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
try {
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
toast.info(`Copied to “${created.name}”.`)
onClose()
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
} catch (err) {
setError(errorMessage(err, 'Could not copy the garden.'))
}
}
return (
<Modal title="Copy garden" onClose={onClose} busy={copy.isPending}>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<p className="text-sm text-muted">
Make a copy of <span className="font-medium text-fg">{garden.name}</span> with its beds and
everything planted in them. The copy is private the original's share link and people you've
shared it with aren't carried over.
</p>
<TextField
label="Name"
name="name"
required
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={copy.isPending}>
Cancel
</Button>
<Button type="submit" disabled={copy.isPending || name.trim() === ''}>
{copy.isPending ? 'Copying' : 'Copy garden'}
</Button>
</div>
</form>
</Modal>
)
}
@@ -1,22 +0,0 @@
import { ConfirmModal } from '@/components/ui/ConfirmModal'
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()
return (
<ConfirmModal
title="Delete garden"
confirmLabel="Delete"
busyLabel="Deleting…"
errorFallback="Could not delete the garden."
onConfirm={() => deletion.mutateAsync(garden.id)}
onClose={onClose}
>
<p className="text-sm text-muted">
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
This can't be undone.
</p>
</ConfirmModal>
)
}
+78 -40
View File
@@ -1,14 +1,24 @@
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { IconButton } from '@/components/ui/Button'
import { Tag } from '@/components/ui/Tag'
import type { Garden } from '@/lib/gardens'
import { formatDimensions } from '@/lib/units'
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
import { useGardenFull } from '@/lib/objects'
import { planYearOf } from '@/lib/plan'
import { sharesQueryOptions } from '@/lib/shares'
import { formatSize } from '@/lib/units'
import { kindPlural } from '@/editor/kinds'
import { GardenThumb } from './GardenThumb'
// The order kinds are counted in on the card's meta line.
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
/**
* One garden as a card: the body links into the editor. The footer differs by
* role — the owner gets Share / Copy / Edit / Delete; a recipient sees a
* "shared · role" badge and a Leave action (garden metadata edit, sharing and
* copying are owner-only).
* Ownership is the authoritative ownerId==me check, not the my_role hint.
* One garden as a card: the plot thumbnail (a link into the editor), name + an
* optional `plan` tag, size, a counts line, who it's shared with, and a footer
* of Open + share / copy / edit / delete. A garden shared WITH you shows its
* role and a leave action instead of the owner's tools.
*/
export function GardenCard({
garden,
@@ -28,47 +38,75 @@ export function GardenCard({
onLeave: () => void
}) {
const owner = currentUserId != null && garden.ownerId === currentUserId
const full = useGardenFull(garden.id)
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
const planYear = planYearOf(garden.name)
const meta = useMemo(() => {
const data = full.data
if (!data) return full.isError ? 'Could not load the plot.' : '…'
if (data.objects.length === 0) return 'Bare ground — drag your first bed on.'
const counts = new Map<string, number>()
for (const o of data.objects) counts.set(o.kind, (counts.get(o.kind) ?? 0) + 1)
const parts = COUNTED_KINDS.filter((k) => counts.has(k)).map((k) => kindPlural(k, counts.get(k)!))
for (const [k, n] of counts) if (!COUNTED_KINDS.includes(k)) parts.push(kindPlural(k, n))
const plops = data.plantings.length
parts.push(`${plops} ${plops === 1 ? 'planting' : 'plantings'}`)
const since = new Date(garden.createdAt).getFullYear()
if (Number.isFinite(since)) parts.push(`tended since ${since}`)
return parts.join(' · ')
}, [full.data, full.isError, garden.createdAt])
const sharedLine = (() => {
if (!owner) return garden.myRole ? `Shared with you · ${garden.myRole}` : 'Shared with you'
const list = shares.data ?? []
if (list.length === 0) return null
const first = list[0]
const who = first.email.includes('@') ? first.email.slice(0, first.email.indexOf('@') + 1) : first.displayName
return list.length === 1 ? `Shared with ${who} · ${first.role}` : `Shared with ${who} +${list.length - 1}`
})()
return (
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
<div className="panel flex flex-col overflow-hidden transition-shadow hover:[box-shadow:var(--shadow-md)]">
<Link
to="/gardens/$gardenId"
params={{ gardenId: String(garden.id) }}
className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
className="block border-b border-divider bg-field no-underline"
aria-label={`Open ${garden.name}`}
>
<GardenThumb widthCm={garden.widthCm} heightCm={garden.heightCm} full={full.data} />
</Link>
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
{!owner && garden.myRole && (
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
shared · {garden.myRole}
</span>
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
{garden.name}
</span>
{planYear != null && <Tag tone="accent">plan</Tag>}
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
</span>
</div>
<div className="text-[13px] leading-relaxed text-ink-soft">{meta}</div>
{sharedLine && <div className="text-xs font-semibold text-accent-2-700">{sharedLine}</div>}
<div className="mt-auto flex gap-2 pt-2">
<Link
to="/gardens/$gardenId"
params={{ gardenId: String(garden.id) }}
className="btn btn-primary flex-1 no-underline"
>
Open
</Link>
{owner ? (
<>
<IconButton label="Share" icon="share-2" onClick={onShare} />
<IconButton label="Copy — plan a season from it" icon="copy" onClick={onCopy} />
<IconButton label="Edit the garden's name and size" icon="pencil" onClick={onEdit} />
<IconButton label="Delete" icon="trash-2" onClick={onDelete} iconClassName="text-accent-700" />
</>
) : (
<IconButton label="Leave this garden" icon="log-out" onClick={onLeave} iconClassName="text-accent-700" />
)}
</div>
<p className="mt-1 text-sm text-muted">
{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}
</p>
{garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>}
</Link>
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
{owner ? (
<>
<button type="button" onClick={onShare} className={cardActionClass}>
Share
</button>
<button type="button" onClick={onCopy} className={cardActionClass}>
Copy
</button>
<button type="button" onClick={onEdit} className={cardActionClass}>
Edit
</button>
<button type="button" onClick={onDelete} className={cardDangerClass}>
Delete
</button>
</>
) : (
<button type="button" onClick={onLeave} className={cardDangerClass}>
Leave
</button>
)}
</div>
</div>
)
+204
View File
@@ -0,0 +1,204 @@
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 { TextAreaField, TextField } from '@/components/ui/Field'
import { Seg } from '@/components/ui/Seg'
import { Toggle } from '@/components/ui/Toggle'
import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import {
cmFromFtIn,
dimensionInputMode,
dimensionUnitLabel,
formatCm,
formatDimensionInput,
isValidDimensionCm,
MIN_GARDEN_GRID_CM,
parseDimension,
type UnitPref,
} from '@/lib/units'
// A new plot: the design's 20 × 12, spoken in feet; the garden grid defaults
// to a foot (the server's 1 m for a metric garden).
const DEFAULT_W_FT = 20
const DEFAULT_H_FT = 12
const DEFAULT_GRID_CM = 100
const unitOptions = [
{ value: 'imperial' as const, label: 'ft' },
{ value: 'metric' as const, label: 'm' },
]
function entryHint(unit: UnitPref): string {
return unit === 'imperial' ? `Sizes read as feet and inches — 8' 6", 8', or 8.5 for feet.` : 'Sizes are in meters, e.g. 2.5.'
}
/**
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
* chosen unit and stored as centimeters; switching units converts what's typed so
* the physical size holds. A 409 rebases the form onto the server's fresh row.
*/
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
const isEdit = !!garden
const create = useCreateGarden()
const update = useUpdateGarden()
const pending = create.isPending || update.isPending
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
const [name, setName] = useState(garden?.name ?? '')
const [unit, setUnit] = useState<UnitPref>(initialUnit)
const [width, setWidth] = useState(() =>
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'),
)
const [height, setHeight] = useState(() =>
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
)
const [gridSize, setGridSize] = useState(() =>
formatDimensionInput(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
)
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '')
const [more, setMore] = useState(isEdit && (!!garden.notes || garden.snapToGrid))
const [version, setVersion] = useState(garden?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null)
const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) {
const convert = (s: string) => {
const cm = parseDimension(s, unit)
return cm === null ? s : formatDimensionInput(cm, next)
}
setWidth(convert(width))
setHeight(convert(height))
setGridSize(convert(gridSize))
setUnit(next)
}
const gridSizeCm = parseDimension(gridSize, unit)
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) {
e.preventDefault()
setFormError(null)
setConflict(null)
if (!name.trim()) {
setFormError('Give the garden a name.')
return
}
const widthCm = parseDimension(width, unit)
const heightCm = parseDimension(height, unit)
if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit))
return
}
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
setFormError('Width and depth must be between 1 cm and 100 m.')
return
}
if (gridSizeCm === null || !isValidDimensionCm(gridSizeCm)) {
setFormError('The grid must be between 1 cm and 100 m.')
return
}
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
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) {
setVersion(current.version)
setName(current.name)
setUnit(current.unitPref)
setWidth(formatDimensionInput(current.widthCm, current.unitPref))
setHeight(formatDimensionInput(current.heightCm, current.unitPref))
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid)
setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
return
}
setFormError(errorMessage(err, isEdit ? 'Could not save the garden.' : 'Could not create the garden.'))
}
}
const u = dimensionUnitLabel(unit)
const inputMode = dimensionInputMode(unit)
return (
<Dialog title={isEdit ? `Edit ${garden.name}` : 'A new garden'} 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="Back forty" value={name} onChange={(e) => setName(e.target.value)} />
<div className="flex gap-2.5">
<TextField
label={`Width (${u})`}
name="width"
type="text"
inputMode={inputMode}
required
value={width}
onChange={(e) => setWidth(e.target.value)}
wrapperClassName="flex-1"
/>
<TextField
label={`Depth (${u})`}
name="height"
type="text"
inputMode={inputMode}
required
value={height}
onChange={(e) => setHeight(e.target.value)}
wrapperClassName="flex-1"
/>
<div className="field">
<label>Units</label>
<Seg options={unitOptions} value={unit} onChange={changeUnit} label="Units" />
</div>
</div>
<div className="text-xs text-ink-mute">Stored in centimeters under the hood {unit === 'imperial' ? 'feet are' : 'meters are'} just how you talk.</div>
{!more ? (
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
Grid &amp; notes
</button>
) : (
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
<div className="flex items-end gap-2.5">
<TextField
label={`Garden grid (${u})`}
name="gridSize"
type="text"
inputMode={inputMode}
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
wrapperClassName="flex-1"
hint={
gridTooFine && gridSizeCm !== null
? `${formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid — plant spacing lives on each bed.`
: undefined
}
/>
<div className="field">
<label>Snap objects</label>
<Toggle on={snapToGrid} onChange={setSnapToGrid} label="Snap objects to the garden grid" />
</div>
</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' : 'Break ground'}
</Button>
</div>
</form>
</Dialog>
)
}
@@ -1,242 +0,0 @@
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 {
dimensionUnitLabel,
formatCm,
dimensionInputMode,
formatDimensionInput,
isValidDimensionCm,
MIN_GARDEN_GRID_CM,
parseDimension,
type UnitPref,
} from '@/lib/units'
const DEFAULT_METERS = 10 // matches the server's 10 m default
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
// What to say when a field can't be read. Naming the accepted forms beats
// "invalid input", which leaves the person guessing which field and which part.
function entryHint(unit: UnitPref): string {
return unit === 'imperial'
? `Enter sizes as feet and inches — 8' 6", 8', or 8.5 for feet.`
: 'Enter sizes in meters, e.g. 2.5.'
}
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) : formatDimensionInput(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<UnitPref>(garden?.unitPref ?? 'metric')
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
const [gridSize, setGridSize] = useState(() =>
formatDimensionInput(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric'),
)
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '')
const [version, setVersion] = useState(garden?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null)
const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) {
// Re-render each field in the new unit, preserving the physical size. An
// unparseable field is left as typed rather than blanked.
const convert = (s: string) => {
const cm = parseDimension(s, unit)
return cm === null ? s : formatDimensionInput(cm, next)
}
setWidth(convert(width))
setHeight(convert(height))
// The garden grid is a layout concern, so it lives at the same scale as the
// garden's own dimensions — same helpers, same unit label. (The *bed* grid in
// the object inspector is a plant-spacing concern and stays at cm/in.)
setGridSize(convert(gridSize))
setUnit(next)
}
// Converted once per render and used by both the submit handler and the
// too-fine hint, so the two can never disagree about what was entered.
const gridSizeCm = parseDimension(gridSize, unit)
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
// someone reaching for plant spacing, which lives on the bed instead — but it
// is a legitimate choice for a very small garden, so the save still goes through.
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
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 = parseDimension(width, unit)
const heightCm = parseDimension(height, unit)
if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit))
return
}
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
setFormError('Width and height must be between 1 cm and 100 m.')
return
}
if (gridSizeCm === null) {
setFormError(entryHint(unit))
return
}
if (!isValidDimensionCm(gridSizeCm)) {
setFormError('Grid size must be between 1 cm and 100 m.')
return
}
const input = {
name: name.trim(),
widthCm,
heightCm,
unitPref: unit,
notes: notes.trim(),
gridSizeCm,
snapToGrid,
}
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))
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid)
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)
const inputMode = dimensionInputMode(unit)
return (
<Modal title={isEdit ? 'Edit garden' : 'New garden'} 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)} />
<Select
label="Units"
name="unitPref"
value={unit}
onChange={(e) => changeUnit(e.target.value as UnitPref)}
options={unitOptions}
/>
<div className="grid grid-cols-2 gap-3">
<TextField
label={`Width (${unitLabel})`}
name="width"
type="text"
inputMode={inputMode}
required
value={width}
onChange={(e) => setWidth(e.target.value)}
/>
<TextField
label={`Height (${unitLabel})`}
name="height"
type="text"
inputMode={inputMode}
required
value={height}
onChange={(e) => setHeight(e.target.value)}
/>
</div>
<div>
<div className="flex items-end gap-3">
<div className="flex-1">
<TextField
label={`Garden grid (${unitLabel})`}
name="gridSize"
type="text"
inputMode={inputMode}
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
/>
</div>
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
<input
type="checkbox"
checked={snapToGrid}
onChange={(e) => setSnapToGrid(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
Snap objects
</label>
</div>
{gridTooFine && (
<p className="mt-1 text-xs text-muted">
{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed
(Bed grid in the inspector), not here.
</p>
)}
</div>
<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 garden'}
</Button>
</div>
</form>
</Modal>
)
}
@@ -0,0 +1,79 @@
import { useMemo } from 'react'
import { localToWorld } from '@/lib/geometry'
import type { FullGarden } from '@/lib/objects'
import { objectStyle, rectRadius } from '@/editor/kinds'
/**
* The plot thumbnail on a garden card: the field, its objects at true layout,
* and every active planting as a dot in its plant's color. Pure SVG from the
* editor payload — the same data the editor opens with, so the card is an
* honest preview and the editor then loads from cache.
*/
export function GardenThumb({
widthCm,
heightCm,
full,
}: {
widthCm: number
heightCm: number
/** Undefined while the payload loads: draws the bare field. */
full?: FullGarden
}) {
const W = Math.max(1, widthCm)
const H = Math.max(1, heightCm)
const sw = Math.max(2, Math.min(8, Math.round(Math.max(W, H) / 120)))
const inset = sw
const plantColor = useMemo(() => new Map((full?.plants ?? []).map((p) => [p.id, p.color])), [full?.plants])
const objects = useMemo(() => [...(full?.objects ?? [])].sort((a, b) => a.zIndex - b.zIndex), [full?.objects])
const byId = useMemo(() => new Map(objects.map((o) => [o.id, o])), [objects])
return (
<svg viewBox={`0 0 ${W} ${H}`} className="block h-[150px] w-full" preserveAspectRatio="xMidYMid meet" aria-hidden>
<rect
x={inset}
y={inset}
width={W - inset * 2}
height={H - inset * 2}
rx={Math.min(18, W * 0.03)}
fill="var(--p-field)"
stroke="var(--p-tree-stroke)"
strokeWidth={sw}
/>
{objects.map((o) => {
const st = objectStyle(o)
const t = `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
return o.shape === 'circle' ? (
<ellipse
key={o.id}
transform={t}
rx={o.widthCm / 2}
ry={o.heightCm / 2}
fill={st.fill}
stroke={st.stroke}
strokeWidth={sw * 0.6}
/>
) : (
<rect
key={o.id}
transform={t}
x={-o.widthCm / 2}
y={-o.heightCm / 2}
width={o.widthCm}
height={o.heightCm}
rx={rectRadius(o.widthCm)}
fill={st.fill}
stroke={st.stroke}
strokeWidth={sw * 0.6}
strokeDasharray={st.dash}
/>
)
})}
{(full?.plantings ?? []).map((p) => {
const o = byId.get(p.objectId)
if (!o) return null
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? '#97a97c'} />
})}
</svg>
)
}
@@ -1,33 +0,0 @@
import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { useMe } from '@/lib/auth'
import type { Garden } from '@/lib/gardens'
import { useRemoveShare } from '@/lib/shares'
/** Confirmation for a recipient leaving a garden shared with them (removes their
* own share). */
export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const me = useMe()
const remove = useRemoveShare(garden.id)
return (
<ConfirmModal
title="Leave garden"
confirmLabel="Leave"
busyLabel="Leaving…"
confirmDisabled={!me.data}
errorFallback="Could not leave the garden."
onConfirm={async () => {
// The button is disabled without a current user; throw rather than
// silently resolve (which would close the dialog as if it had worked) if
// that guard ever drifts.
if (!me.data) throw new Error('Not signed in.')
await remove.mutateAsync(me.data.id)
}}
onClose={onClose}
>
<p className="text-sm text-muted">
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
shares it with you again.
</p>
</ConfirmModal>
)
}
+180
View File
@@ -0,0 +1,180 @@
import { useState, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button, IconButton } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Toggle } from '@/components/ui/Toggle'
import { errorMessage } from '@/lib/api'
import type { Garden } from '@/lib/gardens'
import {
useAddShare,
useDisableShareLink,
useEnableShareLink,
useRemoveShare,
useShareLink,
useShares,
useUpdateShareRole,
type ShareRole,
} from '@/lib/shares'
/**
* Owner-only: invite an existing account by email (new invites start as
* viewers — tap the role chip to flip to editor), remove a share, and manage the
* public read-only link. v1 has no invitation emails; an unknown address gets a
* friendly "no account with that email".
*/
export function ShareDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const shares = useShares(garden.id)
const add = useAddShare(garden.id)
const updateRole = useUpdateShareRole(garden.id)
const remove = useRemoveShare(garden.id)
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const busy = add.isPending || updateRole.isPending || remove.isPending
async function onInvite(e: FormEvent) {
e.preventDefault()
setError(null)
const addr = email.trim()
if (!addr) return
try {
await add.mutateAsync({ email: addr, role: 'viewer' })
setEmail('')
} catch (err) {
setError(errorMessage(err, 'Could not share the garden.'))
}
}
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
return (
<Dialog title={`Share ${garden.name}`} onClose={onClose} busy={busy} width={440}>
<form onSubmit={onInvite} className="flex gap-2">
<input
className="input"
type="email"
placeholder="[email protected]"
aria-label="Invite by email"
autoComplete="off"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button type="submit" variant="primary" className="flex-none" disabled={add.isPending || !email.trim()}>
{add.isPending ? 'Inviting…' : 'Invite'}
</Button>
</form>
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
{shares.isSuccess && shares.data.length === 0 && (
<p className="text-[13px] text-ink-mute">Not shared with anyone yet invites go to existing accounts.</p>
)}
{shares.data?.map((sh) => (
<div key={sh.userId} className="flex items-center gap-2.5 rounded-full border border-divider bg-bg py-1.5 pl-4 pr-1.5">
<span className="min-w-0 truncate text-[13px] font-semibold" title={`${sh.displayName} · ${sh.email}`}>
{sh.email}
</span>
<button
type="button"
className="tag tag-accent-2 ml-auto cursor-pointer border-0"
title="Tap to switch between viewer and editor"
onClick={() => {
setError(null)
const role: ShareRole = sh.role === 'viewer' ? 'editor' : 'viewer'
updateRole.mutate({ userId: sh.userId, role }, { onError: onMutationError('Could not change that role.') })
}}
>
{sh.role}
</button>
<IconButton
label={`Remove ${sh.displayName}`}
icon="x"
iconSize={13}
variant="plain"
size={30}
onClick={() => {
setError(null)
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
}}
/>
</div>
))}
<div className="hr my-0.5" />
<PublicLinkSection gardenId={garden.id} />
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
<Button onClick={onClose}>Done</Button>
</div>
</Dialog>
)
}
/** The public read-only link: a toggle, the link itself (tap to copy), and a
* way to issue a fresh one that invalidates the old. */
function PublicLinkSection({ gardenId }: { gardenId: number }) {
const link = useShareLink(gardenId)
const enable = useEnableShareLink(gardenId)
const disable = useDisableShareLink(gardenId)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const token = link.data?.enabled ? link.data.token : undefined
const url = token ? `${window.location.origin}/g/${token}` : ''
const busy = link.isPending || enable.isPending || disable.isPending
const run = (p: Promise<unknown>, fallback: string) => {
setError(null)
p.catch((err) => setError(errorMessage(err, fallback)))
}
async function copy() {
if (!url) return
try {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// Clipboard may be unavailable (non-secure context); the text is selectable.
}
}
return (
<>
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-semibold">Read-only link</span>
<span className="text-xs text-ink-mute">anyone with it can look, no account needed</span>
<Toggle
className="ml-auto"
label="Public read-only link"
on={!!link.data?.enabled}
disabled={busy}
onChange={(on) =>
on ? run(enable.mutateAsync({}), 'Could not create the link.') : run(disable.mutateAsync(), 'Could not turn off the link.')
}
/>
</div>
{link.isError && <Alert>Could not load the public link.</Alert>}
{error && <Alert>{error}</Alert>}
{url && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={copy}
title="Copy the link"
className="min-w-0 flex-1 cursor-pointer truncate rounded-full border border-dashed border-divider bg-bg px-3.5 py-2 text-left text-xs text-ink-soft hover:border-accent-400"
>
{copied ? 'Copied to the clipboard' : url}
</button>
<IconButton label="Copy the link" icon="copy" iconSize={14} onClick={copy} />
<IconButton
label="Issue a new link (the old one stops working)"
icon="refresh-cw"
iconSize={14}
disabled={busy}
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
/>
</div>
)}
</>
)
}
@@ -1,227 +0,0 @@
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 { TextField } from '@/components/ui/TextField'
import { cn } from '@/lib/cn'
import { fieldControlClass } from '@/components/ui/field'
import { errorMessage } from '@/lib/api'
import type { Garden } from '@/lib/gardens'
import {
useAddShare,
useDisableShareLink,
useEnableShareLink,
useRemoveShare,
useShareLink,
useShares,
useUpdateShareRole,
type ShareRole,
} from '@/lib/shares'
const roleOptions = [
{ value: 'viewer', label: 'Viewer (read-only)' },
{ value: 'editor', label: 'Editor (can edit)' },
]
/**
* Owner-only dialog to manage a garden's shares: invite an existing user by
* email as viewer/editor, change a share's role, or remove it. Targets existing
* accounts only (v1 has no invitation emails) — an unknown email surfaces a
* friendly "no account with that email".
*/
export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const shares = useShares(garden.id)
const add = useAddShare(garden.id)
const updateRole = useUpdateShareRole(garden.id)
const remove = useRemoveShare(garden.id)
const [email, setEmail] = useState('')
const [role, setRole] = useState<ShareRole>('viewer')
const [error, setError] = useState<string | null>(null)
async function onInvite(e: FormEvent) {
e.preventDefault()
setError(null)
if (!email.trim()) {
setError('Enter an email address.')
return
}
try {
await add.mutateAsync({ email: email.trim(), role })
setEmail('')
} catch (err) {
setError(errorMessage(err, 'Could not share the garden.'))
}
}
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
return (
<Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.isPending}>
<div className="flex flex-col gap-4">
<form onSubmit={onInvite} className="flex flex-col gap-2">
<TextField
label="Invite by email"
name="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<div className="flex items-end gap-2">
<Select
label="Role"
name="role"
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
options={roleOptions}
className="flex-1"
/>
<Button type="submit" disabled={add.isPending}>
{add.isPending ? 'Sharing…' : 'Share'}
</Button>
</div>
{error && <Alert>{error}</Alert>}
</form>
<div>
<h3 className="mb-2 text-sm font-medium text-fg">Shared with</h3>
{shares.isPending && <p className="text-sm text-muted">Loading</p>}
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
{shares.isSuccess && shares.data.length === 0 && (
<p className="text-sm text-muted">Not shared with anyone yet.</p>
)}
<ul className="flex flex-col gap-2">
{shares.data?.map((sh) => (
<li key={sh.userId} className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-fg">{sh.displayName}</p>
<p className="truncate text-xs text-muted">{sh.email}</p>
</div>
<select
value={sh.role}
onChange={(e) => {
setError(null)
updateRole.mutate(
{ userId: sh.userId, role: e.target.value as ShareRole },
{ onError: onMutationError('Could not change that role.') },
)
}}
aria-label={`Role for ${sh.displayName}`}
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
<button
type="button"
onClick={() => {
setError(null)
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
}}
aria-label={`Remove ${sh.displayName}`}
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
>
</button>
</li>
))}
</ul>
</div>
<PublicLinkSection gardenId={garden.id} />
<div className="flex justify-end">
<Button variant="ghost" onClick={onClose}>
Done
</Button>
</div>
</div>
</Modal>
)
}
/** The public read-only link controls: create, copy, regenerate, turn off. */
function PublicLinkSection({ gardenId }: { gardenId: number }) {
const link = useShareLink(gardenId)
const enable = useEnableShareLink(gardenId)
const disable = useDisableShareLink(gardenId)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const token = link.data?.enabled ? link.data.token : undefined
const url = token ? `${window.location.origin}/g/${token}` : ''
const busy = link.isPending || enable.isPending || disable.isPending
const run = (p: Promise<unknown>, fallback: string) => {
setError(null)
p.catch((err) => setError(errorMessage(err, fallback)))
}
async function copy() {
if (!url) return
try {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// Clipboard API may be unavailable (e.g. non-secure context); the field is
// selectable so the user can still copy manually.
}
}
return (
<div className="border-t border-border pt-4">
<h3 className="mb-1 text-sm font-medium text-fg">Public link</h3>
<p className="mb-2 text-xs text-muted">
Anyone with the link can view this garden read-only no account needed.
</p>
{link.isError && <Alert>Could not load the public link.</Alert>}
{error && <Alert>{error}</Alert>}
{link.isSuccess && !link.data.enabled && (
<Button onClick={() => run(enable.mutateAsync({}), 'Could not create the link.')} disabled={busy}>
{enable.isPending ? 'Creating…' : 'Create public link'}
</Button>
)}
{link.isSuccess && link.data.enabled && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
readOnly
value={url}
onFocus={(e) => e.currentTarget.select()}
aria-label="Public link URL"
className={cn(fieldControlClass, 'min-w-0 flex-1 text-sm')}
/>
<Button variant="ghost" onClick={copy} disabled={!url}>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
disabled={busy}
title="Issue a new link and invalidate the old one"
>
{enable.isPending ? 'Working…' : 'Regenerate'}
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
onClick={() => run(disable.mutateAsync(), 'Could not turn off the link.')}
disabled={busy}
>
Turn off
</Button>
</div>
</div>
)}
</div>
)
}