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 }) => (
+
+
+
+ Profile Home
+
+
+
+);
+
+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;
+};