Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions .claude/skills/api-module/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<service>/types.ts` with a new top-level namespace and re-export it from `modules/types.ts`.

### 2. Create the module class

Create `modules/<api>/<domain>/v<N>.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<Labrinth.MyDomain.v3.Thing> {
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}

public async create(data: Labrinth.MyDomain.v3.CreateThingRequest): Promise<Labrinth.MyDomain.v3.Thing> {
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
})
}

public async delete(id: string): Promise<void> {
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<string, string>` | 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<void> {
return this.client.upload<void>(`/thing/${id}/file`, {
api: 'labrinth',
version: 3,
file,
})
}

// Or with FormData for multipart:
public createWithFiles(data: CreateRequest, files: File[]): UploadHandle<Thing> {
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>(`/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 `<api>_<domain>_<version>`. 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 './<service>/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/<api>/types.ts` — type definitions per API
- `src/types/upload.ts``UploadHandle`, `UploadProgress`, `UploadRequestOptions`
144 changes: 144 additions & 0 deletions .claude/skills/cross-platform-pages/SKILL.md
Original file line number Diff line number Diff line change
@@ -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<ContentItem[]>
loading: Ref<boolean>
error: Ref<Error | null>
contentTypeLabel: Ref<string>

// These are the platform-abstracted operations:
// App uses invoke(), website uses api-client
toggleEnabled: (item: ContentItem) => Promise<void>
deleteItem: (item: ContentItem) => Promise<void>
refresh: () => Promise<void>
browse: () => void
uploadFiles: () => void

// Optional capabilities — not every platform supports everything
hasUpdateSupport: boolean
updateItem?: (item: ContentItem) => Promise<void>
bulkUpdateItem?: (items: ContentItem[]) => Promise<void>

mapToTableItem: (item: ContentItem) => ContentCardTableItem
}

export const [injectContentManager, provideContentManager] =
createContext<ContentManagerContext>('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
<!-- packages/ui/src/components/instances/ContentPageLayout.vue -->
<script setup lang="ts">
import { injectContentManager } from '../../providers/content-manager'
const { items, loading, toggleEnabled, deleteItem, refresh, mapToTableItem } =
injectContentManager()
// All UI logic lives here — search, filters, sort, bulk ops, etc.
</script>
<template>
<ContentCardTable :items="filteredItems" />
</template>
```

### 3. Each platform provides its implementation

**Website (Nuxt)** — uses `api-client` or `useBaseFetch`:

```vue
<!-- apps/frontend/src/pages/hosting/manage/[id]/content.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
const { labrinth } = injectModrinthClient()
const { data: items } = useQuery({
queryKey: ['content', serverId],
queryFn: () => labrinth.servers_v0.getAddons(serverId),
})
provideContentManager({
items: computed(() => items.value?.map(addonToContentItem) ?? []),
deleteItem: async (item) => {
await labrinth.servers_v0.deleteAddon(serverId, item.id)
},
// ... rest of the contract
})
</script>
<template>
<ContentPageLayout />
</template>
```

**App (Tauri)** — uses `invoke`:

```vue
<!-- apps/app-frontend/src/pages/instance/Content.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
const items = ref<ContentItem[]>([])
await invoke('get_instance_content', { instanceId }).then(/* map to ContentItem[] */)
provideContentManager({
items,
deleteItem: async (item) => {
await invoke('delete_content', { instanceId, path: item.file_path })
},
// ... rest of the contract
})
</script>
<template>
<ContentPageLayout />
</template>
```

## 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
Loading
Loading