From 73fa34cb81b48bb0b80348cac80faba69e90c5e8 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 19 Jul 2026 00:22:32 -0400 Subject: [PATCH] Polish: clear-bed, keyboard nudging, empty states, titles, 404 (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clear bed: a "Clear (N)" action on a focused plantable object → confirm modal (ClearBedModal) → soft-removes every active plop (useClearObject loops PATCHes, invalidates once); rows kept with removed_at. - Keyboard nudging (desktop): arrows move the selected object/plop 1cm, Shift 10cm; the PATCH is debounced ~400ms on key-idle. Plops nudge in the object's local frame and clamp to its bounds; ignored while typing in a field. - Empty states: hint to place a first bed on an empty field, and to add a plant in an empty focused bed (both non-interactive overlays). - Paper cuts: per-route document titles (usePageTitle on every page), an emoji favicon, and a 404 catch-all route (NotFound) inside the app shell. - Mobile: plop tap targets stay ≥~44px at low zoom via a transparent hit circle. - Ownership check hoisted to the authoritative ownerId==me so the nudge handler can gate on canEdit before the loading early-returns. Imperial coverage verified across inspectors/forms/cards/picker (all via the unit-aware helpers); conversion helpers stay in lib/units.ts (their home since #8). tsc --noEmit clean; 24/24 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- web/index.html | 1 + web/src/components/NotFound.tsx | 20 +++++ web/src/editor/ClearBedModal.tsx | 43 ++++++++++ web/src/editor/PlopMarker.tsx | 4 + web/src/lib/objects.ts | 17 ++++ web/src/lib/usePageTitle.ts | 13 ++++ web/src/pages/GardenEditorPage.tsx | 121 +++++++++++++++++++++++++++-- web/src/pages/GardensPage.tsx | 2 + web/src/pages/LoginPage.tsx | 2 + web/src/pages/PlantsPage.tsx | 2 + web/src/pages/RegisterPage.tsx | 2 + web/src/router.tsx | 3 + 12 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 web/src/components/NotFound.tsx create mode 100644 web/src/editor/ClearBedModal.tsx create mode 100644 web/src/lib/usePageTitle.ts diff --git a/web/index.html b/web/index.html index 268c135..edcae38 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,7 @@ + pansy diff --git a/web/src/components/NotFound.tsx b/web/src/components/NotFound.tsx new file mode 100644 index 0000000..11cf65a --- /dev/null +++ b/web/src/components/NotFound.tsx @@ -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 ( +
+

+ 🌱 +

+

Page not found

+

That page doesn't exist — the link may be wrong or the page moved.

+ + Back to gardens + +
+ ) +} diff --git a/web/src/editor/ClearBedModal.tsx b/web/src/editor/ClearBedModal.tsx new file mode 100644 index 0000000..76fd2fc --- /dev/null +++ b/web/src/editor/ClearBedModal.tsx @@ -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 ( + +
+

+ Remove all {n} {n === 1 ? 'plant' : 'plants'} from{' '} + {objectName}? They're marked removed but kept in history. +

+
+ + +
+
+
+ ) +} diff --git a/web/src/editor/PlopMarker.tsx b/web/src/editor/PlopMarker.tsx index 4bbbb73..1aa086e 100644 --- a/web/src/editor/PlopMarker.tsx +++ b/web/src/editor/PlopMarker.tsx @@ -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 ( + { + const today = new Date().toISOString().slice(0, 10) + await Promise.all( + plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })), + ) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }), + onError: (err) => toast.error(objectErrorMessage(err, '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) { diff --git a/web/src/lib/usePageTitle.ts b/web/src/lib/usePageTitle.ts new file mode 100644 index 0000000..668a878 --- /dev/null +++ b/web/src/lib/usePageTitle.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react' + +/** Set the document title for the current route (suffixed with · pansy), and + * restore the previous title on unmount. */ +export function usePageTitle(title: string): void { + useEffect(() => { + const prev = document.title + document.title = title ? `${title} · pansy` : 'pansy' + return () => { + document.title = prev + } + }, [title]) +} diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index daed6ee..220ecdc 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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,15 @@ import { Inspector } from '@/editor/Inspector' import { PlopInspector } from '@/editor/PlopInspector' import { PlantPicker } from '@/editor/PlantPicker' import { Palette } from '@/editor/Palette' +import { ClearBedModal } from '@/editor/ClearBedModal' import { kindDef } 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 +26,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 +41,14 @@ 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(null) const serverObjects = full.data?.objects const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects]) @@ -51,6 +57,12 @@ 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 + // 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 +120,71 @@ 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 bounds. + useEffect(() => { + if (!canEdit) return + const DIRS: Record = { + ArrowUp: [0, -1], + ArrowDown: [0, 1], + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + } + const commitLater = (fire: () => void) => { + if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) + nudgeTimer.current = window.setTimeout(() => { + fire() + nudgeTimer.current = null + }, 400) + } + function onKey(e: KeyboardEvent) { + 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() + const step = e.shiftKey ? 10 : 1 + if (s.selectedId != null) { + const base = s.liveObject?.id === s.selectedId ? s.liveObject : objects.find((o) => o.id === s.selectedId) + if (!base) return + e.preventDefault() + const next = { ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step } + s.setLiveObject(next) + commitLater(() => { + updateObject.mutate({ id: next.id, version: next.version, xCm: next.xCm, yCm: next.yCm }) + useEditorStore.getState().setLiveObject(null) + }) + } else if (s.selectedPlantingId != null) { + const base = + s.livePlanting?.id === s.selectedPlantingId + ? s.livePlanting + : plantings.find((p) => p.id === s.selectedPlantingId) + if (!base) return + e.preventDefault() + const obj = objects.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)) + } + const next = { ...base, xCm: nx, yCm: ny } + s.setLivePlanting(next) + commitLater(() => { + updatePlanting.mutate({ id: next.id, version: next.version, xCm: next.xCm, yCm: next.yCm }) + useEditorStore.getState().setLivePlanting(null) + }) + } + } + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('keydown', onKey) + if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canEdit, objects, plantings]) + if (full.isPending) return

