Polish: imperial, clear-bed, keyboard nudging, empty states (#18)
Build image / build-and-push (push) Successful in 4s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #37.
This commit is contained in:
2026-07-19 04:44:26 +00:00
committed by steve
parent c2dd93a93d
commit 245e0cbe71
14 changed files with 268 additions and 11 deletions
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>%F0%9F%8C%B1</text></svg>" />
<title>pansy</title>
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
</head>
+20
View File
@@ -0,0 +1,20 @@
import { Link } from '@tanstack/react-router'
import { buttonClasses } from '@/components/ui/Button'
import { usePageTitle } from '@/lib/usePageTitle'
/** The router's catch-all for unknown paths. */
export function NotFound() {
usePageTitle('Not found')
return (
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
<p className="text-5xl" aria-hidden>
🌱
</p>
<h1 className="text-lg font-semibold text-fg">Page not found</h1>
<p className="text-sm text-muted">That page doesn't exist — the link may be wrong or the page moved.</p>
<Link to="/gardens" className={buttonClasses('primary')}>
Back to gardens
</Link>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { useClearObject } from '@/lib/objects'
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
* are kept with removed_at, so history survives). */
export function ClearBedModal({
objectName,
plops,
gardenId,
onClose,
}: {
objectName: string
plops: { id: number; version: number }[]
gardenId: number
onClose: () => void
}) {
const clear = useClearObject(gardenId)
const n = plops.length
return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={clear.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={clear.isPending || n === 0}
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
>
{clear.isPending ? 'Clearing' : 'Clear bed'}
</Button>
</div>
</div>
</Modal>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
/** A non-interactive hint overlaid on the canvas (empty-state guidance). */
export function EditorHint({ position = 'center', children }: { position?: 'center' | 'top'; children: ReactNode }) {
return (
<div
className={cn(
'pointer-events-none absolute flex p-6 text-center',
position === 'top' ? 'inset-x-0 top-16 justify-center' : 'inset-0 items-center justify-center',
)}
>
<p className="max-w-xs rounded-lg bg-surface/85 px-4 py-3 text-sm text-muted shadow-sm backdrop-blur">
{children}
</p>
</div>
)
}
+4
View File
@@ -37,6 +37,9 @@ export const PlopMarker = memo(function PlopMarker({
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
const showText = scale >= SEMANTIC_NEAR && !!plant
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
// Keep the tap target at least ~44px across even when the plop draws tiny at
// low zoom (fill=transparent still receives pointer events, unlike fill=none).
const hitR = Math.max(r, 22 / scale)
function down(e: PointerEvent) {
e.stopPropagation()
@@ -45,6 +48,7 @@ export const PlopMarker = memo(function PlopMarker({
return (
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
<circle cx={0} cy={0} r={hitR} fill="transparent" />
<circle
cx={0}
cy={0}
+5
View File
@@ -27,3 +27,8 @@ export const OBJECT_KINDS: KindDef[] = [
export function kindDef(kind: string): KindDef | undefined {
return OBJECT_KINDS.find((k) => k.kind === kind)
}
/** A human label for an object: its name, else its kind's label, else "Object". */
export function objectDisplayName(o: { name: string; kind: string }): string {
return o.name || kindDef(o.kind)?.label || 'Object'
}
+25
View File
@@ -263,6 +263,31 @@ export function useUpdatePlanting(gardenId: number) {
})
}
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes;
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
* once at the end. Pass the object's active plops (id + current version). */
export function useClearObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (plops: { id: number; version: number }[]) => {
const today = new Date().toISOString().slice(0, 10)
// allSettled, not all: a partial failure still soft-removed some rows
// server-side, so we must reconcile the cache rather than roll everything
// back. Report how many failed.
const results = await Promise.allSettled(
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) {
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
}
},
// Reconcile on success OR partial failure, so the cache matches the server.
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'),
})
}
/** Soft-remove ("Remove"): PATCH removed_at to today; the row is kept but leaves
* the active list. Distinct from a hard delete. */
export function useRemovePlanting(gardenId: number) {
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from 'react'
/** Set the document title for the current route (suffixed with · pansy). No
* restore-on-unmount: each route sets its own title, so restoring the previous
* one would race and clobber the incoming route's title on a fast navigation. */
export function usePageTitle(title: string): void {
useEffect(() => {
document.title = title ? `${title} · pansy` : 'pansy'
}, [title])
}
+131 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
@@ -7,13 +7,16 @@ import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
import { Palette } from '@/editor/Palette'
import { kindDef } from '@/editor/kinds'
import { ClearBedModal } from '@/editor/ClearBedModal'
import { EditorHint } from '@/editor/EditorHint'
import { objectDisplayName } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth'
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
import { toEditorObject, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import { usePageTitle } from '@/lib/usePageTitle'
const routeApi = getRouteApi('/gardens/$gardenId')
@@ -24,6 +27,7 @@ export function GardenEditorPage() {
const navigate = routeApi.useNavigate()
const full = useGardenFull(gid)
const me = useMe()
usePageTitle(full.data?.garden.name ?? 'Garden')
const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select)
@@ -38,11 +42,15 @@ export function GardenEditorPage() {
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const updatePlanting = useUpdatePlanting(gid)
const updateObject = useUpdateObject(gid)
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant.
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
const [sharing, setSharing] = useState(false)
const [clearing, setClearing] = useState(false)
const nudgeTimer = useRef<number | null>(null)
const nudgeFire = useRef<(() => void) | null>(null)
const serverObjects = full.data?.objects
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
@@ -51,6 +59,17 @@ export function GardenEditorPage() {
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
// Role gating, computed before the effects/returns so the nudge handler can use
// it. Ownership is the authoritative ownerId==me check.
const gd = full.data?.garden
const canEdit = gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor')
const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id
// Latest values for the mount-once nudge keydown handler to read, so its effect
// never re-subscribes (which would cancel a pending debounced commit).
const nudgeCtx = useRef({ canEdit, objects, plantings, updateObject, updatePlanting })
nudgeCtx.current = { canEdit, objects, plantings, updateObject, updatePlanting }
// Adopt the URL's focus and clear transient editor state on entering/switching
// gardens (and on leaving). `focus` is intentionally read once here, not a dep,
// so URL syncs below don't retrigger a full reset.
@@ -108,6 +127,87 @@ export function GardenEditorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Desktop keyboard nudging: arrows move the selected object/plop 1cm (Shift =
// 10cm); the PATCH is debounced ~400ms on key-idle so a held key doesn't spam.
// Plops nudge in their object's local frame and clamp to its (local) bounds.
// Mounted once, reading live values from nudgeCtx so a data refetch can't
// re-subscribe and cancel a pending commit; the pending commit is flushed on
// unmount, and a fire only commits if its live value is still present (a drag
// that cleared it already committed its own PATCH).
useEffect(() => {
const DIRS: Record<string, [number, number]> = {
ArrowUp: [0, -1],
ArrowDown: [0, 1],
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
}
const commitLater = (fire: () => void) => {
nudgeFire.current = fire
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
nudgeTimer.current = window.setTimeout(() => {
nudgeTimer.current = null
const fn = nudgeFire.current
nudgeFire.current = null
fn?.()
}, 400)
}
function onKey(e: KeyboardEvent) {
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
nudgeCtx.current
if (!canNudge) return
const dir = DIRS[e.key]
if (!dir) return
const el = document.activeElement
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
const s = useEditorStore.getState()
if (s.objectDragging) return // don't fight an active pointer drag
const step = e.shiftKey ? 10 : 1
if (s.selectedId != null) {
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
if (!base) return
e.preventDefault()
s.setLiveObject({ ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step })
commitLater(() => {
const live = useEditorStore.getState().liveObject
if (live?.id !== s.selectedId) return // a drag cleared it and committed
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLiveObject(null)
})
} else if (s.selectedPlantingId != null) {
const base =
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
if (!base) return
e.preventDefault()
const obj = objs.find((o) => o.id === base.objectId)
let nx = base.xCm + dir[0] * step
let ny = base.yCm + dir[1] * step
if (obj) {
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
}
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
commitLater(() => {
const live = useEditorStore.getState().livePlanting
if (live?.id !== s.selectedPlantingId) return
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLivePlanting(null)
})
}
}
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (nudgeTimer.current != null) {
window.clearTimeout(nudgeTimer.current)
nudgeTimer.current = null
}
const fn = nudgeFire.current // flush a pending move so it isn't lost
nudgeFire.current = null
fn?.()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden</p>
if (full.isError)
return (
@@ -124,16 +224,12 @@ export function GardenEditorPage() {
heightCm: g.heightCm,
unitPref: g.unitPref,
}
// Role-driven gating. Ownership is the authoritative ownerId==me check (so a
// missing my_role can never lock an owner out); edit access is owner or an
// editor share.
const isOwner = me.data != null && g.ownerId === me.data.id
const canEdit = isOwner || g.myRole === 'editor'
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
// Committed selected plop; PlopInspector applies live drag geometry itself.
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : []
function onPickPlant(plantId: number) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
@@ -170,9 +266,7 @@ export function GardenEditorPage() {
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
{garden.name}
</button>
<span className="max-w-[8rem] truncate text-muted">
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
</span>
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
{canEdit &&
(!focusedObject.plantable ? (
<span className="text-xs text-muted">Not plantable</span>
@@ -185,9 +279,26 @@ export function GardenEditorPage() {
+ Add plant
</Button>
))}
{canEdit && focusedObject.plantable && focusedPlops.length > 0 && (
<Button
variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
onClick={() => setClearing(true)}
>
Clear ({focusedPlops.length})
</Button>
)}
</div>
)}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
{/* Empty-state hints (non-interactive overlays). */}
{canEdit && focusedObjectId == null && objects.length === 0 && (
<EditorHint>Pick a shape from the palette, then tap the field to place your first bed.</EditorHint>
)}
{canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && (
<EditorHint position="top">Tap + Add plant, then tap inside the bed to place plops.</EditorHint>
)}
</div>
{(selectedObject || selectedPlop) && (
@@ -229,6 +340,15 @@ export function GardenEditorPage() {
)}
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
{clearing && focusedObject && (
<ClearBedModal
objectName={objectDisplayName(focusedObject)}
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
gardenId={gid}
onClose={() => setClearing(false)}
/>
)}
</div>
)
}
+2
View File
@@ -8,6 +8,7 @@ import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth'
import { useGardens, type Garden } from '@/lib/gardens'
import { usePageTitle } from '@/lib/usePageTitle'
// Which modal is open, if any. edit/delete/share/leave carry the target garden.
type Dialog =
@@ -19,6 +20,7 @@ type Dialog =
| null
export function GardensPage() {
usePageTitle('Gardens')
const gardens = useGardens()
const me = useMe()
const [dialog, setDialog] = useState<Dialog>(null)
+2
View File
@@ -8,6 +8,7 @@ import { TextField } from '@/components/ui/TextField'
import { API_BASE, errorMessage } from '@/lib/api'
import { useLogin, useProviders } from '@/lib/auth'
import { safeRedirectPath } from '@/lib/redirect'
import { usePageTitle } from '@/lib/usePageTitle'
// Server-initiated redirect (not a fetch): the browser navigates to the Go
// handler, which 302s to the IdP.
@@ -24,6 +25,7 @@ const callbackErrors: Record<string, string> = {
}
export function LoginPage() {
usePageTitle('Sign in')
const search = useSearch({ from: '/login' })
const navigate = useNavigate()
const providers = useProviders()
+2
View File
@@ -10,6 +10,7 @@ import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
import { PlantPicker } from '@/editor/PlantPicker'
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import type { UnitPref } from '@/lib/units'
import { usePageTitle } from '@/lib/usePageTitle'
// Which modal is open, if any. edit/duplicate/delete carry the target plant.
type Dialog =
@@ -32,6 +33,7 @@ function loadUnit(): UnitPref {
}
export function PlantsPage() {
usePageTitle('Plants')
const plants = usePlants()
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
const [query, setQuery] = useState('')
+2
View File
@@ -6,10 +6,12 @@ import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/TextField'
import { apiErrorCode, errorMessage } from '@/lib/api'
import { useProviders, useRegister } from '@/lib/auth'
import { usePageTitle } from '@/lib/usePageTitle'
const MIN_PASSWORD = 8
export function RegisterPage() {
usePageTitle('Create account')
const navigate = useNavigate()
const providers = useProviders()
const register = useRegister()
+3
View File
@@ -6,6 +6,7 @@ import {
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { AppShell } from '@/components/layout/AppShell'
import { NotFound } from '@/components/NotFound'
import { RouteError } from '@/components/RouteError'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
@@ -25,6 +26,8 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
// A beforeLoad/loader failure that isn't a redirect (e.g. /auth/me errors with
// a 500 or the network drops) lands here instead of a blank screen.
errorComponent: RouteError,
// Unknown paths render inside the app shell rather than a blank screen.
notFoundComponent: NotFound,
})
// requireAuth: resolve the current user (shared cache with useMe); send anyone