Build image / build-and-push (push) Successful in 16s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
153 lines
4.9 KiB
TypeScript
153 lines
4.9 KiB
TypeScript
// 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<typeof gardenRoleSchema>
|
|
|
|
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<typeof gardenSchema>
|
|
|
|
/** 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<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
|
|
gridSizeCm: number
|
|
snapToGrid: boolean
|
|
}
|
|
|
|
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 {
|
|
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<Garden> =>
|
|
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 "<source> (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<Garden> =>
|
|
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
|
|
}
|