diff --git a/.gitignore b/.gitignore index d2afc9b..34fd924 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ dist-ssr __unconfig* todos.json /prisma/generated -settings.local.json \ No newline at end of file +settings.local.json +.idea \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 32e0fe8..6f73a93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,20 +26,19 @@ Postgres is required. Local dev uses the Docker Compose stack; all `db:*` script ```bash pnpm db:local:start # start local postgres (compose.local.yaml) pnpm db:generate # prisma generate → writes to prisma/generated/prisma & prisma/generated/prisma-idb -pnpm db:push # push schema without a migration -pnpm db:migrate # create + apply a dev migration +pnpm db:push # push schema changes directly to the database (no migration files) pnpm db:studio # Prisma Studio pnpm db:seed # run prisma/seed.ts ``` +This project does **not** use Prisma migrations — schema changes are applied with `pnpm db:push`. After editing any `.prisma` file, run `pnpm db:generate` to regenerate both the Postgres and IndexedDB clients, then `pnpm db:push` to sync the schema to the database. + Two Prisma clients are generated from a single `schema.prisma`: - `prisma/generated/prisma` — Postgres server client (used via `src/db.ts` and `prisma/client.ts`). - `prisma/generated/prisma-idb` — IndexedDB client for the browser (via `@prisma-idb/idb-client-generator`), used offline/client-side in `src/integrations/prisma-idb`. Per-game Prisma models live in separate files imported by `schema.prisma`: `prisma/models/clairobscur.prisma`, `prisma/models/remnant2.prisma`, `prisma/models/slaythespire2.prisma`. When adding a new game, create a corresponding `prisma/models/.prisma`. -Always re-run `pnpm db:generate` after editing any `.prisma` file; both generators run together. - ## Skills Skills live in `.claude/skills/`. Use the `/skill-name` slash command or reference them when working on relevant code. @@ -62,7 +61,7 @@ Skills live in `.claude/skills/`. Use the `/skill-name` slash command or referen ### Framework stack - **TanStack Start** (SSR + server functions) on top of **TanStack Router** with file-based routing. `vite.config.ts` registers `tanstackStart()` before `@vitejs/plugin-react` — order matters. -- **React 19** with `babel-plugin-react-compiler` enabled. +- **React 19** with the **React Compiler** enabled. `babel-plugin-react-compiler` is installed as a dev dependency and is auto-detected by `@vitejs/plugin-react` v6+ — no explicit babel config is required, but the package must remain installed for the compiler to run. Write idiomatic React (no manual `useMemo`/`useCallback`/`React.memo` for values that don't need stable identity); the compiler handles memoization. - **Mantine v9** for UI (core, dates, modals, notifications, carousel, spotlight, tiptap, code-highlight). `next-themes` drives the theme class on ``. - **Better Auth** for auth, mounted as a catch-all route at `src/routes/api/auth/$.ts`. Prisma adapter, Discord OAuth, email/password with Resend-sent verification + reset emails (React Email templates in `src/emails/auth/`). - **TanStack Query** integrated with the router via `setupRouterSsrQueryIntegration` in `src/router.tsx`. Shared `QueryClient` is created in `src/integrations/tanstack-query/get-context.ts` and attached to router context. @@ -72,25 +71,27 @@ Skills live in `.claude/skills/`. Use the `/skill-name` slash command or referen - Routes live in `src/routes/`. `routeTree.gen.ts` is **generated** — do not edit (also marked read-only in `.vscode/settings.json` and excluded from Biome). - Root shell is `src/routes/__root.tsx`: renders ``, global providers (`GameProvider` → `MantineProviderWithTheme` → `ModalsProvider`), and the Mantine `AppShell` (header + navbar + footer). - Game-scoped URLs live under `src/routes/$gameId/` — the `gameId` param is written into the game store on mount. -- Profile routes: `src/routes/profile/` (current user, must be signed in) and `src/routes/account/profile/$userId/` (public profiles). +- Profile routes: + - `src/routes/profile/` — the anonymous/offline-friendly profile shell. Always reachable, even when signed out or offline (it renders the local DAL view). When the user is both authenticated **and** online, `route.tsx` redirects to `/account/profile/$userId` with the current session's user id. + - `src/routes/account/profile/$userId/` — the canonical, userId-keyed profile route. Used for both the current user (after the redirect above) and public views of other users. ### Game registry pattern -Everything game-specific hangs off a central registry. Each game under `src/games//` exposes a `game-config/index.ts` that exports: +Everything game-specific hangs off a central registry. Each game under `src/games//` exposes a `core/game-config/index.ts` that exports: ```typescript export const GAME_CONFIG = { - ITEMS, // { all, collectable, categorized, categories, uncollectableCategories } - THEME, // ToolkitThemeDefinition - METADATA, // id, name, label, description, faviconSourcePath, renderLogo(), externalResources[] - PAGES, // { renderItemLookup: () => ReactNode } - SEARCH_PARAMS, // nuqs search param cache (optional) - AVATARS, // GameAvatar[] (optional) - DAL, // { collectedItems: GameCollectedItemsDal } + ITEMS, // { all, collectable, categorized, categories, uncollectableCategories } + THEME, // ToolkitThemeDefinition + METADATA, // id, name, label, description, faviconSourcePath, renderLogo(), externalResources[] + PAGES, // { renderItemLookup: () => ReactNode } + SEARCH_PARAMS, // nuqs search param cache (optional — `undefined` if the game has no custom filters) + AVATARS, // GameAvatar[] (optional) + DAL, // { collectedItems: GameCollectedItemsDal } } satisfies GameConfig ``` -`src/features/game/registry/game-registry.tsx` wires every `gameId` to its `GameConfig` and exports convenience functions: `getGameConfig()`, `getGameItems()`, `getGameTheme()`, `getGameMetadata()`, `getGamePages()`, `getGameAvatars()`, `getGameConfigTyped()`, `getAllRegisteredThemeDefinitions()`, `getAllRegisteredThemeClassNames()`, `isRegisteredGameId()`, `getValidatedGameId()`. +`src/features/game/registry/game-registry.tsx` wires every `gameId` to its `GameConfig` and exports: `GAME_REGISTRY`, `REGISTERED_GAME_IDS`, `getGameConfig()`, `getGameConfigTyped()`, `getGameItems()`, `getGameTheme()`, `getGameMetadata()`, `getGamePages()`, `getGameAvatars()`, `getGameLogo()`, `getGameSearchParams()`, `getAllRegisteredThemeDefinitions()`, `getAllRegisteredThemeClassNames()`, `isRegisteredGameId()`, `getValidatedGameId()`. The `GameId` enum is defined in `schema.prisma` and imported from `@/prisma`. `getAllRegisteredThemeDefinitions()` expands each game theme into light+dark variants plus a base `default-light`/`default-dark`. @@ -99,9 +100,16 @@ The `GameId` enum is defined in `schema.prisma` and imported from `@/prisma`. `g Game-specific logic (Prisma queries, sync handlers, server functions, DAL actions) must live in `src/games//`. Never add game-keyed branches or inline game handlers to files under `src/features/`. DAL actions split by scope: -- **Cross-game** (e.g. `favoriteGames`, `userProfile`) → `src/features/dal/actions/` +- **Cross-game** (e.g. `favoriteGames`, `userProfile`) → `src/features//dal//` — currently all under `src/features/auth/dal/` since both belong to the authenticated-user surface area. - **Game-specific** (e.g. `collectedItems`) → `src/games//dal/` +Within each cross-game DAL folder, files follow a consistent suffix convention: + +- `.ts` — TanStack Start server functions (Postgres reads/writes via Prisma) +- `.idb.ts` — IndexedDB layer (local reads/writes via the prisma-idb client) +- `.actions.ts` — `defineDalRead` / `defineDalWrite` action definitions wiring `remote` to the server functions and `local` to the IDB helpers +- `sync-handler.server.ts` — server-side sync handler invoked by `applyPendingOpServerFn` + All cross-game aggregation maps (registries) belong in `src/features/game/registry/`. When adding a new game, that folder is the single place to look for all maps that need a new entry — `game-registry.tsx`, `game-sync-handler-registry.ts`, `game-db-seed-registry.ts`, `game-idb-seed-registry.ts`, `favicon-registry.json`. Registry files may import from `src/games/` but must not contain any per-game business logic inline. ### Adding a new game @@ -112,32 +120,35 @@ Follow these steps in order. The registry is the single place to check; no other 2. **Create game models** — copy an existing `prisma/models/.prisma` as a template and create `prisma/models/.prisma`. Add the `@@prisma.import` line in `schema.prisma`. -3. **Generate & migrate** — `pnpm db:generate && pnpm db:migrate`. +3. **Generate & push** — `pnpm db:generate && pnpm db:push`. -4. **Scaffold the game directory** — create `src/games//` with: +4. **Scaffold the game directory** — create `src/games//` with the two top-level folders `core/` and `dal/`: ``` - game-config/ - index.ts # exports GAME_CONFIG satisfies GameConfig - metadata.tsx # id, name, label, description, faviconSourcePath, renderLogo() - pages.tsx # GamePages with renderItemLookup() - theme.ts # ToolkitThemeDefinition (colors, Mantine overrides) - items.ts # item data + categorization - search-params.ts # nuqs parsers (optional — only if custom filters needed) - avatars.ts # GameAvatar[] (optional) - db-seed.ts # GameDBSeed (initial Postgres data) - idb-seed.ts # GameIDBSeed (initial IndexedDB data) + core/ + game-config/ + index.ts # exports GAME_CONFIG satisfies GameConfig + metadata.tsx # id, name, label, description, faviconSourcePath, renderLogo() + pages.tsx # GamePages with renderItemLookup() + theme.ts # ToolkitThemeDefinition (colors, Mantine overrides) + items.ts # item data + categorization + search-params.ts # nuqs parsers (optional — only if custom filters needed) + avatars.ts # GameAvatar[] (optional) + db-seed.ts # GameDBSeed (initial Postgres data) + idb-seed.ts # GameIDBSeed (initial IndexedDB data) + item-data/ # raw item definitions consumed by game-config/items.ts + types.ts # game-specific TypeScript types (LocalItem, etc.) + constants.ts # game-specific constants + Logo.tsx # game logo component referenced by metadata.renderLogo() dal/ - collected-items.ts # defineDalRead / defineDalWrite actions + collected-items.ts # exports the GameCollectedItemsDal via createCollectedItemsDal() server/ - sync-handler.ts # createCollectedItemSyncHandler export - server-functions.ts # TanStack Start server functions - types/ # game-specific TypeScript types - constants/ # game-specific constants - item-data/ # raw item definitions - components/ # game-specific UI (e.g. Logo.tsx) + collected-items.ts # TanStack Start server functions for collect/uncollect/list + sync-handler.ts # collectedItemSyncHandler — registered in game-sync-handler-registry.ts ``` + The DAL action file (`dal/collected-items.ts`) is a thin wrapper that calls `createCollectedItemsDal({ entityName, getModel, serverFns })` from `#/features/dal/core/create-collected-items-dal`. + 5. **Register in all registry files** (all live in `src/features/game/registry/`): | File | What to add | @@ -159,13 +170,24 @@ A `subdomain`-sourced value deliberately wins over later `route` writes — be c ### Theme system -- Mantine theme objects live in `src/features/theme/themes/` and per-game `game-config/theme.ts`. +- Mantine theme objects live in `src/features/theme/themes/` and per-game `src/games//core/game-config/theme.ts`. - `MantineProviderWithTheme` reads the active Mantine theme from `theme-store.ts` and feeds `next-themes` with the full list of registered theme class names (`getAllRegisteredThemeClassNames()`), so `html[data-theme]`/`className` toggling is driven off the registry. - `SyncAndApplyTheme` syncs `next-themes` ↔ the Mantine store and persists `autoChangeTheme` in `localStorage`. ### DAL (offline-first) -The DAL (`src/features/dal/`) is an offline-first data layer. Every read/write executes against either a **remote** backend (TanStack Start server functions → Postgres) or a **local** backend (IndexedDB). The backend is chosen automatically: remote when the user is authenticated and online, local otherwise. Local writes are queued as `PendingOp`s and synced later with last-write-wins conflict resolution. +The DAL (`src/features/dal/`) is an offline-first data layer. Every read/write executes against either a **remote** backend (TanStack Start server functions → Postgres) or a **local** backend (IndexedDB). The backend is chosen automatically by `chooseBackend()`: remote when the user is authenticated and online, local otherwise. Local writes are queued as `PendingOp`s and synced later with last-write-wins conflict resolution. + +`src/features/dal/` contains: + +- `core/` — `define-action.ts` (action factories), `create-collected-items-dal.ts` (the per-game DAL builder), `choose-backend.ts`, `to-query-options.ts`, `registry.ts`, `types.ts` +- `hooks/` — `useDalQuery`, `useDalMutation`, `useBackend`, `useDalContextSource` +- `identity/` — anon-id generation/persistence and `useEffectiveUserId` +- `local/` — IndexedDB constants, `local-db.ts` (prisma-idb client wrapper), and shared local row types +- `queue/` — `PendingOp` storage (`pending-ops.ts`), the `syncOps()` runner, last-write-wins resolution, and the `usePendingOps` hook +- `server/` — `apply-pending-ops.ts` server function and the cross-cutting collected-item handler/sync glue + +See `src/features/dal/DIAGRAM.md` for mermaid diagrams of the read/write, sync, and LWW conflict-resolution flows. ``` Component @@ -223,28 +245,34 @@ mutation.mutate({ id: "...", value: "..." }); **Server helpers** (use inside server functions only): ```typescript -import { requireUserId, getOptionalUserId } from "#/features/dal/server/require-user"; -const userId = await requireUserId(); // throws 401 if no session +import { requireUserId, getOptionalUserId } from "#/features/auth/dal/require-user.server"; +const userId = await requireUserId(); // throws 401 if no session const userId = await getOptionalUserId(); // returns null if unauthenticated ``` -See `src/features/dal/README.md` for the full sync queue, conflict resolution, and testing documentation. - ### Imports & path aliases Three aliases resolve to the same place — use the one already in the file: - `#/*` → `./src/*` (declared in `package.json` imports **and** `tsconfig.json` paths). -- `@/*` → `./src/*` (tsconfig only). - `@/prisma` → `./prisma/client` — this is how you import `prisma` and generated types/enums (e.g. `import type { GameId } from "@/prisma"`). Do **not** import directly from `prisma/generated/prisma`. ### Env vars -Validated with zod in `src/config/env.ts` (server) and `import.meta.env` (client). `.env.local.example` is the template. +Validated with zod in `src/config/env.ts`. `.env.local.example` is the template. **Never read `process.env` directly** — always use the type-safe accessors below. + +**Server (private) vars** — import `serverEnv` from `#/config/env`. Keys: `DATABASE_URL`, `NODE_ENV`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `IMAGEKIT_CLIENT_ID`, `IMAGEKIT_CLIENT_SECRET`, `IMAGEKIT_ENDPOINT_URL`, `RESEND_KEY`. -Server keys (from `process.env`): `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `IMAGEKIT_CLIENT_ID`, `IMAGEKIT_CLIENT_SECRET`, `IMAGEKIT_ENDPOINT_URL`, `RESEND_KEY`. +```typescript +import { serverEnv } from "#/config/env"; +const url = serverEnv.DATABASE_URL; +``` -Client keys must be `VITE_*` (from `import.meta.env`): `VITE_APP_NAME`, `VITE_APP_URL`, `VITE_CLOUDFRONT_URL`. +**Client (public) vars** — must be prefixed `VITE_*` and accessed via `import.meta.env`. Keys: `VITE_APP_NAME`, `VITE_APP_URL`, `VITE_CLOUDFRONT_URL`. + +```typescript +const appUrl = import.meta.env.VITE_APP_URL; +``` ## Code style diff --git a/src/features/auth/core/ProfileEditForm.tsx b/src/features/auth/core/ProfileEditForm.tsx new file mode 100644 index 0000000..699ea8d --- /dev/null +++ b/src/features/auth/core/ProfileEditForm.tsx @@ -0,0 +1,128 @@ +import { Button, Group, Stack, Text, Textarea, TextInput } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useForm } from "@tanstack/react-form"; +import { useState } from "react"; +import { userProfileActions } from "#/features/auth/dal/user-profile/user-profile.actions"; +import { useDalMutation } from "#/features/dal/hooks/use-dal-mutation"; + +type ProfileEditFormProps = { + initialDisplayName: string; + initialBio: string; +}; + +export function ProfileEditForm({ + initialDisplayName, + initialBio, +}: ProfileEditFormProps) { + const [serverError, setServerError] = useState(null); + const mutation = useDalMutation(userProfileActions.updateProfile); + + const form = useForm({ + defaultValues: { + displayName: initialDisplayName, + bio: initialBio, + }, + onSubmit: async ({ value }) => { + setServerError(null); + try { + await mutation.mutateAsync(value); + modals.closeAll(); + } catch { + setServerError("Failed to save profile. Please try again."); + } + }, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + { + if (!value) return "Display name is required"; + if (value.length > 100) + return "Display name must be 100 characters or fewer"; + return undefined; + }, + }} + > + {(field) => ( + field.handleChange(e.currentTarget.value)} + onBlur={field.handleBlur} + error={ + field.state.meta.isTouched && field.state.meta.errors.length > 0 + ? field.state.meta.errors[0] + : undefined + } + maxLength={100} + /> + )} + + { + if (value.length > 500) + return "Bio must be 500 characters or fewer"; + return undefined; + }, + }} + > + {(field) => ( +