Add gardens UI: list page + create/edit/delete (#8)
The /gardens page is now home base, backed by the #7 API. - lib/units.ts: cm <-> meters/feet conversion + formatCm/formatDimensions (metric "m", imperial "ft/in"). Minimal for #8; #9's geometry lib consolidates. - lib/gardens.ts: zod gardenSchema + useGardens/useCreateGarden/ useUpdateGarden/useDeleteGarden (mutations invalidate the list) and conflictGarden() to pull the fresh row out of a 409. - GardensPage: loading/empty/error states; empty state prompts a first garden; responsive card grid (single column on phones). - GardenCard: name, dimensions formatted per the garden's unit, notes preview; body links into /gardens/:id, edit/delete kept out of the link. - GardenFormModal: create/edit with a unit selector; dimensions are entered in the chosen unit and converted to cm (switching units converts the current values). A 409 rebases the form onto the server's fresh row. - DeleteGardenModal: confirmation before removing. - ui/: Modal (Escape/backdrop close), TextArea, Select. Verified in a real browser against the embedded binary: create appears without reload; edit persists (version 2), form prefilled from stored cm converted back to the garden's unit; delete confirms then removes -> empty state; an imperial 4 ft x 8 ft garden stores 122 x 244 cm and shows "4' 0" x 8' 0"". tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// Gardens data layer: zod-validated shapes for /api/v1/gardens plus the
|
||||
// react-query hooks the /gardens page uses.
|
||||
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
import type { UnitPref } from './units'
|
||||
|
||||
const unitPrefSchema: z.ZodType<UnitPref> = z.enum(['metric', 'imperial'])
|
||||
|
||||
export const gardenSchema = z.object({
|
||||
id: z.number(),
|
||||
ownerId: z.number(),
|
||||
name: z.string(),
|
||||
widthCm: z.number(),
|
||||
heightCm: z.number(),
|
||||
unitPref: unitPrefSchema,
|
||||
notes: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type Garden = z.infer<typeof gardenSchema>
|
||||
|
||||
const gardensKey = ['gardens'] as const
|
||||
|
||||
export const gardensQueryOptions = queryOptions({
|
||||
queryKey: gardensKey,
|
||||
queryFn: async (): Promise<Garden[]> => z.array(gardenSchema).parse(await api.get('/gardens')),
|
||||
})
|
||||
|
||||
export function useGardens() {
|
||||
return useQuery(gardensQueryOptions)
|
||||
}
|
||||
|
||||
export interface GardenInput {
|
||||
name: string
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
unitPref: UnitPref
|
||||
notes: string
|
||||
}
|
||||
|
||||
export function useCreateGarden() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: GardenInput): Promise<Garden> =>
|
||||
gardenSchema.parse(await api.post('/gardens', input)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface GardenUpdate extends GardenInput {
|
||||
version: number
|
||||
}
|
||||
|
||||
export function useUpdateGarden(id: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: GardenUpdate): Promise<Garden> =>
|
||||
gardenSchema.parse(await api.patch(`/gardens/${id}`, input)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteGarden() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/gardens/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* If err is a 409 version conflict, return the fresh server row it carries
|
||||
* (under `current`), so a form can rebase onto it; otherwise null.
|
||||
*/
|
||||
export function conflictGarden(err: unknown): Garden | null {
|
||||
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
|
||||
const current = (err.body as { current?: unknown }).current
|
||||
const parsed = gardenSchema.safeParse(current)
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user