diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md deleted file mode 100644 index e02abc5..0000000 --- a/documentation/ARCHITECTURE.md +++ /dev/null @@ -1,274 +0,0 @@ -# Architecture - -TODO: This is likely outdated. - -This document covers the parts of the codebase that aren't obvious from reading the source: how the framework pieces fit together, how the game-agnostic registry works, and how to add a new game. - -> [!TIP] -> Pair this doc with the [DAL guide](DAL.md) (persisted data) and the [Themes guide](THEMES.md) (visual identity per game). Most non-trivial changes touch at least two of the three. - -## Framework stack - -- **[TanStack Start](https://tanstack.com/start)** for SSR and server functions, layered on **[TanStack Router](https://tanstack.com/router)** with file-based routing. The order matters in `vite.config.ts` — `tanstackStart()` must be registered **before** `@vitejs/plugin-react`. -- **React 19** with the **React Compiler** enabled. The compiler is auto-detected by `@vitejs/plugin-react` v6+ as long as `babel-plugin-react-compiler` is installed as a dev dependency (it is). Write idiomatic React — no manual `useMemo` / `useCallback` / `React.memo` for ordinary values. The compiler handles memoization. Only reach for those hooks when you need stable identity for an external API. -- **[Mantine v9](https://mantine.dev)** for the component library — core, dates, modals, notifications, carousel, spotlight, tiptap, code-highlight. `next-themes` drives the theme class on ``. -- **[Better Auth](https://better-auth.com)** for authentication, mounted as a catch-all route at `src/routes/api/auth/$.ts`. Prisma adapter, Discord OAuth, email/password with verification + reset emails (React Email templates in `src/emails/auth/`) sent via Resend. -- **[TanStack Query](https://tanstack.com/query)** integrated with the router via `setupRouterSsrQueryIntegration` in `src/router.tsx`. A shared `QueryClient` is created in `src/integrations/tanstack-query/get-context.ts` and attached to the router context. - -## Routing - -Routes live in `src/routes/`. The file system is the source of truth — TanStack Router's plugin generates `routeTree.gen.ts` from the file layout. - -> [!IMPORTANT] -> Never hand-edit `routeTree.gen.ts`. It's marked read-only in `.vscode/settings.json` and excluded from Biome. If it looks broken, delete it and re-run `pnpm dev`. - -### Key routes to know - -- `src/routes/__root.tsx` — the root shell. Renders ``, the Mantine `AppShell` (header + navbar + footer), and mounts `AppProviders` (`src/components/AppProviders.tsx`). The provider chain is: - - ``` - NuqsAdapter - └─ GameProvider - └─ MantineProviderWithTheme - └─ ModalsProvider - └─ ScreenshotPreviewProvider - └─ - ``` - -- `src/routes/$gameId/` — every URL scoped to a specific game. The `gameId` param is written into the active-game store on mount (see below). - -- `src/routes/profile/` — the **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 for viewing other users publicly. - -## The active-game resolution - -The active game is tracked in a `@tanstack/store` at `src/features/game/core/store.ts` (exports `gameStore` and `setGame`). It has a `source` priority — when multiple callers want to set the game, the highest-priority source wins: - -``` -subdomain > route > toggle/session > default -``` - -The store is rehydrated from `localStorage` (`active-game` key) on module load. Two callers write to it: - -- **`GameProvider`** (client-only, mounted via `AppProviders` in `__root.tsx`) reads `window.location.hostname` via `parseSubdomain` from `#/features/game/core/utils`. There's a `?_game=` query override for development. -- **`src/routes/$gameId/route.tsx`** calls `setGame(gameId, "route")` on every navigation under the `$gameId` segment. - -> [!CAUTION] -> A `subdomain`-sourced value deliberately wins over later `route` writes. If you change this precedence, you'll break per-game subdomains (`clairobscur.toolkits.gg`, etc.) — be sure that's what you intend. - -## The game registry pattern - -This is the single most important pattern in the codebase. **Everything game-specific hangs off a central registry.** If you're tempted to write `switch (gameId)` inside a feature module, you're working against the pattern. - -### Anatomy of a game - -Each game lives at `src/games//` and exposes a `GAME_CONFIG` from `core/game-config/client.ts`: - -```typescript -const GAME_CONFIG = { - ITEMS, // { all, collectable, categorized, categories, uncollectableCategories } - THEME, // ToolkitThemeDefinition | undefined - METADATA, // id, name, label, description, faviconSourcePath, renderLogo(), externalResources[] - PAGES, // { renderItemLookup, renderCollectedItems } - SEARCH_PARAMS, // nuqs search param cache | undefined (when the game has no custom filters) - AVATARS, // GameAvatar[] (optional) - DAL, // { collectedItems: GameCollectedItemsDal } -} satisfies GameConfig; -export { GAME_CONFIG }; -``` - -### The registry itself - -`src/features/game/registry/game-registry.tsx` wires every `gameId` to its `GameConfig` and exports the helpers everything else uses: - -```typescript -GAME_REGISTRY // the raw map -REGISTERED_GAME_IDS // ordered list of every gameId -getGameConfig(id) // full config, loosely typed -getGameConfigTyped(id) // full config, narrowed to TId -getGameItems(id) -getGameTheme(id) -getGameMetadata(id) -getGamePages(id) -getGameAvatars(id) -getGameLogo(id) -getGameSearchParams(id) -getAllRegisteredThemeDefinitions() -getAllRegisteredThemeClassNames() -isRegisteredGameId(id) -getValidatedGameId(id) // throws if not registered -``` - -The `GameId` enum is the source of truth — it's defined in `prisma/schema.prisma` and imported from `@/prisma`. - -`getAllRegisteredThemeDefinitions()` expands each game theme into light + dark variants plus a base `default-light` / `default-dark`. See the [Themes guide](THEMES.md) for details. - -## Feature / game separation rule - -This is the hard rule that makes the registry pattern work: - -> **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/`.** - -In practice that means: - -| Logic type | Lives in | -|---------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| -| Cross-game DAL actions (`favoriteGames`, `userProfile`) | `src/features//dal//` — currently all under `src/features/auth/dal/` since both belong to the authenticated-user surface | -| Game-specific DAL actions (`collectedItems`) | `src/games//dal/` | -| The registry that maps game id -> handler | `src/features/game/registry/` | -| Per-game item data, themes, pages, logos | `src/games//` | - -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 server functions and `local` to IDB helpers -- `sync-handler.ts` — server-side sync handler invoked by `applyPendingOpServerFn` - -All cross-game aggregation maps (the registries) belong in `src/features/game/registry/`. When you add a new game, that folder is **the single place** to look for everything that needs a new entry. - -## Adding a new game - -Follow these steps in order. The registry folder is the only place outside `src/games//` that you should need to touch. - -### 1. Add the enum value - -Open `prisma/schema.prisma` and append the new id to `enum GameId`. - -### 2. Create the game's Prisma models - -Copy an existing `prisma/models/.prisma` as a template and create `prisma/models/.prisma`. No `@@prisma.import` directive is needed — Prisma's multi-file schema (configured in `prisma.config.ts` with `schema: path.join('prisma')`) auto-discovers every `.prisma` file under `prisma/`. - -### 3. Generate the clients and push the schema - -```bash -pnpm db:generate -pnpm db:push -``` - -### 4. Scaffold the game directory - -Create `src/games//` with two top-level folders, `core/` and `dal/`: - -``` -core/ - game-config/ - client.ts # exports GAME_CONFIG satisfies GameConfig - metadata.tsx # id, name, label, description, faviconSourcePath, renderLogo() - pages.tsx # GamePages with renderItemLookup() and renderCollectedItems() - theme.ts # ToolkitThemeDefinition (colors, Mantine overrides) — uses generateThemeColors() - items.ts # item data + categorization - nuqs-parsers.ts # nuqs parsers (optional — only if custom filters) - 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 # LocalItem and other game-specific TS types - constants.ts # game-specific constants - Logo.tsx # logo component referenced by metadata.renderLogo() -dal/ - collected-items.ts # exports the GameCollectedItemsDal via createCollectedItemsDal() - server/ - 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 around `createCollectedItemsDal({ entityName, getModel, serverFns })` from `#/features/game/dal/collected-items/collected-items.actions`. The factory handles all the offline/online switching — your game just supplies the model accessor and server functions. - -### 5. Register in every registry file - -All five live in `src/features/game/registry/`: - -| File | What to add | -|---------------------------------|-------------------------------------------------------------------------------------| -| `game-registry.tsx` | Entry in `GAME_REGISTRY` mapping `` -> `GAME_CONFIG` | -| `game-db-seed-registry.ts` | Entry in `allGameDBSeeds` | -| `game-idb-seed-registry.ts` | Entry in `allGameIDBSeeds` | -| `game-sync-handler-registry.ts` | Entry mapping the entity name (e.g. `collectedItems`) -> `collectedItemSyncHandler` | -| `favicon-registry.json` | `"": ""` | - -That's it — no `src/features/` files need to change. If you find yourself editing anything else under `src/features/` to support the new game, stop and ask whether the logic actually belongs in `src/games//` instead. - -### 6. Generate favicons and item images - -Once your `favicon-registry.json` entry exists and your `item-data/` files reference their source images, run the two Gulp pipelines: - -```bash -pnpm favicons:generate # required after adding/changing a favicon-registry.json entry -pnpm images:generate # required after adding/changing item images -``` - -**Favicons (`pnpm favicons:generate`)** — reads `src/features/game/registry/favicon-registry.json`, fetches each entry's source image from CloudFront, runs it through the [`favicons`](https://www.npmjs.com/package/favicons) package, and writes the generated icons to `public/favicons//`. Re-run any time you add, remove, or change a favicon registry entry. - -**Item images (`pnpm images:generate`)** — generates resized variants of every item image used by every registered game. Source images are stored on CloudFront; the resized output lives under `.images//...` and is checked into the repo so production builds don't need to refetch. - -How it works: - -1. Iterates every `gameId` in `favicon-registry.json` (excluding `"default"`). -2. For each game, scans `src/games//core/item-data/*.ts` for `imageUrl: "..."` string literals — that's how it discovers what to resize. **If you add an item, set its `imageUrl` and re-run the script.** -3. Fetches each source from `/games//`. -4. Resizes to every preset defined in `src/features/game/registry/image-sizes.json` (currently `xs`/`sm`/`md`/`lg`/`xl`, from 32×32 to 512×512). -5. Writes each variant to `.images///resized/-x`. - -The pipeline is **idempotent** — for each item it checks whether every expected output already exists on disk and skips the fetch entirely if they do. Only missing variants are regenerated, so re-running after small changes is cheap. - -If you replace a CloudFront original with new content at the same path (i.e. the URL didn't change but the bytes did), delete the relevant `.images//...` subtree first to force regeneration — the idempotency check has no way to know the source changed. - -If you add a new size to `image-sizes.json`, every existing item picks it up on the next `pnpm images:generate` run (only the missing variant is generated; existing variants are skipped). - -### 7. Run it - -```bash -pnpm db:seed # picks up the new game's db-seed.ts -pnpm dev -``` - -Navigate to `/` (or set up a local hostname mapping for `.localhost:3000` if you want to test the subdomain path). - -## Imports & path aliases - -Two aliases are declared in **both** `package.json` `imports` and `tsconfig.json` `paths`. If you change one, change the other. - -- `#/*` -> `./src/*` -- `@/prisma` -> `./prisma/client` — this is how you import `prisma` and generated types/enums (e.g. `import type { GameId } from "@/prisma"`). **Never** import directly from `prisma/generated/prisma` — that bypasses the type wrapper and breaks the two-client setup. - -## Env vars - -Env validation lives in `src/env/`: `server-env.ts` (zod-validated `serverEnv`), `client-env.ts` (zod-validated `clientEnv`), and `validate-required.ts` (a startup presence check for every required key). `.env.local.example` is the canonical template. - -> [!IMPORTANT] -> Never read `process.env` or `import.meta.env` directly. Always use the type-safe accessors below. - -**Server (private) vars** — import `serverEnv` from `#/env/server-env.ts`. Keys: `DATABASE_URL`, `NODE_ENV`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `RESEND_KEY`. - -```typescript -import { serverEnv } from "#/env/server-env.ts"; - -const url = serverEnv.DATABASE_URL; -``` - -**Client (public) vars** — must be prefixed `VITE_*` and accessed via `clientEnv` (imported from `#/env/client-env.ts`). Keys: `VITE_APP_NAME`, `VITE_APP_DESCRIPTION`, `VITE_APP_URL`, `VITE_APP_NOREPLY_EMAIL`, `VITE_CLOUDFRONT_URL`, `VITE_LOCAL_ADMIN_EMAIL`, `VITE_LOCAL_ADMIN_PASSWORD`, `VITE_LOCAL_USER_EMAIL`, `VITE_LOCAL_USER_PASSWORD`. The four `VITE_LOCAL_*` vars seed local admin/user accounts via `prisma/seed.ts`; `VITE_APP_NOREPLY_EMAIL` is the From address for Resend auth emails; `VITE_APP_DESCRIPTION` feeds the root HTML metadata. - -```typescript -import { clientEnv } from "#/env/client-env.ts"; - -const appUrl = clientEnv.VITE_APP_URL; -``` - -Client-side `clientEnv` is **type-safe** — the `ImportMetaEnv` interface in `src/env.d.ts` declares every `VITE_*` key. If a key isn't listed there, TypeScript will reject `clientEnv.VITE_FOO`. This is intentional: it forces every client-side env var to be explicitly opted in, so typos and missing config are caught at compile time rather than silently resolving to `undefined` at runtime. - -If you need a new env var, add it to: - -1. `.env.local.example` (with a comment explaining what it's for) -2. The matching zod schema — `src/env/server-env.ts` or `src/env/client-env.ts` -3. The required-key list in `src/env/validate-required.ts` -4. **`src/env.d.ts`** — only if it's a client-side `VITE_*` var. Add a `readonly VITE_FOO: string` line to the `ImportMetaEnv` interface so `clientEnv.VITE_FOO` is typed. -5. The README's "Configuration" section if it's user-facing - -## Where to go next - -- **Persisted data** (collected items, profile, favorites) — read the [DAL guide](DAL.md). The DAL is offline-first; reaching for `useQuery` directly will silently break the offline experience. -- **Visual identity per game** — read the [Themes guide](THEMES.md). -- **Contributing in general** — see [CONTRIBUTING.md](../.github/CONTRIBUTING.md) for workflow and code style. - diff --git a/documentation/DAL.md b/documentation/DAL.md deleted file mode 100644 index 59e5e6e..0000000 --- a/documentation/DAL.md +++ /dev/null @@ -1,210 +0,0 @@ -# The DAL (Data Access Layer) - -The DAL is an **offline-first data layer**. Every read and write executes against either a remote Postgres backend (via TanStack Start server functions) or a local IndexedDB backend, and the choice is made automatically by `chooseBackend()` based on auth status and network connectivity. - -If you're adding any persisted state to the app - collected items, profile fields, favorites, anything a user expects to keep - you go through the DAL. Reaching for `useQuery` directly will silently break the offline experience. - -## When the remote vs. local backend is chosen - -`chooseBackend()` picks `remote` when **both** are true: - -- The user is authenticated (`authUserId` is set). -- The browser reports it's online (`navigator.onLine` / network event listeners). - -Otherwise it picks `local`. That covers: - -- Signed-out browsing - collected items persist in IndexedDB under an anon UUID. -- Signed-in but offline - reads/writes hit IndexedDB; writes get queued. -- The signed-in user comes back online - queued writes flush to the server with last-write-wins resolution. - -## Folder layout - -`src/features/dal/` contains: - -``` -define-action.ts # defineDalRead / defineDalWrite - the action factories -choose-backend.ts # remote-vs-local selection logic -to-query-options.ts # adapts a DalRead to TanStack Query options -presence-sync-handler.ts # shared SyncHandler factory for presence-toggle entities -types.ts - -useDalQuery / useDalSuspenseQuery -useDalMutation -useBackend # exposes the current backend choice -useDalContextSource # used internally to build the DalContext - -identity/ - Anon-id generation/persistence, useEffectiveUserId - -local/ - IndexedDB constants, local-db.ts (prisma-idb wrapper), shared local row types - -queue/ - pending-ops.ts # PendingOp storage in IndexedDB - sync-runner.ts # syncOps() / forceSyncOp() - drains the queue - last-write-wins.ts # last-write-wins conflict resolution - apply-pending-ops.ts # applyPendingOpServerFn the runner calls; dispatches by entity - usePendingOps # observe pending ops from React - -``` - -The sync runner uploads every queued op through the single `applyPendingOpServerFn`, which dispatches to the right `SyncHandler` by `op.entity`. There is no client-side write-action registry - server-side dispatch is the only routing layer. - -## The flow at a glance - -``` -Component - └─ useDalQuery / useDalMutation - └─ DalContext { anonUserId, authUserId, backend } - ├─ "remote" -> action.remote(input, ctx) (server function) - └─ "local" -> action.local(input, ctx) (IndexedDB) - └─ [writes] enqueueOp() -> PendingOp -> syncOps() -``` - -When a write happens locally, it: - -1. Writes to IndexedDB immediately (so the UI updates). -2. Enqueues a `PendingOp` in IndexedDB. -3. `syncOps()` picks up the op when the user is online and authenticated, and calls the server-side handler via `applyPendingOpServerFn`. - -## Key types - -```typescript -interface DalContext { - anonUserId: string; // UUID from localStorage - always present - authUserId: string | null; // set when signed in - backend: "remote" | "local"; -} -``` - -Use `ctx.authUserId ?? ctx.anonUserId` whenever you need a stable local user ID - that's the key your local rows should be scoped to. - -## Defining actions - -Actions are declared with two factory helpers from `#/features/dal/define-action`. Reads and writes have different shapes. - -### Read action - -```typescript -import { defineDalRead } from "#/features/dal/define-action"; - -const list = defineDalRead({ - queryKey: () => ["myEntity", "list"], - remote: async (_input, _ctx) => myListServerFn(), - local: async (_input, ctx) => listLocalItems(ctx.authUserId ?? ctx.anonUserId), -}); -``` - -The `queryKey` is what TanStack Query uses for caching. The `remote` and `local` resolvers receive the input and the `DalContext`. - -### Write action - -```typescript -import { defineDalWrite } from "#/features/dal/define-action"; - -const upsert = defineDalWrite({ - entity: "myEntity", - operation: "upsert", - invalidates: ["anotherEntity"], - buildIdempotencyKey: (input, ctx) => `myEntity:upsert:${ctx.anonUserId}:${input.id}`, - remote: async (input, _ctx) => myUpsertServerFn({ data: input }), - local: async (input, ctx) => - upsertLocalItem({ userId: ctx.authUserId ?? ctx.anonUserId, ...input }), - // Optional: describe (sync-UI summary) and getServerUpdatedAt (LWW baseline). - describe: (input) => ({ title: `Saved ${input.id}` }), -}); -``` - -Write actions carry more metadata because they need to participate in the sync queue: - -- **`entity` + `operation`** - categorize the op. `entity` must match a key in the server-side sync-handler map (see below). -- **`invalidates`** - entity names to invalidate after the write succeeds. This is what makes related lists re-fetch. The primary entity is always invalidated automatically. -- **`buildIdempotencyKey`** - used for deduplication on the server when the same op flushes twice (network retry, etc.). Include the user id and a stable identifier from the input. -- **`describe`** _(optional)_ - a `PendingOpSummary` snapshot so the data-sync UI can show a friendly description of the queued op. -- **`getServerUpdatedAt`** _(optional)_ – reads the server record's `updatedAt` before the local write and stores it on the op as the last-write-wins baseline (resolution lives in `queue/last-write-wins.ts`). Omit it for pure creates; actions without it fall back to comparing the op's own creation time. - -There is **no `sync` field**. Every queued op is uploaded by the sync runner through the same `applyPendingOpServerFn`, which dispatches by `entity` - so wiring sync is just registering the server-side handler. - -## Using actions in components - -```typescript -import { useDalQuery } from "#/features/dal/use-dal-query.ts"; -import { useDalMutation } from "#/features/dal/use-dal-mutation.ts"; -import { myActions } from "..."; - -const { data, isLoading } = useDalQuery(myActions.list, undefined); - -const mutation = useDalMutation(myActions.upsert); -const onClick = () => mutation.mutate({ id: "...", value: "..." }); -``` - -That's the entire surface area for most consumers. The DAL takes care of: - -- Picking the right backend. -- Wiring the action into TanStack Query (caching, refetch, invalidation). -- Enqueuing local writes and syncing them later. -- Resolving conflicts with last-write-wins when a queued op reaches the server. - -## Server-only helpers - -Inside server functions you have two helpers for resolving the authenticated user: - -```typescript -import { requireUserId, getOptionalUserId } from "#/features/auth/require-user.server"; - -const userId = await requireUserId(); // throws 401 if no session -const maybeUserId = await getOptionalUserId(); // returns null if unauthenticated -``` - -Reach for `requireUserId()` whenever a write absolutely requires a logged-in user. Use `getOptionalUserId()` for reads that can fall back to public/anonymous data. - -## Where DAL files live - -DAL actions split by scope: - -| Scope | Location | -|--------------------------------------------------|------------------------------------| -| Cross-game (e.g. `favoriteGames`, `userProfile`) | `src/features/game/dal//` | -| 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) -- **`.dal.ts`** - `defineDalRead` / `defineDalWrite` action definitions, wiring `remote` to the server functions and `local` to the IDB helpers -- **`sync-handler.ts`** - server-side sync handler invoked by `applyPendingOpServerFn` - -> [!IMPORTANT] -> Game-specific DAL logic must live under `src/games//dal/`, **never** as a branch inside `src/features/`. The shared `createCollectedItemsDal()` factory in `#/features/game/dal/collected-items/collected-items.dal` is how per-game DAL files stay short – they pass in the model accessor and server functions and get a fully-wired DAL back. - -## Adding a new persisted entity - -The shortest path: - -1. **Add the Prisma model** in `prisma/schema.prisma` (or `prisma/models/.prisma` if it's game-scoped). Run `pnpm db:generate && pnpm db:push`. - -2. **Write the server functions** (`.ts`) - standard TanStack Start `createServerFn` calls that read/write via the `prisma` client. - -3. **Write the IDB helpers** (`.idb.ts`) - read/write via the prisma-idb client. The local row type usually mirrors the Postgres row but with extra bookkeeping (e.g. `userId` scoping). - -4. **Define the actions** (`.dal.ts`) with `defineDalRead` / `defineDalWrite` - see the examples above. - -5. **(Writes only)** Add the sync handler in `sync-handler.ts`. It receives the `PendingOp` and applies it to Postgres, with last-write-wins on conflicts. If the entity is a simple presence toggle (a row that either exists or not, with no mutable fields - like collected items or favorited games), reuse `createPresenceToggleSyncHandler` from `#/features/dal/presence-sync-handler` instead of hand-writing the delete/upsert + LWW branching. - -6. **(Writes only)** Register the handler under its `entity` key so `applyPendingOpServerFn` can dispatch to it: - - **Game-scoped** - add it to `src/features/game/registry/game-sync-handler-registry.ts`. - - **Cross-game** - add it to the `handlers` map in `src/features/dal/queue/apply-pending-ops.ts`. - -7. **Use it from components** with `useDalQuery` / `useDalMutation`. - -## Common pitfalls - -- **Calling `useQuery` directly instead of `useDalQuery`.** Works fine when signed in and online; silently shows an empty state otherwise. Always go through the DAL for persisted data. -- **Forgetting `buildIdempotencyKey` on writes.** Without it, a retried op can apply twice and produce duplicate rows. -- **Scoping local rows to `authUserId` only.** Anonymous users have an `anonUserId` but no `authUserId` - your row key should always be `ctx.authUserId ?? ctx.anonUserId`. Anything else makes signed-out usage silently lose data. -- **Mixing `remote` and `local` resolvers that return different shapes.** `useDalQuery` typing won't catch this if both return `unknown`-y types; you'll get a runtime shape mismatch when the user switches backends. Keep the return shape identical. -- **Putting game-keyed branches inside `src/features/dal/`.** The factory pattern exists precisely so game logic stays in `src/games//`. If you find yourself writing `if (gameId === ...)` inside a DAL file, refactor instead. - -## Related docs - -- [Architecture](ARCHITECTURE.md) – the game registry and why the feature/game split matters for the DAL. diff --git a/prisma/client.ts b/prisma/client.ts index 2313792..787d380 100644 --- a/prisma/client.ts +++ b/prisma/client.ts @@ -7,7 +7,17 @@ import { PrismaClient } from './generated/prisma/client'; const connectionString = `${process.env.DATABASE_URL}`; const adapter = new PrismaPg({ connectionString }); -const prisma = new PrismaClient({ adapter }); + +declare global { + // noinspection ES6ConvertVarToLetConst + var __prisma: PrismaClient | undefined; +} + +const prisma = globalThis.__prisma || new PrismaClient({ adapter }); + +if (process.env.NODE_ENV !== 'production') { + globalThis.__prisma = prisma; +} export { prisma }; diff --git a/src/components/AppItemInfoModal.tsx b/src/components/AppItemInfoModal.tsx index b43a6cd..34955a2 100644 --- a/src/components/AppItemInfoModal.tsx +++ b/src/components/AppItemInfoModal.tsx @@ -16,15 +16,15 @@ import { useEffect, useRef, useState } from "react"; import { LuCamera, LuCheck, LuPlus } from "react-icons/lu"; import { AppGameImage } from "#/components/AppGameImage.tsx"; import { AppItemDescription } from "#/components/AppItemDescription.tsx"; -import { getGameMetadata } from "#/features/game/registry/game-registry.tsx"; +import type { CollectItemInput } from "#/features/game/data/types.ts"; +import { getGameMetadata } from "#/features/game/registry/game-public-registry.tsx"; +import type { AppItem } from "#/features/game/types.ts"; import { useGameId } from "#/features/game/use-game-id.ts"; import { ScreenshotContainer, type WatermarkConfig, } from "#/features/screenshot/ScreenshotContainer.tsx"; import { useScreenshot } from "#/features/screenshot/use-screenshot.ts"; -import type {AppItem} from "#/features/game/types.ts"; -import type {CollectItemInput} from "#/features/game/dal/types.ts"; export type AppItemInfoModalProps = { item: AppItem; diff --git a/src/components/AppItemVirtualGrid.tsx b/src/components/AppItemVirtualGrid.tsx index 1329756..8bb647c 100644 --- a/src/components/AppItemVirtualGrid.tsx +++ b/src/components/AppItemVirtualGrid.tsx @@ -1,11 +1,11 @@ import { Modal, Text } from "@mantine/core"; import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { useLayoutEffect, useRef, useState } from "react"; -import { ItemCard } from "#/components/pages/item-list/ItemCard.tsx"; import { AppItemInfoModal } from "#/components/AppItemInfoModal.tsx"; +import { ItemCard } from "#/components/pages/item-list/ItemCard.tsx"; +import type { CollectItemInput } from "#/features/game/data/types.ts"; +import type { AppItem } from "#/features/game/types.ts"; import classes from "./AppItemVirtualGrid.module.css"; -import type {AppItem} from "#/features/game/types.ts"; -import type {CollectItemInput} from "#/features/game/dal/types.ts"; const HEADER_HEIGHT = 64; const ITEM_ROW_HEIGHT = 112; diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index c2b0c79..1564f28 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -1,15 +1,16 @@ +import { MantineProvider } from "@mantine/core"; +import { ModalsProvider } from "@mantine/modals"; +import { Notifications } from "@mantine/notifications"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; import { NuqsAdapter } from "nuqs/adapters/tanstack-router"; -import {type PropsWithChildren, useEffect} from "react"; +import { type PropsWithChildren, useEffect } from "react"; +import { isRegisteredGameId } from "#/features/game/registry/game-public-registry.tsx"; +import { useGameId } from "#/features/game/use-game-id.ts"; import { ScreenshotPreviewProvider } from "#/features/screenshot/ScreenshotPreviewProvider.tsx"; -import {useMantineThemeStore} from "#/features/theme/store.ts"; -import {DEFAULT_NEXT_THEME} from "#/features/theme/constants.ts"; -import {MantineProvider} from "@mantine/core"; -import {SyncAndApplyTheme} from "#/features/theme/SyncAndApplyTheme.ts"; -import {Notifications} from "@mantine/notifications"; -import {ModalsProvider} from "@mantine/modals"; -import {ThemeProvider as NextThemesProvider} from "next-themes"; -import {getAllRegisteredThemeClassNames, isRegisteredGameId} from "#/features/game/registry/game-registry.tsx"; -import {useGameId} from "#/features/game/use-game-id.ts"; +import { DEFAULT_NEXT_THEME } from "#/features/theme/constants.ts"; +import { SyncAndApplyTheme } from "#/features/theme/SyncAndApplyTheme.ts"; +import { useMantineThemeStore } from "#/features/theme/store.ts"; +import { getAllRegisteredThemeClassNames } from "#/features/theme/utils.ts"; const FAVICON_BASE_PATH = "/favicons/"; const ALL_THEME_CLASS_NAMES: string[] = getAllRegisteredThemeClassNames(); diff --git a/src/components/GameSwitcher.tsx b/src/components/GameSwitcher.tsx index ae84d83..a2b5b78 100644 --- a/src/components/GameSwitcher.tsx +++ b/src/components/GameSwitcher.tsx @@ -22,14 +22,16 @@ import { } from "react-icons/lu"; import { DefaultLogo } from "#/components/AppLogo.tsx"; -import { useDalMutation } from "#/features/dal/use-dal-mutation.ts"; -import { useDalQuery } from "#/features/dal/use-dal-query.ts"; -import { createFavoriteGameDal } from "#/features/game/dal/favorite-games/favorite-games.dal.ts"; import { - getGameConfig, + useFavoriteGame, + useFavoriteGames, + useUnfavoriteGame, +} from "#/features/game/data/favorite-games/use-favorite-games.ts"; +import { getGameLogoComponent, + getGameMetadata, REGISTERED_GAME_IDS, -} from "#/features/game/registry/game-registry.tsx"; +} from "#/features/game/registry/game-public-registry.tsx"; import { setGame } from "#/features/game/store.ts"; import { useGameId } from "#/features/game/use-game-id.ts"; import { setActiveGameCookie } from "#/features/game/utils.ts"; @@ -43,7 +45,7 @@ type GameEntry = { const allGames: GameEntry[] = REGISTERED_GAME_IDS.map((id) => ({ id: id as GameId, - label: getGameConfig(id)?.THEME?.label ?? id, + label: getGameMetadata(id)?.label ?? id, })); const sortByLabel = (a: GameEntry, b: GameEntry) => @@ -101,15 +103,13 @@ function GameSwitcher() { const navigate = useNavigate(); const { location } = useRouterState(); - const favoriteGameDal = createFavoriteGameDal(); - const { data } = useDalQuery(favoriteGameDal.list, undefined); - const favorite = useDalMutation(favoriteGameDal.favorite); - const unfavorite = useDalMutation(favoriteGameDal.unfavorite); + const { data } = useFavoriteGames(); + const favorite = useFavoriteGame(); + const unfavorite = useUnfavoriteGame(); const favoriteGameIds = data?.map((r) => r.gameId) ?? []; - const activeLabel = - getGameConfig(activeGameId)?.THEME?.label ?? "Toolkits.gg"; + const activeLabel = getGameMetadata(activeGameId)?.label ?? "Toolkits.gg"; const ActiveGameLogo = getGameLogoComponent(activeGameId); const filteredGames = allGames.filter((g) => diff --git a/src/components/navbar/AppNavbar.tsx b/src/components/navbar/AppNavbar.tsx index 59f2497..48e11bd 100644 --- a/src/components/navbar/AppNavbar.tsx +++ b/src/components/navbar/AppNavbar.tsx @@ -2,9 +2,9 @@ import { Flex, ScrollArea } from "@mantine/core"; import { ClientOnly } from "@tanstack/react-router"; import { getNavLinks } from "#/components/navbar/get-nav-links"; import { NavbarLinksGroup } from "#/components/navbar/NavbarLinksGroup"; -import { UserMenu } from "#/features/auth/UserMenu.tsx"; import { useGameId } from "#/features/game/use-game-id.ts"; import { ChangeThemeButton } from "#/features/theme/ChangeThemeButton.tsx"; +import { UserMenu } from "#/features/user/UserMenu.tsx"; import classes from "./AppNavbar.module.css"; type AppNavbarProps = { diff --git a/src/components/navbar/get-nav-links.tsx b/src/components/navbar/get-nav-links.tsx index 3b86a8b..3106194 100644 --- a/src/components/navbar/get-nav-links.tsx +++ b/src/components/navbar/get-nav-links.tsx @@ -1,7 +1,6 @@ import type { FC } from "react"; import { BsCollection } from "react-icons/bs"; import { GiCapeArmor, GiLockedChest } from "react-icons/gi"; -import { getGamePages } from "#/features/game/registry/game-registry.tsx"; import type { GameId } from "@/prisma"; type NavLinkSubLink = { @@ -93,16 +92,8 @@ const getNavLinks = ({ }: GetNavLinksParams): NavLink[] => { const navLinks: NavLink[] = []; if (gameId && gameId !== "none") { - const gamePages = getGamePages(gameId); - const noBuildPages = - !gamePages?.renderCreateBuild && - !gamePages?.renderViewBuild && - !gamePages?.renderEditBuild; - navLinks.push(buildItemsNavLink(gameId)); - if (!noBuildPages) { - navLinks.push(buildBuildsNavLink(gameId)); - } + navLinks.push(buildBuildsNavLink(gameId)); } navLinks.push(...buildToolkitLinks(onGettingStartedWizard)); return navLinks; diff --git a/src/components/pages/ItemList.tsx b/src/components/pages/ItemList.tsx index 1def30a..58f5fdb 100644 --- a/src/components/pages/ItemList.tsx +++ b/src/components/pages/ItemList.tsx @@ -1,17 +1,21 @@ import { Box } from "@mantine/core"; -import { ItemFilterBar } from "#/components/pages/item-list/ItemFilterBar.tsx"; import { AppItemVirtualGrid } from "#/components/AppItemVirtualGrid.tsx"; import { ItemCollectionShareButton } from "#/components/pages/item-list/ItemCollectionShareButton.tsx"; +import { ItemFilterBar } from "#/components/pages/item-list/ItemFilterBar.tsx"; import { useCollectedItems } from "#/components/pages/item-list/use-collected-items.ts"; import { useItemFilters } from "#/components/pages/item-list/use-item-filters.ts"; -import type { AnyGameConfig } from "#/features/game/registry/game-registry.tsx"; -import type {AppItem, CollectedItemsViewMode, GameFilterConfig} from "#/features/game/types.ts"; -import type {GameCollectedItemsDal} from "#/features/game/dal/types.ts"; +import type { GameCollectedItemsData } from "#/features/game/data/types.ts"; +import type { + AnyGameConfig, + AppItem, + CollectedItemsViewMode, + GameFilterConfig, +} from "#/features/game/types.ts"; export type ItemListPageProps = { items: AnyGameConfig["ITEMS"]; resolveLinkedItems: (item: AppItem) => AppItem[]; - dal: GameCollectedItemsDal; + data: GameCollectedItemsData; gameFilterConfig?: GameFilterConfig; viewMode?: CollectedItemsViewMode; }; @@ -19,13 +23,13 @@ export type ItemListPageProps = { export const ItemListPage = ({ items, resolveLinkedItems, - dal, + data, gameFilterConfig, viewMode, }: ItemListPageProps) => { const isCollectedItemsTab = viewMode !== undefined; const { collectedIds, isPublicView, handleCollect, handleUncollect } = - useCollectedItems({ dal, viewMode }); + useCollectedItems({ data, viewMode }); const filters = useItemFilters({ items, collectedIds, diff --git a/src/components/pages/created-builds/CreatedBuilds.tsx b/src/components/pages/created-builds/CreatedBuilds.tsx new file mode 100644 index 0000000..517d001 --- /dev/null +++ b/src/components/pages/created-builds/CreatedBuilds.tsx @@ -0,0 +1,110 @@ +import { + Badge, + Card, + Center, + EmptyState, + Group, + Loader, + SimpleGrid, + Text, +} from "@mantine/core"; +import { Link } from "@tanstack/react-router"; +import { AppImage } from "#/components/AppImage.tsx"; +import type { + CreatedBuildSummary, + GameCreatedBuildsData, +} from "#/features/game/data/types.ts"; +import type { ProfileTabViewMode } from "#/features/game/types.ts"; +import { useGameId } from "#/features/game/use-game-id.ts"; +import { useCreatedBuilds } from "./use-created-builds.ts"; + +type CreatedBuildsPageProps = { + data: GameCreatedBuildsData; + viewMode: ProfileTabViewMode; +}; + +const VISIBILITY_COLORS: Record = { + PUBLIC: "green", + UNLISTED: "yellow", + PRIVATE: "gray", +}; + +const BuildCard = ({ + build, + gameId, +}: { + build: CreatedBuildSummary; + gameId: string; +}) => { + const imageSrc = build.thumbnailUrl ?? build.imageUrl ?? undefined; + return ( + + + + + + + + {build.name} + + + {build.visibility} + + + + + ); +}; + +/** Profile-tab grid of a user's created builds (self or public view). */ +const CreatedBuildsPage = ({ data, viewMode }: CreatedBuildsPageProps) => { + const gameId = useGameId(); + const { builds, isLoading, isPublicView } = useCreatedBuilds({ + data, + viewMode, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (builds.length === 0) { + return ( + + ); + } + + return ( + + {builds.map((build) => ( + + ))} + + ); +}; + +export { CreatedBuildsPage }; diff --git a/src/components/pages/created-builds/use-created-builds.ts b/src/components/pages/created-builds/use-created-builds.ts new file mode 100644 index 0000000..1bd9567 --- /dev/null +++ b/src/components/pages/created-builds/use-created-builds.ts @@ -0,0 +1,43 @@ +import type { + CreatedBuildSummary, + GameCreatedBuildsData, +} from "#/features/game/data/types.ts"; +import type { ProfileTabViewMode } from "#/features/game/types.ts"; + +type UseCreatedBuildsArgs = { + data: GameCreatedBuildsData; + viewMode: ProfileTabViewMode; +}; + +type UseCreatedBuildsResult = { + builds: CreatedBuildSummary[]; + isLoading: boolean; + isPublicView: boolean; +}; + +/** + * Reads created builds for either the signed-in owner (local/remote) or, on a + * public profile, another user's publicly-visible builds via the server fn. + * Mirrors use-collected-items so both profile tabs behave consistently. + */ +const useCreatedBuilds = ({ + data, + viewMode, +}: UseCreatedBuildsArgs): UseCreatedBuildsResult => { + const isPublicView = viewMode.kind === "public"; + const publicUserId = viewMode.kind === "public" ? viewMode.userId : null; + + const selfQuery = data.useList(); + const publicQuery = data.usePublicList(publicUserId); + + const activeQuery = isPublicView ? publicQuery : selfQuery; + + return { + builds: activeQuery.data ?? [], + isLoading: activeQuery.isLoading, + isPublicView, + }; +}; + +export type { UseCreatedBuildsArgs, UseCreatedBuildsResult }; +export { useCreatedBuilds }; diff --git a/src/components/pages/item-list/ItemCard.tsx b/src/components/pages/item-list/ItemCard.tsx index c48c5a2..8a06b3c 100644 --- a/src/components/pages/item-list/ItemCard.tsx +++ b/src/components/pages/item-list/ItemCard.tsx @@ -3,9 +3,9 @@ import clsx from "clsx"; import { LuCheck, LuInfo, LuPlus, LuX } from "react-icons/lu"; import { AppGameImage } from "#/components/AppGameImage.tsx"; import { AppItemDescription } from "#/components/AppItemDescription.tsx"; +import type { CollectItemInput } from "#/features/game/data/types.ts"; +import type { AppItem } from "#/features/game/types.ts"; import classes from "./ItemCard.module.css"; -import type {AppItem} from "#/features/game/types.ts"; -import type {CollectItemInput} from "#/features/game/dal/types.ts"; type ItemCardProps = { item: AppItem; diff --git a/src/components/pages/item-list/ItemSearchInput.tsx b/src/components/pages/item-list/ItemSearchInput.tsx index 38aa82f..8c46bb1 100644 --- a/src/components/pages/item-list/ItemSearchInput.tsx +++ b/src/components/pages/item-list/ItemSearchInput.tsx @@ -1,6 +1,6 @@ import { Select } from "@mantine/core"; import { useRef, useState } from "react"; -import { getGameItems } from "#/features/game/registry/game-registry.tsx"; +import { getGameItems } from "#/features/game/registry/game-public-registry.tsx"; import { useGameId } from "#/features/game/use-game-id.ts"; export type ItemSearchInputProps = { diff --git a/src/components/pages/item-list/use-collected-items.ts b/src/components/pages/item-list/use-collected-items.ts index 7f6cf1c..7548f7c 100644 --- a/src/components/pages/item-list/use-collected-items.ts +++ b/src/components/pages/item-list/use-collected-items.ts @@ -1,11 +1,11 @@ -import { useQuery } from "@tanstack/react-query"; -import { useDalMutation } from "#/features/dal/use-dal-mutation.ts"; -import { useDalQuery } from "#/features/dal/use-dal-query.ts"; -import type {CollectItemInput, GameCollectedItemsDal} from "#/features/game/dal/types.ts"; -import type {CollectedItemsViewMode} from "#/features/game/types.ts"; +import type { + CollectItemInput, + GameCollectedItemsData, +} from "#/features/game/data/types.ts"; +import type { CollectedItemsViewMode } from "#/features/game/types.ts"; type UseCollectedItemsArgs = { - dal: GameCollectedItemsDal; + data: GameCollectedItemsData; viewMode?: CollectedItemsViewMode; }; @@ -17,27 +17,20 @@ type UseCollectedItemsResult = { }; const useCollectedItems = ({ - dal, + data, viewMode, }: UseCollectedItemsArgs): UseCollectedItemsResult => { const isPublicView = viewMode?.kind === "public"; const publicUserId = viewMode?.kind === "public" ? viewMode.userId : null; - const selfQuery = useDalQuery(dal.list, undefined); - const publicQuery = useQuery({ - queryKey: [...dal.list.queryKey(undefined), "byUserId", publicUserId], - queryFn: () => - publicUserId - ? dal.listByUserIdServerFn({ data: { userId: publicUserId } }) - : Promise.resolve([]), - enabled: isPublicView && !!publicUserId, - }); + const selfQuery = data.useList(); + const publicQuery = data.usePublicList(publicUserId); const collectedData = isPublicView ? publicQuery.data : selfQuery.data; const collectedIds = (collectedData ?? []).map((r) => r.itemId); - const { mutate: collect } = useDalMutation(dal.collect); - const { mutate: uncollect } = useDalMutation(dal.uncollect); + const { mutate: collect } = data.useCollect(); + const { mutate: uncollect } = data.useUncollect(); const handleCollect = ({ itemId, itemName }: CollectItemInput) => collect({ itemId, itemName }); diff --git a/src/components/pages/item-list/use-item-filters.ts b/src/components/pages/item-list/use-item-filters.ts index d0be5cf..643178b 100644 --- a/src/components/pages/item-list/use-item-filters.ts +++ b/src/components/pages/item-list/use-item-filters.ts @@ -1,7 +1,11 @@ import { parseAsBoolean, useQueryStates } from "nuqs"; import type { ReactNode } from "react"; import type { ActiveFilter } from "#/components/pages/item-list/ItemFilterBar.tsx"; -import type { AnyGameConfig } from "#/features/game/registry/game-registry.tsx"; +import type { + AnyGameConfig, + AppItem, + GameFilterConfig, +} from "#/features/game/types.ts"; import { dimUncollectedItemsParser, showCollectableOnlyParser, @@ -9,7 +13,6 @@ import { showUncollectedItemsParser, } from "#/features/nuqs/parsers/item-collection.ts"; import { searchParser } from "#/features/nuqs/parsers/search.ts"; -import type {AppItem, GameFilterConfig} from "#/features/game/types.ts"; const itemLookupParsers = { search: searchParser, diff --git a/src/features/dal/__tests__/choose-backend.test.ts b/src/features/dal/__tests__/choose-backend.test.ts deleted file mode 100644 index c94e16a..0000000 --- a/src/features/dal/__tests__/choose-backend.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { chooseBackend } from "#/features/dal/choose-backend.ts"; - -describe("chooseBackend", () => { - it.each([ - { authed: true, online: true, expected: "remote" as const }, - { authed: true, online: false, expected: "local" as const }, - { authed: false, online: true, expected: "local" as const }, - { authed: false, online: false, expected: "local" as const }, - ])("authed=$authed online=$online -> $expected", ({ - authed, - online, - expected, - }) => { - expect(chooseBackend({ authed, online })).toBe(expected); - }); -}); diff --git a/src/features/dal/choose-backend.ts b/src/features/dal/choose-backend.ts deleted file mode 100644 index d62c835..0000000 --- a/src/features/dal/choose-backend.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Single source of truth for backend selection. -// All DAL hooks derive their backend from this function so the rule stays consistent. - -import type { Backend } from "#/features/dal/types.ts"; - -interface DispatchInput { - /** Whether the user has an active authenticated session. */ - authed: boolean; - /** Whether the browser currently has network connectivity. */ - online: boolean; -} - -/** - * Returns "remote" only when BOTH authed and online are true. - * Unauthenticated users always use "local" — they have no server identity to sync to. - */ -const chooseBackend = ({ authed, online }: DispatchInput): Backend => { - return authed && online ? "remote" : "local"; -}; - -export { chooseBackend }; diff --git a/src/features/dal/define-action.ts b/src/features/dal/define-action.ts deleted file mode 100644 index 64d6734..0000000 --- a/src/features/dal/define-action.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Factory helpers that stamp the `kind` discriminator on action configs. -// Using these ensures callers never set `kind` manually and TypeScript can -// narrow DalAction to DalReadAction or DalWriteAction via the discriminant. - -import type { DalReadAction, DalWriteAction } from "#/features/dal/types.ts"; - -/** Creates a DalReadAction with the `kind: "read"` discriminator. */ -const defineDalRead = ( - config: Omit, "kind">, -): DalReadAction => { - return { kind: "read", ...config }; -}; - -/** Creates a DalWriteAction with the `kind: "write"` discriminator. */ -const defineDalWrite = ( - config: Omit, "kind">, -): DalWriteAction => { - return { kind: "write", ...config }; -}; - -export { defineDalRead, defineDalWrite }; diff --git a/src/features/dal/to-query-options.ts b/src/features/dal/to-query-options.ts deleted file mode 100644 index f21af9d..0000000 --- a/src/features/dal/to-query-options.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Bridges DalReadAction to TanStack Query's queryOptions format. - -import { queryOptions } from "@tanstack/react-query"; -import type { DalReadAction } from "#/features/dal/types.ts"; -import type { DalContextGetter } from "#/features/dal/use-dal-context-source.ts"; - -/** - * Converts a DalReadAction into TanStack Query options. - * - * The `["dal", ...]` prefix namespaces every DAL query so mutations can - * invalidate all DAL queries for an entity with a single key prefix. - * - * `ctxGetter` is a function (not the context value itself) so the backend - * is evaluated at query execution time rather than at hook creation time. - * This prevents stale backend reads when auth state changes between renders. - */ -const toQueryOptions = ( - action: DalReadAction, - input: Input, - ctxGetter: DalContextGetter, -) => { - const ctx = ctxGetter(); - return queryOptions({ - queryKey: ["dal", ...(action.queryKey(input, ctx) as readonly unknown[])], - queryFn: async () => { - const execCtx = ctxGetter(); - if (execCtx.backend === "remote") return action.remote(input, execCtx); - return action.local(input, execCtx); - }, - }); -}; - -export { toQueryOptions }; diff --git a/src/features/dal/types.ts b/src/features/dal/types.ts deleted file mode 100644 index 87b6fa6..0000000 --- a/src/features/dal/types.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Core types shared across all DAL layers. - -import type { QueryKey } from "@tanstack/react-query"; -import type { - PendingOp, - PendingOpOperation, - PendingOpSummary, -} from "#/features/dal/queue/types.ts"; - -/** "remote" when the user is authenticated and online; "local" otherwise. */ -type Backend = "remote" | "local"; - -/** Passed to every DAL action so it knows who is acting and where to persist. */ -interface DalContext { - /** UUID persisted in localStorage — always present, even before sign-in. */ - anonUserId: string; - /** Better Auth user ID — null when the user is not authenticated. */ - authUserId: string | null; - /** Execution target for this operation. */ - backend: Backend; -} - -/** - * Result returned by a SyncHandler after attempting to apply a pending op. - * - `applied` — op was written to the server; caller marks the op "synced". - * - `conflict` — server record is newer; caller marks the op "conflict" and surfaces the server record. - * - `noop` — op is redundant (already applied); caller deletes the op. - * - `error` — handler threw or returned an error; caller marks the op "failed". - */ -type SyncResult = - | { status: "applied" } - | { status: "conflict"; serverRecordJson: string } - | { status: "noop" } - | { status: "error"; message: string }; - -/** - * Options forwarded from the sync runner / caller to a SyncHandler. - * Used today only to opt out of LWW conflict checks when the user explicitly - * chose "Keep mine" on a previously-conflicted op. - */ -interface SyncOptions { - /** When true, the handler skips its LWW check and applies the op unconditionally. */ - force?: boolean; -} - -/** - * Server-side function that applies a single pending op for an entity. - * `userId` is the authenticated user — always resolved before the handler is called. - */ -type SyncHandler = ( - op: PendingOp, - userId: string, - options?: SyncOptions, -) => Promise; - -/** Defines a read operation: how to build the cache key and how to fetch from each backend. */ -interface DalReadAction { - kind: "read"; - /** - * Returns the TanStack Query cache key for this input. Namespaced with `["dal", ...]` by toQueryOptions. - * Receives the DalContext so actions can include the active user identity in the key, which is required - * for any action with per-user data, so the anon -> authed transition triggers a key change. - */ - queryKey: (input: Input, ctx?: DalContext) => QueryKey; - /** Fetches from the server (Postgres via TanStack Start server function). */ - remote: (input: Input, ctx: DalContext) => Promise; - /** Fetches from IndexedDB. */ - local: (input: Input, ctx: DalContext) => Promise; -} - -/** Defines a write operation: how to execute it on each backend and how to sync queued ops. */ -interface DalWriteAction { - kind: "write"; - /** String key that must match the entry in the sync-handler registry. */ - entity: string; - /** Operation type stored on PendingOp; used by LWW conflict resolution. */ - operation: PendingOpOperation; - /** Builds a stable key used by the server to deduplicate retried ops within the TTL window. */ - buildIdempotencyKey: (input: Input, ctx: DalContext) => string; - /** Additional entity names whose queries should be invalidated after a successful mutation. */ - invalidates: readonly string[]; - /** Writes directly to the server. Used when backend === "remote" (no queue involved). */ - remote: (input: Input, ctx: DalContext) => Promise; - /** Writes to IndexedDB. Used when backend === "local"; the op is then enqueued for sync. */ - local: (input: Input, ctx: DalContext) => Promise; - /** - * Returns the server's last-known `updatedAt` for the record being written. - * Captured before the local write and stored on the PendingOp as the LWW baseline. - * If the server has advanced past this value by sync time, a concurrent writer won. - * Return null for pure creates or when the record doesn't exist locally yet. - */ - getServerUpdatedAt?: ( - input: Input, - ctx: DalContext, - ) => Promise; - /** - * Returns a display snapshot for the pending op. Called at enqueue time only - * (not on the remote path, which doesn't queue). The result is stored on the - * PendingOp so the data-sync UI can render a user-friendly description. - */ - describe?: (input: Input, ctx: DalContext) => PendingOpSummary; -} - -type DalAction = - | DalReadAction - | DalWriteAction; - -export type { - Backend, - DalAction, - DalContext, - DalReadAction, - DalWriteAction, - SyncHandler, - SyncOptions, - SyncResult, -}; diff --git a/src/features/dal/use-backend.ts b/src/features/dal/use-backend.ts deleted file mode 100644 index a660fbc..0000000 --- a/src/features/dal/use-backend.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useNetwork } from "@mantine/hooks"; -import { chooseBackend } from "#/features/dal/choose-backend.ts"; -import type { Backend } from "#/features/dal/types.ts"; -import { useSession } from "#/integrations/better-auth/auth-client.ts"; - -/** Convenience hook — reads auth session and network state and returns the current backend. */ -const useBackend = (): Backend => { - const { data } = useSession(); - const { online } = useNetwork(); - return chooseBackend({ authed: !!data?.user?.id, online }); -}; - -export { useBackend }; diff --git a/src/features/dal/use-dal-context-source.ts b/src/features/dal/use-dal-context-source.ts deleted file mode 100644 index e30b7e8..0000000 --- a/src/features/dal/use-dal-context-source.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Provides a stable getter for the current DalContext. -// The getter pattern defers backend evaluation to query/mutation execution time, -// not hook creation time, so stale context from a previous render is never used. - -import { useNetwork } from "@mantine/hooks"; -import { useRef } from "react"; -import { chooseBackend } from "#/features/dal/choose-backend.ts"; -import { getOrCreateAnonUserId } from "#/features/dal/identity/anon-id.ts"; -import type { DalContext } from "#/features/dal/types.ts"; -import { useSession } from "#/integrations/better-auth/auth-client.ts"; - -/** A function that returns the current DalContext when called. */ -type DalContextGetter = () => DalContext; - -/** - * Returns a memoized DalContextGetter. - * - * The ref is updated on every render with the latest auth/network state, but the - * returned getter function has a stable identity. Hooks that capture the getter - * (e.g. useMutation's mutationFn) will always read the latest context when they - * execute, even if the auth state changed since the hook was first mounted. - */ -const useDalContextSource = (): DalContextGetter => { - const { data } = useSession(); - - const { online } = useNetwork(); - const sourceRef = useRef({ - anonUserId: "", - authUserId: null, - backend: "local", - }); - - sourceRef.current = { - anonUserId: getOrCreateAnonUserId(), - authUserId: data?.user?.id ?? null, - backend: chooseBackend({ authed: !!data?.user?.id, online }), - }; - - return () => sourceRef.current; -}; - -export { type DalContextGetter, useDalContextSource }; diff --git a/src/features/dal/use-dal-mutation.ts b/src/features/dal/use-dal-mutation.ts deleted file mode 100644 index 841b305..0000000 --- a/src/features/dal/use-dal-mutation.ts +++ /dev/null @@ -1,80 +0,0 @@ -// React hook wrapping useMutation with DAL backend dispatch and op enqueueing. - -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { enqueueOp } from "#/features/dal/queue/pending-ops.ts"; -import type { DalContext, DalWriteAction } from "#/features/dal/types.ts"; -import { useDalContextSource } from "#/features/dal/use-dal-context-source.ts"; - -/** The value returned by a resolved useDalMutation call. */ -interface UseDalMutationResult { - result: Output; - /** Which execution path ran: "remote" (direct server call) or "local" (IndexedDB + queued). */ - branch: "remote" | "local"; - /** The queued op's ID when branch is "local"; null on the remote path. */ - enqueuedOpId: string | null; -} - -/** - * Wraps useMutation with DAL backend selection: - * - "remote" path: calls action.remote() directly, no queue involved. - * - "local" path: calls action.local() for optimistic UI, then enqueues the op for later sync. - * On success, invalidates TanStack Query caches for the primary entity and any additional invalidates. - */ -const useDalMutation = ( - action: DalWriteAction, -) => { - const ctxGetter = useDalContextSource(); - const queryClient = useQueryClient(); - - return useMutation, Error, Input>({ - mutationKey: ["dal", action.entity, action.operation], - mutationFn: async (input: Input) => { - const ctx = ctxGetter(); - if (ctx.backend === "remote") { - const result = await action.remote(input, ctx); - return { result, branch: "remote", enqueuedOpId: null }; - } - return runLocalWithEnqueue(action, input, ctx); - }, - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["dal", action.entity] }), - ...action.invalidates.map((entity) => - queryClient.invalidateQueries({ queryKey: ["dal", entity] }), - ), - ]); - }, - }); -}; - -/** - * Executes the local write and enqueues the op for sync. - * `getServerUpdatedAt` runs first because it reads the pre-write `updatedAt` baseline - * from the same prisma-idb record that `action.local` mutates — racing them would lose - * the baseline. `action.local` and `enqueueOp` target separate IndexedDB databases and - * share no data, so they race in parallel. - */ -const runLocalWithEnqueue = async ( - action: DalWriteAction, - input: Input, - ctx: DalContext, -): Promise> => { - const serverUpdatedAt = action.getServerUpdatedAt - ? await action.getServerUpdatedAt(input, ctx) - : undefined; - const [result, op] = await Promise.all([ - action.local(input, ctx), - enqueueOp({ - anonUserId: ctx.anonUserId, - entity: action.entity, - operation: action.operation, - payload: input, - idempotencyKey: action.buildIdempotencyKey(input, ctx), - serverUpdatedAt: serverUpdatedAt ?? undefined, - summary: action.describe?.(input, ctx), - }), - ]); - return { result, branch: "local", enqueuedOpId: op?.id ?? null }; -}; - -export { useDalMutation }; diff --git a/src/features/dal/use-dal-query.ts b/src/features/dal/use-dal-query.ts deleted file mode 100644 index d480442..0000000 --- a/src/features/dal/use-dal-query.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; -import { toQueryOptions } from "#/features/dal/to-query-options.ts"; -import type { DalReadAction } from "#/features/dal/types.ts"; -import { useDalContextSource } from "#/features/dal/use-dal-context-source.ts"; - -/** - * Executes a DAL read action via TanStack Query. - * The context getter ensures the backend (remote vs local) is chosen at query execution time. - */ -const useDalQuery = ( - action: DalReadAction, - input: Input, -) => { - const ctxGetter = useDalContextSource(); - return useQuery(toQueryOptions(action, input, ctxGetter)); -}; - -/** Suspense-enabled variant of useDalQuery. */ -const useDalSuspenseQuery = ( - action: DalReadAction, - input: Input, -) => { - const ctxGetter = useDalContextSource(); - return useSuspenseQuery(toQueryOptions(action, input, ctxGetter)); -}; - -export { useDalQuery, useDalSuspenseQuery }; diff --git a/src/features/db/client.ts b/src/features/db/client.ts deleted file mode 100644 index a5da6dd..0000000 --- a/src/features/db/client.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { PrismaPg } from "@prisma/adapter-pg"; -import { serverEnv } from "#/env/server-env.ts"; -import { PrismaClient } from "@/prisma"; - -const adapter = new PrismaPg({ - connectionString: serverEnv.DATABASE_URL, -}); - -declare global { - // noinspection ES6ConvertVarToLetConst - var __prisma: PrismaClient | undefined; -} - -const prisma = globalThis.__prisma || new PrismaClient({ adapter }); - -if (process.env.NODE_ENV !== "production") { - globalThis.__prisma = prisma; -} - -export { prisma }; diff --git a/src/features/game/dal/active-game.ts b/src/features/game/active-game.ts similarity index 93% rename from src/features/game/dal/active-game.ts rename to src/features/game/active-game.ts index 67a03cf..accca1f 100644 --- a/src/features/game/dal/active-game.ts +++ b/src/features/game/active-game.ts @@ -1,7 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; -import { getValidatedGameId } from "#/features/game/registry/game-registry"; -import { parseCookie, parseSubdomain } from "#/features/game/utils.ts"; +import { getValidatedGameId } from "#/features/game/registry/game-public-registry.tsx"; +import { parseCookie, parseSubdomain } from "#/utils.ts"; import type { GameId } from "@/prisma"; const ACTIVE_GAME_COOKIE = "active-game"; diff --git a/src/features/game/dal/collected-items/collected-items.dal.ts b/src/features/game/dal/collected-items/collected-items.dal.ts deleted file mode 100644 index b9ada0d..0000000 --- a/src/features/game/dal/collected-items/collected-items.dal.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Reusable DAL actions for any game's collected-item feature. -// Each game instantiates this with its own entity name, IDB model accessor, and server functions. - -import { defineDalRead, defineDalWrite } from "#/features/dal/define-action.ts"; -import type { DalContext } from "#/features/dal/types.ts"; -import { - type CollectedItemIDBDelegate, - createCollectedItemsIdb, - type IDBClient, -} from "#/features/game/dal/collected-items/collected-items.idb.ts"; -import type {CollectedItemRecord, CollectItemInput, GameCollectedItemsDal} from "#/features/game/dal/types.ts"; - -/** TanStack Start server functions injected per game. */ -interface CollectedItemServerFns { - collectItemServerFn(opts: { - data: CollectItemInput; - }): Promise; - uncollectItemServerFn(opts: { - data: CollectItemInput; - }): Promise<{ ok: true }>; - listCollectedItemsServerFn(): Promise; - listCollectedItemsByUserIdServerFn(opts: { - data: { userId: string }; - }): Promise; -} - -/** - * Creates a GameCollectedItemsDal for a specific game. - * `entityName` is the string key used in the sync-handler registry and for TanStack Query cache namespacing. - * `getModel` extracts the game-specific IDB delegate from the shared IDB client. - */ -const createCollectedItemsDal = (config: { - entityName: string; - getModel: (idb: IDBClient) => CollectedItemIDBDelegate; - serverFns: CollectedItemServerFns; -}): GameCollectedItemsDal => { - const { entityName, getModel, serverFns } = config; - const idb = createCollectedItemsIdb(getModel); - - // Reads the pre-write `updatedAt` of the local record as the LWW baseline. - // Shared by collect and uncollect — both need the same snapshot. - const readServerUpdatedAt = ( - input: CollectItemInput, - ctx: DalContext, - ): Promise => - idb.readUpdatedAt(ctx.authUserId ?? ctx.anonUserId, input.itemId); - - return { - list: defineDalRead({ - queryKey: () => [entityName, "list"] as const, - remote: async () => serverFns.listCollectedItemsServerFn(), - local: async (_input, ctx) => { - const userId = ctx.authUserId ?? ctx.anonUserId; - if (!userId) return []; - return idb.list(userId); - }, - }), - - collect: defineDalWrite({ - entity: entityName, - operation: "upsert", - invalidates: [entityName], - buildIdempotencyKey: (input, ctx) => - `${entityName}:upsert:${ctx.anonUserId}:${input.itemId}`, - describe: (input) => ({ - title: `Collected: ${input.itemName}`, - }), - getServerUpdatedAt: readServerUpdatedAt, - remote: async (input) => serverFns.collectItemServerFn({ data: input }), - local: async (input, ctx) => - idb.collect(ctx.authUserId ?? ctx.anonUserId, input.itemId), - }), - - uncollect: defineDalWrite({ - entity: entityName, - operation: "delete", - invalidates: [entityName], - buildIdempotencyKey: (input, ctx) => - `${entityName}:delete:${ctx.anonUserId}:${input.itemId}`, - describe: (input) => ({ - title: `Uncollected: ${input.itemName}`, - }), - getServerUpdatedAt: readServerUpdatedAt, - remote: async (input) => serverFns.uncollectItemServerFn({ data: input }), - local: async (input, ctx) => { - await idb.uncollect(ctx.authUserId ?? ctx.anonUserId, input.itemId); - return { ok: true as const }; - }, - }), - - listByUserIdServerFn: serverFns.listCollectedItemsByUserIdServerFn, - }; -}; - -export { createCollectedItemsDal }; diff --git a/src/features/game/dal/collected-items/collected-items.idb.ts b/src/features/game/dal/collected-items/collected-items.idb.ts deleted file mode 100644 index cd57ea4..0000000 --- a/src/features/game/dal/collected-items/collected-items.idb.ts +++ /dev/null @@ -1,87 +0,0 @@ -// IndexedDB layer for any game's collected-item feature. -// Each game's Prisma IDB client is a different generated type; this factory works -// with any of them by taking a `getModel` accessor instead of importing game-specific types. -import { getIDBClient } from "#/integrations/prisma-idb/idb-client"; -import type {CollectedItemRecord} from "#/features/game/dal/types.ts"; - -/** - * Structural interface for the IDB model delegate. - * Each game's Prisma IDB client is a different generated type; this interface - * lets the factory work with any of them without importing game-specific types. - */ -interface CollectedItemIDBDelegate { - findMany: (args: { - where: { userId: string }; - }) => Promise; - findFirst: (args: { - where: { userId: string; itemId: string }; - }) => Promise; - upsert: (args: { - where: { userId_itemId: { userId: string; itemId: string } }; - update: object; - create: { userId: string; itemId: string }; - }) => Promise; - deleteMany: (args: { - where: { userId: string; itemId?: string }; - }) => Promise; -} - -/** Inferred type of the Prisma IDB client returned by getIDBClient(). */ -type IDBClient = Awaited>; - -/** Creates the IndexedDB helpers for a game's collected-item model. */ -const createCollectedItemsIdb = ( - getModel: (idb: IDBClient) => CollectedItemIDBDelegate, -) => { - const list = async (userId: string): Promise => { - const idb = await getIDBClient(); - return getModel(idb).findMany({ where: { userId } }); - }; - - const collect = async ( - userId: string, - itemId: string, - ): Promise => { - const idb = await getIDBClient(); - // IDB enforces a FK from collectedItem.userId -> user.id, so we must - // ensure a stub user row exists before writing the item row. - await idb.user.upsert({ - where: { id: userId }, - update: {}, - create: { - id: userId, - username: `_local_${userId}`, - email: `_local_${userId}@local.invalid`, - emailVerified: false, - }, - }); - return getModel(idb).upsert({ - where: { userId_itemId: { userId, itemId } }, - update: {}, - create: { userId, itemId }, - }); - }; - - const uncollect = async (userId: string, itemId: string): Promise => { - const idb = await getIDBClient(); - // deleteMany (not delete) so this is a no-op if the item is already absent. - await getModel(idb).deleteMany({ where: { userId, itemId } }); - }; - - // Reads the pre-write `updatedAt` of the local record as the LWW baseline. - const readUpdatedAt = async ( - userId: string, - itemId: string, - ): Promise => { - const idb = await getIDBClient(); - const record = await getModel(idb).findFirst({ where: { userId, itemId } }); - if (!record) return null; - const t = record.updatedAt; - return t instanceof Date ? t.toISOString() : (t ?? null); - }; - - return { list, collect, uncollect, readUpdatedAt }; -}; - -export type { CollectedItemIDBDelegate, IDBClient }; -export { createCollectedItemsIdb }; diff --git a/src/features/game/dal/collected-items/collected-items.ts b/src/features/game/dal/collected-items/collected-items.ts deleted file mode 100644 index cf49f32..0000000 --- a/src/features/game/dal/collected-items/collected-items.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Thin Prisma wrappers for collected-item database operations. -// Used by per-game server functions to perform CRUD without duplicating Prisma call shapes. - -import type {CollectedItemRecord} from "#/features/game/dal/types.ts"; - -/** - * Structural interface satisfied by every game's Prisma collected-item model delegate. - * Each game generates a different concrete type, but they all share this shape, - * allowing one factory to work across all games. - */ -interface CollectedItemPrismaDelegate { - upsert(args: { - where: { userId_itemId: { userId: string; itemId: string } }; - update: object; - create: { userId: string; itemId: string }; - }): Promise; - deleteMany(args: { - where: { userId: string; itemId: string }; - }): Promise; - findMany(args: { where: { userId: string } }): Promise; -} - -/** Creates database helpers for a game's collected-item Prisma model. */ -const createCollectedItemHandlers = (model: CollectedItemPrismaDelegate) => { - return { - /** Upserts a collected-item row for the given user and item. */ - collect: (itemId: string, userId: string): Promise => - model.upsert({ - where: { userId_itemId: { userId, itemId } }, - update: {}, - create: { userId, itemId }, - }), - /** Removes a collected-item row. No-op if it doesn't exist. */ - uncollect: async ( - itemId: string, - userId: string, - ): Promise<{ ok: true }> => { - await model.deleteMany({ where: { userId, itemId } }); - return { ok: true }; - }, - /** Returns all collected items for a user. */ - list: (userId: string): Promise => - model.findMany({ where: { userId } }), - }; -}; - -export { createCollectedItemHandlers }; diff --git a/src/features/game/dal/collected-items/sync-handler.ts b/src/features/game/dal/collected-items/sync-handler.ts deleted file mode 100644 index 204c84e..0000000 --- a/src/features/game/dal/collected-items/sync-handler.ts +++ /dev/null @@ -1,42 +0,0 @@ -// SyncHandler for collected items — a presence-toggle entity (collected or not). -// Delegates the delete/upsert + LWW branching to createPresenceToggleSyncHandler. - -import { createPresenceToggleSyncHandler } from "#/features/dal/presence-sync-handler.ts"; -import type { HasUpdatedAt } from "#/features/dal/queue/last-write-wins.ts"; -import type { SyncHandler } from "#/features/dal/types.ts"; - -// Structural interface so the same handler works with any game's Prisma model delegate -// without importing game-specific generated types. Each game passes its own model instance. -interface CollectedItemDelegate { - findUnique(args: { - where: { userId_itemId: { userId: string; itemId: string } }; - }): Promise; - deleteMany(args: { - where: { userId: string; itemId: string }; - }): Promise; - create(args: { data: { userId: string; itemId: string } }): Promise; -} - -/** Creates a SyncHandler for a game's collected-item Prisma model. */ -const createCollectedItemSyncHandler = ( - model: CollectedItemDelegate, -): SyncHandler => - createPresenceToggleSyncHandler({ - resolveKey: (op) => { - const payload = op.payload as { itemId?: string } | null; - const itemId = payload?.itemId; - return itemId - ? { ok: true, key: itemId } - : { ok: false, message: "missing itemId" }; - }, - findRecord: (userId, itemId) => - model.findUnique({ where: { userId_itemId: { userId, itemId } } }), - deleteRecord: async (userId, itemId) => { - await model.deleteMany({ where: { userId, itemId } }); - }, - createRecord: async (userId, itemId) => { - await model.create({ data: { userId, itemId } }); - }, - }); - -export { createCollectedItemSyncHandler }; diff --git a/src/features/game/dal/favorite-games/favorite-games.dal.ts b/src/features/game/dal/favorite-games/favorite-games.dal.ts deleted file mode 100644 index 7b89c5d..0000000 --- a/src/features/game/dal/favorite-games/favorite-games.dal.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { defineDalRead, defineDalWrite } from "#/features/dal/define-action.ts"; -import type { LocalUserFavoriteGame } from "#/features/dal/local/types.ts"; -import type { DalContext } from "#/features/dal/types.ts"; -import { - deleteLocalFavoriteGame, - listLocalFavoriteGames, - upsertLocalFavoriteGame, -} from "#/features/game/dal/favorite-games/favorite-games.idb.ts"; -import { - favoriteGameServerFn, - listFavoriteGamesServerFn, - unfavoriteGameServerFn, -} from "#/features/game/dal/favorite-games/favorite-games.ts"; -import { getGameMetadata } from "#/features/game/registry/game-registry.tsx"; -import type { GameId } from "@/prisma"; - -interface FavoriteGameInput { - gameId: GameId; -} - -const resolveLocalUserId = (ctx: DalContext): string => { - return ctx.authUserId ?? ctx.anonUserId; -}; - -const createFavoriteGameDal = () => { - return { - list: defineDalRead({ - queryKey: () => ["userFavoriteGame", "list"] as const, - remote: async () => { - const rows = await listFavoriteGamesServerFn(); - return rows.map((r) => ({ - userId: r.userId, - gameId: r.gameId, - createdAt: r.createdAt.toISOString(), - updatedAt: r.updatedAt.toISOString(), - })); - }, - local: async (_input, ctx) => { - const userId = resolveLocalUserId(ctx); - if (!userId) return []; - return listLocalFavoriteGames(userId); - }, - }), - - favorite: defineDalWrite({ - entity: "userFavoriteGame", - operation: "upsert", - invalidates: ["userFavoriteGame"], - buildIdempotencyKey: (input, ctx) => - `userFavoriteGame:upsert:${ctx.anonUserId}:${input.gameId}`, - describe: (input) => ({ - title: "Favorited game", - details: getGameMetadata(input.gameId)?.label, - gameId: input.gameId, - }), - remote: async (input) => { - const row = await favoriteGameServerFn({ data: input }); - return { - userId: row.userId, - gameId: row.gameId, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; - }, - local: async (input, ctx) => { - const userId = resolveLocalUserId(ctx); - return upsertLocalFavoriteGame({ userId, gameId: input.gameId }); - }, - }), - - unfavorite: defineDalWrite({ - entity: "userFavoriteGame", - operation: "delete", - invalidates: ["userFavoriteGame"], - buildIdempotencyKey: (input, ctx) => - `userFavoriteGame:delete:${ctx.anonUserId}:${input.gameId}`, - describe: (input) => ({ - title: "Unfavorited game", - details: getGameMetadata(input.gameId)?.label, - gameId: input.gameId, - }), - remote: async (input) => unfavoriteGameServerFn({ data: input }), - local: async (input, ctx) => { - const userId = resolveLocalUserId(ctx); - await deleteLocalFavoriteGame({ userId, gameId: input.gameId }); - return { ok: true as const }; - }, - }), - }; -}; - -export { createFavoriteGameDal }; diff --git a/src/features/game/dal/favorite-games/favorite-games.ts b/src/features/game/dal/favorite-games/favorite-games.ts deleted file mode 100644 index 053e737..0000000 --- a/src/features/game/dal/favorite-games/favorite-games.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; -import { requireUserId } from "#/features/auth/require-user.server.ts"; -import { GameId, prisma } from "@/prisma"; - -const FavoriteInput = z.object({ gameId: z.enum(GameId) }); - -const favoriteGameServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => FavoriteInput.parse(v)) - .handler(async ({ data }) => { - const userId = await requireUserId(); - return prisma.userFavoriteGame.upsert({ - where: { userId_gameId: { userId, gameId: data.gameId } }, - update: {}, - create: { userId, gameId: data.gameId }, - }); - }); - -const unfavoriteGameServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => FavoriteInput.parse(v)) - .handler(async ({ data }) => { - const userId = await requireUserId(); - await prisma.userFavoriteGame.deleteMany({ - where: { userId, gameId: data.gameId }, - }); - return { ok: true as const }; - }); - -const listFavoriteGamesServerFn = createServerFn({ - method: "GET", -}).handler(async () => { - const userId = await requireUserId(); - return prisma.userFavoriteGame.findMany({ where: { userId } }); -}); - -export { - favoriteGameServerFn, - listFavoriteGamesServerFn, - unfavoriteGameServerFn, -}; diff --git a/src/features/game/dal/favorite-games/sync-handler.ts b/src/features/game/dal/favorite-games/sync-handler.ts deleted file mode 100644 index cdab9b4..0000000 --- a/src/features/game/dal/favorite-games/sync-handler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createPresenceToggleSyncHandler } from "#/features/dal/presence-sync-handler.ts"; -import { GameId, prisma } from "@/prisma"; - -const favoriteGameSyncHandler = createPresenceToggleSyncHandler({ - resolveKey: (op) => { - const payload = op.payload as { gameId?: string } | null; - const rawGameId = payload?.gameId; - if (!rawGameId) return { ok: false, message: "missing gameId" }; - const gameId = GameId[rawGameId as keyof typeof GameId]; - if (!gameId) return { ok: false, message: `unknown gameId ${rawGameId}` }; - return { ok: true, key: gameId }; - }, - findRecord: (userId, gameId) => - prisma.userFavoriteGame.findUnique({ - where: { userId_gameId: { userId, gameId } }, - }), - deleteRecord: async (userId, gameId) => { - await prisma.userFavoriteGame.deleteMany({ where: { userId, gameId } }); - }, - createRecord: async (userId, gameId) => { - await prisma.userFavoriteGame.create({ data: { userId, gameId } }); - }, -}); - -export { favoriteGameSyncHandler }; diff --git a/src/features/game/dal/types.ts b/src/features/game/dal/types.ts deleted file mode 100644 index dda8db0..0000000 --- a/src/features/game/dal/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type {DalReadAction, DalWriteAction} from "#/features/dal/types.ts"; - -export type CollectItemInput = { itemId: string; itemName: string }; - -export type CollectedItemRecord = { - userId: string; - itemId: string; - updatedAt?: Date | string | null; -}; - -export type GameCollectedItemsDal = { - list: DalReadAction; - collect: DalWriteAction; - uncollect: DalWriteAction; - listByUserIdServerFn: (input: { - data: { userId: string }; - }) => Promise; -}; \ No newline at end of file diff --git a/src/features/game/dal/user-profile/sync-handler.ts b/src/features/game/dal/user-profile/sync-handler.ts deleted file mode 100644 index a773410..0000000 --- a/src/features/game/dal/user-profile/sync-handler.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { SyncHandler } from "#/features/dal/types.ts"; -import { GameId, prisma } from "@/prisma"; - -const userProfileHandler: SyncHandler = async (op, userId) => { - if (op.operation === "upsert") { - await prisma.userProfile.update({ - where: { userId }, - data: { primaryAvatarId: null, primaryAvatarGameId: null }, - }); - return { status: "applied" }; - } - return { status: "error", message: `unsupported operation ${op.operation}` }; -}; - -const userAvatarOverrideHandler: SyncHandler = async (op, userId) => { - const payload = op.payload as { - avatarId?: string; - avatarGameId?: string; - targetGameId?: string; - } | null; - - const profile = await prisma.userProfile.findUnique({ where: { userId } }); - if (!profile) return { status: "error", message: "user profile not found" }; - - if (op.operation === "delete") { - const rawTargetGameId = payload?.targetGameId; - if (!rawTargetGameId) - return { status: "error", message: "missing targetGameId" }; - const targetGameId = GameId[rawTargetGameId as keyof typeof GameId]; - if (!targetGameId) - return { status: "error", message: `unknown gameId ${rawTargetGameId}` }; - await prisma.userAvatarOverride.deleteMany({ - where: { userProfileId: profile.id, gameId: targetGameId }, - }); - return { status: "applied" }; - } - - const { - avatarId, - avatarGameId: rawAvatarGameId, - targetGameId: rawTargetGameId, - } = payload ?? {}; - if (!avatarId) return { status: "error", message: "missing avatarId" }; - if (!rawAvatarGameId) - return { status: "error", message: "missing avatarGameId" }; - const avatarGameId = GameId[rawAvatarGameId as keyof typeof GameId]; - if (!avatarGameId) - return { - status: "error", - message: `unknown avatarGameId ${rawAvatarGameId}`, - }; - - if (rawTargetGameId) { - const targetGameId = GameId[rawTargetGameId as keyof typeof GameId]; - if (!targetGameId) - return { - status: "error", - message: `unknown targetGameId ${rawTargetGameId}`, - }; - await prisma.userAvatarOverride.upsert({ - where: { - userProfileId_gameId: { - userProfileId: profile.id, - gameId: targetGameId, - }, - }, - update: { avatarId }, - create: { userProfileId: profile.id, gameId: targetGameId, avatarId }, - }); - } else { - await prisma.userProfile.update({ - where: { userId }, - data: { primaryAvatarId: avatarId, primaryAvatarGameId: avatarGameId }, - }); - } - return { status: "applied" }; -}; - -export { userAvatarOverrideHandler, userProfileHandler }; diff --git a/src/features/game/dal/user-profile/user-profile.dal.ts b/src/features/game/dal/user-profile/user-profile.dal.ts deleted file mode 100644 index 3b96608..0000000 --- a/src/features/game/dal/user-profile/user-profile.dal.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { - getOptionalUserId, - requireUserId, -} from "#/features/auth/require-user.server.ts"; -import { defineDalRead, defineDalWrite } from "#/features/dal/define-action.ts"; -import type { DalContext } from "#/features/dal/types.ts"; -import { prisma } from "#/features/db/client.ts"; -import { - deleteLocalAvatarOverride, - getLocalAvatarOverrides, - getLocalUserProfile, - upsertLocalAvatarOverride, - upsertLocalUserProfile, -} from "#/features/game/dal/user-profile/user-profile.idb.ts"; -import { - getPublicUserProfileServerFn, - removeAvatarOverrideServerFn, - removePrimaryAvatarServerFn, - updateAvatarServerFn, - updateProfileServerFn, -} from "#/features/game/dal/user-profile/user-profile.ts"; -import { getGameMetadata } from "#/features/game/registry/game-registry.tsx"; -import type { GameId } from "@/prisma"; - -type UserProfileData = { - displayName: string; - bio: string; - avatarUrl: string | null; - primaryAvatarId: string | null; - primaryAvatarGameId: GameId | null; - avatarOverrides: { gameId: GameId; avatarId: string; avatarGameId: GameId }[]; -}; - -// Not using UserProfileData type due to needed `null` flexibility -type UserWithProfile = { - userProfile: { - displayName: string; - bio: string; - avatarUrl: string | null; - primaryAvatarId: string | null; - primaryAvatarGameId: string | null; - avatarOverrides: { gameId: string; avatarId: string }[]; - } | null; -} | null; - -type GetProfileInput = { userId?: string } | undefined; - -const mapUserToProfileData = ( - user: UserWithProfile, -): UserProfileData | null => { - if (!user?.userProfile) return null; - const profile = user.userProfile; - return { - displayName: profile.displayName, - bio: profile.bio, - avatarUrl: profile.avatarUrl ?? null, - primaryAvatarId: profile.primaryAvatarId ?? null, - primaryAvatarGameId: (profile.primaryAvatarGameId as GameId) ?? null, - avatarOverrides: profile.avatarOverrides.map((o) => ({ - gameId: o.gameId as GameId, - avatarId: o.avatarId, - // Server-side overrides don't store avatarGameId; use gameId as fallback - avatarGameId: o.gameId as GameId, - })), - }; -}; - -// Inner cache-key tail (without the ["dal", ...] prefix that toQueryOptions adds). -const getProfileQueryKeyTail = (userId: string) => - ["userProfile", "getProfile", userId] as const; - -// Full cache key, used from route loaders that prefetch via queryClient directly -// Client-side useDalQuery resolves to the same prefixed key via toQueryOptions. -const buildGetProfileQueryKey = (userId: string) => - ["dal", ...getProfileQueryKeyTail(userId)] as const; - -const resolveLocalUserId = (ctx: DalContext): string => { - return ctx.authUserId ?? ctx.anonUserId; -}; - -const createUserProfileDal = () => { - return { - getProfile: defineDalRead({ - queryKey: (input, ctx) => - getProfileQueryKeyTail( - input?.userId ?? ctx?.authUserId ?? ctx?.anonUserId ?? "", - ), - remote: async (input) => { - const user = input?.userId - ? await getPublicUserProfileServerFn({ - data: { userId: input.userId }, - }) - : await getUserProfileServerFn(); - return mapUserToProfileData(user); - }, - local: async (input, ctx) => { - const userId = input?.userId ?? resolveLocalUserId(ctx); - const [profile, overrides] = await Promise.all([ - getLocalUserProfile(userId), - getLocalAvatarOverrides(userId), - ]); - return { - displayName: profile?.displayName ?? "Traveler", - bio: profile?.bio ?? "No bio provided.", - avatarUrl: null, - primaryAvatarId: profile?.primaryAvatarId ?? null, - primaryAvatarGameId: (profile?.primaryAvatarGameId as GameId) ?? null, - avatarOverrides: overrides.map((o) => ({ - gameId: o.gameId, - avatarId: o.avatarId, - avatarGameId: o.avatarGameId, - })), - }; - }, - }), - - updateAvatar: defineDalWrite< - { avatarId: string; avatarGameId: GameId; targetGameId?: GameId }, - { ok: true } - >({ - entity: "userAvatarOverride", - operation: "upsert", - invalidates: ["userProfile"], - buildIdempotencyKey: (input, ctx) => - `userAvatarOverride:upsert:${ctx.anonUserId}:${input.targetGameId ?? "primary"}:${input.avatarId}`, - describe: (input) => - input.targetGameId - ? { - title: "Set avatar override", - details: `For ${getGameMetadata(input.targetGameId)?.label ?? input.targetGameId}`, - gameId: input.targetGameId, - } - : { - title: "Updated primary avatar", - }, - remote: async (input) => updateAvatarServerFn({ data: input }), - local: async (input, ctx) => { - const userId = resolveLocalUserId(ctx); - if (input.targetGameId) { - await upsertLocalAvatarOverride({ - userId, - gameId: input.targetGameId, - avatarId: input.avatarId, - avatarGameId: input.avatarGameId, - }); - } else { - await upsertLocalUserProfile({ - userId, - primaryAvatarId: input.avatarId, - primaryAvatarGameId: input.avatarGameId, - }); - } - return { ok: true as const }; - }, - }), - - removePrimaryAvatar: defineDalWrite({ - entity: "userProfile", - operation: "upsert", - invalidates: ["userProfile"], - buildIdempotencyKey: (_input, ctx) => - `userProfile:removePrimary:${ctx.anonUserId}`, - describe: () => ({ - title: "Removed primary avatar", - }), - remote: async () => removePrimaryAvatarServerFn(), - local: async (_input, ctx) => { - const userId = resolveLocalUserId(ctx); - await upsertLocalUserProfile({ - userId, - primaryAvatarId: null, - primaryAvatarGameId: null, - }); - return { ok: true as const }; - }, - }), - - removeAvatarOverride: defineDalWrite< - { targetGameId: GameId }, - { ok: true } - >({ - entity: "userAvatarOverride", - operation: "delete", - invalidates: ["userProfile"], - buildIdempotencyKey: (input, ctx) => - `userAvatarOverride:delete:${ctx.anonUserId}:${input.targetGameId}`, - describe: (input) => ({ - title: "Removed avatar override", - details: `For ${getGameMetadata(input.targetGameId)?.label ?? input.targetGameId}`, - gameId: input.targetGameId, - }), - remote: async (input) => removeAvatarOverrideServerFn({ data: input }), - local: async (input, ctx) => { - const userId = resolveLocalUserId(ctx); - await deleteLocalAvatarOverride(userId, input.targetGameId); - return { ok: true as const }; - }, - }), - - updateProfile: defineDalWrite< - { displayName: string; bio: string }, - { ok: true } - >({ - entity: "userProfile", - operation: "upsert", - invalidates: ["userProfile"], - buildIdempotencyKey: (_input, ctx) => - `userProfile:update:${ctx.anonUserId}`, - describe: (input) => ({ - title: "Updated profile", - details: `Display name -> ${input.displayName}`, - }), - remote: async (input) => updateProfileServerFn({ data: input }), - local: async (input, ctx) => { - const userId = resolveLocalUserId(ctx); - await upsertLocalUserProfile({ - userId, - displayName: input.displayName, - bio: input.bio, - }); - return { ok: true as const }; - }, - }), - }; -}; - -const getViewerUserIdServerFn = createServerFn({ - method: "GET", -}).handler(async () => getOptionalUserId()); - -const getUserProfileServerFn = createServerFn({ method: "GET" }).handler( - async () => { - const userId = await requireUserId(); - return prisma.user.findUnique({ - where: { id: userId }, - include: { - userProfile: { - include: { avatarOverrides: true }, - }, - }, - }); - }, -); - -export { - buildGetProfileQueryKey, - createUserProfileDal, - getUserProfileServerFn, - getViewerUserIdServerFn, - mapUserToProfileData, -}; diff --git a/src/features/game/dal/user-profile/user-profile.ts b/src/features/game/dal/user-profile/user-profile.ts deleted file mode 100644 index 532ab88..0000000 --- a/src/features/game/dal/user-profile/user-profile.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; -import { requireUserId } from "#/features/auth/require-user.server.ts"; -import { getGameAvatars } from "#/features/game/registry/game-registry.tsx"; -import { GameId, prisma } from "@/prisma"; - -const AvatarInput = z.object({ - avatarId: z.string(), - avatarGameId: z.enum(GameId), - targetGameId: z.enum(GameId).optional(), -}); - -const updateAvatarServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => AvatarInput.parse(v)) - .handler(async ({ data }) => { - const userId = await requireUserId(); - - const avatars = getGameAvatars(data.avatarGameId); - const avatarExists = avatars?.some((a) => a.id === data.avatarId); - if (!avatarExists) { - throw new Error( - `Avatar ${data.avatarId} not found in game ${data.avatarGameId}`, - ); - } - - const profile = await prisma.userProfile.findUnique({ where: { userId } }); - if (!profile) throw new Error("User profile not found"); - - if (data.targetGameId) { - await prisma.userAvatarOverride.upsert({ - where: { - userProfileId_gameId: { - userProfileId: profile.id, - gameId: data.targetGameId, - }, - }, - update: { avatarId: data.avatarId }, - create: { - userProfileId: profile.id, - gameId: data.targetGameId, - avatarId: data.avatarId, - }, - }); - } else { - await prisma.userProfile.update({ - where: { userId }, - data: { - primaryAvatarId: data.avatarId, - primaryAvatarGameId: data.avatarGameId, - }, - }); - } - - return { ok: true as const }; - }); - -const removePrimaryAvatarServerFn = createServerFn({ method: "POST" }).handler( - async () => { - const userId = await requireUserId(); - await prisma.userProfile.update({ - where: { userId }, - data: { primaryAvatarId: null, primaryAvatarGameId: null }, - }); - return { ok: true as const }; - }, -); - -const RemoveOverrideInput = z.object({ targetGameId: z.enum(GameId) }); - -const removeAvatarOverrideServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => RemoveOverrideInput.parse(v)) - .handler(async ({ data }) => { - const userId = await requireUserId(); - const profile = await prisma.userProfile.findUnique({ where: { userId } }); - if (!profile) throw new Error("User profile not found"); - - await prisma.userAvatarOverride.deleteMany({ - where: { userProfileId: profile.id, gameId: data.targetGameId }, - }); - return { ok: true as const }; - }); - -const UpdateProfileInput = z.object({ - displayName: z.string().min(1).max(100).optional(), - bio: z.string().max(500).optional(), -}); - -const updateProfileServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => UpdateProfileInput.parse(v)) - .handler(async ({ data }) => { - const userId = await requireUserId(); - await prisma.userProfile.update({ - where: { userId }, - data: { - ...(data.displayName !== undefined && { - displayName: data.displayName, - }), - ...(data.bio !== undefined && { bio: data.bio }), - }, - }); - return { ok: true as const }; - }); - -const getPublicUserProfileServerFn = createServerFn({ method: "GET" }) - .inputValidator((v: unknown) => z.object({ userId: z.string() }).parse(v)) - .handler(async ({ data }) => { - return prisma.user.findUnique({ - where: { id: data.userId }, - include: { - userProfile: { - include: { avatarOverrides: true }, - }, - }, - }); - }); - -export { - getPublicUserProfileServerFn, - removeAvatarOverrideServerFn, - removePrimaryAvatarServerFn, - updateAvatarServerFn, - updateProfileServerFn, -}; diff --git a/src/features/game/data/build-fields.ts b/src/features/game/data/build-fields.ts new file mode 100644 index 0000000..7e69925 --- /dev/null +++ b/src/features/game/data/build-fields.ts @@ -0,0 +1,41 @@ +// Prisma-free definition of a build's user-editable fields. Shared by the client +// DAL (local IDB write), the server functions, and the sync handler so all three +// write exactly the same set of fields. Must NOT import `@/prisma` — it is pulled +// into the client bundle via the DAL. + +/** The mutable fields a user can edit on a build (visibility kept as a string here). */ +type BuildWriteFields = { + name?: string; + description?: string | null; + visibility?: string; + videoUrl?: string | null; + imageUrl?: string | null; + thumbnailUrl?: string | null; + referenceUrl?: string | null; + gameVersion?: string | null; +}; + +const BUILD_WRITE_KEYS = [ + "name", + "description", + "visibility", + "videoUrl", + "imageUrl", + "thumbnailUrl", + "referenceUrl", + "gameVersion", +] as const; + +/** Picks only the defined build-write fields from an input object (drops `buildId`, undefined). */ +const extractBuildWriteFields = ( + input: Record, +): BuildWriteFields => { + const out: Record = {}; + for (const key of BUILD_WRITE_KEYS) { + if (input[key] !== undefined) out[key] = input[key]; + } + return out as BuildWriteFields; +}; + +export type { BuildWriteFields }; +export { BUILD_WRITE_KEYS, extractBuildWriteFields }; diff --git a/src/features/game/dal/favorite-games/favorite-games.idb.ts b/src/features/game/data/favorite-games/favorite-games.idb.ts similarity index 84% rename from src/features/game/dal/favorite-games/favorite-games.idb.ts rename to src/features/game/data/favorite-games/favorite-games.idb.ts index 6b83650..b397738 100644 --- a/src/features/game/dal/favorite-games/favorite-games.idb.ts +++ b/src/features/game/data/favorite-games/favorite-games.idb.ts @@ -1,6 +1,6 @@ -import { STORE_USER_FAVORITE_GAME } from "#/features/dal/local/constants.ts"; -import { getLocalDB } from "#/features/dal/local/local-db.ts"; -import type { LocalUserFavoriteGame } from "#/features/dal/local/types.ts"; +import { STORE_USER_FAVORITE_GAME } from "#/features/sync/local/constants.ts"; +import { getLocalDB } from "#/features/sync/local/local-db.ts"; +import type { LocalUserFavoriteGame } from "#/features/sync/local/types.ts"; import type { GameId } from "@/prisma"; const listLocalFavoriteGames = async ( diff --git a/src/features/game/data/favorite-games/favorite-games.ts b/src/features/game/data/favorite-games/favorite-games.ts new file mode 100644 index 0000000..a50f765 --- /dev/null +++ b/src/features/game/data/favorite-games/favorite-games.ts @@ -0,0 +1,87 @@ +// Favorite games: server functions (Postgres via Prisma) and the offline-sync +// handler for the userFavoriteGame entity (a presence toggle). + +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { REGISTERED_GAME_IDS } from "#/features/game/registry/game-public-registry.tsx"; +import { createPresenceToggleSyncHandler } from "#/features/sync/presence-sync-handler.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; +import type { GameId } from "@/prisma"; + +const GAME_ID_SET = new Set(REGISTERED_GAME_IDS); +const isGameId = (value: string): value is GameId => GAME_ID_SET.has(value); + +const FavoriteInput = z.object({ gameId: z.string().refine(isGameId) }); + +const favoriteGameServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => FavoriteInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + return prisma.userFavoriteGame.upsert({ + where: { userId_gameId: { userId, gameId: data.gameId } }, + update: {}, + create: { userId, gameId: data.gameId }, + }); + }); + +const unfavoriteGameServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => FavoriteInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + await prisma.userFavoriteGame.deleteMany({ + where: { userId, gameId: data.gameId }, + }); + return { ok: true as const }; + }); + +const listFavoriteGamesServerFn = createServerFn({ method: "GET" }).handler( + async () => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + return prisma.userFavoriteGame.findMany({ where: { userId } }); + }, +); + +const favoriteGameSyncHandler: SyncHandler = + createPresenceToggleSyncHandler({ + resolveKey: (op) => { + const rawGameId = (op.payload as { gameId?: string } | null)?.gameId; + if (!rawGameId) return { ok: false, message: "missing gameId" }; + if (!isGameId(rawGameId)) + return { ok: false, message: `unknown gameId ${rawGameId}` }; + const gameId = rawGameId; + return { ok: true, key: gameId }; + }, + findRecord: async (userId, gameId) => { + const { prisma } = await import("@/prisma"); + return prisma.userFavoriteGame.findUnique({ + where: { userId_gameId: { userId, gameId } }, + }); + }, + deleteRecord: async (userId, gameId) => { + const { prisma } = await import("@/prisma"); + await prisma.userFavoriteGame.deleteMany({ where: { userId, gameId } }); + }, + createRecord: async (userId, gameId) => { + const { prisma } = await import("@/prisma"); + await prisma.userFavoriteGame.create({ data: { userId, gameId } }); + }, + }); + +export { + favoriteGameServerFn, + favoriteGameSyncHandler, + listFavoriteGamesServerFn, + unfavoriteGameServerFn, +}; diff --git a/src/features/game/data/favorite-games/use-favorite-games.ts b/src/features/game/data/favorite-games/use-favorite-games.ts new file mode 100644 index 0000000..4cab980 --- /dev/null +++ b/src/features/game/data/favorite-games/use-favorite-games.ts @@ -0,0 +1,129 @@ +// Favorite games: client data hooks. Each hook inlines the backend choice +// (remote when authed + online, else local IndexedDB + a queued op for sync). + +import { useNetwork } from "@mantine/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + deleteLocalFavoriteGame, + listLocalFavoriteGames, + upsertLocalFavoriteGame, +} from "#/features/game/data/favorite-games/favorite-games.idb.ts"; +import { + favoriteGameServerFn, + listFavoriteGamesServerFn, + unfavoriteGameServerFn, +} from "#/features/game/data/favorite-games/favorite-games.ts"; +import { getGameMetadata } from "#/features/game/registry/game-public-registry.tsx"; +import { getOrCreateAnonUserId } from "#/features/sync/identity/anon-id.ts"; +import type { LocalUserFavoriteGame } from "#/features/sync/local/types.ts"; +import { enqueueOp } from "#/features/sync/queue/pending-ops.ts"; +import { useSession } from "#/integrations/better-auth/auth-client.ts"; +import type { GameId } from "@/prisma"; + +type FavoriteGameInput = { gameId: GameId }; + +const ENTITY = "userFavoriteGame"; + +const useFavoriteGames = () => { + const { data: session } = useSession(); + const { online } = useNetwork(); + const authUserId = session?.user?.id ?? null; + const userId = authUserId ?? getOrCreateAnonUserId(); + const remote = !!authUserId && online; + + return useQuery({ + queryKey: ["data", ENTITY, "list", userId], + queryFn: async (): Promise => { + if (remote) { + const rows = await listFavoriteGamesServerFn(); + return rows.map((r) => ({ + userId: r.userId, + gameId: r.gameId, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })); + } + if (!userId) return []; + return listLocalFavoriteGames(userId); + }, + }); +}; + +const useFavoriteGame = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + const row = await favoriteGameServerFn({ data: input }); + return { + userId: row.userId, + gameId: row.gameId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + const [local] = await Promise.all([ + upsertLocalFavoriteGame({ userId, gameId: input.gameId }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "upsert", + payload: input, + idempotencyKey: `${ENTITY}:upsert:${anonUserId}:${input.gameId}`, + summary: { + title: "Favorited game", + details: getGameMetadata(input.gameId)?.label, + gameId: input.gameId, + }, + }), + ]); + return local; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +const useUnfavoriteGame = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, FavoriteGameInput>({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return unfavoriteGameServerFn({ data: input }); + } + await Promise.all([ + deleteLocalFavoriteGame({ userId, gameId: input.gameId }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "delete", + payload: input, + idempotencyKey: `${ENTITY}:delete:${anonUserId}:${input.gameId}`, + summary: { + title: "Unfavorited game", + details: getGameMetadata(input.gameId)?.label, + gameId: input.gameId, + }, + }), + ]); + return { ok: true as const }; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +export type { FavoriteGameInput }; +export { useFavoriteGame, useFavoriteGames, useUnfavoriteGame }; diff --git a/src/features/game/data/types.ts b/src/features/game/data/types.ts new file mode 100644 index 0000000..3ad56cf --- /dev/null +++ b/src/features/game/data/types.ts @@ -0,0 +1,90 @@ +// Plain data types shared by the per-entity data modules and the generic UI +// components. No coupling to any data-access abstraction — these are just the +// record/input shapes plus the "hooks bundle" contracts a game exposes to the +// generic ItemList / CreatedBuilds pages. + +import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; + +export type CollectItemInput = { itemId: string; itemName: string }; + +export type CollectedItemRecord = { + userId: string; + itemId: string; + updatedAt?: Date | string | null; +}; + +/** Lightweight row for build lists (profile tabs, cards). */ +export type CreatedBuildSummary = { + id: string; + name: string; + createdById: string | null; + visibility: string; + imageUrl?: string | null; + thumbnailUrl?: string | null; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; +}; + +/** + * Full scalar build record returned by the byId read. Game-specific relations + * (e.g. Remnant2BuildItem[]) are intentionally excluded from this shared type; + * each game can widen its own server-fn return when the build/edit UI needs them. + */ +export type CreatedBuildRecord = CreatedBuildSummary & { + description?: string | null; + videoUrl?: string | null; + referenceUrl?: string | null; + gameVersion?: string | null; +}; + +/** Patch-style input for editing an existing build (all fields optional but `buildId`). */ +export type UpdateBuildInput = { + buildId: string; + name?: string; + description?: string | null; + visibility?: string; + videoUrl?: string | null; + imageUrl?: string | null; + thumbnailUrl?: string | null; + referenceUrl?: string | null; + gameVersion?: string | null; +}; + +export type DeleteBuildInput = { buildId: string }; + +/** + * A game's collected-items data hooks. Passed to the generic ItemList page so it + * stays game-agnostic while each game owns its own (duplicated) read/write hooks. + */ +export type GameCollectedItemsData = { + /** The acting user's collected items (remote when authed+online, else local IDB). */ + useList: () => UseQueryResult; + /** Another user's collected items, by id (always remote). Disabled when null. */ + usePublicList: ( + userId: string | null, + ) => UseQueryResult; + useCollect: () => UseMutationResult< + CollectedItemRecord, + Error, + CollectItemInput + >; + useUncollect: () => UseMutationResult<{ ok: true }, Error, CollectItemInput>; +}; + +/** A game's created-builds data hooks (only games with a build table provide it). */ +export type GameCreatedBuildsData = { + /** The acting user's own builds. */ + useList: () => UseQueryResult; + /** Another user's publicly-visible builds, by id (always remote). Disabled when null. */ + usePublicList: ( + userId: string | null, + ) => UseQueryResult; + /** A single build by id (null when missing). */ + useById: (buildId: string) => UseQueryResult; + useUpdate: () => UseMutationResult< + CreatedBuildRecord, + Error, + UpdateBuildInput + >; + useRemove: () => UseMutationResult<{ ok: true }, Error, DeleteBuildInput>; +}; diff --git a/src/features/game/data/user-profile/use-user-profile-data.ts b/src/features/game/data/user-profile/use-user-profile-data.ts new file mode 100644 index 0000000..c021c11 --- /dev/null +++ b/src/features/game/data/user-profile/use-user-profile-data.ts @@ -0,0 +1,232 @@ +// User profile: client data hooks. Each hook inlines the backend choice (remote +// when authed + online, else local IndexedDB + a queued op for sync). Profile +// reads default to friendly placeholders when no record exists yet. + +import { useNetwork } from "@mantine/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + deleteLocalAvatarOverride, + getLocalAvatarOverrides, + getLocalUserProfile, + upsertLocalAvatarOverride, + upsertLocalUserProfile, +} from "#/features/game/data/user-profile/user-profile.idb.ts"; +import { + buildGetProfileQueryKey, + getPublicUserProfileServerFn, + getUserProfileServerFn, + mapUserToProfileData, + removeAvatarOverrideServerFn, + removePrimaryAvatarServerFn, + type UserProfileData, + updateAvatarServerFn, + updateProfileServerFn, +} from "#/features/game/data/user-profile/user-profile.ts"; +import { getGameMetadata } from "#/features/game/registry/game-public-registry.tsx"; +import { getOrCreateAnonUserId } from "#/features/sync/identity/anon-id.ts"; +import { enqueueOp } from "#/features/sync/queue/pending-ops.ts"; +import { useSession } from "#/integrations/better-auth/auth-client.ts"; +import type { GameId } from "@/prisma"; + +type GetProfileArgs = { userId?: string } | undefined; +type UpdateAvatarInput = { + avatarId: string; + avatarGameId: GameId; + targetGameId?: GameId; +}; + +const invalidateProfile = (queryClient: ReturnType) => + queryClient.invalidateQueries({ queryKey: ["data", "userProfile"] }); + +const useUserProfileQuery = (args?: GetProfileArgs) => { + const { data: session } = useSession(); + const { online } = useNetwork(); + const authUserId = session?.user?.id ?? null; + const resolvedId = args?.userId ?? authUserId ?? getOrCreateAnonUserId(); + const remote = !!authUserId && online; + + return useQuery({ + queryKey: buildGetProfileQueryKey(resolvedId), + queryFn: async (): Promise => { + if (remote) { + const user = args?.userId + ? await getPublicUserProfileServerFn({ + data: { userId: args.userId }, + }) + : await getUserProfileServerFn(); + return mapUserToProfileData(user); + } + const userId = args?.userId ?? authUserId ?? getOrCreateAnonUserId(); + const [profile, overrides] = await Promise.all([ + getLocalUserProfile(userId), + getLocalAvatarOverrides(userId), + ]); + return { + displayName: profile?.displayName ?? "Traveler", + bio: profile?.bio ?? "No bio provided.", + avatarUrl: null, + primaryAvatarId: profile?.primaryAvatarId ?? null, + primaryAvatarGameId: (profile?.primaryAvatarGameId as GameId) ?? null, + avatarOverrides: overrides.map((o) => ({ + gameId: o.gameId, + avatarId: o.avatarId, + avatarGameId: o.avatarGameId, + })), + }; + }, + }); +}; + +const useUpdateAvatar = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, UpdateAvatarInput>({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) return updateAvatarServerFn({ data: input }); + + if (input.targetGameId) { + await upsertLocalAvatarOverride({ + userId, + gameId: input.targetGameId, + avatarId: input.avatarId, + avatarGameId: input.avatarGameId, + }); + } else { + await upsertLocalUserProfile({ + userId, + primaryAvatarId: input.avatarId, + primaryAvatarGameId: input.avatarGameId, + }); + } + await enqueueOp({ + anonUserId, + entity: "userAvatarOverride", + operation: "upsert", + payload: input, + idempotencyKey: `userAvatarOverride:upsert:${anonUserId}:${input.targetGameId ?? "primary"}:${input.avatarId}`, + summary: input.targetGameId + ? { + title: "Set avatar override", + details: `For ${getGameMetadata(input.targetGameId)?.label ?? input.targetGameId}`, + gameId: input.targetGameId, + } + : { title: "Updated primary avatar" }, + }); + return { ok: true as const }; + }, + onSuccess: () => invalidateProfile(queryClient), + }); +}; + +const useRemovePrimaryAvatar = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, void>({ + mutationFn: async () => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) return removePrimaryAvatarServerFn(); + + await upsertLocalUserProfile({ + userId, + primaryAvatarId: null, + primaryAvatarGameId: null, + }); + await enqueueOp({ + anonUserId, + entity: "userProfile", + operation: "upsert", + payload: {}, + idempotencyKey: `userProfile:removePrimary:${anonUserId}`, + summary: { title: "Removed primary avatar" }, + }); + return { ok: true as const }; + }, + onSuccess: () => invalidateProfile(queryClient), + }); +}; + +const useRemoveAvatarOverride = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, { targetGameId: GameId }>({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return removeAvatarOverrideServerFn({ data: input }); + } + await deleteLocalAvatarOverride(userId, input.targetGameId); + await enqueueOp({ + anonUserId, + entity: "userAvatarOverride", + operation: "delete", + payload: input, + idempotencyKey: `userAvatarOverride:delete:${anonUserId}:${input.targetGameId}`, + summary: { + title: "Removed avatar override", + details: `For ${getGameMetadata(input.targetGameId)?.label ?? input.targetGameId}`, + gameId: input.targetGameId, + }, + }); + return { ok: true as const }; + }, + onSuccess: () => invalidateProfile(queryClient), + }); +}; + +const useUpdateProfile = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, { displayName: string; bio: string }>( + { + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) return updateProfileServerFn({ data: input }); + + await upsertLocalUserProfile({ + userId, + displayName: input.displayName, + bio: input.bio, + }); + await enqueueOp({ + anonUserId, + entity: "userProfile", + operation: "upsert", + payload: input, + idempotencyKey: `userProfile:update:${anonUserId}`, + summary: { + title: "Updated profile", + details: `Display name -> ${input.displayName}`, + }, + }); + return { ok: true as const }; + }, + onSuccess: () => invalidateProfile(queryClient), + }, + ); +}; + +export type { GetProfileArgs, UpdateAvatarInput }; +export { + useRemoveAvatarOverride, + useRemovePrimaryAvatar, + useUpdateAvatar, + useUpdateProfile, + useUserProfileQuery, +}; diff --git a/src/features/game/dal/user-profile/user-profile.idb.ts b/src/features/game/data/user-profile/user-profile.idb.ts similarity index 94% rename from src/features/game/dal/user-profile/user-profile.idb.ts rename to src/features/game/data/user-profile/user-profile.idb.ts index 2904ae0..d5a4b8e 100644 --- a/src/features/game/dal/user-profile/user-profile.idb.ts +++ b/src/features/game/data/user-profile/user-profile.idb.ts @@ -1,12 +1,12 @@ import { STORE_USER_AVATAR_OVERRIDE, STORE_USER_PROFILE, -} from "#/features/dal/local/constants.ts"; -import { getLocalDB } from "#/features/dal/local/local-db.ts"; +} from "#/features/sync/local/constants.ts"; +import { getLocalDB } from "#/features/sync/local/local-db.ts"; import type { LocalUserAvatarOverride, LocalUserProfile, -} from "#/features/dal/local/types.ts"; +} from "#/features/sync/local/types.ts"; import type { GameId } from "@/prisma"; const getLocalUserProfile = async ( diff --git a/src/features/game/data/user-profile/user-profile.ts b/src/features/game/data/user-profile/user-profile.ts new file mode 100644 index 0000000..ea6625d --- /dev/null +++ b/src/features/game/data/user-profile/user-profile.ts @@ -0,0 +1,314 @@ +// Shared user-profile helpers and types, plus server-fn wrappers whose server- +// only dependencies are loaded lazily inside the handlers. + +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { + getGameAvatars, + REGISTERED_GAME_IDS, +} from "#/features/game/registry/game-public-registry.tsx"; +import type { SyncHandler } from "#/features/sync/types.ts"; +import type { GameId } from "@/prisma"; + +type UserProfileData = { + displayName: string; + bio: string; + avatarUrl: string | null; + primaryAvatarId: string | null; + primaryAvatarGameId: GameId | null; + avatarOverrides: { gameId: GameId; avatarId: string; avatarGameId: GameId }[]; +}; + +// Not using UserProfileData type due to needed `null` flexibility. +type UserWithProfile = { + userProfile: { + displayName: string; + bio: string; + avatarUrl: string | null; + primaryAvatarId: string | null; + primaryAvatarGameId: string | null; + avatarOverrides: { gameId: string; avatarId: string }[]; + } | null; +} | null; + +type GetProfileInput = { userId?: string } | undefined; + +const GAME_ID_SET = new Set(["none", ...REGISTERED_GAME_IDS]); + +const isGameId = (value: string): value is GameId => GAME_ID_SET.has(value); + +const mapUserToProfileData = ( + user: UserWithProfile, +): UserProfileData | null => { + if (!user?.userProfile) return null; + const profile = user.userProfile; + return { + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl ?? null, + primaryAvatarId: profile.primaryAvatarId ?? null, + primaryAvatarGameId: profile.primaryAvatarGameId as GameId | null, + avatarOverrides: profile.avatarOverrides.map((o) => ({ + gameId: o.gameId as GameId, + avatarId: o.avatarId, + // Server-side overrides don't store avatarGameId; use gameId as fallback. + avatarGameId: o.gameId as GameId, + })), + }; +}; + +// Inner cache-key tail (without the ["data", ...] prefix). +const getProfileQueryKeyTail = (userId: string) => + ["userProfile", "getProfile", userId] as const; + +// Full cache key, used from route loaders that prefetch via queryClient directly +// and from the client hook so both resolve to the same key. +const buildGetProfileQueryKey = (userId: string) => + ["data", ...getProfileQueryKeyTail(userId)] as const; + +const AvatarInput = z.object({ + avatarId: z.string(), + avatarGameId: z.string().refine(isGameId), + targetGameId: z.string().refine(isGameId).optional(), +}); + +const updateAvatarServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => AvatarInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + + const avatars = getGameAvatars(data.avatarGameId); + const avatarExists = avatars?.some((a) => a.id === data.avatarId); + if (!avatarExists) { + throw new Error( + `Avatar ${data.avatarId} not found in game ${data.avatarGameId}`, + ); + } + + const profile = await prisma.userProfile.findUnique({ where: { userId } }); + if (!profile) throw new Error("User profile not found"); + + if (data.targetGameId) { + await prisma.userAvatarOverride.upsert({ + where: { + userProfileId_gameId: { + userProfileId: profile.id, + gameId: data.targetGameId, + }, + }, + update: { avatarId: data.avatarId }, + create: { + userProfileId: profile.id, + gameId: data.targetGameId, + avatarId: data.avatarId, + }, + }); + } else { + await prisma.userProfile.update({ + where: { userId }, + data: { + primaryAvatarId: data.avatarId, + primaryAvatarGameId: data.avatarGameId, + }, + }); + } + + return { ok: true as const }; + }); + +const removePrimaryAvatarServerFn = createServerFn({ method: "POST" }).handler( + async () => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + await prisma.userProfile.update({ + where: { userId }, + data: { primaryAvatarId: null, primaryAvatarGameId: null }, + }); + return { ok: true as const }; + }, +); + +const RemoveOverrideInput = z.object({ + targetGameId: z.string().refine(isGameId), +}); + +const removeAvatarOverrideServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => RemoveOverrideInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + const profile = await prisma.userProfile.findUnique({ where: { userId } }); + if (!profile) throw new Error("User profile not found"); + + await prisma.userAvatarOverride.deleteMany({ + where: { userProfileId: profile.id, gameId: data.targetGameId }, + }); + return { ok: true as const }; + }); + +const UpdateProfileInput = z.object({ + displayName: z.string().min(1).max(100).optional(), + bio: z.string().max(500).optional(), +}); + +const updateProfileServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => UpdateProfileInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + await prisma.userProfile.update({ + where: { userId }, + data: { + ...(data.displayName !== undefined && { + displayName: data.displayName, + }), + ...(data.bio !== undefined && { bio: data.bio }), + }, + }); + return { ok: true as const }; + }); + +const getPublicUserProfileServerFn = createServerFn({ method: "GET" }) + .validator((v: unknown) => z.object({ userId: z.string() }).parse(v)) + .handler(async ({ data }): Promise => { + const { prisma } = await import("@/prisma"); + return prisma.user.findUnique({ + where: { id: data.userId }, + include: { + userProfile: { + include: { avatarOverrides: true }, + }, + }, + }); + }); + +const getViewerUserIdServerFn = createServerFn({ method: "GET" }).handler( + async () => { + const { getOptionalUserId: getOptionalUserIdFn } = await import( + "#/features/user/require-user.server.ts" + ); + return getOptionalUserIdFn(); + }, +); + +const getUserProfileServerFn = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + return prisma.user.findUnique({ + where: { id: userId }, + include: { + userProfile: { + include: { avatarOverrides: true }, + }, + }, + }); + }, +); + +const userProfileHandler: SyncHandler = async (op, userId) => { + const { prisma } = await import("@/prisma"); + if (op.operation === "upsert") { + await prisma.userProfile.update({ + where: { userId }, + data: { primaryAvatarId: null, primaryAvatarGameId: null }, + }); + return { status: "applied" }; + } + return { status: "error", message: `unsupported operation ${op.operation}` }; +}; + +const userAvatarOverrideHandler: SyncHandler = async (op, userId) => { + const { prisma } = await import("@/prisma"); + const payload = op.payload as { + avatarId?: string; + avatarGameId?: string; + targetGameId?: string; + } | null; + + const profile = await prisma.userProfile.findUnique({ where: { userId } }); + if (!profile) return { status: "error", message: "user profile not found" }; + + if (op.operation === "delete") { + const rawTargetGameId = payload?.targetGameId; + if (!rawTargetGameId) + return { status: "error", message: "missing targetGameId" }; + if (!isGameId(rawTargetGameId)) + return { status: "error", message: `unknown gameId ${rawTargetGameId}` }; + await prisma.userAvatarOverride.deleteMany({ + where: { userProfileId: profile.id, gameId: rawTargetGameId }, + }); + return { status: "applied" }; + } + + const { + avatarId, + avatarGameId: rawAvatarGameId, + targetGameId: rawTargetGameId, + } = payload ?? {}; + if (!avatarId) return { status: "error", message: "missing avatarId" }; + if (!rawAvatarGameId) + return { status: "error", message: "missing avatarGameId" }; + if (!isGameId(rawAvatarGameId)) + return { + status: "error", + message: `unknown avatarGameId ${rawAvatarGameId}`, + }; + + if (rawTargetGameId) { + if (!isGameId(rawTargetGameId)) + return { + status: "error", + message: `unknown targetGameId ${rawTargetGameId}`, + }; + await prisma.userAvatarOverride.upsert({ + where: { + userProfileId_gameId: { + userProfileId: profile.id, + gameId: rawTargetGameId, + }, + }, + update: { avatarId }, + create: { userProfileId: profile.id, gameId: rawTargetGameId, avatarId }, + }); + } else { + await prisma.userProfile.update({ + where: { userId }, + data: { primaryAvatarId: avatarId, primaryAvatarGameId: rawAvatarGameId }, + }); + } + return { status: "applied" }; +}; + +export type { GetProfileInput, UserProfileData, UserWithProfile }; +export { + buildGetProfileQueryKey, + getProfileQueryKeyTail, + getPublicUserProfileServerFn, + getUserProfileServerFn, + getViewerUserIdServerFn, + isGameId, + mapUserToProfileData, + removeAvatarOverrideServerFn, + removePrimaryAvatarServerFn, + updateAvatarServerFn, + updateProfileServerFn, + userAvatarOverrideHandler, + userProfileHandler, +}; diff --git a/src/features/game/items/utils.ts b/src/features/game/items/utils.ts index c3427da..fade90c 100644 --- a/src/features/game/items/utils.ts +++ b/src/features/game/items/utils.ts @@ -1,8 +1,19 @@ import { upperFirst } from "@mantine/hooks"; -import type {AppItem} from "#/features/game/types.ts"; +import type { AppItem } from "#/features/game/types.ts"; +import { titleCase } from "#/utils.ts"; + +type CategoryOption = { + label: string; + value: string; +}; + +type GroupedOption = { + group: string; + items: CategoryOption[]; +}; /** Formats a comma-separated category filter value (or "cat:sub") into a human-readable label. */ -const formatCategoryLabel = (raw: string): string => { +export const formatCategoryLabel = (raw: string): string => { const selected = raw ? raw.split(",").filter(Boolean) : []; if (selected.length === 0) return ""; if (selected.length === 1) { @@ -17,7 +28,10 @@ const formatCategoryLabel = (raw: string): string => { }; /** Returns true if the item's category (and optional subcategory) matches the filter value. */ -const itemMatchesCategory = (item: AppItem, filterValue: string): boolean => { +export const itemMatchesCategory = ( + item: AppItem, + filterValue: string, +): boolean => { if (filterValue.includes(":")) { const [category, subcategory] = filterValue.split(":"); if (String(item.category) !== category) return false; @@ -27,18 +41,7 @@ const itemMatchesCategory = (item: AppItem, filterValue: string): boolean => { } return String(item.category) === filterValue; }; - -type CategoryOption = { - label: string; - value: string; -}; - -type GroupedOption = { - group: string; - items: CategoryOption[]; -}; - -const getItemSubcategories = ( +export const getItemSubcategories = ( items: AppItem[], ): (CategoryOption | GroupedOption)[] => { const categoryMap = new Map>(); @@ -85,18 +88,11 @@ const getItemSubcategories = ( return result; }; -const titleCase = (str: string): string => { - return str - .split(/[_ ]/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); -}; - /** * Resolves linked items for a given item by matching names * from the item's linkedItems field against a list of all items. */ -const resolveLinkedItems = ( +export const resolveLinkedItems = ( item: TItem, allItems: TItem[], ): TItem[] => { @@ -124,10 +120,3 @@ const resolveLinkedItems = ( return results; }; - -export { - getItemSubcategories, - formatCategoryLabel, - itemMatchesCategory, - resolveLinkedItems, -}; diff --git a/src/features/game/registry/game-pages-registry.tsx b/src/features/game/registry/game-pages-registry.tsx new file mode 100644 index 0000000..f08fcc8 --- /dev/null +++ b/src/features/game/registry/game-pages-registry.tsx @@ -0,0 +1,17 @@ +import type { GamePages } from "#/features/game/types.ts"; +import { PAGES as CLAIROBSCUR_PAGES } from "#/games/clairobscur/core/game-config/pages"; +import { PAGES as REMNANT2_PAGES } from "#/games/remnant2/core/game-config/pages"; +import { PAGES as SLAYTHESPIRE2_PAGES } from "#/games/slaythespire2/core/game-config/pages"; + +type GamePagesRegistryGameId = "clairobscur" | "remnant2" | "slaythespire2"; + +export const GAME_PAGES_REGISTRY = { + clairobscur: CLAIROBSCUR_PAGES, + remnant2: REMNANT2_PAGES, + slaythespire2: SLAYTHESPIRE2_PAGES, +} satisfies Record; + +export type GamePagesRegistryGameIdKey = keyof typeof GAME_PAGES_REGISTRY; + +export const getGamePages = (gameId: string): GamePages | undefined => + GAME_PAGES_REGISTRY[gameId as GamePagesRegistryGameIdKey]; diff --git a/src/features/game/registry/game-public-registry.tsx b/src/features/game/registry/game-public-registry.tsx new file mode 100644 index 0000000..d18b535 --- /dev/null +++ b/src/features/game/registry/game-public-registry.tsx @@ -0,0 +1,96 @@ +import type { ComponentType } from "react"; +import type { LogoSize } from "#/components/AppLogo.tsx"; +import type { AnyGameConfig, GameAvatar } from "#/features/game/types.ts"; +import type { ToolkitThemeDefinition } from "#/features/theme/types.ts"; +import { ITEMS as CLAIROBSCUR_ITEMS } from "#/games/clairobscur/core/game-config/items"; +import { METADATA as CLAIROBSCUR_METADATA } from "#/games/clairobscur/core/game-config/metadata"; +import { THEME as CLAIROBSCUR_THEME } from "#/games/clairobscur/core/game-config/theme"; +import { AVATARS as REMNANT2_AVATARS } from "#/games/remnant2/core/game-config/avatars"; +import { ITEMS as REMNANT2_ITEMS } from "#/games/remnant2/core/game-config/items"; +import { METADATA as REMNANT2_METADATA } from "#/games/remnant2/core/game-config/metadata"; +import { THEME as REMNANT2_THEME } from "#/games/remnant2/core/game-config/theme"; +import { AVATARS as SLAYTHESPIRE2_AVATARS } from "#/games/slaythespire2/core/game-config/avatars"; +import { ITEMS as SLAYTHESPIRE2_ITEMS } from "#/games/slaythespire2/core/game-config/items"; +import { METADATA as SLAYTHESPIRE2_METADATA } from "#/games/slaythespire2/core/game-config/metadata"; +import { THEME as SLAYTHESPIRE2_THEME } from "#/games/slaythespire2/core/game-config/theme"; +import type { GameId } from "@/prisma"; + +type PublicGameConfig = { + ITEMS: { + all: readonly unknown[]; + collectable: readonly unknown[]; + categorized: Record; + categories: readonly string[]; + uncollectableCategories: readonly string[]; + }; + METADATA: { + label: string; + LogoComponent: ComponentType<{ size?: LogoSize }>; + }; + THEME: + | { + label: string; + className: string; + } + | undefined; + AVATARS?: GameAvatar[]; +}; + +export const PUBLIC_GAME_REGISTRY = { + clairobscur: { + ITEMS: CLAIROBSCUR_ITEMS, + METADATA: CLAIROBSCUR_METADATA, + THEME: CLAIROBSCUR_THEME, + AVATARS: undefined, + }, + remnant2: { + ITEMS: REMNANT2_ITEMS, + METADATA: REMNANT2_METADATA, + THEME: REMNANT2_THEME, + AVATARS: REMNANT2_AVATARS, + }, + slaythespire2: { + ITEMS: SLAYTHESPIRE2_ITEMS, + METADATA: SLAYTHESPIRE2_METADATA, + THEME: SLAYTHESPIRE2_THEME, + AVATARS: SLAYTHESPIRE2_AVATARS, + }, +} satisfies Record, PublicGameConfig>; + +export type PublicRegistryGameId = keyof typeof PUBLIC_GAME_REGISTRY; + +export const REGISTERED_GAME_IDS: readonly PublicRegistryGameId[] = Object.keys( + PUBLIC_GAME_REGISTRY, +) as PublicRegistryGameId[]; + +export const isRegisteredGameId = (id: string): id is PublicRegistryGameId => + id in PUBLIC_GAME_REGISTRY; + +export const getValidatedGameId = (id: string): GameId | undefined => + isRegisteredGameId(id) ? (id as GameId) : undefined; + +// Runtime-keyed getters. Return types are widened to AnyGameConfig's base shapes +// so callers get the usable `AppItem`/`GameMetadata` types rather than the loose +// `unknown`-based PublicGameConfig used only for the `satisfies` check above. +export const getGameMetadata = ( + gameId: string, +): AnyGameConfig["METADATA"] | undefined => + PUBLIC_GAME_REGISTRY[gameId as PublicRegistryGameId]?.METADATA; + +export const getGameItems = ( + gameId: string, +): AnyGameConfig["ITEMS"] | undefined => + PUBLIC_GAME_REGISTRY[gameId as PublicRegistryGameId]?.ITEMS; + +export const getGameTheme = ( + gameId: string, +): ToolkitThemeDefinition | undefined => + PUBLIC_GAME_REGISTRY[gameId as PublicRegistryGameId]?.THEME; + +export const getGameAvatars = (gameId: string): GameAvatar[] | undefined => + PUBLIC_GAME_REGISTRY[gameId as PublicRegistryGameId]?.AVATARS; + +export const getGameLogoComponent = ( + gameId: string, +): ComponentType<{ size?: LogoSize }> | undefined => + PUBLIC_GAME_REGISTRY[gameId as PublicRegistryGameId]?.METADATA?.LogoComponent; diff --git a/src/features/game/registry/game-registry.tsx b/src/features/game/registry/game-registry.tsx deleted file mode 100644 index 7df2317..0000000 --- a/src/features/game/registry/game-registry.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { ComponentType } from "react"; -import type { LogoSize } from "#/components/AppLogo"; -import type { GameAvatar, GameConfig } from "#/features/game/types.ts"; -import { defaultTheme } from "#/features/theme/themes/default-theme"; -import type { ToolkitThemeDefinition } from "#/features/theme/types.ts"; -import { GAME_CONFIG as CLAIROBSCUR_CONFIG } from "#/games/clairobscur/core/game-config"; -import { GAME_CONFIG as REMNANT2_CONFIG } from "#/games/remnant2/core/game-config"; -import { GAME_CONFIG as SLAYTHESPIRE2_CONFIG } from "#/games/slaythespire2/core/game-config"; -import type { GameId } from "@/prisma"; - -// Widened type for runtime-keyed access (base AppItem, string category) -export type AnyGameConfig = GameConfig; - -export type RegistryGameId = keyof typeof GAME_REGISTRY; - -// The registry — keys are the exact gameId strings -export const GAME_REGISTRY = { - clairobscur: CLAIROBSCUR_CONFIG, - remnant2: REMNANT2_CONFIG, - slaythespire2: SLAYTHESPIRE2_CONFIG, -} satisfies Record, AnyGameConfig>; - -export const REGISTERED_GAME_IDS: readonly RegistryGameId[] = Object.keys( - GAME_REGISTRY, -) as RegistryGameId[]; - -/** Type guard */ -export const isRegisteredGameId = (id: string): id is RegistryGameId => - id in GAME_REGISTRY; - -export const getValidatedGameId = (id: string): GameId | undefined => - id in GAME_REGISTRY ? (id as GameId) : undefined; - -export const getGameConfig = (gameId: string): AnyGameConfig | undefined => - GAME_REGISTRY[gameId as RegistryGameId]; - -export const getGameItems = ( - gameId: string, -): AnyGameConfig["ITEMS"] | undefined => - GAME_REGISTRY[gameId as RegistryGameId]?.ITEMS as - | AnyGameConfig["ITEMS"] - | undefined; - -export const getGameLogoComponent = ( - gameId: string, -): ComponentType<{ size?: LogoSize }> | undefined => - GAME_REGISTRY[gameId as RegistryGameId]?.METADATA?.LogoComponent; - -export const getGameTheme = ( - gameId: string, -): ToolkitThemeDefinition | undefined => - GAME_REGISTRY[gameId as RegistryGameId]?.THEME; - -export const getGameMetadata = ( - gameId: string, -): AnyGameConfig["METADATA"] | undefined => - GAME_REGISTRY[gameId as RegistryGameId]?.METADATA; - -export const getGamePages = ( - gameId: string, -): AnyGameConfig["PAGES"] | undefined => - GAME_REGISTRY[gameId as RegistryGameId]?.PAGES; - -export const getGameAvatars = (gameId: string): GameAvatar[] | undefined => - (GAME_REGISTRY[gameId as RegistryGameId] as AnyGameConfig | undefined) - ?.AVATARS; - -// Return an array of all THEME defintions across registered games (for validation, theme switcher dropdowns, etc.) -export const getAllRegisteredThemeDefinitions = - (): ToolkitThemeDefinition[] => { - const definitions: ToolkitThemeDefinition[] = [ - { - label: "Default Light", - className: "default-light", - theme: defaultTheme, - }, - { - label: "Default Dark", - className: "default-dark", - theme: defaultTheme, - }, - ]; - - for (const gameId of REGISTERED_GAME_IDS) { - const theme = getGameTheme(gameId); - if (theme) { - definitions.push({ - label: `${theme.label} - Light`, - className: `${theme.className}-light`, - theme: theme.theme, - }); - definitions.push({ - label: `${theme.label} - Dark`, - className: `${theme.className}-dark`, - theme: theme.theme, - }); - } - } - return definitions; - }; - -export const getAllRegisteredThemeClassNames = (): string[] => - getAllRegisteredThemeDefinitions() - .map((def) => def.className) - .sort(); diff --git a/src/features/game/registry/game-sync-handler-registry.ts b/src/features/game/registry/game-sync-handler-registry.ts deleted file mode 100644 index 36d0198..0000000 --- a/src/features/game/registry/game-sync-handler-registry.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { SyncHandler } from "#/features/dal/types.ts"; -import { collectedItemSyncHandler as clairObscurCollectedItemSyncHandler } from "#/games/clairobscur/dal/server/sync-handler"; -import { collectedItemSyncHandler as remnant2CollectedItemSyncHandler } from "#/games/remnant2/dal/server/sync-handler"; -import { collectedItemSyncHandler as slayTheSpire2CollectedItemSyncHandler } from "#/games/slaythespire2/dal/server/sync-handler"; - -// When adding a new game, register its collectedItemSyncHandler here. -export const gameSyncHandlers: Record = { - clairObscurCollectedItem: clairObscurCollectedItemSyncHandler, - remnant2CollectedItem: remnant2CollectedItemSyncHandler, - slayTheSpire2CollectedItem: slayTheSpire2CollectedItemSyncHandler, -}; diff --git a/src/features/game/types.ts b/src/features/game/types.ts index 770c3c0..b28c12d 100644 --- a/src/features/game/types.ts +++ b/src/features/game/types.ts @@ -1,10 +1,20 @@ +import type { SingleParserBuilder } from "nuqs"; import type { createSearchParamsCache } from "nuqs/server"; import type { ComponentType, ReactNode } from "react"; import type { LogoSize } from "#/components/AppLogo.tsx"; +import type { + GameCollectedItemsData, + GameCreatedBuildsData, +} from "#/features/game/data/types.ts"; import type { ToolkitThemeDefinition } from "#/features/theme/types.ts"; import type { GameId } from "@/prisma"; -import type {SingleParserBuilder} from "nuqs"; -import type {GameCollectedItemsDal} from "#/features/game/dal/types.ts"; + +type GameFilterDef = { + key: string; + label: string; + defaultValue: string; + formatValue?: (raw: string) => string; +}; export type AppItemTag = { token: string; @@ -68,16 +78,12 @@ export type AppItem< internalSlug?: string; }; -export type CollectedItemsViewMode = +/** Whether a profile tab is viewed by its owner or by another user. */ +export type ProfileTabViewMode = | { kind: "self" } | { kind: "public"; userId: string }; -type GameFilterDef = { - key: string; - label: string; - defaultValue: string; - formatValue?: (raw: string) => string; -}; +export type CollectedItemsViewMode = ProfileTabViewMode; export type GameFilterConfig = { label: string; @@ -110,7 +116,11 @@ export type GameDBSeed = { seed: () => Promise; }; -export type GameDal = { collectedItems: GameCollectedItemsDal }; +export type GameData = { + collectedItems: GameCollectedItemsData; + /** Optional — only games with a build table expose created-builds hooks. */ + createdBuilds?: GameCreatedBuildsData; +}; export type GameMetadata = { id: GameId; @@ -134,6 +144,11 @@ export type GamePages = { renderItemLookup: () => ReactNode; renderCollectedItems: (args: { mode: CollectedItemsViewMode }) => ReactNode; + /** + * Lists a user's created builds for the profile tab. Optional — only games with + * a build table provide it; the route falls back to a placeholder otherwise. + */ + renderCreatedBuilds?: (args: { mode: ProfileTabViewMode }) => ReactNode; }; export type GameConfig< @@ -152,5 +167,8 @@ export type GameConfig< SEARCH_PARAMS: ReturnType | undefined; THEME: ToolkitThemeDefinition | undefined; AVATARS?: GameAvatar[]; - DAL: GameDal; + data: GameData; }; + +// Widened type for runtime-keyed access (base AppItem, string category) +export type AnyGameConfig = GameConfig; diff --git a/src/features/game/utils.ts b/src/features/game/utils.ts index f9054a6..ba670d1 100644 --- a/src/features/game/utils.ts +++ b/src/features/game/utils.ts @@ -1,37 +1,3 @@ -import { isRegisteredGameId } from "#/features/game/registry/game-registry.tsx"; -import type { GameId } from "@/prisma"; - -const ROOT_DOMAINS = ["toolkits.gg", "www.toolkits.gg", "localhost"]; - -export const parseSubdomain = (hostname: string): GameId | null => { - // Strip port (e.g. localhost:3000) - const host = hostname.split(":")[0]; - - // No subdomain possible on bare localhost - if (host === "localhost") return null; - - // Check against known root domains - for (const root of ROOT_DOMAINS) { - if (host === root) return null; - } - - // e.g. "remnant2.toolkits.gg" -> ["remnant2", "toolkits", "gg"] - const parts = host.split("."); - - // Need at least 3 parts for a subdomain: [sub, domain, tld] - if (parts.length < 3) return null; - - const subdomain = parts[0]; - - // Reject "www" explicitly in case it slips through - if (subdomain === "www") return null; - - // Reject invalid subdomains - if (!isRegisteredGameId(subdomain)) return null; - - return subdomain; -}; - /** * Writes the active-game preference cookie from the client. * Read on the server in beforeLoad to seed the gameId resolution chain. @@ -45,24 +11,3 @@ export const setActiveGameCookie = (value: string | null): void => { // biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API isn't supported in Safari/Firefox. document.cookie = `${base}${maxAge}${secure}`; }; - -/** - * Tiny cookie-header parser for server-side reads. Returns the decoded value of - * the named cookie or null if absent. Format: "a=1; b=2; c=3". - */ -export const parseCookie = (header: string, name: string): string | null => { - const parts = header.split(/;\s*/); - for (const part of parts) { - const eq = part.indexOf("="); - if (eq === -1) continue; - const k = part.slice(0, eq); - if (k !== name) continue; - const v = part.slice(eq + 1); - try { - return decodeURIComponent(v); - } catch { - return v; - } - } - return null; -}; diff --git a/src/features/sync/__tests__/record-sync-handler.test.ts b/src/features/sync/__tests__/record-sync-handler.test.ts new file mode 100644 index 0000000..2260d69 --- /dev/null +++ b/src/features/sync/__tests__/record-sync-handler.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PendingOp } from "#/features/sync/queue/types.ts"; +import { createRecordSyncHandler } from "#/features/sync/record-sync-handler.ts"; + +const T1 = "2026-01-01T00:00:00.000Z"; +const T2 = "2026-01-02T00:00:00.000Z"; +const T3 = "2026-01-03T00:00:00.000Z"; + +const makeOp = (over: Partial): PendingOp => ({ + id: "op1", + createdAt: T2, + updatedAt: T2, + anonUserId: "anon", + entity: "thing", + operation: "upsert", + payload: { id: "r1", name: "Local" }, + idempotencyKey: "k", + status: "pending", + serverUpdatedAt: T1, + ...over, +}); + +const makeDeps = (record: { updatedAt: string } | null) => ({ + resolveKey: (op: { payload: unknown }) => ({ + ok: true as const, + key: (op.payload as { id: string }).id, + }), + findRecord: vi.fn(async () => record), + createRecord: vi.fn(async () => {}), + updateRecord: vi.fn(async () => {}), + deleteRecord: vi.fn(async () => {}), +}); + +describe("createRecordSyncHandler", () => { + it("creates when no server record exists", async () => { + const deps = makeDeps(null); + const handler = createRecordSyncHandler(deps); + const result = await handler(makeOp({ operation: "create" }), "u1"); + expect(result).toEqual({ status: "applied" }); + expect(deps.createRecord).toHaveBeenCalledWith("u1", "r1", { + id: "r1", + name: "Local", + }); + expect(deps.updateRecord).not.toHaveBeenCalled(); + }); + + it("updates the record from payload when local wins", async () => { + const deps = makeDeps({ updatedAt: T1 }); // server == baseline -> equal? no: baseline T1, server T1 => equal + const handler = createRecordSyncHandler(deps); + // Make server behind baseline so it resolves to local-wins -> update. + const result = await handler( + makeOp({ operation: "update", serverUpdatedAt: T2 }), + "u1", + ); + expect(result).toEqual({ status: "applied" }); + expect(deps.updateRecord).toHaveBeenCalledWith("u1", "r1", { + id: "r1", + name: "Local", + }); + }); + + it("noops an update when the server matches the baseline (already synced)", async () => { + const deps = makeDeps({ updatedAt: T1 }); + const handler = createRecordSyncHandler(deps); + const result = await handler( + makeOp({ operation: "upsert", serverUpdatedAt: T1 }), + "u1", + ); + expect(result).toEqual({ status: "noop" }); + expect(deps.updateRecord).not.toHaveBeenCalled(); + }); + + it("reports a conflict when the server advanced past the baseline", async () => { + const deps = makeDeps({ updatedAt: T3 }); + const handler = createRecordSyncHandler(deps); + const result = await handler( + makeOp({ operation: "update", serverUpdatedAt: T1 }), + "u1", + ); + expect(result.status).toBe("conflict"); + if (result.status === "conflict") { + expect(JSON.parse(result.serverRecordJson)).toEqual({ updatedAt: T3 }); + } + expect(deps.updateRecord).not.toHaveBeenCalled(); + }); + + it("force-applies an update despite a server conflict", async () => { + const deps = makeDeps({ updatedAt: T3 }); + const handler = createRecordSyncHandler(deps); + const result = await handler( + makeOp({ operation: "update", serverUpdatedAt: T1 }), + "u1", + { force: true }, + ); + expect(result).toEqual({ status: "applied" }); + expect(deps.updateRecord).toHaveBeenCalled(); + }); + + it("noops a delete when the record is already absent", async () => { + const deps = makeDeps(null); + const handler = createRecordSyncHandler(deps); + const result = await handler(makeOp({ operation: "delete" }), "u1"); + expect(result).toEqual({ status: "noop" }); + expect(deps.deleteRecord).not.toHaveBeenCalled(); + }); + + it("deletes when present and local wins", async () => { + const deps = makeDeps({ updatedAt: T1 }); + const handler = createRecordSyncHandler(deps); + const result = await handler( + makeOp({ operation: "delete", serverUpdatedAt: T1 }), + "u1", + ); + expect(result).toEqual({ status: "applied" }); + expect(deps.deleteRecord).toHaveBeenCalledWith("u1", "r1"); + }); + + it("conflicts on delete when the server advanced past the baseline", async () => { + const deps = makeDeps({ updatedAt: T3 }); + const handler = createRecordSyncHandler(deps); + const result = await handler( + makeOp({ operation: "delete", serverUpdatedAt: T1 }), + "u1", + ); + expect(result.status).toBe("conflict"); + expect(deps.deleteRecord).not.toHaveBeenCalled(); + }); + + it("returns an error when the key cannot be resolved", async () => { + const deps = makeDeps(null); + const handler = createRecordSyncHandler({ + ...deps, + resolveKey: () => ({ ok: false, message: "missing id" }), + }); + const result = await handler(makeOp({}), "u1"); + expect(result).toEqual({ status: "error", message: "missing id" }); + }); +}); diff --git a/src/features/dal/queue/apply-pending-ops.ts b/src/features/sync/apply-pending-ops.ts similarity index 80% rename from src/features/dal/queue/apply-pending-ops.ts rename to src/features/sync/apply-pending-ops.ts index 0365a99..e0211d1 100644 --- a/src/features/dal/queue/apply-pending-ops.ts +++ b/src/features/sync/apply-pending-ops.ts @@ -2,14 +2,9 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireUserId } from "#/features/auth/require-user.server.ts"; -import type { SyncHandler, SyncResult } from "#/features/dal/types.ts"; -import { favoriteGameSyncHandler } from "#/features/game/dal/favorite-games/sync-handler.ts"; -import { - userAvatarOverrideHandler, - userProfileHandler, -} from "#/features/game/dal/user-profile/sync-handler.ts"; -import { gameSyncHandlers } from "#/features/game/registry/game-sync-handler-registry.ts"; +import { syncHandlers } from "#/features/sync/handler-registry.ts"; +import type { SyncResult } from "#/features/sync/types.ts"; +import { requireUserId } from "#/features/user/require-user.server.ts"; /** Re-validates the PendingOp at the server boundary; client data is untrusted. */ const PendingOpSchema = z.object({ @@ -62,13 +57,6 @@ const recallResult = (key: string): SyncResult | null => { return entry.result; }; -const handlers: Record = { - ...gameSyncHandlers, - userFavoriteGame: favoriteGameSyncHandler, - userAvatarOverride: userAvatarOverrideHandler, - userProfile: userProfileHandler, -}; - const applyPendingOpServerFn = createServerFn({ method: "POST" }) .inputValidator((v: unknown) => ApplyPendingOpInputSchema.parse(v)) .handler(async ({ data }) => { @@ -83,7 +71,7 @@ const applyPendingOpServerFn = createServerFn({ method: "POST" }) if (cached) return cached; } - const handler = handlers[op.entity]; + const handler = syncHandlers[op.entity]; if (!handler) { return { status: "error", message: `no sync handler for ${op.entity}` }; } diff --git a/src/features/sync/handler-registry.ts b/src/features/sync/handler-registry.ts new file mode 100644 index 0000000..b10c4b5 --- /dev/null +++ b/src/features/sync/handler-registry.ts @@ -0,0 +1,29 @@ +// Explicit registry of offline-sync handlers, keyed by entity name. +// +// Each entity's handler is imported and listed here by hand — no auto-discovery. +// To add a new syncable entity: export its SyncHandler, then add one line below. +// applyPendingOpServerFn looks up the handler for an op's `entity` here. + +import { favoriteGameSyncHandler } from "#/features/game/data/favorite-games/favorite-games.ts"; +import { + userAvatarOverrideHandler, + userProfileHandler, +} from "#/features/game/data/user-profile/user-profile.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; +import { clairObscurCollectedItemHandler } from "#/games/clairobscur/data/server/collected-items.ts"; +import { remnant2CollectedItemHandler } from "#/games/remnant2/data/server/collected-items.ts"; +import { remnant2BuildHandler } from "#/games/remnant2/data/server/created-builds.ts"; +import { slayTheSpire2CollectedItemHandler } from "#/games/slaythespire2/data/server/collected-items.ts"; + +/** entity name -> SyncHandler. The `entity` field on each PendingOp indexes this. */ +const syncHandlers: Record = { + remnant2CollectedItem: remnant2CollectedItemHandler, + clairObscurCollectedItem: clairObscurCollectedItemHandler, + slayTheSpire2CollectedItem: slayTheSpire2CollectedItemHandler, + remnant2Build: remnant2BuildHandler, + userFavoriteGame: favoriteGameSyncHandler, + userProfile: userProfileHandler, + userAvatarOverride: userAvatarOverrideHandler, +}; + +export { syncHandlers }; diff --git a/src/features/dal/identity/anon-id.ts b/src/features/sync/identity/anon-id.ts similarity index 89% rename from src/features/dal/identity/anon-id.ts rename to src/features/sync/identity/anon-id.ts index 94eba1e..6eb53c1 100644 --- a/src/features/dal/identity/anon-id.ts +++ b/src/features/sync/identity/anon-id.ts @@ -1,4 +1,4 @@ -// Persistent anonymous user ID for pre-auth data collection. +// Persistent anonymous user ID for pre-user data collection. // Allows users to collect items before signing up; the ID travels with pending ops // so the server can attribute offline writes to the correct user after login. @@ -29,4 +29,4 @@ const clearAnonUserId = (): void => { window.localStorage.removeItem(STORAGE_KEY); }; -export { getOrCreateAnonUserId, clearAnonUserId }; +export { clearAnonUserId, getOrCreateAnonUserId }; diff --git a/src/features/dal/identity/use-effective-user-id.ts b/src/features/sync/identity/use-effective-user-id.ts similarity index 85% rename from src/features/dal/identity/use-effective-user-id.ts rename to src/features/sync/identity/use-effective-user-id.ts index eadd73c..4c2cb12 100644 --- a/src/features/dal/identity/use-effective-user-id.ts +++ b/src/features/sync/identity/use-effective-user-id.ts @@ -1,13 +1,13 @@ -// Resolves the active user identity across auth and anon states. +// Resolves the active user identity across user and anon states. import { useEffect, useState } from "react"; -import { getOrCreateAnonUserId } from "#/features/dal/identity/anon-id"; +import { getOrCreateAnonUserId } from "#/features/sync/identity/anon-id"; import { useSession } from "#/integrations/better-auth/auth-client"; type EffectiveUserId = { id: string; /** - * - `"auth"` — user is logged in; id is the Better Auth user ID. + * - `"user"` — user is logged in; id is the Better Auth user ID. * - `"anon"` — user is not logged in but has a persistent anon ID from localStorage. * - `"none"` — transitional state on first render before the anon ID is initialized (SSR). */ diff --git a/src/features/dal/queue/last-write-wins.ts b/src/features/sync/last-write-wins.ts similarity index 100% rename from src/features/dal/queue/last-write-wins.ts rename to src/features/sync/last-write-wins.ts diff --git a/src/features/dal/local/constants.ts b/src/features/sync/local/constants.ts similarity index 100% rename from src/features/dal/local/constants.ts rename to src/features/sync/local/constants.ts index 7ca985c..5f0385b 100644 --- a/src/features/dal/local/constants.ts +++ b/src/features/sync/local/constants.ts @@ -8,7 +8,7 @@ const STORE_USER_FAVORITE_GAME = "userFavoriteGame"; const STORE_USER_AVATAR_OVERRIDE = "userAvatarOverride"; export { - STORE_USER_PROFILE, - STORE_USER_FAVORITE_GAME, STORE_USER_AVATAR_OVERRIDE, + STORE_USER_FAVORITE_GAME, + STORE_USER_PROFILE, }; diff --git a/src/features/dal/local/local-db.ts b/src/features/sync/local/local-db.ts similarity index 96% rename from src/features/dal/local/local-db.ts rename to src/features/sync/local/local-db.ts index 932a7f1..481957c 100644 --- a/src/features/dal/local/local-db.ts +++ b/src/features/sync/local/local-db.ts @@ -3,12 +3,12 @@ import { STORE_USER_AVATAR_OVERRIDE, STORE_USER_FAVORITE_GAME, STORE_USER_PROFILE, -} from "#/features/dal/local/constants"; +} from "#/features/sync/local/constants"; import type { LocalUserAvatarOverride, LocalUserFavoriteGame, LocalUserProfile, -} from "#/features/dal/local/types"; +} from "#/features/sync/local/types"; const DB_NAME = "toolkitsgg-local"; /** Increment whenever the schema changes; migrations run in the `upgrade` callback on next open. */ @@ -102,4 +102,4 @@ const _resetLocalDBForTests = async (): Promise => { }); }; -export { getLocalDB, _resetLocalDBForTests }; +export { _resetLocalDBForTests, getLocalDB }; diff --git a/src/features/dal/local/types.ts b/src/features/sync/local/types.ts similarity index 100% rename from src/features/dal/local/types.ts rename to src/features/sync/local/types.ts index 7595424..ea7effa 100644 --- a/src/features/dal/local/types.ts +++ b/src/features/sync/local/types.ts @@ -34,7 +34,7 @@ interface LocalUserFavoriteGame { } export type { - LocalUserProfile, - LocalUserFavoriteGame, LocalUserAvatarOverride, + LocalUserFavoriteGame, + LocalUserProfile, }; diff --git a/src/features/dal/presence-sync-handler.ts b/src/features/sync/presence-sync-handler.ts similarity index 92% rename from src/features/dal/presence-sync-handler.ts rename to src/features/sync/presence-sync-handler.ts index 71a49f0..69dfca0 100644 --- a/src/features/dal/presence-sync-handler.ts +++ b/src/features/sync/presence-sync-handler.ts @@ -7,9 +7,9 @@ import { compareTimestamps, type HasUpdatedAt, -} from "#/features/dal/queue/last-write-wins.ts"; -import type { PendingOp } from "#/features/dal/queue/types.ts"; -import type { SyncHandler } from "#/features/dal/types.ts"; +} from "#/features/sync/last-write-wins.ts"; +import type { PendingOp } from "#/features/sync/queue/types.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; /** Result of pulling a record key out of an op payload. */ type KeyResolution = @@ -77,5 +77,5 @@ const createPresenceToggleSyncHandler = ( }; }; -export type { PresenceToggleDeps }; +export type { KeyResolution, PresenceToggleDeps }; export { createPresenceToggleSyncHandler }; diff --git a/src/features/dal/queue/__tests__/lww.test.ts b/src/features/sync/queue/__tests__/lww.test.ts similarity index 94% rename from src/features/dal/queue/__tests__/lww.test.ts rename to src/features/sync/queue/__tests__/lww.test.ts index 0487a56..bcd5bbe 100644 --- a/src/features/dal/queue/__tests__/lww.test.ts +++ b/src/features/sync/queue/__tests__/lww.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { compareTimestamps } from "#/features/dal/queue/last-write-wins"; +import { compareTimestamps } from "#/features/sync/last-write-wins"; const T1 = "2026-01-01T00:00:00.000Z"; const T2 = "2026-01-02T00:00:00.000Z"; diff --git a/src/features/dal/queue/__tests__/pending-ops.test.ts b/src/features/sync/queue/__tests__/pending-ops.test.ts similarity index 98% rename from src/features/dal/queue/__tests__/pending-ops.test.ts rename to src/features/sync/queue/__tests__/pending-ops.test.ts index b426ae4..cc0119a 100644 --- a/src/features/dal/queue/__tests__/pending-ops.test.ts +++ b/src/features/sync/queue/__tests__/pending-ops.test.ts @@ -9,7 +9,7 @@ import { getOp, listOps, markStatus, -} from "#/features/dal/queue/pending-ops"; +} from "#/features/sync/queue/pending-ops"; const BASE = { anonUserId: "anon-1", diff --git a/src/features/dal/queue/pending-ops.ts b/src/features/sync/queue/pending-ops.ts similarity index 99% rename from src/features/dal/queue/pending-ops.ts rename to src/features/sync/queue/pending-ops.ts index 1fed9f3..d2035b9 100644 --- a/src/features/dal/queue/pending-ops.ts +++ b/src/features/sync/queue/pending-ops.ts @@ -3,7 +3,7 @@ import type { ListOpsFilter, PendingOp, PendingOpStatus, -} from "#/features/dal/queue/types"; +} from "#/features/sync/queue/types"; const DB_NAME = "toolkitsgg-pending-ops"; const DB_VERSION = 2; @@ -225,12 +225,12 @@ const _resetForTests = async (): Promise => { }; export { + _resetForTests, + clearSynced, + deleteOp, enqueueOp, - listOps, getOp, - markStatus, + listOps, markConflict, - deleteOp, - clearSynced, - _resetForTests, + markStatus, }; diff --git a/src/features/dal/queue/types.ts b/src/features/sync/queue/types.ts similarity index 100% rename from src/features/dal/queue/types.ts rename to src/features/sync/queue/types.ts index 29cedc5..2b0c428 100644 --- a/src/features/dal/queue/types.ts +++ b/src/features/sync/queue/types.ts @@ -75,10 +75,10 @@ interface ListOpsFilter { } export type { + ListOpsFilter, PendingOp, - PendingOpStatus, + PendingOpConflictInfo, PendingOpOperation, + PendingOpStatus, PendingOpSummary, - PendingOpConflictInfo, - ListOpsFilter, }; diff --git a/src/features/dal/queue/use-pending-ops.ts b/src/features/sync/queue/use-pending-ops.ts similarity index 69% rename from src/features/dal/queue/use-pending-ops.ts rename to src/features/sync/queue/use-pending-ops.ts index 22c1340..1c46f2d 100644 --- a/src/features/dal/queue/use-pending-ops.ts +++ b/src/features/sync/queue/use-pending-ops.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { listOps } from "#/features/dal/queue/pending-ops"; -import type { ListOpsFilter, PendingOp } from "#/features/dal/queue/types"; +import { listOps } from "#/features/sync/queue/pending-ops"; +import type { ListOpsFilter, PendingOp } from "#/features/sync/queue/types"; /** * Returns pending ops from IndexedDB, optionally filtered by status or entity. @@ -9,7 +9,7 @@ import type { ListOpsFilter, PendingOp } from "#/features/dal/queue/types"; */ const usePendingOps = (filter?: ListOpsFilter) => { return useQuery({ - queryKey: ["dal-queue", filter?.status ?? "all", filter?.entity ?? "all"], + queryKey: ["sync-queue", filter?.status ?? "all", filter?.entity ?? "all"], queryFn: () => listOps(filter), refetchOnWindowFocus: false, }); diff --git a/src/features/sync/record-sync-handler.ts b/src/features/sync/record-sync-handler.ts new file mode 100644 index 0000000..2d05a76 --- /dev/null +++ b/src/features/sync/record-sync-handler.ts @@ -0,0 +1,85 @@ +// Shared SyncHandler factory for "content-record" entities — rows with mutable +// fields beyond mere existence (e.g. builds, profiles). The create/update/delete +// branching and last-write-wins conflict resolution live here once, so individual +// entities only supply how to extract their key from the op payload and how to +// read/create/update/delete the row. +// +// Sibling of presence-sync-handler.ts: presence-toggle rows have no mutable fields, +// so an upsert onto an existing row is a noop. Content records instead overwrite +// the row from the op payload on update. + +import { + compareTimestamps, + type HasUpdatedAt, +} from "#/features/sync/last-write-wins.ts"; +import type { KeyResolution } from "#/features/sync/presence-sync-handler.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; + +export interface RecordSyncDeps { + /** Pulls the record's key fields from the op payload (validating/coercing as needed). */ + resolveKey: (op: { payload: unknown }) => KeyResolution; + /** Reads the current server record (must expose `updatedAt` for LWW) or null if absent. */ + findRecord: (userId: string, key: TKey) => Promise; + /** Inserts the row from the op payload. Only called when no record currently exists. */ + createRecord: (userId: string, key: TKey, payload: unknown) => Promise; + /** Overwrites the existing row's mutable fields from the op payload. */ + updateRecord: (userId: string, key: TKey, payload: unknown) => Promise; + /** Deletes the row. Must be a no-op if the row is already absent. */ + deleteRecord: (userId: string, key: TKey) => Promise; +} + +/** + * Builds a SyncHandler for a content-record entity. + * + * Delete op: + * - record absent -> noop (desired end state already reached) + * - record present -> LWW check (server newer = conflict, unless `force`), then delete + * + * Create / update / upsert op: + * - record absent -> create from payload + * - record present -> LWW check (unless `force`): server newer = conflict, equal = noop + * (already synced), otherwise overwrite from payload + */ +export const createRecordSyncHandler = ( + deps: RecordSyncDeps, +): SyncHandler => { + return async (op, userId, options) => { + const resolved = deps.resolveKey(op); + if (!resolved.ok) return { status: "error", message: resolved.message }; + const { key } = resolved; + + const record = await deps.findRecord(userId, key); + const force = options?.force ?? false; + + if (op.operation === "delete") { + if (!record) return { status: "noop" }; + if (!force) { + const cmp = compareTimestamps(record, op); + if (cmp === "server-wins") + return { + status: "conflict", + serverRecordJson: JSON.stringify(record), + }; + } + await deps.deleteRecord(userId, key); + return { status: "applied" }; + } + + if (record) { + if (!force) { + const cmp = compareTimestamps(record, op); + if (cmp === "server-wins") + return { + status: "conflict", + serverRecordJson: JSON.stringify(record), + }; + if (cmp === "equal") return { status: "noop" }; + } + await deps.updateRecord(userId, key, op.payload); + return { status: "applied" }; + } + + await deps.createRecord(userId, key, op.payload); + return { status: "applied" }; + }; +}; diff --git a/src/features/dal/queue/sync-runner.ts b/src/features/sync/sync-runner.ts similarity index 91% rename from src/features/dal/queue/sync-runner.ts rename to src/features/sync/sync-runner.ts index e9c7857..b290c96 100644 --- a/src/features/dal/queue/sync-runner.ts +++ b/src/features/sync/sync-runner.ts @@ -1,13 +1,13 @@ // Orchestrates syncing a batch of pending ops to the server sequentially. -import { applyPendingOpServerFn } from "#/features/dal/queue/apply-pending-ops.ts"; +import { applyPendingOpServerFn } from "#/features/sync/apply-pending-ops.ts"; import { deleteOp, markConflict, markStatus, -} from "#/features/dal/queue/pending-ops"; -import type { PendingOp } from "#/features/dal/queue/types"; -import type { SyncResult } from "#/features/dal/types.ts"; +} from "#/features/sync/queue/pending-ops"; +import type { PendingOp } from "#/features/sync/queue/types"; +import type { SyncResult } from "#/features/sync/types.ts"; interface SyncAllOptions { /** Called before each op is processed — use to drive UI progress indicators. */ diff --git a/src/features/sync/types.ts b/src/features/sync/types.ts new file mode 100644 index 0000000..61bd486 --- /dev/null +++ b/src/features/sync/types.ts @@ -0,0 +1,38 @@ +// Core types shared across the offline-sync machinery. + +import type { PendingOp } from "#/features/sync/queue/types.ts"; + +/** + * Result returned by a SyncHandler after attempting to apply a pending op. + * - `applied` — op was written to the server; caller marks the op "synced". + * - `conflict` — server record is newer; caller marks the op "conflict" and surfaces the server record. + * - `noop` — op is redundant (already applied); caller deletes the op. + * - `error` — handler threw or returned an error; caller marks the op "failed". + */ +type SyncResult = + | { status: "applied" } + | { status: "conflict"; serverRecordJson: string } + | { status: "noop" } + | { status: "error"; message: string }; + +/** + * Options forwarded from the sync runner / caller to a SyncHandler. + * Used today only to opt out of LWW conflict checks when the user explicitly + * chose "Keep mine" on a previously-conflicted op. + */ +interface SyncOptions { + /** When true, the handler skips its LWW check and applies the op unconditionally. */ + force?: boolean; +} + +/** + * Server-side function that applies a single pending op for an entity. + * `userId` is the authenticated user — always resolved before the handler is called. + */ +type SyncHandler = ( + op: PendingOp, + userId: string, + options?: SyncOptions, +) => Promise; + +export type { SyncHandler, SyncOptions, SyncResult }; diff --git a/src/features/theme/SyncAndApplyTheme.ts b/src/features/theme/SyncAndApplyTheme.ts index 3de4d11..efadaf2 100644 --- a/src/features/theme/SyncAndApplyTheme.ts +++ b/src/features/theme/SyncAndApplyTheme.ts @@ -1,14 +1,12 @@ import type { MantineThemeOverride } from "@mantine/core"; import { useTheme as useNextTheme } from "next-themes"; import { useEffect } from "react"; -import { - getAllRegisteredThemeDefinitions, - getGameTheme, -} from "#/features/game/registry/game-registry.tsx"; +import { getGameTheme } from "#/features/game/registry/game-public-registry.tsx"; import { useGameId } from "#/features/game/use-game-id.ts"; import { LOCALSTORAGE_KEYS } from "#/features/theme/constants.ts"; import { changeMantineTheme } from "#/features/theme/store.ts"; import { defaultTheme } from "#/features/theme/themes/default-theme.ts"; +import { getAllRegisteredThemeDefinitions } from "#/features/theme/utils.ts"; /** * This function determines which Mantine theme to use based on the provided nextTheme class string. diff --git a/src/features/theme/ThemeModal.tsx b/src/features/theme/ThemeModal.tsx index 6f0ac68..d251aaf 100644 --- a/src/features/theme/ThemeModal.tsx +++ b/src/features/theme/ThemeModal.tsx @@ -8,16 +8,16 @@ import { import { upperFirst, useLocalStorage } from "@mantine/hooks"; import { useTheme as useNextTheme } from "next-themes"; import { type ChangeEvent, useState } from "react"; -import { - getAllRegisteredThemeClassNames, - getAllRegisteredThemeDefinitions, - getGameTheme, -} from "#/features/game/registry/game-registry.tsx"; +import { getGameTheme } from "#/features/game/registry/game-public-registry.tsx"; import { LOCALSTORAGE_KEYS, MANTINE_COLOR_SCHEMES, } from "#/features/theme/constants.ts"; -import { parseColorScheme } from "#/features/theme/utils.ts"; +import { + getAllRegisteredThemeClassNames, + getAllRegisteredThemeDefinitions, + parseColorScheme, +} from "#/features/theme/utils.ts"; // This feature was game-aware, need to rework it const allThemeDefinitions: Array<{ label: string; className: string }> = diff --git a/src/features/theme/utils.ts b/src/features/theme/utils.ts index 25c9a0e..f632c6e 100644 --- a/src/features/theme/utils.ts +++ b/src/features/theme/utils.ts @@ -1,21 +1,16 @@ import { type MantineColorsTuple, virtualColor } from "@mantine/core"; +import { + getGameTheme, + REGISTERED_GAME_IDS, +} from "#/features/game/registry/game-public-registry.tsx"; +import { defaultTheme } from "#/features/theme/themes/default-theme"; import type { ColorVariants, ToolkitThemeColorKey, ToolkitThemeColors, + ToolkitThemeDefinition, } from "#/features/theme/types.ts"; -/** - * Input type for creating a theme color with all its variants. - * Includes color tuples for dark and light modes, both background and foreground. - */ -type ThemeColorInput = { - dark: MantineColorsTuple; - light: MantineColorsTuple; - fgDark: MantineColorsTuple; - fgLight: MantineColorsTuple; -}; - /** * Creates a complete set of color variants for a theme color. * @@ -67,6 +62,17 @@ function createThemeColor( } as Pick>; } +/** + * Input type for creating a theme color with all its variants. + * Includes color tuples for dark and light modes, both background and foreground. + */ +export type ThemeColorInput = { + dark: MantineColorsTuple; + light: MantineColorsTuple; + fgDark: MantineColorsTuple; + fgLight: MantineColorsTuple; +}; + /** * Creates theme colors from an object of color definitions. * This is a convenience function for creating multiple colors at once. @@ -92,7 +98,7 @@ function createThemeColor( * }); * ``` */ -function createThemeColors( +export function createThemeColors( colorDefinitions: Record, ): Record { const result: Record = {}; @@ -112,9 +118,46 @@ function createThemeColors( * @param nextTheme - The current Next.js theme string * @returns The parsed color scheme ('light' or 'dark') */ -const parseColorScheme = (nextTheme: string | undefined) => { +export const parseColorScheme = (nextTheme: string | undefined) => { if (!nextTheme) return "dark"; return nextTheme.includes("-light") ? "light" : "dark"; }; -export { createThemeColors, parseColorScheme, type ThemeColorInput }; +// Return an array of all THEME defintions across registered games (for validation, theme switcher dropdowns, etc.) +export const getAllRegisteredThemeDefinitions = + (): ToolkitThemeDefinition[] => { + const definitions: ToolkitThemeDefinition[] = [ + { + label: "Default Light", + className: "default-light", + theme: defaultTheme, + }, + { + label: "Default Dark", + className: "default-dark", + theme: defaultTheme, + }, + ]; + + for (const gameId of REGISTERED_GAME_IDS) { + const theme = getGameTheme(gameId); + if (theme) { + definitions.push({ + label: `${theme.label} - Light`, + className: `${theme.className}-light`, + theme: theme.theme, + }); + definitions.push({ + label: `${theme.label} - Dark`, + className: `${theme.className}-dark`, + theme: theme.theme, + }); + } + } + return definitions; + }; + +export const getAllRegisteredThemeClassNames = (): string[] => + getAllRegisteredThemeDefinitions() + .map((def) => def.className) + .sort(); diff --git a/src/features/auth/AvatarPicker.module.css b/src/features/user/AvatarPicker.module.css similarity index 100% rename from src/features/auth/AvatarPicker.module.css rename to src/features/user/AvatarPicker.module.css diff --git a/src/features/auth/AvatarPicker.tsx b/src/features/user/AvatarPicker.tsx similarity index 94% rename from src/features/auth/AvatarPicker.tsx rename to src/features/user/AvatarPicker.tsx index 03c5c6f..093d460 100644 --- a/src/features/auth/AvatarPicker.tsx +++ b/src/features/user/AvatarPicker.tsx @@ -18,18 +18,17 @@ import { import { useDisclosure } from "@mantine/hooks"; import { useState } from "react"; import { LuCheck, LuChevronDown, LuSearch, LuX } from "react-icons/lu"; -import { useUserProfile } from "#/features/auth/use-user-profile.ts"; -import { avatarImageUrl } from "#/features/auth/utils.ts"; -import { useDalQuery } from "#/features/dal/use-dal-query.ts"; -import { createFavoriteGameDal } from "#/features/game/dal/favorite-games/favorite-games.dal.ts"; +import { useFavoriteGames } from "#/features/game/data/favorite-games/use-favorite-games.ts"; import { getGameAvatars, - getGameConfig, getGameLogoComponent, + getGameMetadata, REGISTERED_GAME_IDS, -} from "#/features/game/registry/game-registry.tsx"; +} from "#/features/game/registry/game-public-registry.tsx"; import type { GameAvatar } from "#/features/game/types.ts"; import { useGameId } from "#/features/game/use-game-id.ts"; +import { useUserProfile } from "#/features/user/use-user-profile.ts"; +import { avatarImageUrl } from "#/features/user/utils.ts"; import type { GameId } from "@/prisma"; import classes from "./AvatarPicker.module.css"; @@ -41,10 +40,10 @@ type GameWithAvatars = { const gamesWithAvatars: GameWithAvatars[] = REGISTERED_GAME_IDS.flatMap( (id) => { - const config = getGameConfig(id); const avatars = getGameAvatars(id); - if (!avatars?.length || !config) return []; - return [{ gameId: id as GameId, label: config.METADATA.label, avatars }]; + const metadata = getGameMetadata(id); + if (!avatars?.length || !metadata) return []; + return [{ gameId: id as GameId, label: metadata.label, avatars }]; }, ); @@ -78,7 +77,7 @@ export function AvatarPicker() { const gameId = useGameId(); const { profile, updateAvatar, removePrimaryAvatar, removeAvatarOverride } = useUserProfile(); - const favoritesQuery = useDalQuery(createFavoriteGameDal().list, undefined); + const favoritesQuery = useFavoriteGames(); const favoriteGameIds: GameId[] = (favoritesQuery.data ?? []).map( (f) => f.gameId, ); @@ -341,8 +340,7 @@ export function AvatarPicker() { /> - {getGameConfig(browsingGameId)?.METADATA.label ?? - browsingGameId} + {getGameMetadata(browsingGameId)?.label ?? browsingGameId} {gameSpecificAvatar @@ -352,7 +350,7 @@ export function AvatarPicker() { {gameSpecificAvatar && ( { const [serverError, setServerError] = useState(null); - const mutation = useDalMutation(createUserProfileDal().updateProfile); + const mutation = useUpdateProfile(); const form = useForm({ defaultValues: { diff --git a/src/features/auth/ProfileHeader.module.css b/src/features/user/ProfileHeader.module.css similarity index 100% rename from src/features/auth/ProfileHeader.module.css rename to src/features/user/ProfileHeader.module.css diff --git a/src/features/auth/ProfileHeader.tsx b/src/features/user/ProfileHeader.tsx similarity index 91% rename from src/features/auth/ProfileHeader.tsx rename to src/features/user/ProfileHeader.tsx index 86644a6..357e975 100644 --- a/src/features/auth/ProfileHeader.tsx +++ b/src/features/user/ProfileHeader.tsx @@ -9,10 +9,10 @@ import { } from "@mantine/core"; import { modals } from "@mantine/modals"; import { LuCamera, LuPencil } from "react-icons/lu"; -import { AvatarPicker } from "#/features/auth/AvatarPicker.tsx"; -import { ProfileEditForm } from "#/features/auth/ProfileEditForm.tsx"; -import { useResolvedAvatar } from "#/features/auth/use-resolved-avatar.ts"; -import { useUserProfile } from "#/features/auth/use-user-profile.ts"; +import { AvatarPicker } from "#/features/user/AvatarPicker.tsx"; +import { ProfileEditForm } from "#/features/user/ProfileEditForm.tsx"; +import { useResolvedAvatar } from "#/features/user/use-resolved-avatar.ts"; +import { useUserProfile } from "#/features/user/use-user-profile.ts"; import classes from "./ProfileHeader.module.css"; type ProfileHeaderProps = { diff --git a/src/features/auth/ProfileTabNav.tsx b/src/features/user/ProfileTabNav.tsx similarity index 100% rename from src/features/auth/ProfileTabNav.tsx rename to src/features/user/ProfileTabNav.tsx diff --git a/src/features/user/ProfileTabPlaceholder.tsx b/src/features/user/ProfileTabPlaceholder.tsx new file mode 100644 index 0000000..f5715f2 --- /dev/null +++ b/src/features/user/ProfileTabPlaceholder.tsx @@ -0,0 +1,15 @@ +import { Button, EmptyState } from "@mantine/core"; +import { Link } from "@tanstack/react-router"; + +/** Placeholder body shared by not-yet-implemented profile tabs. */ +const ProfileTabPlaceholder = ({ title }: { title: string }) => ( + + + + + +); + +export { ProfileTabPlaceholder }; diff --git a/src/features/auth/UserMenu.module.css b/src/features/user/UserMenu.module.css similarity index 100% rename from src/features/auth/UserMenu.module.css rename to src/features/user/UserMenu.module.css diff --git a/src/features/auth/UserMenu.tsx b/src/features/user/UserMenu.tsx similarity index 94% rename from src/features/auth/UserMenu.tsx rename to src/features/user/UserMenu.tsx index 3a92841..8eed608 100644 --- a/src/features/auth/UserMenu.tsx +++ b/src/features/user/UserMenu.tsx @@ -21,9 +21,9 @@ import { LuSettings, LuStar, } from "react-icons/lu"; -import { AvatarPicker } from "#/features/auth/AvatarPicker.tsx"; -import { useResolvedAvatar } from "#/features/auth/use-resolved-avatar.ts"; -import { useUserProfile } from "#/features/auth/use-user-profile.ts"; +import { AvatarPicker } from "#/features/user/AvatarPicker.tsx"; +import { useResolvedAvatar } from "#/features/user/use-resolved-avatar.ts"; +import { useUserProfile } from "#/features/user/use-user-profile.ts"; import { signOut } from "#/integrations/better-auth/auth-client.ts"; import classes from "./UserMenu.module.css"; @@ -66,7 +66,7 @@ export function UserMenu() { signOut({ fetchOptions: { onSuccess: async () => { - queryClient.removeQueries({ queryKey: ["dal"] }); + queryClient.removeQueries({ queryKey: ["data"] }); await navigate({ to: "/" }); }, }, diff --git a/src/features/auth/profile-tab-head.ts b/src/features/user/profile-tab-head.ts similarity index 92% rename from src/features/auth/profile-tab-head.ts rename to src/features/user/profile-tab-head.ts index 0d700f0..8ee9316 100644 --- a/src/features/auth/profile-tab-head.ts +++ b/src/features/user/profile-tab-head.ts @@ -3,8 +3,8 @@ import { FALLBACK_DISPLAY_NAME } from "#/constants.ts"; import { buildGetProfileQueryKey, mapUserToProfileData, -} from "#/features/game/dal/user-profile/user-profile.dal.ts"; -import { getPublicUserProfileServerFn } from "#/features/game/dal/user-profile/user-profile.ts"; +} from "#/features/game/data/user-profile/user-profile.ts"; +import { getPublicUserProfileServerFn } from "#/features/game/data/user-profile/user-profile.ts"; // hits the cached profile query populated by the parent profile loader. const loadProfileTabData = async ( diff --git a/src/features/auth/require-user.server.ts b/src/features/user/require-user.server.ts similarity index 100% rename from src/features/auth/require-user.server.ts rename to src/features/user/require-user.server.ts diff --git a/src/features/auth/use-resolved-avatar.ts b/src/features/user/use-resolved-avatar.ts similarity index 64% rename from src/features/auth/use-resolved-avatar.ts rename to src/features/user/use-resolved-avatar.ts index 907728d..04808a3 100644 --- a/src/features/auth/use-resolved-avatar.ts +++ b/src/features/user/use-resolved-avatar.ts @@ -1,16 +1,13 @@ -import { resolveAvatar } from "#/features/auth/utils.ts"; -import { useDalQuery } from "#/features/dal/use-dal-query.ts"; -import { createUserProfileDal } from "#/features/game/dal/user-profile/user-profile.dal.ts"; +import { useUserProfileQuery } from "#/features/game/data/user-profile/use-user-profile-data.ts"; import { useGameId } from "#/features/game/use-game-id.ts"; +import { resolveAvatar } from "#/features/user/utils.ts"; type UseResolvedAvatarArgs = { userId?: string } | undefined; const useResolvedAvatar = (args?: UseResolvedAvatarArgs) => { const gameId = useGameId(); - const userProfileDal = createUserProfileDal(); - const { data: profile } = useDalQuery( - userProfileDal.getProfile, + const { data: profile } = useUserProfileQuery( args?.userId ? { userId: args.userId } : undefined, ); diff --git a/src/features/auth/use-user-profile.ts b/src/features/user/use-user-profile.ts similarity index 57% rename from src/features/auth/use-user-profile.ts rename to src/features/user/use-user-profile.ts index e508291..51940e5 100644 --- a/src/features/auth/use-user-profile.ts +++ b/src/features/user/use-user-profile.ts @@ -1,6 +1,9 @@ -import { useDalMutation } from "#/features/dal/use-dal-mutation.ts"; -import { useDalQuery } from "#/features/dal/use-dal-query.ts"; -import { createUserProfileDal } from "#/features/game/dal/user-profile/user-profile.dal.ts"; +import { + useRemoveAvatarOverride, + useRemovePrimaryAvatar, + useUpdateAvatar, + useUserProfileQuery, +} from "#/features/game/data/user-profile/use-user-profile-data.ts"; import { useSession } from "#/integrations/better-auth/auth-client.ts"; import type { GameId } from "@/prisma"; @@ -8,19 +11,13 @@ type UseUserProfileArgs = { userId?: string } | undefined; const useUserProfile = (args?: UseUserProfileArgs) => { const { data: session, isPending: sessionPending } = useSession(); - const userProfileDal = createUserProfileDal(); - const profileQuery = useDalQuery( - userProfileDal.getProfile, + const profileQuery = useUserProfileQuery( args?.userId ? { userId: args.userId } : undefined, ); - const updateAvatarMutation = useDalMutation(userProfileDal.updateAvatar); - const removePrimaryAvatarMutation = useDalMutation( - userProfileDal.removePrimaryAvatar, - ); - const removeAvatarOverrideMutation = useDalMutation( - userProfileDal.removeAvatarOverride, - ); + const updateAvatarMutation = useUpdateAvatar(); + const removePrimaryAvatarMutation = useRemovePrimaryAvatar(); + const removeAvatarOverrideMutation = useRemoveAvatarOverride(); const isAuthenticated = !!session?.user; const isLoading = sessionPending || profileQuery.isPending; @@ -38,10 +35,7 @@ const useUserProfile = (args?: UseUserProfileArgs) => { avatarGameId: GameId; targetGameId?: GameId; }) => updateAvatarMutation.mutateAsync(params), - removePrimaryAvatar: () => - removePrimaryAvatarMutation.mutateAsync( - undefined as unknown as undefined, - ), + removePrimaryAvatar: () => removePrimaryAvatarMutation.mutateAsync(), removeAvatarOverride: (targetGameId: GameId) => removeAvatarOverrideMutation.mutateAsync({ targetGameId }), }; diff --git a/src/features/auth/utils.ts b/src/features/user/utils.ts similarity index 95% rename from src/features/auth/utils.ts rename to src/features/user/utils.ts index bd420ca..5fe0466 100644 --- a/src/features/auth/utils.ts +++ b/src/features/user/utils.ts @@ -1,5 +1,5 @@ import { clientEnv } from "#/env/client-env.ts"; -import { getGameAvatars } from "#/features/game/registry/game-registry.tsx"; +import { getGameAvatars } from "#/features/game/registry/game-public-registry.tsx"; import type { GameId } from "@/prisma"; type ResolveAvatarParams = { diff --git a/src/games/clairobscur/core/game-config/index.ts b/src/games/clairobscur/core/game-config/index.ts index 149901f..52f10ca 100644 --- a/src/games/clairobscur/core/game-config/index.ts +++ b/src/games/clairobscur/core/game-config/index.ts @@ -4,7 +4,7 @@ import { METADATA } from "#/games/clairobscur/core/game-config/metadata"; import { PAGES } from "#/games/clairobscur/core/game-config/pages"; import { THEME } from "#/games/clairobscur/core/game-config/theme"; import type { ClairObscurLocalItem } from "#/games/clairobscur/core/types.ts"; -import { clairObscurCollectedItemsDal } from "#/games/clairobscur/dal/collected-items"; +import { clairObscurCollectedItemsData } from "#/games/clairobscur/data/collected-items"; import type { ClairObscurItemCategory } from "@/prisma"; const GAME_CONFIG = { @@ -13,7 +13,7 @@ const GAME_CONFIG = { METADATA, PAGES, SEARCH_PARAMS: undefined, // TODO - DAL: { collectedItems: clairObscurCollectedItemsDal }, + data: { collectedItems: clairObscurCollectedItemsData }, } satisfies GameConfig; export { GAME_CONFIG }; diff --git a/src/games/clairobscur/core/game-config/pages.tsx b/src/games/clairobscur/core/game-config/pages.tsx index 982047d..dc91805 100644 --- a/src/games/clairobscur/core/game-config/pages.tsx +++ b/src/games/clairobscur/core/game-config/pages.tsx @@ -5,21 +5,21 @@ import { ItemListPage } from "#/components/pages/ItemList.tsx"; import { resolveLinkedItems } from "#/features/game/items/utils.ts"; import type { GamePages } from "#/features/game/types.ts"; import { ITEMS } from "#/games/clairobscur/core/game-config/items"; -import { clairObscurCollectedItemsDal } from "#/games/clairobscur/dal/collected-items"; +import { clairObscurCollectedItemsData } from "#/games/clairobscur/data/collected-items"; const PAGES: GamePages = { renderItemLookup: () => ( resolveLinkedItems(item, ITEMS.all)} - dal={clairObscurCollectedItemsDal} + data={clairObscurCollectedItemsData} /> ), renderCollectedItems: ({ mode }) => ( resolveLinkedItems(item, ITEMS.all)} - dal={clairObscurCollectedItemsDal} + data={clairObscurCollectedItemsData} viewMode={mode} /> ), diff --git a/src/games/clairobscur/dal/collected-items.ts b/src/games/clairobscur/dal/collected-items.ts deleted file mode 100644 index a6eb3c0..0000000 --- a/src/games/clairobscur/dal/collected-items.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createCollectedItemsDal } from "#/features/game/dal/collected-items/collected-items.dal.ts"; -import { - collectItemServerFn, - listCollectedItemsByUserIdServerFn, - listCollectedItemsServerFn, - uncollectItemServerFn, -} from "#/games/clairobscur/dal/server/collected-items"; - -export const clairObscurCollectedItemsDal = createCollectedItemsDal({ - entityName: "clairObscurCollectedItem", - getModel: (idb) => idb.clairObscurCollectedItem, - serverFns: { - collectItemServerFn, - uncollectItemServerFn, - listCollectedItemsServerFn, - listCollectedItemsByUserIdServerFn, - }, -}); diff --git a/src/games/clairobscur/dal/server/collected-items.ts b/src/games/clairobscur/dal/server/collected-items.ts deleted file mode 100644 index 810bf27..0000000 --- a/src/games/clairobscur/dal/server/collected-items.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; -import { requireUserId } from "#/features/auth/require-user.server.ts"; -import { createCollectedItemHandlers } from "#/features/game/dal/collected-items/collected-items.ts"; -import { prisma } from "@/prisma"; - -const CollectInput = z.object({ itemId: z.string().min(1) }); -const ListByUserIdInput = z.object({ userId: z.string().min(1) }); -const h = createCollectedItemHandlers(prisma.clairObscurCollectedItem); - -export const collectItemServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => CollectInput.parse(v)) - .handler(async ({ data }) => h.collect(data.itemId, await requireUserId())); - -export const uncollectItemServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => CollectInput.parse(v)) - .handler(async ({ data }) => h.uncollect(data.itemId, await requireUserId())); - -export const listCollectedItemsServerFn = createServerFn({ - method: "GET", -}).handler(async () => h.list(await requireUserId())); - -export const listCollectedItemsByUserIdServerFn = createServerFn({ - method: "POST", -}) - .inputValidator((v: unknown) => ListByUserIdInput.parse(v)) - .handler(async ({ data }) => h.list(data.userId)); diff --git a/src/games/clairobscur/dal/server/sync-handler.ts b/src/games/clairobscur/dal/server/sync-handler.ts deleted file mode 100644 index 95df8eb..0000000 --- a/src/games/clairobscur/dal/server/sync-handler.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createCollectedItemSyncHandler } from "#/features/game/dal/collected-items/sync-handler.ts"; -import { prisma } from "@/prisma"; - -export const collectedItemSyncHandler = createCollectedItemSyncHandler( - prisma.clairObscurCollectedItem, -); diff --git a/src/games/clairobscur/data/collected-items.ts b/src/games/clairobscur/data/collected-items.ts new file mode 100644 index 0000000..5b197b4 --- /dev/null +++ b/src/games/clairobscur/data/collected-items.ts @@ -0,0 +1,141 @@ +// Clair Obscur collected items: client data hooks. Each hook inlines the backend +// choice (remote when authed + online, else local IndexedDB) and, on the local +// path, mirrors the write to IDB and enqueues a pending op for later sync. + +import { useNetwork } from "@mantine/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CollectedItemRecord, + CollectItemInput, + GameCollectedItemsData, +} from "#/features/game/data/types.ts"; +import { getOrCreateAnonUserId } from "#/features/sync/identity/anon-id.ts"; +import { enqueueOp } from "#/features/sync/queue/pending-ops.ts"; +import { + collectItemServerFn, + listCollectedItemsByUserIdServerFn, + listCollectedItemsServerFn, + uncollectItemServerFn, +} from "#/games/clairobscur/data/server/collected-items.ts"; +import { useSession } from "#/integrations/better-auth/auth-client.ts"; +import { ensureIdbUserStub } from "#/integrations/prisma-idb/ensure-user-stub.ts"; +import { getIDBClient } from "#/integrations/prisma-idb/idb-client.ts"; + +const ENTITY = "clairObscurCollectedItem"; + +const toIso = (value: Date | string | null | undefined): string | undefined => + value instanceof Date ? value.toISOString() : (value ?? undefined); + +const useList = () => { + const { data: session } = useSession(); + const { online } = useNetwork(); + const authUserId = session?.user?.id ?? null; + const userId = authUserId ?? getOrCreateAnonUserId(); + const remote = !!authUserId && online; + + return useQuery({ + queryKey: ["data", ENTITY, "list", userId], + queryFn: async (): Promise => { + if (remote) return listCollectedItemsServerFn(); + if (!userId) return []; + const idb = await getIDBClient(); + return idb.clairObscurCollectedItem.findMany({ where: { userId } }); + }, + }); +}; + +const usePublicList = (publicUserId: string | null) => + useQuery({ + queryKey: ["data", ENTITY, "list", "byUserId", publicUserId], + queryFn: (): Promise => + publicUserId + ? listCollectedItemsByUserIdServerFn({ data: { userId: publicUserId } }) + : Promise.resolve([]), + enabled: !!publicUserId, + }); + +const useCollect = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return collectItemServerFn({ data: { itemId: input.itemId } }); + } + const idb = await getIDBClient(); + await ensureIdbUserStub(idb, userId); + const existing = await idb.clairObscurCollectedItem.findFirst({ + where: { userId, itemId: input.itemId }, + }); + const [record] = await Promise.all([ + idb.clairObscurCollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: input.itemId } }, + update: {}, + create: { userId, itemId: input.itemId }, + }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "upsert", + payload: { itemId: input.itemId, itemName: input.itemName }, + idempotencyKey: `${ENTITY}:upsert:${anonUserId}:${input.itemId}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { title: `Collected: ${input.itemName}` }, + }), + ]); + return record; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +const useUncollect = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, CollectItemInput>({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return uncollectItemServerFn({ data: { itemId: input.itemId } }); + } + const idb = await getIDBClient(); + const existing = await idb.clairObscurCollectedItem.findFirst({ + where: { userId, itemId: input.itemId }, + }); + await Promise.all([ + idb.clairObscurCollectedItem.deleteMany({ + where: { userId, itemId: input.itemId }, + }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "delete", + payload: { itemId: input.itemId, itemName: input.itemName }, + idempotencyKey: `${ENTITY}:delete:${anonUserId}:${input.itemId}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { title: `Uncollected: ${input.itemName}` }, + }), + ]); + return { ok: true as const }; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +export const clairObscurCollectedItemsData: GameCollectedItemsData = { + useList, + usePublicList, + useCollect, + useUncollect, +}; diff --git a/src/games/clairobscur/data/server/collected-items.ts b/src/games/clairobscur/data/server/collected-items.ts new file mode 100644 index 0000000..4aaa563 --- /dev/null +++ b/src/games/clairobscur/data/server/collected-items.ts @@ -0,0 +1,100 @@ +// Clair Obscur collected items: server functions (Postgres via Prisma) and the +// offline-sync handler. Hand-written per game — the small amount of duplication +// across games is intentional and keeps each game's data access self-contained. + +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import type { CollectedItemRecord } from "#/features/game/data/types.ts"; +import { createPresenceToggleSyncHandler } from "#/features/sync/presence-sync-handler.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; +import { prisma } from "@/prisma"; + +const CollectInput = z.object({ itemId: z.string().min(1) }); +const ListByUserIdInput = z.object({ userId: z.string().min(1) }); + +const collectItemServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + return prisma.clairObscurCollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: data.itemId } }, + update: {}, + create: { userId, itemId: data.itemId }, + }); + }); + +const uncollectItemServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + await prisma.clairObscurCollectedItem.deleteMany({ + where: { userId, itemId: data.itemId }, + }); + return { ok: true as const }; + }); + +const listCollectedItemsServerFn = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + return prisma.clairObscurCollectedItem.findMany({ + where: { userId: await requireUserId() }, + }); + }, +); + +const listCollectedItemsByUserIdServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => ListByUserIdInput.parse(v)) + .handler( + async ({ data }): Promise => + prisma.clairObscurCollectedItem.findMany({ + where: { userId: data.userId }, + }), + ); + +/** Offline-sync handler for the clairObscurCollectedItem entity (presence toggle). */ +const clairObscurCollectedItemHandler: SyncHandler = + createPresenceToggleSyncHandler({ + resolveKey: (op) => { + const itemId = (op.payload as { itemId?: string } | null)?.itemId; + return itemId + ? { ok: true, key: itemId } + : { ok: false, message: "missing itemId" }; + }, + findRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + return prisma.clairObscurCollectedItem.findUnique({ + where: { userId_itemId: { userId, itemId } }, + }); + }, + deleteRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + await prisma.clairObscurCollectedItem.deleteMany({ + where: { userId, itemId }, + }); + }, + createRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + await prisma.clairObscurCollectedItem.create({ + data: { userId, itemId }, + }); + }, + }); + +export { + clairObscurCollectedItemHandler, + collectItemServerFn, + listCollectedItemsByUserIdServerFn, + listCollectedItemsServerFn, + uncollectItemServerFn, +}; diff --git a/src/games/remnant2/core/game-config/index.ts b/src/games/remnant2/core/game-config/index.ts index 6d150ec..0edd35c 100644 --- a/src/games/remnant2/core/game-config/index.ts +++ b/src/games/remnant2/core/game-config/index.ts @@ -6,7 +6,8 @@ import { SEARCH_PARAMS } from "#/games/remnant2/core/game-config/nuqs-parsers.ts import { PAGES } from "#/games/remnant2/core/game-config/pages"; import { THEME } from "#/games/remnant2/core/game-config/theme"; import type { Remnant2LocalItem } from "#/games/remnant2/core/types"; -import { remnant2CollectedItemsDal } from "#/games/remnant2/dal/collected-items"; +import { remnant2CollectedItemsData } from "#/games/remnant2/data/collected-items"; +import { remnant2CreatedBuildsData } from "#/games/remnant2/data/created-builds"; import type { Remnant2ItemCategory } from "@/prisma"; const GAME_CONFIG = { @@ -16,7 +17,10 @@ const GAME_CONFIG = { PAGES, SEARCH_PARAMS, AVATARS, - DAL: { collectedItems: remnant2CollectedItemsDal }, + data: { + collectedItems: remnant2CollectedItemsData, + createdBuilds: remnant2CreatedBuildsData, + }, } satisfies GameConfig; export { GAME_CONFIG }; diff --git a/src/games/remnant2/core/game-config/pages.tsx b/src/games/remnant2/core/game-config/pages.tsx index b4893f4..9100f1d 100644 --- a/src/games/remnant2/core/game-config/pages.tsx +++ b/src/games/remnant2/core/game-config/pages.tsx @@ -4,20 +4,26 @@ import type { ReactNode } from "react"; import { BuildCreatePage } from "#/components/pages/BuildCreate.tsx"; import { BuildEditPage } from "#/components/pages/BuildEdit.tsx"; import { BuildViewPage } from "#/components/pages/BuildView.tsx"; +import { CreatedBuildsPage } from "#/components/pages/created-builds/CreatedBuilds.tsx"; +import { ItemListPage } from "#/components/pages/ItemList.tsx"; import { TriStateFilter, type TriStateFilterValue, } from "#/components/TriStateFilter.tsx"; -import { ItemListPage } from "#/components/pages/ItemList.tsx"; import { formatCategoryLabel, getItemSubcategories, itemMatchesCategory, resolveLinkedItems, } from "#/features/game/items/utils"; -import type {AppItem, GameFilterConfig, GamePages} from "#/features/game/types.ts"; +import type { + AppItem, + GameFilterConfig, + GamePages, +} from "#/features/game/types.ts"; import { ITEMS } from "#/games/remnant2/core/game-config/items"; -import { remnant2CollectedItemsDal } from "#/games/remnant2/dal/collected-items"; +import { remnant2CollectedItemsData } from "#/games/remnant2/data/collected-items"; +import { remnant2CreatedBuildsData } from "#/games/remnant2/data/created-builds"; import type { Remnant2DLC } from "@/prisma"; const REMNANT2_DLC_LABELS: Record = { @@ -156,7 +162,7 @@ export const PAGES: GamePages = { resolveLinkedItems(item, ITEMS.all)} - dal={remnant2CollectedItemsDal} + data={remnant2CollectedItemsData} gameFilterConfig={remnant2ItemFilterConfig} /> ), @@ -164,11 +170,14 @@ export const PAGES: GamePages = { resolveLinkedItems(item, ITEMS.all)} - dal={remnant2CollectedItemsDal} + data={remnant2CollectedItemsData} gameFilterConfig={remnant2ItemFilterConfig} viewMode={mode} /> ), + renderCreatedBuilds: ({ mode }) => ( + + ), renderCreateBuild: () => , renderEditBuild: () => , renderViewBuild: () => , diff --git a/src/games/remnant2/dal/collected-items.ts b/src/games/remnant2/dal/collected-items.ts deleted file mode 100644 index 8fabea7..0000000 --- a/src/games/remnant2/dal/collected-items.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createCollectedItemsDal } from "#/features/game/dal/collected-items/collected-items.dal.ts"; -import { - collectItemServerFn, - listCollectedItemsByUserIdServerFn, - listCollectedItemsServerFn, - uncollectItemServerFn, -} from "#/games/remnant2/dal/server/collected-items"; - -export const remnant2CollectedItemsDal = createCollectedItemsDal({ - entityName: "remnant2CollectedItem", - getModel: (idb) => idb.remnant2CollectedItem, - serverFns: { - collectItemServerFn, - uncollectItemServerFn, - listCollectedItemsServerFn, - listCollectedItemsByUserIdServerFn, - }, -}); diff --git a/src/games/remnant2/dal/server/collected-items.ts b/src/games/remnant2/dal/server/collected-items.ts deleted file mode 100644 index 9e9a4ab..0000000 --- a/src/games/remnant2/dal/server/collected-items.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; -import { requireUserId } from "#/features/auth/require-user.server.ts"; -import { createCollectedItemHandlers } from "#/features/game/dal/collected-items/collected-items.ts"; -import { prisma } from "@/prisma"; - -const CollectInput = z.object({ itemId: z.string().min(1) }); -const ListByUserIdInput = z.object({ userId: z.string().min(1) }); -const h = createCollectedItemHandlers(prisma.remnant2CollectedItem); - -const collectItemServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => CollectInput.parse(v)) - .handler(async ({ data }) => h.collect(data.itemId, await requireUserId())); - -const uncollectItemServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => CollectInput.parse(v)) - .handler(async ({ data }) => h.uncollect(data.itemId, await requireUserId())); - -const listCollectedItemsServerFn = createServerFn({ - method: "GET", -}).handler(async () => h.list(await requireUserId())); - -const listCollectedItemsByUserIdServerFn = createServerFn({ - method: "POST", -}) - .inputValidator((v: unknown) => ListByUserIdInput.parse(v)) - .handler(async ({ data }) => h.list(data.userId)); - -export { - collectItemServerFn, - listCollectedItemsByUserIdServerFn, - listCollectedItemsServerFn, - uncollectItemServerFn, -}; diff --git a/src/games/remnant2/dal/server/sync-handler.ts b/src/games/remnant2/dal/server/sync-handler.ts deleted file mode 100644 index f1464f9..0000000 --- a/src/games/remnant2/dal/server/sync-handler.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createCollectedItemSyncHandler } from "#/features/game/dal/collected-items/sync-handler.ts"; -import { prisma } from "@/prisma"; - -export const collectedItemSyncHandler = createCollectedItemSyncHandler( - prisma.remnant2CollectedItem, -); diff --git a/src/games/remnant2/data/collected-items.ts b/src/games/remnant2/data/collected-items.ts new file mode 100644 index 0000000..a542243 --- /dev/null +++ b/src/games/remnant2/data/collected-items.ts @@ -0,0 +1,141 @@ +// Remnant 2 collected items: client data hooks. Each hook inlines the backend +// choice (remote when authed + online, else local IndexedDB) and, on the local +// path, mirrors the write to IDB and enqueues a pending op for later sync. + +import { useNetwork } from "@mantine/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CollectedItemRecord, + CollectItemInput, + GameCollectedItemsData, +} from "#/features/game/data/types.ts"; +import { getOrCreateAnonUserId } from "#/features/sync/identity/anon-id.ts"; +import { enqueueOp } from "#/features/sync/queue/pending-ops.ts"; +import { + collectItemServerFn, + listCollectedItemsByUserIdServerFn, + listCollectedItemsServerFn, + uncollectItemServerFn, +} from "#/games/remnant2/data/server/collected-items.ts"; +import { useSession } from "#/integrations/better-auth/auth-client.ts"; +import { ensureIdbUserStub } from "#/integrations/prisma-idb/ensure-user-stub.ts"; +import { getIDBClient } from "#/integrations/prisma-idb/idb-client.ts"; + +const ENTITY = "remnant2CollectedItem"; + +const toIso = (value: Date | string | null | undefined): string | undefined => + value instanceof Date ? value.toISOString() : (value ?? undefined); + +const useList = () => { + const { data: session } = useSession(); + const { online } = useNetwork(); + const authUserId = session?.user?.id ?? null; + const userId = authUserId ?? getOrCreateAnonUserId(); + const remote = !!authUserId && online; + + return useQuery({ + queryKey: ["data", ENTITY, "list", userId], + queryFn: async (): Promise => { + if (remote) return listCollectedItemsServerFn(); + if (!userId) return []; + const idb = await getIDBClient(); + return idb.remnant2CollectedItem.findMany({ where: { userId } }); + }, + }); +}; + +const usePublicList = (publicUserId: string | null) => + useQuery({ + queryKey: ["data", ENTITY, "list", "byUserId", publicUserId], + queryFn: (): Promise => + publicUserId + ? listCollectedItemsByUserIdServerFn({ data: { userId: publicUserId } }) + : Promise.resolve([]), + enabled: !!publicUserId, + }); + +const useCollect = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return collectItemServerFn({ data: { itemId: input.itemId } }); + } + const idb = await getIDBClient(); + await ensureIdbUserStub(idb, userId); + const existing = await idb.remnant2CollectedItem.findFirst({ + where: { userId, itemId: input.itemId }, + }); + const [record] = await Promise.all([ + idb.remnant2CollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: input.itemId } }, + update: {}, + create: { userId, itemId: input.itemId }, + }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "upsert", + payload: { itemId: input.itemId, itemName: input.itemName }, + idempotencyKey: `${ENTITY}:upsert:${anonUserId}:${input.itemId}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { title: `Collected: ${input.itemName}` }, + }), + ]); + return record; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +const useUncollect = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, CollectItemInput>({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return uncollectItemServerFn({ data: { itemId: input.itemId } }); + } + const idb = await getIDBClient(); + const existing = await idb.remnant2CollectedItem.findFirst({ + where: { userId, itemId: input.itemId }, + }); + await Promise.all([ + idb.remnant2CollectedItem.deleteMany({ + where: { userId, itemId: input.itemId }, + }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "delete", + payload: { itemId: input.itemId, itemName: input.itemName }, + idempotencyKey: `${ENTITY}:delete:${anonUserId}:${input.itemId}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { title: `Uncollected: ${input.itemName}` }, + }), + ]); + return { ok: true as const }; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +export const remnant2CollectedItemsData: GameCollectedItemsData = { + useList, + usePublicList, + useCollect, + useUncollect, +}; diff --git a/src/games/remnant2/data/created-builds.ts b/src/games/remnant2/data/created-builds.ts new file mode 100644 index 0000000..a3ae373 --- /dev/null +++ b/src/games/remnant2/data/created-builds.ts @@ -0,0 +1,163 @@ +// Remnant 2 created builds: client data hooks. Each hook inlines the backend +// choice (remote when authed + online, else local IndexedDB) and, on the local +// path, mirrors the write to IDB and enqueues a pending op for later sync. + +import { useNetwork } from "@mantine/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { extractBuildWriteFields } from "#/features/game/data/build-fields.ts"; +import type { + CreatedBuildRecord, + CreatedBuildSummary, + DeleteBuildInput, + GameCreatedBuildsData, + UpdateBuildInput, +} from "#/features/game/data/types.ts"; +import { getOrCreateAnonUserId } from "#/features/sync/identity/anon-id.ts"; +import { enqueueOp } from "#/features/sync/queue/pending-ops.ts"; +import { + deleteBuildServerFn, + getBuildByIdServerFn, + listBuildsByUserIdServerFn, + listBuildsServerFn, + updateBuildServerFn, +} from "#/games/remnant2/data/server/created-builds.ts"; +import { useSession } from "#/integrations/better-auth/auth-client.ts"; +import { getIDBClient } from "#/integrations/prisma-idb/idb-client.ts"; + +const ENTITY = "remnant2Build"; + +const toIso = (value: Date | string | null | undefined): string | undefined => + value instanceof Date ? value.toISOString() : (value ?? undefined); + +const useList = () => { + const { data: session } = useSession(); + const { online } = useNetwork(); + const authUserId = session?.user?.id ?? null; + const userId = authUserId ?? getOrCreateAnonUserId(); + const remote = !!authUserId && online; + + return useQuery({ + queryKey: ["data", ENTITY, "list", userId], + queryFn: async (): Promise => { + if (remote) return listBuildsServerFn(); + if (!userId) return []; + const idb = await getIDBClient(); + return idb.remnant2Build.findMany({ + where: { createdById: userId }, + orderBy: { updatedAt: "desc" }, + }); + }, + }); +}; + +const usePublicList = (publicUserId: string | null) => + useQuery({ + queryKey: ["data", ENTITY, "list", "byUserId", publicUserId], + queryFn: (): Promise => + publicUserId + ? listBuildsByUserIdServerFn({ data: { userId: publicUserId } }) + : Promise.resolve([]), + enabled: !!publicUserId, + }); + +const useById = (buildId: string) => { + const { data: session } = useSession(); + const { online } = useNetwork(); + const remote = !!session?.user?.id && online; + + return useQuery({ + queryKey: ["data", ENTITY, "byId", buildId], + queryFn: async (): Promise => { + if (remote) return getBuildByIdServerFn({ data: { buildId } }); + const idb = await getIDBClient(); + return idb.remnant2Build.findUnique({ where: { id: buildId } }); + }, + }); +}; + +const useUpdate = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + if (authUserId && online) return updateBuildServerFn({ data: input }); + + const idb = await getIDBClient(); + const existing = await idb.remnant2Build.findUnique({ + where: { id: input.buildId }, + }); + const [record] = await Promise.all([ + idb.remnant2Build.update({ + where: { id: input.buildId }, + // generated prisma-idb arg types don't accept the string visibility; + // the runtime stores it verbatim. See features/game/data/build-fields. + data: extractBuildWriteFields(input) as never, + }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "update", + payload: input, + // Unique per edit: a stable key would let enqueueOp drop a second edit + // made before the first synced. + idempotencyKey: `${ENTITY}:update:${anonUserId}:${input.buildId}:${crypto.randomUUID()}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { + title: input.name + ? `Updated build: ${input.name}` + : "Updated build", + }, + }), + ]); + return record; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +const useRemove = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, DeleteBuildInput>({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + if (authUserId && online) return deleteBuildServerFn({ data: input }); + + const idb = await getIDBClient(); + const existing = await idb.remnant2Build.findUnique({ + where: { id: input.buildId }, + }); + await Promise.all([ + idb.remnant2Build.deleteMany({ where: { id: input.buildId } }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "delete", + payload: input, + idempotencyKey: `${ENTITY}:delete:${anonUserId}:${input.buildId}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { title: "Deleted build" }, + }), + ]); + return { ok: true as const }; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +export const remnant2CreatedBuildsData: GameCreatedBuildsData = { + useList, + usePublicList, + useById, + useUpdate, + useRemove, +}; diff --git a/src/games/remnant2/data/server/collected-items.ts b/src/games/remnant2/data/server/collected-items.ts new file mode 100644 index 0000000..cc3616b --- /dev/null +++ b/src/games/remnant2/data/server/collected-items.ts @@ -0,0 +1,96 @@ +// Remnant 2 collected items: server functions (Postgres via Prisma) and the +// offline-sync handler. Hand-written per game — the small amount of duplication +// across games is intentional and keeps each game's data access self-contained. + +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import type { CollectedItemRecord } from "#/features/game/data/types.ts"; +import { createPresenceToggleSyncHandler } from "#/features/sync/presence-sync-handler.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; +import { prisma } from "@/prisma"; + +const CollectInput = z.object({ itemId: z.string().min(1) }); +const ListByUserIdInput = z.object({ userId: z.string().min(1) }); + +const collectItemServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + return prisma.remnant2CollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: data.itemId } }, + update: {}, + create: { userId, itemId: data.itemId }, + }); + }); + +const uncollectItemServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + await prisma.remnant2CollectedItem.deleteMany({ + where: { userId, itemId: data.itemId }, + }); + return { ok: true as const }; + }); + +const listCollectedItemsServerFn = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + return prisma.remnant2CollectedItem.findMany({ + where: { userId: await requireUserId() }, + }); + }, +); + +const listCollectedItemsByUserIdServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => ListByUserIdInput.parse(v)) + .handler( + async ({ data }): Promise => + prisma.remnant2CollectedItem.findMany({ where: { userId: data.userId } }), + ); + +/** Offline-sync handler for the remnant2CollectedItem entity (presence toggle). */ +const remnant2CollectedItemHandler: SyncHandler = + createPresenceToggleSyncHandler({ + resolveKey: (op) => { + const itemId = (op.payload as { itemId?: string } | null)?.itemId; + return itemId + ? { ok: true, key: itemId } + : { ok: false, message: "missing itemId" }; + }, + findRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + return prisma.remnant2CollectedItem.findUnique({ + where: { userId_itemId: { userId, itemId } }, + }); + }, + deleteRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + await prisma.remnant2CollectedItem.deleteMany({ + where: { userId, itemId }, + }); + }, + createRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + await prisma.remnant2CollectedItem.create({ data: { userId, itemId } }); + }, + }); + +export { + collectItemServerFn, + listCollectedItemsByUserIdServerFn, + listCollectedItemsServerFn, + remnant2CollectedItemHandler, + uncollectItemServerFn, +}; diff --git a/src/games/remnant2/data/server/created-builds.ts b/src/games/remnant2/data/server/created-builds.ts new file mode 100644 index 0000000..c2f2bdb --- /dev/null +++ b/src/games/remnant2/data/server/created-builds.ts @@ -0,0 +1,174 @@ +// Remnant 2 created builds: server functions (Postgres via Prisma) and the +// offline-sync handler. Build CRUD is written out here (and re-used by the sync +// handler below) rather than hidden behind a factory. + +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { + type BuildWriteFields, + extractBuildWriteFields, +} from "#/features/game/data/build-fields.ts"; +import type { + CreatedBuildRecord, + CreatedBuildSummary, +} from "#/features/game/data/types.ts"; +import type { HasUpdatedAt } from "#/features/sync/last-write-wins.ts"; +import { createRecordSyncHandler } from "#/features/sync/record-sync-handler.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; + +// Client-safe mirror of the Prisma `BuildVisibility` enum. This module is pulled +// into the client bundle via the route, so it must NOT reference `@/prisma` at +// module scope — only inside the dynamically-imported handler bodies below. +const BUILD_VISIBILITY_VALUES = ["PUBLIC", "UNLISTED", "PRIVATE"] as const; +type BuildVisibilityValue = (typeof BUILD_VISIBILITY_VALUES)[number]; + +const BuildByIdInput = z.object({ buildId: z.string().min(1) }); +const ListByUserIdInput = z.object({ userId: z.string().min(1) }); +const UpdateBuildInput = z.object({ + buildId: z.string().min(1), + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + visibility: z.enum(BUILD_VISIBILITY_VALUES).optional(), + videoUrl: z.string().nullable().optional(), + imageUrl: z.string().nullable().optional(), + thumbnailUrl: z.string().nullable().optional(), + referenceUrl: z.string().nullable().optional(), + gameVersion: z.string().nullable().optional(), +}); + +/** Server-side build-write fields — like BuildWriteFields but with the real enum. */ +type BuildUpdateData = Omit & { + visibility?: BuildVisibilityValue; +}; + +/** Updates a build the user owns. Throws if it doesn't exist or isn't theirs. */ +const updateOwnedBuild = async ( + userId: string, + buildId: string, + fields: BuildWriteFields, +): Promise => { + const { prisma } = await import("@/prisma"); + // updateMany lets us scope by createdById (ownership) in the where clause. + const res = await prisma.remnant2Build.updateMany({ + where: { id: buildId, createdById: userId }, + data: fields as BuildUpdateData, + }); + if (res.count === 0) throw new Error("Build not found or not owned by user"); + const updated = await prisma.remnant2Build.findUnique({ + where: { id: buildId }, + }); + if (!updated) throw new Error("Build not found after update"); + return updated; +}; + +const listBuildsServerFn = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + return prisma.remnant2Build.findMany({ + where: { createdById: await requireUserId() }, + orderBy: { updatedAt: "desc" }, + }); + }, +); + +const getBuildByIdServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => BuildByIdInput.parse(v)) + .handler( + async ({ data }): Promise => { + const { prisma } = await import("@/prisma"); + return prisma.remnant2Build.findUnique({ where: { id: data.buildId } }); + }, + ); + +const listBuildsByUserIdServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => ListByUserIdInput.parse(v)) + .handler( + async ({ data }): Promise => { + const { BuildVisibility, prisma } = await import("@/prisma"); + return prisma.remnant2Build.findMany({ + where: { createdById: data.userId, visibility: BuildVisibility.PUBLIC }, + orderBy: { updatedAt: "desc" }, + }); + }, + ); + +const updateBuildServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => UpdateBuildInput.parse(v)) + .handler(async ({ data }) => { + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + return updateOwnedBuild( + await requireUserId(), + data.buildId, + extractBuildWriteFields(data), + ); + }); + +const deleteBuildServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => BuildByIdInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + await prisma.remnant2Build.deleteMany({ + where: { id: data.buildId, createdById: userId }, + }); + return { ok: true as const }; + }); + +/** Offline-sync handler for the remnant2Build entity (mutable content record). */ +const remnant2BuildHandler: SyncHandler = createRecordSyncHandler({ + resolveKey: (op) => { + const buildId = (op.payload as { buildId?: string } | null)?.buildId; + return buildId + ? { ok: true, key: buildId } + : { ok: false, message: "missing buildId" }; + }, + findRecord: async (_userId, buildId) => { + const { prisma } = await import("@/prisma"); + return prisma.remnant2Build.findUnique({ + where: { id: buildId }, + }) as Promise; + }, + createRecord: async (userId, buildId, payload) => { + const { prisma } = await import("@/prisma"); + // Only reached if the server row is gone — resurrect the user's own build. + const fields = extractBuildWriteFields(payload as Record); + await prisma.remnant2Build.create({ + data: { + ...(fields as BuildUpdateData), + id: buildId, + createdById: userId, + name: fields.name ?? "Untitled build", + }, + }); + }, + updateRecord: async (userId, buildId, payload) => { + await updateOwnedBuild( + userId, + buildId, + extractBuildWriteFields(payload as Record), + ); + }, + deleteRecord: async (userId, buildId) => { + const { prisma } = await import("@/prisma"); + await prisma.remnant2Build.deleteMany({ + where: { id: buildId, createdById: userId }, + }); + }, +}); + +export { + deleteBuildServerFn, + getBuildByIdServerFn, + listBuildsByUserIdServerFn, + listBuildsServerFn, + remnant2BuildHandler, + updateBuildServerFn, +}; diff --git a/src/games/slaythespire2/core/game-config/index.ts b/src/games/slaythespire2/core/game-config/index.ts index 55bd26c..ee0e416 100644 --- a/src/games/slaythespire2/core/game-config/index.ts +++ b/src/games/slaythespire2/core/game-config/index.ts @@ -6,7 +6,7 @@ import { SEARCH_PARAMS } from "#/games/slaythespire2/core/game-config/nuqs-parse import { PAGES } from "#/games/slaythespire2/core/game-config/pages"; import { THEME } from "#/games/slaythespire2/core/game-config/theme"; import type { SlayTheSpire2LocalItem } from "#/games/slaythespire2/core/types"; -import { slayTheSpire2CollectedItemsDal } from "#/games/slaythespire2/dal/collected-items"; +import { slayTheSpire2CollectedItemsData } from "#/games/slaythespire2/data/collected-items"; import type { SlayTheSpire2ItemCategory } from "@/prisma"; const GAME_CONFIG = { @@ -16,7 +16,7 @@ const GAME_CONFIG = { PAGES, SEARCH_PARAMS, AVATARS, - DAL: { collectedItems: slayTheSpire2CollectedItemsDal }, + data: { collectedItems: slayTheSpire2CollectedItemsData }, } satisfies GameConfig; export { GAME_CONFIG }; diff --git a/src/games/slaythespire2/core/game-config/pages.tsx b/src/games/slaythespire2/core/game-config/pages.tsx index 5ad5e16..cef454c 100644 --- a/src/games/slaythespire2/core/game-config/pages.tsx +++ b/src/games/slaythespire2/core/game-config/pages.tsx @@ -4,16 +4,20 @@ import type { ReactNode } from "react"; import { BuildCreatePage } from "#/components/pages/BuildCreate.tsx"; import { BuildEditPage } from "#/components/pages/BuildEdit.tsx"; import { BuildViewPage } from "#/components/pages/BuildView.tsx"; +import { ItemListPage } from "#/components/pages/ItemList.tsx"; import { formatCategoryLabel, getItemSubcategories, itemMatchesCategory, resolveLinkedItems, } from "#/features/game/items/utils"; -import type {AppItem, GameFilterConfig, GamePages} from "#/features/game/types.ts"; +import type { + AppItem, + GameFilterConfig, + GamePages, +} from "#/features/game/types.ts"; import { ITEMS } from "#/games/slaythespire2/core/game-config/items"; -import { slayTheSpire2CollectedItemsDal } from "#/games/slaythespire2/dal/collected-items"; -import {ItemListPage} from "#/components/pages/ItemList.tsx"; +import { slayTheSpire2CollectedItemsData } from "#/games/slaythespire2/data/collected-items"; const slayTheSpire2FilterConfig: GameFilterConfig = { label: "Slay the Spire 2 Filters", @@ -79,7 +83,7 @@ const PAGES: GamePages = { resolveLinkedItems(item, ITEMS.all)} - dal={slayTheSpire2CollectedItemsDal} + data={slayTheSpire2CollectedItemsData} gameFilterConfig={slayTheSpire2FilterConfig} /> ), @@ -87,7 +91,7 @@ const PAGES: GamePages = { resolveLinkedItems(item, ITEMS.all)} - dal={slayTheSpire2CollectedItemsDal} + data={slayTheSpire2CollectedItemsData} gameFilterConfig={slayTheSpire2FilterConfig} viewMode={mode} /> diff --git a/src/games/slaythespire2/dal/collected-items.ts b/src/games/slaythespire2/dal/collected-items.ts deleted file mode 100644 index 32da73a..0000000 --- a/src/games/slaythespire2/dal/collected-items.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createCollectedItemsDal } from "#/features/game/dal/collected-items/collected-items.dal.ts"; -import { - collectItemServerFn, - listCollectedItemsByUserIdServerFn, - listCollectedItemsServerFn, - uncollectItemServerFn, -} from "#/games/slaythespire2/dal/server/collected-items"; - -export const slayTheSpire2CollectedItemsDal = createCollectedItemsDal({ - entityName: "slayTheSpire2CollectedItem", - getModel: (idb) => idb.slayTheSpire2CollectedItem, - serverFns: { - collectItemServerFn, - uncollectItemServerFn, - listCollectedItemsServerFn, - listCollectedItemsByUserIdServerFn, - }, -}); diff --git a/src/games/slaythespire2/dal/server/collected-items.ts b/src/games/slaythespire2/dal/server/collected-items.ts deleted file mode 100644 index 1261bd1..0000000 --- a/src/games/slaythespire2/dal/server/collected-items.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; -import { requireUserId } from "#/features/auth/require-user.server.ts"; -import { createCollectedItemHandlers } from "#/features/game/dal/collected-items/collected-items.ts"; -import { prisma } from "@/prisma"; - -const CollectInput = z.object({ itemId: z.string().min(1) }); -const ListByUserIdInput = z.object({ userId: z.string().min(1) }); -const h = createCollectedItemHandlers(prisma.slayTheSpire2CollectedItem); - -export const collectItemServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => CollectInput.parse(v)) - .handler(async ({ data }) => h.collect(data.itemId, await requireUserId())); - -export const uncollectItemServerFn = createServerFn({ method: "POST" }) - .inputValidator((v: unknown) => CollectInput.parse(v)) - .handler(async ({ data }) => h.uncollect(data.itemId, await requireUserId())); - -export const listCollectedItemsServerFn = createServerFn({ - method: "GET", -}).handler(async () => h.list(await requireUserId())); - -export const listCollectedItemsByUserIdServerFn = createServerFn({ - method: "POST", -}) - .inputValidator((v: unknown) => ListByUserIdInput.parse(v)) - .handler(async ({ data }) => h.list(data.userId)); diff --git a/src/games/slaythespire2/dal/server/sync-handler.ts b/src/games/slaythespire2/dal/server/sync-handler.ts deleted file mode 100644 index 269e3c7..0000000 --- a/src/games/slaythespire2/dal/server/sync-handler.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createCollectedItemSyncHandler } from "#/features/game/dal/collected-items/sync-handler.ts"; -import { prisma } from "@/prisma"; - -export const collectedItemSyncHandler = createCollectedItemSyncHandler( - prisma.slayTheSpire2CollectedItem, -); diff --git a/src/games/slaythespire2/data/collected-items.ts b/src/games/slaythespire2/data/collected-items.ts new file mode 100644 index 0000000..b896384 --- /dev/null +++ b/src/games/slaythespire2/data/collected-items.ts @@ -0,0 +1,141 @@ +// Slay the Spire 2 collected items: client data hooks. Each hook inlines the backend +// choice (remote when authed + online, else local IndexedDB) and, on the local +// path, mirrors the write to IDB and enqueues a pending op for later sync. + +import { useNetwork } from "@mantine/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CollectedItemRecord, + CollectItemInput, + GameCollectedItemsData, +} from "#/features/game/data/types.ts"; +import { getOrCreateAnonUserId } from "#/features/sync/identity/anon-id.ts"; +import { enqueueOp } from "#/features/sync/queue/pending-ops.ts"; +import { + collectItemServerFn, + listCollectedItemsByUserIdServerFn, + listCollectedItemsServerFn, + uncollectItemServerFn, +} from "#/games/slaythespire2/data/server/collected-items.ts"; +import { useSession } from "#/integrations/better-auth/auth-client.ts"; +import { ensureIdbUserStub } from "#/integrations/prisma-idb/ensure-user-stub.ts"; +import { getIDBClient } from "#/integrations/prisma-idb/idb-client.ts"; + +const ENTITY = "slayTheSpire2CollectedItem"; + +const toIso = (value: Date | string | null | undefined): string | undefined => + value instanceof Date ? value.toISOString() : (value ?? undefined); + +const useList = () => { + const { data: session } = useSession(); + const { online } = useNetwork(); + const authUserId = session?.user?.id ?? null; + const userId = authUserId ?? getOrCreateAnonUserId(); + const remote = !!authUserId && online; + + return useQuery({ + queryKey: ["data", ENTITY, "list", userId], + queryFn: async (): Promise => { + if (remote) return listCollectedItemsServerFn(); + if (!userId) return []; + const idb = await getIDBClient(); + return idb.slayTheSpire2CollectedItem.findMany({ where: { userId } }); + }, + }); +}; + +const usePublicList = (publicUserId: string | null) => + useQuery({ + queryKey: ["data", ENTITY, "list", "byUserId", publicUserId], + queryFn: (): Promise => + publicUserId + ? listCollectedItemsByUserIdServerFn({ data: { userId: publicUserId } }) + : Promise.resolve([]), + enabled: !!publicUserId, + }); + +const useCollect = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return collectItemServerFn({ data: { itemId: input.itemId } }); + } + const idb = await getIDBClient(); + await ensureIdbUserStub(idb, userId); + const existing = await idb.slayTheSpire2CollectedItem.findFirst({ + where: { userId, itemId: input.itemId }, + }); + const [record] = await Promise.all([ + idb.slayTheSpire2CollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: input.itemId } }, + update: {}, + create: { userId, itemId: input.itemId }, + }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "upsert", + payload: { itemId: input.itemId, itemName: input.itemName }, + idempotencyKey: `${ENTITY}:upsert:${anonUserId}:${input.itemId}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { title: `Collected: ${input.itemName}` }, + }), + ]); + return record; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +const useUncollect = () => { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + const { online } = useNetwork(); + + return useMutation<{ ok: true }, Error, CollectItemInput>({ + mutationFn: async (input) => { + const authUserId = session?.user?.id ?? null; + const anonUserId = getOrCreateAnonUserId(); + const userId = authUserId ?? anonUserId; + if (authUserId && online) { + return uncollectItemServerFn({ data: { itemId: input.itemId } }); + } + const idb = await getIDBClient(); + const existing = await idb.slayTheSpire2CollectedItem.findFirst({ + where: { userId, itemId: input.itemId }, + }); + await Promise.all([ + idb.slayTheSpire2CollectedItem.deleteMany({ + where: { userId, itemId: input.itemId }, + }), + enqueueOp({ + anonUserId, + entity: ENTITY, + operation: "delete", + payload: { itemId: input.itemId, itemName: input.itemName }, + idempotencyKey: `${ENTITY}:delete:${anonUserId}:${input.itemId}`, + serverUpdatedAt: toIso(existing?.updatedAt), + summary: { title: `Uncollected: ${input.itemName}` }, + }), + ]); + return { ok: true as const }; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["data", ENTITY] }), + }); +}; + +export const slayTheSpire2CollectedItemsData: GameCollectedItemsData = { + useList, + usePublicList, + useCollect, + useUncollect, +}; diff --git a/src/games/slaythespire2/data/server/collected-items.ts b/src/games/slaythespire2/data/server/collected-items.ts new file mode 100644 index 0000000..be1b6c8 --- /dev/null +++ b/src/games/slaythespire2/data/server/collected-items.ts @@ -0,0 +1,100 @@ +// Slay the Spire 2 collected items: server functions (Postgres via Prisma) and the +// offline-sync handler. Hand-written per game — the small amount of duplication +// across games is intentional and keeps each game's data access self-contained. + +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import type { CollectedItemRecord } from "#/features/game/data/types.ts"; +import { createPresenceToggleSyncHandler } from "#/features/sync/presence-sync-handler.ts"; +import type { SyncHandler } from "#/features/sync/types.ts"; +import { prisma } from "@/prisma"; + +const CollectInput = z.object({ itemId: z.string().min(1) }); +const ListByUserIdInput = z.object({ userId: z.string().min(1) }); + +const collectItemServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + return prisma.slayTheSpire2CollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: data.itemId } }, + update: {}, + create: { userId, itemId: data.itemId }, + }); + }); + +const uncollectItemServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }) => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + const userId = await requireUserId(); + await prisma.slayTheSpire2CollectedItem.deleteMany({ + where: { userId, itemId: data.itemId }, + }); + return { ok: true as const }; + }); + +const listCollectedItemsServerFn = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const { prisma } = await import("@/prisma"); + const { requireUserId } = await import( + "#/features/user/require-user.server.ts" + ); + return prisma.slayTheSpire2CollectedItem.findMany({ + where: { userId: await requireUserId() }, + }); + }, +); + +const listCollectedItemsByUserIdServerFn = createServerFn({ method: "POST" }) + .validator((v: unknown) => ListByUserIdInput.parse(v)) + .handler( + async ({ data }): Promise => + prisma.slayTheSpire2CollectedItem.findMany({ + where: { userId: data.userId }, + }), + ); + +/** Offline-sync handler for the slayTheSpire2CollectedItem entity (presence toggle). */ +const slayTheSpire2CollectedItemHandler: SyncHandler = + createPresenceToggleSyncHandler({ + resolveKey: (op) => { + const itemId = (op.payload as { itemId?: string } | null)?.itemId; + return itemId + ? { ok: true, key: itemId } + : { ok: false, message: "missing itemId" }; + }, + findRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + return prisma.slayTheSpire2CollectedItem.findUnique({ + where: { userId_itemId: { userId, itemId } }, + }); + }, + deleteRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + await prisma.slayTheSpire2CollectedItem.deleteMany({ + where: { userId, itemId }, + }); + }, + createRecord: async (userId, itemId) => { + const { prisma } = await import("@/prisma"); + await prisma.slayTheSpire2CollectedItem.create({ + data: { userId, itemId }, + }); + }, + }); + +export { + collectItemServerFn, + listCollectedItemsByUserIdServerFn, + listCollectedItemsServerFn, + slayTheSpire2CollectedItemHandler, + uncollectItemServerFn, +}; diff --git a/src/integrations/prisma-idb/ensure-user-stub.ts b/src/integrations/prisma-idb/ensure-user-stub.ts new file mode 100644 index 0000000..9b4ac72 --- /dev/null +++ b/src/integrations/prisma-idb/ensure-user-stub.ts @@ -0,0 +1,27 @@ +import type { getIDBClient } from "#/integrations/prisma-idb/idb-client.ts"; + +type IDBClient = Awaited>; + +/** + * Ensures a stub `user` row exists in IndexedDB before writing a row that FKs to + * it. The prisma-idb client enforces foreign keys, so junction rows (collected + * items, etc.) written for an anon/offline user need their `user.id` to exist. + * This is local-only plumbing — the real user row lives in Postgres. + */ +const ensureIdbUserStub = async ( + idb: IDBClient, + userId: string, +): Promise => { + await idb.user.upsert({ + where: { id: userId }, + update: {}, + create: { + id: userId, + username: `_local_${userId}`, + email: `_local_${userId}@local.invalid`, + emailVerified: false, + }, + }); +}; + +export { ensureIdbUserStub }; diff --git a/src/routes/$gameId/build/$buildId/edit.tsx b/src/routes/$gameId/build/$buildId/edit.tsx index 056d9bf..fd90ced 100644 --- a/src/routes/$gameId/build/$buildId/edit.tsx +++ b/src/routes/$gameId/build/$buildId/edit.tsx @@ -1,10 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getGameConfig } from "#/features/game/registry/game-registry.tsx"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; export const Route = createFileRoute("/$gameId/build/$buildId/edit")({ component: function CreateBuildPage() { const { gameId } = Route.useParams(); - const config = getGameConfig(gameId); - return <>{config?.PAGES.renderEditBuild()}; + const pages = getGamePages(gameId); + return <>{pages?.renderEditBuild()}; }, }); diff --git a/src/routes/$gameId/build/$buildId/route.tsx b/src/routes/$gameId/build/$buildId/route.tsx index 7df2830..77abe71 100644 --- a/src/routes/$gameId/build/$buildId/route.tsx +++ b/src/routes/$gameId/build/$buildId/route.tsx @@ -1,10 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getGameConfig } from "#/features/game/registry/game-registry.tsx"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; export const Route = createFileRoute("/$gameId/build/$buildId")({ component: function CreateBuildPage() { const { gameId } = Route.useParams(); - const config = getGameConfig(gameId); - return <>{config?.PAGES.renderViewBuild()}; + const pages = getGamePages(gameId); + return <>{pages?.renderViewBuild()}; }, }); diff --git a/src/routes/$gameId/build/create.tsx b/src/routes/$gameId/build/create.tsx index ef6bf44..11b56f2 100644 --- a/src/routes/$gameId/build/create.tsx +++ b/src/routes/$gameId/build/create.tsx @@ -1,10 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getGameConfig } from "#/features/game/registry/game-registry"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; export const Route = createFileRoute("/$gameId/build/create")({ component: function CreateBuildPage() { const { gameId } = Route.useParams(); - const config = getGameConfig(gameId); - return <>{config?.PAGES.renderCreateBuild()}; + const pages = getGamePages(gameId); + return <>{pages?.renderCreateBuild()}; }, }); diff --git a/src/routes/$gameId/items.tsx b/src/routes/$gameId/items.tsx index 97c82e2..7ebbc4e 100644 --- a/src/routes/$gameId/items.tsx +++ b/src/routes/$gameId/items.tsx @@ -1,10 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getGameConfig } from "#/features/game/registry/game-registry"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; export const Route = createFileRoute("/$gameId/items")({ component: function ItemsPage() { const { gameId } = Route.useParams(); - const config = getGameConfig(gameId); - return <>{config?.PAGES.renderItemLookup()}; + const pages = getGamePages(gameId); + return <>{pages?.renderItemLookup()}; }, }); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index cf5063f..ccdd323 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -12,8 +12,8 @@ import { createRootRouteWithContext } from "@tanstack/react-router"; import { RootDocument } from "#/components/RootDocument.tsx"; import { OG_IMAGE, SERVER_GAME_INPUTS_QUERY_KEY } from "#/constants"; import { clientEnv } from "#/env/client-env.ts"; -import { getServerResolvedGameInputsServerFn } from "#/features/game/dal/active-game"; -import { getValidatedGameId } from "#/features/game/registry/game-registry"; +import { getServerResolvedGameInputsServerFn } from "#/features/game/active-game"; +import { getValidatedGameId } from "#/features/game/registry/game-public-registry.tsx"; import type { GameId } from "@/prisma"; interface MyRouterContext { @@ -100,4 +100,4 @@ const Route = createRootRouteWithContext()({ }, }); -export { Route, type SERVER_GAME_INPUTS_QUERY_KEY }; +export { Route }; diff --git a/src/routes/account/profile/$userId/build-collections.tsx b/src/routes/account/profile/$userId/build-collections.tsx index 42d65fb..0b83f88 100644 --- a/src/routes/account/profile/$userId/build-collections.tsx +++ b/src/routes/account/profile/$userId/build-collections.tsx @@ -1,18 +1,9 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/profile-tab-head.ts"; - -function BuildCollections() { - return ( - - Build Collections - Content coming soon. - - ); -} +} from "#/features/user/profile-tab-head.ts"; const Route = createFileRoute("/account/profile/$userId/build-collections")({ loader: async ({ params, context }) => @@ -23,7 +14,7 @@ const Route = createFileRoute("/account/profile/$userId/build-collections")({ "Build Collections", ), }), - component: BuildCollections, + component: () => , }); export { Route }; diff --git a/src/routes/account/profile/$userId/collected-items.tsx b/src/routes/account/profile/$userId/collected-items.tsx index 7f7b3a8..cc9a0cf 100644 --- a/src/routes/account/profile/$userId/collected-items.tsx +++ b/src/routes/account/profile/$userId/collected-items.tsx @@ -1,15 +1,15 @@ import { createFileRoute, getRouteApi } from "@tanstack/react-router"; import { useEffect } from "react"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; import { - buildTabHead, - loadProfileTabData, -} from "#/features/auth/profile-tab-head.ts"; -import { - getGameConfig, getGameMetadata, isRegisteredGameId, -} from "#/features/game/registry/game-registry"; +} from "#/features/game/registry/game-public-registry.tsx"; import { useGameId } from "#/features/game/use-game-id.ts"; +import { + buildTabHead, + loadProfileTabData, +} from "#/features/user/profile-tab-head.ts"; import type { GameId } from "@/prisma"; type CollectedItemsSearch = { @@ -36,11 +36,11 @@ const CollectedItems = () => { }); }, [gameId, urlGameId, navigate]); - const config = gameId !== "none" ? getGameConfig(gameId) : undefined; + const pages = gameId !== "none" ? getGamePages(gameId) : undefined; return ( <> - {config?.PAGES.renderCollectedItems({ + {pages?.renderCollectedItems({ mode: isOwner ? { kind: "self" } : { kind: "public", userId }, })} diff --git a/src/routes/account/profile/$userId/collection-stats.tsx b/src/routes/account/profile/$userId/collection-stats.tsx index 211b223..c3bf406 100644 --- a/src/routes/account/profile/$userId/collection-stats.tsx +++ b/src/routes/account/profile/$userId/collection-stats.tsx @@ -1,18 +1,9 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/profile-tab-head.ts"; - -function CollectionStats() { - return ( - - Collection Stats - Content coming soon. - - ); -} +} from "#/features/user/profile-tab-head.ts"; const Route = createFileRoute("/account/profile/$userId/collection-stats")({ loader: async ({ params, context }) => @@ -23,7 +14,7 @@ const Route = createFileRoute("/account/profile/$userId/collection-stats")({ "Collection Stats", ), }), - component: CollectionStats, + component: () => , }); export { Route }; diff --git a/src/routes/account/profile/$userId/created-builds.tsx b/src/routes/account/profile/$userId/created-builds.tsx index dd1aa7b..a188186 100644 --- a/src/routes/account/profile/$userId/created-builds.tsx +++ b/src/routes/account/profile/$userId/created-builds.tsx @@ -1,20 +1,60 @@ -import { Stack, Text, Title } from "@mantine/core"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, getRouteApi } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { useGameId } from "#/features/game/use-game-id.ts"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; +import { + isRegisteredGameId, +} from "#/features/game/registry/game-public-registry.tsx"; +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/profile-tab-head.ts"; +} from "#/features/user/profile-tab-head.ts"; +import type { GameId } from "@/prisma"; + +type CreatedBuildsSearch = { + gameId?: GameId; +}; + +const parentRouteApi = getRouteApi("/account/profile/$userId"); + +const CreatedBuilds = () => { + const { userId } = Route.useParams(); + const { isOwner } = parentRouteApi.useLoaderData(); + const { gameId: urlGameId } = Route.useSearch(); + const navigate = Route.useNavigate(); + const gameId = useGameId(); + + // Mirror the active gameId back to the URL so the page state is shareable + // and so picking a different game via GameSwitcher keeps the URL in sync. + useEffect(() => { + if (gameId === "none") return; + if (urlGameId === gameId) return; + void navigate({ + search: (prev) => ({ ...prev, gameId }), + replace: true, + }); + }, [gameId, urlGameId, navigate]); + + const pages = gameId !== "none" ? getGamePages(gameId) : undefined; -function CreatedBuilds() { return ( - - Created Builds - Content coming soon. - + <> + {pages?.renderCreatedBuilds?.({ + mode: isOwner ? { kind: "self" } : { kind: "public", userId }, + }) ?? } + ); -} +}; const Route = createFileRoute("/account/profile/$userId/created-builds")({ + validateSearch: (search: Record): CreatedBuildsSearch => { + const raw = search.gameId; + if (typeof raw === "string" && isRegisteredGameId(raw)) { + return { gameId: raw }; + } + return {}; + }, loader: async ({ params, context }) => loadProfileTabData(params.userId, context.queryClient), head: ({ loaderData }) => ({ diff --git a/src/routes/account/profile/$userId/data-sync.tsx b/src/routes/account/profile/$userId/data-sync.tsx index 1ed8e7b..310b45b 100644 --- a/src/routes/account/profile/$userId/data-sync.tsx +++ b/src/routes/account/profile/$userId/data-sync.tsx @@ -13,15 +13,15 @@ import { notifications } from "@mantine/notifications"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { type PropsWithChildren, useEffect, useState } from "react"; +import { getGameMetadata } from "#/features/game/registry/game-public-registry.tsx"; +import { clearSynced, deleteOp } from "#/features/sync/queue/pending-ops"; +import type { PendingOp } from "#/features/sync/queue/types"; +import { usePendingOps } from "#/features/sync/queue/use-pending-ops"; +import { forceSyncOp, syncOps } from "#/features/sync/sync-runner"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/profile-tab-head.ts"; -import { clearSynced, deleteOp } from "#/features/dal/queue/pending-ops"; -import { forceSyncOp, syncOps } from "#/features/dal/queue/sync-runner"; -import type { PendingOp } from "#/features/dal/queue/types"; -import { usePendingOps } from "#/features/dal/queue/use-pending-ops"; -import { getGameMetadata } from "#/features/game/registry/game-registry"; +} from "#/features/user/profile-tab-head.ts"; import { useSession } from "#/integrations/better-auth/auth-client"; const PendingList = ({ @@ -257,14 +257,15 @@ function DataSync() { }); }, onSettled: () => { - void queryClient.invalidateQueries({ queryKey: ["dal-queue"] }); - void queryClient.invalidateQueries({ queryKey: ["dal"] }); + void queryClient.invalidateQueries({ queryKey: ["sync-queue"] }); + void queryClient.invalidateQueries({ queryKey: ["data"] }); }, }); const clear = useMutation({ mutationFn: () => clearSynced(), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dal-queue"] }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["sync-queue"] }), }); const keepMine = useMutation({ @@ -285,8 +286,8 @@ function DataSync() { }); }, onSettled: () => { - void queryClient.invalidateQueries({ queryKey: ["dal-queue"] }); - void queryClient.invalidateQueries({ queryKey: ["dal"] }); + void queryClient.invalidateQueries({ queryKey: ["sync-queue"] }); + void queryClient.invalidateQueries({ queryKey: ["data"] }); }, }); @@ -300,8 +301,8 @@ function DataSync() { }); }, onSettled: () => { - void queryClient.invalidateQueries({ queryKey: ["dal-queue"] }); - void queryClient.invalidateQueries({ queryKey: ["dal"] }); + void queryClient.invalidateQueries({ queryKey: ["sync-queue"] }); + void queryClient.invalidateQueries({ queryKey: ["data"] }); }, }); @@ -336,7 +337,7 @@ function DataSync() { ops={(pending.data ?? []).filter((op) => op.status !== "synced")} onDelete={(id) => { deleteOp(id).then(() => - queryClient.invalidateQueries({ queryKey: ["dal-queue"] }), + queryClient.invalidateQueries({ queryKey: ["sync-queue"] }), ); }} onKeepMine={(op) => keepMine.mutate(op)} diff --git a/src/routes/account/profile/$userId/index.tsx b/src/routes/account/profile/$userId/index.tsx index 4dab966..2ef3092 100644 --- a/src/routes/account/profile/$userId/index.tsx +++ b/src/routes/account/profile/$userId/index.tsx @@ -1,17 +1,8 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; - -function ProfileHome() { - return ( - - Profile - Content coming soon. - - ); -} +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; const Route = createFileRoute("/account/profile/$userId/")({ - component: ProfileHome, + component: () => , }); export { Route }; diff --git a/src/routes/account/profile/$userId/liked-builds.tsx b/src/routes/account/profile/$userId/liked-builds.tsx index 1d6e1ec..c1f77cc 100644 --- a/src/routes/account/profile/$userId/liked-builds.tsx +++ b/src/routes/account/profile/$userId/liked-builds.tsx @@ -1,18 +1,9 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/profile-tab-head.ts"; - -function LikedBuilds() { - return ( - - Liked Builds - Content coming soon. - - ); -} +} from "#/features/user/profile-tab-head.ts"; const Route = createFileRoute("/account/profile/$userId/liked-builds")({ loader: async ({ params, context }) => @@ -23,7 +14,7 @@ const Route = createFileRoute("/account/profile/$userId/liked-builds")({ "Liked Builds", ), }), - component: LikedBuilds, + component: () => , }); export { Route }; diff --git a/src/routes/account/profile/$userId/route.tsx b/src/routes/account/profile/$userId/route.tsx index c3e8199..f5aa7f4 100644 --- a/src/routes/account/profile/$userId/route.tsx +++ b/src/routes/account/profile/$userId/route.tsx @@ -6,17 +6,17 @@ import { SERVER_GAME_INPUTS_QUERY_KEY, } from "#/constants.ts"; import { clientEnv } from "#/env/client-env.ts"; -import { ProfileHeader } from "#/features/auth/ProfileHeader.tsx"; -import { ProfileTabNav } from "#/features/auth/ProfileTabNav.tsx"; -import { resolveAvatar } from "#/features/auth/utils.ts"; -import { getServerResolvedGameInputsServerFn } from "#/features/game/dal/active-game"; +import { getServerResolvedGameInputsServerFn } from "#/features/game/active-game"; import { buildGetProfileQueryKey, + getPublicUserProfileServerFn, getViewerUserIdServerFn, mapUserToProfileData, -} from "#/features/game/dal/user-profile/user-profile.dal.ts"; -import { getPublicUserProfileServerFn } from "#/features/game/dal/user-profile/user-profile.ts"; -import { getValidatedGameId } from "#/features/game/registry/game-registry"; +} from "#/features/game/data/user-profile/user-profile.ts"; +import { getValidatedGameId } from "#/features/game/registry/game-public-registry.tsx"; +import { ProfileHeader } from "#/features/user/ProfileHeader.tsx"; +import { ProfileTabNav } from "#/features/user/ProfileTabNav.tsx"; +import { resolveAvatar } from "#/features/user/utils.ts"; import type { GameId } from "@/prisma"; const ProfileLayout = () => { diff --git a/src/routes/profile/build-collections.tsx b/src/routes/profile/build-collections.tsx index 4b8c6a5..2ed05d2 100644 --- a/src/routes/profile/build-collections.tsx +++ b/src/routes/profile/build-collections.tsx @@ -1,17 +1,8 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; - -function BuildCollections() { - return ( - - Build Collections - Content coming soon. - - ); -} +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; const Route = createFileRoute("/profile/build-collections")({ - component: BuildCollections, + component: () => , }); export { Route }; diff --git a/src/routes/profile/collected-items.tsx b/src/routes/profile/collected-items.tsx index fbe9768..b05841e 100644 --- a/src/routes/profile/collected-items.tsx +++ b/src/routes/profile/collected-items.tsx @@ -1,11 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getGameConfig } from "#/features/game/registry/game-registry"; import { useGameId } from "#/features/game/use-game-id.ts"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; function CollectedItems() { const gameId = useGameId(); - const config = getGameConfig(gameId); - return <>{config?.PAGES.renderCollectedItems({ mode: { kind: "self" } })}; + const pages = getGamePages(gameId); + return <>{pages?.renderCollectedItems({ mode: { kind: "self" } })}; } const Route = createFileRoute("/profile/collected-items")({ diff --git a/src/routes/profile/collection-stats.tsx b/src/routes/profile/collection-stats.tsx index 8b199bf..fad1c5a 100644 --- a/src/routes/profile/collection-stats.tsx +++ b/src/routes/profile/collection-stats.tsx @@ -1,17 +1,8 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; - -function CollectionStats() { - return ( - - Collection Stats - Content coming soon. - - ); -} +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; const Route = createFileRoute("/profile/collection-stats")({ - component: CollectionStats, + component: () => , }); export { Route }; diff --git a/src/routes/profile/created-builds.tsx b/src/routes/profile/created-builds.tsx index e495393..2dfea36 100644 --- a/src/routes/profile/created-builds.tsx +++ b/src/routes/profile/created-builds.tsx @@ -1,12 +1,17 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; +import { useGameId } from "#/features/game/use-game-id.ts"; +import { getGamePages } from "#/features/game/registry/game-pages-registry.tsx"; +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; function CreatedBuilds() { + const gameId = useGameId(); + const pages = getGamePages(gameId); return ( - - Created Builds - Content coming soon. - + <> + {pages?.renderCreatedBuilds?.({ mode: { kind: "self" } }) ?? ( + + )} + ); } diff --git a/src/routes/profile/index.tsx b/src/routes/profile/index.tsx index a3f2f74..ddc7c96 100644 --- a/src/routes/profile/index.tsx +++ b/src/routes/profile/index.tsx @@ -1,7 +1,7 @@ import { Badge, Card, Group, Stack, Text } from "@mantine/core"; import { useNetwork } from "@mantine/hooks"; import { createFileRoute } from "@tanstack/react-router"; -import { useEffectiveUserId } from "#/features/dal/identity/use-effective-user-id"; +import { useEffectiveUserId } from "#/features/sync/identity/use-effective-user-id"; import { useSession } from "#/integrations/better-auth/auth-client"; function LocalProfileHome() { diff --git a/src/routes/profile/liked-builds.tsx b/src/routes/profile/liked-builds.tsx index b123d95..636b6b2 100644 --- a/src/routes/profile/liked-builds.tsx +++ b/src/routes/profile/liked-builds.tsx @@ -1,17 +1,8 @@ -import { Stack, Text, Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; - -function LikedBuilds() { - return ( - - Liked Builds - Content coming soon. - - ); -} +import { ProfileTabPlaceholder } from "#/features/user/ProfileTabPlaceholder.tsx"; const Route = createFileRoute("/profile/liked-builds")({ - component: LikedBuilds, + component: () => , }); export { Route }; diff --git a/src/routes/profile/route.tsx b/src/routes/profile/route.tsx index fe4b332..d0617eb 100644 --- a/src/routes/profile/route.tsx +++ b/src/routes/profile/route.tsx @@ -2,8 +2,8 @@ import { Box, Stack } from "@mantine/core"; import { useNetwork } from "@mantine/hooks"; import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; -import { ProfileHeader } from "#/features/auth/ProfileHeader.tsx"; -import { ProfileTabNav } from "#/features/auth/ProfileTabNav.tsx"; +import { ProfileHeader } from "#/features/user/ProfileHeader.tsx"; +import { ProfileTabNav } from "#/features/user/ProfileTabNav.tsx"; import { useSession } from "#/integrations/better-auth/auth-client"; const LocalProfileLayout = () => { diff --git a/src/utils.ts b/src/utils.ts index 6dc2e51..4a3b1fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,63 @@ -const capitalize = (s: string): string => +import { isRegisteredGameId } from "#/features/game/registry/game-public-registry.tsx"; +import type { GameId } from "@/prisma"; + +const ROOT_DOMAINS = ["toolkits.gg", "www.toolkits.gg", "localhost"]; + +export const capitalize = (s: string): string => s.length === 0 ? s : s[0].toUpperCase() + s.slice(1); -export { capitalize }; +/** Title-cases a string, splitting on spaces and underscores. */ +export const titleCase = (str: string): string => + str + .split(/[_ ]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + +/** + * Tiny cookie-header parser for server-side reads. Returns the decoded value of + * the named cookie or null if absent. Format: "a=1; b=2; c=3". + */ +export const parseCookie = (header: string, name: string): string | null => { + const parts = header.split(/;\s*/); + for (const part of parts) { + const eq = part.indexOf("="); + if (eq === -1) continue; + const k = part.slice(0, eq); + if (k !== name) continue; + const v = part.slice(eq + 1); + try { + return decodeURIComponent(v); + } catch { + return v; + } + } + return null; +}; +export const parseSubdomain = (hostname: string): GameId | null => { + // Strip port (e.g. localhost:3000) + const host = hostname.split(":")[0]; + + // No subdomain possible on bare localhost + if (host === "localhost") return null; + + // Check against known root domains + for (const root of ROOT_DOMAINS) { + if (host === root) return null; + } + + // e.g. "remnant2.toolkits.gg" -> ["remnant2", "toolkits", "gg"] + const parts = host.split("."); + + // Need at least 3 parts for a subdomain: [sub, domain, tld] + if (parts.length < 3) return null; + + const subdomain = parts[0]; + + // Reject "www" explicitly in case it slips through + if (subdomain === "www") return null; + + // Reject invalid subdomains + if (!isRegisteredGameId(subdomain)) return null; + + return subdomain; +};