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,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite/
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>pansy</title>
|
||||
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2701
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "pansy-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Pansy garden planner — web frontend.",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Placeholder page scaffolding used until each feature issue fills these in. */
|
||||
export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
{children ?? 'Placeholder — this page arrives in a later issue.'}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Link, Outlet } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/gardens', label: 'Gardens' },
|
||||
{ to: '/plants', label: 'Plants' },
|
||||
] as const
|
||||
|
||||
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
|
||||
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
|
||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
||||
🌱 pansy
|
||||
</Link>
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{navLinks.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg',
|
||||
)}
|
||||
activeProps={{ className: 'bg-border/60 text-fg' }}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
import './styles/index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
|
||||
},
|
||||
})
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) throw new Error('Missing #root element')
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||
|
||||
export function GardenEditorPage() {
|
||||
const { gardenId } = routeApi.useParams()
|
||||
return (
|
||||
<PageStub title={`Garden ${gardenId}`}>
|
||||
The field editor (canvas, palette, plops) arrives across issues #9–#15.
|
||||
</PageStub>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function GardensPage() {
|
||||
return <PageStub title="Gardens">The garden list and create flow arrive in issue #8.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function LoginPage() {
|
||||
return <PageStub title="Sign in">Login form and SSO button arrive in issue #6.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function PlantsPage() {
|
||||
return <PageStub title="Plants">The plant catalog arrives in issue #13.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function RegisterPage() {
|
||||
return <PageStub title="Create account">Registration form arrives in issue #6.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
redirect,
|
||||
} from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/AppShell'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { RegisterPage } from '@/pages/RegisterPage'
|
||||
import { GardensPage } from '@/pages/GardensPage'
|
||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||
import { PlantsPage } from '@/pages/PlantsPage'
|
||||
|
||||
const rootRoute = createRootRoute({ component: AppShell })
|
||||
|
||||
// The root path has no page of its own yet; send it to the gardens list. A real
|
||||
// auth-aware landing/redirect lands with the route guard in issue #6.
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/gardens' })
|
||||
},
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', component: LoginPage })
|
||||
const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', component: RegisterPage })
|
||||
const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', component: GardensPage })
|
||||
const gardenEditorRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'gardens/$gardenId',
|
||||
component: GardenEditorPage,
|
||||
})
|
||||
const plantsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'plants', component: PlantsPage })
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
loginRoute,
|
||||
registerRoute,
|
||||
gardensRoute,
|
||||
gardenEditorRoute,
|
||||
plantsRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Garden-planner theme tokens. Each --color-* becomes a Tailwind utility
|
||||
(bg-*, text-*, border-*). Light by default; dark via prefers-color-scheme. */
|
||||
@theme {
|
||||
--color-bg: #f8faf7;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e3e8e0;
|
||||
--color-muted: #61705d;
|
||||
--color-fg: #1c2419;
|
||||
|
||||
--color-accent: #3f8f4f;
|
||||
--color-accent-strong: #2f7a3e;
|
||||
--color-accent-contrast: #ffffff;
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@theme {
|
||||
--color-bg: #10140f;
|
||||
--color-surface: #171c15;
|
||||
--color-border: #2a3226;
|
||||
--color-muted: #8a967f;
|
||||
--color-fg: #e7ede2;
|
||||
|
||||
--color-accent: #5bb06a;
|
||||
--color-accent-strong: #6fc07d;
|
||||
--color-accent-contrast: #0c1109;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// In dev, Vite serves the SPA with HMR and proxies /api to the Go backend. The
|
||||
// backend listens on PANSY_PORT (default 8080); read it so the proxy target
|
||||
// tracks an overridden port. The production build is embedded into the Go binary
|
||||
// (see internal/webdist), so no proxy is involved there.
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const backendPort = env.PANSY_PORT || process.env.PANSY_PORT || '8080'
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://127.0.0.1:${backendPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user