Loading garden…

if (full.isError) return ( @@ -124,16 +201,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 @@ -185,9 +258,34 @@ export function GardenEditorPage() { + Add plant ))} + {canEdit && focusedObject.plantable && focusedPlops.length > 0 && ( + + )} )} + + {/* Empty-state hints (non-interactive overlays). */} + {canEdit && focusedObjectId == null && objects.length === 0 && ( +
+

+ Pick a shape from the palette, then tap the field to place your first bed. +

+
+ )} + {canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && ( +
+

+ Tap “+ Add plant”, then tap inside the bed to place plops. +

+
+ )} {(selectedObject || selectedPlop) && ( @@ -229,6 +327,15 @@ export function GardenEditorPage() { )} {sharing && setSharing(false)} />} + + {clearing && focusedObject && ( + ({ id: p.id, version: p.version }))} + gardenId={gid} + onClose={() => setClearing(false)} + /> + )} ) } diff --git a/web/src/pages/GardensPage.tsx b/web/src/pages/GardensPage.tsx index 808280e..5e9f8a8 100644 --- a/web/src/pages/GardensPage.tsx +++ b/web/src/pages/GardensPage.tsx @@ -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(null) diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 884918a..7b49d0b 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -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 = { } export function LoginPage() { + usePageTitle('Sign in') const search = useSearch({ from: '/login' }) const navigate = useNavigate() const providers = useProviders() diff --git a/web/src/pages/PlantsPage.tsx b/web/src/pages/PlantsPage.tsx index 648eb1f..37c91f7 100644 --- a/web/src/pages/PlantsPage.tsx +++ b/web/src/pages/PlantsPage.tsx @@ -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(() => loadUnit()) const [query, setQuery] = useState('') diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index fd3d51e..143df7e 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -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() diff --git a/web/src/router.tsx b/web/src/router.tsx index b56cc2f..951df6a 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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()({ // 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