Polish: imperial, clear-bed, keyboard nudging, empty states (#18)
Build image / build-and-push (push) Successful in 4s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user