Polish: clear-bed, keyboard nudging, empty states, titles, 404 (#18)
- 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) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<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>
|
<title>pansy</title>
|
||||||
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
|
||||||
|
>
|
||||||
|
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ export const PlopMarker = memo(function PlopMarker({
|
|||||||
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
|
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
|
||||||
const showText = scale >= SEMANTIC_NEAR && !!plant
|
const showText = scale >= SEMANTIC_NEAR && !!plant
|
||||||
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
|
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) {
|
function down(e: PointerEvent) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -45,6 +48,7 @@ export const PlopMarker = memo(function PlopMarker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
|
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
|
||||||
|
<circle cx={0} cy={0} r={hitR} fill="transparent" />
|
||||||
<circle
|
<circle
|
||||||
cx={0}
|
cx={0}
|
||||||
cy={0}
|
cy={0}
|
||||||
|
|||||||
@@ -263,6 +263,23 @@ 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)
|
||||||
|
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
|
/** Soft-remove ("Remove"): PATCH removed_at to today; the row is kept but leaves
|
||||||
* the active list. Distinct from a hard delete. */
|
* the active list. Distinct from a hard delete. */
|
||||||
export function useRemovePlanting(gardenId: number) {
|
export function useRemovePlanting(gardenId: number) {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getRouteApi } from '@tanstack/react-router'
|
import { getRouteApi } from '@tanstack/react-router'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@@ -7,13 +7,15 @@ import { Inspector } from '@/editor/Inspector'
|
|||||||
import { PlopInspector } from '@/editor/PlopInspector'
|
import { PlopInspector } from '@/editor/PlopInspector'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
import { Palette } from '@/editor/Palette'
|
import { Palette } from '@/editor/Palette'
|
||||||
|
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||||
import { kindDef } from '@/editor/kinds'
|
import { kindDef } from '@/editor/kinds'
|
||||||
import { useEditorStore } from '@/editor/store'
|
import { useEditorStore } from '@/editor/store'
|
||||||
import type { EditorGarden } from '@/editor/types'
|
import type { EditorGarden } from '@/editor/types'
|
||||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||||
import { useMe } from '@/lib/auth'
|
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 { toEditorPlanting } from '@/lib/plantings'
|
||||||
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
const routeApi = getRouteApi('/gardens/$gardenId')
|
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export function GardenEditorPage() {
|
|||||||
const navigate = routeApi.useNavigate()
|
const navigate = routeApi.useNavigate()
|
||||||
const full = useGardenFull(gid)
|
const full = useGardenFull(gid)
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
|
usePageTitle(full.data?.garden.name ?? 'Garden')
|
||||||
|
|
||||||
const selectedId = useEditorStore((s) => s.selectedId)
|
const selectedId = useEditorStore((s) => s.selectedId)
|
||||||
const select = useEditorStore((s) => s.select)
|
const select = useEditorStore((s) => s.select)
|
||||||
@@ -38,11 +41,14 @@ export function GardenEditorPage() {
|
|||||||
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
|
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
|
||||||
|
|
||||||
const updatePlanting = useUpdatePlanting(gid)
|
const updatePlanting = useUpdatePlanting(gid)
|
||||||
|
const updateObject = useUpdateObject(gid)
|
||||||
|
|
||||||
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
|
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
|
||||||
// 'change' swaps the selected plop's plant.
|
// 'change' swaps the selected plop's plant.
|
||||||
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
|
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
|
||||||
const [sharing, setSharing] = useState(false)
|
const [sharing, setSharing] = useState(false)
|
||||||
|
const [clearing, setClearing] = useState(false)
|
||||||
|
const nudgeTimer = useRef<number | null>(null)
|
||||||
|
|
||||||
const serverObjects = full.data?.objects
|
const serverObjects = full.data?.objects
|
||||||
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
|
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
|
||||||
@@ -51,6 +57,12 @@ export function GardenEditorPage() {
|
|||||||
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
||||||
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [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
|
// 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,
|
// gardens (and on leaving). `focus` is intentionally read once here, not a dep,
|
||||||
// so URL syncs below don't retrigger a full reset.
|
// 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
|
// 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<string, [number, number]> = {
|
||||||
|
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 <p className="p-6 text-sm text-muted">Loading garden…</p>
|
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
||||||
if (full.isError)
|
if (full.isError)
|
||||||
return (
|
return (
|
||||||
@@ -124,16 +201,12 @@ export function GardenEditorPage() {
|
|||||||
heightCm: g.heightCm,
|
heightCm: g.heightCm,
|
||||||
unitPref: g.unitPref,
|
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 selectedObject = objects.find((o) => o.id === selectedId) ?? null
|
||||||
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
|
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
|
||||||
// Committed selected plop; PlopInspector applies live drag geometry itself.
|
// Committed selected plop; PlopInspector applies live drag geometry itself.
|
||||||
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
||||||
|
const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : []
|
||||||
|
|
||||||
function onPickPlant(plantId: number) {
|
function onPickPlant(plantId: number) {
|
||||||
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||||
@@ -185,9 +258,34 @@ export function GardenEditorPage() {
|
|||||||
+ Add plant
|
+ Add plant
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||||
|
|
||||||
|
{/* Empty-state hints (non-interactive overlays). */}
|
||||||
|
{canEdit && focusedObjectId == null && objects.length === 0 && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-6 text-center">
|
||||||
|
<p className="max-w-xs rounded-lg bg-surface/85 px-4 py-3 text-sm text-muted shadow-sm backdrop-blur">
|
||||||
|
Pick a shape from the palette, then tap the field to place your first bed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-16 flex justify-center p-6 text-center">
|
||||||
|
<p className="max-w-xs rounded-lg bg-surface/85 px-4 py-3 text-sm text-muted shadow-sm backdrop-blur">
|
||||||
|
Tap “+ Add plant”, then tap inside the bed to place plops.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(selectedObject || selectedPlop) && (
|
{(selectedObject || selectedPlop) && (
|
||||||
@@ -229,6 +327,15 @@ export function GardenEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
||||||
|
|
||||||
|
{clearing && focusedObject && (
|
||||||
|
<ClearBedModal
|
||||||
|
objectName={focusedObject.name || kindDef(focusedObject.kind)?.label || 'bed'}
|
||||||
|
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
|
||||||
|
gardenId={gid}
|
||||||
|
onClose={() => setClearing(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
|
|||||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||||
import { useMe } from '@/lib/auth'
|
import { useMe } from '@/lib/auth'
|
||||||
import { useGardens, type Garden } from '@/lib/gardens'
|
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.
|
// Which modal is open, if any. edit/delete/share/leave carry the target garden.
|
||||||
type Dialog =
|
type Dialog =
|
||||||
@@ -19,6 +20,7 @@ type Dialog =
|
|||||||
| null
|
| null
|
||||||
|
|
||||||
export function GardensPage() {
|
export function GardensPage() {
|
||||||
|
usePageTitle('Gardens')
|
||||||
const gardens = useGardens()
|
const gardens = useGardens()
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const [dialog, setDialog] = useState<Dialog>(null)
|
const [dialog, setDialog] = useState<Dialog>(null)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TextField } from '@/components/ui/TextField'
|
|||||||
import { API_BASE, errorMessage } from '@/lib/api'
|
import { API_BASE, errorMessage } from '@/lib/api'
|
||||||
import { useLogin, useProviders } from '@/lib/auth'
|
import { useLogin, useProviders } from '@/lib/auth'
|
||||||
import { safeRedirectPath } from '@/lib/redirect'
|
import { safeRedirectPath } from '@/lib/redirect'
|
||||||
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
// Server-initiated redirect (not a fetch): the browser navigates to the Go
|
// Server-initiated redirect (not a fetch): the browser navigates to the Go
|
||||||
// handler, which 302s to the IdP.
|
// handler, which 302s to the IdP.
|
||||||
@@ -24,6 +25,7 @@ const callbackErrors: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
usePageTitle('Sign in')
|
||||||
const search = useSearch({ from: '/login' })
|
const search = useSearch({ from: '/login' })
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
|||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||||
import type { UnitPref } from '@/lib/units'
|
import type { UnitPref } from '@/lib/units'
|
||||||
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
// Which modal is open, if any. edit/duplicate/delete carry the target plant.
|
// Which modal is open, if any. edit/duplicate/delete carry the target plant.
|
||||||
type Dialog =
|
type Dialog =
|
||||||
@@ -32,6 +33,7 @@ function loadUnit(): UnitPref {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlantsPage() {
|
export function PlantsPage() {
|
||||||
|
usePageTitle('Plants')
|
||||||
const plants = usePlants()
|
const plants = usePlants()
|
||||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { TextField } from '@/components/ui/TextField'
|
import { TextField } from '@/components/ui/TextField'
|
||||||
import { apiErrorCode, errorMessage } from '@/lib/api'
|
import { apiErrorCode, errorMessage } from '@/lib/api'
|
||||||
import { useProviders, useRegister } from '@/lib/auth'
|
import { useProviders, useRegister } from '@/lib/auth'
|
||||||
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
const MIN_PASSWORD = 8
|
const MIN_PASSWORD = 8
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
|
usePageTitle('Create account')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const register = useRegister()
|
const register = useRegister()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { AppShell } from '@/components/layout/AppShell'
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { NotFound } from '@/components/NotFound'
|
||||||
import { RouteError } from '@/components/RouteError'
|
import { RouteError } from '@/components/RouteError'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { RegisterPage } from '@/pages/RegisterPage'
|
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 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.
|
// a 500 or the network drops) lands here instead of a blank screen.
|
||||||
errorComponent: RouteError,
|
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
|
// requireAuth: resolve the current user (shared cache with useMe); send anyone
|
||||||
|
|||||||
Reference in New Issue
Block a user