Phase 0: backend + frontend scaffold, single-binary build
Implements the Pansy v1 scaffold (epic #20, phase 0). #1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config, gin server with slog + recovery + /api/v1/healthz, modernc SQLite store with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration runner, full initial schema (users, sessions, gardens, garden_shares, garden_objects, plants, plantings), domain structs + sentinel errors, graceful shutdown. #2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack Router/Query, typed /api/v1 fetch wrapper (ApiError carries status + body so later issues can read 409 conflict rows), dev proxy /api -> :8080, responsive app shell with stub pages for all five routes. #3 Single binary: //go:embed of the web build with a committed placeholder, SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched /api and missing assets), Makefile (web/build/dev/test), README quickstart + env var table. Verified: go build/vet/test clean (CGO off); binary migrates idempotently and serves healthz; web tsc + build clean; integrated binary serves the SPA, deep links, and correct cache headers; app mounts and navigates all five routes at desktop and 375px widths. 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,121 @@
|
||||
// Typed fetch wrapper for the pansy JSON API under /api/v1.
|
||||
//
|
||||
// Every non-2xx response throws an ApiError carrying the HTTP status and the
|
||||
// parsed response body. That contract matters beyond this issue: the optimistic
|
||||
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
|
||||
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
|
||||
|
||||
const BASE = '/api/v1'
|
||||
|
||||
/** Error thrown for any non-2xx response (or a network failure, status 0). */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
/** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */
|
||||
readonly body: unknown
|
||||
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
/** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */
|
||||
get isConflict(): boolean {
|
||||
return this.status === 409
|
||||
}
|
||||
|
||||
/** No authenticated session; callers typically redirect to /login. */
|
||||
get isUnauthorized(): boolean {
|
||||
return this.status === 401
|
||||
}
|
||||
}
|
||||
|
||||
type ParamValue = string | number | boolean | undefined | null
|
||||
export type Params = Record<string, ParamValue>
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
/** JSON request body; serialized and sent with a JSON content-type. */
|
||||
body?: unknown
|
||||
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
||||
params?: Params
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Params): string {
|
||||
const url = BASE + path
|
||||
if (!params) return url
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue
|
||||
qs.set(k, String(v))
|
||||
}
|
||||
const s = qs.toString()
|
||||
return s ? `${url}?${s}` : url
|
||||
}
|
||||
|
||||
async function parseBody(res: Response): Promise<unknown> {
|
||||
if (res.status === 204) return null
|
||||
const text = await res.text()
|
||||
if (!text) return null
|
||||
const contentType = res.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function messageFrom(body: unknown, status: number): string {
|
||||
if (body && typeof body === 'object') {
|
||||
const err = (body as { error?: { message?: string } }).error
|
||||
if (err?.message) return err.message
|
||||
const msg = (body as { message?: string }).message
|
||||
if (msg) return msg
|
||||
}
|
||||
return `Request failed (${status})`
|
||||
}
|
||||
|
||||
/** Perform a request against /api/v1 and return the parsed JSON body as T. */
|
||||
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, params, signal } = opts
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(buildUrl(path, params), {
|
||||
method,
|
||||
signal,
|
||||
credentials: 'same-origin', // send the HttpOnly session cookie
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') throw err
|
||||
throw new ApiError('Cannot reach the pansy server.', 0, null)
|
||||
}
|
||||
|
||||
const payload = await parseBody(res)
|
||||
if (!res.ok) {
|
||||
throw new ApiError(messageFrom(payload, res.status), res.status, payload)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
/** Convenience verbs over apiFetch. */
|
||||
export const api = {
|
||||
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
||||
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
||||
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
||||
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/** Merge conditional class names, resolving Tailwind conflicts (last wins). */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user