diff --git a/.claude/skills/api-module/SKILL.md b/.claude/skills/api-module/SKILL.md new file mode 100644 index 0000000000..6cecf0b21c --- /dev/null +++ b/.claude/skills/api-module/SKILL.md @@ -0,0 +1,156 @@ +# Adding a New API Module + +How to add a new API endpoint module to `packages/api-client`. + +## Steps + +### 1. Define types in the module's `types.ts` + +Types must match 1:1 with the backend API response. Do not reshape, rename, or omit fields. + +Add to an existing namespace or create a new one: + +```ts +// modules/labrinth/types.ts (existing namespace) +export namespace Labrinth { + export namespace MyDomain { + export namespace v3 { + export type Thing = { + id: string + name: string + created: string + // ... matches API response exactly + } + + export type CreateThingRequest = { + name: string + } + } + } +} +``` + +For a new API service, create `modules//types.ts` with a new top-level namespace and re-export it from `modules/types.ts`. + +### 2. Create the module class + +Create `modules///v.ts`: + +```ts +// modules/labrinth/things/v3.ts +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthThingsV3Module extends AbstractModule { + public getModuleID(): string { + return 'labrinth_things_v3' + } + + public async get(id: string): Promise { + return this.client.request(`/thing/${id}`, { + api: 'labrinth', + version: 3, + method: 'GET', + }) + } + + public async create(data: Labrinth.MyDomain.v3.CreateThingRequest): Promise { + return this.client.request(`/thing`, { + api: 'labrinth', + version: 3, + method: 'POST', + body: data, + }) + } + + public async delete(id: string): Promise { + return this.client.request(`/thing/${id}`, { + api: 'labrinth', + version: 3, + method: 'DELETE', + }) + } +} +``` + +#### Request options + +| Field | Values | Purpose | +|-------|--------|---------| +| `api` | `'labrinth'`, `'archon'`, or a full URL | Which base URL to use | +| `version` | `2`, `3`, `'internal'`, `'modrinth/v0'`, etc. | URL version segment | +| `method` | `'GET'`, `'POST'`, `'PUT'`, `'PATCH'`, `'DELETE'` | HTTP method | +| `body` | object | JSON request body | +| `params` | `Record` | Query parameters | +| `skipAuth` | `boolean` | Skip auth feature for this request | +| `useNodeAuth` | `boolean` | Use node-level auth (kyros) | +| `timeout` | `number` | Request timeout in ms | +| `retry` | `boolean \| number` | Override retry behavior | + +#### For uploads + +Return an `UploadHandle` instead of a `Promise`: + +```ts +public uploadThing(id: string, file: File): UploadHandle { + return this.client.upload(`/thing/${id}/file`, { + api: 'labrinth', + version: 3, + file, + }) +} + +// Or with FormData for multipart: +public createWithFiles(data: CreateRequest, files: File[]): UploadHandle { + const formData = new FormData() + formData.append('data', JSON.stringify(data)) + files.forEach((f, i) => formData.append(`file-${i}`, f, f.name)) + + return this.client.upload(`/thing`, { + api: 'labrinth', + version: 3, + formData, + timeout: 60 * 5 * 1000, // longer timeout for uploads + }) +} +``` + +### 3. Register in the MODULE_REGISTRY + +Add to `modules/index.ts`: + +```ts +import { LabrinthThingsV3Module } from './labrinth/things/v3' + +export const MODULE_REGISTRY = { + // ... existing modules + labrinth_things_v3: LabrinthThingsV3Module, +} as const +``` + +The naming convention is `__`. This flat key gets transformed into nested access: `client.labrinth.things_v3`. + +### 4. Export types + +If you added to an existing namespace, types are already re-exported. If you created a new `types.ts`, add it to `modules/types.ts`: + +```ts +export * from './/types' +``` + +## Naming Conventions + +| Convention | Example | +|-----------|---------| +| Module class | `LabrinthThingsV3Module` — `{Api}{Domain}V{N}Module` | +| Module ID | `labrinth_things_v3` — `{api}_{domain}_v{n}` | +| Type namespace | `Labrinth.MyDomain.v3.Thing` | +| File path | `modules/labrinth/things/v3.ts` | + +## Key Files + +- `src/core/abstract-module.ts` — base class all modules extend +- `src/core/abstract-client.ts` — `request()` and `upload()` methods +- `src/modules/index.ts` — `MODULE_REGISTRY` and `buildModuleStructure()` +- `src/modules//types.ts` — type definitions per API +- `src/types/upload.ts` — `UploadHandle`, `UploadProgress`, `UploadRequestOptions` diff --git a/.claude/skills/cross-platform-pages/SKILL.md b/.claude/skills/cross-platform-pages/SKILL.md new file mode 100644 index 0000000000..05b596d43c --- /dev/null +++ b/.claude/skills/cross-platform-pages/SKILL.md @@ -0,0 +1,144 @@ +# Cross-Platform Page System + +When a page needs to exist in both the Modrinth App (`apps/app-frontend`) and the Modrinth Website (`apps/frontend`), use the cross-platform page system. + +## How It Works + +1. **Pages live as Vue SFCs in `packages/ui`** — either in `src/pages/` or `src/layout/` (if `src/pages/` doesn't exist, it's been renamed to `src/layout/`). +2. **Platform-dependent data flows via DI** — the app uses Tauri `invoke` commands, the website uses `api-client` or the legacy `useBaseFetch` composable. The shared page never knows which. See the `dependency-injection` skill for full DI docs. +3. **Non-platform-dependent data flows via props** — if data doesn't change based on _how_ it's fetched, just pass it as a prop. + +## Example: Content Page + +`ContentPageLayout` demonstrates the full pattern. + +### 1. Define a DI contract in `packages/ui/src/providers/` + +The provider interface abstracts all platform-specific operations: + +```ts +// packages/ui/src/providers/content-manager.ts +export interface ContentManagerContext { + items: Ref + loading: Ref + error: Ref + contentTypeLabel: Ref + + // These are the platform-abstracted operations: + // App uses invoke(), website uses api-client + toggleEnabled: (item: ContentItem) => Promise + deleteItem: (item: ContentItem) => Promise + refresh: () => Promise + browse: () => void + uploadFiles: () => void + + // Optional capabilities — not every platform supports everything + hasUpdateSupport: boolean + updateItem?: (item: ContentItem) => Promise + bulkUpdateItem?: (items: ContentItem[]) => Promise + + mapToTableItem: (item: ContentItem) => ContentCardTableItem +} + +export const [injectContentManager, provideContentManager] = + createContext('ContentManager') +``` + +### 2. Build the shared page in `packages/ui` + +The page component injects the context and handles all UI logic (search, filtering, selection, bulk operations, empty states, modals) without knowing the platform: + +```vue + + + + +``` + +### 3. Each platform provides its implementation + +**Website (Nuxt)** — uses `api-client` or `useBaseFetch`: + +```vue + + + + +``` + +**App (Tauri)** — uses `invoke`: + +```vue + + + + +``` + +## When to Use Props vs DI + +| Use | When | +| --------- | -------------------------------------------------------------------------------------------------------- | +| **DI** | The data depends on _how_ it's fetched (different per platform) — API calls, file operations, navigation | +| **Props** | The data is the same regardless of platform — configuration flags, display options | + +## Composables for Shared Logic + +Extract reusable stateful logic into composables in `packages/ui/src/composables/`. The shared page orchestrates them internally: + +- Search (Fuse.js fuzzy search over items) +- Filtering (dynamic filter pills) +- Selection (multi-select with bulk operations) +- Bulk operations (sequential execution with progress tracking) + +## Key Files + +- `packages/ui/src/pages/` (or `src/layout/`) — shared page components +- `packages/ui/src/providers/` — DI contracts +- `packages/ui/src/composables/` — shared stateful logic +- `apps/frontend/src/app.vue` — website root provider setup +- `apps/app-frontend/src/App.vue` — app root provider setup +- `apps/app-frontend/src/routes.js` — app route definitions diff --git a/.claude/skills/dependency-injection/SKILL.md b/.claude/skills/dependency-injection/SKILL.md new file mode 100644 index 0000000000..9c83b974f9 --- /dev/null +++ b/.claude/skills/dependency-injection/SKILL.md @@ -0,0 +1,174 @@ +# Dependency Injection + +Modrinth uses a lightweight DI layer built on Vue's `provide`/`inject` for sharing platform-specific capabilities and page-level state across shared UI components. + +## The `createContext` Factory + +All providers are defined using `createContext` from `packages/ui/src/providers/index.ts` (adapted from Reka UI). It produces a typed `[inject, provide]` tuple: + +```ts +import { createContext } from '@modrinth/ui' + +interface MyContext { + someValue: Ref + doSomething: () => void +} + +export const [injectMyContext, provideMyContext] = createContext('MyComponent') +``` + +- **`provideMyContext(value)`** — call in a parent component's `setup()`. +- **`injectMyContext()`** — call in any descendant's `setup()`. Throws if never provided. +- **`injectMyContext(null)`** — returns `null` instead of throwing (for optional contexts). + +## When to Use DI + +Use DI when: +- **The same interface needs different implementations** depending on the platform (web vs desktop app). +- **Deeply nested components** need access to shared page-level state without prop drilling through 3+ levels. + +### Platform Abstraction (Primary Use Case) + +`packages/ui` components need capabilities that each frontend fulfils differently: + +| Provider | App Frontend | Website Frontend | +|----------|-------------|-----------------| +| API client | Tauri IPC client | REST fetch client | +| Notifications | `ref()` state + app window mgmt | `useState()` for SSR hydration | +| File picker | Native Tauri dialogs | Browser file inputs | +| Tags | Tauri commands | Nuxt server state | +| Page context | `sidebar: true`, ad window hooks | `sidebar: false`, no ads | + +### Page-Level Context + +Sharing data between a page and deeply nested children — e.g. project page data consumed by sidebar, header, and version components. + +## Creating a New Provider + +### 1. Define the interface in `packages/ui/src/providers/` + +```ts +// packages/ui/src/providers/my-feature.ts +import type { Ref } from 'vue' +import { createContext } from '.' + +export interface MyFeatureContext { + items: Ref + addItem: (item: Item) => Promise + removeItem: (id: string) => Promise +} + +export const [injectMyFeature, provideMyFeature] = createContext('MyFeature') +``` + +Re-export from the barrel file (`packages/ui/src/providers/index.ts`). + +### 2. For complex platform-specific logic, use an abstract class + +```ts +export abstract class AbstractMyFeatureManager { + abstract items: Ref + abstract addItem(item: Item): Promise + + // Shared logic lives on the base class + handleError(err: unknown) { + console.error(err) + } +} + +export const [injectMyFeature, provideMyFeature] = + createContext('MyFeature') +``` + +See `AbstractWebNotificationManager` in `packages/ui/src/providers/web-notifications.ts` for a real example. + +## Wiring Up Providers + +### App Frontend (Tauri) + +Create a setup function in `apps/app-frontend/src/providers/setup/`: + +```ts +// apps/app-frontend/src/providers/setup/my-feature.ts +import { ref } from 'vue' +import { provideMyFeature } from '@modrinth/ui' + +export function setupMyFeatureProvider() { + const items = ref([]) + + provideMyFeature({ + items, + addItem: async (item) => { + await invoke('add_item', { item }) + items.value.push(item) + }, + removeItem: async (id) => { + await invoke('remove_item', { id }) + items.value = items.value.filter(i => i.id !== id) + }, + }) +} +``` + +Register it in `apps/app-frontend/src/providers/setup.ts`, which is called from `App.vue`'s `setup()`. + +### Website Frontend (Nuxt) + +Provide directly in `apps/frontend/src/app.vue`, using Nuxt's `useState()` where SSR hydration is needed: + +```ts +provideMyFeature({ + items: useState('my-feature-items', () => []), + addItem: async (item) => { + await $fetch('/api/items', { method: 'POST', body: item }) + }, + removeItem: async (id) => { + await $fetch(`/api/items/${id}`, { method: 'DELETE' }) + }, +}) +``` + +## Consuming Providers + +In any component across `packages/ui`, `apps/frontend`, or `apps/app-frontend`: + +```vue + + + +``` + +## When NOT to Use DI + +Default to props and emits. DI adds indirection — only use it with a concrete reason. + +- **Parent to direct child** — use props. +- **Data only exists in one frontend** — keep context local to that app, not in `packages/ui`. +- **Shallow prop drilling (1–2 levels)** — passing through one intermediate is fine. +- **Component-local state** — use `ref()` / `reactive()` locally. + +## Existing Providers + +| Provider | File | Purpose | +|----------|------|---------| +| `provideModrinthClient` | `providers/api-client.ts` | API client instance | +| `provideNotificationManager` | `providers/web-notifications.ts` | Notification management | +| `providePageContext` | `providers/page-context.ts` | Page config (sidebar, ads) | +| `provideProjectPageContext` | `providers/project-page.ts` | Project page state + mutations | +| `provideServerContext` | `providers/server-context.ts` | Server hosting state | +| `provideUserPageContext` | `providers/user-page.ts` | User page state | + +## Key Files + +- `packages/ui/src/providers/index.ts` — `createContext` factory + barrel exports +- `packages/ui/src/providers/*.ts` — Provider definitions +- `apps/frontend/src/app.vue` — Nuxt root provider setup +- `apps/app-frontend/src/App.vue` — Tauri root provider setup +- `apps/app-frontend/src/providers/setup/` — App provider setup functions diff --git a/.claude/skills/figma-mcp/SKILL.md b/.claude/skills/figma-mcp/SKILL.md new file mode 100644 index 0000000000..cd1bf60a27 --- /dev/null +++ b/.claude/skills/figma-mcp/SKILL.md @@ -0,0 +1,45 @@ +# Figma MCP Usage + +When the Figma MCP server is connected, use it to translate Figma designs into production-ready Vue components for this monorepo. + +## Workflow + +### 1. Get the design context + +Use `get_design_context` with the node ID from a Figma URL. If the URL is `https://figma.com/design/:fileKey/:fileName?node-id=1-2`, the node ID is `1:2`. + +``` +get_design_context(nodeId: "1:2", clientLanguages: "typescript,html,css", clientFrameworks: "vue") +``` + +This returns reference code, a screenshot, and metadata. Always start here. + +### 2. Get a screenshot for visual reference + +Use `get_screenshot` if you need to see the design without full code context: + +``` +get_screenshot(nodeId: "1:2") +``` + +### 3. Get variable definitions + +Use `get_variable_defs` to see what design tokens are applied to a node: + +``` +get_variable_defs(nodeId: "1:2") +``` + +### 4. Get metadata for structure overview + +Use `get_metadata` to get an XML overview of node IDs, layer types, names, positions and sizes — useful for understanding the structure of a complex frame before diving into individual nodes. + +## Adapting Figma Output + +The Figma MCP returns generic reference code. Adapt it to match the Modrinth codebase: + +1. **Read `packages/ui/CLAUDE.md`** for color usage rules, surface token mapping, and component patterns. +2. **Map Figma color variables to `surface-*` tokens** — never use Figma's aliased names like `bg/default` or `bg/raised` directly. The CLAUDE.md has the full mapping table. +3. **Check `packages/assets/styles/variables.scss`** for tokens not exposed in Figma (brand highlights, semantic backgrounds, shadows). +4. **Check for existing components** in `packages/ui/src/components/` before building from scratch. +5. **Match spacing exactly** — do not approximate values from the design. diff --git a/.claude/skills/i18n-convert/SKILL.md b/.claude/skills/i18n-convert/SKILL.md new file mode 100644 index 0000000000..80fa2c127f --- /dev/null +++ b/.claude/skills/i18n-convert/SKILL.md @@ -0,0 +1,105 @@ +# i18n String Conversion + +Convert hard-coded natural-language strings in Vue SFCs into the localization system using utilities from `@modrinth/ui`. + +## Rules + +### 1. Identify translatable strings + +- Scan `