diff --git a/.agents/skills/better-auth-best-practices/SKILL.md b/.agents/skills/better-auth-best-practices/SKILL.md deleted file mode 100644 index ede2e89..0000000 --- a/.agents/skills/better-auth-best-practices/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: better-auth-best-practices -description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration. ---- - -# Better Auth Integration Guide - -**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.** - ---- - -## Setup Workflow - -1. Install: `npm install better-auth` -2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` -3. Create `auth.ts` with database + config -4. Create route handler for your framework -5. Run `npx @better-auth/cli@latest migrate` -6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }` - ---- - -## Quick Reference - -### Environment Variables -- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32` -- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`) - -Only define `baseURL`/`secret` in config if env vars are NOT set. - -### File Location -CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path. - -### CLI Commands -- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter) -- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle -- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools - -**Re-run after adding/changing plugins.** - ---- - -## Core Config Options - -| Option | Notes | -|--------|-------| -| `appName` | Optional display name | -| `baseURL` | Only if `BETTER_AUTH_URL` not set | -| `basePath` | Default `/api/auth`. Set `/` for root. | -| `secret` | Only if `BETTER_AUTH_SECRET` not set | -| `database` | Required for most features. See adapters docs. | -| `secondaryStorage` | Redis/KV for sessions & rate limits | -| `emailAndPassword` | `{ enabled: true }` to activate | -| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` | -| `plugins` | Array of plugins | -| `trustedOrigins` | CSRF whitelist | - ---- - -## Database - -**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. - -**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`. - -**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`. - ---- - -## Session Management - -**Storage priority:** -1. If `secondaryStorage` defined -> sessions go there (not DB) -2. Set `session.storeSessionInDatabase: true` to also persist to DB -3. No database + `cookieCache` -> fully stateless mode - -**Cookie cache strategies:** -- `compact` (default) - Base64url + HMAC. Smallest. -- `jwt` - Standard JWT. Readable but signed. -- `jwe` - Encrypted. Maximum security. - -**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions). - ---- - -## User & Account Config - -**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default). - -**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth). - -**Required for registration:** `email` and `name` fields. - ---- - -## Email Flows - -- `emailVerification.sendVerificationEmail` - Must be defined for verification to work -- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers -- `emailAndPassword.sendResetPassword` - Password reset email handler - ---- - -## Security - -**In `advanced`:** -- `useSecureCookies` - Force HTTPS cookies -- `disableCSRFCheck` - ⚠️ Security risk -- `disableOriginCheck` - ⚠️ Security risk -- `crossSubDomainCookies.enabled` - Share cookies across subdomains -- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies -- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false` - -**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage"). - ---- - -## Hooks - -**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`. - -**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions. - -**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`. - ---- - -## Plugins - -**Import from dedicated paths for tree-shaking:** -``` -import { twoFactor } from "better-auth/plugins/two-factor" -``` -NOT `from "better-auth/plugins"`. - -**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`. - -Client plugins go in `createAuthClient({ plugins: [...] })`. - ---- - -## Client - -Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`. - -Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`. - ---- - -## Type Safety - -Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`. - -For separate client/server projects: `createAuthClient()`. - ---- - -## Common Gotchas - -1. **Model vs table name** - Config uses ORM model name, not DB table name -2. **Plugin schema** - Re-run CLI after adding plugins -3. **Secondary storage** - Sessions go there by default, not DB -4. **Cookie cache** - Custom session fields NOT cached, always re-fetched -5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry -6. **Change email flow** - Sends to current email first, then new email - ---- - -## Resources - -- [Docs](https://better-auth.com/docs) -- [Options Reference](https://better-auth.com/docs/reference/options) -- [LLMs.txt](https://better-auth.com/llms.txt) -- [GitHub](https://github.com/better-auth/better-auth) -- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts) \ No newline at end of file diff --git a/.agents/skills/mantine-custom-components/SKILL.md b/.agents/skills/mantine-custom-components/SKILL.md deleted file mode 100644 index cacb44e..0000000 --- a/.agents/skills/mantine-custom-components/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: mantine-custom-core -description: > - Build custom components that integrate with Mantine's theming, Styles API, and core features. - Use this skill when: (1) creating a new component using factory(), polymorphicFactory(), or - genericFactory(), (2) adding Styles API support (classNames, styles, vars, unstyled), (3) - implementing CSS variables via createVarsResolver, (4) building compound components with - sub-components and shared context, (5) registering a component with MantineProvider via - Component.extend(), or (6) any task involving Factory, useProps, useStyles, BoxProps, - StylesApiProps, or ElementProps in @mantine/core. ---- - -# Mantine Custom Components Skill - -## Component template - -```tsx -import { - Box, BoxProps, createVarsResolver, ElementProps, - factory, Factory, getRadius, MantineRadius, - StylesApiProps, useProps, useStyles, -} from '@mantine/core'; -import classes from './MyComponent.module.css'; - -export type MyComponentStylesNames = 'root' | 'inner'; -export type MyComponentVariant = 'filled' | 'outline'; -export type MyComponentCssVariables = { root: '--my-radius' }; - -export interface MyComponentProps - extends BoxProps, StylesApiProps, ElementProps<'div'> { - radius?: MantineRadius; -} - -export type MyComponentFactory = Factory<{ - props: MyComponentProps; - ref: HTMLDivElement; - stylesNames: MyComponentStylesNames; - vars: MyComponentCssVariables; - variant: MyComponentVariant; -}>; - -const defaultProps = { radius: 'md' } satisfies Partial; - -const varsResolver = createVarsResolver((_theme, { radius }) => ({ - root: { '--my-radius': getRadius(radius) }, -})); - -export const MyComponent = factory((_props) => { - const props = useProps('MyComponent', defaultProps, _props); - const { classNames, className, style, styles, unstyled, vars, attributes, radius, ...others } = props; - - const getStyles = useStyles({ - name: 'MyComponent', classes, props, - className, style, classNames, styles, unstyled, vars, attributes, varsResolver, - }); - - return ; -}); - -MyComponent.displayName = '@mantine/core/MyComponent'; -MyComponent.classes = classes; -``` - -## Factory variant — which to use - -| Scenario | Factory function | Type | -|---|---|---| -| Standard component | `factory()` | `Factory<{}>` | -| Supports `component` prop (polymorphic) | `polymorphicFactory()` | `PolymorphicFactory<{}>` — add `defaultComponent` and `defaultRef` | -| Props change based on a generic (e.g. `multiple`) | `genericFactory()` | `Factory<{ signature: ... }>` | - -Use `polymorphicFactory` sparingly — it adds TypeScript overhead and slows IDE autocomplete. - -## Factory type fields - -```ts -Factory<{ - props: MyComponentProps; // required - ref: HTMLDivElement; // element type for the forwarded ref - stylesNames: 'root' | 'inner'; // union of Styles API selectors - vars: { root: '--my-var' }; // CSS variable map per selector - variant: 'filled' | 'outline'; // accepted variant strings - staticComponents: { // sub-core (compound pattern) - Item: typeof MyComponentItem; - }; - compound?: boolean; // true = sub-component; disables theme classNames/styles/vars - ctx?: MyContextType; // passed to styles/vars resolvers as third arg - signature?: (...) => JSX.Element; // only for genericFactory -}> -``` - -## Theme integration - -Users and the theme can override defaults via `Component.extend()`: - -```ts -const theme = createTheme({ - components: { - MyComponent: MyComponent.extend({ - defaultProps: { radius: 'xl' }, - classNames: { root: 'my-root' }, - styles: { root: { color: 'red' } }, - vars: (_theme, props) => ({ root: { '--my-radius': getRadius(props.radius) } }), - }), - }, -}); -``` - -## References - -- **[`references/api.md`](references/api.md)** — All imports: `factory`, `useProps`, `useStyles`, `createVarsResolver`, `createSafeContext`, `StylesApiProps`, `CompoundStylesApiProps`, `BoxProps`, `ElementProps`, theme helpers (`getSize`, `getRadius`, etc.) -- **[`references/patterns.md`](references/patterns.md)** — Full examples: compound components with context, polymorphic component, generic component, theme integration diff --git a/.agents/skills/mantine-custom-components/references/api.md b/.agents/skills/mantine-custom-components/references/api.md deleted file mode 100644 index b471a38..0000000 --- a/.agents/skills/mantine-custom-components/references/api.md +++ /dev/null @@ -1,407 +0,0 @@ -# Custom Components API Reference - -## Table of Contents -- [Imports cheatsheet](#imports-cheatsheet) -- [factory / polymorphicFactory / genericFactory](#factory--polymorphicfactory--genericfactory) -- [Factory type fields](#factory-type-fields) -- [useProps](#usepropss) -- [useStyles](#usestyles) -- [createVarsResolver](#createvarsresolver) -- [StylesApiProps and CompoundStylesApiProps](#stylesapiprops-and-compoundstylesapiprops) -- [BoxProps and ElementProps](#boxprops-and-elementprops) -- [createSafeContext](#createsafecontext) -- [Theme helper functions](#theme-helper-functions) -- [Static properties](#static-properties) - ---- - -## Imports cheatsheet - -```ts -import { - // Factory functions - factory, - polymorphicFactory, - genericFactory, - - // Types - Factory, - PolymorphicFactory, - StylesApiProps, - CompoundStylesApiProps, - BoxProps, - ElementProps, - - // Hooks - useProps, - useStyles, - - // Vars - createVarsResolver, - - // Context - createSafeContext, - - // Base component - Box, - - // Theme helpers - getSize, - getSpacing, - getRadius, - getFontSize, - getLineHeight, - getShadow, - rem, - em, -} from '@mantine/core'; -``` - ---- - -## factory / polymorphicFactory / genericFactory - -### factory() - -Standard factory for non-polymorphic components. - -```ts -factory( - ui: (props: Payload['props'] & { ref?: React.Ref }) => React.ReactNode -): MantineComponent -``` - -The returned component has static properties: `.extend()`, `.withProps()`, `.classes`, `.displayName`, `.varsResolver` (if vars are used), and any sub-components assigned. - -### polymorphicFactory() - -For components that accept a `component` prop to render as a different element. Same signature as `factory()`, but uses `PolymorphicFactory` type. - -```ts -// Type uses PolymorphicFactory<{}> instead of Factory<{}> -export type MyFactory = PolymorphicFactory<{ - props: MyProps; - defaultRef: HTMLButtonElement; // default ref type - defaultComponent: 'button'; // default element - stylesNames: ...; - vars: ...; -}>; - -export const My = polymorphicFactory((_props) => { ... }); -``` - -### genericFactory() - -For components whose prop types depend on a generic argument. - -```ts -// Factory uses 'signature' field -export type MyFactory = Factory<{ - props: MyProps; - signature: (props: MyProps) => React.JSX.Element; - ref: HTMLDivElement; - // ... other fields -}>; - -export const My = genericFactory((_props) => { ... }); -``` - ---- - -## Factory type fields - -All fields except `props` are optional. - -```ts -Factory<{ - props: MyComponentProps; - - // Forwarded ref element type - ref: HTMLDivElement; - - // Union of Styles API selector strings (must match CSS module class names) - stylesNames: 'root' | 'label' | 'icon'; - - // CSS variables definition: { selectorName: '--var-name' | '--other-var' } - vars: { - root: '--my-height' | '--my-color'; - label: '--my-label-fz'; - }; - - // Accepted values for the variant prop - variant: 'filled' | 'outline' | 'subtle'; - - // Sub-core for compound pattern - staticComponents: { - Item: typeof MyItem; - Label: typeof MyLabel; - }; - - // Set to true for sub-core — disables theme classNames/styles/vars for this component - compound: true; - - // Context type passed as 3rd argument to styles/vars resolvers - ctx: { opened: boolean }; - - // Generic signature (genericFactory only) - signature: (props: MyProps) => React.JSX.Element; -}> -``` - ---- - -## useProps - -Merges default props from three sources in priority order (highest -> lowest): -1. Props passed by the user -2. Default props from `MantineProvider` theme (`components.MyComponent.defaultProps`) -3. Component-level `defaultProps` - -```ts -useProps>( - componentName: string, // must match the name used in theme.core - defaultProps: Partial, // use 'satisfies Partial' for correct inference - props: T -): T -``` - -**Important:** Always call `useProps` before destructuring. Always use `satisfies Partial` (not `: Partial`) for `defaultProps` to preserve narrowed types. - -```ts -const defaultProps = { size: 'md', variant: 'filled' } satisfies Partial; - -const props = useProps('MyComponent', defaultProps, _props); -const { className, style, classNames, styles, unstyled, vars, attributes, ...others } = props; -``` - ---- - -## useStyles - -Returns a `getStyles` function that provides `className` and `style` for each Styles API selector. - -```ts -useStyles(input: { - name: string | string[]; // component name(s) for static CSS class generation - classes: Record; // CSS module classes object - props: Payload['props']; - stylesCtx?: Payload['ctx']; // optional context for styles/vars resolvers - className?: string; // spread to rootSelector - style?: MantineStyleProp; // spread to rootSelector - rootSelector?: string; // which selector gets className/style (default: 'root') - unstyled?: boolean; - classNames?: ClassNames; - styles?: Styles; - vars?: PartialVarsResolver; - varsResolver?: VarsResolver; - attributes?: Attributes; -}): GetStylesApi -``` - -**`getStyles` function:** -```ts -getStyles( - selector: StylesNames, - options?: { - className?: string; // additional className merged in - style?: CSSProperties; // additional style merged in - focusable?: boolean; - active?: boolean; - withStaticClass?: boolean; - } -): { className: string; style: CSSProperties } -``` - -Usage: -```tsx - -
-``` - ---- - -## createVarsResolver - -Defines how component props map to CSS variables. - -```ts -createVarsResolver( - resolver: ( - theme: MantineTheme, - props: Payload['props'], - ctx: Payload['ctx'] // only if Factory has ctx field - ) => TransformVars -): VarsResolver -``` - -The resolver must return an object matching the `vars` structure defined in `Factory`: - -```ts -// Factory vars: { root: '--my-height' | '--my-color' } -const varsResolver = createVarsResolver((_theme, { size, color }) => ({ - root: { - '--my-height': getSize(size, 'my-height'), - '--my-color': color ?? undefined, // undefined = CSS var not set (uses CSS fallback) - }, -})); -``` - -Assign to the component after creation: -```ts -MyComponent.varsResolver = varsResolver; -``` - ---- - -## StylesApiProps and CompoundStylesApiProps - -**`StylesApiProps`** — extend on the root/main component's props interface: -```ts -interface StylesApiProps { - unstyled?: boolean; - variant?: Payload['variant'] | (string & {}); - classNames?: ClassNames; // { root: 'my-class', inner: 'other' } or callback - styles?: Styles; // { root: { color: 'red' } } or callback - vars?: PartialVarsResolver; // (theme, props) => { root: { '--my-var': '...' } } - attributes?: Attributes; // { root: { 'data-custom': value } } -} -``` - -**`CompoundStylesApiProps`** — extend on sub-component (compound) props instead. Subset of `StylesApiProps` — no `unstyled` or `attributes`. - -```ts -interface CompoundStylesApiProps - extends Omit, 'unstyled' | 'attributes'> {} -``` - -Compound sub-components also use `Factory<{ ..., compound: true }>` and access styles via the parent context's `getStyles`. - ---- - -## BoxProps and ElementProps - -**`BoxProps`** — extends `MantineStyleProps`, adds: -```ts -interface BoxProps extends MantineStyleProps { - className?: string; - style?: MantineStyleProp; // accepts function: (theme) => CSSProperties - mod?: string | Record | (string | Record)[]; // data-* attributes - hiddenFrom?: MantineBreakpoint; // hidden at this breakpoint and above - visibleFrom?: MantineBreakpoint; // visible only at this breakpoint and above - lightHidden?: boolean; // hidden in light color scheme - darkHidden?: boolean; // hidden in dark color scheme -} -``` - -**`MantineStyleProps`** — shorthand style props (all accept responsive `{ base, sm, md, lg, xl }` objects): - -| Prop | CSS property | Prop | CSS property | -|---|---|---|---| -| `m` `mt` `mb` `ml` `mr` `mx` `my` `ms` `me` | margin variants | `p` `pt` `pb` `pl` `pr` `px` `py` `ps` `pe` | padding variants | -| `w` `miw` `maw` | width | `h` `mih` `mah` | height | -| `c` | color | `bg` | background | -| `fz` | font-size | `fw` | font-weight | -| `ff` | font-family | `fs` | font-style | -| `lh` | line-height | `lts` | letter-spacing | -| `ta` | text-align | `tt` | text-transform | -| `td` | text-decoration | `bd` | border | -| `bdrs` | border-radius | `opacity` | opacity | -| `pos` | position | `top` `left` `bottom` `right` `inset` | positioning | -| `display` | display | `flex` | flex | - -**`ElementProps`** — gets HTML element props, remapping `style` to Mantine's type: -```ts -// Include all div props except style (remapped) and any conflicting props -interface MyProps extends ElementProps<'div'> {} - -// Omit conflicting HTML attrs (e.g. input has native 'size' and 'color') -interface MyProps extends ElementProps<'input', 'size' | 'color'> { - size?: MantineSize; - color?: MantineColor; -} - -// Can also accept a React component type instead of element string -interface MyProps extends ElementProps {} -``` - ---- - -## createSafeContext - -Used inside compound components to share state from the parent to sub-components. - -```ts -createSafeContext( - errorMessage: string // thrown when hook is used outside the provider -): [ - Context: React.Context, - useContext: () => ContextValue // throws errorMessage if used outside provider -] -``` - -**Usage pattern (in ComponentName.context.ts):** -```ts -import { createSafeContext, GetStylesApi } from '@mantine/core'; -import { MyFactory } from './MyComponent'; - -interface MyContextValue { - getStyles: GetStylesApi; - // other shared state... -} - -export const [MyProvider, useMyContext] = createSafeContext( - 'MyComponent was not found in tree' -); -``` - -In the root component: -```tsx -return ( - - {children} - -); -``` - -In sub-components: -```tsx -const { getStyles } = useMyContext(); -return
; -``` - ---- - -## Theme helper functions - -Use these in `createVarsResolver` to convert Mantine size tokens to CSS values: - -| Function | Input | Output example | -|---|---|---| -| `getSize(size, prefix)` | `'sm'`, `'button-height'` | `'var(--mantine-button-height-sm)'` | -| `getSpacing(size)` | `'md'` or `16` | `'var(--mantine-spacing-md)'` or `'1rem'` | -| `getRadius(size)` | `'sm'` or `4` | `'var(--mantine-radius-sm)'` or `'0.25rem'` | -| `getFontSize(size)` | `'sm'` | `'var(--mantine-font-size-sm)'` | -| `getLineHeight(size)` | `'sm'` | `'var(--mantine-line-height-sm)'` | -| `getShadow(size)` | `'md'` | `'var(--mantine-shadow-md)'` | -| `rem(value)` | `16` | `'1rem'` | -| `em(value)` | `16` | `'1em'` | - -Return `undefined` from a var resolver entry to leave that CSS variable unset (CSS fallback applies). - ---- - -## Static properties - -These must be set on every component after creation: - -```ts -MyComponent.displayName = '@mantine/core/MyComponent'; // or '@mantine/package/Name' -MyComponent.classes = classes; // CSS module classes object -MyComponent.varsResolver = varsResolver; // only if component defines vars - -// Sub-core (compound pattern) -MyComponent.Item = MyItem; -MyComponent.Label = MyLabel; -``` - -`.extend()` and `.withProps()` are added automatically by `factory()`. diff --git a/.agents/skills/mantine-custom-components/references/patterns.md b/.agents/skills/mantine-custom-components/references/patterns.md deleted file mode 100644 index acbe0c5..0000000 --- a/.agents/skills/mantine-custom-components/references/patterns.md +++ /dev/null @@ -1,431 +0,0 @@ -# Custom Component Patterns - -## Table of Contents -- [Minimal component (no styles API)](#minimal-component-no-styles-api) -- [Component with CSS variables](#component-with-css-variables) -- [Compound component with context](#compound-component-with-context) -- [Polymorphic component](#polymorphic-component) -- [Generic component](#generic-component) -- [Theme integration](#theme-integration) -- [Namespace exports](#namespace-exports) - ---- - -## Minimal component (no styles API) - -When you don't need theming/Styles API support — just Box + useProps. - -```tsx -import { Box, BoxProps, ElementProps, factory, Factory, useProps } from '@mantine/core'; - -export interface MinimalProps extends BoxProps, ElementProps<'div'> { - label?: string; -} - -export type MinimalFactory = Factory<{ - props: MinimalProps; - ref: HTMLDivElement; -}>; - -const defaultProps = {} satisfies Partial; - -export const Minimal = factory((_props) => { - const props = useProps('Minimal', defaultProps, _props); - const { label, children, ...others } = props; - - return ( - - {label && {label}} - {children} - - ); -}); - -Minimal.displayName = '@mantine/core/Minimal'; -``` - ---- - -## Component with CSS variables - -Full example with Styles API, CSS variables, and theme integration. - -**MyComponent.module.css:** -```css -.root { - border-radius: var(--my-radius); - padding: var(--my-padding); -} - -.inner { - font-size: var(--my-fz); -} -``` - -**MyComponent.tsx:** -```tsx -import { - Box, BoxProps, createVarsResolver, ElementProps, factory, Factory, - getFontSize, getRadius, getSpacing, MantineFontSize, MantineRadius, - MantineSpacing, StylesApiProps, useProps, useStyles, -} from '@mantine/core'; -import classes from './MyComponent.module.css'; - -export type MyComponentStylesNames = 'root' | 'inner'; -export type MyComponentVariant = 'filled' | 'outline'; -export type MyComponentCssVariables = { - root: '--my-radius' | '--my-padding'; - inner: '--my-fz'; -}; - -export interface MyComponentProps - extends BoxProps, StylesApiProps, ElementProps<'div'> { - radius?: MantineRadius; - padding?: MantineSpacing; - size?: MantineFontSize; - variant?: MyComponentVariant; -} - -export type MyComponentFactory = Factory<{ - props: MyComponentProps; - ref: HTMLDivElement; - stylesNames: MyComponentStylesNames; - vars: MyComponentCssVariables; - variant: MyComponentVariant; -}>; - -const defaultProps = { - radius: 'sm', - padding: 'md', - size: 'md', -} satisfies Partial; - -const varsResolver = createVarsResolver((_theme, { radius, padding, size }) => ({ - root: { - '--my-radius': getRadius(radius), - '--my-padding': getSpacing(padding), - }, - inner: { - '--my-fz': getFontSize(size), - }, -})); - -export const MyComponent = factory((_props) => { - const props = useProps('MyComponent', defaultProps, _props); - const { - classNames, className, style, styles, unstyled, vars, attributes, - radius, padding, size, - children, - ...others - } = props; - - const getStyles = useStyles({ - name: 'MyComponent', - classes, - props, - className, - style, - classNames, - styles, - unstyled, - vars, - attributes, - varsResolver, - }); - - return ( - -
{children}
-
- ); -}); - -MyComponent.displayName = '@mantine/core/MyComponent'; -MyComponent.classes = classes; -MyComponent.varsResolver = varsResolver; -``` - ---- - -## Compound component with context - -Pattern for components with typed sub-components (e.g. `Card.Section`, `Tabs.Tab`). - -**MyCard.context.ts:** -```ts -import { createSafeContext, GetStylesApi } from '@mantine/core'; -import type { MyCardFactory } from './MyCard'; - -interface MyCardContextValue { - getStyles: GetStylesApi; - orientation: 'horizontal' | 'vertical'; -} - -export const [MyCardProvider, useMyCardContext] = createSafeContext( - 'MyCard component was not found in tree' -); -``` - -**MyCardSection.tsx** (sub-component): -```tsx -import { - Box, BoxProps, CompoundStylesApiProps, ElementProps, - factory, Factory, useProps, useStyles, -} from '@mantine/core'; -import { useMyCardContext } from './MyCard.context'; -import classes from './MyCard.module.css'; - -export type MyCardSectionStylesNames = 'section'; - -export interface MyCardSectionProps - extends BoxProps, CompoundStylesApiProps, ElementProps<'div'> { - withBorder?: boolean; -} - -export type MyCardSectionFactory = Factory<{ - props: MyCardSectionProps; - ref: HTMLDivElement; - stylesNames: MyCardSectionStylesNames; - compound: true; // marks as a compound sub-component -}>; - -const defaultProps = {} satisfies Partial; - -export const MyCardSection = factory((_props) => { - const props = useProps('MyCardSection', defaultProps, _props); - const { className, style, classNames, styles, withBorder, children, ...others } = props; - - // Access styles from parent context - const { getStyles } = useMyCardContext(); - - return ( - - {children} - - ); -}); - -MyCardSection.displayName = '@mantine/core/MyCardSection'; -``` - -**MyCard.tsx** (root component): -```tsx -import { MyCardProvider } from './MyCard.context'; - -// ... (same Styles API setup as above) - -export type MyCardFactory = Factory<{ - props: MyCardProps; - ref: HTMLDivElement; - stylesNames: 'root' | 'section'; // include sub-component selectors too - staticComponents: { - Section: typeof MyCardSection; - }; -}>; - -export const MyCard = factory((_props) => { - const props = useProps('MyCard', defaultProps, _props); - const { - classNames, className, style, styles, unstyled, vars, attributes, - orientation, children, ...others - } = props; - - const getStyles = useStyles({ ... }); - - return ( - - {children} - - ); -}); - -MyCard.displayName = '@mantine/core/MyCard'; -MyCard.classes = classes; -MyCard.Section = MyCardSection; // attach sub-component -``` - ---- - -## Polymorphic component - -Supports `component` prop to render as any element or React component. - -```tsx -import { - Box, BoxProps, polymorphicFactory, PolymorphicFactory, - StylesApiProps, useProps, useStyles, -} from '@mantine/core'; - -export type MyLinkStylesNames = 'root'; - -export interface MyLinkProps extends BoxProps, StylesApiProps { - active?: boolean; -} - -export type MyLinkFactory = PolymorphicFactory<{ - props: MyLinkProps; - defaultRef: HTMLAnchorElement; - defaultComponent: 'a'; // renders as unless component prop is provided - stylesNames: MyLinkStylesNames; -}>; - -const defaultProps = {} satisfies Partial; - -export const MyLink = polymorphicFactory((_props) => { - const props = useProps('MyLink', defaultProps, _props); - const { - classNames, className, style, styles, unstyled, vars, attributes, - active, ...others - } = props; - - const getStyles = useStyles({ - name: 'MyLink', classes, props, className, style, - classNames, styles, unstyled, vars, attributes, - }); - - return ( - - ); -}); - -MyLink.displayName = '@mantine/core/MyLink'; -MyLink.classes = classes; -``` - -**Usage:** -```tsx -Link -As button -Router link -``` - ---- - -## Generic component - -For components where prop types depend on a generic parameter. - -```tsx -import { factory, Factory, genericFactory, useProps } from '@mantine/core'; - -type SelectValue = M extends true ? string[] : string | null; - -export interface MySelectProps - extends BoxProps, StylesApiProps { - multiple?: M; - value?: SelectValue; - defaultValue?: SelectValue; - onChange?: (value: SelectValue) => void; -} - -export type MySelectFactory = Factory<{ - props: MySelectProps; - ref: HTMLDivElement; - signature: (props: MySelectProps) => React.JSX.Element; - stylesNames: 'root'; -}>; - -const defaultProps = { multiple: false } satisfies Partial; - -export const MySelect = genericFactory((_props) => { - const props = useProps('MySelect', defaultProps as any, _props); - const { multiple, value, onChange, ...others } = props; - // ... -}); - -MySelect.displayName = '@mantine/core/MySelect'; -``` - -**Usage:** -```tsx -// TypeScript infers value as string | null - setVal(v)} /> - -// TypeScript infers value as string[] - setVals(v)} /> -``` - ---- - -## Theme integration - -Components built with `factory()` automatically get `.extend()` and `.withProps()`. - -**`.extend()`** — for theme-level configuration in `createTheme`: -```tsx -const theme = createTheme({ - components: { - MyComponent: MyComponent.extend({ - // Override default props - defaultProps: { - radius: 'xl', - size: 'lg', - }, - // Add classes to selectors - classNames: { - root: 'my-root-class', - inner: 'my-inner-class', - }, - // Add inline styles to selectors - styles: { - root: { border: '1px solid red' }, - }, - // Or use a callback for theme-aware styles - styles: (theme) => ({ - root: { background: theme.colors.blue[0] }, - }), - // Override CSS variables - vars: (_theme, props) => ({ - root: { '--my-radius': props.radius ? getRadius(props.radius) : undefined }, - }), - }), - }, -}); -``` - -**`.withProps()`** — create a pre-configured variant at the call site: -```tsx -const BigMyComponent = MyComponent.withProps({ size: 'xl', radius: 'lg' }); - -// Same as MyComponent but with size and radius pre-set -Content -``` - ---- - -## Namespace exports - -Add at the bottom of the component file or `generate-palette.ts` to let consumers access types without extra imports. - -```tsx -export namespace MyComponent { - export type Props = MyComponentProps; - export type StylesNames = MyComponentStylesNames; - export type CssVariables = MyComponentCssVariables; - export type Factory = MyComponentFactory; - export type Variant = MyComponentVariant; - - export namespace Section { - export type Props = MyComponentSectionProps; - export type StylesNames = MyComponentSectionStylesNames; - export type Factory = MyComponentSectionFactory; - } -} -``` - -**Usage:** -```ts -import { MyComponent } from './MyComponent'; - -// No need to import MyComponentProps separately -const props: MyComponent.Props = { radius: 'md' }; -``` diff --git a/.agents/skills/tanstack-form/SKILL.md b/.agents/skills/tanstack-form/SKILL.md deleted file mode 100644 index 5017057..0000000 --- a/.agents/skills/tanstack-form/SKILL.md +++ /dev/null @@ -1,416 +0,0 @@ ---- -name: tanstack-form -description: Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte. ---- - - -## Overview - -TanStack Form is a headless form library with deep TypeScript integration. It provides field-level and form-level validation (sync/async), array fields, linked/dependent fields, fine-grained reactivity, and schema validation adapter support (Zod, Valibot, Yup). - -**Package:** `@tanstack/react-form` -**Adapters:** `@tanstack/zod-form-adapter`, `@tanstack/valibot-form-adapter` -**Status:** Stable (v1) - -## Installation - -```bash -npm install @tanstack/react-form -# Optional schema adapters: -npm install @tanstack/zod-form-adapter zod -npm install @tanstack/valibot-form-adapter valibot -``` - -## Core: useForm - -```tsx -import { useForm } from '@tanstack/react-form' - -function MyForm() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - email: '', - age: 0, - }, - onSubmit: async ({ value }) => { - // value is fully typed - await submitToServer(value) - }, - onSubmitInvalid: ({ value, formApi }) => { - console.log('Validation failed:', formApi.state.errors) - }, - }) - - return ( -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > - {/* Fields */} - ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} - children={({ canSubmit, isSubmitting }) => ( - - )} - /> - - ) -} -``` - -## Fields (form.Field) - -```tsx - - value.length < 3 ? 'Must be at least 3 characters' : undefined, - }} - children={(field) => ( -
- - field.handleChange(e.target.value)} - /> - {field.state.meta.isTouched && field.state.meta.errors.length > 0 && ( - {field.state.meta.errors.join(', ')} - )} -
- )} -/> - - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - /> - )} - -``` - -## Validation - -### Validation Timing - -| Cause | When | -|-------|------| -| `onChange` | After every value change | -| `onBlur` | When field loses focus | -| `onSubmit` | During submission | -| `onMount` | When field mounts | - -### Synchronous Validation - -```tsx - { - if (value < 18) return 'Must be 18 or older' - return undefined // undefined = valid - }, - onBlur: ({ value }) => { - if (!value) return 'Required' - return undefined - }, - }} -/> -``` - -### Asynchronous Validation - -```tsx - { - const res = await fetch(`/api/check-username?q=${value}`) - const { available } = await res.json() - if (!available) return 'Username taken' - return undefined - }, - }} -> - {(field) => ( - <> - field.handleChange(e.target.value)} /> - {field.state.meta.isValidating && Checking...} - - )} - -``` - -### Schema Validation (Zod) - -```tsx -import { zodValidator } from '@tanstack/zod-form-adapter' -import { z } from 'zod' - -const form = useForm({ - defaultValues: { email: '', age: 0 }, - validatorAdapter: zodValidator(), - onSubmit: async ({ value }) => { /* ... */ }, -}) - - - - -``` - -### Form-Level Validation - -```tsx -const form = useForm({ - defaultValues: { password: '', confirmPassword: '' }, - validators: { - onChange: ({ value }) => { - if (value.password !== value.confirmPassword) { - return 'Passwords do not match' - } - return undefined - }, - }, -}) -``` - -### Linked/Dependent Fields - -```tsx - { - const password = fieldApi.form.getFieldValue('password') - if (value !== password) return 'Passwords do not match' - return undefined - }, - }} -/> -``` - -## Array Fields - -```tsx - - {(field) => ( -
- {field.state.value.map((_, index) => ( -
- - {(subField) => ( - subField.handleChange(e.target.value)} - /> - )} - - -
- ))} - -
- )} -
-``` - -### Array Methods - -```typescript -field.pushValue(item) // Add to end -field.insertValue(index, item) // Insert at index -field.replaceValue(index, item) // Replace at index -field.removeValue(index) // Remove at index -field.swapValues(indexA, indexB) // Swap positions -field.moveValue(from, to) // Move position -``` - -## Listeners (Side Effects) - -```tsx - { - // Side effect: reset dependent fields - form.setFieldValue('state', '') - form.setFieldValue('postalCode', '') - }, - }} -/> -``` - -## Reactivity (form.Subscribe & useStore) - -```tsx -// Render-prop subscription (fine-grained) - ({ canSubmit: state.canSubmit, isDirty: state.isDirty })} - children={({ canSubmit, isDirty }) => ( -
- {isDirty && Unsaved changes} - -
- )} -/> - -// Hook-based subscription -function FormStatus() { - const isValid = form.useStore((s) => s.isValid) - return isValid ? null :

Fix errors

-} -``` - -## Form State - -```typescript -interface FormState { - values: TFormData - errors: ValidationError[] - errorMap: Record - isFormValid: boolean - isFieldsValid: boolean - isValid: boolean // isFormValid && isFieldsValid - isTouched: boolean - isPristine: boolean - isDirty: boolean - isSubmitting: boolean - isSubmitted: boolean - isSubmitSuccessful: boolean - submissionAttempts: number - canSubmit: boolean // isValid && !isSubmitting -} -``` - -## Field State - -```typescript -interface FieldState { - value: TData - meta: { - isTouched: boolean - isDirty: boolean - isPristine: boolean - isValidating: boolean - errors: ValidationError[] - errorMap: Record - } -} -``` - -## FormApi Methods - -```typescript -form.handleSubmit() -form.reset() -form.getFieldValue(field) -form.setFieldValue(field, value) -form.getFieldMeta(field) -form.setFieldMeta(field, updater) -form.validateAllFields(cause) -form.validateField(field, cause) -form.deleteField(field) -``` - -## Shared Form Options (formOptions) - -```tsx -import { formOptions } from '@tanstack/react-form' - -const sharedOpts = formOptions({ - defaultValues: { firstName: '', lastName: '' }, -}) - -// Reuse across core -const form = useForm({ - ...sharedOpts, - onSubmit: async ({ value }) => { /* ... */ }, -}) -``` - -## Server-Side Validation - -```tsx -// TanStack Start / Next.js server action -import { ServerValidateError } from '@tanstack/react-form/nextjs' - -export async function validateForm(data: FormData) { - const email = data.get('email') as string - if (await checkEmailExists(email)) { - throw new ServerValidateError({ - form: 'Submission failed', - fields: { email: 'Email already registered' }, - }) - } -} -``` - -## TypeScript Integration - -```tsx -// Type-safe field paths with DeepKeys -interface UserForm { - name: string - address: { street: string; city: string } - tags: string[] - contacts: Array<{ name: string; phone: string }> -} - -// TypeScript auto-completes all valid paths: -// 'name', 'address', 'address.street', 'address.city', 'tags', 'contacts' - // OK - // Type Error! -``` - -## Best Practices - -1. **Always call `e.preventDefault()` and `e.stopPropagation()`** on form submit -2. **Always attach `onBlur={field.handleBlur}`** for blur validation and isTouched tracking -3. **Use `mode="array"`** for array fields to get array methods -4. **Return `undefined`** (not null/false) for valid validators -5. **Use `asyncDebounceMs`** for async validators to prevent API spam -6. **Check `isTouched` before showing errors** for better UX -7. **Use `form.Subscribe` with selectors** to minimize re-renders -8. **Use `formOptions`** for shared configuration across components -9. **Use schema validators** (Zod/Valibot) for complex validation rules -10. **Use `onChangeListenTo`** for cross-field validation dependencies - -## Common Pitfalls - -- Forgetting `e.preventDefault()` on form submit (causes page reload) -- Not attaching `onBlur` to inputs (breaks blur validation and isTouched) -- Returning `null` or `false` instead of `undefined` for valid fields -- Using `mode="array"` incorrectly (only needed on the array field itself, not sub-fields) -- Subscribing to entire form state instead of using selectors (unnecessary re-renders) -- Not using `asyncDebounceMs` with async validators (fires on every keystroke) diff --git a/.agents/skills/tanstack-query/SKILL.md b/.agents/skills/tanstack-query/SKILL.md deleted file mode 100644 index 1538eb4..0000000 --- a/.agents/skills/tanstack-query/SKILL.md +++ /dev/null @@ -1,849 +0,0 @@ ---- -name: tanstack-query -description: Powerful asynchronous state management, server-state utilities, and data fetching for TS/JS, React, Vue, Solid, Svelte & Angular. ---- - - -## Overview - -TanStack Query (formerly React Query) manages server state - data that lives on the server and needs to be fetched, cached, synchronized, and updated. It provides automatic caching, background refetching, stale-while-revalidate patterns, pagination, infinite scrolling, and optimistic updates out of the box. - -**Package:** `@tanstack/react-query` -**Devtools:** `@tanstack/react-query-devtools` -**Current Version:** v5 - -## Installation - -```bash -npm install @tanstack/react-query -npm install -D @tanstack/react-query-devtools # Optional -``` - -## Setup - -```tsx -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60, // 1 minute - gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection) - retry: 3, - refetchOnWindowFocus: true, - refetchOnReconnect: true, - }, - }, -}) - -function App() { - return ( - - - - - ) -} -``` - -## Core Concepts - -### Query Keys - -Query keys uniquely identify cached data. They must be serializable arrays: - -```tsx -// Simple key -useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) - -// With variables (dependency array pattern) -useQuery({ queryKey: ['todos', { status, page }], queryFn: fetchTodos }) - -// Hierarchical keys for invalidation -useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) }) -useQuery({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) }) - -// Invalidation matches prefixes: -// queryClient.invalidateQueries({ queryKey: ['todos'] }) -// ^ Invalidates ALL queries starting with 'todos' -``` - -### Query Functions - -```tsx -// Query function receives a QueryFunctionContext -useQuery({ - queryKey: ['todos', todoId], - queryFn: async ({ queryKey, signal, meta }) => { - const [_key, id] = queryKey - const response = await fetch(`/api/todos/${id}`, { signal }) - if (!response.ok) throw new Error('Failed to fetch') - return response.json() - }, -}) - -// Using the signal for automatic cancellation -useQuery({ - queryKey: ['todos'], - queryFn: async ({ signal }) => { - const response = await fetch('/api/todos', { signal }) - return response.json() - }, -}) -``` - -### queryOptions Helper - -Create reusable, type-safe query configurations: - -```tsx -import { queryOptions } from '@tanstack/react-query' - -export const todosQueryOptions = queryOptions({ - queryKey: ['todos'], - queryFn: fetchTodos, - staleTime: 5000, -}) - -export const todoQueryOptions = (todoId: string) => - queryOptions({ - queryKey: ['todos', todoId], - queryFn: () => fetchTodo(todoId), - enabled: !!todoId, - }) - -// Usage -const { data } = useQuery(todosQueryOptions) -const { data } = useSuspenseQuery(todoQueryOptions(id)) -await queryClient.prefetchQuery(todosQueryOptions) -``` - -## Queries (useQuery) - -### Basic Usage - -```tsx -import { useQuery } from '@tanstack/react-query' - -function Todos() { - const { - data, - error, - isLoading, // First load, no data yet - isFetching, // Any fetch in progress (including background) - isError, - isSuccess, - isPending, // No data yet (same as isLoading in most cases) - status, // 'pending' | 'error' | 'success' - fetchStatus, // 'fetching' | 'paused' | 'idle' - refetch, - isStale, - isPlaceholderData, - dataUpdatedAt, - errorUpdatedAt, - } = useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - - if (isLoading) return - if (isError) return - return -} -``` - -### Query Options - -```tsx -useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - - // Freshness - staleTime: 5000, // ms data stays fresh (default: 0) - gcTime: 300000, // ms unused data stays in cache (default: 5 min) - - // Refetching - refetchInterval: 10000, // Poll every 10s - refetchIntervalInBackground: false, // Don't poll when tab hidden - refetchOnMount: true, // Refetch on component mount if stale - refetchOnWindowFocus: true, // Refetch on window focus if stale - refetchOnReconnect: true, // Refetch on network reconnect - - // Retry - retry: 3, // Number of retries (or function) - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), - - // Conditional - enabled: !!userId, // Only run when truthy - - // Initial/placeholder data - initialData: () => cachedData, - initialDataUpdatedAt: Date.now() - 10000, - placeholderData: (previousData) => previousData, // keepPreviousData pattern - placeholderData: initialTodos, - - // Transform - select: (data) => data.filter(todo => !todo.done), - - // Structural sharing (default: true) - structuralSharing: true, - - // Network mode - networkMode: 'online', // 'online' | 'always' | 'offlineFirst' - - // Meta (accessible in query function context) - meta: { purpose: 'user-facing' }, -}) -``` - -## Mutations (useMutation) - -### Basic Usage - -```tsx -import { useMutation, useQueryClient } from '@tanstack/react-query' - -function AddTodo() { - const queryClient = useQueryClient() - - const mutation = useMutation({ - mutationFn: (newTodo: { title: string }) => { - return fetch('/api/todos', { - method: 'POST', - body: JSON.stringify(newTodo), - }).then(res => res.json()) - }, - // Lifecycle callbacks - onMutate: async (variables) => { - // Called before mutationFn - // Good for optimistic updates - return { previousTodos } // context for onError - }, - onSuccess: (data, variables, context) => { - // Invalidate related queries - queryClient.invalidateQueries({ queryKey: ['todos'] }) - }, - onError: (error, variables, context) => { - // Rollback optimistic updates - queryClient.setQueryData(['todos'], context.previousTodos) - }, - onSettled: (data, error, variables, context) => { - // Always runs (success or error) - queryClient.invalidateQueries({ queryKey: ['todos'] }) - }, - }) - - return ( - - ) -} -``` - -### Mutation State - -```tsx -const { - mutate, // Fire-and-forget - mutateAsync, // Returns promise - isPending, // Mutation in progress - isError, - isSuccess, - isIdle, // Not yet fired - data, // Success response - error, // Error object - reset, // Reset state to idle - variables, // Variables passed to mutate - status, // 'idle' | 'pending' | 'error' | 'success' -} = useMutation({ ... }) -``` - -## Optimistic Updates - -```tsx -const mutation = useMutation({ - mutationFn: updateTodo, - onMutate: async (newTodo) => { - // 1. Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] }) - - // 2. Snapshot previous value - const previousTodo = queryClient.getQueryData(['todos', newTodo.id]) - - // 3. Optimistically update - queryClient.setQueryData(['todos', newTodo.id], newTodo) - - // 4. Return context for rollback - return { previousTodo } - }, - onError: (err, newTodo, context) => { - // Rollback on error - queryClient.setQueryData(['todos', newTodo.id], context.previousTodo) - }, - onSettled: () => { - // Always refetch to sync with server - queryClient.invalidateQueries({ queryKey: ['todos'] }) - }, -}) -``` - -### Optimistic Updates on Lists - -```tsx -onMutate: async (newTodo) => { - await queryClient.cancelQueries({ queryKey: ['todos'] }) - const previousTodos = queryClient.getQueryData(['todos']) - - queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) - - return { previousTodos } -}, -onError: (err, newTodo, context) => { - queryClient.setQueryData(['todos'], context.previousTodos) -}, -``` - -## Query Invalidation - -```tsx -const queryClient = useQueryClient() - -// Invalidate all queries -queryClient.invalidateQueries() - -// Invalidate by prefix -queryClient.invalidateQueries({ queryKey: ['todos'] }) - -// Invalidate exact match -queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true }) - -// Invalidate with predicate -queryClient.invalidateQueries({ - predicate: (query) => - query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done', -}) - -// Invalidate and refetch immediately -queryClient.refetchQueries({ queryKey: ['todos'] }) - -// Remove from cache entirely -queryClient.removeQueries({ queryKey: ['todos', 1] }) - -// Reset to initial state -queryClient.resetQueries({ queryKey: ['todos'] }) -``` - -## Infinite Queries - -```tsx -import { useInfiniteQuery } from '@tanstack/react-query' - -function InfiniteList() { - const { - data, - fetchNextPage, - fetchPreviousPage, - hasNextPage, - hasPreviousPage, - isFetchingNextPage, - isFetchingPreviousPage, - } = useInfiniteQuery({ - queryKey: ['projects'], - queryFn: async ({ pageParam }) => { - const res = await fetch(`/api/projects?cursor=${pageParam}`) - return res.json() - }, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages, lastPageParam) => { - return lastPage.nextCursor ?? undefined // undefined = no more pages - }, - getPreviousPageParam: (firstPage, allPages, firstPageParam) => { - return firstPage.prevCursor ?? undefined - }, - maxPages: 3, // Keep max 3 pages in cache (for performance) - }) - - return ( -
- {data.pages.map((page) => - page.items.map((item) => ) - )} - -
- ) -} -``` - -## Parallel Queries - -```tsx -// Multiple independent queries run in parallel automatically -function Dashboard() { - const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) - const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }) - - // Both fetch simultaneously -} - -// Dynamic parallel queries with useQueries -function UserProjects({ userIds }) { - const queries = useQueries({ - queries: userIds.map((id) => ({ - queryKey: ['user', id], - queryFn: () => fetchUser(id), - })), - combine: (results) => ({ - data: results.map(r => r.data), - pending: results.some(r => r.isPending), - }), - }) -} -``` - -## Dependent Queries - -```tsx -// Sequential queries using enabled -function UserPosts({ userId }) { - const userQuery = useQuery({ - queryKey: ['user', userId], - queryFn: () => fetchUser(userId), - }) - - const postsQuery = useQuery({ - queryKey: ['posts', userId], - queryFn: () => fetchPostsByUser(userId), - enabled: !!userQuery.data, // Only run when user is loaded - }) -} -``` - -## Paginated Queries - -```tsx -function PaginatedList() { - const [page, setPage] = useState(1) - - const { data, isPlaceholderData } = useQuery({ - queryKey: ['todos', page], - queryFn: () => fetchTodos(page), - placeholderData: (previousData) => previousData, // Keep showing old data - }) - - return ( -
- {data.items.map(item => )} - -
- ) -} -``` - -## Suspense Integration - -```tsx -import { useSuspenseQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query' - -// Component will suspend until data is loaded -function TodoList() { - const { data } = useSuspenseQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - // data is guaranteed to be defined here - return
    {data.map(todo =>
  • {todo.title}
  • )}
-} - -// Wrap with Suspense boundary -function App() { - return ( - }> - }> - - - - ) -} - -// Multiple suspense queries (fetch in parallel) -function Dashboard() { - const [{ data: users }, { data: projects }] = useSuspenseQueries({ - queries: [ - { queryKey: ['users'], queryFn: fetchUsers }, - { queryKey: ['projects'], queryFn: fetchProjects }, - ], - }) -} -``` - -## Prefetching - -```tsx -const queryClient = useQueryClient() - -// Prefetch on hover -function TodoLink({ todoId }) { - const prefetch = () => { - queryClient.prefetchQuery({ - queryKey: ['todo', todoId], - queryFn: () => fetchTodo(todoId), - staleTime: 5000, // Only prefetch if data older than 5s - }) - } - - return ( - - Todo {todoId} - - ) -} - -// Prefetch in route loader (TanStack Router integration) -export const Route = createFileRoute('/todos/$todoId')({ - loader: ({ context: { queryClient }, params: { todoId } }) => - queryClient.ensureQueryData(todoQueryOptions(todoId)), -}) - -// Prefetch infinite queries -queryClient.prefetchInfiniteQuery({ - queryKey: ['projects'], - queryFn: fetchProjects, - initialPageParam: 0, - pages: 3, // Prefetch first 3 pages -}) -``` - -## SSR & Hydration - -### Server-Side Prefetching - -```tsx -// Server component or loader -import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' - -async function getServerSideProps() { - const queryClient = new QueryClient() - - await queryClient.prefetchQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - - return { - props: { - dehydratedState: dehydrate(queryClient), - }, - } -} - -function Page({ dehydratedState }) { - return ( - - - - ) -} -``` - -### Streaming SSR (React Server Components) - -```tsx -import { dehydrate, HydrationBoundary } from '@tanstack/react-query' -import { makeQueryClient } from './query-client' - -export default async function Page() { - const queryClient = makeQueryClient() - - // Prefetch on server - await queryClient.prefetchQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - - return ( - - - - ) -} -``` - -## QueryClient API - -```tsx -const queryClient = useQueryClient() - -// Get cached data -queryClient.getQueryData(['todos']) - -// Set cached data -queryClient.setQueryData(['todos'], updatedTodos) -queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) - -// Get query state -queryClient.getQueryState(['todos']) - -// Check if fetching -queryClient.isFetching({ queryKey: ['todos'] }) -queryClient.isMutating() - -// Cancel queries -queryClient.cancelQueries({ queryKey: ['todos'] }) - -// Invalidate (marks stale, refetches active) -queryClient.invalidateQueries({ queryKey: ['todos'] }) - -// Refetch (force refetch even if fresh) -queryClient.refetchQueries({ queryKey: ['todos'] }) - -// Remove from cache -queryClient.removeQueries({ queryKey: ['todos'] }) - -// Reset to initial state -queryClient.resetQueries({ queryKey: ['todos'] }) - -// Clear entire cache -queryClient.clear() - -// Prefetch -queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos }) -queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos }) - -// Get/set defaults -queryClient.setQueryDefaults(['todos'], { staleTime: 10000 }) -queryClient.getQueryDefaults(['todos']) -queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo }) -``` - -## Testing - -```tsx -import { renderHook, waitFor } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, // Don't retry in tests - gcTime: Infinity, // Prevent garbage collection during tests - }, - }, - }) - return ({ children }) => ( - - {children} - - ) -} - -test('fetches todos', async () => { - const { result } = renderHook(() => useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }), { wrapper: createWrapper() }) - - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(expectedTodos) -}) - -// Mock with setQueryData for component tests -test('renders todos', () => { - const queryClient = new QueryClient() - queryClient.setQueryData(['todos'], mockTodos) - - render( - - - - ) - - expect(screen.getByText('Todo 1')).toBeInTheDocument() -}) -``` - -## TypeScript Patterns - -### Typing Query Functions - -```tsx -interface Todo { - id: number - title: string - completed: boolean -} - -// Type is inferred from queryFn return type -const { data } = useQuery({ - queryKey: ['todos'], - queryFn: async (): Promise => { - const res = await fetch('/api/todos') - return res.json() - }, -}) -// data: Todo[] | undefined - -// With select -const { data } = useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - select: (data): string[] => data.map(t => t.title), -}) -// data: string[] | undefined -``` - -### Typing Errors - -```tsx -// Default error type is Error -const { error } = useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, -}) - -// Or register globally -declare module '@tanstack/react-query' { - interface Register { - defaultError: AxiosError - } -} -``` - -### Query Options Pattern (Recommended) - -```tsx -import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query' - -export const todosOptions = queryOptions({ - queryKey: ['todos'] as const, - queryFn: fetchTodos, - staleTime: 5000, -}) - -export const todoOptions = (id: string) => - queryOptions({ - queryKey: ['todos', id] as const, - queryFn: () => fetchTodo(id), - enabled: !!id, - }) - -// Full type inference everywhere -const { data } = useQuery(todosOptions) -const { data } = useSuspenseQuery(todoOptions('123')) -await queryClient.ensureQueryData(todosOptions) -queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) -``` - -## Advanced Patterns - -### Window Focus Refetching - -```tsx -// Disable globally -const queryClient = new QueryClient({ - defaultOptions: { - queries: { refetchOnWindowFocus: false }, - }, -}) - -// Custom focus manager -import { focusManager } from '@tanstack/react-query' - -// For React Native -focusManager.setEventListener((handleFocus) => { - const subscription = AppState.addEventListener('change', (state) => { - handleFocus(state === 'active') - }) - return () => subscription.remove() -}) -``` - -### Network Mode - -```tsx -useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - // 'online' (default): only fetch when online - // 'always': always fetch (useful for local-first) - // 'offlineFirst': try fetch, use cache if offline - networkMode: 'offlineFirst', -}) -``` - -### Query Cancellation - -```tsx -useQuery({ - queryKey: ['todos'], - queryFn: async ({ signal }) => { - // signal is AbortSignal - automatically cancelled on unmount or key change - const res = await fetch('/api/todos', { signal }) - return res.json() - }, -}) - -// Manual cancellation -queryClient.cancelQueries({ queryKey: ['todos'] }) -``` - -### Persistence - -```tsx -import { persistQueryClient } from '@tanstack/react-query-persist-client' -import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' - -const persister = createSyncStoragePersister({ - storage: window.localStorage, -}) - -persistQueryClient({ - queryClient, - persister, - maxAge: 1000 * 60 * 60 * 24, // 24 hours -}) -``` - -## Best Practices - -1. **Use `queryOptions` helper** for type-safe, reusable query configurations -2. **Structure query keys hierarchically** for granular invalidation -3. **Set appropriate `staleTime`** - 0 means always refetch on mount (default), increase for less dynamic data -4. **Use `placeholderData`** (not `initialData`) for keeping previous page data during pagination -5. **Prefer `useSuspenseQuery`** when using Suspense boundaries for cleaner component code -6. **Use `enabled`** for dependent queries, not conditional hook calls -7. **Always invalidate after mutations** - don't rely solely on optimistic updates -8. **Cancel queries in `onMutate`** before optimistic updates to prevent race conditions -9. **Use `ensureQueryData`** in route loaders instead of `prefetchQuery` for immediate access -10. **Set `retry: false` in tests** to avoid timeout issues -11. **Don't destructure the query result** if you need to pass it around (breaks reactivity) -12. **Use `select`** for derived data instead of transforming in the component -13. **Keep query functions pure** - they should only fetch, not cause side effects -14. **Use `gcTime: Infinity`** in tests to prevent cache cleanup during assertions - -## Common Pitfalls - -- Using `initialData` when you mean `placeholderData` (initialData counts as "fresh" data) -- Not providing `initialPageParam` for infinite queries (required in v5) -- Calling hooks conditionally (violates React rules) -- Not cancelling queries before optimistic updates (race conditions) -- Setting `staleTime` higher than `gcTime` (data gets garbage collected while "fresh") -- Forgetting to wrap tests with `QueryClientProvider` -- Using same `QueryClient` instance across tests (shared state) -- Not awaiting `invalidateQueries` in mutation callbacks when order matters diff --git a/.agents/skills/tanstack-router/SKILL.md b/.agents/skills/tanstack-router/SKILL.md deleted file mode 100644 index a4b817c..0000000 --- a/.agents/skills/tanstack-router/SKILL.md +++ /dev/null @@ -1,734 +0,0 @@ ---- -name: tanstack-router -description: Type-safe routing for React and Solid applications with first-class search params, data loading, and seamless integration with the React ecosystem. ---- - - -## Overview - -TanStack Router is a fully type-safe router for React (and Solid) applications. It provides file-based routing, first-class search parameter management, built-in data loading, code splitting, and deep TypeScript integration. It serves as the routing foundation for TanStack Start (the full-stack framework). - -**Package:** `@tanstack/react-router` -**CLI:** `@tanstack/router-cli` or `@tanstack/router-plugin` (Vite/Rspack/Webpack) -**Devtools:** `@tanstack/react-router-devtools` - -## Installation - -```bash -npm install @tanstack/react-router -# For file-based routing with Vite: -npm install -D @tanstack/router-plugin -# Or standalone CLI: -npm install -D @tanstack/router-cli -``` - -## Core Concepts - -### Route Trees - -Routes are organized in a tree structure. The root route is the top-level layout, and child routes nest underneath. - -```tsx -import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router' - -const rootRoute = createRootRoute({ - component: RootLayout, -}) - -const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: HomePage, -}) - -const aboutRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/about', - component: AboutPage, -}) - -const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) -const router = createRouter({ routeTree }) -``` - -### File-Based Routing - -File-based routing automatically generates the route tree from your file structure. Configure with Vite plugin: - -```ts -// vite.config.ts -import { defineConfig } from 'vite' -import { TanStackRouterVite } from '@tanstack/router-plugin/vite' - -export default defineConfig({ - plugins: [ - TanStackRouterVite(), - // ... other plugins - ], -}) -``` - -#### File Naming Conventions - -| File Pattern | Route Type | Example Path | -|---|---|---| -| `__root.tsx` | Root layout | N/A (wraps all) | -| `index.tsx` | Index route | `/` | -| `about.tsx` | Static route | `/about` | -| `$postId.tsx` | Dynamic param | `/posts/$postId` | -| `posts.tsx` | Layout route | `/posts/*` (layout) | -| `posts/index.tsx` | Nested index | `/posts` | -| `posts/$postId.tsx` | Nested dynamic | `/posts/123` | -| `posts_.$postId.tsx` | Pathless layout | `/posts/123` (different layout) | -| `_layout.tsx` | Pathless layout | N/A (groups routes) | -| `_layout/dashboard.tsx` | Grouped route | `/dashboard` | -| `$.tsx` | Splat/catch-all | `/*` | -| `posts.$postId.edit.tsx` | Dot notation | `/posts/123/edit` | - -#### Special Prefixes -- `_` prefix: Pathless routes (layout groups without URL segment) -- `$` prefix: Dynamic path parameters -- `(folder)` parentheses: Route groups (organizational, no URL impact) - -### Route Configuration - -Each route can define: - -```tsx -// routes/posts.$postId.tsx -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/posts/$postId')({ - // Validation for path params - params: { - parse: (params) => ({ postId: Number(params.postId) }), - stringify: (params) => ({ postId: String(params.postId) }), - }, - - // Search params validation - validateSearch: (search: Record) => { - return { - page: Number(search.page ?? 1), - filter: (search.filter as string) || '', - } - }, - - // Data loading - loader: async ({ params, context, abortController }) => { - return fetchPost(params.postId) - }, - - // Loader dependencies (re-run loader when these change) - loaderDeps: ({ search }) => ({ page: search.page }), - - // Stale time for cached loader data - staleTime: 5_000, - - // Preloading - preloadStaleTime: 30_000, - - // Error component - errorComponent: PostErrorComponent, - - // Pending/loading component - pendingComponent: PostLoadingComponent, - - // 404 component - notFoundComponent: PostNotFoundComponent, - - // Before load hook (authentication, redirects) - beforeLoad: async ({ context, location }) => { - if (!context.auth.isAuthenticated) { - throw redirect({ - to: '/login', - search: { redirect: location.href }, - }) - } - }, - - // Head/meta management - head: () => ({ - meta: [{ title: 'Post Details' }], - }), - - // Component - component: PostComponent, -}) - -function PostComponent() { - const { postId } = Route.useParams() - const post = Route.useLoaderData() - const { page, filter } = Route.useSearch() - - return
{post.title}
-} -``` - -## Data Loading - -### Route Loaders - -```tsx -export const Route = createFileRoute('/posts')({ - loader: async ({ context }) => { - // Access router context (e.g., queryClient) - const posts = await context.queryClient.ensureQueryData({ - queryKey: ['posts'], - queryFn: fetchPosts, - }) - return { posts } - }, - component: PostsComponent, -}) - -function PostsComponent() { - const { posts } = Route.useLoaderData() - // ... -} -``` - -### Loader Dependencies - -Control when loaders re-execute: - -```tsx -export const Route = createFileRoute('/posts')({ - loaderDeps: ({ search: { page, filter } }) => ({ page, filter }), - loader: async ({ deps: { page, filter } }) => { - return fetchPosts({ page, filter }) - }, -}) -``` - -### Deferred Data Loading - -Stream non-critical data: - -```tsx -import { Await, defer } from '@tanstack/react-router' - -export const Route = createFileRoute('/dashboard')({ - loader: async () => { - const criticalData = await fetchCriticalData() - const deferredData = defer(fetchSlowData()) - return { criticalData, deferredData } - }, - component: DashboardComponent, -}) - -function DashboardComponent() { - const { criticalData, deferredData } = Route.useLoaderData() - - return ( -
- - }> - - {(data) => } - - -
- ) -} -``` - -### Context-Based Data Loading - -Provide shared dependencies via router context: - -```tsx -// Create router with context -const router = createRouter({ - routeTree, - context: { - queryClient, - auth: undefined!, // Will be provided by RouterProvider - }, -}) - -// In root/app component -function App() { - const auth = useAuth() - return -} - -// In routes -export const Route = createFileRoute('/protected')({ - beforeLoad: ({ context }) => { - if (!context.auth.user) throw redirect({ to: '/login' }) - }, - loader: ({ context }) => { - return context.queryClient.ensureQueryData(userQueryOptions()) - }, -}) -``` - -## Search Parameters - -### Validation - -```tsx -import { z } from 'zod' - -const postSearchSchema = z.object({ - page: z.number().default(1), - filter: z.string().default(''), - sort: z.enum(['date', 'title']).default('date'), -}) - -export const Route = createFileRoute('/posts')({ - validateSearch: postSearchSchema, - // Or manual validation: - // validateSearch: (search) => postSearchSchema.parse(search), -}) -``` - -### Reading Search Params - -```tsx -function PostsComponent() { - // From route - const { page, filter, sort } = Route.useSearch() - - // Or from any component with useSearch hook - const search = useSearch({ from: '/posts' }) -} -``` - -### Updating Search Params - -```tsx -import { useNavigate } from '@tanstack/react-router' - -function Pagination() { - const navigate = useNavigate() - const { page } = Route.useSearch() - - return ( - - ) -} - -// Or via Link component - ({ ...prev, page: 2 })} -> - Page 2 - -``` - -### Search Param Options - -```tsx -const router = createRouter({ - routeTree, - // Custom serialization - search: { - strict: true, // Reject unknown params - }, - // Default search param serializer - stringifySearch: defaultStringifySearch, - parseSearch: defaultParseSearch, -}) -``` - -## Navigation - -### Link Component - -```tsx -import { Link } from '@tanstack/react-router' - -// Static route -About - -// Dynamic route with params - - Post 123 - - -// With search params - - Page 2 - - -// Active link styling - - Posts - - -// Preloading -Posts -Dashboard - -// Hash -API Reference -``` - -### Programmatic Navigation - -```tsx -import { useNavigate, useRouter } from '@tanstack/react-router' - -function MyComponent() { - const navigate = useNavigate() - const router = useRouter() - - // Navigate to a route - navigate({ to: '/posts', search: { page: 1 } }) - - // Navigate with replace - navigate({ to: '/posts', replace: true }) - - // Relative navigation - navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) }) - - // Go back/forward - router.history.back() - router.history.forward() - - // Invalidate and reload current route - router.invalidate() -} -``` - -### Redirects - -```tsx -import { redirect } from '@tanstack/react-router' - -// In beforeLoad or loader -throw redirect({ - to: '/login', - search: { redirect: location.href }, - // Optional status code - statusCode: 301, // Permanent redirect (SSR) -}) -``` - -### Navigation Blocking - -```tsx -import { useBlocker } from '@tanstack/react-router' - -function FormComponent() { - const [isDirty, setIsDirty] = useState(false) - - useBlocker({ - shouldBlockFn: () => isDirty, - withResolver: true, // Shows confirm dialog - }) - - // Or with custom UI - const { proceed, reset, status } = useBlocker({ - shouldBlockFn: () => isDirty, - }) - - if (status === 'blocked') { - return ( -
-

Are you sure you want to leave?

- - -
- ) - } -} -``` - -## Code Splitting - -### Automatic (File-Based Routing) - -With file-based routing, create a lazy file: - -``` -routes/ - posts.tsx # Critical: loader, beforeLoad, meta - posts.lazy.tsx # Lazy: component, pendingComponent, errorComponent -``` - -```tsx -// posts.tsx (loaded eagerly) -export const Route = createFileRoute('/posts')({ - loader: () => fetchPosts(), -}) - -// posts.lazy.tsx (loaded lazily) -import { createLazyFileRoute } from '@tanstack/react-router' - -export const Route = createLazyFileRoute('/posts')({ - component: PostsComponent, - pendingComponent: PostsLoading, - errorComponent: PostsError, -}) -``` - -### Manual Code Splitting - -```tsx -const postsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/posts', - loader: () => fetchPosts(), -}).lazy(() => import('./posts.lazy').then((d) => d.Route)) -``` - -## Preloading - -```tsx -// Router-level defaults -const router = createRouter({ - routeTree, - defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false - defaultPreloadStaleTime: 30_000, // 30 seconds -}) - -// Route-level -export const Route = createFileRoute('/posts/$postId')({ - // Stale time for the loader data - staleTime: 5_000, - // How long preloaded data stays fresh - preloadStaleTime: 30_000, -}) - -// Link-level - - Posts - -``` - -## Type Safety - -### Register Router Type - -```tsx -// Declare module for type inference -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} -``` - -### Type-Safe Hooks - -All hooks are fully typed based on the route tree: - -```tsx -// useParams - typed to route's params -const { postId } = useParams({ from: '/posts/$postId' }) - -// useSearch - typed to route's search schema -const { page } = useSearch({ from: '/posts' }) - -// useLoaderData - typed to loader return -const data = useLoaderData({ from: '/posts/$postId' }) - -// useRouteContext - typed to route context -const { auth } = useRouteContext({ from: '/protected' }) -``` - -### Route Generics - -```tsx -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/posts/$postId')({ - // TypeScript infers: - // params: { postId: string } - // search: validated search schema type - // loaderData: return type of loader - // context: router context type -}) -``` - -## Authenticated Routes - -```tsx -// __root.tsx -export const Route = createRootRouteWithContext<{ - auth: AuthContext -}>()({ - component: RootComponent, -}) - -// _authenticated.tsx (pathless layout for auth) -export const Route = createFileRoute('/_authenticated')({ - beforeLoad: ({ context, location }) => { - if (!context.auth.isAuthenticated) { - throw redirect({ - to: '/login', - search: { redirect: location.href }, - }) - } - }, -}) - -// _authenticated/dashboard.tsx -export const Route = createFileRoute('/_authenticated/dashboard')({ - component: Dashboard, // Only accessible when authenticated -}) -``` - -## Scroll Restoration - -```tsx -const router = createRouter({ - routeTree, - // Enable scroll restoration - defaultScrollRestoration: true, -}) - -// Or per-route -export const Route = createFileRoute('/posts')({ - // Scroll to top on navigation - scrollRestoration: true, -}) - -// Custom scroll restoration key - location.pathname} -/> -``` - -## Route Masking - -Display a different URL than the actual route: - -```tsx - - View Photo - - -// Or programmatically -navigate({ - to: '/photos/$photoId', - params: { photoId: photo.id }, - mask: { to: '/photos', search: { photoId: photo.id } }, -}) -``` - -## Not Found Handling - -```tsx -// Global 404 -const router = createRouter({ - routeTree, - defaultNotFoundComponent: () =>
Page not found
, -}) - -// Route-level 404 -export const Route = createFileRoute('/posts/$postId')({ - loader: async ({ params }) => { - const post = await fetchPost(params.postId) - if (!post) throw notFound() - return post - }, - notFoundComponent: () =>
Post not found
, -}) -``` - -## Head Management - -```tsx -export const Route = createFileRoute('/posts/$postId')({ - head: ({ loaderData }) => ({ - meta: [ - { title: loaderData.title }, - { name: 'description', content: loaderData.excerpt }, - { property: 'og:title', content: loaderData.title }, - ], - links: [ - { rel: 'canonical', href: `https://example.com/posts/${loaderData.id}` }, - ], - }), -}) -``` - -## Integration with TanStack Query - -```tsx -import { queryOptions } from '@tanstack/react-query' - -const postsQueryOptions = queryOptions({ - queryKey: ['posts'], - queryFn: fetchPosts, -}) - -export const Route = createFileRoute('/posts')({ - loader: ({ context: { queryClient } }) => { - // Ensure data is in cache, won't refetch if fresh - return queryClient.ensureQueryData(postsQueryOptions) - }, - component: PostsComponent, -}) - -function PostsComponent() { - // Use the same query options for reactive updates - const { data: posts } = useSuspenseQuery(postsQueryOptions) - return -} -``` - -## Router Hooks Reference - -| Hook | Purpose | -|------|---------| -| `useRouter()` | Access router instance | -| `useRouterState()` | Subscribe to router state | -| `useParams()` | Get route path params | -| `useSearch()` | Get validated search params | -| `useLoaderData()` | Get route loader data | -| `useRouteContext()` | Get route context | -| `useNavigate()` | Get navigate function | -| `useLocation()` | Get current location | -| `useMatches()` | Get all matched routes | -| `useMatch()` | Get specific route match | -| `useBlocker()` | Block navigation | -| `useLinkProps()` | Get link props for custom components | -| `useMatchRoute()` | Check if a route matches | - -## Best Practices - -1. **Use file-based routing** for most applications - it's simpler and auto-generates the route tree -2. **Validate search params** with Zod or custom validators for type safety -3. **Use `loaderDeps`** to control when loaders re-execute based on search param changes -4. **Leverage context** for dependency injection (QueryClient, auth state) -5. **Use `beforeLoad`** for authentication guards, not in components -6. **Separate critical vs lazy code** - keep loaders in the main file, components in `.lazy.tsx` -7. **Use `preload="intent"`** on Links for perceived performance -8. **Use `staleTime`** to prevent unnecessary refetches during navigation -9. **Register the router type** for full TypeScript inference across the app -10. **Use `notFound()`** instead of conditional rendering for 404 states -11. **Colocate search param logic** with routes that own them -12. **Use pathless layouts** (`_authenticated`) for shared auth/layout logic without URL segments - -## Common Pitfalls - -- Forgetting to register the router type (`declare module`) -- Not using `loaderDeps` when loader depends on search params (causes stale data) -- Putting auth checks in components instead of `beforeLoad` (flash of protected content) -- Not handling the loading state with `pendingComponent` -- Using `useEffect` for data fetching instead of route loaders -- Mutating search params directly instead of using navigate/Link -- Not wrapping the app with `RouterProvider` -- Forgetting `getParentRoute` in code-based route definitions diff --git a/.agents/skills/tanstack-start/SKILL.md b/.agents/skills/tanstack-start/SKILL.md deleted file mode 100644 index 2d34fab..0000000 --- a/.agents/skills/tanstack-start/SKILL.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -name: tanstack-start -description: Full-stack React framework powered by TanStack Router with SSR, streaming, server functions, and deployment to any hosting provider. ---- - -# TanStack Start Skills - -## Overview - -TanStack Start is a full-stack React framework built on TanStack Router, powered by Vite and Nitro (via Vinxi). It provides server-side rendering, streaming, server functions (RPC), middleware, API routes, and deploys to any platform via Nitro presets. - -**Package:** `@tanstack/react-start` -**Router Plugin:** `@tanstack/router-plugin` -**Build Tool:** Vinxi (Vite + Nitro) -**Status:** RC (Release Candidate) -**RSC Support:** React Server Components support is in active development and will land as a non-breaking v1.x addition - -## Installation & Project Setup - -```bash -npx @tanstack/cli create my-app -# Or manually: -npm install @tanstack/react-start @tanstack/react-router react react-dom -npm install -D @tanstack/router-plugin typescript vite vite-tsconfig-paths -``` - -### Project Structure - -``` -my-app/ - app/ - routes/ - __root.tsx # Root layout - index.tsx # / route - posts.$postId.tsx # /posts/:postId - api/ - users.ts # /api/users API route - client.tsx # Client entry - router.tsx # Router creation - ssr.tsx # SSR entry - routeTree.gen.ts # Auto-generated route tree - app.config.ts # TanStack Start config - tsconfig.json - package.json -``` - -### Configuration (`app.config.ts`) - -```typescript -import { defineConfig } from '@tanstack/react-start/config' -import viteTsConfigPaths from 'vite-tsconfig-paths' - -export default defineConfig({ - vite: { - plugins: [ - viteTsConfigPaths({ projects: ['./tsconfig.json'] }), - ], - }, - server: { - preset: 'node-server', // 'vercel' | 'netlify' | 'cloudflare-pages' | etc. - }, - tsr: { - appDirectory: './app', - routesDirectory: './app/routes', - generatedRouteTree: './app/routeTree.gen.ts', - }, -}) -``` - -## Server Functions (`createServerFn`) - -Server functions provide type-safe RPC calls between client and server. - -### Basic Server Functions - -```typescript -import { createServerFn } from '@tanstack/react-start' - -// GET (data fetching, cacheable) -const getUsers = createServerFn() - .handler(async () => { - const users = await db.query.users.findMany() - return users - }) - -// POST (mutations, side effects) -const createUser = createServerFn({ method: 'POST' }) - .validator((data: { name: string; email: string }) => data) - .handler(async ({ data }) => { - const user = await db.insert(users).values(data).returning() - return user - }) -``` - -### With Zod Validation - -```typescript -import { z } from 'zod' - -const updateUser = createServerFn({ method: 'POST' }) - .validator( - z.object({ - id: z.string(), - name: z.string().min(1), - email: z.string().email(), - }) - ) - .handler(async ({ data }) => { - // data is fully typed: { id: string; name: string; email: string } - return await db.update(users).set(data).where(eq(users.id, data.id)) - }) -``` - -## Middleware - -### Creating Middleware - -```typescript -import { createMiddleware } from '@tanstack/react-start' - -const loggingMiddleware = createMiddleware().handler(async ({ next }) => { - console.log('Request started') - const result = await next() - console.log('Request completed') - return result -}) -``` - -### Auth Middleware with Context - -```typescript -const authMiddleware = createMiddleware().handler(async ({ next }) => { - const request = getWebRequest() - const session = await getSession(request) - - if (!session?.user) { - throw redirect({ to: '/login' }) - } - - // Pass typed context to handler - return next({ context: { user: session.user } }) -}) -``` - -### Chaining Middleware - -```typescript -const adminMiddleware = createMiddleware() - .middleware([authMiddleware]) - .handler(async ({ next, context }) => { - // context.user is typed from authMiddleware - if (context.user.role !== 'admin') { - throw redirect({ to: '/unauthorized' }) - } - return next({ context: { isAdmin: true } }) - }) - -// Usage -const adminAction = createServerFn({ method: 'POST' }) - .middleware([adminMiddleware]) - .handler(async ({ context }) => { - // context: { user: User; isAdmin: boolean } - return { success: true } - }) -``` - -## API Routes (Server Routes) - -```typescript -// app/routes/api/users.ts -import { createAPIFileRoute } from '@tanstack/react-start/api' - -export const APIRoute = createAPIFileRoute('/api/users')({ - GET: async ({ request }) => { - const users = await db.query.users.findMany() - return Response.json(users) - }, - POST: async ({ request }) => { - const body = await request.json() - const user = await db.insert(users).values(body).returning() - return new Response(JSON.stringify(user), { status: 201 }) - }, -}) -``` - -## SSR Strategies - -### Streaming SSR (Default) - -```typescript -export const Route = createFileRoute('/dashboard')({ - loader: async () => ({ - criticalData: await fetchCriticalData(), - deferredData: defer(fetchSlowData()), - }), - component: Dashboard, -}) - -function Dashboard() { - const { criticalData, deferredData } = Route.useLoaderData() - return ( -
- - }> - - {(data) => } - - -
- ) -} -``` - -## Deployment - -### Supported Platforms (Nitro Presets) - -```typescript -// app.config.ts -export default defineConfig({ - server: { - preset: 'node-server', // Self-hosted Node.js - // preset: 'vercel', // Vercel - // preset: 'netlify', // Netlify - // preset: 'cloudflare-pages', // Cloudflare Pages - // preset: 'aws-lambda', // AWS Lambda - // preset: 'deno-server', // Deno Deploy - // preset: 'bun', // Bun - }, -}) -``` - -## Best Practices - -1. **Use validators for all server function inputs** - runtime safety and TypeScript inference -2. **Compose middleware** for cross-cutting concerns (auth, logging, rate limiting) -3. **Use `createServerFn` GET** for data fetching (cacheable, preloadable) -4. **Use `createServerFn` POST** for mutations and side effects -5. **Use `beforeLoad`** for route-level auth guards -6. **Use `defer()`** for non-critical data to improve TTFB -7. **Set `defaultPreload: 'intent'`** on the router for instant navigation -8. **Co-locate server functions** with the routes that use them - -## Common Pitfalls - -- Server functions cannot close over client-side variables (they're extracted to separate bundles) -- Data returned from server functions must be serializable -- Forgetting `await` in loaders leads to streaming issues -- Importing server-only code in client bundles causes build errors -- Missing `declare module '@tanstack/react-router'` loses all type safety diff --git a/.agents/skills/tanstack-store/SKILL.md b/.agents/skills/tanstack-store/SKILL.md deleted file mode 100644 index c1e289b..0000000 --- a/.agents/skills/tanstack-store/SKILL.md +++ /dev/null @@ -1,305 +0,0 @@ ---- -name: tanstack-store -description: Framework-agnostic, immutable reactive data store with framework adapters for React, Vue, Solid, Angular, and Svelte. ---- - - -## Overview - -TanStack Store is a lightweight reactive store (signals-like) that powers the internals of TanStack libraries. It provides `Store` for state, `Derived` for computed values, `Effect` for side effects, and `batch` for atomic updates. Framework adapters provide reactive hooks. - -**Core:** `@tanstack/store` -**React:** `@tanstack/react-store` - -## Installation - -```bash -npm install @tanstack/store @tanstack/react-store -``` - -## Store - -### Creating a Store - -```typescript -import { Store } from '@tanstack/store' - -const countStore = new Store(0) - -const userStore = new Store<{ name: string; email: string }>({ - name: 'Alice', - email: 'alice@example.com', -}) -``` - -### Updating State - -```typescript -// Function updater (immutable update) -countStore.setState((prev) => prev + 1) - -userStore.setState((prev) => ({ ...prev, name: 'Bob' })) -``` - -### Subscribing to Changes - -```typescript -const unsub = countStore.subscribe(() => { - console.log('Count:', countStore.state) -}) - -// Cleanup -unsub() -``` - -### Store Options - -```typescript -const store = new Store(initialState, { - // Custom update function - updateFn: (prevValue) => (updater) => { - return updater(prevValue) // custom logic - }, - // Callback on subscribe - onSubscribe: (listener, store) => { - console.log('New subscriber') - return () => console.log('Unsubscribed') - }, - // Callback on every update - onUpdate: () => { - console.log('State updated:', store.state) - }, -}) -``` - -### Store Properties - -```typescript -store.state // Current state -store.prevState // Previous state -store.listeners // Set of listener callbacks -``` - -## Derived (Computed Values) - -```typescript -import { Store, Derived } from '@tanstack/store' - -const count = new Store(5) -const multiplier = new Store(2) - -const doubled = new Derived({ - deps: [count, multiplier], - fn: ({ currDepVals }) => currDepVals[0] * currDepVals[1], -}) - -// MUST mount to activate -const unmount = doubled.mount() - -console.log(doubled.state) // 10 - -count.setState(() => 10) -console.log(doubled.state) // 20 - -// Cleanup -unmount() -``` - -### Derived with Previous Value - -```typescript -const accumulated = new Derived({ - deps: [count], - fn: ({ prevVal, currDepVals }) => { - return currDepVals[0] + (prevVal ?? 0) - }, -}) -``` - -### Chaining Derived - -```typescript -const filtered = new Derived({ - deps: [dataStore, filterStore], - fn: ({ currDepVals }) => currDepVals[0].filter(matchesFilter(currDepVals[1])), -}) - -const sorted = new Derived({ - deps: [filtered, sortStore], - fn: ({ currDepVals }) => [...currDepVals[0]].sort(comparator(currDepVals[1])), -}) - -const paginated = new Derived({ - deps: [sorted, pageStore], - fn: ({ currDepVals }) => currDepVals[0].slice( - currDepVals[1].offset, - currDepVals[1].offset + currDepVals[1].limit, - ), -}) -``` - -## Effect (Side Effects) - -```typescript -import { Store, Effect } from '@tanstack/store' - -const count = new Store(0) - -const logger = new Effect({ - deps: [count], - fn: () => { - console.log('Count changed:', count.state) - // Optionally return cleanup function - return () => console.log('Cleaning up') - }, - eager: false, // true = run immediately on mount -}) - -const unmount = logger.mount() - -count.setState(() => 1) // logs: "Count changed: 1" - -unmount() -``` - -### Effect with Cleanup - -```typescript -const timerEffect = new Effect({ - deps: [intervalStore], - fn: () => { - const id = setInterval(() => { /* ... */ }, intervalStore.state) - return () => clearInterval(id) // cleanup on next run or unmount - }, -}) -``` - -## Batch - -Group multiple updates into one notification: - -```typescript -import { batch } from '@tanstack/store' - -// Subscribers fire only once with final state -batch(() => { - countStore.setState(() => 1) - nameStore.setState(() => 'Alice') - settingsStore.setState((prev) => ({ ...prev, theme: 'dark' })) -}) -``` - -## React Integration - -### useStore Hook - -```tsx -import { useStore } from '@tanstack/react-store' - -// Subscribe to full state -function Counter() { - const count = useStore(countStore) - return -} - -// Subscribe with selector (performance optimization) -function UserName() { - const name = useStore(userStore, (state) => state.name) - return {name} -} - -// Subscribe to Derived -function DoubledDisplay() { - const value = useStore(doubledDerived) - return {value} -} -``` - -### shallow Equality Function - -Prevents re-renders when selector returns structurally-equal objects: - -```tsx -import { useStore } from '@tanstack/react-store' -import { shallow } from '@tanstack/react-store' - -function TodoList() { - // Without shallow: re-renders on ANY state change (new object ref) - // With shallow: only re-renders when items actually change - const items = useStore(todosStore, (state) => state.items, shallow) - return
    {items.map(/* ... */)}
-} -``` - -### Mounting Derived/Effect in React - -```tsx -function MyComponent() { - useEffect(() => { - const unmountDerived = myDerived.mount() - const unmountEffect = myEffect.mount() - return () => { - unmountDerived() - unmountEffect() - } - }, []) - - const value = useStore(myDerived) - return {value} -} -``` - -## Module-Level Store Pattern - -```typescript -// stores/counter.ts -import { Store, Derived } from '@tanstack/store' - -export const counterStore = new Store(0) - -export const doubledCount = new Derived({ - deps: [counterStore], - fn: ({ currDepVals }) => currDepVals[0] * 2, -}) - -// Actions as plain functions -export function increment() { - counterStore.setState((c) => c + 1) -} - -export function reset() { - counterStore.setState(() => 0) -} -``` - -## Framework Adapters - -| Framework | Package | Hook/Composable | -|-----------|---------|-----------------| -| React | `@tanstack/react-store` | `useStore(store, selector?, equalityFn?)` | -| Vue | `@tanstack/vue-store` | `useStore(store, selector?)` (returns computed ref) | -| Solid | `@tanstack/solid-store` | `useStore(store, selector?)` (returns signal) | -| Angular | `@tanstack/angular-store` | `injectStore(store, selector?)` (returns signal) | -| Svelte | `@tanstack/svelte-store` | `useStore(store, selector?)` (returns $state) | - -## Best Practices - -1. **Define stores at module level** - they're singletons -2. **Use selectors** in `useStore` to prevent unnecessary re-renders -3. **Use `shallow`** when selectors return objects/arrays -4. **Always call `mount()`** on Derived and Effect instances -5. **Always clean up** unmount functions (especially in React useEffect) -6. **Never mutate state directly** - always use `setState` -7. **Use `batch`** for multiple related updates -8. **Use Derived chains** for data transformations (filter -> sort -> paginate) -9. **Return cleanup functions** from Effect `fn` for timers/listeners -10. **Select primitives** when possible (no equality fn needed) - -## Common Pitfalls - -- Forgetting to `mount()` Derived/Effect (they won't activate) -- Not cleaning up subscriptions/unmount functions (memory leaks) -- Mutating `store.state` directly instead of using `setState` -- Creating new object references in selectors without `shallow` -- Using `useStore` without a selector (subscribes to everything) -- Forgetting `eager: true` when Effect should run immediately diff --git a/.agents/skills/tanstack-table/SKILL.md b/.agents/skills/tanstack-table/SKILL.md deleted file mode 100644 index 3159adb..0000000 --- a/.agents/skills/tanstack-table/SKILL.md +++ /dev/null @@ -1,582 +0,0 @@ ---- -name: tanstack-table -description: Headless UI for building powerful tables & datagrids for TS/JS, React, Vue, Solid, Svelte, Qwik, Angular, and Lit. ---- - - -## Overview - -TanStack Table is a headless UI library for building data tables and datagrids. It provides logic for sorting, filtering, pagination, grouping, expanding, column pinning/ordering/visibility/resizing, and row selection - without rendering any markup or styles. - -**Package:** `@tanstack/react-table` -**Utilities:** `@tanstack/match-sorter-utils` (fuzzy filtering) -**Current Version:** v8 - -## Installation - -```bash -npm install @tanstack/react-table -``` - -## Core Architecture - -### Building Blocks - -1. **Column Definitions** - describe columns (data access, rendering, features) -2. **Table Instance** - central coordinator with state and APIs -3. **Row Models** - data processing pipeline (filter -> sort -> group -> paginate) -4. **Headers, Rows, Cells** - renderable units - -### Critical: Data & Column Stability - -```typescript -// WRONG - new references every render, causes infinite loops -const table = useReactTable({ - data: fetchedData.results, // new ref! - columns: [{ accessorKey: 'name' }], // new ref! -}) - -// CORRECT - stable references -const columns = useMemo(() => [...], []) -const data = useMemo(() => fetchedData?.results ?? [], [fetchedData]) - -const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) -``` - -## Column Definitions - -### Using createColumnHelper (Recommended) - -```typescript -import { createColumnHelper } from '@tanstack/react-table' - -type Person = { - firstName: string - lastName: string - age: number - status: 'active' | 'inactive' -} - -const columnHelper = createColumnHelper() - -const columns = [ - // Accessor column (data column) - columnHelper.accessor('firstName', { - header: 'First Name', - cell: info => info.getValue(), - footer: info => info.column.id, - }), - - // Accessor with function - columnHelper.accessor(row => row.lastName, { - id: 'lastName', // required with accessorFn - header: () => Last Name, - cell: info => {info.getValue()}, - }), - - // Display column (no data, custom rendering) - columnHelper.display({ - id: 'actions', - header: 'Actions', - cell: ({ row }) => ( - - ), - }), - - // Group column (nested headers) - columnHelper.group({ - id: 'info', - header: 'Info', - columns: [ - columnHelper.accessor('age', { header: 'Age' }), - columnHelper.accessor('status', { header: 'Status' }), - ], - }), -] -``` - -### Column Options - -| Option | Type | Description | -|--------|------|-------------| -| `id` | `string` | Unique identifier (auto-derived from accessorKey) | -| `accessorKey` | `string` | Dot-notation path to row data | -| `accessorFn` | `(row) => any` | Custom accessor function | -| `header` | `string \| (context) => ReactNode` | Header renderer | -| `cell` | `(context) => ReactNode` | Cell renderer | -| `footer` | `(context) => ReactNode` | Footer renderer | -| `size` | `number` | Default width (default: 150) | -| `minSize` | `number` | Min width (default: 20) | -| `maxSize` | `number` | Max width | -| `enableSorting` | `boolean` | Enable sorting | -| `sortingFn` | `string \| SortingFn` | Sort function | -| `enableFiltering` | `boolean` | Enable filtering | -| `filterFn` | `string \| FilterFn` | Filter function | -| `enableGrouping` | `boolean` | Enable grouping | -| `aggregationFn` | `string \| AggregationFn` | Aggregation function | -| `enableHiding` | `boolean` | Enable visibility toggle | -| `enableResizing` | `boolean` | Enable resizing | -| `enablePinning` | `boolean` | Enable pinning | -| `meta` | `any` | Custom metadata | - -## Table Instance - -### Creating a Table - -```typescript -import { - useReactTable, - getCoreRowModel, - getSortedRowModel, - getFilteredRowModel, - getPaginationRowModel, - flexRender, -} from '@tanstack/react-table' - -function MyTable() { - const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }) - - const table = useReactTable({ - data, - columns, - state: { sorting, columnFilters, pagination }, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - }) - - return ( - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - ))} - - ))} - - - {table.getRowModel().rows.map(row => ( - - {row.getVisibleCells().map(cell => ( - - ))} - - ))} - -
- {header.isPlaceholder ? null : - flexRender(header.column.columnDef.header, header.getContext())} - {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ) -} -``` - -## Sorting - -```typescript -const table = useReactTable({ - state: { sorting }, - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - enableMultiSort: true, - // manualSorting: true, // For server-side sorting -}) - -// Built-in sort functions: 'alphanumeric', 'text', 'datetime', 'basic' -// Column-level: sortingFn: 'alphanumeric' -``` - -## Filtering - -### Column Filtering - -```typescript -const table = useReactTable({ - state: { columnFilters }, - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - getFacetedMinMaxValues: getFacetedMinMaxValues(), -}) - -// Built-in: 'includesString', 'equalsString', 'arrIncludes', 'inNumberRange', etc. - -// Filter UI -function Filter({ column }) { - return ( - column.setFilterValue(e.target.value)} - placeholder={`Filter... (${column.getFacetedUniqueValues()?.size})`} - /> - ) -} -``` - -### Global Filtering - -```typescript -const [globalFilter, setGlobalFilter] = useState('') - -const table = useReactTable({ - state: { globalFilter }, - onGlobalFilterChange: setGlobalFilter, - globalFilterFn: 'includesString', - getFilteredRowModel: getFilteredRowModel(), -}) -``` - -### Fuzzy Filtering - -```typescript -import { rankItem } from '@tanstack/match-sorter-utils' - -const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { - const itemRank = rankItem(row.getValue(columnId), value) - addMeta({ itemRank }) - return itemRank.passed -} - -const table = useReactTable({ - filterFns: { fuzzy: fuzzyFilter }, - globalFilterFn: 'fuzzy', -}) -``` - -## Pagination - -```typescript -const table = useReactTable({ - state: { pagination }, - onPaginationChange: setPagination, - getPaginationRowModel: getPaginationRowModel(), - // For server-side: - // manualPagination: true, - // pageCount: serverPageCount, -}) - -// Navigation -table.nextPage() -table.previousPage() -table.firstPage() -table.lastPage() -table.setPageSize(20) -table.getCanNextPage() // boolean -table.getCanPreviousPage() // boolean -table.getPageCount() // total pages -``` - -## Row Selection - -```typescript -const [rowSelection, setRowSelection] = useState({}) - -const table = useReactTable({ - state: { rowSelection }, - onRowSelectionChange: setRowSelection, - enableRowSelection: true, - enableMultiRowSelection: true, -}) - -// Checkbox column -columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - ), - cell: ({ row }) => ( - - ), -}) - -// Get selected rows -table.getSelectedRowModel().rows -``` - -## Column Visibility - -```typescript -const [columnVisibility, setColumnVisibility] = useState({}) - -const table = useReactTable({ - state: { columnVisibility }, - onColumnVisibilityChange: setColumnVisibility, -}) - -// Toggle UI -{table.getAllLeafColumns().map(column => ( - -))} -``` - -## Column Pinning - -```typescript -const [columnPinning, setColumnPinning] = useState({ - left: ['select', 'name'], - right: ['actions'], -}) - -const table = useReactTable({ - state: { columnPinning }, - onColumnPinningChange: setColumnPinning, - enableColumnPinning: true, -}) - -// Render pinned sections separately -row.getLeftVisibleCells() // Left-pinned -row.getCenterVisibleCells() // Unpinned -row.getRightVisibleCells() // Right-pinned -``` - -## Column Resizing - -```typescript -const table = useReactTable({ - enableColumnResizing: true, - columnResizeMode: 'onChange', // 'onChange' | 'onEnd' - defaultColumn: { size: 150, minSize: 50, maxSize: 500 }, -}) - -// Resize handle in header -
-``` - -## Grouping & Aggregation - -```typescript -const [grouping, setGrouping] = useState([]) - -const table = useReactTable({ - state: { grouping }, - onGroupingChange: setGrouping, - getGroupedRowModel: getGroupedRowModel(), - getExpandedRowModel: getExpandedRowModel(), -}) - -// Built-in aggregation: 'sum', 'min', 'max', 'mean', 'median', 'count', 'unique', 'uniqueCount' -columnHelper.accessor('amount', { - aggregationFn: 'sum', - aggregatedCell: ({ getValue }) => `Total: ${getValue()}`, -}) -``` - -## Row Expanding - -```typescript -const [expanded, setExpanded] = useState({}) - -const table = useReactTable({ - state: { expanded }, - onExpandedChange: setExpanded, - getExpandedRowModel: getExpandedRowModel(), - getSubRows: (row) => row.subRows, // For hierarchical data -}) - -// Expand toggle - - -// Detail row pattern -{row.getIsExpanded() && ( - - - - - -)} -``` - -## Virtualization Integration - -```typescript -import { useVirtualizer } from '@tanstack/react-virtual' - -function VirtualizedTable() { - const table = useReactTable({ /* ... */ }) - const { rows } = table.getRowModel() - const parentRef = useRef(null) - - const virtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 35, - overscan: 10, - }) - - return ( -
- - - {virtualizer.getVirtualItems().map(virtualRow => { - const row = rows[virtualRow.index] - return ( - - {row.getVisibleCells().map(cell => ( - - ))} - - ) - })} - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- ) -} -``` - -## Server-Side Operations - -```typescript -const table = useReactTable({ - data: serverData, - columns, - manualSorting: true, - manualFiltering: true, - manualPagination: true, - pageCount: serverPageCount, - state: { sorting, columnFilters, pagination }, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - // Do NOT include getSortedRowModel, getFilteredRowModel, getPaginationRowModel -}) - -// Fetch data based on state -useEffect(() => { - fetchData({ sorting, filters: columnFilters, pagination }) -}, [sorting, columnFilters, pagination]) -``` - -## TypeScript Patterns - -### Extending Column Meta - -```typescript -declare module '@tanstack/react-table' { - interface ColumnMeta { - filterVariant?: 'text' | 'range' | 'select' - align?: 'left' | 'center' | 'right' - } -} -``` - -### Custom Filter/Sort Function Registration - -```typescript -declare module '@tanstack/react-table' { - interface FilterFns { - fuzzy: FilterFn - } - interface SortingFns { - myCustomSort: SortingFn - } -} -``` - -### Editable Cells via Table Meta - -```typescript -declare module '@tanstack/react-table' { - interface TableMeta { - updateData: (rowIndex: number, columnId: string, value: unknown) => void - } -} - -const table = useReactTable({ - meta: { - updateData: (rowIndex, columnId, value) => { - setData(old => old.map((row, i) => - i === rowIndex ? { ...row, [columnId]: value } : row - )) - }, - }, -}) -``` - -## Key Imports - -```typescript -import { - createColumnHelper, flexRender, useReactTable, - getCoreRowModel, getSortedRowModel, getFilteredRowModel, - getPaginationRowModel, getGroupedRowModel, getExpandedRowModel, - getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues, -} from '@tanstack/react-table' - -import type { - ColumnDef, SortingState, ColumnFiltersState, VisibilityState, - PaginationState, ExpandedState, RowSelectionState, GroupingState, - ColumnOrderState, ColumnPinningState, FilterFn, SortingFn, -} from '@tanstack/react-table' -``` - -## Best Practices - -1. **Always memoize `data` and `columns`** to prevent infinite re-renders -2. **Use `flexRender`** for all header/cell/footer rendering -3. **Use `table.getRowModel().rows`** for final rendered rows (not getCoreRowModel) -4. **Import only needed row models** - each adds processing to the pipeline -5. **Use `getRowId`** for stable row keys when data has unique IDs -6. **Use `manualX` options** for server-side operations -7. **Pair controlled state** with both `state.X` and `onXChange` -8. **Use module augmentation** for custom meta, filter fns, sort fns -9. **Use column helper** for type-safe column definitions -10. **Set `autoResetPageIndex: true`** when filtering should reset pagination - -## Common Pitfalls - -- Defining columns inline (creates new ref each render) -- Forgetting `getCoreRowModel()` (required for all tables) -- Using row models without importing them -- Not providing `id` when using `accessorFn` -- Mixing `manualPagination` with client-side `getPaginationRowModel` -- Forgetting `colSpan` for grouped headers -- Not handling `header.isPlaceholder` for group column spacers diff --git a/.agents/skills/tanstack-virtual/SKILL.md b/.agents/skills/tanstack-virtual/SKILL.md deleted file mode 100644 index ec6401e..0000000 --- a/.agents/skills/tanstack-virtual/SKILL.md +++ /dev/null @@ -1,369 +0,0 @@ ---- -name: tanstack-virtual -description: Headless UI for virtualizing large element lists at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular. ---- - - -## Overview - -TanStack Virtual provides virtualization logic for rendering only visible items in large lists, grids, and tables. It calculates which items are in the viewport and positions them with absolute positioning, keeping DOM node count minimal regardless of dataset size. - -**Package:** `@tanstack/react-virtual` -**Core:** `@tanstack/virtual-core` (framework-agnostic) - -## Installation - -```bash -npm install @tanstack/react-virtual -``` - -## Core Pattern - -```tsx -import { useVirtualizer } from '@tanstack/react-virtual' - -function VirtualList() { - const parentRef = useRef(null) - - const virtualizer = useVirtualizer({ - count: 10000, - getScrollElement: () => parentRef.current, - estimateSize: () => 35, // estimated row height in px - overscan: 5, - }) - - return ( -
-
- {virtualizer.getVirtualItems().map((virtualItem) => ( -
- Row {virtualItem.index} -
- ))} -
-
- ) -} -``` - -## Virtualizer Options - -### Required - -| Option | Type | Description | -|--------|------|-------------| -| `count` | `number` | Total number of items | -| `getScrollElement` | `() => Element \| null` | Returns scroll container | -| `estimateSize` | `(index) => number` | Estimated item size (overestimate recommended) | - -### Optional - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `overscan` | `number` | `1` | Extra items rendered beyond viewport | -| `horizontal` | `boolean` | `false` | Horizontal virtualization | -| `gap` | `number` | `0` | Gap between items (px) | -| `lanes` | `number` | `1` | Number of lanes (masonry/grid) | -| `paddingStart` | `number` | `0` | Padding before first item | -| `paddingEnd` | `number` | `0` | Padding after last item | -| `scrollPaddingStart` | `number` | `0` | Offset for scrollTo positioning | -| `scrollPaddingEnd` | `number` | `0` | Offset for scrollTo positioning | -| `initialOffset` | `number` | `0` | Starting scroll position | -| `initialRect` | `Rect` | - | Initial dimensions (SSR) | -| `enabled` | `boolean` | `true` | Enable/disable | -| `getItemKey` | `(index) => Key` | `(i) => i` | Stable key for items | -| `rangeExtractor` | `(range) => number[]` | default | Custom visible indices | -| `scrollToFn` | `(offset, options, instance) => void` | default | Custom scroll behavior | -| `measureElement` | `(el, entry, instance) => number` | default | Custom measurement | -| `onChange` | `(instance, sync) => void` | - | State change callback | -| `isScrollingResetDelay` | `number` | `150` | Delay before scroll complete | - -## Virtualizer API - -```typescript -// Get visible items -virtualizer.getVirtualItems(): VirtualItem[] - -// Get total scrollable size -virtualizer.getTotalSize(): number - -// Scroll to specific index -virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' }) - -// Scroll to offset -virtualizer.scrollToOffset(offset, options) - -// Force recalculation -virtualizer.measure() -``` - -## VirtualItem Properties - -```typescript -interface VirtualItem { - key: Key // Unique key - index: number // Index in source data - start: number // Pixel offset (use for transform) - end: number // End pixel offset - size: number // Item dimension - lane: number // Lane index (multi-column) -} -``` - -## Dynamic/Variable Heights - -Use `measureElement` ref for items with unknown heights: - -```tsx -const virtualizer = useVirtualizer({ - count: items.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 50, // overestimate -}) - -{virtualizer.getVirtualItems().map((virtualItem) => ( -
- {items[virtualItem.index].content} -
-))} -``` - -## Horizontal Virtualization - -```tsx -const virtualizer = useVirtualizer({ - count: columns.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 100, - horizontal: true, -}) - -// Use width for container, translateX for positioning -
- {virtualizer.getVirtualItems().map((item) => ( -
- Column {item.index} -
- ))} -
-``` - -## Grid Virtualization (Two Virtualizers) - -```tsx -function VirtualGrid() { - const parentRef = useRef(null) - - const rowVirtualizer = useVirtualizer({ - count: 10000, - getScrollElement: () => parentRef.current, - estimateSize: () => 35, - overscan: 5, - }) - - const columnVirtualizer = useVirtualizer({ - count: 10000, - getScrollElement: () => parentRef.current, - estimateSize: () => 100, - horizontal: true, - overscan: 5, - }) - - return ( -
-
- {rowVirtualizer.getVirtualItems().map((virtualRow) => ( - - {columnVirtualizer.getVirtualItems().map((virtualColumn) => ( -
- Cell {virtualRow.index},{virtualColumn.index} -
- ))} -
- ))} -
-
- ) -} -``` - -## Window Scrolling - -```tsx -import { useWindowVirtualizer } from '@tanstack/react-virtual' - -function WindowList() { - const listRef = useRef(null) - - const virtualizer = useWindowVirtualizer({ - count: 10000, - estimateSize: () => 45, - overscan: 5, - scrollMargin: listRef.current?.offsetTop ?? 0, - }) - - return ( -
-
- {virtualizer.getVirtualItems().map((item) => ( -
- Row {item.index} -
- ))} -
-
- ) -} -``` - -## Infinite Scrolling - -```tsx -import { useVirtualizer } from '@tanstack/react-virtual' -import { useInfiniteQuery } from '@tanstack/react-query' - -function InfiniteList() { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - queryKey: ['items'], - queryFn: ({ pageParam = 0 }) => fetchItems(pageParam), - getNextPageParam: (lastPage) => lastPage.nextCursor, - }) - - const allItems = data?.pages.flatMap((page) => page.items) ?? [] - - const virtualizer = useVirtualizer({ - count: hasNextPage ? allItems.length + 1 : allItems.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 50, - overscan: 5, - }) - - useEffect(() => { - const items = virtualizer.getVirtualItems() - const lastItem = items[items.length - 1] - if (lastItem && lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) { - fetchNextPage() - } - }, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allItems.length]) - - // Render virtual items, show loader row for last item if loading -} -``` - -## Sticky Items - -```tsx -import { defaultRangeExtractor, Range } from '@tanstack/react-virtual' - -const stickyIndexes = [0, 10, 20, 30] // Header indices - -const virtualizer = useVirtualizer({ - count: 1000, - getScrollElement: () => parentRef.current, - estimateSize: () => 50, - rangeExtractor: useCallback((range: Range) => { - const next = new Set([...stickyIndexes, ...defaultRangeExtractor(range)]) - return [...next].sort((a, b) => a - b) - }, [stickyIndexes]), -}) - -// Render sticky items with position: sticky; top: 0; zIndex: 1 -``` - -## Smooth Scrolling - -```tsx -const virtualizer = useVirtualizer({ - scrollToFn: (offset, { behavior }, instance) => { - if (behavior === 'smooth') { - // Custom easing animation - instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' }) - } else { - instance.scrollElement?.scrollTo({ top: offset }) - } - }, -}) - -// Usage -virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' }) -``` - -## Best Practices - -1. **Overestimate `estimateSize`** - prevents scroll jumps (items shrinking causes issues) -2. **Increase `overscan`** (3-5) to reduce blank flashing during fast scrolling -3. **Use `transform: translateY()`** over `top` for GPU-composited positioning -4. **Add `data-index` attribute** when using `measureElement` for dynamic sizing -5. **Don't set fixed height** on dynamically measured items -6. **Use `getItemKey`** for stable keys when items can reorder -7. **Use `gap` option** instead of margins (margins interfere with measurement) -8. **Use `paddingStart/End`** instead of CSS padding on the container -9. **Use `enabled: false`** to pause when the list is hidden -10. **Memoize callbacks** (`estimateSize`, `getItemKey`, `rangeExtractor`) -11. **Use `will-change: transform`** CSS on items for GPU acceleration - -## Common Pitfalls - -- Setting fixed height on dynamically measured items -- Using CSS margins instead of the `gap` option -- Forgetting `data-index` with `measureElement` -- Not providing `position: relative` on the inner container -- Underestimating `estimateSize` (causes scroll jumps) -- Setting `overscan` too low for fast scrolling (blank items) -- Forgetting to subtract `scrollMargin` from `translateY` in window scrolling -- Not memoizing the `estimateSize` function (causes re-renders) diff --git a/.agents/skills/web-design-guidelines/SKILL.md b/.agents/skills/web-design-guidelines/SKILL.md deleted file mode 100644 index ceae92a..0000000 --- a/.agents/skills/web-design-guidelines/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: web-design-guidelines -description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices". -metadata: - author: vercel - version: "1.0.0" - argument-hint: ---- - -# Web Interface Guidelines - -Review files for compliance with Web Interface Guidelines. - -## How It Works - -1. Fetch the latest guidelines from the source URL below -2. Read the specified files (or prompt user for files/pattern) -3. Check against all rules in the fetched guidelines -4. Output findings in the terse `file:line` format - -## Guidelines Source - -Fetch fresh guidelines before each review: - -``` -https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md -``` - -Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions. - -## Usage - -When a user provides a file or pattern argument: -1. Fetch guidelines from the source URL above -2. Read the specified files -3. Apply all rules from the fetched guidelines -4. Output findings using the format specified in the guidelines - -If no files specified, ask the user which files to review. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 76488c1..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "includeCoAuthoredBy": false, - "permissions": { - "ask": ["Bash(git commit:*)"] - } -} diff --git a/.claude/skills/better-auth-best-practices/SKILL.md b/.claude/skills/better-auth-best-practices/SKILL.md deleted file mode 100644 index ede2e89..0000000 --- a/.claude/skills/better-auth-best-practices/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: better-auth-best-practices -description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration. ---- - -# Better Auth Integration Guide - -**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.** - ---- - -## Setup Workflow - -1. Install: `npm install better-auth` -2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` -3. Create `auth.ts` with database + config -4. Create route handler for your framework -5. Run `npx @better-auth/cli@latest migrate` -6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }` - ---- - -## Quick Reference - -### Environment Variables -- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32` -- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`) - -Only define `baseURL`/`secret` in config if env vars are NOT set. - -### File Location -CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path. - -### CLI Commands -- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter) -- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle -- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools - -**Re-run after adding/changing plugins.** - ---- - -## Core Config Options - -| Option | Notes | -|--------|-------| -| `appName` | Optional display name | -| `baseURL` | Only if `BETTER_AUTH_URL` not set | -| `basePath` | Default `/api/auth`. Set `/` for root. | -| `secret` | Only if `BETTER_AUTH_SECRET` not set | -| `database` | Required for most features. See adapters docs. | -| `secondaryStorage` | Redis/KV for sessions & rate limits | -| `emailAndPassword` | `{ enabled: true }` to activate | -| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` | -| `plugins` | Array of plugins | -| `trustedOrigins` | CSRF whitelist | - ---- - -## Database - -**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. - -**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`. - -**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`. - ---- - -## Session Management - -**Storage priority:** -1. If `secondaryStorage` defined -> sessions go there (not DB) -2. Set `session.storeSessionInDatabase: true` to also persist to DB -3. No database + `cookieCache` -> fully stateless mode - -**Cookie cache strategies:** -- `compact` (default) - Base64url + HMAC. Smallest. -- `jwt` - Standard JWT. Readable but signed. -- `jwe` - Encrypted. Maximum security. - -**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions). - ---- - -## User & Account Config - -**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default). - -**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth). - -**Required for registration:** `email` and `name` fields. - ---- - -## Email Flows - -- `emailVerification.sendVerificationEmail` - Must be defined for verification to work -- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers -- `emailAndPassword.sendResetPassword` - Password reset email handler - ---- - -## Security - -**In `advanced`:** -- `useSecureCookies` - Force HTTPS cookies -- `disableCSRFCheck` - ⚠️ Security risk -- `disableOriginCheck` - ⚠️ Security risk -- `crossSubDomainCookies.enabled` - Share cookies across subdomains -- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies -- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false` - -**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage"). - ---- - -## Hooks - -**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`. - -**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions. - -**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`. - ---- - -## Plugins - -**Import from dedicated paths for tree-shaking:** -``` -import { twoFactor } from "better-auth/plugins/two-factor" -``` -NOT `from "better-auth/plugins"`. - -**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`. - -Client plugins go in `createAuthClient({ plugins: [...] })`. - ---- - -## Client - -Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`. - -Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`. - ---- - -## Type Safety - -Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`. - -For separate client/server projects: `createAuthClient()`. - ---- - -## Common Gotchas - -1. **Model vs table name** - Config uses ORM model name, not DB table name -2. **Plugin schema** - Re-run CLI after adding plugins -3. **Secondary storage** - Sessions go there by default, not DB -4. **Cookie cache** - Custom session fields NOT cached, always re-fetched -5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry -6. **Change email flow** - Sends to current email first, then new email - ---- - -## Resources - -- [Docs](https://better-auth.com/docs) -- [Options Reference](https://better-auth.com/docs/reference/options) -- [LLMs.txt](https://better-auth.com/llms.txt) -- [GitHub](https://github.com/better-auth/better-auth) -- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts) \ No newline at end of file diff --git a/.claude/skills/mantine-custom-components/SKILL.md b/.claude/skills/mantine-custom-components/SKILL.md deleted file mode 100644 index cacb44e..0000000 --- a/.claude/skills/mantine-custom-components/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: mantine-custom-core -description: > - Build custom components that integrate with Mantine's theming, Styles API, and core features. - Use this skill when: (1) creating a new component using factory(), polymorphicFactory(), or - genericFactory(), (2) adding Styles API support (classNames, styles, vars, unstyled), (3) - implementing CSS variables via createVarsResolver, (4) building compound components with - sub-components and shared context, (5) registering a component with MantineProvider via - Component.extend(), or (6) any task involving Factory, useProps, useStyles, BoxProps, - StylesApiProps, or ElementProps in @mantine/core. ---- - -# Mantine Custom Components Skill - -## Component template - -```tsx -import { - Box, BoxProps, createVarsResolver, ElementProps, - factory, Factory, getRadius, MantineRadius, - StylesApiProps, useProps, useStyles, -} from '@mantine/core'; -import classes from './MyComponent.module.css'; - -export type MyComponentStylesNames = 'root' | 'inner'; -export type MyComponentVariant = 'filled' | 'outline'; -export type MyComponentCssVariables = { root: '--my-radius' }; - -export interface MyComponentProps - extends BoxProps, StylesApiProps, ElementProps<'div'> { - radius?: MantineRadius; -} - -export type MyComponentFactory = Factory<{ - props: MyComponentProps; - ref: HTMLDivElement; - stylesNames: MyComponentStylesNames; - vars: MyComponentCssVariables; - variant: MyComponentVariant; -}>; - -const defaultProps = { radius: 'md' } satisfies Partial; - -const varsResolver = createVarsResolver((_theme, { radius }) => ({ - root: { '--my-radius': getRadius(radius) }, -})); - -export const MyComponent = factory((_props) => { - const props = useProps('MyComponent', defaultProps, _props); - const { classNames, className, style, styles, unstyled, vars, attributes, radius, ...others } = props; - - const getStyles = useStyles({ - name: 'MyComponent', classes, props, - className, style, classNames, styles, unstyled, vars, attributes, varsResolver, - }); - - return ; -}); - -MyComponent.displayName = '@mantine/core/MyComponent'; -MyComponent.classes = classes; -``` - -## Factory variant — which to use - -| Scenario | Factory function | Type | -|---|---|---| -| Standard component | `factory()` | `Factory<{}>` | -| Supports `component` prop (polymorphic) | `polymorphicFactory()` | `PolymorphicFactory<{}>` — add `defaultComponent` and `defaultRef` | -| Props change based on a generic (e.g. `multiple`) | `genericFactory()` | `Factory<{ signature: ... }>` | - -Use `polymorphicFactory` sparingly — it adds TypeScript overhead and slows IDE autocomplete. - -## Factory type fields - -```ts -Factory<{ - props: MyComponentProps; // required - ref: HTMLDivElement; // element type for the forwarded ref - stylesNames: 'root' | 'inner'; // union of Styles API selectors - vars: { root: '--my-var' }; // CSS variable map per selector - variant: 'filled' | 'outline'; // accepted variant strings - staticComponents: { // sub-core (compound pattern) - Item: typeof MyComponentItem; - }; - compound?: boolean; // true = sub-component; disables theme classNames/styles/vars - ctx?: MyContextType; // passed to styles/vars resolvers as third arg - signature?: (...) => JSX.Element; // only for genericFactory -}> -``` - -## Theme integration - -Users and the theme can override defaults via `Component.extend()`: - -```ts -const theme = createTheme({ - components: { - MyComponent: MyComponent.extend({ - defaultProps: { radius: 'xl' }, - classNames: { root: 'my-root' }, - styles: { root: { color: 'red' } }, - vars: (_theme, props) => ({ root: { '--my-radius': getRadius(props.radius) } }), - }), - }, -}); -``` - -## References - -- **[`references/api.md`](references/api.md)** — All imports: `factory`, `useProps`, `useStyles`, `createVarsResolver`, `createSafeContext`, `StylesApiProps`, `CompoundStylesApiProps`, `BoxProps`, `ElementProps`, theme helpers (`getSize`, `getRadius`, etc.) -- **[`references/patterns.md`](references/patterns.md)** — Full examples: compound components with context, polymorphic component, generic component, theme integration diff --git a/.claude/skills/mantine-custom-components/references/api.md b/.claude/skills/mantine-custom-components/references/api.md deleted file mode 100644 index b471a38..0000000 --- a/.claude/skills/mantine-custom-components/references/api.md +++ /dev/null @@ -1,407 +0,0 @@ -# Custom Components API Reference - -## Table of Contents -- [Imports cheatsheet](#imports-cheatsheet) -- [factory / polymorphicFactory / genericFactory](#factory--polymorphicfactory--genericfactory) -- [Factory type fields](#factory-type-fields) -- [useProps](#usepropss) -- [useStyles](#usestyles) -- [createVarsResolver](#createvarsresolver) -- [StylesApiProps and CompoundStylesApiProps](#stylesapiprops-and-compoundstylesapiprops) -- [BoxProps and ElementProps](#boxprops-and-elementprops) -- [createSafeContext](#createsafecontext) -- [Theme helper functions](#theme-helper-functions) -- [Static properties](#static-properties) - ---- - -## Imports cheatsheet - -```ts -import { - // Factory functions - factory, - polymorphicFactory, - genericFactory, - - // Types - Factory, - PolymorphicFactory, - StylesApiProps, - CompoundStylesApiProps, - BoxProps, - ElementProps, - - // Hooks - useProps, - useStyles, - - // Vars - createVarsResolver, - - // Context - createSafeContext, - - // Base component - Box, - - // Theme helpers - getSize, - getSpacing, - getRadius, - getFontSize, - getLineHeight, - getShadow, - rem, - em, -} from '@mantine/core'; -``` - ---- - -## factory / polymorphicFactory / genericFactory - -### factory() - -Standard factory for non-polymorphic components. - -```ts -factory( - ui: (props: Payload['props'] & { ref?: React.Ref }) => React.ReactNode -): MantineComponent -``` - -The returned component has static properties: `.extend()`, `.withProps()`, `.classes`, `.displayName`, `.varsResolver` (if vars are used), and any sub-components assigned. - -### polymorphicFactory() - -For components that accept a `component` prop to render as a different element. Same signature as `factory()`, but uses `PolymorphicFactory` type. - -```ts -// Type uses PolymorphicFactory<{}> instead of Factory<{}> -export type MyFactory = PolymorphicFactory<{ - props: MyProps; - defaultRef: HTMLButtonElement; // default ref type - defaultComponent: 'button'; // default element - stylesNames: ...; - vars: ...; -}>; - -export const My = polymorphicFactory((_props) => { ... }); -``` - -### genericFactory() - -For components whose prop types depend on a generic argument. - -```ts -// Factory uses 'signature' field -export type MyFactory = Factory<{ - props: MyProps; - signature: (props: MyProps) => React.JSX.Element; - ref: HTMLDivElement; - // ... other fields -}>; - -export const My = genericFactory((_props) => { ... }); -``` - ---- - -## Factory type fields - -All fields except `props` are optional. - -```ts -Factory<{ - props: MyComponentProps; - - // Forwarded ref element type - ref: HTMLDivElement; - - // Union of Styles API selector strings (must match CSS module class names) - stylesNames: 'root' | 'label' | 'icon'; - - // CSS variables definition: { selectorName: '--var-name' | '--other-var' } - vars: { - root: '--my-height' | '--my-color'; - label: '--my-label-fz'; - }; - - // Accepted values for the variant prop - variant: 'filled' | 'outline' | 'subtle'; - - // Sub-core for compound pattern - staticComponents: { - Item: typeof MyItem; - Label: typeof MyLabel; - }; - - // Set to true for sub-core — disables theme classNames/styles/vars for this component - compound: true; - - // Context type passed as 3rd argument to styles/vars resolvers - ctx: { opened: boolean }; - - // Generic signature (genericFactory only) - signature: (props: MyProps) => React.JSX.Element; -}> -``` - ---- - -## useProps - -Merges default props from three sources in priority order (highest -> lowest): -1. Props passed by the user -2. Default props from `MantineProvider` theme (`components.MyComponent.defaultProps`) -3. Component-level `defaultProps` - -```ts -useProps>( - componentName: string, // must match the name used in theme.core - defaultProps: Partial, // use 'satisfies Partial' for correct inference - props: T -): T -``` - -**Important:** Always call `useProps` before destructuring. Always use `satisfies Partial` (not `: Partial`) for `defaultProps` to preserve narrowed types. - -```ts -const defaultProps = { size: 'md', variant: 'filled' } satisfies Partial; - -const props = useProps('MyComponent', defaultProps, _props); -const { className, style, classNames, styles, unstyled, vars, attributes, ...others } = props; -``` - ---- - -## useStyles - -Returns a `getStyles` function that provides `className` and `style` for each Styles API selector. - -```ts -useStyles(input: { - name: string | string[]; // component name(s) for static CSS class generation - classes: Record; // CSS module classes object - props: Payload['props']; - stylesCtx?: Payload['ctx']; // optional context for styles/vars resolvers - className?: string; // spread to rootSelector - style?: MantineStyleProp; // spread to rootSelector - rootSelector?: string; // which selector gets className/style (default: 'root') - unstyled?: boolean; - classNames?: ClassNames; - styles?: Styles; - vars?: PartialVarsResolver; - varsResolver?: VarsResolver; - attributes?: Attributes; -}): GetStylesApi -``` - -**`getStyles` function:** -```ts -getStyles( - selector: StylesNames, - options?: { - className?: string; // additional className merged in - style?: CSSProperties; // additional style merged in - focusable?: boolean; - active?: boolean; - withStaticClass?: boolean; - } -): { className: string; style: CSSProperties } -``` - -Usage: -```tsx - -
-``` - ---- - -## createVarsResolver - -Defines how component props map to CSS variables. - -```ts -createVarsResolver( - resolver: ( - theme: MantineTheme, - props: Payload['props'], - ctx: Payload['ctx'] // only if Factory has ctx field - ) => TransformVars -): VarsResolver -``` - -The resolver must return an object matching the `vars` structure defined in `Factory`: - -```ts -// Factory vars: { root: '--my-height' | '--my-color' } -const varsResolver = createVarsResolver((_theme, { size, color }) => ({ - root: { - '--my-height': getSize(size, 'my-height'), - '--my-color': color ?? undefined, // undefined = CSS var not set (uses CSS fallback) - }, -})); -``` - -Assign to the component after creation: -```ts -MyComponent.varsResolver = varsResolver; -``` - ---- - -## StylesApiProps and CompoundStylesApiProps - -**`StylesApiProps`** — extend on the root/main component's props interface: -```ts -interface StylesApiProps { - unstyled?: boolean; - variant?: Payload['variant'] | (string & {}); - classNames?: ClassNames; // { root: 'my-class', inner: 'other' } or callback - styles?: Styles; // { root: { color: 'red' } } or callback - vars?: PartialVarsResolver; // (theme, props) => { root: { '--my-var': '...' } } - attributes?: Attributes; // { root: { 'data-custom': value } } -} -``` - -**`CompoundStylesApiProps`** — extend on sub-component (compound) props instead. Subset of `StylesApiProps` — no `unstyled` or `attributes`. - -```ts -interface CompoundStylesApiProps - extends Omit, 'unstyled' | 'attributes'> {} -``` - -Compound sub-components also use `Factory<{ ..., compound: true }>` and access styles via the parent context's `getStyles`. - ---- - -## BoxProps and ElementProps - -**`BoxProps`** — extends `MantineStyleProps`, adds: -```ts -interface BoxProps extends MantineStyleProps { - className?: string; - style?: MantineStyleProp; // accepts function: (theme) => CSSProperties - mod?: string | Record | (string | Record)[]; // data-* attributes - hiddenFrom?: MantineBreakpoint; // hidden at this breakpoint and above - visibleFrom?: MantineBreakpoint; // visible only at this breakpoint and above - lightHidden?: boolean; // hidden in light color scheme - darkHidden?: boolean; // hidden in dark color scheme -} -``` - -**`MantineStyleProps`** — shorthand style props (all accept responsive `{ base, sm, md, lg, xl }` objects): - -| Prop | CSS property | Prop | CSS property | -|---|---|---|---| -| `m` `mt` `mb` `ml` `mr` `mx` `my` `ms` `me` | margin variants | `p` `pt` `pb` `pl` `pr` `px` `py` `ps` `pe` | padding variants | -| `w` `miw` `maw` | width | `h` `mih` `mah` | height | -| `c` | color | `bg` | background | -| `fz` | font-size | `fw` | font-weight | -| `ff` | font-family | `fs` | font-style | -| `lh` | line-height | `lts` | letter-spacing | -| `ta` | text-align | `tt` | text-transform | -| `td` | text-decoration | `bd` | border | -| `bdrs` | border-radius | `opacity` | opacity | -| `pos` | position | `top` `left` `bottom` `right` `inset` | positioning | -| `display` | display | `flex` | flex | - -**`ElementProps`** — gets HTML element props, remapping `style` to Mantine's type: -```ts -// Include all div props except style (remapped) and any conflicting props -interface MyProps extends ElementProps<'div'> {} - -// Omit conflicting HTML attrs (e.g. input has native 'size' and 'color') -interface MyProps extends ElementProps<'input', 'size' | 'color'> { - size?: MantineSize; - color?: MantineColor; -} - -// Can also accept a React component type instead of element string -interface MyProps extends ElementProps {} -``` - ---- - -## createSafeContext - -Used inside compound components to share state from the parent to sub-components. - -```ts -createSafeContext( - errorMessage: string // thrown when hook is used outside the provider -): [ - Context: React.Context, - useContext: () => ContextValue // throws errorMessage if used outside provider -] -``` - -**Usage pattern (in ComponentName.context.ts):** -```ts -import { createSafeContext, GetStylesApi } from '@mantine/core'; -import { MyFactory } from './MyComponent'; - -interface MyContextValue { - getStyles: GetStylesApi; - // other shared state... -} - -export const [MyProvider, useMyContext] = createSafeContext( - 'MyComponent was not found in tree' -); -``` - -In the root component: -```tsx -return ( - - {children} - -); -``` - -In sub-components: -```tsx -const { getStyles } = useMyContext(); -return
; -``` - ---- - -## Theme helper functions - -Use these in `createVarsResolver` to convert Mantine size tokens to CSS values: - -| Function | Input | Output example | -|---|---|---| -| `getSize(size, prefix)` | `'sm'`, `'button-height'` | `'var(--mantine-button-height-sm)'` | -| `getSpacing(size)` | `'md'` or `16` | `'var(--mantine-spacing-md)'` or `'1rem'` | -| `getRadius(size)` | `'sm'` or `4` | `'var(--mantine-radius-sm)'` or `'0.25rem'` | -| `getFontSize(size)` | `'sm'` | `'var(--mantine-font-size-sm)'` | -| `getLineHeight(size)` | `'sm'` | `'var(--mantine-line-height-sm)'` | -| `getShadow(size)` | `'md'` | `'var(--mantine-shadow-md)'` | -| `rem(value)` | `16` | `'1rem'` | -| `em(value)` | `16` | `'1em'` | - -Return `undefined` from a var resolver entry to leave that CSS variable unset (CSS fallback applies). - ---- - -## Static properties - -These must be set on every component after creation: - -```ts -MyComponent.displayName = '@mantine/core/MyComponent'; // or '@mantine/package/Name' -MyComponent.classes = classes; // CSS module classes object -MyComponent.varsResolver = varsResolver; // only if component defines vars - -// Sub-core (compound pattern) -MyComponent.Item = MyItem; -MyComponent.Label = MyLabel; -``` - -`.extend()` and `.withProps()` are added automatically by `factory()`. diff --git a/.claude/skills/mantine-custom-components/references/patterns.md b/.claude/skills/mantine-custom-components/references/patterns.md deleted file mode 100644 index acbe0c5..0000000 --- a/.claude/skills/mantine-custom-components/references/patterns.md +++ /dev/null @@ -1,431 +0,0 @@ -# Custom Component Patterns - -## Table of Contents -- [Minimal component (no styles API)](#minimal-component-no-styles-api) -- [Component with CSS variables](#component-with-css-variables) -- [Compound component with context](#compound-component-with-context) -- [Polymorphic component](#polymorphic-component) -- [Generic component](#generic-component) -- [Theme integration](#theme-integration) -- [Namespace exports](#namespace-exports) - ---- - -## Minimal component (no styles API) - -When you don't need theming/Styles API support — just Box + useProps. - -```tsx -import { Box, BoxProps, ElementProps, factory, Factory, useProps } from '@mantine/core'; - -export interface MinimalProps extends BoxProps, ElementProps<'div'> { - label?: string; -} - -export type MinimalFactory = Factory<{ - props: MinimalProps; - ref: HTMLDivElement; -}>; - -const defaultProps = {} satisfies Partial; - -export const Minimal = factory((_props) => { - const props = useProps('Minimal', defaultProps, _props); - const { label, children, ...others } = props; - - return ( - - {label && {label}} - {children} - - ); -}); - -Minimal.displayName = '@mantine/core/Minimal'; -``` - ---- - -## Component with CSS variables - -Full example with Styles API, CSS variables, and theme integration. - -**MyComponent.module.css:** -```css -.root { - border-radius: var(--my-radius); - padding: var(--my-padding); -} - -.inner { - font-size: var(--my-fz); -} -``` - -**MyComponent.tsx:** -```tsx -import { - Box, BoxProps, createVarsResolver, ElementProps, factory, Factory, - getFontSize, getRadius, getSpacing, MantineFontSize, MantineRadius, - MantineSpacing, StylesApiProps, useProps, useStyles, -} from '@mantine/core'; -import classes from './MyComponent.module.css'; - -export type MyComponentStylesNames = 'root' | 'inner'; -export type MyComponentVariant = 'filled' | 'outline'; -export type MyComponentCssVariables = { - root: '--my-radius' | '--my-padding'; - inner: '--my-fz'; -}; - -export interface MyComponentProps - extends BoxProps, StylesApiProps, ElementProps<'div'> { - radius?: MantineRadius; - padding?: MantineSpacing; - size?: MantineFontSize; - variant?: MyComponentVariant; -} - -export type MyComponentFactory = Factory<{ - props: MyComponentProps; - ref: HTMLDivElement; - stylesNames: MyComponentStylesNames; - vars: MyComponentCssVariables; - variant: MyComponentVariant; -}>; - -const defaultProps = { - radius: 'sm', - padding: 'md', - size: 'md', -} satisfies Partial; - -const varsResolver = createVarsResolver((_theme, { radius, padding, size }) => ({ - root: { - '--my-radius': getRadius(radius), - '--my-padding': getSpacing(padding), - }, - inner: { - '--my-fz': getFontSize(size), - }, -})); - -export const MyComponent = factory((_props) => { - const props = useProps('MyComponent', defaultProps, _props); - const { - classNames, className, style, styles, unstyled, vars, attributes, - radius, padding, size, - children, - ...others - } = props; - - const getStyles = useStyles({ - name: 'MyComponent', - classes, - props, - className, - style, - classNames, - styles, - unstyled, - vars, - attributes, - varsResolver, - }); - - return ( - -
{children}
-
- ); -}); - -MyComponent.displayName = '@mantine/core/MyComponent'; -MyComponent.classes = classes; -MyComponent.varsResolver = varsResolver; -``` - ---- - -## Compound component with context - -Pattern for components with typed sub-components (e.g. `Card.Section`, `Tabs.Tab`). - -**MyCard.context.ts:** -```ts -import { createSafeContext, GetStylesApi } from '@mantine/core'; -import type { MyCardFactory } from './MyCard'; - -interface MyCardContextValue { - getStyles: GetStylesApi; - orientation: 'horizontal' | 'vertical'; -} - -export const [MyCardProvider, useMyCardContext] = createSafeContext( - 'MyCard component was not found in tree' -); -``` - -**MyCardSection.tsx** (sub-component): -```tsx -import { - Box, BoxProps, CompoundStylesApiProps, ElementProps, - factory, Factory, useProps, useStyles, -} from '@mantine/core'; -import { useMyCardContext } from './MyCard.context'; -import classes from './MyCard.module.css'; - -export type MyCardSectionStylesNames = 'section'; - -export interface MyCardSectionProps - extends BoxProps, CompoundStylesApiProps, ElementProps<'div'> { - withBorder?: boolean; -} - -export type MyCardSectionFactory = Factory<{ - props: MyCardSectionProps; - ref: HTMLDivElement; - stylesNames: MyCardSectionStylesNames; - compound: true; // marks as a compound sub-component -}>; - -const defaultProps = {} satisfies Partial; - -export const MyCardSection = factory((_props) => { - const props = useProps('MyCardSection', defaultProps, _props); - const { className, style, classNames, styles, withBorder, children, ...others } = props; - - // Access styles from parent context - const { getStyles } = useMyCardContext(); - - return ( - - {children} - - ); -}); - -MyCardSection.displayName = '@mantine/core/MyCardSection'; -``` - -**MyCard.tsx** (root component): -```tsx -import { MyCardProvider } from './MyCard.context'; - -// ... (same Styles API setup as above) - -export type MyCardFactory = Factory<{ - props: MyCardProps; - ref: HTMLDivElement; - stylesNames: 'root' | 'section'; // include sub-component selectors too - staticComponents: { - Section: typeof MyCardSection; - }; -}>; - -export const MyCard = factory((_props) => { - const props = useProps('MyCard', defaultProps, _props); - const { - classNames, className, style, styles, unstyled, vars, attributes, - orientation, children, ...others - } = props; - - const getStyles = useStyles({ ... }); - - return ( - - {children} - - ); -}); - -MyCard.displayName = '@mantine/core/MyCard'; -MyCard.classes = classes; -MyCard.Section = MyCardSection; // attach sub-component -``` - ---- - -## Polymorphic component - -Supports `component` prop to render as any element or React component. - -```tsx -import { - Box, BoxProps, polymorphicFactory, PolymorphicFactory, - StylesApiProps, useProps, useStyles, -} from '@mantine/core'; - -export type MyLinkStylesNames = 'root'; - -export interface MyLinkProps extends BoxProps, StylesApiProps { - active?: boolean; -} - -export type MyLinkFactory = PolymorphicFactory<{ - props: MyLinkProps; - defaultRef: HTMLAnchorElement; - defaultComponent: 'a'; // renders as
unless component prop is provided - stylesNames: MyLinkStylesNames; -}>; - -const defaultProps = {} satisfies Partial; - -export const MyLink = polymorphicFactory((_props) => { - const props = useProps('MyLink', defaultProps, _props); - const { - classNames, className, style, styles, unstyled, vars, attributes, - active, ...others - } = props; - - const getStyles = useStyles({ - name: 'MyLink', classes, props, className, style, - classNames, styles, unstyled, vars, attributes, - }); - - return ( - - ); -}); - -MyLink.displayName = '@mantine/core/MyLink'; -MyLink.classes = classes; -``` - -**Usage:** -```tsx -Link -As button -Router link -``` - ---- - -## Generic component - -For components where prop types depend on a generic parameter. - -```tsx -import { factory, Factory, genericFactory, useProps } from '@mantine/core'; - -type SelectValue = M extends true ? string[] : string | null; - -export interface MySelectProps - extends BoxProps, StylesApiProps { - multiple?: M; - value?: SelectValue; - defaultValue?: SelectValue; - onChange?: (value: SelectValue) => void; -} - -export type MySelectFactory = Factory<{ - props: MySelectProps; - ref: HTMLDivElement; - signature: (props: MySelectProps) => React.JSX.Element; - stylesNames: 'root'; -}>; - -const defaultProps = { multiple: false } satisfies Partial; - -export const MySelect = genericFactory((_props) => { - const props = useProps('MySelect', defaultProps as any, _props); - const { multiple, value, onChange, ...others } = props; - // ... -}); - -MySelect.displayName = '@mantine/core/MySelect'; -``` - -**Usage:** -```tsx -// TypeScript infers value as string | null - setVal(v)} /> - -// TypeScript infers value as string[] - setVals(v)} /> -``` - ---- - -## Theme integration - -Components built with `factory()` automatically get `.extend()` and `.withProps()`. - -**`.extend()`** — for theme-level configuration in `createTheme`: -```tsx -const theme = createTheme({ - components: { - MyComponent: MyComponent.extend({ - // Override default props - defaultProps: { - radius: 'xl', - size: 'lg', - }, - // Add classes to selectors - classNames: { - root: 'my-root-class', - inner: 'my-inner-class', - }, - // Add inline styles to selectors - styles: { - root: { border: '1px solid red' }, - }, - // Or use a callback for theme-aware styles - styles: (theme) => ({ - root: { background: theme.colors.blue[0] }, - }), - // Override CSS variables - vars: (_theme, props) => ({ - root: { '--my-radius': props.radius ? getRadius(props.radius) : undefined }, - }), - }), - }, -}); -``` - -**`.withProps()`** — create a pre-configured variant at the call site: -```tsx -const BigMyComponent = MyComponent.withProps({ size: 'xl', radius: 'lg' }); - -// Same as MyComponent but with size and radius pre-set -Content -``` - ---- - -## Namespace exports - -Add at the bottom of the component file or `generate-palette.ts` to let consumers access types without extra imports. - -```tsx -export namespace MyComponent { - export type Props = MyComponentProps; - export type StylesNames = MyComponentStylesNames; - export type CssVariables = MyComponentCssVariables; - export type Factory = MyComponentFactory; - export type Variant = MyComponentVariant; - - export namespace Section { - export type Props = MyComponentSectionProps; - export type StylesNames = MyComponentSectionStylesNames; - export type Factory = MyComponentSectionFactory; - } -} -``` - -**Usage:** -```ts -import { MyComponent } from './MyComponent'; - -// No need to import MyComponentProps separately -const props: MyComponent.Props = { radius: 'md' }; -``` diff --git a/.claude/skills/tanstack-form/SKILL.md b/.claude/skills/tanstack-form/SKILL.md deleted file mode 100644 index 5017057..0000000 --- a/.claude/skills/tanstack-form/SKILL.md +++ /dev/null @@ -1,416 +0,0 @@ ---- -name: tanstack-form -description: Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte. ---- - - -## Overview - -TanStack Form is a headless form library with deep TypeScript integration. It provides field-level and form-level validation (sync/async), array fields, linked/dependent fields, fine-grained reactivity, and schema validation adapter support (Zod, Valibot, Yup). - -**Package:** `@tanstack/react-form` -**Adapters:** `@tanstack/zod-form-adapter`, `@tanstack/valibot-form-adapter` -**Status:** Stable (v1) - -## Installation - -```bash -npm install @tanstack/react-form -# Optional schema adapters: -npm install @tanstack/zod-form-adapter zod -npm install @tanstack/valibot-form-adapter valibot -``` - -## Core: useForm - -```tsx -import { useForm } from '@tanstack/react-form' - -function MyForm() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - email: '', - age: 0, - }, - onSubmit: async ({ value }) => { - // value is fully typed - await submitToServer(value) - }, - onSubmitInvalid: ({ value, formApi }) => { - console.log('Validation failed:', formApi.state.errors) - }, - }) - - return ( -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > - {/* Fields */} - ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} - children={({ canSubmit, isSubmitting }) => ( - - )} - /> - - ) -} -``` - -## Fields (form.Field) - -```tsx - - value.length < 3 ? 'Must be at least 3 characters' : undefined, - }} - children={(field) => ( -
- - field.handleChange(e.target.value)} - /> - {field.state.meta.isTouched && field.state.meta.errors.length > 0 && ( - {field.state.meta.errors.join(', ')} - )} -
- )} -/> - - - - {(field) => ( - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - /> - )} - -``` - -## Validation - -### Validation Timing - -| Cause | When | -|-------|------| -| `onChange` | After every value change | -| `onBlur` | When field loses focus | -| `onSubmit` | During submission | -| `onMount` | When field mounts | - -### Synchronous Validation - -```tsx - { - if (value < 18) return 'Must be 18 or older' - return undefined // undefined = valid - }, - onBlur: ({ value }) => { - if (!value) return 'Required' - return undefined - }, - }} -/> -``` - -### Asynchronous Validation - -```tsx - { - const res = await fetch(`/api/check-username?q=${value}`) - const { available } = await res.json() - if (!available) return 'Username taken' - return undefined - }, - }} -> - {(field) => ( - <> - field.handleChange(e.target.value)} /> - {field.state.meta.isValidating && Checking...} - - )} - -``` - -### Schema Validation (Zod) - -```tsx -import { zodValidator } from '@tanstack/zod-form-adapter' -import { z } from 'zod' - -const form = useForm({ - defaultValues: { email: '', age: 0 }, - validatorAdapter: zodValidator(), - onSubmit: async ({ value }) => { /* ... */ }, -}) - - - - -``` - -### Form-Level Validation - -```tsx -const form = useForm({ - defaultValues: { password: '', confirmPassword: '' }, - validators: { - onChange: ({ value }) => { - if (value.password !== value.confirmPassword) { - return 'Passwords do not match' - } - return undefined - }, - }, -}) -``` - -### Linked/Dependent Fields - -```tsx - { - const password = fieldApi.form.getFieldValue('password') - if (value !== password) return 'Passwords do not match' - return undefined - }, - }} -/> -``` - -## Array Fields - -```tsx - - {(field) => ( -
- {field.state.value.map((_, index) => ( -
- - {(subField) => ( - subField.handleChange(e.target.value)} - /> - )} - - -
- ))} - -
- )} -
-``` - -### Array Methods - -```typescript -field.pushValue(item) // Add to end -field.insertValue(index, item) // Insert at index -field.replaceValue(index, item) // Replace at index -field.removeValue(index) // Remove at index -field.swapValues(indexA, indexB) // Swap positions -field.moveValue(from, to) // Move position -``` - -## Listeners (Side Effects) - -```tsx - { - // Side effect: reset dependent fields - form.setFieldValue('state', '') - form.setFieldValue('postalCode', '') - }, - }} -/> -``` - -## Reactivity (form.Subscribe & useStore) - -```tsx -// Render-prop subscription (fine-grained) - ({ canSubmit: state.canSubmit, isDirty: state.isDirty })} - children={({ canSubmit, isDirty }) => ( -
- {isDirty && Unsaved changes} - -
- )} -/> - -// Hook-based subscription -function FormStatus() { - const isValid = form.useStore((s) => s.isValid) - return isValid ? null :

Fix errors

-} -``` - -## Form State - -```typescript -interface FormState { - values: TFormData - errors: ValidationError[] - errorMap: Record - isFormValid: boolean - isFieldsValid: boolean - isValid: boolean // isFormValid && isFieldsValid - isTouched: boolean - isPristine: boolean - isDirty: boolean - isSubmitting: boolean - isSubmitted: boolean - isSubmitSuccessful: boolean - submissionAttempts: number - canSubmit: boolean // isValid && !isSubmitting -} -``` - -## Field State - -```typescript -interface FieldState { - value: TData - meta: { - isTouched: boolean - isDirty: boolean - isPristine: boolean - isValidating: boolean - errors: ValidationError[] - errorMap: Record - } -} -``` - -## FormApi Methods - -```typescript -form.handleSubmit() -form.reset() -form.getFieldValue(field) -form.setFieldValue(field, value) -form.getFieldMeta(field) -form.setFieldMeta(field, updater) -form.validateAllFields(cause) -form.validateField(field, cause) -form.deleteField(field) -``` - -## Shared Form Options (formOptions) - -```tsx -import { formOptions } from '@tanstack/react-form' - -const sharedOpts = formOptions({ - defaultValues: { firstName: '', lastName: '' }, -}) - -// Reuse across core -const form = useForm({ - ...sharedOpts, - onSubmit: async ({ value }) => { /* ... */ }, -}) -``` - -## Server-Side Validation - -```tsx -// TanStack Start / Next.js server action -import { ServerValidateError } from '@tanstack/react-form/nextjs' - -export async function validateForm(data: FormData) { - const email = data.get('email') as string - if (await checkEmailExists(email)) { - throw new ServerValidateError({ - form: 'Submission failed', - fields: { email: 'Email already registered' }, - }) - } -} -``` - -## TypeScript Integration - -```tsx -// Type-safe field paths with DeepKeys -interface UserForm { - name: string - address: { street: string; city: string } - tags: string[] - contacts: Array<{ name: string; phone: string }> -} - -// TypeScript auto-completes all valid paths: -// 'name', 'address', 'address.street', 'address.city', 'tags', 'contacts' - // OK - // Type Error! -``` - -## Best Practices - -1. **Always call `e.preventDefault()` and `e.stopPropagation()`** on form submit -2. **Always attach `onBlur={field.handleBlur}`** for blur validation and isTouched tracking -3. **Use `mode="array"`** for array fields to get array methods -4. **Return `undefined`** (not null/false) for valid validators -5. **Use `asyncDebounceMs`** for async validators to prevent API spam -6. **Check `isTouched` before showing errors** for better UX -7. **Use `form.Subscribe` with selectors** to minimize re-renders -8. **Use `formOptions`** for shared configuration across components -9. **Use schema validators** (Zod/Valibot) for complex validation rules -10. **Use `onChangeListenTo`** for cross-field validation dependencies - -## Common Pitfalls - -- Forgetting `e.preventDefault()` on form submit (causes page reload) -- Not attaching `onBlur` to inputs (breaks blur validation and isTouched) -- Returning `null` or `false` instead of `undefined` for valid fields -- Using `mode="array"` incorrectly (only needed on the array field itself, not sub-fields) -- Subscribing to entire form state instead of using selectors (unnecessary re-renders) -- Not using `asyncDebounceMs` with async validators (fires on every keystroke) diff --git a/.claude/skills/tanstack-query/SKILL.md b/.claude/skills/tanstack-query/SKILL.md deleted file mode 100644 index 1538eb4..0000000 --- a/.claude/skills/tanstack-query/SKILL.md +++ /dev/null @@ -1,849 +0,0 @@ ---- -name: tanstack-query -description: Powerful asynchronous state management, server-state utilities, and data fetching for TS/JS, React, Vue, Solid, Svelte & Angular. ---- - - -## Overview - -TanStack Query (formerly React Query) manages server state - data that lives on the server and needs to be fetched, cached, synchronized, and updated. It provides automatic caching, background refetching, stale-while-revalidate patterns, pagination, infinite scrolling, and optimistic updates out of the box. - -**Package:** `@tanstack/react-query` -**Devtools:** `@tanstack/react-query-devtools` -**Current Version:** v5 - -## Installation - -```bash -npm install @tanstack/react-query -npm install -D @tanstack/react-query-devtools # Optional -``` - -## Setup - -```tsx -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60, // 1 minute - gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection) - retry: 3, - refetchOnWindowFocus: true, - refetchOnReconnect: true, - }, - }, -}) - -function App() { - return ( - - - - - ) -} -``` - -## Core Concepts - -### Query Keys - -Query keys uniquely identify cached data. They must be serializable arrays: - -```tsx -// Simple key -useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) - -// With variables (dependency array pattern) -useQuery({ queryKey: ['todos', { status, page }], queryFn: fetchTodos }) - -// Hierarchical keys for invalidation -useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) }) -useQuery({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) }) - -// Invalidation matches prefixes: -// queryClient.invalidateQueries({ queryKey: ['todos'] }) -// ^ Invalidates ALL queries starting with 'todos' -``` - -### Query Functions - -```tsx -// Query function receives a QueryFunctionContext -useQuery({ - queryKey: ['todos', todoId], - queryFn: async ({ queryKey, signal, meta }) => { - const [_key, id] = queryKey - const response = await fetch(`/api/todos/${id}`, { signal }) - if (!response.ok) throw new Error('Failed to fetch') - return response.json() - }, -}) - -// Using the signal for automatic cancellation -useQuery({ - queryKey: ['todos'], - queryFn: async ({ signal }) => { - const response = await fetch('/api/todos', { signal }) - return response.json() - }, -}) -``` - -### queryOptions Helper - -Create reusable, type-safe query configurations: - -```tsx -import { queryOptions } from '@tanstack/react-query' - -export const todosQueryOptions = queryOptions({ - queryKey: ['todos'], - queryFn: fetchTodos, - staleTime: 5000, -}) - -export const todoQueryOptions = (todoId: string) => - queryOptions({ - queryKey: ['todos', todoId], - queryFn: () => fetchTodo(todoId), - enabled: !!todoId, - }) - -// Usage -const { data } = useQuery(todosQueryOptions) -const { data } = useSuspenseQuery(todoQueryOptions(id)) -await queryClient.prefetchQuery(todosQueryOptions) -``` - -## Queries (useQuery) - -### Basic Usage - -```tsx -import { useQuery } from '@tanstack/react-query' - -function Todos() { - const { - data, - error, - isLoading, // First load, no data yet - isFetching, // Any fetch in progress (including background) - isError, - isSuccess, - isPending, // No data yet (same as isLoading in most cases) - status, // 'pending' | 'error' | 'success' - fetchStatus, // 'fetching' | 'paused' | 'idle' - refetch, - isStale, - isPlaceholderData, - dataUpdatedAt, - errorUpdatedAt, - } = useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - - if (isLoading) return - if (isError) return - return -} -``` - -### Query Options - -```tsx -useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - - // Freshness - staleTime: 5000, // ms data stays fresh (default: 0) - gcTime: 300000, // ms unused data stays in cache (default: 5 min) - - // Refetching - refetchInterval: 10000, // Poll every 10s - refetchIntervalInBackground: false, // Don't poll when tab hidden - refetchOnMount: true, // Refetch on component mount if stale - refetchOnWindowFocus: true, // Refetch on window focus if stale - refetchOnReconnect: true, // Refetch on network reconnect - - // Retry - retry: 3, // Number of retries (or function) - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), - - // Conditional - enabled: !!userId, // Only run when truthy - - // Initial/placeholder data - initialData: () => cachedData, - initialDataUpdatedAt: Date.now() - 10000, - placeholderData: (previousData) => previousData, // keepPreviousData pattern - placeholderData: initialTodos, - - // Transform - select: (data) => data.filter(todo => !todo.done), - - // Structural sharing (default: true) - structuralSharing: true, - - // Network mode - networkMode: 'online', // 'online' | 'always' | 'offlineFirst' - - // Meta (accessible in query function context) - meta: { purpose: 'user-facing' }, -}) -``` - -## Mutations (useMutation) - -### Basic Usage - -```tsx -import { useMutation, useQueryClient } from '@tanstack/react-query' - -function AddTodo() { - const queryClient = useQueryClient() - - const mutation = useMutation({ - mutationFn: (newTodo: { title: string }) => { - return fetch('/api/todos', { - method: 'POST', - body: JSON.stringify(newTodo), - }).then(res => res.json()) - }, - // Lifecycle callbacks - onMutate: async (variables) => { - // Called before mutationFn - // Good for optimistic updates - return { previousTodos } // context for onError - }, - onSuccess: (data, variables, context) => { - // Invalidate related queries - queryClient.invalidateQueries({ queryKey: ['todos'] }) - }, - onError: (error, variables, context) => { - // Rollback optimistic updates - queryClient.setQueryData(['todos'], context.previousTodos) - }, - onSettled: (data, error, variables, context) => { - // Always runs (success or error) - queryClient.invalidateQueries({ queryKey: ['todos'] }) - }, - }) - - return ( - - ) -} -``` - -### Mutation State - -```tsx -const { - mutate, // Fire-and-forget - mutateAsync, // Returns promise - isPending, // Mutation in progress - isError, - isSuccess, - isIdle, // Not yet fired - data, // Success response - error, // Error object - reset, // Reset state to idle - variables, // Variables passed to mutate - status, // 'idle' | 'pending' | 'error' | 'success' -} = useMutation({ ... }) -``` - -## Optimistic Updates - -```tsx -const mutation = useMutation({ - mutationFn: updateTodo, - onMutate: async (newTodo) => { - // 1. Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] }) - - // 2. Snapshot previous value - const previousTodo = queryClient.getQueryData(['todos', newTodo.id]) - - // 3. Optimistically update - queryClient.setQueryData(['todos', newTodo.id], newTodo) - - // 4. Return context for rollback - return { previousTodo } - }, - onError: (err, newTodo, context) => { - // Rollback on error - queryClient.setQueryData(['todos', newTodo.id], context.previousTodo) - }, - onSettled: () => { - // Always refetch to sync with server - queryClient.invalidateQueries({ queryKey: ['todos'] }) - }, -}) -``` - -### Optimistic Updates on Lists - -```tsx -onMutate: async (newTodo) => { - await queryClient.cancelQueries({ queryKey: ['todos'] }) - const previousTodos = queryClient.getQueryData(['todos']) - - queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) - - return { previousTodos } -}, -onError: (err, newTodo, context) => { - queryClient.setQueryData(['todos'], context.previousTodos) -}, -``` - -## Query Invalidation - -```tsx -const queryClient = useQueryClient() - -// Invalidate all queries -queryClient.invalidateQueries() - -// Invalidate by prefix -queryClient.invalidateQueries({ queryKey: ['todos'] }) - -// Invalidate exact match -queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true }) - -// Invalidate with predicate -queryClient.invalidateQueries({ - predicate: (query) => - query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done', -}) - -// Invalidate and refetch immediately -queryClient.refetchQueries({ queryKey: ['todos'] }) - -// Remove from cache entirely -queryClient.removeQueries({ queryKey: ['todos', 1] }) - -// Reset to initial state -queryClient.resetQueries({ queryKey: ['todos'] }) -``` - -## Infinite Queries - -```tsx -import { useInfiniteQuery } from '@tanstack/react-query' - -function InfiniteList() { - const { - data, - fetchNextPage, - fetchPreviousPage, - hasNextPage, - hasPreviousPage, - isFetchingNextPage, - isFetchingPreviousPage, - } = useInfiniteQuery({ - queryKey: ['projects'], - queryFn: async ({ pageParam }) => { - const res = await fetch(`/api/projects?cursor=${pageParam}`) - return res.json() - }, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages, lastPageParam) => { - return lastPage.nextCursor ?? undefined // undefined = no more pages - }, - getPreviousPageParam: (firstPage, allPages, firstPageParam) => { - return firstPage.prevCursor ?? undefined - }, - maxPages: 3, // Keep max 3 pages in cache (for performance) - }) - - return ( -
- {data.pages.map((page) => - page.items.map((item) => ) - )} - -
- ) -} -``` - -## Parallel Queries - -```tsx -// Multiple independent queries run in parallel automatically -function Dashboard() { - const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) - const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }) - - // Both fetch simultaneously -} - -// Dynamic parallel queries with useQueries -function UserProjects({ userIds }) { - const queries = useQueries({ - queries: userIds.map((id) => ({ - queryKey: ['user', id], - queryFn: () => fetchUser(id), - })), - combine: (results) => ({ - data: results.map(r => r.data), - pending: results.some(r => r.isPending), - }), - }) -} -``` - -## Dependent Queries - -```tsx -// Sequential queries using enabled -function UserPosts({ userId }) { - const userQuery = useQuery({ - queryKey: ['user', userId], - queryFn: () => fetchUser(userId), - }) - - const postsQuery = useQuery({ - queryKey: ['posts', userId], - queryFn: () => fetchPostsByUser(userId), - enabled: !!userQuery.data, // Only run when user is loaded - }) -} -``` - -## Paginated Queries - -```tsx -function PaginatedList() { - const [page, setPage] = useState(1) - - const { data, isPlaceholderData } = useQuery({ - queryKey: ['todos', page], - queryFn: () => fetchTodos(page), - placeholderData: (previousData) => previousData, // Keep showing old data - }) - - return ( -
- {data.items.map(item => )} - -
- ) -} -``` - -## Suspense Integration - -```tsx -import { useSuspenseQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query' - -// Component will suspend until data is loaded -function TodoList() { - const { data } = useSuspenseQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - // data is guaranteed to be defined here - return
    {data.map(todo =>
  • {todo.title}
  • )}
-} - -// Wrap with Suspense boundary -function App() { - return ( - }> - }> - - - - ) -} - -// Multiple suspense queries (fetch in parallel) -function Dashboard() { - const [{ data: users }, { data: projects }] = useSuspenseQueries({ - queries: [ - { queryKey: ['users'], queryFn: fetchUsers }, - { queryKey: ['projects'], queryFn: fetchProjects }, - ], - }) -} -``` - -## Prefetching - -```tsx -const queryClient = useQueryClient() - -// Prefetch on hover -function TodoLink({ todoId }) { - const prefetch = () => { - queryClient.prefetchQuery({ - queryKey: ['todo', todoId], - queryFn: () => fetchTodo(todoId), - staleTime: 5000, // Only prefetch if data older than 5s - }) - } - - return ( - - Todo {todoId} - - ) -} - -// Prefetch in route loader (TanStack Router integration) -export const Route = createFileRoute('/todos/$todoId')({ - loader: ({ context: { queryClient }, params: { todoId } }) => - queryClient.ensureQueryData(todoQueryOptions(todoId)), -}) - -// Prefetch infinite queries -queryClient.prefetchInfiniteQuery({ - queryKey: ['projects'], - queryFn: fetchProjects, - initialPageParam: 0, - pages: 3, // Prefetch first 3 pages -}) -``` - -## SSR & Hydration - -### Server-Side Prefetching - -```tsx -// Server component or loader -import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' - -async function getServerSideProps() { - const queryClient = new QueryClient() - - await queryClient.prefetchQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - - return { - props: { - dehydratedState: dehydrate(queryClient), - }, - } -} - -function Page({ dehydratedState }) { - return ( - - - - ) -} -``` - -### Streaming SSR (React Server Components) - -```tsx -import { dehydrate, HydrationBoundary } from '@tanstack/react-query' -import { makeQueryClient } from './query-client' - -export default async function Page() { - const queryClient = makeQueryClient() - - // Prefetch on server - await queryClient.prefetchQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) - - return ( - - - - ) -} -``` - -## QueryClient API - -```tsx -const queryClient = useQueryClient() - -// Get cached data -queryClient.getQueryData(['todos']) - -// Set cached data -queryClient.setQueryData(['todos'], updatedTodos) -queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) - -// Get query state -queryClient.getQueryState(['todos']) - -// Check if fetching -queryClient.isFetching({ queryKey: ['todos'] }) -queryClient.isMutating() - -// Cancel queries -queryClient.cancelQueries({ queryKey: ['todos'] }) - -// Invalidate (marks stale, refetches active) -queryClient.invalidateQueries({ queryKey: ['todos'] }) - -// Refetch (force refetch even if fresh) -queryClient.refetchQueries({ queryKey: ['todos'] }) - -// Remove from cache -queryClient.removeQueries({ queryKey: ['todos'] }) - -// Reset to initial state -queryClient.resetQueries({ queryKey: ['todos'] }) - -// Clear entire cache -queryClient.clear() - -// Prefetch -queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos }) -queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos }) - -// Get/set defaults -queryClient.setQueryDefaults(['todos'], { staleTime: 10000 }) -queryClient.getQueryDefaults(['todos']) -queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo }) -``` - -## Testing - -```tsx -import { renderHook, waitFor } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, // Don't retry in tests - gcTime: Infinity, // Prevent garbage collection during tests - }, - }, - }) - return ({ children }) => ( - - {children} - - ) -} - -test('fetches todos', async () => { - const { result } = renderHook(() => useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }), { wrapper: createWrapper() }) - - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(expectedTodos) -}) - -// Mock with setQueryData for component tests -test('renders todos', () => { - const queryClient = new QueryClient() - queryClient.setQueryData(['todos'], mockTodos) - - render( - - - - ) - - expect(screen.getByText('Todo 1')).toBeInTheDocument() -}) -``` - -## TypeScript Patterns - -### Typing Query Functions - -```tsx -interface Todo { - id: number - title: string - completed: boolean -} - -// Type is inferred from queryFn return type -const { data } = useQuery({ - queryKey: ['todos'], - queryFn: async (): Promise => { - const res = await fetch('/api/todos') - return res.json() - }, -}) -// data: Todo[] | undefined - -// With select -const { data } = useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - select: (data): string[] => data.map(t => t.title), -}) -// data: string[] | undefined -``` - -### Typing Errors - -```tsx -// Default error type is Error -const { error } = useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, -}) - -// Or register globally -declare module '@tanstack/react-query' { - interface Register { - defaultError: AxiosError - } -} -``` - -### Query Options Pattern (Recommended) - -```tsx -import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query' - -export const todosOptions = queryOptions({ - queryKey: ['todos'] as const, - queryFn: fetchTodos, - staleTime: 5000, -}) - -export const todoOptions = (id: string) => - queryOptions({ - queryKey: ['todos', id] as const, - queryFn: () => fetchTodo(id), - enabled: !!id, - }) - -// Full type inference everywhere -const { data } = useQuery(todosOptions) -const { data } = useSuspenseQuery(todoOptions('123')) -await queryClient.ensureQueryData(todosOptions) -queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) -``` - -## Advanced Patterns - -### Window Focus Refetching - -```tsx -// Disable globally -const queryClient = new QueryClient({ - defaultOptions: { - queries: { refetchOnWindowFocus: false }, - }, -}) - -// Custom focus manager -import { focusManager } from '@tanstack/react-query' - -// For React Native -focusManager.setEventListener((handleFocus) => { - const subscription = AppState.addEventListener('change', (state) => { - handleFocus(state === 'active') - }) - return () => subscription.remove() -}) -``` - -### Network Mode - -```tsx -useQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - // 'online' (default): only fetch when online - // 'always': always fetch (useful for local-first) - // 'offlineFirst': try fetch, use cache if offline - networkMode: 'offlineFirst', -}) -``` - -### Query Cancellation - -```tsx -useQuery({ - queryKey: ['todos'], - queryFn: async ({ signal }) => { - // signal is AbortSignal - automatically cancelled on unmount or key change - const res = await fetch('/api/todos', { signal }) - return res.json() - }, -}) - -// Manual cancellation -queryClient.cancelQueries({ queryKey: ['todos'] }) -``` - -### Persistence - -```tsx -import { persistQueryClient } from '@tanstack/react-query-persist-client' -import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' - -const persister = createSyncStoragePersister({ - storage: window.localStorage, -}) - -persistQueryClient({ - queryClient, - persister, - maxAge: 1000 * 60 * 60 * 24, // 24 hours -}) -``` - -## Best Practices - -1. **Use `queryOptions` helper** for type-safe, reusable query configurations -2. **Structure query keys hierarchically** for granular invalidation -3. **Set appropriate `staleTime`** - 0 means always refetch on mount (default), increase for less dynamic data -4. **Use `placeholderData`** (not `initialData`) for keeping previous page data during pagination -5. **Prefer `useSuspenseQuery`** when using Suspense boundaries for cleaner component code -6. **Use `enabled`** for dependent queries, not conditional hook calls -7. **Always invalidate after mutations** - don't rely solely on optimistic updates -8. **Cancel queries in `onMutate`** before optimistic updates to prevent race conditions -9. **Use `ensureQueryData`** in route loaders instead of `prefetchQuery` for immediate access -10. **Set `retry: false` in tests** to avoid timeout issues -11. **Don't destructure the query result** if you need to pass it around (breaks reactivity) -12. **Use `select`** for derived data instead of transforming in the component -13. **Keep query functions pure** - they should only fetch, not cause side effects -14. **Use `gcTime: Infinity`** in tests to prevent cache cleanup during assertions - -## Common Pitfalls - -- Using `initialData` when you mean `placeholderData` (initialData counts as "fresh" data) -- Not providing `initialPageParam` for infinite queries (required in v5) -- Calling hooks conditionally (violates React rules) -- Not cancelling queries before optimistic updates (race conditions) -- Setting `staleTime` higher than `gcTime` (data gets garbage collected while "fresh") -- Forgetting to wrap tests with `QueryClientProvider` -- Using same `QueryClient` instance across tests (shared state) -- Not awaiting `invalidateQueries` in mutation callbacks when order matters diff --git a/.claude/skills/tanstack-router/SKILL.md b/.claude/skills/tanstack-router/SKILL.md deleted file mode 100644 index a4b817c..0000000 --- a/.claude/skills/tanstack-router/SKILL.md +++ /dev/null @@ -1,734 +0,0 @@ ---- -name: tanstack-router -description: Type-safe routing for React and Solid applications with first-class search params, data loading, and seamless integration with the React ecosystem. ---- - - -## Overview - -TanStack Router is a fully type-safe router for React (and Solid) applications. It provides file-based routing, first-class search parameter management, built-in data loading, code splitting, and deep TypeScript integration. It serves as the routing foundation for TanStack Start (the full-stack framework). - -**Package:** `@tanstack/react-router` -**CLI:** `@tanstack/router-cli` or `@tanstack/router-plugin` (Vite/Rspack/Webpack) -**Devtools:** `@tanstack/react-router-devtools` - -## Installation - -```bash -npm install @tanstack/react-router -# For file-based routing with Vite: -npm install -D @tanstack/router-plugin -# Or standalone CLI: -npm install -D @tanstack/router-cli -``` - -## Core Concepts - -### Route Trees - -Routes are organized in a tree structure. The root route is the top-level layout, and child routes nest underneath. - -```tsx -import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router' - -const rootRoute = createRootRoute({ - component: RootLayout, -}) - -const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: HomePage, -}) - -const aboutRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/about', - component: AboutPage, -}) - -const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) -const router = createRouter({ routeTree }) -``` - -### File-Based Routing - -File-based routing automatically generates the route tree from your file structure. Configure with Vite plugin: - -```ts -// vite.config.ts -import { defineConfig } from 'vite' -import { TanStackRouterVite } from '@tanstack/router-plugin/vite' - -export default defineConfig({ - plugins: [ - TanStackRouterVite(), - // ... other plugins - ], -}) -``` - -#### File Naming Conventions - -| File Pattern | Route Type | Example Path | -|---|---|---| -| `__root.tsx` | Root layout | N/A (wraps all) | -| `index.tsx` | Index route | `/` | -| `about.tsx` | Static route | `/about` | -| `$postId.tsx` | Dynamic param | `/posts/$postId` | -| `posts.tsx` | Layout route | `/posts/*` (layout) | -| `posts/index.tsx` | Nested index | `/posts` | -| `posts/$postId.tsx` | Nested dynamic | `/posts/123` | -| `posts_.$postId.tsx` | Pathless layout | `/posts/123` (different layout) | -| `_layout.tsx` | Pathless layout | N/A (groups routes) | -| `_layout/dashboard.tsx` | Grouped route | `/dashboard` | -| `$.tsx` | Splat/catch-all | `/*` | -| `posts.$postId.edit.tsx` | Dot notation | `/posts/123/edit` | - -#### Special Prefixes -- `_` prefix: Pathless routes (layout groups without URL segment) -- `$` prefix: Dynamic path parameters -- `(folder)` parentheses: Route groups (organizational, no URL impact) - -### Route Configuration - -Each route can define: - -```tsx -// routes/posts.$postId.tsx -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/posts/$postId')({ - // Validation for path params - params: { - parse: (params) => ({ postId: Number(params.postId) }), - stringify: (params) => ({ postId: String(params.postId) }), - }, - - // Search params validation - validateSearch: (search: Record) => { - return { - page: Number(search.page ?? 1), - filter: (search.filter as string) || '', - } - }, - - // Data loading - loader: async ({ params, context, abortController }) => { - return fetchPost(params.postId) - }, - - // Loader dependencies (re-run loader when these change) - loaderDeps: ({ search }) => ({ page: search.page }), - - // Stale time for cached loader data - staleTime: 5_000, - - // Preloading - preloadStaleTime: 30_000, - - // Error component - errorComponent: PostErrorComponent, - - // Pending/loading component - pendingComponent: PostLoadingComponent, - - // 404 component - notFoundComponent: PostNotFoundComponent, - - // Before load hook (authentication, redirects) - beforeLoad: async ({ context, location }) => { - if (!context.auth.isAuthenticated) { - throw redirect({ - to: '/login', - search: { redirect: location.href }, - }) - } - }, - - // Head/meta management - head: () => ({ - meta: [{ title: 'Post Details' }], - }), - - // Component - component: PostComponent, -}) - -function PostComponent() { - const { postId } = Route.useParams() - const post = Route.useLoaderData() - const { page, filter } = Route.useSearch() - - return
{post.title}
-} -``` - -## Data Loading - -### Route Loaders - -```tsx -export const Route = createFileRoute('/posts')({ - loader: async ({ context }) => { - // Access router context (e.g., queryClient) - const posts = await context.queryClient.ensureQueryData({ - queryKey: ['posts'], - queryFn: fetchPosts, - }) - return { posts } - }, - component: PostsComponent, -}) - -function PostsComponent() { - const { posts } = Route.useLoaderData() - // ... -} -``` - -### Loader Dependencies - -Control when loaders re-execute: - -```tsx -export const Route = createFileRoute('/posts')({ - loaderDeps: ({ search: { page, filter } }) => ({ page, filter }), - loader: async ({ deps: { page, filter } }) => { - return fetchPosts({ page, filter }) - }, -}) -``` - -### Deferred Data Loading - -Stream non-critical data: - -```tsx -import { Await, defer } from '@tanstack/react-router' - -export const Route = createFileRoute('/dashboard')({ - loader: async () => { - const criticalData = await fetchCriticalData() - const deferredData = defer(fetchSlowData()) - return { criticalData, deferredData } - }, - component: DashboardComponent, -}) - -function DashboardComponent() { - const { criticalData, deferredData } = Route.useLoaderData() - - return ( -
- - }> - - {(data) => } - - -
- ) -} -``` - -### Context-Based Data Loading - -Provide shared dependencies via router context: - -```tsx -// Create router with context -const router = createRouter({ - routeTree, - context: { - queryClient, - auth: undefined!, // Will be provided by RouterProvider - }, -}) - -// In root/app component -function App() { - const auth = useAuth() - return -} - -// In routes -export const Route = createFileRoute('/protected')({ - beforeLoad: ({ context }) => { - if (!context.auth.user) throw redirect({ to: '/login' }) - }, - loader: ({ context }) => { - return context.queryClient.ensureQueryData(userQueryOptions()) - }, -}) -``` - -## Search Parameters - -### Validation - -```tsx -import { z } from 'zod' - -const postSearchSchema = z.object({ - page: z.number().default(1), - filter: z.string().default(''), - sort: z.enum(['date', 'title']).default('date'), -}) - -export const Route = createFileRoute('/posts')({ - validateSearch: postSearchSchema, - // Or manual validation: - // validateSearch: (search) => postSearchSchema.parse(search), -}) -``` - -### Reading Search Params - -```tsx -function PostsComponent() { - // From route - const { page, filter, sort } = Route.useSearch() - - // Or from any component with useSearch hook - const search = useSearch({ from: '/posts' }) -} -``` - -### Updating Search Params - -```tsx -import { useNavigate } from '@tanstack/react-router' - -function Pagination() { - const navigate = useNavigate() - const { page } = Route.useSearch() - - return ( - - ) -} - -// Or via Link component - ({ ...prev, page: 2 })} -> - Page 2 - -``` - -### Search Param Options - -```tsx -const router = createRouter({ - routeTree, - // Custom serialization - search: { - strict: true, // Reject unknown params - }, - // Default search param serializer - stringifySearch: defaultStringifySearch, - parseSearch: defaultParseSearch, -}) -``` - -## Navigation - -### Link Component - -```tsx -import { Link } from '@tanstack/react-router' - -// Static route -About - -// Dynamic route with params - - Post 123 - - -// With search params - - Page 2 - - -// Active link styling - - Posts - - -// Preloading -Posts -Dashboard - -// Hash -API Reference -``` - -### Programmatic Navigation - -```tsx -import { useNavigate, useRouter } from '@tanstack/react-router' - -function MyComponent() { - const navigate = useNavigate() - const router = useRouter() - - // Navigate to a route - navigate({ to: '/posts', search: { page: 1 } }) - - // Navigate with replace - navigate({ to: '/posts', replace: true }) - - // Relative navigation - navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) }) - - // Go back/forward - router.history.back() - router.history.forward() - - // Invalidate and reload current route - router.invalidate() -} -``` - -### Redirects - -```tsx -import { redirect } from '@tanstack/react-router' - -// In beforeLoad or loader -throw redirect({ - to: '/login', - search: { redirect: location.href }, - // Optional status code - statusCode: 301, // Permanent redirect (SSR) -}) -``` - -### Navigation Blocking - -```tsx -import { useBlocker } from '@tanstack/react-router' - -function FormComponent() { - const [isDirty, setIsDirty] = useState(false) - - useBlocker({ - shouldBlockFn: () => isDirty, - withResolver: true, // Shows confirm dialog - }) - - // Or with custom UI - const { proceed, reset, status } = useBlocker({ - shouldBlockFn: () => isDirty, - }) - - if (status === 'blocked') { - return ( -
-

Are you sure you want to leave?

- - -
- ) - } -} -``` - -## Code Splitting - -### Automatic (File-Based Routing) - -With file-based routing, create a lazy file: - -``` -routes/ - posts.tsx # Critical: loader, beforeLoad, meta - posts.lazy.tsx # Lazy: component, pendingComponent, errorComponent -``` - -```tsx -// posts.tsx (loaded eagerly) -export const Route = createFileRoute('/posts')({ - loader: () => fetchPosts(), -}) - -// posts.lazy.tsx (loaded lazily) -import { createLazyFileRoute } from '@tanstack/react-router' - -export const Route = createLazyFileRoute('/posts')({ - component: PostsComponent, - pendingComponent: PostsLoading, - errorComponent: PostsError, -}) -``` - -### Manual Code Splitting - -```tsx -const postsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/posts', - loader: () => fetchPosts(), -}).lazy(() => import('./posts.lazy').then((d) => d.Route)) -``` - -## Preloading - -```tsx -// Router-level defaults -const router = createRouter({ - routeTree, - defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false - defaultPreloadStaleTime: 30_000, // 30 seconds -}) - -// Route-level -export const Route = createFileRoute('/posts/$postId')({ - // Stale time for the loader data - staleTime: 5_000, - // How long preloaded data stays fresh - preloadStaleTime: 30_000, -}) - -// Link-level - - Posts - -``` - -## Type Safety - -### Register Router Type - -```tsx -// Declare module for type inference -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} -``` - -### Type-Safe Hooks - -All hooks are fully typed based on the route tree: - -```tsx -// useParams - typed to route's params -const { postId } = useParams({ from: '/posts/$postId' }) - -// useSearch - typed to route's search schema -const { page } = useSearch({ from: '/posts' }) - -// useLoaderData - typed to loader return -const data = useLoaderData({ from: '/posts/$postId' }) - -// useRouteContext - typed to route context -const { auth } = useRouteContext({ from: '/protected' }) -``` - -### Route Generics - -```tsx -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/posts/$postId')({ - // TypeScript infers: - // params: { postId: string } - // search: validated search schema type - // loaderData: return type of loader - // context: router context type -}) -``` - -## Authenticated Routes - -```tsx -// __root.tsx -export const Route = createRootRouteWithContext<{ - auth: AuthContext -}>()({ - component: RootComponent, -}) - -// _authenticated.tsx (pathless layout for auth) -export const Route = createFileRoute('/_authenticated')({ - beforeLoad: ({ context, location }) => { - if (!context.auth.isAuthenticated) { - throw redirect({ - to: '/login', - search: { redirect: location.href }, - }) - } - }, -}) - -// _authenticated/dashboard.tsx -export const Route = createFileRoute('/_authenticated/dashboard')({ - component: Dashboard, // Only accessible when authenticated -}) -``` - -## Scroll Restoration - -```tsx -const router = createRouter({ - routeTree, - // Enable scroll restoration - defaultScrollRestoration: true, -}) - -// Or per-route -export const Route = createFileRoute('/posts')({ - // Scroll to top on navigation - scrollRestoration: true, -}) - -// Custom scroll restoration key - location.pathname} -/> -``` - -## Route Masking - -Display a different URL than the actual route: - -```tsx - - View Photo - - -// Or programmatically -navigate({ - to: '/photos/$photoId', - params: { photoId: photo.id }, - mask: { to: '/photos', search: { photoId: photo.id } }, -}) -``` - -## Not Found Handling - -```tsx -// Global 404 -const router = createRouter({ - routeTree, - defaultNotFoundComponent: () =>
Page not found
, -}) - -// Route-level 404 -export const Route = createFileRoute('/posts/$postId')({ - loader: async ({ params }) => { - const post = await fetchPost(params.postId) - if (!post) throw notFound() - return post - }, - notFoundComponent: () =>
Post not found
, -}) -``` - -## Head Management - -```tsx -export const Route = createFileRoute('/posts/$postId')({ - head: ({ loaderData }) => ({ - meta: [ - { title: loaderData.title }, - { name: 'description', content: loaderData.excerpt }, - { property: 'og:title', content: loaderData.title }, - ], - links: [ - { rel: 'canonical', href: `https://example.com/posts/${loaderData.id}` }, - ], - }), -}) -``` - -## Integration with TanStack Query - -```tsx -import { queryOptions } from '@tanstack/react-query' - -const postsQueryOptions = queryOptions({ - queryKey: ['posts'], - queryFn: fetchPosts, -}) - -export const Route = createFileRoute('/posts')({ - loader: ({ context: { queryClient } }) => { - // Ensure data is in cache, won't refetch if fresh - return queryClient.ensureQueryData(postsQueryOptions) - }, - component: PostsComponent, -}) - -function PostsComponent() { - // Use the same query options for reactive updates - const { data: posts } = useSuspenseQuery(postsQueryOptions) - return -} -``` - -## Router Hooks Reference - -| Hook | Purpose | -|------|---------| -| `useRouter()` | Access router instance | -| `useRouterState()` | Subscribe to router state | -| `useParams()` | Get route path params | -| `useSearch()` | Get validated search params | -| `useLoaderData()` | Get route loader data | -| `useRouteContext()` | Get route context | -| `useNavigate()` | Get navigate function | -| `useLocation()` | Get current location | -| `useMatches()` | Get all matched routes | -| `useMatch()` | Get specific route match | -| `useBlocker()` | Block navigation | -| `useLinkProps()` | Get link props for custom components | -| `useMatchRoute()` | Check if a route matches | - -## Best Practices - -1. **Use file-based routing** for most applications - it's simpler and auto-generates the route tree -2. **Validate search params** with Zod or custom validators for type safety -3. **Use `loaderDeps`** to control when loaders re-execute based on search param changes -4. **Leverage context** for dependency injection (QueryClient, auth state) -5. **Use `beforeLoad`** for authentication guards, not in components -6. **Separate critical vs lazy code** - keep loaders in the main file, components in `.lazy.tsx` -7. **Use `preload="intent"`** on Links for perceived performance -8. **Use `staleTime`** to prevent unnecessary refetches during navigation -9. **Register the router type** for full TypeScript inference across the app -10. **Use `notFound()`** instead of conditional rendering for 404 states -11. **Colocate search param logic** with routes that own them -12. **Use pathless layouts** (`_authenticated`) for shared auth/layout logic without URL segments - -## Common Pitfalls - -- Forgetting to register the router type (`declare module`) -- Not using `loaderDeps` when loader depends on search params (causes stale data) -- Putting auth checks in components instead of `beforeLoad` (flash of protected content) -- Not handling the loading state with `pendingComponent` -- Using `useEffect` for data fetching instead of route loaders -- Mutating search params directly instead of using navigate/Link -- Not wrapping the app with `RouterProvider` -- Forgetting `getParentRoute` in code-based route definitions diff --git a/.claude/skills/tanstack-start/SKILL.md b/.claude/skills/tanstack-start/SKILL.md deleted file mode 100644 index 2d34fab..0000000 --- a/.claude/skills/tanstack-start/SKILL.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -name: tanstack-start -description: Full-stack React framework powered by TanStack Router with SSR, streaming, server functions, and deployment to any hosting provider. ---- - -# TanStack Start Skills - -## Overview - -TanStack Start is a full-stack React framework built on TanStack Router, powered by Vite and Nitro (via Vinxi). It provides server-side rendering, streaming, server functions (RPC), middleware, API routes, and deploys to any platform via Nitro presets. - -**Package:** `@tanstack/react-start` -**Router Plugin:** `@tanstack/router-plugin` -**Build Tool:** Vinxi (Vite + Nitro) -**Status:** RC (Release Candidate) -**RSC Support:** React Server Components support is in active development and will land as a non-breaking v1.x addition - -## Installation & Project Setup - -```bash -npx @tanstack/cli create my-app -# Or manually: -npm install @tanstack/react-start @tanstack/react-router react react-dom -npm install -D @tanstack/router-plugin typescript vite vite-tsconfig-paths -``` - -### Project Structure - -``` -my-app/ - app/ - routes/ - __root.tsx # Root layout - index.tsx # / route - posts.$postId.tsx # /posts/:postId - api/ - users.ts # /api/users API route - client.tsx # Client entry - router.tsx # Router creation - ssr.tsx # SSR entry - routeTree.gen.ts # Auto-generated route tree - app.config.ts # TanStack Start config - tsconfig.json - package.json -``` - -### Configuration (`app.config.ts`) - -```typescript -import { defineConfig } from '@tanstack/react-start/config' -import viteTsConfigPaths from 'vite-tsconfig-paths' - -export default defineConfig({ - vite: { - plugins: [ - viteTsConfigPaths({ projects: ['./tsconfig.json'] }), - ], - }, - server: { - preset: 'node-server', // 'vercel' | 'netlify' | 'cloudflare-pages' | etc. - }, - tsr: { - appDirectory: './app', - routesDirectory: './app/routes', - generatedRouteTree: './app/routeTree.gen.ts', - }, -}) -``` - -## Server Functions (`createServerFn`) - -Server functions provide type-safe RPC calls between client and server. - -### Basic Server Functions - -```typescript -import { createServerFn } from '@tanstack/react-start' - -// GET (data fetching, cacheable) -const getUsers = createServerFn() - .handler(async () => { - const users = await db.query.users.findMany() - return users - }) - -// POST (mutations, side effects) -const createUser = createServerFn({ method: 'POST' }) - .validator((data: { name: string; email: string }) => data) - .handler(async ({ data }) => { - const user = await db.insert(users).values(data).returning() - return user - }) -``` - -### With Zod Validation - -```typescript -import { z } from 'zod' - -const updateUser = createServerFn({ method: 'POST' }) - .validator( - z.object({ - id: z.string(), - name: z.string().min(1), - email: z.string().email(), - }) - ) - .handler(async ({ data }) => { - // data is fully typed: { id: string; name: string; email: string } - return await db.update(users).set(data).where(eq(users.id, data.id)) - }) -``` - -## Middleware - -### Creating Middleware - -```typescript -import { createMiddleware } from '@tanstack/react-start' - -const loggingMiddleware = createMiddleware().handler(async ({ next }) => { - console.log('Request started') - const result = await next() - console.log('Request completed') - return result -}) -``` - -### Auth Middleware with Context - -```typescript -const authMiddleware = createMiddleware().handler(async ({ next }) => { - const request = getWebRequest() - const session = await getSession(request) - - if (!session?.user) { - throw redirect({ to: '/login' }) - } - - // Pass typed context to handler - return next({ context: { user: session.user } }) -}) -``` - -### Chaining Middleware - -```typescript -const adminMiddleware = createMiddleware() - .middleware([authMiddleware]) - .handler(async ({ next, context }) => { - // context.user is typed from authMiddleware - if (context.user.role !== 'admin') { - throw redirect({ to: '/unauthorized' }) - } - return next({ context: { isAdmin: true } }) - }) - -// Usage -const adminAction = createServerFn({ method: 'POST' }) - .middleware([adminMiddleware]) - .handler(async ({ context }) => { - // context: { user: User; isAdmin: boolean } - return { success: true } - }) -``` - -## API Routes (Server Routes) - -```typescript -// app/routes/api/users.ts -import { createAPIFileRoute } from '@tanstack/react-start/api' - -export const APIRoute = createAPIFileRoute('/api/users')({ - GET: async ({ request }) => { - const users = await db.query.users.findMany() - return Response.json(users) - }, - POST: async ({ request }) => { - const body = await request.json() - const user = await db.insert(users).values(body).returning() - return new Response(JSON.stringify(user), { status: 201 }) - }, -}) -``` - -## SSR Strategies - -### Streaming SSR (Default) - -```typescript -export const Route = createFileRoute('/dashboard')({ - loader: async () => ({ - criticalData: await fetchCriticalData(), - deferredData: defer(fetchSlowData()), - }), - component: Dashboard, -}) - -function Dashboard() { - const { criticalData, deferredData } = Route.useLoaderData() - return ( -
- - }> - - {(data) => } - - -
- ) -} -``` - -## Deployment - -### Supported Platforms (Nitro Presets) - -```typescript -// app.config.ts -export default defineConfig({ - server: { - preset: 'node-server', // Self-hosted Node.js - // preset: 'vercel', // Vercel - // preset: 'netlify', // Netlify - // preset: 'cloudflare-pages', // Cloudflare Pages - // preset: 'aws-lambda', // AWS Lambda - // preset: 'deno-server', // Deno Deploy - // preset: 'bun', // Bun - }, -}) -``` - -## Best Practices - -1. **Use validators for all server function inputs** - runtime safety and TypeScript inference -2. **Compose middleware** for cross-cutting concerns (auth, logging, rate limiting) -3. **Use `createServerFn` GET** for data fetching (cacheable, preloadable) -4. **Use `createServerFn` POST** for mutations and side effects -5. **Use `beforeLoad`** for route-level auth guards -6. **Use `defer()`** for non-critical data to improve TTFB -7. **Set `defaultPreload: 'intent'`** on the router for instant navigation -8. **Co-locate server functions** with the routes that use them - -## Common Pitfalls - -- Server functions cannot close over client-side variables (they're extracted to separate bundles) -- Data returned from server functions must be serializable -- Forgetting `await` in loaders leads to streaming issues -- Importing server-only code in client bundles causes build errors -- Missing `declare module '@tanstack/react-router'` loses all type safety diff --git a/.claude/skills/tanstack-store/SKILL.md b/.claude/skills/tanstack-store/SKILL.md deleted file mode 100644 index c1e289b..0000000 --- a/.claude/skills/tanstack-store/SKILL.md +++ /dev/null @@ -1,305 +0,0 @@ ---- -name: tanstack-store -description: Framework-agnostic, immutable reactive data store with framework adapters for React, Vue, Solid, Angular, and Svelte. ---- - - -## Overview - -TanStack Store is a lightweight reactive store (signals-like) that powers the internals of TanStack libraries. It provides `Store` for state, `Derived` for computed values, `Effect` for side effects, and `batch` for atomic updates. Framework adapters provide reactive hooks. - -**Core:** `@tanstack/store` -**React:** `@tanstack/react-store` - -## Installation - -```bash -npm install @tanstack/store @tanstack/react-store -``` - -## Store - -### Creating a Store - -```typescript -import { Store } from '@tanstack/store' - -const countStore = new Store(0) - -const userStore = new Store<{ name: string; email: string }>({ - name: 'Alice', - email: 'alice@example.com', -}) -``` - -### Updating State - -```typescript -// Function updater (immutable update) -countStore.setState((prev) => prev + 1) - -userStore.setState((prev) => ({ ...prev, name: 'Bob' })) -``` - -### Subscribing to Changes - -```typescript -const unsub = countStore.subscribe(() => { - console.log('Count:', countStore.state) -}) - -// Cleanup -unsub() -``` - -### Store Options - -```typescript -const store = new Store(initialState, { - // Custom update function - updateFn: (prevValue) => (updater) => { - return updater(prevValue) // custom logic - }, - // Callback on subscribe - onSubscribe: (listener, store) => { - console.log('New subscriber') - return () => console.log('Unsubscribed') - }, - // Callback on every update - onUpdate: () => { - console.log('State updated:', store.state) - }, -}) -``` - -### Store Properties - -```typescript -store.state // Current state -store.prevState // Previous state -store.listeners // Set of listener callbacks -``` - -## Derived (Computed Values) - -```typescript -import { Store, Derived } from '@tanstack/store' - -const count = new Store(5) -const multiplier = new Store(2) - -const doubled = new Derived({ - deps: [count, multiplier], - fn: ({ currDepVals }) => currDepVals[0] * currDepVals[1], -}) - -// MUST mount to activate -const unmount = doubled.mount() - -console.log(doubled.state) // 10 - -count.setState(() => 10) -console.log(doubled.state) // 20 - -// Cleanup -unmount() -``` - -### Derived with Previous Value - -```typescript -const accumulated = new Derived({ - deps: [count], - fn: ({ prevVal, currDepVals }) => { - return currDepVals[0] + (prevVal ?? 0) - }, -}) -``` - -### Chaining Derived - -```typescript -const filtered = new Derived({ - deps: [dataStore, filterStore], - fn: ({ currDepVals }) => currDepVals[0].filter(matchesFilter(currDepVals[1])), -}) - -const sorted = new Derived({ - deps: [filtered, sortStore], - fn: ({ currDepVals }) => [...currDepVals[0]].sort(comparator(currDepVals[1])), -}) - -const paginated = new Derived({ - deps: [sorted, pageStore], - fn: ({ currDepVals }) => currDepVals[0].slice( - currDepVals[1].offset, - currDepVals[1].offset + currDepVals[1].limit, - ), -}) -``` - -## Effect (Side Effects) - -```typescript -import { Store, Effect } from '@tanstack/store' - -const count = new Store(0) - -const logger = new Effect({ - deps: [count], - fn: () => { - console.log('Count changed:', count.state) - // Optionally return cleanup function - return () => console.log('Cleaning up') - }, - eager: false, // true = run immediately on mount -}) - -const unmount = logger.mount() - -count.setState(() => 1) // logs: "Count changed: 1" - -unmount() -``` - -### Effect with Cleanup - -```typescript -const timerEffect = new Effect({ - deps: [intervalStore], - fn: () => { - const id = setInterval(() => { /* ... */ }, intervalStore.state) - return () => clearInterval(id) // cleanup on next run or unmount - }, -}) -``` - -## Batch - -Group multiple updates into one notification: - -```typescript -import { batch } from '@tanstack/store' - -// Subscribers fire only once with final state -batch(() => { - countStore.setState(() => 1) - nameStore.setState(() => 'Alice') - settingsStore.setState((prev) => ({ ...prev, theme: 'dark' })) -}) -``` - -## React Integration - -### useStore Hook - -```tsx -import { useStore } from '@tanstack/react-store' - -// Subscribe to full state -function Counter() { - const count = useStore(countStore) - return -} - -// Subscribe with selector (performance optimization) -function UserName() { - const name = useStore(userStore, (state) => state.name) - return {name} -} - -// Subscribe to Derived -function DoubledDisplay() { - const value = useStore(doubledDerived) - return {value} -} -``` - -### shallow Equality Function - -Prevents re-renders when selector returns structurally-equal objects: - -```tsx -import { useStore } from '@tanstack/react-store' -import { shallow } from '@tanstack/react-store' - -function TodoList() { - // Without shallow: re-renders on ANY state change (new object ref) - // With shallow: only re-renders when items actually change - const items = useStore(todosStore, (state) => state.items, shallow) - return
    {items.map(/* ... */)}
-} -``` - -### Mounting Derived/Effect in React - -```tsx -function MyComponent() { - useEffect(() => { - const unmountDerived = myDerived.mount() - const unmountEffect = myEffect.mount() - return () => { - unmountDerived() - unmountEffect() - } - }, []) - - const value = useStore(myDerived) - return {value} -} -``` - -## Module-Level Store Pattern - -```typescript -// stores/counter.ts -import { Store, Derived } from '@tanstack/store' - -export const counterStore = new Store(0) - -export const doubledCount = new Derived({ - deps: [counterStore], - fn: ({ currDepVals }) => currDepVals[0] * 2, -}) - -// Actions as plain functions -export function increment() { - counterStore.setState((c) => c + 1) -} - -export function reset() { - counterStore.setState(() => 0) -} -``` - -## Framework Adapters - -| Framework | Package | Hook/Composable | -|-----------|---------|-----------------| -| React | `@tanstack/react-store` | `useStore(store, selector?, equalityFn?)` | -| Vue | `@tanstack/vue-store` | `useStore(store, selector?)` (returns computed ref) | -| Solid | `@tanstack/solid-store` | `useStore(store, selector?)` (returns signal) | -| Angular | `@tanstack/angular-store` | `injectStore(store, selector?)` (returns signal) | -| Svelte | `@tanstack/svelte-store` | `useStore(store, selector?)` (returns $state) | - -## Best Practices - -1. **Define stores at module level** - they're singletons -2. **Use selectors** in `useStore` to prevent unnecessary re-renders -3. **Use `shallow`** when selectors return objects/arrays -4. **Always call `mount()`** on Derived and Effect instances -5. **Always clean up** unmount functions (especially in React useEffect) -6. **Never mutate state directly** - always use `setState` -7. **Use `batch`** for multiple related updates -8. **Use Derived chains** for data transformations (filter -> sort -> paginate) -9. **Return cleanup functions** from Effect `fn` for timers/listeners -10. **Select primitives** when possible (no equality fn needed) - -## Common Pitfalls - -- Forgetting to `mount()` Derived/Effect (they won't activate) -- Not cleaning up subscriptions/unmount functions (memory leaks) -- Mutating `store.state` directly instead of using `setState` -- Creating new object references in selectors without `shallow` -- Using `useStore` without a selector (subscribes to everything) -- Forgetting `eager: true` when Effect should run immediately diff --git a/.claude/skills/tanstack-table/SKILL.md b/.claude/skills/tanstack-table/SKILL.md deleted file mode 100644 index 3159adb..0000000 --- a/.claude/skills/tanstack-table/SKILL.md +++ /dev/null @@ -1,582 +0,0 @@ ---- -name: tanstack-table -description: Headless UI for building powerful tables & datagrids for TS/JS, React, Vue, Solid, Svelte, Qwik, Angular, and Lit. ---- - - -## Overview - -TanStack Table is a headless UI library for building data tables and datagrids. It provides logic for sorting, filtering, pagination, grouping, expanding, column pinning/ordering/visibility/resizing, and row selection - without rendering any markup or styles. - -**Package:** `@tanstack/react-table` -**Utilities:** `@tanstack/match-sorter-utils` (fuzzy filtering) -**Current Version:** v8 - -## Installation - -```bash -npm install @tanstack/react-table -``` - -## Core Architecture - -### Building Blocks - -1. **Column Definitions** - describe columns (data access, rendering, features) -2. **Table Instance** - central coordinator with state and APIs -3. **Row Models** - data processing pipeline (filter -> sort -> group -> paginate) -4. **Headers, Rows, Cells** - renderable units - -### Critical: Data & Column Stability - -```typescript -// WRONG - new references every render, causes infinite loops -const table = useReactTable({ - data: fetchedData.results, // new ref! - columns: [{ accessorKey: 'name' }], // new ref! -}) - -// CORRECT - stable references -const columns = useMemo(() => [...], []) -const data = useMemo(() => fetchedData?.results ?? [], [fetchedData]) - -const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) -``` - -## Column Definitions - -### Using createColumnHelper (Recommended) - -```typescript -import { createColumnHelper } from '@tanstack/react-table' - -type Person = { - firstName: string - lastName: string - age: number - status: 'active' | 'inactive' -} - -const columnHelper = createColumnHelper() - -const columns = [ - // Accessor column (data column) - columnHelper.accessor('firstName', { - header: 'First Name', - cell: info => info.getValue(), - footer: info => info.column.id, - }), - - // Accessor with function - columnHelper.accessor(row => row.lastName, { - id: 'lastName', // required with accessorFn - header: () => Last Name, - cell: info => {info.getValue()}, - }), - - // Display column (no data, custom rendering) - columnHelper.display({ - id: 'actions', - header: 'Actions', - cell: ({ row }) => ( - - ), - }), - - // Group column (nested headers) - columnHelper.group({ - id: 'info', - header: 'Info', - columns: [ - columnHelper.accessor('age', { header: 'Age' }), - columnHelper.accessor('status', { header: 'Status' }), - ], - }), -] -``` - -### Column Options - -| Option | Type | Description | -|--------|------|-------------| -| `id` | `string` | Unique identifier (auto-derived from accessorKey) | -| `accessorKey` | `string` | Dot-notation path to row data | -| `accessorFn` | `(row) => any` | Custom accessor function | -| `header` | `string \| (context) => ReactNode` | Header renderer | -| `cell` | `(context) => ReactNode` | Cell renderer | -| `footer` | `(context) => ReactNode` | Footer renderer | -| `size` | `number` | Default width (default: 150) | -| `minSize` | `number` | Min width (default: 20) | -| `maxSize` | `number` | Max width | -| `enableSorting` | `boolean` | Enable sorting | -| `sortingFn` | `string \| SortingFn` | Sort function | -| `enableFiltering` | `boolean` | Enable filtering | -| `filterFn` | `string \| FilterFn` | Filter function | -| `enableGrouping` | `boolean` | Enable grouping | -| `aggregationFn` | `string \| AggregationFn` | Aggregation function | -| `enableHiding` | `boolean` | Enable visibility toggle | -| `enableResizing` | `boolean` | Enable resizing | -| `enablePinning` | `boolean` | Enable pinning | -| `meta` | `any` | Custom metadata | - -## Table Instance - -### Creating a Table - -```typescript -import { - useReactTable, - getCoreRowModel, - getSortedRowModel, - getFilteredRowModel, - getPaginationRowModel, - flexRender, -} from '@tanstack/react-table' - -function MyTable() { - const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }) - - const table = useReactTable({ - data, - columns, - state: { sorting, columnFilters, pagination }, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - }) - - return ( - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - ))} - - ))} - - - {table.getRowModel().rows.map(row => ( - - {row.getVisibleCells().map(cell => ( - - ))} - - ))} - -
- {header.isPlaceholder ? null : - flexRender(header.column.columnDef.header, header.getContext())} - {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ) -} -``` - -## Sorting - -```typescript -const table = useReactTable({ - state: { sorting }, - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - enableMultiSort: true, - // manualSorting: true, // For server-side sorting -}) - -// Built-in sort functions: 'alphanumeric', 'text', 'datetime', 'basic' -// Column-level: sortingFn: 'alphanumeric' -``` - -## Filtering - -### Column Filtering - -```typescript -const table = useReactTable({ - state: { columnFilters }, - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - getFacetedMinMaxValues: getFacetedMinMaxValues(), -}) - -// Built-in: 'includesString', 'equalsString', 'arrIncludes', 'inNumberRange', etc. - -// Filter UI -function Filter({ column }) { - return ( - column.setFilterValue(e.target.value)} - placeholder={`Filter... (${column.getFacetedUniqueValues()?.size})`} - /> - ) -} -``` - -### Global Filtering - -```typescript -const [globalFilter, setGlobalFilter] = useState('') - -const table = useReactTable({ - state: { globalFilter }, - onGlobalFilterChange: setGlobalFilter, - globalFilterFn: 'includesString', - getFilteredRowModel: getFilteredRowModel(), -}) -``` - -### Fuzzy Filtering - -```typescript -import { rankItem } from '@tanstack/match-sorter-utils' - -const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { - const itemRank = rankItem(row.getValue(columnId), value) - addMeta({ itemRank }) - return itemRank.passed -} - -const table = useReactTable({ - filterFns: { fuzzy: fuzzyFilter }, - globalFilterFn: 'fuzzy', -}) -``` - -## Pagination - -```typescript -const table = useReactTable({ - state: { pagination }, - onPaginationChange: setPagination, - getPaginationRowModel: getPaginationRowModel(), - // For server-side: - // manualPagination: true, - // pageCount: serverPageCount, -}) - -// Navigation -table.nextPage() -table.previousPage() -table.firstPage() -table.lastPage() -table.setPageSize(20) -table.getCanNextPage() // boolean -table.getCanPreviousPage() // boolean -table.getPageCount() // total pages -``` - -## Row Selection - -```typescript -const [rowSelection, setRowSelection] = useState({}) - -const table = useReactTable({ - state: { rowSelection }, - onRowSelectionChange: setRowSelection, - enableRowSelection: true, - enableMultiRowSelection: true, -}) - -// Checkbox column -columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - ), - cell: ({ row }) => ( - - ), -}) - -// Get selected rows -table.getSelectedRowModel().rows -``` - -## Column Visibility - -```typescript -const [columnVisibility, setColumnVisibility] = useState({}) - -const table = useReactTable({ - state: { columnVisibility }, - onColumnVisibilityChange: setColumnVisibility, -}) - -// Toggle UI -{table.getAllLeafColumns().map(column => ( - -))} -``` - -## Column Pinning - -```typescript -const [columnPinning, setColumnPinning] = useState({ - left: ['select', 'name'], - right: ['actions'], -}) - -const table = useReactTable({ - state: { columnPinning }, - onColumnPinningChange: setColumnPinning, - enableColumnPinning: true, -}) - -// Render pinned sections separately -row.getLeftVisibleCells() // Left-pinned -row.getCenterVisibleCells() // Unpinned -row.getRightVisibleCells() // Right-pinned -``` - -## Column Resizing - -```typescript -const table = useReactTable({ - enableColumnResizing: true, - columnResizeMode: 'onChange', // 'onChange' | 'onEnd' - defaultColumn: { size: 150, minSize: 50, maxSize: 500 }, -}) - -// Resize handle in header -
-``` - -## Grouping & Aggregation - -```typescript -const [grouping, setGrouping] = useState([]) - -const table = useReactTable({ - state: { grouping }, - onGroupingChange: setGrouping, - getGroupedRowModel: getGroupedRowModel(), - getExpandedRowModel: getExpandedRowModel(), -}) - -// Built-in aggregation: 'sum', 'min', 'max', 'mean', 'median', 'count', 'unique', 'uniqueCount' -columnHelper.accessor('amount', { - aggregationFn: 'sum', - aggregatedCell: ({ getValue }) => `Total: ${getValue()}`, -}) -``` - -## Row Expanding - -```typescript -const [expanded, setExpanded] = useState({}) - -const table = useReactTable({ - state: { expanded }, - onExpandedChange: setExpanded, - getExpandedRowModel: getExpandedRowModel(), - getSubRows: (row) => row.subRows, // For hierarchical data -}) - -// Expand toggle - - -// Detail row pattern -{row.getIsExpanded() && ( - - - - - -)} -``` - -## Virtualization Integration - -```typescript -import { useVirtualizer } from '@tanstack/react-virtual' - -function VirtualizedTable() { - const table = useReactTable({ /* ... */ }) - const { rows } = table.getRowModel() - const parentRef = useRef(null) - - const virtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 35, - overscan: 10, - }) - - return ( -
- - - {virtualizer.getVirtualItems().map(virtualRow => { - const row = rows[virtualRow.index] - return ( - - {row.getVisibleCells().map(cell => ( - - ))} - - ) - })} - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- ) -} -``` - -## Server-Side Operations - -```typescript -const table = useReactTable({ - data: serverData, - columns, - manualSorting: true, - manualFiltering: true, - manualPagination: true, - pageCount: serverPageCount, - state: { sorting, columnFilters, pagination }, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - // Do NOT include getSortedRowModel, getFilteredRowModel, getPaginationRowModel -}) - -// Fetch data based on state -useEffect(() => { - fetchData({ sorting, filters: columnFilters, pagination }) -}, [sorting, columnFilters, pagination]) -``` - -## TypeScript Patterns - -### Extending Column Meta - -```typescript -declare module '@tanstack/react-table' { - interface ColumnMeta { - filterVariant?: 'text' | 'range' | 'select' - align?: 'left' | 'center' | 'right' - } -} -``` - -### Custom Filter/Sort Function Registration - -```typescript -declare module '@tanstack/react-table' { - interface FilterFns { - fuzzy: FilterFn - } - interface SortingFns { - myCustomSort: SortingFn - } -} -``` - -### Editable Cells via Table Meta - -```typescript -declare module '@tanstack/react-table' { - interface TableMeta { - updateData: (rowIndex: number, columnId: string, value: unknown) => void - } -} - -const table = useReactTable({ - meta: { - updateData: (rowIndex, columnId, value) => { - setData(old => old.map((row, i) => - i === rowIndex ? { ...row, [columnId]: value } : row - )) - }, - }, -}) -``` - -## Key Imports - -```typescript -import { - createColumnHelper, flexRender, useReactTable, - getCoreRowModel, getSortedRowModel, getFilteredRowModel, - getPaginationRowModel, getGroupedRowModel, getExpandedRowModel, - getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues, -} from '@tanstack/react-table' - -import type { - ColumnDef, SortingState, ColumnFiltersState, VisibilityState, - PaginationState, ExpandedState, RowSelectionState, GroupingState, - ColumnOrderState, ColumnPinningState, FilterFn, SortingFn, -} from '@tanstack/react-table' -``` - -## Best Practices - -1. **Always memoize `data` and `columns`** to prevent infinite re-renders -2. **Use `flexRender`** for all header/cell/footer rendering -3. **Use `table.getRowModel().rows`** for final rendered rows (not getCoreRowModel) -4. **Import only needed row models** - each adds processing to the pipeline -5. **Use `getRowId`** for stable row keys when data has unique IDs -6. **Use `manualX` options** for server-side operations -7. **Pair controlled state** with both `state.X` and `onXChange` -8. **Use module augmentation** for custom meta, filter fns, sort fns -9. **Use column helper** for type-safe column definitions -10. **Set `autoResetPageIndex: true`** when filtering should reset pagination - -## Common Pitfalls - -- Defining columns inline (creates new ref each render) -- Forgetting `getCoreRowModel()` (required for all tables) -- Using row models without importing them -- Not providing `id` when using `accessorFn` -- Mixing `manualPagination` with client-side `getPaginationRowModel` -- Forgetting `colSpan` for grouped headers -- Not handling `header.isPlaceholder` for group column spacers diff --git a/.claude/skills/tanstack-virtual/SKILL.md b/.claude/skills/tanstack-virtual/SKILL.md deleted file mode 100644 index ec6401e..0000000 --- a/.claude/skills/tanstack-virtual/SKILL.md +++ /dev/null @@ -1,369 +0,0 @@ ---- -name: tanstack-virtual -description: Headless UI for virtualizing large element lists at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular. ---- - - -## Overview - -TanStack Virtual provides virtualization logic for rendering only visible items in large lists, grids, and tables. It calculates which items are in the viewport and positions them with absolute positioning, keeping DOM node count minimal regardless of dataset size. - -**Package:** `@tanstack/react-virtual` -**Core:** `@tanstack/virtual-core` (framework-agnostic) - -## Installation - -```bash -npm install @tanstack/react-virtual -``` - -## Core Pattern - -```tsx -import { useVirtualizer } from '@tanstack/react-virtual' - -function VirtualList() { - const parentRef = useRef(null) - - const virtualizer = useVirtualizer({ - count: 10000, - getScrollElement: () => parentRef.current, - estimateSize: () => 35, // estimated row height in px - overscan: 5, - }) - - return ( -
-
- {virtualizer.getVirtualItems().map((virtualItem) => ( -
- Row {virtualItem.index} -
- ))} -
-
- ) -} -``` - -## Virtualizer Options - -### Required - -| Option | Type | Description | -|--------|------|-------------| -| `count` | `number` | Total number of items | -| `getScrollElement` | `() => Element \| null` | Returns scroll container | -| `estimateSize` | `(index) => number` | Estimated item size (overestimate recommended) | - -### Optional - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `overscan` | `number` | `1` | Extra items rendered beyond viewport | -| `horizontal` | `boolean` | `false` | Horizontal virtualization | -| `gap` | `number` | `0` | Gap between items (px) | -| `lanes` | `number` | `1` | Number of lanes (masonry/grid) | -| `paddingStart` | `number` | `0` | Padding before first item | -| `paddingEnd` | `number` | `0` | Padding after last item | -| `scrollPaddingStart` | `number` | `0` | Offset for scrollTo positioning | -| `scrollPaddingEnd` | `number` | `0` | Offset for scrollTo positioning | -| `initialOffset` | `number` | `0` | Starting scroll position | -| `initialRect` | `Rect` | - | Initial dimensions (SSR) | -| `enabled` | `boolean` | `true` | Enable/disable | -| `getItemKey` | `(index) => Key` | `(i) => i` | Stable key for items | -| `rangeExtractor` | `(range) => number[]` | default | Custom visible indices | -| `scrollToFn` | `(offset, options, instance) => void` | default | Custom scroll behavior | -| `measureElement` | `(el, entry, instance) => number` | default | Custom measurement | -| `onChange` | `(instance, sync) => void` | - | State change callback | -| `isScrollingResetDelay` | `number` | `150` | Delay before scroll complete | - -## Virtualizer API - -```typescript -// Get visible items -virtualizer.getVirtualItems(): VirtualItem[] - -// Get total scrollable size -virtualizer.getTotalSize(): number - -// Scroll to specific index -virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' }) - -// Scroll to offset -virtualizer.scrollToOffset(offset, options) - -// Force recalculation -virtualizer.measure() -``` - -## VirtualItem Properties - -```typescript -interface VirtualItem { - key: Key // Unique key - index: number // Index in source data - start: number // Pixel offset (use for transform) - end: number // End pixel offset - size: number // Item dimension - lane: number // Lane index (multi-column) -} -``` - -## Dynamic/Variable Heights - -Use `measureElement` ref for items with unknown heights: - -```tsx -const virtualizer = useVirtualizer({ - count: items.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 50, // overestimate -}) - -{virtualizer.getVirtualItems().map((virtualItem) => ( -
- {items[virtualItem.index].content} -
-))} -``` - -## Horizontal Virtualization - -```tsx -const virtualizer = useVirtualizer({ - count: columns.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 100, - horizontal: true, -}) - -// Use width for container, translateX for positioning -
- {virtualizer.getVirtualItems().map((item) => ( -
- Column {item.index} -
- ))} -
-``` - -## Grid Virtualization (Two Virtualizers) - -```tsx -function VirtualGrid() { - const parentRef = useRef(null) - - const rowVirtualizer = useVirtualizer({ - count: 10000, - getScrollElement: () => parentRef.current, - estimateSize: () => 35, - overscan: 5, - }) - - const columnVirtualizer = useVirtualizer({ - count: 10000, - getScrollElement: () => parentRef.current, - estimateSize: () => 100, - horizontal: true, - overscan: 5, - }) - - return ( -
-
- {rowVirtualizer.getVirtualItems().map((virtualRow) => ( - - {columnVirtualizer.getVirtualItems().map((virtualColumn) => ( -
- Cell {virtualRow.index},{virtualColumn.index} -
- ))} -
- ))} -
-
- ) -} -``` - -## Window Scrolling - -```tsx -import { useWindowVirtualizer } from '@tanstack/react-virtual' - -function WindowList() { - const listRef = useRef(null) - - const virtualizer = useWindowVirtualizer({ - count: 10000, - estimateSize: () => 45, - overscan: 5, - scrollMargin: listRef.current?.offsetTop ?? 0, - }) - - return ( -
-
- {virtualizer.getVirtualItems().map((item) => ( -
- Row {item.index} -
- ))} -
-
- ) -} -``` - -## Infinite Scrolling - -```tsx -import { useVirtualizer } from '@tanstack/react-virtual' -import { useInfiniteQuery } from '@tanstack/react-query' - -function InfiniteList() { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - queryKey: ['items'], - queryFn: ({ pageParam = 0 }) => fetchItems(pageParam), - getNextPageParam: (lastPage) => lastPage.nextCursor, - }) - - const allItems = data?.pages.flatMap((page) => page.items) ?? [] - - const virtualizer = useVirtualizer({ - count: hasNextPage ? allItems.length + 1 : allItems.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 50, - overscan: 5, - }) - - useEffect(() => { - const items = virtualizer.getVirtualItems() - const lastItem = items[items.length - 1] - if (lastItem && lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) { - fetchNextPage() - } - }, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allItems.length]) - - // Render virtual items, show loader row for last item if loading -} -``` - -## Sticky Items - -```tsx -import { defaultRangeExtractor, Range } from '@tanstack/react-virtual' - -const stickyIndexes = [0, 10, 20, 30] // Header indices - -const virtualizer = useVirtualizer({ - count: 1000, - getScrollElement: () => parentRef.current, - estimateSize: () => 50, - rangeExtractor: useCallback((range: Range) => { - const next = new Set([...stickyIndexes, ...defaultRangeExtractor(range)]) - return [...next].sort((a, b) => a - b) - }, [stickyIndexes]), -}) - -// Render sticky items with position: sticky; top: 0; zIndex: 1 -``` - -## Smooth Scrolling - -```tsx -const virtualizer = useVirtualizer({ - scrollToFn: (offset, { behavior }, instance) => { - if (behavior === 'smooth') { - // Custom easing animation - instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' }) - } else { - instance.scrollElement?.scrollTo({ top: offset }) - } - }, -}) - -// Usage -virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' }) -``` - -## Best Practices - -1. **Overestimate `estimateSize`** - prevents scroll jumps (items shrinking causes issues) -2. **Increase `overscan`** (3-5) to reduce blank flashing during fast scrolling -3. **Use `transform: translateY()`** over `top` for GPU-composited positioning -4. **Add `data-index` attribute** when using `measureElement` for dynamic sizing -5. **Don't set fixed height** on dynamically measured items -6. **Use `getItemKey`** for stable keys when items can reorder -7. **Use `gap` option** instead of margins (margins interfere with measurement) -8. **Use `paddingStart/End`** instead of CSS padding on the container -9. **Use `enabled: false`** to pause when the list is hidden -10. **Memoize callbacks** (`estimateSize`, `getItemKey`, `rangeExtractor`) -11. **Use `will-change: transform`** CSS on items for GPU acceleration - -## Common Pitfalls - -- Setting fixed height on dynamically measured items -- Using CSS margins instead of the `gap` option -- Forgetting `data-index` with `measureElement` -- Not providing `position: relative` on the inner container -- Underestimating `estimateSize` (causes scroll jumps) -- Setting `overscan` too low for fast scrolling (blank items) -- Forgetting to subtract `scrollMargin` from `translateY` in window scrolling -- Not memoizing the `estimateSize` function (causes re-renders) diff --git a/.claude/skills/web-design-guidelines/SKILL.md b/.claude/skills/web-design-guidelines/SKILL.md deleted file mode 100644 index ceae92a..0000000 --- a/.claude/skills/web-design-guidelines/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: web-design-guidelines -description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices". -metadata: - author: vercel - version: "1.0.0" - argument-hint: ---- - -# Web Interface Guidelines - -Review files for compliance with Web Interface Guidelines. - -## How It Works - -1. Fetch the latest guidelines from the source URL below -2. Read the specified files (or prompt user for files/pattern) -3. Check against all rules in the fetched guidelines -4. Output findings in the terse `file:line` format - -## Guidelines Source - -Fetch fresh guidelines before each review: - -``` -https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md -``` - -Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions. - -## Usage - -When a user provides a file or pattern argument: -1. Fetch guidelines from the source URL above -2. Read the specified files -3. Apply all rules from the fetched guidelines -4. Output findings using the format specified in the guidelines - -If no files specified, ask the user which files to review. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6d9b196..08d29e6 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -20,7 +20,7 @@ If you ever get stuck, come say hi in the [Discord](https://discord.gg/VQF23tPKy - **First-time contributors** — look for issues labelled `good first issue` or `help wanted`. - **Adding a new game** — read the [Architecture guide](../documentation/ARCHITECTURE.md) end-to-end first; the "Adding a new game" section is your checklist. - **Adding a theme** — read the [Themes guide](../documentation/THEMES.md). -- **Working with persisted data** (collected items, profile, favorites) — read the [DAL guide](DAL.md). The DAL is offline-first; bypassing it will silently break the offline experience. +- **Working with persisted data** (collected items, profile, favorites) — read the [DAL guide](../documentation/DAL.md). The DAL is offline-first; bypassing it will silently break the offline experience. ## Local setup @@ -122,7 +122,7 @@ These three docs cover the non-obvious parts of the codebase. Read the one(s) re |-----------------------------------|---------------------------------------------------------------------------------------------------------------| | [Architecture](../documentation/ARCHITECTURE.md) | Adding a game, touching the game registry, navigating routes, or wondering "where does this logic belong?" | | [Themes](../documentation/THEMES.md) | Building a per-game theme, modifying the palette generator, or changing how light/dark switching works | -| [DAL (data access layer)](DAL.md) | Adding any persisted state — collected items, profile fields, favorites. Anything that needs to work offline. | +| [DAL (data access layer)](../documentation/DAL.md) | Adding any persisted state — collected items, profile fields, favorites. Anything that needs to work offline. | Thanks for contributing! ❤️ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 81a5c73..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,332 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -Package manager is **pnpm**. Test runner is **Vitest**. Formatter/linter is **Biome** (not Prettier/ESLint). - -```bash -pnpm dev # Vite dev server on :3000 -pnpm build # Production build (TanStack Start) -pnpm preview # Preview production build -pnpm test # Run vitest once (CI-style) -pnpm check # Biome check (lint + format) -pnpm format # Biome format only -pnpm lint # Biome lint only -pnpm type # TypeScript type check (no emit) -``` - -Single test: `pnpm vitest run path/to/file.test.ts` or `pnpm vitest run -t "test name"`. - -### Database - -Postgres is required. Local dev uses the Docker Compose stack; all `db:*` scripts load `.env.local` via `dotenv-cli`. - -```bash -pnpm db:local:start # start local postgres (compose.local.yaml) -pnpm db:local:stop # stop local postgres -pnpm db:local:restart # restart local postgres -pnpm db:local:down # tear down volumes (destructive) -pnpm db:generate # prisma generate -> writes to prisma/generated/prisma & prisma/generated/prisma-idb -pnpm db:push # push schema changes directly to the database (no migration files) -pnpm db:migrate # prisma migrate dev (rarely used — see note below) -pnpm db:studio # Prisma Studio -pnpm db:seed # run prisma/seed.ts -``` - -Image/favicon pipelines (gulp): `pnpm favicons:generate`, `pnpm images:generate`. - -This project does **not** use Prisma migrations in normal workflow — schema changes are applied with `pnpm db:push`. After editing any `.prisma` file, run `pnpm db:generate` to regenerate both the Postgres and IndexedDB clients, then `pnpm db:push` to sync the schema to the database. - -Two Prisma clients are generated from a single `schema.prisma`: -- `prisma/generated/prisma` — Postgres server client (used via `src/db.ts` and `prisma/client.ts`). -- `prisma/generated/prisma-idb` — IndexedDB client for the browser (via `@prisma-idb/idb-client-generator`), used offline/client-side in `src/integrations/prisma-idb`. - -Prisma is configured via `prisma.config.ts` with `schema: path.join('prisma')`, which enables Prisma's multi-file schema mode: every `.prisma` file under `prisma/` is auto-discovered. Per-game models live in `prisma/models/.prisma` (currently `clairobscur.prisma`, `remnant2.prisma`, `slaythespire2.prisma`). No `@@prisma.import` directive is needed — just create the file. - -## Skills - -Skills live in `.claude/skills/`. Use the `/skill-name` slash command or reference them when working on relevant code. - -| Skill | Use when | -|------------------------------|-------------------------------------------------------------------------------| -| `tanstack-start` | Server functions, SSR, deployment | -| `tanstack-router` | Routes, loaders, search params, link generation | -| `tanstack-query` | `useQuery`, `useMutation`, query keys, cache invalidation | -| `tanstack-form` | Form state, validation, field arrays | -| `tanstack-virtual` | Virtualizing large lists | -| `tanstack-table` | Tables, sorting, filtering, pagination | -| `tanstack-store` | Reactive stores with `@tanstack/store` | -| `mantine-custom-components` | Creating components with Mantine Styles API, `factory()`, compound components | -| `better-auth-best-practices` | Auth config, plugins, session management | -| `web-design-guidelines` | Accessibility, UX review | - -## Architecture - -### Framework stack - -- **TanStack Start** (SSR + server functions) on top of **TanStack Router** with file-based routing. `vite.config.ts` registers `tanstackStart()` before `@vitejs/plugin-react` — order matters. -- **React 19** with the **React Compiler** enabled. `babel-plugin-react-compiler` is installed as a dev dependency and is auto-detected by `@vitejs/plugin-react` v6+ — no explicit babel config is required, but the package must remain installed for the compiler to run. Write idiomatic React (no manual `useMemo`/`useCallback`/`React.memo` for values that don't need stable identity); the compiler handles memoization. -- **Mantine v9** for UI (core, dates, modals, notifications, carousel, spotlight, tiptap, code-highlight). `next-themes` drives the theme class on ``. -- **Better Auth** for auth, mounted as a catch-all route at `src/routes/api/auth/$.ts`. Prisma adapter, Discord OAuth, email/password with Resend-sent verification + reset emails (React Email templates in `src/emails/auth/`). -- **TanStack Query** integrated with the router via `setupRouterSsrQueryIntegration` in `src/router.tsx`. Shared `QueryClient` is created in `src/integrations/tanstack-query/get-context.ts` and attached to router context. - -### Routing - -- Routes live in `src/routes/`. `routeTree.gen.ts` is **generated** — do not edit (also marked read-only in `.vscode/settings.json` and excluded from Biome). -- Root shell is `src/routes/__root.tsx`: renders ``, the Mantine `AppShell` (header + navbar + footer), and mounts `AppProviders` (`src/components/AppProviders.tsx`). The provider chain is `NuqsAdapter` -> `MantineProviderWithTheme`; `MantineProviderWithTheme` itself wraps children in Mantine's `ModalsProvider`, and a `ScreenshotPreviewProvider` is also rendered inside it. -- The root route's `beforeLoad` resolves an authoritative `ssrGameId` for every request (see "Active-game resolution" below) and exposes it via route context. -- Game-scoped URLs live under `src/routes/$gameId/` — the `$gameId` path segment feeds into the root `beforeLoad`'s resolution chain; the segment itself drives favicon `` tags via the route's `head()`. -- Profile routes: - - `src/routes/profile/` — the anonymous/offline-friendly profile shell. Always reachable, even when signed out or offline (it renders the local DAL view). When the user is both authenticated **and** online, `route.tsx` redirects to `/account/profile/$userId` with the current session's user id. - - `src/routes/account/profile/$userId/` — the canonical, userId-keyed profile route. Used for both the current user (after the redirect above) and public views of other users. -- HTML head metadata: - - Root tags (title, og:*, twitter:*) are defined in `__root.tsx`. Routes can override them with their own `head()` — TanStack Router merges by `property`/`name`, with child routes winning on key conflicts. - - The canonical profile route (`/account/profile/$userId`) overrides root metadata with user-specific OG/Twitter tags. The OG image is the active-game avatar override → primary avatar → legacy `avatarUrl` → site default. The active game here resolves as `subdomain → ?gameId= → active-game cookie` (the loader-context route-segment chain isn't used for OG since the profile path itself never carries a gameId segment). The cookie matters because the owner often lands on a profile tab via in-app nav with no `?gameId=` in the URL; including the cookie keeps the SSR'd OG consistent with what they see on screen, while crawlers without the cookie still get the correct preview when the shared URL carries `?gameId=` (the in-tab `useEffect` in `collected-items.tsx` mirrors the active gameId into the URL on hydration). The loader reuses the cached result of `getServerResolvedGameInputsServerFn` (`src/features/game/dal/active-game.ts`, queryKey `SERVER_GAME_INPUTS_QUERY_KEY` from `src/routes/__root.tsx`) that the root `beforeLoad` already populated, so no extra server call fires. - - Per-tab title overrides (e.g. "Display Name — Collected Items | Toolkits.gg") use the shared helpers in `src/features/auth/core/profile-tab-head.ts`. Each tab's `loader` calls `loadProfileTabData()` (cache-hit from the parent loader's `ensureQueryData` — no extra fetch). - -### Game registry pattern - -Everything game-specific hangs off a central registry. Each game under `src/games//` exposes a `core/game-config/index.ts` that exports: - -```typescript -const GAME_CONFIG = { - ITEMS, // { all, collectable, categorized, categories, uncollectableCategories } - THEME, // ToolkitThemeDefinition | undefined - METADATA, // id, name, label, description, faviconSourcePath, LogoComponent, 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 }; -``` - -`src/features/game/registry/game-registry.tsx` wires every `gameId` to its `GameConfig` and exports: `GAME_REGISTRY`, `REGISTERED_GAME_IDS`, `getGameConfig()`, `getGameConfigTyped()`, `getGameItems()`, `getGameTheme()`, `getGameMetadata()`, `getGamePages()`, `getGameAvatars()`, `getGameLogoComponent()`, `getGameSearchParams()`, `getAllRegisteredThemeDefinitions()`, `getAllRegisteredThemeClassNames()`, `isRegisteredGameId()`, `getValidatedGameId()`. - -The `GameId` enum is defined in `schema.prisma` and imported from `@/prisma`. `getAllRegisteredThemeDefinitions()` expands each game theme into light+dark variants plus a base `default-light`/`default-dark`. - -### Feature/game separation rule - -Game-specific logic (Prisma queries, sync handlers, server functions, DAL actions) must live in `src/games//`. Never add game-keyed branches or inline game handlers to files under `src/features/`. - -DAL actions split by scope: -- **Cross-game** (e.g. `favoriteGames`, `userProfile`) -> `src/features//dal//` — currently all under `src/features/auth/dal/` since both belong to the authenticated-user surface area. -- **Game-specific** (e.g. `collectedItems`) -> `src/games//dal/` - -Within each cross-game DAL folder, files follow a consistent suffix convention: - -- `.ts` — TanStack Start server functions (Postgres reads/writes via Prisma) -- `.idb.ts` — IndexedDB layer (local reads/writes via the prisma-idb client) -- `.actions.ts` — `defineDalRead` / `defineDalWrite` action definitions wiring `remote` to the server functions and `local` to the IDB helpers -- `sync-handler.ts` — server-side sync handler invoked by `applyPendingOpServerFn` - -The game-specific `collectedItems` entity follows the same four-file convention in `src/features/game/dal/collected-items/`, but because it spans every game's Prisma model each file exports a **factory** rather than a concrete singleton: `collected-items.ts` (`createCollectedItemHandlers` — Prisma CRUD), `collected-items.idb.ts` (`createCollectedItemsIdb`), `collected-items.dal.ts` (`createCollectedItemsDal`), and `sync-handler.ts` (`createCollectedItemSyncHandler`). Each game's `src/games//dal/` instantiates these factories with its own model. - -All cross-game aggregation maps (registries) belong in `src/features/game/registry/`. When adding a new game, that folder is the single place to look for all maps that need a new entry — `game-registry.tsx`, `game-sync-handler-registry.ts`, `game-db-seed-registry.ts`, `game-idb-seed-registry.ts`, `favicon-registry.json`. Registry files may import from `src/games/` but must not contain any per-game business logic inline. - -### Adding a new game - -Follow these steps in order. The registry is the single place to check; no other `src/features/` files need changes. - -1. **Add the enum value** — open `prisma/schema.prisma` and append the new id to `enum GameId`. - -2. **Create game models** — copy an existing `prisma/models/.prisma` as a template and create `prisma/models/.prisma`. No `@@prisma.import` is needed — Prisma's multi-file schema (configured in `prisma.config.ts`) auto-discovers every `.prisma` file under `prisma/`. - -3. **Generate & push** — `pnpm db:generate && pnpm db:push`. - -4. **Scaffold the game directory** — create `src/games//` with the two top-level folders `core/` and `dal/`: - - ``` - core/ - game-config/ - index.ts # exports GAME_CONFIG satisfies GameConfig - metadata.tsx # id, name, label, description, faviconSourcePath, LogoComponent - pages.tsx # GamePages with renderItemLookup() and renderCollectedItems() - theme.ts # ToolkitThemeDefinition (colors, Mantine overrides) — uses generateThemeColors() from #/features/theme/core/generate-palette - items.ts # item data + categorization - search-params.ts # nuqs parsers (optional — only if custom filters needed) - avatars.ts # GameAvatar[] (optional) - db-seed.ts # GameDBSeed (initial Postgres data) - idb-seed.ts # GameIDBSeed (initial IndexedDB data) - item-data/ # raw item definitions consumed by game-config/items.ts - types.ts # game-specific TypeScript types (LocalItem, etc.) - constants.ts # game-specific constants - Logo.tsx # game logo component referenced by metadata.LogoComponent - 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 that calls `createCollectedItemsDal({ entityName, getModel, serverFns })` from `#/features/game/dal/collected-items/collected-items.actions`. - -5. **Register in all registry files** (all live in `src/features/game/registry/`): - - | File | What to add | - |---------------------------------|---------------------------------------------------------| - | `game-registry.tsx` | Entry in `GAME_REGISTRY` object | - | `game-db-seed-registry.ts` | Entry in `allGameDBSeeds` | - | `game-idb-seed-registry.ts` | Entry in `allGameIDBSeeds` | - | `game-sync-handler-registry.ts` | Entry mapping entity name -> `collectedItemSyncHandler` | - | `favicon-registry.json` | `"": ""` | - -### Active-game resolution - -GameId resolution is SSR-deterministic: the root route's `beforeLoad` (in `src/routes/__root.tsx`) computes an authoritative `ssrGameId` per request and exposes it via route context. Components read it through `useGameId()` (`src/features/game/core/use-game-id.ts`), which merges the SSR value with a client-only in-memory store used for mid-session toggles. - -**Priority chain inside `beforeLoad`:** -1. **Subdomain** — from the `Host` header (e.g. `remnant2.toolkits.gg` → `remnant2`). -2. **Dev override** `?_game=` — only when `import.meta.env.DEV`. Used to test subdomain behavior on `localhost`. -3. **Route segment** — leading `/$gameId/...` in `location.pathname`. -4. **Search param** `?gameId=`. -5. **Cookie** `active-game` — durable user preference, written by `GameSwitcher` via `setActiveGameCookie()` (in `#/features/game/core/utils`). -6. Fallback `null` → resolves to `"none"` in `useGameId()`. - -**Server-side resolution:** `getServerResolvedGameInputsServerFn` in `src/features/game/dal/active-game.ts` reads the Host header and the `active-game` cookie via `getRequest()`. Cached for the session via `queryClient.ensureQueryData` (queryKey `SERVER_GAME_INPUTS_QUERY_KEY` exported from `src/routes/__root.tsx`) with `staleTime: Infinity`, so it runs at most once per page load even when other loaders (e.g. the profile route) need the same inputs. - -**Client layer:** the `@tanstack/store` at `src/features/game/core/store.ts` is a thin reactive layer (`{ gameId: GameId | null }`) used by `GameSwitcher` for mid-session toggles. `useGameId()` returns `clientStore.gameId ?? ssrGameId ?? "none"` — the client store wins when set so a switcher click updates the UI immediately, but on initial paint the store is empty and the SSR value wins (deterministic hydration). - -**Writing from `GameSwitcher`:** `handleSelectGame` calls `setActiveGameCookie(id)` (durable) + `setGame(id)` (immediate UI reactivity). `handleGoHome` clears both. - -Because every input in the chain is server-knowable, no `ClientOnly` wrappers are needed for gameId-driven render output. (`ClientOnly` is still used for genuinely client-only state like `UserMenu`'s auth status.) - -### Theme system - -- Mantine theme objects live in `src/features/theme/themes/` (base + default) and per-game in `src/games//core/game-config/theme.ts`. Per-game palettes are built with `generateThemeColors()` from `#/features/theme/core/generate-palette`. -- `MantineProviderWithTheme` reads the active Mantine theme from `#/features/theme/core/store` (`useMantineThemeStore`) and feeds `next-themes` with the full list of registered theme class names (`getAllRegisteredThemeClassNames()`), so `html[data-theme]`/`className` toggling is driven off the registry. It also nests Mantine's `ModalsProvider`. -- `SyncAndApplyTheme` syncs `next-themes` <-> the Mantine store and persists `autoChangeTheme` in `localStorage`. - -### DAL (offline-first) - -The DAL (`src/features/dal/`) is an offline-first data layer. Every read/write executes against either a **remote** backend (TanStack Start server functions -> Postgres) or a **local** backend (IndexedDB). The backend is chosen automatically by `chooseBackend()`: remote when the user is authenticated and online, local otherwise. Local writes are queued as `PendingOp`s and synced later with last-write-wins conflict resolution. - -`src/features/dal/` contains: - -- `core/` — `define-action.ts` (action factories), `choose-backend.ts`, `to-query-options.ts`, `types.ts`, and `presence-sync-handler.ts` (shared `createPresenceToggleSyncHandler` factory for presence-toggle entities) -- `hooks/` — `useDalQuery`, `useDalMutation`, `useBackend`, `useDalContextSource` -- `identity/` — anon-id generation/persistence and `useEffectiveUserId` -- `local/` — IndexedDB constants, `local-db.ts` (prisma-idb client wrapper), and shared local row types -- `queue/` — `PendingOp` storage (`pending-ops.ts`), the `syncOps()`/`forceSyncOp()` runner (`sync-runner.ts`), last-write-wins resolution (`last-write-wins.ts`), the `usePendingOps` hook, and `apply-pending-ops.ts` (the `applyPendingOpServerFn` server function that dispatches each op to a `SyncHandler` by `entity`) - -``` -Component - └─ useDalQuery / useDalMutation - └─ DalContext { anonUserId, authUserId, backend } - ├─ "remote" -> action.remote(input, ctx) (server function) - └─ "local" -> action.local(input, ctx) (IndexedDB) - └─ [writes] enqueueOp() -> PendingOp -> syncOps() -``` - -**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` as the stable local user ID. - -**Define actions** with the factory helpers: - -```typescript -import { defineDalRead, defineDalWrite } from "#/features/dal/core/define-action"; - -// Read -const list = defineDalRead({ - queryKey: () => ["myEntity", "list"], - remote: async (_input, _ctx) => myListServerFn(), - local: async (_input, ctx) => listLocalItems(ctx.authUserId ?? ctx.anonUserId), -}); - -// Write -const upsert = defineDalWrite({ - entity: "myEntity", - operation: "upsert", - invalidates: ["myEntity"], - 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 }), -}); -// Queued ops are uploaded by syncOps via applyPendingOpServerFn, which dispatches -// to the right SyncHandler by `entity` — no per-action sync wiring is needed. -``` - -**Use in components:** - -```typescript -const { data } = useDalQuery(myActions.list, undefined); -const mutation = useDalMutation(myActions.upsert); -mutation.mutate({ id: "...", value: "..." }); -``` - -**Server helpers** (use inside server functions only): - -```typescript -import { requireUserId, getOptionalUserId } from "#/features/auth/dal/require-user.server"; -const userId = await requireUserId(); // throws 401 if no session -const anotherUserId = await getOptionalUserId(); // returns null if unauthenticated -``` - -### Imports & path aliases - -Two aliases are declared in both `package.json` `imports` **and** `tsconfig.json` `paths`: - -- `#/*` -> `./src/*`. -- `@/prisma` -> `./prisma/client` — this is how you import `prisma` and generated types/enums (e.g. `import type { GameId } from "@/prisma"`). Do **not** import directly from `prisma/generated/prisma`. - -### Env vars - -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 template. **Never read `process.env` / `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 are consumed by `prisma/seed.ts` to seed local admin/user accounts; `VITE_APP_NOREPLY_EMAIL` is the From address for Resend auth emails (`src/features/email/utils.ts`); `VITE_APP_DESCRIPTION` feeds the root HTML metadata. - -```typescript -import { clientEnv } from "#/env/client-env.ts"; -const appUrl = clientEnv.VITE_APP_URL; -``` - -## Code style - -- **Tabs** for indentation, double quotes (Biome config). Organize-imports runs on save in VS Code. -- TypeScript is strict with `noUnusedLocals`, `noUnusedParameters`, `verbatimModuleSyntax` — always use `import type` for type-only imports. -- `routeTree.gen.ts` and `styles.css` are excluded from Biome; don't hand-edit them. -- **Arrow functions over `function` declarations.** Prefer `const foo = () => {}` over `function foo() {}` for all module-level functions. Do not use `function` declarations except where required (e.g. generators, or framework APIs that demand them). -- **Exports at the bottom of the file.** Declare everything (`const`, `type`, `class`, etc.) without an `export` keyword inline, then put a single `export { ... }` block (and `export type { ... }` if needed) at the very end of the file. No `export const`, `export function`, `export type`, or `export default` at the declaration site. This keeps the public surface area of each file visible in one place. - -## Documentation - -**After any non-trivial code change, check whether the contributor docs need to be updated.** Stale docs are worse than missing docs — a contributor who follows an out-of-date guide loses time and trust. - -The contributor-facing docs all live under `.github/`: - -- `.github/CONTRIBUTING.md` — entry point: workflow, code style, commit/PR conventions, links to the deep dives. -- `.github/LOCALSETUP.md` — environment setup (Node, pnpm, Docker, env vars, db scripts, troubleshooting). -- `.github/ARCHITECTURE.md` — framework stack, routing, active-game store, the game registry pattern, the feature/game separation rule, "Adding a new game" checklist. -- `.github/THEMES.md` — per-game theming, palette generation, light/dark handling. -- `.github/DAL.md` — offline-first data layer, action factories, sync queue. - -And `CLAUDE.md` itself (this file) is the source of truth for AI-assisted work — keep it in sync with the contributor docs when the underlying behavior changes. - -Use this checklist when finishing a change: - -- Did you add, remove, or rename a `pnpm` script? Update `CLAUDE.md` Commands, `CONTRIBUTING.md`, and `LOCALSETUP.md`. -- Did you add or change an env var? Update `.env.local.example`, the matching schema in `src/env/server-env.ts` or `src/env/client-env.ts`, the required-key list in `src/env/validate-required.ts`, `CLAUDE.md` Env vars, `LOCALSETUP.md` config table, and `ARCHITECTURE.md` Env vars. **If it's a client-side `VITE_*` var, also add it to `src/env.d.ts`'s `ImportMetaEnv` interface** — without that, `import.meta.env.VITE_FOO` won't typecheck. -- Did you add a new game, change the registry shape, or change "Adding a new game" steps? Update `CLAUDE.md` Architecture and `.github/ARCHITECTURE.md`. -- Did you change the theme system (palette generator, light/dark switching, registry expansion)? Update `.github/THEMES.md`. -- Did you change DAL action shapes, the sync flow, or the file conventions under `src/features/dal/` or `src/games/*/dal/`? Update `.github/DAL.md`. -- Did you change routing structure (root shell, provider chain, profile redirect, `$gameId` route behavior)? Update `CLAUDE.md` Routing and `.github/ARCHITECTURE.md` Routing. - -If you're unsure whether a change warrants a doc update, mention it in your response so the user can decide — don't silently skip it. diff --git a/README.md b/README.md index 9bfa3bc..3f7a12c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# toolkits.gg-web +# toolkitsgg-web This is the web application for Toolkits.gg. Toolkits.gg is a collection of gaming toolkits and utilities designed to enhance your experience in a variety of games. @@ -13,7 +13,7 @@ This is the web application for Toolkits.gg. Toolkits.gg is a collection of gami | [Contributing](.github/CONTRIBUTING.md) | Guidelines for contributing code and content | | [Architecture](documentation/ARCHITECTURE.md) | Framework stack, game registry pattern, adding a new game | | [Themes](documentation/THEMES.md) | Per-game theming, palette generation, light/dark handling | -| [DAL](.github/DAL.md) | Offline-first data layer for persisted state | +| [DAL](documentation/DAL.md) | Offline-first data layer for persisted state | ## Contributing diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index 716d819..e02abc5 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -1,9 +1,11 @@ # 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](../.github/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. +> 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 @@ -61,7 +63,7 @@ This is the single most important pattern in the codebase. **Everything game-spe ### Anatomy of a game -Each game lives at `src/games//` and exposes a `GAME_CONFIG` from `core/game-config/index.ts`: +Each game lives at `src/games//` and exposes a `GAME_CONFIG` from `core/game-config/client.ts`: ```typescript const GAME_CONFIG = { @@ -152,12 +154,12 @@ Create `src/games//` with two top-level folders, `core/` and `dal/`: ``` core/ game-config/ - index.ts # exports GAME_CONFIG satisfies GameConfig + 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 - search-params.ts # nuqs parsers (optional — only if custom filters) + 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) @@ -266,7 +268,7 @@ If you need a new env var, add it to: ## Where to go next -- **Persisted data** (collected items, profile, favorites) — read the [DAL guide](../.github/DAL.md). The DAL is offline-first; reaching for `useQuery` directly will silently break the offline experience. +- **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/.github/DAL.md b/documentation/DAL.md similarity index 93% rename from .github/DAL.md rename to documentation/DAL.md index 1f0c0fc..eead9fa 100644 --- a/.github/DAL.md +++ b/documentation/DAL.md @@ -36,7 +36,7 @@ hooks/ identity/ Anon-id generation/persistence, useEffectiveUserId local/ - IndexedDB constants, local-db.ts (prisma-idb wrapper), shared local row types + IndexedDB constants, local-client.ts (prisma-idb wrapper), shared local row types queue/ pending-ops.ts # PendingOp storage in IndexedDB sync-runner.ts # syncOps() / forceSyncOp() - drains the queue @@ -158,10 +158,10 @@ Reach for `requireUserId()` whenever a write absolutely requires a logged-in use DAL actions split by scope: -| Scope | Location | -|---|---| +| Scope | Location | +|--------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| | Cross-game (e.g. `favoriteGames`, `userProfile`) | `src/features//dal//` - currently all under `src/features/auth/dal/` since both belong to the authenticated-user surface | -| Game-specific (e.g. `collectedItems`) | `src/games//dal/` | +| Game-specific (e.g. `collectedItems`) | `src/games//dal/` | Within each cross-game DAL folder, files follow a consistent suffix convention: @@ -203,4 +203,4 @@ The shortest path: ## Related docs -- [Architecture](../documentation/ARCHITECTURE.md) - the game registry and why the feature/game split matters for the DAL. +- [Architecture](ARCHITECTURE.md) – the game registry and why the feature/game split matters for the DAL. diff --git a/documentation/THEMES.md b/documentation/THEMES.md index 7bae302..8bdf5cb 100644 --- a/documentation/THEMES.md +++ b/documentation/THEMES.md @@ -67,7 +67,7 @@ const THEME: ToolkitThemeDefinition = { export { THEME }; ``` -That definition gets attached to the game's `GAME_CONFIG.THEME` in `src/games//core/game-config/index.ts`. +That definition gets attached to the game's `GAME_CONFIG.THEME` in `src/games//core/game-config/client.ts`. ### Why `generateThemeColors()`? @@ -101,7 +101,7 @@ Most contributors only need to do this when adding a new game. The full path: 2. **Write `src/games//core/game-config/theme.ts`** following the shape above. -3. **Attach it to the game config** in `src/games//core/game-config/index.ts`: +3. **Attach it to the game config** in `src/games//core/game-config/client.ts`: ```typescript import { THEME } from "./theme"; diff --git a/gulpfile.js b/gulpfile.js index 8925ab8..1e7f7ce 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -6,7 +6,7 @@ import sharp from "sharp"; import FAVICON_REGISTRY from "./src/features/game/registry/favicon-registry.json" with { type: "json", }; -import IMAGE_SIZES from "#/features/game/core/image-sizes.json" with { +import IMAGE_SIZES from "#/features/game/image-sizes.json" with { type: "json", }; diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 9bad417..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "version": 1, - "skills": { - "better-auth-best-practices": { - "source": "better-auth/skills", - "sourceType": "github", - "computedHash": "9ab075b5061be2a5f299c10505667345cc1ec76e8de4120901cfd586643e776f" - }, - "mantine-custom-components": { - "source": "mantinedev/skills", - "sourceType": "github", - "computedHash": "f61c7368715ee81aeb1bf849041e833551ba376d501105ded998553dfaa3e904" - }, - "tanstack-form": { - "source": "tanstack-skills/tanstack-skills", - "sourceType": "github", - "computedHash": "6d00fa22960ef5c45d14906f8f05d91870de658effb8c76bacc69d60a1000098" - }, - "tanstack-query": { - "source": "tanstack-skills/tanstack-skills", - "sourceType": "github", - "computedHash": "bfe2977e92667b84efeb4b12e5c3010807934aab2f7ac2b059241cd3da2a3e6e" - }, - "tanstack-router": { - "source": "tanstack-skills/tanstack-skills", - "sourceType": "github", - "computedHash": "6aba9066ca12bf6bb6eebec9c58f5d4e33b6b06e2b11bf263f5b7a5c18edc389" - }, - "tanstack-start": { - "source": "tanstack-skills/tanstack-skills", - "sourceType": "github", - "computedHash": "31690f8eb3a24feb093bfd14d673f0519078cdcd297f79fc2bbb584b4daa4db1" - }, - "tanstack-store": { - "source": "tanstack-skills/tanstack-skills", - "sourceType": "github", - "computedHash": "a82bb1763290dfdab49c2b506a85da42d4030cfad502ef5c965c7440bfdefa6c" - }, - "tanstack-table": { - "source": "tanstack-skills/tanstack-skills", - "sourceType": "github", - "computedHash": "5527e42e541ac26fc1157e49d58c399b95e44eb276ef3caa7576ab138558d593" - }, - "tanstack-virtual": { - "source": "tanstack-skills/tanstack-skills", - "sourceType": "github", - "computedHash": "3696d2db04a03373dfdab1f8d5c2249989c6d8c299bb02c4491b25d2b446e51c" - }, - "web-design-guidelines": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "computedHash": "f3bc47f890f42a44db1007ab390709ec368e4b8c089baee6b0007182236ac474" - } - } -} diff --git a/src/components/AppImage.tsx b/src/components/AppImage.tsx index 6351552..74cb590 100644 --- a/src/components/AppImage.tsx +++ b/src/components/AppImage.tsx @@ -3,7 +3,7 @@ import { type ImageProps as MantineImageProps, } from "@mantine/core"; import { clientEnv } from "#/env/client-env.ts"; -import IMAGE_SIZES from "#/features/game/core/image-sizes.json"; +import IMAGE_SIZES from "#/features/game/image-sizes.json"; type SizePreset = keyof typeof IMAGE_SIZES; diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index e0ff1e6..b8e608a 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -1,7 +1,7 @@ import { NuqsAdapter } from "nuqs/adapters/tanstack-router"; import type { PropsWithChildren } from "react"; -import { ScreenshotPreviewProvider } from "#/features/screenshot/core/ScreenshotPreviewProvider"; -import { MantineProviderWithTheme } from "#/features/theme/core/MantineProviderWithTheme"; +import { ScreenshotPreviewProvider } from "#/features/screenshot/ScreenshotPreviewProvider.tsx"; +import { MantineProviderWithTheme } from "#/features/theme/MantineProviderWithTheme.tsx"; const AppProviders = ({ children }: PropsWithChildren) => { return ( diff --git a/src/components/GameImage.tsx b/src/components/GameImage.tsx index e7ab5c8..180f709 100644 --- a/src/components/GameImage.tsx +++ b/src/components/GameImage.tsx @@ -1,5 +1,5 @@ import { AppImage, type AppImageProps } from "#/components/AppImage"; -import { useGameId } from "#/features/game/core/use-game-id"; +import { useGameId } from "#/features/game/use-game-id.ts"; type GameImageProps = AppImageProps & {}; diff --git a/src/components/RootDocument.tsx b/src/components/RootDocument.tsx index fc68b0b..45774fb 100644 --- a/src/components/RootDocument.tsx +++ b/src/components/RootDocument.tsx @@ -20,7 +20,7 @@ import { SocialMedia } from "#/components/SocialMedia.tsx"; import { GettingStartedWizard } from "#/components/wizards/getting-started/components/GettingStartedWizard.tsx"; import { useGettingStartedWizard } from "#/components/wizards/getting-started/hooks/use-getting-started-wizard.ts"; import { clientEnv } from "#/env/client-env.ts"; -import { GameSwitcher } from "#/features/game/core/GameSwitcher.tsx"; +import { GameSwitcher } from "#/features/game/GameSwitcher.tsx"; import classes from "./RootDocument.module.css"; export const RootDocument = ({ children }: PropsWithChildren) => { diff --git a/src/components/navigation/AppNavbar.tsx b/src/components/navigation/AppNavbar.tsx index 3d90702..e177dbc 100644 --- a/src/components/navigation/AppNavbar.tsx +++ b/src/components/navigation/AppNavbar.tsx @@ -2,9 +2,9 @@ import { Flex, ScrollArea } from "@mantine/core"; import { ClientOnly } from "@tanstack/react-router"; import { getNavLinks } from "#/components/navigation/get-nav-links"; import { NavbarLinksGroup } from "#/components/navigation/NavbarLinksGroup"; -import { UserMenu } from "#/features/auth/core/UserMenu"; -import { useGameId } from "#/features/game/core/use-game-id"; -import { ChangeThemeButton } from "#/features/theme/core/ChangeThemeButton"; +import { UserMenu } from "#/features/auth/UserMenu.tsx"; +import { useGameId } from "#/features/game/use-game-id.ts"; +import { ChangeThemeButton } from "#/features/theme/ChangeThemeButton.tsx"; import classes from "./AppNavbar.module.css"; type AppNavbarProps = { diff --git a/src/components/wizards/getting-started/components/GettingStartedWizard.tsx b/src/components/wizards/getting-started/components/GettingStartedWizard.tsx index c271d89..f46558f 100644 --- a/src/components/wizards/getting-started/components/GettingStartedWizard.tsx +++ b/src/components/wizards/getting-started/components/GettingStartedWizard.tsx @@ -1,7 +1,7 @@ import { useMediaQuery } from "@mantine/hooks"; import { LOCALSTORAGE_KEY_PREFIX } from "#/components/wizards/getting-started/constants/localstorage-keys"; import { GETTING_STARTED_STEPS } from "#/components/wizards/getting-started/constants/steps"; -import { useGameId } from "#/features/game/core/use-game-id"; +import { useGameId } from "#/features/game/use-game-id.ts"; import { Wizard } from "#/features/wizard/Wizard"; type GettingStartedWizardProps = { diff --git a/src/features/auth/core/AvatarPicker.module.css b/src/features/auth/AvatarPicker.module.css similarity index 100% rename from src/features/auth/core/AvatarPicker.module.css rename to src/features/auth/AvatarPicker.module.css diff --git a/src/features/auth/core/AvatarPicker.tsx b/src/features/auth/AvatarPicker.tsx similarity index 97% rename from src/features/auth/core/AvatarPicker.tsx rename to src/features/auth/AvatarPicker.tsx index e683edb..03c5c6f 100644 --- a/src/features/auth/core/AvatarPicker.tsx +++ b/src/features/auth/AvatarPicker.tsx @@ -18,18 +18,18 @@ import { import { useDisclosure } from "@mantine/hooks"; import { useState } from "react"; import { LuCheck, LuChevronDown, LuSearch, LuX } from "react-icons/lu"; -import { avatarImageUrl } from "#/features/auth/core/utils"; -import { useUserProfile } from "#/features/auth/hooks/use-user-profile"; -import { useDalQuery } from "#/features/dal/hooks/use-dal-query"; -import type { GameAvatar } from "#/features/game/core/types"; -import { useGameId } from "#/features/game/core/use-game-id"; +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 { getGameAvatars, getGameConfig, getGameLogoComponent, REGISTERED_GAME_IDS, -} from "#/features/game/registry/game-registry"; +} from "#/features/game/registry/game-registry.tsx"; +import type { GameAvatar } from "#/features/game/types.ts"; +import { useGameId } from "#/features/game/use-game-id.ts"; import type { GameId } from "@/prisma"; import classes from "./AvatarPicker.module.css"; diff --git a/src/features/auth/core/ProfileEditForm.tsx b/src/features/auth/ProfileEditForm.tsx similarity index 97% rename from src/features/auth/core/ProfileEditForm.tsx rename to src/features/auth/ProfileEditForm.tsx index 22b3186..c33f9be 100644 --- a/src/features/auth/core/ProfileEditForm.tsx +++ b/src/features/auth/ProfileEditForm.tsx @@ -2,7 +2,7 @@ import { Button, Group, Stack, Text, Textarea, TextInput } from "@mantine/core"; import { modals } from "@mantine/modals"; import { useForm } from "@tanstack/react-form"; import { useState } from "react"; -import { useDalMutation } from "#/features/dal/hooks/use-dal-mutation"; +import { useDalMutation } from "#/features/dal/use-dal-mutation.ts"; import { createUserProfileDal } from "#/features/game/dal/user-profile/user-profile.dal.ts"; type ProfileEditFormProps = { diff --git a/src/features/auth/core/ProfileHeader.module.css b/src/features/auth/ProfileHeader.module.css similarity index 100% rename from src/features/auth/core/ProfileHeader.module.css rename to src/features/auth/ProfileHeader.module.css diff --git a/src/features/auth/core/ProfileHeader.tsx b/src/features/auth/ProfileHeader.tsx similarity index 90% rename from src/features/auth/core/ProfileHeader.tsx rename to src/features/auth/ProfileHeader.tsx index a7112bb..86644a6 100644 --- a/src/features/auth/core/ProfileHeader.tsx +++ b/src/features/auth/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/core/AvatarPicker"; -import { ProfileEditForm } from "#/features/auth/core/ProfileEditForm"; -import { useResolvedAvatar } from "#/features/auth/hooks/use-resolved-avatar"; -import { useUserProfile } from "#/features/auth/hooks/use-user-profile"; +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 classes from "./ProfileHeader.module.css"; type ProfileHeaderProps = { diff --git a/src/features/auth/core/ProfileTabNav.tsx b/src/features/auth/ProfileTabNav.tsx similarity index 100% rename from src/features/auth/core/ProfileTabNav.tsx rename to src/features/auth/ProfileTabNav.tsx diff --git a/src/features/auth/core/UserMenu.module.css b/src/features/auth/UserMenu.module.css similarity index 100% rename from src/features/auth/core/UserMenu.module.css rename to src/features/auth/UserMenu.module.css diff --git a/src/features/auth/core/UserMenu.tsx b/src/features/auth/UserMenu.tsx similarity index 94% rename from src/features/auth/core/UserMenu.tsx rename to src/features/auth/UserMenu.tsx index ce9cd4d..3a92841 100644 --- a/src/features/auth/core/UserMenu.tsx +++ b/src/features/auth/UserMenu.tsx @@ -21,10 +21,10 @@ import { LuSettings, LuStar, } from "react-icons/lu"; -import { AvatarPicker } from "#/features/auth/core/AvatarPicker"; -import { useResolvedAvatar } from "#/features/auth/hooks/use-resolved-avatar"; -import { useUserProfile } from "#/features/auth/hooks/use-user-profile"; -import { signOut } from "#/integrations/better-auth/auth-client"; +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 { signOut } from "#/integrations/better-auth/auth-client.ts"; import classes from "./UserMenu.module.css"; export function UserMenu() { diff --git a/src/features/auth/core/profile-tab-head.ts b/src/features/auth/profile-tab-head.ts similarity index 100% rename from src/features/auth/core/profile-tab-head.ts rename to src/features/auth/profile-tab-head.ts diff --git a/src/features/auth/dal/require-user.server.ts b/src/features/auth/require-user.server.ts similarity index 90% rename from src/features/auth/dal/require-user.server.ts rename to src/features/auth/require-user.server.ts index 6a66a43..a286b86 100644 --- a/src/features/auth/dal/require-user.server.ts +++ b/src/features/auth/require-user.server.ts @@ -1,5 +1,5 @@ import { getRequest } from "@tanstack/react-start/server"; -import { auth } from "#/integrations/better-auth/auth"; +import { auth } from "#/integrations/better-auth/auth.ts"; const requireUserId = async (): Promise => { const request = getRequest(); diff --git a/src/features/auth/hooks/use-resolved-avatar.ts b/src/features/auth/use-resolved-avatar.ts similarity index 80% rename from src/features/auth/hooks/use-resolved-avatar.ts rename to src/features/auth/use-resolved-avatar.ts index 487288e..907728d 100644 --- a/src/features/auth/hooks/use-resolved-avatar.ts +++ b/src/features/auth/use-resolved-avatar.ts @@ -1,7 +1,7 @@ -import { resolveAvatar } from "#/features/auth/core/utils"; -import { useDalQuery } from "#/features/dal/hooks/use-dal-query"; -import { useGameId } from "#/features/game/core/use-game-id"; +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 { useGameId } from "#/features/game/use-game-id.ts"; type UseResolvedAvatarArgs = { userId?: string } | undefined; diff --git a/src/features/auth/hooks/use-user-profile.ts b/src/features/auth/use-user-profile.ts similarity index 91% rename from src/features/auth/hooks/use-user-profile.ts rename to src/features/auth/use-user-profile.ts index 5039590..e508291 100644 --- a/src/features/auth/hooks/use-user-profile.ts +++ b/src/features/auth/use-user-profile.ts @@ -1,7 +1,7 @@ -import { useDalMutation } from "#/features/dal/hooks/use-dal-mutation"; -import { useDalQuery } from "#/features/dal/hooks/use-dal-query"; +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 { useSession } from "#/integrations/better-auth/auth-client"; +import { useSession } from "#/integrations/better-auth/auth-client.ts"; import type { GameId } from "@/prisma"; type UseUserProfileArgs = { userId?: string } | undefined; diff --git a/src/features/auth/core/utils.ts b/src/features/auth/utils.ts similarity index 99% rename from src/features/auth/core/utils.ts rename to src/features/auth/utils.ts index 846c15e..bd420ca 100644 --- a/src/features/auth/core/utils.ts +++ b/src/features/auth/utils.ts @@ -1,5 +1,5 @@ import { clientEnv } from "#/env/client-env.ts"; -import { getGameAvatars } from "#/features/game/registry/game-registry"; +import { getGameAvatars } from "#/features/game/registry/game-registry.tsx"; import type { GameId } from "@/prisma"; type ResolveAvatarParams = { diff --git a/src/features/dal/core/__tests__/choose-backend.test.ts b/src/features/dal/__tests__/choose-backend.test.ts similarity index 88% rename from src/features/dal/core/__tests__/choose-backend.test.ts rename to src/features/dal/__tests__/choose-backend.test.ts index b63359b..c94e16a 100644 --- a/src/features/dal/core/__tests__/choose-backend.test.ts +++ b/src/features/dal/__tests__/choose-backend.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chooseBackend } from "#/features/dal/core/choose-backend"; +import { chooseBackend } from "#/features/dal/choose-backend.ts"; describe("chooseBackend", () => { it.each([ diff --git a/src/features/dal/core/choose-backend.ts b/src/features/dal/choose-backend.ts similarity index 91% rename from src/features/dal/core/choose-backend.ts rename to src/features/dal/choose-backend.ts index 7662787..d62c835 100644 --- a/src/features/dal/core/choose-backend.ts +++ b/src/features/dal/choose-backend.ts @@ -1,7 +1,7 @@ // 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/core/types"; +import type { Backend } from "#/features/dal/types.ts"; interface DispatchInput { /** Whether the user has an active authenticated session. */ diff --git a/src/features/dal/core/define-action.ts b/src/features/dal/define-action.ts similarity index 98% rename from src/features/dal/core/define-action.ts rename to src/features/dal/define-action.ts index dac0a39..64d6734 100644 --- a/src/features/dal/core/define-action.ts +++ b/src/features/dal/define-action.ts @@ -2,7 +2,7 @@ // 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/core/types"; +import type { DalReadAction, DalWriteAction } from "#/features/dal/types.ts"; /** Creates a DalReadAction with the `kind: "read"` discriminator. */ const defineDalRead = ( diff --git a/src/features/dal/core/presence-sync-handler.ts b/src/features/dal/presence-sync-handler.ts similarity index 97% rename from src/features/dal/core/presence-sync-handler.ts rename to src/features/dal/presence-sync-handler.ts index 6573532..71a49f0 100644 --- a/src/features/dal/core/presence-sync-handler.ts +++ b/src/features/dal/presence-sync-handler.ts @@ -4,12 +4,12 @@ // conflict resolution live here once, so individual entities only supply how to // extract their key from the op payload and how to read/create/delete the row. -import type { SyncHandler } from "#/features/dal/core/types.ts"; 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"; /** Result of pulling a record key out of an op payload. */ type KeyResolution = diff --git a/src/features/dal/queue/apply-pending-ops.ts b/src/features/dal/queue/apply-pending-ops.ts index 499b6f2..0365a99 100644 --- a/src/features/dal/queue/apply-pending-ops.ts +++ b/src/features/dal/queue/apply-pending-ops.ts @@ -2,8 +2,8 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireUserId } from "#/features/auth/dal/require-user.server.ts"; -import type { SyncHandler, SyncResult } from "#/features/dal/core/types.ts"; +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, diff --git a/src/features/dal/queue/sync-runner.ts b/src/features/dal/queue/sync-runner.ts index 84b6029..e9c7857 100644 --- a/src/features/dal/queue/sync-runner.ts +++ b/src/features/dal/queue/sync-runner.ts @@ -1,6 +1,5 @@ // Orchestrates syncing a batch of pending ops to the server sequentially. -import type { SyncResult } from "#/features/dal/core/types"; import { applyPendingOpServerFn } from "#/features/dal/queue/apply-pending-ops.ts"; import { deleteOp, @@ -8,6 +7,7 @@ import { markStatus, } from "#/features/dal/queue/pending-ops"; import type { PendingOp } from "#/features/dal/queue/types"; +import type { SyncResult } from "#/features/dal/types.ts"; interface SyncAllOptions { /** Called before each op is processed — use to drive UI progress indicators. */ diff --git a/src/features/dal/core/to-query-options.ts b/src/features/dal/to-query-options.ts similarity index 87% rename from src/features/dal/core/to-query-options.ts rename to src/features/dal/to-query-options.ts index af048de..f21af9d 100644 --- a/src/features/dal/core/to-query-options.ts +++ b/src/features/dal/to-query-options.ts @@ -1,8 +1,8 @@ // Bridges DalReadAction to TanStack Query's queryOptions format. import { queryOptions } from "@tanstack/react-query"; -import type { DalReadAction } from "#/features/dal/core/types"; -import type { DalContextGetter } from "#/features/dal/hooks/use-dal-context-source"; +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. diff --git a/src/features/dal/core/types.ts b/src/features/dal/types.ts similarity index 99% rename from src/features/dal/core/types.ts rename to src/features/dal/types.ts index 6a928a5..87b6fa6 100644 --- a/src/features/dal/core/types.ts +++ b/src/features/dal/types.ts @@ -5,7 +5,7 @@ import type { PendingOp, PendingOpOperation, PendingOpSummary, -} from "#/features/dal/queue/types"; +} from "#/features/dal/queue/types.ts"; /** "remote" when the user is authenticated and online; "local" otherwise. */ type Backend = "remote" | "local"; diff --git a/src/features/dal/hooks/use-backend.ts b/src/features/dal/use-backend.ts similarity index 75% rename from src/features/dal/hooks/use-backend.ts rename to src/features/dal/use-backend.ts index 79455ec..a660fbc 100644 --- a/src/features/dal/hooks/use-backend.ts +++ b/src/features/dal/use-backend.ts @@ -1,7 +1,7 @@ import { useNetwork } from "@mantine/hooks"; -import { chooseBackend } from "#/features/dal/core/choose-backend"; -import type { Backend } from "#/features/dal/core/types"; -import { useSession } from "#/integrations/better-auth/auth-client"; +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 => { diff --git a/src/features/dal/hooks/use-dal-context-source.ts b/src/features/dal/use-dal-context-source.ts similarity index 87% rename from src/features/dal/hooks/use-dal-context-source.ts rename to src/features/dal/use-dal-context-source.ts index ad65913..e30b7e8 100644 --- a/src/features/dal/hooks/use-dal-context-source.ts +++ b/src/features/dal/use-dal-context-source.ts @@ -4,10 +4,10 @@ import { useNetwork } from "@mantine/hooks"; import { useRef } from "react"; -import { chooseBackend } from "#/features/dal/core/choose-backend"; -import type { DalContext } from "#/features/dal/core/types"; -import { getOrCreateAnonUserId } from "#/features/dal/identity/anon-id"; -import { useSession } from "#/integrations/better-auth/auth-client"; +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; @@ -39,4 +39,4 @@ const useDalContextSource = (): DalContextGetter => { return () => sourceRef.current; }; -export { useDalContextSource, type DalContextGetter }; +export { type DalContextGetter, useDalContextSource }; diff --git a/src/features/dal/hooks/use-dal-mutation.ts b/src/features/dal/use-dal-mutation.ts similarity index 94% rename from src/features/dal/hooks/use-dal-mutation.ts rename to src/features/dal/use-dal-mutation.ts index 52c08e3..841b305 100644 --- a/src/features/dal/hooks/use-dal-mutation.ts +++ b/src/features/dal/use-dal-mutation.ts @@ -1,9 +1,9 @@ // React hook wrapping useMutation with DAL backend dispatch and op enqueueing. import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { DalContext, DalWriteAction } from "#/features/dal/core/types"; -import { useDalContextSource } from "#/features/dal/hooks/use-dal-context-source"; -import { enqueueOp } from "#/features/dal/queue/pending-ops"; +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 { diff --git a/src/features/dal/hooks/use-dal-query.ts b/src/features/dal/use-dal-query.ts similarity index 77% rename from src/features/dal/hooks/use-dal-query.ts rename to src/features/dal/use-dal-query.ts index 37251a9..d480442 100644 --- a/src/features/dal/hooks/use-dal-query.ts +++ b/src/features/dal/use-dal-query.ts @@ -1,7 +1,7 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; -import { toQueryOptions } from "#/features/dal/core/to-query-options"; -import type { DalReadAction } from "#/features/dal/core/types"; -import { useDalContextSource } from "#/features/dal/hooks/use-dal-context-source"; +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. diff --git a/src/db.ts b/src/features/db/client.ts similarity index 100% rename from src/db.ts rename to src/features/db/client.ts diff --git a/src/features/game/core/GameSwitcher.module.css b/src/features/game/GameSwitcher.module.css similarity index 100% rename from src/features/game/core/GameSwitcher.module.css rename to src/features/game/GameSwitcher.module.css diff --git a/src/features/game/core/GameSwitcher.tsx b/src/features/game/GameSwitcher.tsx similarity index 94% rename from src/features/game/core/GameSwitcher.tsx rename to src/features/game/GameSwitcher.tsx index 27330b7..ae84d83 100644 --- a/src/features/game/core/GameSwitcher.tsx +++ b/src/features/game/GameSwitcher.tsx @@ -21,18 +21,18 @@ import { LuUser, } from "react-icons/lu"; -import { DefaultLogo } from "#/components/AppLogo"; -import { useDalMutation } from "#/features/dal/hooks/use-dal-mutation"; -import { useDalQuery } from "#/features/dal/hooks/use-dal-query"; -import { setGame } from "#/features/game/core/store"; -import { useGameId } from "#/features/game/core/use-game-id"; -import { setActiveGameCookie } from "#/features/game/core/utils"; +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, getGameLogoComponent, REGISTERED_GAME_IDS, -} from "#/features/game/registry/game-registry"; +} from "#/features/game/registry/game-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"; import type { GameId } from "@/prisma"; import classes from "./GameSwitcher.module.css"; diff --git a/src/features/game/core/SyncFavicon.tsx b/src/features/game/SyncFavicon.tsx similarity index 89% rename from src/features/game/core/SyncFavicon.tsx rename to src/features/game/SyncFavicon.tsx index 40e1db9..bee5799 100644 --- a/src/features/game/core/SyncFavicon.tsx +++ b/src/features/game/SyncFavicon.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import { useGameId } from "#/features/game/core/use-game-id"; -import { isRegisteredGameId } from "#/features/game/registry/game-registry"; +import { isRegisteredGameId } from "#/features/game/registry/game-registry.tsx"; +import { useGameId } from "#/features/game/use-game-id.ts"; const FAVICON_BASE_PATH = "/favicons/"; diff --git a/src/features/game/dal/active-game.ts b/src/features/game/dal/active-game.ts index d27fa4e..67a03cf 100644 --- a/src/features/game/dal/active-game.ts +++ b/src/features/game/dal/active-game.ts @@ -1,7 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; -import { parseCookie, parseSubdomain } from "#/features/game/core/utils"; import { getValidatedGameId } from "#/features/game/registry/game-registry"; +import { parseCookie, parseSubdomain } from "#/features/game/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 index 0e0dc57..077209d 100644 --- a/src/features/game/dal/collected-items/collected-items.dal.ts +++ b/src/features/game/dal/collected-items/collected-items.dal.ts @@ -1,11 +1,8 @@ // 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/core/define-action"; -import type { DalContext } from "#/features/dal/core/types"; +import { defineDalRead, defineDalWrite } from "#/features/dal/define-action.ts"; +import type { DalContext } from "#/features/dal/types.ts"; import { type CollectedItemIDBDelegate, createCollectedItemsIdb, diff --git a/src/features/game/dal/collected-items/sync-handler.ts b/src/features/game/dal/collected-items/sync-handler.ts index a94a01b..204c84e 100644 --- a/src/features/game/dal/collected-items/sync-handler.ts +++ b/src/features/game/dal/collected-items/sync-handler.ts @@ -1,9 +1,9 @@ // SyncHandler for collected items — a presence-toggle entity (collected or not). // Delegates the delete/upsert + LWW branching to createPresenceToggleSyncHandler. -import { createPresenceToggleSyncHandler } from "#/features/dal/core/presence-sync-handler.ts"; -import type { SyncHandler } from "#/features/dal/core/types.ts"; +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. diff --git a/src/features/game/dal/favorite-games/favorite-games.dal.ts b/src/features/game/dal/favorite-games/favorite-games.dal.ts index cb2792b..7b89c5d 100644 --- a/src/features/game/dal/favorite-games/favorite-games.dal.ts +++ b/src/features/game/dal/favorite-games/favorite-games.dal.ts @@ -1,9 +1,6 @@ -import { - defineDalRead, - defineDalWrite, -} from "#/features/dal/core/define-action.ts"; -import type { DalContext } from "#/features/dal/core/types.ts"; +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, diff --git a/src/features/game/dal/favorite-games/favorite-games.ts b/src/features/game/dal/favorite-games/favorite-games.ts index a7ab8c2..053e737 100644 --- a/src/features/game/dal/favorite-games/favorite-games.ts +++ b/src/features/game/dal/favorite-games/favorite-games.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireUserId } from "#/features/auth/dal/require-user.server.ts"; +import { requireUserId } from "#/features/auth/require-user.server.ts"; import { GameId, prisma } from "@/prisma"; const FavoriteInput = z.object({ gameId: z.enum(GameId) }); diff --git a/src/features/game/dal/favorite-games/sync-handler.ts b/src/features/game/dal/favorite-games/sync-handler.ts index ff4e845..cdab9b4 100644 --- a/src/features/game/dal/favorite-games/sync-handler.ts +++ b/src/features/game/dal/favorite-games/sync-handler.ts @@ -1,4 +1,4 @@ -import { createPresenceToggleSyncHandler } from "#/features/dal/core/presence-sync-handler.ts"; +import { createPresenceToggleSyncHandler } from "#/features/dal/presence-sync-handler.ts"; import { GameId, prisma } from "@/prisma"; const favoriteGameSyncHandler = createPresenceToggleSyncHandler({ diff --git a/src/features/game/dal/user-profile/sync-handler.ts b/src/features/game/dal/user-profile/sync-handler.ts index b554168..a773410 100644 --- a/src/features/game/dal/user-profile/sync-handler.ts +++ b/src/features/game/dal/user-profile/sync-handler.ts @@ -1,4 +1,4 @@ -import type { SyncHandler } from "#/features/dal/core/types.ts"; +import type { SyncHandler } from "#/features/dal/types.ts"; import { GameId, prisma } from "@/prisma"; const userProfileHandler: SyncHandler = async (op, userId) => { diff --git a/src/features/game/dal/user-profile/user-profile.dal.ts b/src/features/game/dal/user-profile/user-profile.dal.ts index 6d42238..3b96608 100644 --- a/src/features/game/dal/user-profile/user-profile.dal.ts +++ b/src/features/game/dal/user-profile/user-profile.dal.ts @@ -1,14 +1,11 @@ import { createServerFn } from "@tanstack/react-start"; -import { prisma } from "#/db.ts"; import { getOptionalUserId, requireUserId, -} from "#/features/auth/dal/require-user.server.ts"; -import { - defineDalRead, - defineDalWrite, -} from "#/features/dal/core/define-action.ts"; -import type { DalContext } from "#/features/dal/core/types.ts"; +} 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, diff --git a/src/features/game/dal/user-profile/user-profile.ts b/src/features/game/dal/user-profile/user-profile.ts index be7c5b7..532ab88 100644 --- a/src/features/game/dal/user-profile/user-profile.ts +++ b/src/features/game/dal/user-profile/user-profile.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireUserId } from "#/features/auth/dal/require-user.server.ts"; +import { requireUserId } from "#/features/auth/require-user.server.ts"; import { getGameAvatars } from "#/features/game/registry/game-registry.tsx"; import { GameId, prisma } from "@/prisma"; diff --git a/src/features/game/core/image-sizes.json b/src/features/game/image-sizes.json similarity index 100% rename from src/features/game/core/image-sizes.json rename to src/features/game/image-sizes.json diff --git a/src/features/game/items/ItemInfoModal.tsx b/src/features/game/items/ItemInfoModal.tsx index 3201360..36b744c 100644 --- a/src/features/game/items/ItemInfoModal.tsx +++ b/src/features/game/items/ItemInfoModal.tsx @@ -15,15 +15,15 @@ import { import { useEffect, useRef, useState } from "react"; import { LuCamera, LuCheck, LuPlus } from "react-icons/lu"; import { GameImage } from "#/components/GameImage"; -import { useGameId } from "#/features/game/core/use-game-id"; import { ItemDescription } from "#/features/game/items/ItemDescription"; import type { AppItem, CollectItemInput } from "#/features/game/items/types"; import { getGameMetadata } from "#/features/game/registry/game-registry"; +import { useGameId } from "#/features/game/use-game-id.ts"; import { ScreenshotContainer, type WatermarkConfig, -} from "#/features/screenshot/core/ScreenshotContainer"; -import { useScreenshot } from "#/features/screenshot/hooks/use-screenshot"; +} from "#/features/screenshot/ScreenshotContainer.tsx"; +import { useScreenshot } from "#/features/screenshot/use-screenshot.ts"; type ItemInfoModalProps = { item: AppItem; diff --git a/src/features/game/items/SearchItemInput.tsx b/src/features/game/items/SearchItemInput.tsx index 0813253..f761e0d 100644 --- a/src/features/game/items/SearchItemInput.tsx +++ b/src/features/game/items/SearchItemInput.tsx @@ -1,7 +1,7 @@ import { Select } from "@mantine/core"; -import { useGameId } from "#/features/game/core/use-game-id"; -import { getGameItems } from "#/features/game/registry/game-registry"; import { useRef, useState } from "react"; +import { getGameItems } from "#/features/game/registry/game-registry"; +import { useGameId } from "#/features/game/use-game-id.ts"; type SearchItemInputProps = { searchValue: string; diff --git a/src/features/game/items/ShareCollectionButton.tsx b/src/features/game/items/ShareCollectionButton.tsx index 76933b1..ee11201 100644 --- a/src/features/game/items/ShareCollectionButton.tsx +++ b/src/features/game/items/ShareCollectionButton.tsx @@ -3,7 +3,7 @@ import { notifications } from "@mantine/notifications"; import { ClientOnly, useRouterState } from "@tanstack/react-router"; import { LuShare2 } from "react-icons/lu"; import { clientEnv } from "#/env/client-env.ts"; -import { useGameId } from "#/features/game/core/use-game-id"; +import { useGameId } from "#/features/game/use-game-id.ts"; const ShareCollectionButton = () => { const activeGameId = useGameId(); diff --git a/src/features/game/items/types.ts b/src/features/game/items/types.ts index db48f69..116749f 100644 --- a/src/features/game/items/types.ts +++ b/src/features/game/items/types.ts @@ -1,6 +1,6 @@ import type { SingleParserBuilder } from "nuqs"; import type { ReactNode } from "react"; -import type { DalReadAction, DalWriteAction } from "#/features/dal/core/types"; +import type { DalReadAction, DalWriteAction } from "#/features/dal/types.ts"; /** * Shared item definition across the application diff --git a/src/features/game/items/use-collected-items.ts b/src/features/game/items/use-collected-items.ts index 111fc9c..c2c0140 100644 --- a/src/features/game/items/use-collected-items.ts +++ b/src/features/game/items/use-collected-items.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { useDalMutation } from "#/features/dal/hooks/use-dal-mutation"; -import { useDalQuery } from "#/features/dal/hooks/use-dal-query"; +import { useDalMutation } from "#/features/dal/use-dal-mutation.ts"; +import { useDalQuery } from "#/features/dal/use-dal-query.ts"; import type { CollectedItemsViewMode, CollectItemInput, @@ -51,5 +51,5 @@ const useCollectedItems = ({ return { collectedIds, isPublicView, handleCollect, handleUncollect }; }; -export { useCollectedItems }; export type { UseCollectedItemsArgs, UseCollectedItemsResult }; +export { useCollectedItems }; diff --git a/src/features/game/items/use-item-filters.ts b/src/features/game/items/use-item-filters.ts index a5d0a8e..22d51a9 100644 --- a/src/features/game/items/use-item-filters.ts +++ b/src/features/game/items/use-item-filters.ts @@ -5,11 +5,11 @@ import type { AppItem, GameFilterConfig } from "#/features/game/items/types"; import type { AnyGameConfig } from "#/features/game/registry/game-registry"; import { dimUncollectedItemsParser, - searchParser, showCollectableOnlyParser, showCollectedItemsParser, showUncollectedItemsParser, -} from "#/search-params"; +} from "#/features/nuqs/parsers/item-collection.ts"; +import { searchParser } from "#/features/nuqs/parsers/search.ts"; const itemLookupParsers = { search: searchParser, @@ -19,9 +19,9 @@ const itemLookupParsers = { showCollectableOnly: showCollectableOnlyParser, }; -// Hide uncollected items by default on the Profile collected items tab const collectedItemsTabParsers = { ...itemLookupParsers, + // Hide uncollected items by default on the Profile collected items tab showUncollectedItems: parseAsBoolean.withDefault(false).withOptions({ shallow: true, clearOnDefault: true, @@ -258,5 +258,5 @@ const useItemFilters = ({ }; }; +export type { UniversalParamKey, UseItemFiltersArgs, UseItemFiltersResult }; export { useItemFilters }; -export type { UseItemFiltersArgs, UseItemFiltersResult, UniversalParamKey }; diff --git a/src/features/game/registry/game-db-seed-registry.ts b/src/features/game/registry/game-db-seed-registry.ts index 7f5b04e..13c11f4 100644 --- a/src/features/game/registry/game-db-seed-registry.ts +++ b/src/features/game/registry/game-db-seed-registry.ts @@ -1,4 +1,4 @@ -import type { GameDBSeed } from "#/features/game/core/types"; +import type { GameDBSeed } from "#/features/game/types.ts"; import { clairObscurDBSeed } from "#/games/clairobscur/core/game-config/db-seed"; import { remnant2DBSeed } from "#/games/remnant2/core/game-config/db-seed"; import { slayTheSpire2DBSeed } from "#/games/slaythespire2/core/game-config/db-seed"; diff --git a/src/features/game/registry/game-idb-seed-registry.ts b/src/features/game/registry/game-idb-seed-registry.ts index 6617a76..db6db90 100644 --- a/src/features/game/registry/game-idb-seed-registry.ts +++ b/src/features/game/registry/game-idb-seed-registry.ts @@ -1,4 +1,4 @@ -import type { GameIDBSeed } from "#/features/game/core/types"; +import type { GameIDBSeed } from "#/features/game/types.ts"; import { clairObscurIDBSeed } from "#/games/clairobscur/core/game-config/idb-seed"; import { remnant2IDBSeed } from "#/games/remnant2/core/game-config/idb-seed"; import { slayTheSpire2IDBSeed } from "#/games/slaythespire2/core/game-config/idb-seed"; diff --git a/src/features/game/registry/game-registry.tsx b/src/features/game/registry/game-registry.tsx index 55d26c9..7df2317 100644 --- a/src/features/game/registry/game-registry.tsx +++ b/src/features/game/registry/game-registry.tsx @@ -1,8 +1,8 @@ import type { ComponentType } from "react"; import type { LogoSize } from "#/components/AppLogo"; -import type { GameAvatar, GameConfig } from "#/features/game/core/types"; -import type { ToolkitThemeDefinition } from "#/features/theme/core/types"; +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"; diff --git a/src/features/game/registry/game-sync-handler-registry.ts b/src/features/game/registry/game-sync-handler-registry.ts index a0f0554..36d0198 100644 --- a/src/features/game/registry/game-sync-handler-registry.ts +++ b/src/features/game/registry/game-sync-handler-registry.ts @@ -1,4 +1,4 @@ -import type { SyncHandler } from "#/features/dal/core/types"; +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"; diff --git a/src/features/game/core/store.ts b/src/features/game/store.ts similarity index 100% rename from src/features/game/core/store.ts rename to src/features/game/store.ts diff --git a/src/features/game/core/types.ts b/src/features/game/types.ts similarity index 91% rename from src/features/game/core/types.ts rename to src/features/game/types.ts index a37b981..91c6ba7 100644 --- a/src/features/game/core/types.ts +++ b/src/features/game/types.ts @@ -1,12 +1,12 @@ import type { createSearchParamsCache } from "nuqs/server"; import type { ComponentType, ReactNode } from "react"; -import type { LogoSize } from "#/components/AppLogo"; +import type { LogoSize } from "#/components/AppLogo.tsx"; import type { AppItem, CollectedItemsViewMode, GameCollectedItemsDal, -} from "#/features/game/items/types"; -import type { ToolkitThemeDefinition } from "#/features/theme/core/types"; +} from "#/features/game/items/types.ts"; +import type { ToolkitThemeDefinition } from "#/features/theme/types.ts"; import type { GameId } from "@/prisma"; type GameAvatar = { @@ -70,11 +70,11 @@ type GameConfig< }; export type { - GameDBSeed, - GameIDBSeed, GameAvatar, GameConfig, GameDal, + GameDBSeed, + GameIDBSeed, GameMetadata, GamePages, }; diff --git a/src/features/game/core/use-game-id.ts b/src/features/game/use-game-id.ts similarity index 90% rename from src/features/game/core/use-game-id.ts rename to src/features/game/use-game-id.ts index acd2bbf..b8bc681 100644 --- a/src/features/game/core/use-game-id.ts +++ b/src/features/game/use-game-id.ts @@ -1,6 +1,6 @@ import { useRouteContext } from "@tanstack/react-router"; import { useSelector } from "@tanstack/react-store"; -import { gameStore } from "#/features/game/core/store"; +import { gameStore } from "#/features/game/store.ts"; import type { GameId } from "@/prisma"; /** diff --git a/src/features/game/core/utils.ts b/src/features/game/utils.ts similarity index 100% rename from src/features/game/core/utils.ts rename to src/features/game/utils.ts diff --git a/src/features/nuqs/parsers/item-collection.ts b/src/features/nuqs/parsers/item-collection.ts new file mode 100644 index 0000000..5f24947 --- /dev/null +++ b/src/features/nuqs/parsers/item-collection.ts @@ -0,0 +1,29 @@ +import { parseAsBoolean } from "nuqs/server"; + +export const dimUncollectedItemsParser = parseAsBoolean + .withDefault(false) + .withOptions({ + shallow: true, + clearOnDefault: true, + }); + +export const showUncollectedItemsParser = parseAsBoolean + .withDefault(true) + .withOptions({ + shallow: true, + clearOnDefault: true, + }); + +export const showCollectedItemsParser = parseAsBoolean + .withDefault(true) + .withOptions({ + shallow: true, + clearOnDefault: true, + }); + +export const showCollectableOnlyParser = parseAsBoolean + .withDefault(false) + .withOptions({ + shallow: true, + clearOnDefault: true, + }); diff --git a/src/features/nuqs/parsers/item-slug.ts b/src/features/nuqs/parsers/item-slug.ts new file mode 100644 index 0000000..f49e76e --- /dev/null +++ b/src/features/nuqs/parsers/item-slug.ts @@ -0,0 +1,6 @@ +import { parseAsString } from "nuqs/server"; + +export const itemSlugParser = parseAsString.withDefault("").withOptions({ + shallow: false, + clearOnDefault: true, +}); diff --git a/src/features/nuqs/parsers/pagination.ts b/src/features/nuqs/parsers/pagination.ts new file mode 100644 index 0000000..4459d90 --- /dev/null +++ b/src/features/nuqs/parsers/pagination.ts @@ -0,0 +1,11 @@ +import { parseAsInteger } from "nuqs/server"; + +export const paginationParser = { + page: parseAsInteger.withDefault(0), + size: parseAsInteger.withDefault(16), +}; + +export const paginationOptions = { + shallow: false, + clearOnDefault: true, +}; diff --git a/src/features/nuqs/parsers/search.ts b/src/features/nuqs/parsers/search.ts new file mode 100644 index 0000000..8d37a64 --- /dev/null +++ b/src/features/nuqs/parsers/search.ts @@ -0,0 +1,6 @@ +import { parseAsString } from "nuqs"; + +export const searchParser = parseAsString.withDefault("").withOptions({ + shallow: false, + clearOnDefault: true, +}); diff --git a/src/features/nuqs/parsers/sort.ts b/src/features/nuqs/parsers/sort.ts new file mode 100644 index 0000000..849d748 --- /dev/null +++ b/src/features/nuqs/parsers/sort.ts @@ -0,0 +1,11 @@ +import { parseAsString } from "nuqs"; + +export const sortParser = { + sortKey: parseAsString.withDefault("createdAt"), + sortValue: parseAsString.withDefault("desc"), +}; + +export const sortOptions = { + shallow: false, + clearOnDefault: true, +}; diff --git a/src/features/screenshot/core/ScreenshotContainer.module.css b/src/features/screenshot/ScreenshotContainer.module.css similarity index 100% rename from src/features/screenshot/core/ScreenshotContainer.module.css rename to src/features/screenshot/ScreenshotContainer.module.css diff --git a/src/features/screenshot/core/ScreenshotContainer.tsx b/src/features/screenshot/ScreenshotContainer.tsx similarity index 94% rename from src/features/screenshot/core/ScreenshotContainer.tsx rename to src/features/screenshot/ScreenshotContainer.tsx index 17b4e3f..ff4ee22 100644 --- a/src/features/screenshot/core/ScreenshotContainer.tsx +++ b/src/features/screenshot/ScreenshotContainer.tsx @@ -1,8 +1,8 @@ import { Avatar, Box, type BoxProps, Group, Stack, Text } from "@mantine/core"; import cx from "clsx"; import { type ComponentType, forwardRef, type PropsWithChildren } from "react"; -import type { LogoSize } from "#/components/AppLogo"; -import { ScreenshotWatermark } from "#/features/screenshot/core/ScreenshotWatermark"; +import type { LogoSize } from "#/components/AppLogo.tsx"; +import { ScreenshotWatermark } from "#/features/screenshot/ScreenshotWatermark.tsx"; import classes from "./ScreenshotContainer.module.css"; type WatermarkGameConfig = { @@ -105,5 +105,5 @@ const ScreenshotContainer = forwardRef< ScreenshotContainer.displayName = "ScreenshotContainer"; -export { ScreenshotContainer }; export type { WatermarkConfig }; +export { ScreenshotContainer }; diff --git a/src/features/screenshot/core/ScreenshotPreview.tsx b/src/features/screenshot/ScreenshotPreview.tsx similarity index 100% rename from src/features/screenshot/core/ScreenshotPreview.tsx rename to src/features/screenshot/ScreenshotPreview.tsx diff --git a/src/features/screenshot/core/ScreenshotPreviewProvider.tsx b/src/features/screenshot/ScreenshotPreviewProvider.tsx similarity index 89% rename from src/features/screenshot/core/ScreenshotPreviewProvider.tsx rename to src/features/screenshot/ScreenshotPreviewProvider.tsx index 0a5b663..82b0b1d 100644 --- a/src/features/screenshot/core/ScreenshotPreviewProvider.tsx +++ b/src/features/screenshot/ScreenshotPreviewProvider.tsx @@ -1,7 +1,7 @@ import { notifications } from "@mantine/notifications"; import type React from "react"; -import { ScreenshotPreview } from "#/features/screenshot/core/ScreenshotPreview"; -import { useScreenshotPreviewStore } from "./store"; +import { ScreenshotPreview } from "#/features/screenshot/ScreenshotPreview.tsx"; +import { useScreenshotPreviewStore } from "./store.ts"; const ScreenshotPreviewProvider: React.FC = () => { const { diff --git a/src/features/screenshot/core/ScreenshotWatermark.tsx b/src/features/screenshot/ScreenshotWatermark.tsx similarity index 98% rename from src/features/screenshot/core/ScreenshotWatermark.tsx rename to src/features/screenshot/ScreenshotWatermark.tsx index 24a7f86..b5f55bf 100644 --- a/src/features/screenshot/core/ScreenshotWatermark.tsx +++ b/src/features/screenshot/ScreenshotWatermark.tsx @@ -1,7 +1,7 @@ import { Flex, Text } from "@mantine/core"; import type { ComponentType } from "react"; -import { DEFAULT_LOGO_SIZE, type LogoSize } from "#/components/AppLogo"; +import { DEFAULT_LOGO_SIZE, type LogoSize } from "#/components/AppLogo.tsx"; type ScreenshotWatermarkProps = { LogoComponent: ComponentType<{ size?: LogoSize }>; diff --git a/src/features/screenshot/core/constants.ts b/src/features/screenshot/constants.ts similarity index 100% rename from src/features/screenshot/core/constants.ts rename to src/features/screenshot/constants.ts diff --git a/src/features/screenshot/core/store.ts b/src/features/screenshot/store.ts similarity index 100% rename from src/features/screenshot/core/store.ts rename to src/features/screenshot/store.ts diff --git a/src/features/screenshot/hooks/use-screenshot.ts b/src/features/screenshot/use-screenshot.ts similarity index 96% rename from src/features/screenshot/hooks/use-screenshot.ts rename to src/features/screenshot/use-screenshot.ts index a74873c..bd7ae17 100644 --- a/src/features/screenshot/hooks/use-screenshot.ts +++ b/src/features/screenshot/use-screenshot.ts @@ -1,8 +1,8 @@ import { useMantineTheme } from "@mantine/core"; import { domToBlob } from "modern-screenshot"; import { type RefObject, useRef } from "react"; -import { useScreenshotPreviewStore } from "#/features/screenshot/core/store"; -import { logger } from "#/integrations/pino/logger"; +import { useScreenshotPreviewStore } from "#/features/screenshot/store.ts"; +import { logger } from "#/integrations/pino/logger.ts"; type ScreenshotResult = { triggerScreenshot: () => void; diff --git a/src/features/theme/core/ChangeThemeButton.tsx b/src/features/theme/ChangeThemeButton.tsx similarity index 92% rename from src/features/theme/core/ChangeThemeButton.tsx rename to src/features/theme/ChangeThemeButton.tsx index 2dcfd56..fe7b3ab 100644 --- a/src/features/theme/core/ChangeThemeButton.tsx +++ b/src/features/theme/ChangeThemeButton.tsx @@ -2,7 +2,7 @@ import { ActionIcon } from "@mantine/core"; import { modals } from "@mantine/modals"; import { LuPalette } from "react-icons/lu"; -import { ThemeModal } from "#/features/theme/core/ThemeModal"; +import { ThemeModal } from "#/features/theme/ThemeModal.tsx"; import type { GameId } from "@/prisma"; type ChangeThemeButtonProps = { diff --git a/src/features/theme/core/MantineProviderWithTheme.tsx b/src/features/theme/MantineProviderWithTheme.tsx similarity index 79% rename from src/features/theme/core/MantineProviderWithTheme.tsx rename to src/features/theme/MantineProviderWithTheme.tsx index ad8c006..0382dd2 100644 --- a/src/features/theme/core/MantineProviderWithTheme.tsx +++ b/src/features/theme/MantineProviderWithTheme.tsx @@ -3,11 +3,11 @@ import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import type { PropsWithChildren } from "react"; -import { SyncFavicon } from "#/features/game/core/SyncFavicon"; -import { getAllRegisteredThemeClassNames } from "#/features/game/registry/game-registry"; -import { DEFAULT_NEXT_THEME } from "#/features/theme/core/constants"; -import { SyncAndApplyTheme } from "#/features/theme/core/SyncAndApplyTheme"; -import { useMantineThemeStore } from "#/features/theme/core/store"; +import { getAllRegisteredThemeClassNames } from "#/features/game/registry/game-registry.tsx"; +import { SyncFavicon } from "#/features/game/SyncFavicon.tsx"; +import { DEFAULT_NEXT_THEME } from "#/features/theme/constants.ts"; +import { SyncAndApplyTheme } from "#/features/theme/SyncAndApplyTheme.ts"; +import { useMantineThemeStore } from "#/features/theme/store.ts"; const allThemeClassNames: string[] = getAllRegisteredThemeClassNames(); diff --git a/src/features/theme/core/SyncAndApplyTheme.ts b/src/features/theme/SyncAndApplyTheme.ts similarity index 91% rename from src/features/theme/core/SyncAndApplyTheme.ts rename to src/features/theme/SyncAndApplyTheme.ts index 34b40a4..3de4d11 100644 --- a/src/features/theme/core/SyncAndApplyTheme.ts +++ b/src/features/theme/SyncAndApplyTheme.ts @@ -1,14 +1,14 @@ import type { MantineThemeOverride } from "@mantine/core"; import { useTheme as useNextTheme } from "next-themes"; import { useEffect } from "react"; -import { useGameId } from "#/features/game/core/use-game-id"; import { getAllRegisteredThemeDefinitions, getGameTheme, -} from "#/features/game/registry/game-registry"; -import { LOCALSTORAGE_KEYS } from "#/features/theme/core/constants"; -import { changeMantineTheme } from "#/features/theme/core/store"; -import { defaultTheme } from "#/features/theme/themes/default-theme"; +} from "#/features/game/registry/game-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"; /** * This function determines which Mantine theme to use based on the provided nextTheme class string. diff --git a/src/features/theme/core/ThemeModal.tsx b/src/features/theme/ThemeModal.tsx similarity index 95% rename from src/features/theme/core/ThemeModal.tsx rename to src/features/theme/ThemeModal.tsx index 8e4cf23..6f0ac68 100644 --- a/src/features/theme/core/ThemeModal.tsx +++ b/src/features/theme/ThemeModal.tsx @@ -12,12 +12,12 @@ import { getAllRegisteredThemeClassNames, getAllRegisteredThemeDefinitions, getGameTheme, -} from "#/features/game/registry/game-registry"; +} from "#/features/game/registry/game-registry.tsx"; import { LOCALSTORAGE_KEYS, MANTINE_COLOR_SCHEMES, -} from "#/features/theme/core/constants"; -import { parseColorScheme } from "#/features/theme/core/utils"; +} from "#/features/theme/constants.ts"; +import { 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/core/constants.ts b/src/features/theme/constants.ts similarity index 100% rename from src/features/theme/core/constants.ts rename to src/features/theme/constants.ts diff --git a/src/features/theme/core/generate-palette.ts b/src/features/theme/generate-palette.ts similarity index 98% rename from src/features/theme/core/generate-palette.ts rename to src/features/theme/generate-palette.ts index 2d471f4..8d83ab0 100644 --- a/src/features/theme/core/generate-palette.ts +++ b/src/features/theme/generate-palette.ts @@ -1,8 +1,8 @@ import type { MantineColorsTuple } from "@mantine/core"; import { clampChroma, converter, formatHex, parse } from "culori"; -import { RED, WHITE } from "#/features/theme/core/constants"; -import type { ToolkitThemeColorKey } from "#/features/theme/core/types"; -import type { ThemeColorInput } from "#/features/theme/core/utils"; +import { RED, WHITE } from "#/features/theme/constants.ts"; +import type { ToolkitThemeColorKey } from "#/features/theme/types.ts"; +import type { ThemeColorInput } from "#/features/theme/utils.ts"; // --------------------------------------------------------------------------- // OKLCH helpers @@ -324,5 +324,5 @@ const generateThemeColors = ( }; }; -export { generateThemeColors, hexToOklch, pickForeground, rampFromHex }; export type { ThemePaletteConfig }; +export { generateThemeColors, hexToOklch, pickForeground, rampFromHex }; diff --git a/src/features/theme/core/mantine-variables.css b/src/features/theme/mantine-variables.css similarity index 100% rename from src/features/theme/core/mantine-variables.css rename to src/features/theme/mantine-variables.css diff --git a/src/features/theme/core/store.ts b/src/features/theme/store.ts similarity index 88% rename from src/features/theme/core/store.ts rename to src/features/theme/store.ts index 6f9a29c..e547636 100644 --- a/src/features/theme/core/store.ts +++ b/src/features/theme/store.ts @@ -2,7 +2,7 @@ import type { MantineThemeOverride } from "@mantine/core"; import { useSelector } from "@tanstack/react-store"; import { Store } from "@tanstack/store"; -import { defaultTheme } from "#/features/theme/themes/default-theme"; +import { defaultTheme } from "#/features/theme/themes/default-theme.ts"; type ThemeState = { theme: MantineThemeOverride; @@ -18,4 +18,4 @@ function useMantineThemeStore(selector: (state: ThemeState) => T): T { return useSelector(themeStore, selector); } -export { themeStore, changeMantineTheme, useMantineThemeStore }; +export { changeMantineTheme, themeStore, useMantineThemeStore }; diff --git a/src/features/theme/themes/base-theme.ts b/src/features/theme/themes/base-theme.ts index d3f4277..f391469 100644 --- a/src/features/theme/themes/base-theme.ts +++ b/src/features/theme/themes/base-theme.ts @@ -14,7 +14,7 @@ import { Tooltip, } from "@mantine/core"; -import { GREEN, RED } from "#/features/theme/core/constants"; +import { GREEN, RED } from "#/features/theme/constants.ts"; import inputClasses from "../modules/Input.module.css"; import modalClasses from "../modules/Modal.module.css"; diff --git a/src/features/theme/themes/default-theme.ts b/src/features/theme/themes/default-theme.ts index 3934d49..fce46a8 100644 --- a/src/features/theme/themes/default-theme.ts +++ b/src/features/theme/themes/default-theme.ts @@ -1,8 +1,8 @@ import type { MantineThemeOverride } from "@mantine/core"; -import { BLACK, RED, WHITE } from "#/features/theme/core/constants"; -import { createThemeColors } from "#/features/theme/core/utils"; +import { BLACK, RED, WHITE } from "#/features/theme/constants.ts"; import { baseTheme } from "#/features/theme/themes/base-theme"; +import { createThemeColors } from "#/features/theme/utils.ts"; const defaultThemeColors = createThemeColors({ primary: { diff --git a/src/features/theme/core/types.ts b/src/features/theme/types.ts similarity index 100% rename from src/features/theme/core/types.ts rename to src/features/theme/types.ts diff --git a/src/features/theme/core/utils.ts b/src/features/theme/utils.ts similarity index 96% rename from src/features/theme/core/utils.ts rename to src/features/theme/utils.ts index 3530819..25c9a0e 100644 --- a/src/features/theme/core/utils.ts +++ b/src/features/theme/utils.ts @@ -3,7 +3,7 @@ import type { ColorVariants, ToolkitThemeColorKey, ToolkitThemeColors, -} from "#/features/theme/core/types"; +} from "#/features/theme/types.ts"; /** * Input type for creating a theme color with all its variants. @@ -117,4 +117,4 @@ const parseColorScheme = (nextTheme: string | undefined) => { return nextTheme.includes("-light") ? "light" : "dark"; }; -export { createThemeColors, type ThemeColorInput, parseColorScheme }; +export { createThemeColors, parseColorScheme, type ThemeColorInput }; diff --git a/src/games/clairobscur/core/game-config/db-seed.ts b/src/games/clairobscur/core/game-config/db-seed.ts index c077ec7..5699506 100644 --- a/src/games/clairobscur/core/game-config/db-seed.ts +++ b/src/games/clairobscur/core/game-config/db-seed.ts @@ -1,4 +1,4 @@ -import type { GameDBSeed } from "#/features/game/core/types"; +import type { GameDBSeed } from "#/features/game/types.ts"; import { ALL_CLAIROBSCUR_ITEMS } from "#/games/clairobscur/core/game-config/items"; import { prisma } from "@/prisma"; diff --git a/src/games/clairobscur/core/game-config/idb-seed.ts b/src/games/clairobscur/core/game-config/idb-seed.ts index 44dd512..8eced9b 100644 --- a/src/games/clairobscur/core/game-config/idb-seed.ts +++ b/src/games/clairobscur/core/game-config/idb-seed.ts @@ -1,4 +1,4 @@ -import type { GameIDBSeed } from "#/features/game/core/types"; +import type { GameIDBSeed } from "#/features/game/types.ts"; import { ITEMS } from "#/games/clairobscur/core/game-config/items"; import { getIDBClient } from "#/integrations/prisma-idb/idb-client"; diff --git a/src/games/clairobscur/core/game-config/index.ts b/src/games/clairobscur/core/game-config/index.ts index c0ed653..149901f 100644 --- a/src/games/clairobscur/core/game-config/index.ts +++ b/src/games/clairobscur/core/game-config/index.ts @@ -1,4 +1,4 @@ -import type { GameConfig } from "#/features/game/core/types"; +import type { GameConfig } from "#/features/game/types.ts"; import { ITEMS } from "#/games/clairobscur/core/game-config/items"; import { METADATA } from "#/games/clairobscur/core/game-config/metadata"; import { PAGES } from "#/games/clairobscur/core/game-config/pages"; diff --git a/src/games/clairobscur/core/game-config/items.ts b/src/games/clairobscur/core/game-config/items.ts index bba439c..c47859b 100644 --- a/src/games/clairobscur/core/game-config/items.ts +++ b/src/games/clairobscur/core/game-config/items.ts @@ -1,4 +1,4 @@ -import type { GameConfig } from "#/features/game/core/types"; +import type { GameConfig } from "#/features/game/types.ts"; import { CHARACTERS } from "#/games/clairobscur/core/item-data/characters"; import type { ClairObscurLocalItem } from "#/games/clairobscur/core/types.ts"; import type { ClairObscurItemCategory } from "@/prisma"; diff --git a/src/games/clairobscur/core/game-config/metadata.tsx b/src/games/clairobscur/core/game-config/metadata.tsx index 5777d07..e590370 100644 --- a/src/games/clairobscur/core/game-config/metadata.tsx +++ b/src/games/clairobscur/core/game-config/metadata.tsx @@ -1,4 +1,4 @@ -import type { GameMetadata } from "#/features/game/core/types"; +import type { GameMetadata } from "#/features/game/types.ts"; import { GAME_ID } from "#/games/clairobscur/core/constants"; import { ClairObscurLogo } from "#/games/clairobscur/core/Logo"; diff --git a/src/games/clairobscur/core/game-config/pages.tsx b/src/games/clairobscur/core/game-config/pages.tsx index 1c10b1a..6e3c390 100644 --- a/src/games/clairobscur/core/game-config/pages.tsx +++ b/src/games/clairobscur/core/game-config/pages.tsx @@ -1,6 +1,6 @@ -import type { GamePages } from "#/features/game/core/types"; import { AppItemPage } from "#/features/game/items/AppItemPage"; 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"; diff --git a/src/games/clairobscur/core/game-config/theme.ts b/src/games/clairobscur/core/game-config/theme.ts index 761137e..6faa32f 100644 --- a/src/games/clairobscur/core/game-config/theme.ts +++ b/src/games/clairobscur/core/game-config/theme.ts @@ -1,8 +1,8 @@ import type { MantineThemeOverride } from "@mantine/core"; -import { generateThemeColors } from "#/features/theme/core/generate-palette"; -import type { ToolkitThemeDefinition } from "#/features/theme/core/types"; -import { createThemeColors } from "#/features/theme/core/utils"; +import { generateThemeColors } from "#/features/theme/generate-palette.ts"; import { baseTheme } from "#/features/theme/themes/base-theme"; +import type { ToolkitThemeDefinition } from "#/features/theme/types.ts"; +import { createThemeColors } from "#/features/theme/utils.ts"; import { GAME_ID } from "#/games/clairobscur/core/constants"; const clairObscurThemeColors = createThemeColors( diff --git a/src/games/clairobscur/dal/server/collected-items.ts b/src/games/clairobscur/dal/server/collected-items.ts index 935fbfe..810bf27 100644 --- a/src/games/clairobscur/dal/server/collected-items.ts +++ b/src/games/clairobscur/dal/server/collected-items.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireUserId } from "#/features/auth/dal/require-user.server"; +import { requireUserId } from "#/features/auth/require-user.server.ts"; import { createCollectedItemHandlers } from "#/features/game/dal/collected-items/collected-items.ts"; import { prisma } from "@/prisma"; diff --git a/src/games/remnant2/core/game-config/avatars.ts b/src/games/remnant2/core/game-config/avatars.ts index 90c5164..907529c 100644 --- a/src/games/remnant2/core/game-config/avatars.ts +++ b/src/games/remnant2/core/game-config/avatars.ts @@ -1,4 +1,4 @@ -import type { GameAvatar } from "#/features/game/core/types"; +import type { GameAvatar } from "#/features/game/types.ts"; import { ENEMIES } from "#/games/remnant2/core/enemy-data/remnant2-enemies"; const enemies: GameAvatar[] = ENEMIES.sort((a, b) => diff --git a/src/games/remnant2/core/game-config/db-seed.ts b/src/games/remnant2/core/game-config/db-seed.ts index cf0dd84..c723c6b 100644 --- a/src/games/remnant2/core/game-config/db-seed.ts +++ b/src/games/remnant2/core/game-config/db-seed.ts @@ -1,4 +1,4 @@ -import type { GameDBSeed } from "#/features/game/core/types"; +import type { GameDBSeed } from "#/features/game/types.ts"; import { ALL_REMNANT2_ITEMS } from "#/games/remnant2/core/game-config/items"; import { prisma } from "@/prisma"; diff --git a/src/games/remnant2/core/game-config/idb-seed.ts b/src/games/remnant2/core/game-config/idb-seed.ts index 183d028..aa881cf 100644 --- a/src/games/remnant2/core/game-config/idb-seed.ts +++ b/src/games/remnant2/core/game-config/idb-seed.ts @@ -1,4 +1,4 @@ -import type { GameIDBSeed } from "#/features/game/core/types"; +import type { GameIDBSeed } from "#/features/game/types.ts"; import { ITEMS } from "#/games/remnant2/core/game-config/items"; import { getIDBClient } from "#/integrations/prisma-idb/idb-client"; diff --git a/src/games/remnant2/core/game-config/index.ts b/src/games/remnant2/core/game-config/index.ts index bbf65ae..6d150ec 100644 --- a/src/games/remnant2/core/game-config/index.ts +++ b/src/games/remnant2/core/game-config/index.ts @@ -1,9 +1,9 @@ -import type { GameConfig } from "#/features/game/core/types"; +import type { GameConfig } from "#/features/game/types.ts"; import { AVATARS } from "#/games/remnant2/core/game-config/avatars"; import { ITEMS } from "#/games/remnant2/core/game-config/items"; import { METADATA } from "#/games/remnant2/core/game-config/metadata"; +import { SEARCH_PARAMS } from "#/games/remnant2/core/game-config/nuqs-parsers.ts"; import { PAGES } from "#/games/remnant2/core/game-config/pages"; -import { SEARCH_PARAMS } from "#/games/remnant2/core/game-config/search-params"; 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"; diff --git a/src/games/remnant2/core/game-config/items.ts b/src/games/remnant2/core/game-config/items.ts index 80615c2..f7590dd 100644 --- a/src/games/remnant2/core/game-config/items.ts +++ b/src/games/remnant2/core/game-config/items.ts @@ -1,4 +1,4 @@ -import type { GameConfig } from "#/features/game/core/types"; +import type { GameConfig } from "#/features/game/types.ts"; import { AMULETS } from "#/games/remnant2/core/item-data/amulets"; import { ARCHETYPES } from "#/games/remnant2/core/item-data/archetypes"; import { ARMORS } from "#/games/remnant2/core/item-data/armors"; diff --git a/src/games/remnant2/core/game-config/metadata.tsx b/src/games/remnant2/core/game-config/metadata.tsx index a1bde43..d060df8 100644 --- a/src/games/remnant2/core/game-config/metadata.tsx +++ b/src/games/remnant2/core/game-config/metadata.tsx @@ -1,4 +1,4 @@ -import type { GameMetadata } from "#/features/game/core/types"; +import type { GameMetadata } from "#/features/game/types.ts"; import { GAME_ID } from "#/games/remnant2/core/constants"; import { Remnant2Logo } from "#/games/remnant2/core/Logo"; diff --git a/src/games/remnant2/core/game-config/search-params.ts b/src/games/remnant2/core/game-config/nuqs-parsers.ts similarity index 81% rename from src/games/remnant2/core/game-config/search-params.ts rename to src/games/remnant2/core/game-config/nuqs-parsers.ts index 5a76228..c4e8fe7 100644 --- a/src/games/remnant2/core/game-config/search-params.ts +++ b/src/games/remnant2/core/game-config/nuqs-parsers.ts @@ -5,12 +5,9 @@ import { parseAsString, } from "nuqs/server"; import type { TriStateFilterValue } from "#/components/TriStateFilter"; -import { - paginationParser, - patchParser, - searchParser, - sortParser, -} from "#/search-params"; +import { paginationParser } from "#/features/nuqs/parsers/pagination.ts"; +import { searchParser } from "#/features/nuqs/parsers/search.ts"; +import { sortParser } from "#/features/nuqs/parsers/sort.ts"; const categoryParser = parseAsArrayOf( parseAsString.withOptions({ @@ -48,11 +45,10 @@ const remnant2SearchParamsCache = createSearchParamsCache({ search: searchParser, category: categoryParser, dlc: dlcFilterParser, - patch: patchParser, ...sortParser, ...paginationParser, }); const SEARCH_PARAMS = remnant2SearchParamsCache; -export { categoryParser, dlcFilterParser, type patchParser, SEARCH_PARAMS }; +export { categoryParser, dlcFilterParser, SEARCH_PARAMS }; diff --git a/src/games/remnant2/core/game-config/pages.tsx b/src/games/remnant2/core/game-config/pages.tsx index 95d8eb3..c8b6ec8 100644 --- a/src/games/remnant2/core/game-config/pages.tsx +++ b/src/games/remnant2/core/game-config/pages.tsx @@ -5,7 +5,6 @@ import { TriStateFilter, type TriStateFilterValue, } from "#/components/TriStateFilter"; -import type { GamePages } from "#/features/game/core/types"; import { AppItemPage } from "#/features/game/items/AppItemPage"; import type { AppItem, GameFilterConfig } from "#/features/game/items/types"; import { @@ -14,6 +13,7 @@ import { itemMatchesCategory, resolveLinkedItems, } from "#/features/game/items/utils"; +import type { GamePages } from "#/features/game/types.ts"; import { ITEMS } from "#/games/remnant2/core/game-config/items"; import { remnant2CollectedItemsDal } from "#/games/remnant2/dal/collected-items"; import type { Remnant2DLC } from "@/prisma"; diff --git a/src/games/remnant2/core/game-config/theme.ts b/src/games/remnant2/core/game-config/theme.ts index 48230f6..f68af7f 100644 --- a/src/games/remnant2/core/game-config/theme.ts +++ b/src/games/remnant2/core/game-config/theme.ts @@ -1,8 +1,8 @@ import type { MantineThemeOverride } from "@mantine/core"; -import { generateThemeColors } from "#/features/theme/core/generate-palette"; -import type { ToolkitThemeDefinition } from "#/features/theme/core/types"; -import { createThemeColors } from "#/features/theme/core/utils"; +import { generateThemeColors } from "#/features/theme/generate-palette.ts"; import { baseTheme } from "#/features/theme/themes/base-theme"; +import type { ToolkitThemeDefinition } from "#/features/theme/types.ts"; +import { createThemeColors } from "#/features/theme/utils.ts"; import { GAME_ID } from "#/games/remnant2/core/constants"; const remnant2ThemeColors = createThemeColors( diff --git a/src/games/remnant2/dal/server/collected-items.ts b/src/games/remnant2/dal/server/collected-items.ts index daa33d0..9e9a4ab 100644 --- a/src/games/remnant2/dal/server/collected-items.ts +++ b/src/games/remnant2/dal/server/collected-items.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireUserId } from "#/features/auth/dal/require-user.server"; +import { requireUserId } from "#/features/auth/require-user.server.ts"; import { createCollectedItemHandlers } from "#/features/game/dal/collected-items/collected-items.ts"; import { prisma } from "@/prisma"; diff --git a/src/games/slaythespire2/core/game-config/avatars.ts b/src/games/slaythespire2/core/game-config/avatars.ts index 97781f1..b074a48 100644 --- a/src/games/slaythespire2/core/game-config/avatars.ts +++ b/src/games/slaythespire2/core/game-config/avatars.ts @@ -1,4 +1,4 @@ -import type { GameAvatar } from "#/features/game/core/types"; +import type { GameAvatar } from "#/features/game/types.ts"; import { CARDS } from "#/games/slaythespire2/core/item-data/cards"; import { CHARACTERS } from "#/games/slaythespire2/core/item-data/characters"; import { POTIONS } from "#/games/slaythespire2/core/item-data/potions"; diff --git a/src/games/slaythespire2/core/game-config/db-seed.ts b/src/games/slaythespire2/core/game-config/db-seed.ts index 1efc90e..41062d6 100644 --- a/src/games/slaythespire2/core/game-config/db-seed.ts +++ b/src/games/slaythespire2/core/game-config/db-seed.ts @@ -1,4 +1,4 @@ -import type { GameDBSeed } from "#/features/game/core/types"; +import type { GameDBSeed } from "#/features/game/types.ts"; import { ALL_SLAYTHESPIRE2_ITEMS } from "#/games/slaythespire2/core/game-config/items"; import { prisma } from "@/prisma"; diff --git a/src/games/slaythespire2/core/game-config/idb-seed.ts b/src/games/slaythespire2/core/game-config/idb-seed.ts index 536b426..c6d130f 100644 --- a/src/games/slaythespire2/core/game-config/idb-seed.ts +++ b/src/games/slaythespire2/core/game-config/idb-seed.ts @@ -1,4 +1,4 @@ -import type { GameIDBSeed } from "#/features/game/core/types"; +import type { GameIDBSeed } from "#/features/game/types.ts"; import { ITEMS } from "#/games/slaythespire2/core/game-config/items"; import { getIDBClient } from "#/integrations/prisma-idb/idb-client"; diff --git a/src/games/slaythespire2/core/game-config/index.ts b/src/games/slaythespire2/core/game-config/index.ts index e998b71..55bd26c 100644 --- a/src/games/slaythespire2/core/game-config/index.ts +++ b/src/games/slaythespire2/core/game-config/index.ts @@ -1,12 +1,12 @@ -import type { GameConfig } from "#/features/game/core/types"; +import type { GameConfig } from "#/features/game/types.ts"; import { AVATARS } from "#/games/slaythespire2/core/game-config/avatars"; import { ITEMS } from "#/games/slaythespire2/core/game-config/items"; import { METADATA } from "#/games/slaythespire2/core/game-config/metadata"; +import { SEARCH_PARAMS } from "#/games/slaythespire2/core/game-config/nuqs-parsers.ts"; import { PAGES } from "#/games/slaythespire2/core/game-config/pages"; -import { SEARCH_PARAMS } from "#/games/slaythespire2/core/game-config/search-params"; import { THEME } from "#/games/slaythespire2/core/game-config/theme"; -import { slayTheSpire2CollectedItemsDal } from "#/games/slaythespire2/dal/collected-items"; import type { SlayTheSpire2LocalItem } from "#/games/slaythespire2/core/types"; +import { slayTheSpire2CollectedItemsDal } from "#/games/slaythespire2/dal/collected-items"; import type { SlayTheSpire2ItemCategory } from "@/prisma"; const GAME_CONFIG = { diff --git a/src/games/slaythespire2/core/game-config/items.ts b/src/games/slaythespire2/core/game-config/items.ts index c2a3f30..94acb79 100644 --- a/src/games/slaythespire2/core/game-config/items.ts +++ b/src/games/slaythespire2/core/game-config/items.ts @@ -1,4 +1,4 @@ -import type { GameConfig } from "#/features/game/core/types"; +import type { GameConfig } from "#/features/game/types.ts"; import { ANCIENTS } from "#/games/slaythespire2/core/item-data/ancients.ts"; import { CARDS } from "#/games/slaythespire2/core/item-data/cards"; import { CHARACTERS } from "#/games/slaythespire2/core/item-data/characters"; diff --git a/src/games/slaythespire2/core/game-config/metadata.tsx b/src/games/slaythespire2/core/game-config/metadata.tsx index bf722cb..5b9b1a6 100644 --- a/src/games/slaythespire2/core/game-config/metadata.tsx +++ b/src/games/slaythespire2/core/game-config/metadata.tsx @@ -1,4 +1,4 @@ -import type { GameMetadata } from "#/features/game/core/types"; +import type { GameMetadata } from "#/features/game/types.ts"; import { GAME_ID } from "#/games/slaythespire2/core/constants"; import { SlayTheSpire2Logo } from "#/games/slaythespire2/core/Logo"; diff --git a/src/games/slaythespire2/core/game-config/search-params.ts b/src/games/slaythespire2/core/game-config/nuqs-parsers.ts similarity index 81% rename from src/games/slaythespire2/core/game-config/search-params.ts rename to src/games/slaythespire2/core/game-config/nuqs-parsers.ts index 4dccfcf..0392b32 100644 --- a/src/games/slaythespire2/core/game-config/search-params.ts +++ b/src/games/slaythespire2/core/game-config/nuqs-parsers.ts @@ -5,12 +5,9 @@ import { parseAsString, } from "nuqs/server"; import type { TriStateFilterValue } from "#/components/TriStateFilter"; -import { - paginationParser, - patchParser, - searchParser, - sortParser, -} from "#/search-params"; +import { paginationParser } from "#/features/nuqs/parsers/pagination.ts"; +import { searchParser } from "#/features/nuqs/parsers/search.ts"; +import { sortParser } from "#/features/nuqs/parsers/sort.ts"; const categoryParser = parseAsArrayOf( parseAsString.withOptions({ @@ -48,11 +45,10 @@ const slayTheSpire2SearchParamsCache = createSearchParamsCache({ search: searchParser, category: categoryParser, dlc: dlcFilterParser, - patch: patchParser, ...sortParser, ...paginationParser, }); const SEARCH_PARAMS = slayTheSpire2SearchParamsCache; -export { categoryParser, dlcFilterParser, type patchParser, SEARCH_PARAMS }; +export { categoryParser, dlcFilterParser, SEARCH_PARAMS }; diff --git a/src/games/slaythespire2/core/game-config/pages.tsx b/src/games/slaythespire2/core/game-config/pages.tsx index b351a7b..06182ef 100644 --- a/src/games/slaythespire2/core/game-config/pages.tsx +++ b/src/games/slaythespire2/core/game-config/pages.tsx @@ -1,7 +1,6 @@ import { MultiSelect, Stack, Text } from "@mantine/core"; import { parseAsString } from "nuqs"; import type { ReactNode } from "react"; -import type { GamePages } from "#/features/game/core/types"; import { AppItemPage } from "#/features/game/items/AppItemPage"; import type { AppItem, GameFilterConfig } from "#/features/game/items/types"; import { @@ -10,6 +9,7 @@ import { itemMatchesCategory, resolveLinkedItems, } from "#/features/game/items/utils"; +import type { GamePages } from "#/features/game/types.ts"; import { ITEMS } from "#/games/slaythespire2/core/game-config/items"; import { slayTheSpire2CollectedItemsDal } from "#/games/slaythespire2/dal/collected-items"; diff --git a/src/games/slaythespire2/core/game-config/theme.ts b/src/games/slaythespire2/core/game-config/theme.ts index 640c8ac..dadea44 100644 --- a/src/games/slaythespire2/core/game-config/theme.ts +++ b/src/games/slaythespire2/core/game-config/theme.ts @@ -1,8 +1,8 @@ import type { MantineThemeOverride } from "@mantine/core"; -import { generateThemeColors } from "#/features/theme/core/generate-palette"; -import type { ToolkitThemeDefinition } from "#/features/theme/core/types"; -import { createThemeColors } from "#/features/theme/core/utils"; +import { generateThemeColors } from "#/features/theme/generate-palette.ts"; import { baseTheme } from "#/features/theme/themes/base-theme"; +import type { ToolkitThemeDefinition } from "#/features/theme/types.ts"; +import { createThemeColors } from "#/features/theme/utils.ts"; import { GAME_ID } from "#/games/slaythespire2/core/constants"; const slayTheSpire2ThemeColors = createThemeColors( diff --git a/src/games/slaythespire2/dal/server/collected-items.ts b/src/games/slaythespire2/dal/server/collected-items.ts index 8b16ecb..1261bd1 100644 --- a/src/games/slaythespire2/dal/server/collected-items.ts +++ b/src/games/slaythespire2/dal/server/collected-items.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { requireUserId } from "#/features/auth/dal/require-user.server"; +import { requireUserId } from "#/features/auth/require-user.server.ts"; import { createCollectedItemHandlers } from "#/features/game/dal/collected-items/collected-items.ts"; import { prisma } from "@/prisma"; diff --git a/src/routes/account/profile/$userId/build-collections.tsx b/src/routes/account/profile/$userId/build-collections.tsx index 8921bf3..42d65fb 100644 --- a/src/routes/account/profile/$userId/build-collections.tsx +++ b/src/routes/account/profile/$userId/build-collections.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/core/profile-tab-head"; +} from "#/features/auth/profile-tab-head.ts"; function BuildCollections() { return ( diff --git a/src/routes/account/profile/$userId/collected-items.tsx b/src/routes/account/profile/$userId/collected-items.tsx index 807ce06..7f7b3a8 100644 --- a/src/routes/account/profile/$userId/collected-items.tsx +++ b/src/routes/account/profile/$userId/collected-items.tsx @@ -3,13 +3,13 @@ import { useEffect } from "react"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/core/profile-tab-head"; -import { useGameId } from "#/features/game/core/use-game-id"; +} from "#/features/auth/profile-tab-head.ts"; import { getGameConfig, getGameMetadata, isRegisteredGameId, } from "#/features/game/registry/game-registry"; +import { useGameId } from "#/features/game/use-game-id.ts"; import type { GameId } from "@/prisma"; type CollectedItemsSearch = { diff --git a/src/routes/account/profile/$userId/collection-stats.tsx b/src/routes/account/profile/$userId/collection-stats.tsx index 017eefd..211b223 100644 --- a/src/routes/account/profile/$userId/collection-stats.tsx +++ b/src/routes/account/profile/$userId/collection-stats.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/core/profile-tab-head"; +} from "#/features/auth/profile-tab-head.ts"; function CollectionStats() { return ( diff --git a/src/routes/account/profile/$userId/created-builds.tsx b/src/routes/account/profile/$userId/created-builds.tsx index 84e4116..dd1aa7b 100644 --- a/src/routes/account/profile/$userId/created-builds.tsx +++ b/src/routes/account/profile/$userId/created-builds.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/core/profile-tab-head"; +} from "#/features/auth/profile-tab-head.ts"; function CreatedBuilds() { return ( diff --git a/src/routes/account/profile/$userId/data-sync.tsx b/src/routes/account/profile/$userId/data-sync.tsx index 7a83e39..1ed8e7b 100644 --- a/src/routes/account/profile/$userId/data-sync.tsx +++ b/src/routes/account/profile/$userId/data-sync.tsx @@ -16,7 +16,7 @@ import { type PropsWithChildren, useEffect, useState } from "react"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/core/profile-tab-head"; +} 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"; diff --git a/src/routes/account/profile/$userId/liked-builds.tsx b/src/routes/account/profile/$userId/liked-builds.tsx index 6008b73..1d6e1ec 100644 --- a/src/routes/account/profile/$userId/liked-builds.tsx +++ b/src/routes/account/profile/$userId/liked-builds.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { buildTabHead, loadProfileTabData, -} from "#/features/auth/core/profile-tab-head"; +} from "#/features/auth/profile-tab-head.ts"; function LikedBuilds() { return ( diff --git a/src/routes/account/profile/$userId/route.tsx b/src/routes/account/profile/$userId/route.tsx index 86e6013..c3e8199 100644 --- a/src/routes/account/profile/$userId/route.tsx +++ b/src/routes/account/profile/$userId/route.tsx @@ -6,9 +6,9 @@ import { SERVER_GAME_INPUTS_QUERY_KEY, } from "#/constants.ts"; import { clientEnv } from "#/env/client-env.ts"; -import { ProfileHeader } from "#/features/auth/core/ProfileHeader"; -import { ProfileTabNav } from "#/features/auth/core/ProfileTabNav"; -import { resolveAvatar } from "#/features/auth/core/utils"; +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 { buildGetProfileQueryKey, diff --git a/src/routes/profile/collected-items.tsx b/src/routes/profile/collected-items.tsx index 3486c6c..fbe9768 100644 --- a/src/routes/profile/collected-items.tsx +++ b/src/routes/profile/collected-items.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useGameId } from "#/features/game/core/use-game-id"; import { getGameConfig } from "#/features/game/registry/game-registry"; +import { useGameId } from "#/features/game/use-game-id.ts"; function CollectedItems() { const gameId = useGameId(); diff --git a/src/routes/profile/route.tsx b/src/routes/profile/route.tsx index b2d89d4..fe4b332 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/core/ProfileHeader"; -import { ProfileTabNav } from "#/features/auth/core/ProfileTabNav"; +import { ProfileHeader } from "#/features/auth/ProfileHeader.tsx"; +import { ProfileTabNav } from "#/features/auth/ProfileTabNav.tsx"; import { useSession } from "#/integrations/better-auth/auth-client"; const LocalProfileLayout = () => { diff --git a/src/search-params.ts b/src/search-params.ts deleted file mode 100644 index 5912b8c..0000000 --- a/src/search-params.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - createSearchParamsCache, - parseAsArrayOf, - parseAsBoolean, - parseAsInteger, - parseAsString, -} from "nuqs/server"; - -const searchParser = parseAsString.withDefault("").withOptions({ - shallow: false, - clearOnDefault: true, -}); - -const itemSlugParser = parseAsString.withDefault("").withOptions({ - shallow: false, - clearOnDefault: true, -}); - -const sortParser = { - sortKey: parseAsString.withDefault("createdAt"), - sortValue: parseAsString.withDefault("desc"), -}; - -const sortOptions = { - shallow: false, - clearOnDefault: true, -}; - -const paginationParser = { - page: parseAsInteger.withDefault(0), - size: parseAsInteger.withDefault(16), -}; - -const paginationOptions = { - shallow: false, - clearOnDefault: true, -}; - -const patchParser = parseAsString.withDefault("").withOptions({ - shallow: false, - clearOnDefault: true, -}); - -const dimUncollectedItemsParser = parseAsBoolean - .withDefault(false) - .withOptions({ - shallow: true, - clearOnDefault: true, - }); - -const showUncollectedItemsParser = parseAsBoolean - .withDefault(true) - .withOptions({ - shallow: true, - clearOnDefault: true, - }); - -const showCollectedItemsParser = parseAsBoolean.withDefault(true).withOptions({ - shallow: true, - clearOnDefault: true, -}); - -const compareItemsParser = parseAsArrayOf(parseAsString) - .withDefault([]) - .withOptions({ - shallow: true, - clearOnDefault: true, - }); - -const showCollectableOnlyParser = parseAsBoolean - .withDefault(false) - .withOptions({ - shallow: true, - clearOnDefault: true, - }); - -const baseSearchParamsCache = createSearchParamsCache({ - search: searchParser, - itemSlug: itemSlugParser, - dimUncollectedItems: dimUncollectedItemsParser, - showUncollectedItems: showUncollectedItemsParser, - showCollectedItems: showCollectedItemsParser, - showCollectableOnly: showCollectableOnlyParser, - ...sortParser, - ...paginationParser, -}); - -type ParsedSearchParams = Awaited< - ReturnType ->; - -export { - baseSearchParamsCache, - compareItemsParser, - dimUncollectedItemsParser, - itemSlugParser, - type ParsedSearchParams, - paginationOptions, - paginationParser, - patchParser, - searchParser, - showCollectableOnlyParser, - showCollectedItemsParser, - showUncollectedItemsParser, - sortOptions, - sortParser, -}; diff --git a/src/env.d.ts b/types/env.d.ts similarity index 100% rename from src/env.d.ts rename to types/env.d.ts diff --git a/src/features/theme/core/mantine.d.ts b/types/mantine.d.ts similarity index 100% rename from src/features/theme/core/mantine.d.ts rename to types/mantine.d.ts