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..ed5665e --- /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/EditorHint.tsx b/web/src/editor/EditorHint.tsx new file mode 100644 index 0000000..9b4f9c9 --- /dev/null +++ b/web/src/editor/EditorHint.tsx @@ -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 ( +
+

+ {children} +

+
+ ) +} 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 ( + 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' +} diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index a1e8cd5..e97b0be 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -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) { diff --git a/web/src/lib/usePageTitle.ts b/web/src/lib/usePageTitle.ts new file mode 100644 index 0000000..d052a94 --- /dev/null +++ b/web/src/lib/usePageTitle.ts @@ -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]) +} diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index daed6ee..e688beb 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,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(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 = { + 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

Loading garden…

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() { - - {focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'} - + {objectDisplayName(focusedObject)} {canEdit && (!focusedObject.plantable ? ( Not plantable @@ -185,9 +279,26 @@ 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 +340,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