// 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.enum(['metric', 'imperial']) export const gardenRoleSchema = z.enum(['owner', 'editor', 'viewer']) export type GardenRole = z.infer 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(), gridSizeCm: z.number(), snapToGrid: z.boolean(), version: z.number(), createdAt: z.string(), updatedAt: z.string(), // The actor's effective role on this garden (owner/editor/viewer). Present on // every accessible response; optional defensively. myRole: gardenRoleSchema.optional(), }) export type Garden = z.infer /** Whether a role may edit garden contents (objects/plops). Unknown → false. */ export function canEditRole(role?: GardenRole): boolean { return role === 'owner' || role === 'editor' } /** Whether a role owns the garden (may edit metadata, manage shares, delete). */ export function isOwnerRole(role?: GardenRole): boolean { return role === 'owner' } const gardensKey = ['gardens'] as const export const gardensQueryOptions = queryOptions({ queryKey: gardensKey, queryFn: async (): Promise => 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 gridSizeCm: number snapToGrid: boolean } export function useCreateGarden() { const qc = useQueryClient() return useMutation({ mutationFn: async (input: GardenInput): Promise => gardenSchema.parse(await api.post('/gardens', input)), onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }), }) } export interface GardenUpdate extends GardenInput { id: number version: number } export function useUpdateGarden() { const qc = useQueryClient() return useMutation({ // id travels with the mutation variables (not the hook) so callers don't need // a placeholder id before a garden is chosen. mutationFn: async ({ id, ...input }: GardenUpdate): Promise => gardenSchema.parse(await api.patch(`/gardens/${id}`, input)), onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }), }) } // Mirrors the server's garden-name cap (maxGardenNameLen in // internal/service/gardens.go). It is a BYTE cap, not a character count. const MAX_GARDEN_NAME_BYTES = 200 const COPY_SUFFIX = ' (copy)' /** Trim s to at most maxBytes of UTF-8, never splitting a code point. */ function truncateUtf8(s: string, maxBytes: number): string { const enc = new TextEncoder() if (enc.encode(s).length <= maxBytes) return s let out = '' let used = 0 for (const ch of s) { const size = enc.encode(ch).length if (used + size > maxBytes) break out += ch used += size } return out } /** * The name a copy gets by default. Mirrors the server's copyName (same file as * the cap above) so the prefilled value is one the server will accept — without * the byte-capped truncation, copying a garden whose name is already at the cap * would prefill an over-long name and fail with a 400. */ export function defaultCopyName(name: string): string { return truncateUtf8(name, MAX_GARDEN_NAME_BYTES - COPY_SUFFIX.length).trim() + COPY_SUFFIX } /** * Duplicate a garden the actor owns, including its objects and current plantings. * An omitted/blank name lets the server derive " (copy)". The copy does * not inherit the source's public link or shares. */ export function useCopyGarden() { const qc = useQueryClient() return useMutation({ mutationFn: async ({ id, name }: { id: number; name?: string }): Promise => gardenSchema.parse(await api.post(`/gardens/${id}/copy`, { name: name ?? '' })), 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 }