Plops editor: focus mode, place/move/resize, semantic zoom (#15)
Build image / build-and-push (push) Successful in 14s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #34.
This commit is contained in:
2026-07-19 03:22:51 +00:00
committed by steve
parent f4e5dab98c
commit 48ba08e8f2
14 changed files with 1084 additions and 73 deletions
+146 -2
View File
@@ -7,6 +7,8 @@ import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient }
import { z } from 'zod'
import { ApiError, api } from './api'
import { gardenSchema } from './gardens'
import { plantSchema } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings'
import { toast } from '@/components/ui/toast'
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
@@ -35,8 +37,8 @@ export type ServerObject = z.infer<typeof serverObjectSchema>
export const fullGardenSchema = z.object({
garden: gardenSchema,
objects: z.array(serverObjectSchema),
plantings: z.array(z.unknown()), // typed in #14
plants: z.array(z.unknown()), // typed in #12
plantings: z.array(serverPlantingSchema),
plants: z.array(plantSchema),
})
export type FullGarden = z.infer<typeof fullGardenSchema>
@@ -181,12 +183,154 @@ export function useDeleteObject(gardenId: number) {
})
}
// --- planting (plop) mutations ---------------------------------------------
// Plops are part of the FullGarden cache, so these patch the same query as the
// object mutations, with the same optimistic + 409-rollback contract.
export interface PlantingCreate {
objectId: number
plantId: number
xCm: number
yCm: number
radiusCm: number
count?: number | null
label?: string | null
}
export function useCreatePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)),
onSuccess: (created) => {
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
},
onError: (err) => toast.error(objectErrorMessage(err, 'Could not place that plant.')),
})
}
/** Fields the plop editor patches. version is required for the optimistic guard. */
export interface PlantingPatch {
id: number
version: number
plantId?: number
xCm?: number
yCm?: number
radiusCm?: number
count?: number | null
label?: string | null
plantedAt?: string | null
removedAt?: string | null
}
export function useUpdatePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...body }: PlantingPatch): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, body)),
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === patch.id ? applyPlantingPatch(p, patch) : p)),
}))
return { prev }
},
onSuccess: (updated) => {
patchFullCache(qc, gardenId, (full) => ({
...full,
// A soft-remove (removedAt set) drops the plop from the active list;
// otherwise write the authoritative row (carries the fresh derivedCount).
plantings: updated.removedAt
? full.plantings.filter((p) => p.id !== updated.id)
: full.plantings.map((p) => (p.id === updated.id ? updated : p)),
}))
},
onError: (err, _patch, ctx) => {
const current = conflictPlanting(err)
if (current) {
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === current.id ? current : p)),
}))
toast.error('That plant was updated elsewhere — your change was rolled back.')
} else if (ctx?.prev) {
qc.setQueryData(fullKey(gardenId), ctx.prev)
toast.error(objectErrorMessage(err, 'Could not save that change.'))
}
},
})
}
/** 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) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
const today = new Date().toISOString().slice(0, 10)
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
},
onMutate: async ({ id }) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.filter((p) => p.id !== id) }))
return { prev }
},
onError: (err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(fullKey(gardenId), ctx.prev)
// On a 409, adopt the server's current row so the plop reappears with a
// fresh version — otherwise a retry keeps 409-ing on the stale version.
const current = conflictPlanting(err)
if (current) {
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === current.id ? current : p)),
}))
toast.error('That plant was updated elsewhere — try removing it again.')
} else {
toast.error(objectErrorMessage(err, 'Could not remove that plant.'))
}
},
})
}
// --- helpers ---------------------------------------------------------------
function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) {
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
}
/** Apply a plop patch onto a cached row for the optimistic pass. version is
* bumped to mirror the server's post-commit increment (as applyServerPatch does
* for objects), so a fast follow-up edit doesn't self-conflict. derivedCount is
* left as-is here; the editor recomputes it live from the plant's spacing and
* onSuccess writes the authoritative value. */
function applyPlantingPatch(p: ServerPlanting, patch: PlantingPatch): ServerPlanting {
return {
...p,
version: p.version + 1,
...(patch.plantId !== undefined ? { plantId: patch.plantId } : {}),
...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}),
...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}),
...(patch.radiusCm !== undefined ? { radiusCm: patch.radiusCm } : {}),
...(patch.count !== undefined ? { count: patch.count } : {}),
...(patch.label !== undefined ? { label: patch.label } : {}),
...(patch.plantedAt !== undefined ? { plantedAt: patch.plantedAt } : {}),
...(patch.removedAt !== undefined ? { removedAt: patch.removedAt } : {}),
}
}
/** The current server plop from a 409 conflict body, or null. */
function conflictPlanting(err: unknown): ServerPlanting | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const parsed = serverPlantingSchema.safeParse((err.body as { current?: unknown }).current)
if (parsed.success) return parsed.data
}
return null
}
/** Apply a patch onto a cached server object for the optimistic pass. The
* version is bumped to mirror the server's post-commit increment, so a second
* edit started before the first PATCH resolves reads the next version instead