The repo had no agent-facing project guidance: AGENTS.md contained only the generated TanStack Intent skill pointers, and README.md described a stack this app does not use. - CLAUDE.md (new): API client contract (envelope, ApiError, single-flight 401 refresh, the FormData Content-Type trap), the PERM catalog mirroring the backend permission classes, the route + NAV_GROUPS + PERM triad that must be edited together to add a page, and the useListPage/DataTable pattern. - AGENTS.md: gotchas above the generated block, which is left untouched — global query retry policy, can() vs useCan() reactivity, the gig-admin-auth persist key, and that jsdom is opted into per file via a `// @vitest-environment jsdom` pragma since there is no vitest config. - README.md: @tanstack/react-start is not a dependency (this is a plain Vite SPA, no SSR); cf:deploy no longer copies a shell to index.html; the feature list was a milestone-1 snapshot that omitted five shipped nav groups and still called moderation reports a future milestone. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_013MADG54Y51wZkU3ViRjeSi
6.7 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Standalone admin dashboard for the gig-platform Laravel backend, consuming /api/admin/v1/*. React 19, TanStack Router (+ Query + Table), shadcn/ui on Radix, Tailwind v4, TypeScript, Vite. It is a plain client-rendered SPA — index.html → main.tsx → RouterProvider; @tanstack/react-start is not a dependency, so there is no SSR and no server functions. Package manager is pnpm. UI copy is hardcoded 简体中文 — i18next/react-i18next are in package.json but nothing imports them yet, so don't reach for translation hooks.
The backend repo is expected as a sibling checkout at ../gig-platform (see pnpm gen:api).
Commands
| Command | Purpose |
|---|---|
pnpm dev |
dev server on :3000 |
pnpm build / pnpm preview |
production build / preview |
pnpm typecheck |
tsc --noEmit |
pnpm test |
vitest (all) |
pnpm test src/api/client.test.ts |
single test file |
pnpm generate-routes |
tsr generate — regenerate routeTree.gen.ts (the Vite plugin also does this on dev/build) |
pnpm gen:api |
regenerate src/api/types.gen.ts from ../gig-platform/storage/app/api-docs/admin/openapi.yaml |
pnpm cf:deploy |
build + wrangler deploy |
There is no lint script and no ESLint config — typecheck + test are the gate (and what CI runs, in .github/workflows/ci.yml and the mirrored .gitea/workflows/ci.yml).
pnpm gen:api requires the backend to have generated its docs first: run ddev artisan api-docs:generate in ../gig-platform.
Architecture
API layer (src/api/)
client.ts is the only place that talks to the network. Everything else calls the thin api.{get,post,put,patch,delete,upload,text} helpers.
- Envelope: the backend returns
{ success, data, message }; paginated lists addpagination(current_page,next_page,page_size,prev_page,total_count,total_pages). Call sites type responses asApiResponse<T>/PaginatedResponse<T>and read.data. - Errors: non-2xx throws
ApiError(status, code, message, errors?)built from the backend's{ success: false, code, message, data.errors }payload. CatchApiError, don't inspect raw responses. - Auth: Bearer token from the zustand store; on a 401 the client does a single-flight refresh (
POST /api/v1/auth/refresh-tokenwithclient_machine_name=admin-api-client— note this is the publicv1prefix, notadmin/v1) and retries the request once, then clears the session. Login and refresh passanonymous: trueto opt out of this. - Headers:
X-Language-Locale: zh-CNon every request. AFormDatabody deliberately gets noContent-Type— the browser must set it so the multipart boundary survives; setting it by hand makes the server see an empty upload (422 instead of 202).fetchgives no upload progress, so upload UIs stay indeterminate. - Endpoint modules: one file per backend domain in
src/api/modules/(studio.ts,exc.ts,categories.ts, …), each with aconst BASE = '/api/admin/v1/{module}'and exportedfetchX/ mutation functions. Add new endpoints there, not inline in components. - Types:
src/api/types.tsis hand-written and authoritative.types.gen.tsis@ts-nocheckreference-only — scribe emits duplicate operationIds so the generated file doesn't typecheck.
Auth & permissions (src/auth/)
store.ts— zustand + persist (localStorage) holding tokens, user, roles, permissions.can(key)/useCan(key)return true for anyone holding the key or thesuper-adminrole.permissions.ts— thePERMcatalog mirroring the backend'sModules/{Module}/app/Permissions/{Module}Permissions.php. Keys areadmin:{area}:{action}; a few operator-only ones use theauth:prefix (role management, db-studio, robot tokens) precisely because theadminrole is synced toadmin:%only. When the backend adds a permission, add it toPERMhere — routes and nav reference the constants, never raw strings.requirePermission(PERM.X)is the routebeforeLoadguard. It redirects to the first accessible nav route only once a session is loaded; an empty store falls through and the API's 403 is the backstop.GET /auth/merefreshesmeta.roles/meta.permissionson mount and window focus (src/routes/_authed.tsx); an account with neither is logged out with a toast.
Routing (src/routes/)
File-based TanStack Router with autoCodeSplitting. routeTree.gen.ts is generated — never edit it. _authed.tsx is the authenticated shell (sidebar + header + <Outlet/>); everything under _authed/ is behind the session check. Public routes: login.tsx, api-docs.tsx.
Adding a page means three coordinated edits: the route file (with its requirePermission guard), an entry in NAV_GROUPS in src/components/layout/nav.ts (with permission, so the sidebar self-filters), and the PERM key if it's new.
List pages
useListPage(key, fetcher, filters) in src/lib/list-page.ts is the standard pattern: it owns page / page_size state, keys the query on the serialized filters, uses keepPreviousData, and snaps back to page 1 when filters change. Pair it with <DataTable> (src/components/data-table/) and <PageHeader>. Follow this rather than hand-rolling useQuery for a table.
Layout of the rest
src/components/ui/— shadcn/ui primitives (components.jsonconfig); regenerate rather than hand-editing where possiblesrc/components/— shared app-level pieces (data-table/,page-header,status-pill,confirm-dialog,show-once-token-dialogfor one-time token reveals)src/features/{domain}/— domain-specific composed UI (detail sheets, edit sheets) pulled into route files- Path aliases: both
@/*and#/*map to./src/*; existing code consistently uses@/
Deployment
Client-rendered Vite SPA served as static assets by a Cloudflare Worker (wrangler.jsonc, not_found_handling: single-page-application, so dist/index.html answers every client route). cf:deploy is just vite build && wrangler deploy. VITE_API_BASE_URL is baked in at build time — it is a CI variable, not a runtime setting. Pushes to main typecheck + test + build + deploy; CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are repo secrets.
Notes
AGENTS.mdholds gotchas and non-obvious conventions that complement this file; everything below itsintent-skillsmarkers is generated by TanStack Intent — edit only above them.- Local backend defaults to
https://gig-platform.ddev.site(.env.example). Seed a local admin from the backend repo withddev exec php artisan tinker storage/app/e2e-admin-seed.php→[email protected] / Admin@12345.