mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51066c476a | ||
|
|
47ef7ee42e | ||
|
|
6fba33d443 | ||
|
|
017f6a5afb | ||
|
|
1ab722411a |
@@ -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`
|
||||
@@ -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
|
||||
@@ -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<string>
|
||||
doSomething: () => void
|
||||
}
|
||||
|
||||
export const [injectMyContext, provideMyContext] = createContext<MyContext>('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<Item[]>
|
||||
addItem: (item: Item) => Promise<void>
|
||||
removeItem: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectMyFeature, provideMyFeature] = createContext<MyFeatureContext>('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<Item[]>
|
||||
abstract addItem(item: Item): Promise<void>
|
||||
|
||||
// Shared logic lives on the base class
|
||||
handleError(err: unknown) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
export const [injectMyFeature, provideMyFeature] =
|
||||
createContext<AbstractMyFeatureManager>('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<Item[]>([])
|
||||
|
||||
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<Item[]>('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
|
||||
<script setup lang="ts">
|
||||
import { injectMyFeature } from '@modrinth/ui'
|
||||
|
||||
const { items, addItem } = injectMyFeature()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
|
||||
<button @click="addItem({ id: '1', name: 'New' })">Add</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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.
|
||||
@@ -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 `<template>` for all user-visible strings: inner text, alt attributes, placeholders, button labels, etc.
|
||||
- Check `<script>` too: dropdown option labels, notification messages, etc.
|
||||
- Do NOT extract dynamic expressions (`{{ user.name }}`) or HTML tags — only static human-readable text.
|
||||
|
||||
### 2. Create message definitions
|
||||
|
||||
Import `defineMessage` or `defineMessages` from `@modrinth/ui` in `<script setup>`. Define messages with a unique `id` (descriptive prefix based on component path) and `defaultMessage` equal to the original English string:
|
||||
|
||||
```ts
|
||||
const messages = defineMessages({
|
||||
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
|
||||
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: "You're now part of the community…" },
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Handle variables and ICU formats
|
||||
|
||||
- Dynamic parts become ICU placeholders: `"Hello, ${user.name}!"` → `defaultMessage: 'Hello, {name}!'`
|
||||
- Numbers/dates/times use ICU options: `{price, number, ::currency/USD}`
|
||||
- Plurals/selects use ICU: `'{count, plural, one {# message} other {# messages}}'`
|
||||
|
||||
### 4. Rich-text messages (links/markup)
|
||||
|
||||
Wrap link/markup ranges with tags in `defaultMessage`:
|
||||
|
||||
```
|
||||
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
|
||||
```
|
||||
|
||||
Render with `<IntlFormatted>` from `@modrinth/ui` using named slots:
|
||||
|
||||
```vue
|
||||
<IntlFormatted :message-id="messages.tosLabel">
|
||||
<template #terms-link="{ children }">
|
||||
<NuxtLink to="/terms">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #privacy-link="{ children }">
|
||||
<NuxtLink to="/privacy">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
```
|
||||
|
||||
For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` with a slot:
|
||||
|
||||
```vue
|
||||
<template #strong="{ children }">
|
||||
<strong><component :is="() => children" /></strong>
|
||||
</template>
|
||||
```
|
||||
|
||||
For complex child handling, use `normalizeChildren` from `@modrinth/ui`:
|
||||
|
||||
```vue
|
||||
<template #bold="{ children }">
|
||||
<strong><component :is="() => normalizeChildren(children)" /></strong>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5. Formatting in templates
|
||||
|
||||
Use `useVIntl()` from `@modrinth/ui`; prefer `formatMessage` for simple strings:
|
||||
|
||||
```ts
|
||||
const { formatMessage } = useVIntl()
|
||||
```
|
||||
|
||||
```vue
|
||||
<button>{{ formatMessage(messages.welcomeTitle) }}</button>
|
||||
{{ formatMessage(messages.greeting, { name: user.name }) }}
|
||||
```
|
||||
|
||||
### 6. Naming conventions
|
||||
|
||||
Make `id`s descriptive and stable (e.g., `error.generic.default.title`). Group related messages with `defineMessages`.
|
||||
|
||||
### 7. Avoid Vue/ICU delimiter collisions
|
||||
|
||||
If an ICU placeholder ends right before `}}` in a Vue template, insert a space: `} }` to avoid parsing issues.
|
||||
|
||||
### 8. Imports
|
||||
|
||||
Ensure these are imported from `@modrinth/ui` as needed: `defineMessage`/`defineMessages`, `useVIntl`, `IntlFormatted`, `normalizeChildren`.
|
||||
|
||||
### 9. Preserve functionality
|
||||
|
||||
Do not change logic, layout, reactivity, or bindings — only refactor strings into i18n.
|
||||
|
||||
## Reference Examples
|
||||
|
||||
- Variables/plurals: `apps/frontend/src/pages/frog.vue`
|
||||
- Rich-text link tags: `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
|
||||
|
||||
When finished, there should be no hard-coded English strings left in the template — everything comes from `formatMessage` or `<IntlFormatted>`.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Multistage Modals
|
||||
|
||||
The `MultiStageModal` component (`packages/ui/src/components/base/MultiStageModal.vue`) provides a wizard-like modal with progress tracking, conditional stages, and per-stage button configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
A multistage modal has three parts:
|
||||
|
||||
1. **Context** — A DI provider that holds all state, business logic, and stage configs
|
||||
2. **Stage configs** — Data objects describing each stage (title, component, buttons, skip conditions)
|
||||
3. **Stage components** — Vue components rendered inside the modal, consuming the context
|
||||
|
||||
## Building a Multistage Modal
|
||||
|
||||
### 1. Define the context
|
||||
|
||||
Create a DI provider with all the state your wizard needs. Include the modal ref and stage configs.
|
||||
|
||||
```ts
|
||||
// providers/my-feature/my-modal.ts
|
||||
import type { ShallowRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import type { MultiStageModal, StageConfigInput } from '@modrinth/ui'
|
||||
import { createContext } from '@modrinth/ui'
|
||||
|
||||
export interface MyModalContext {
|
||||
// State
|
||||
formData: Ref<MyFormData>
|
||||
isSubmitting: Ref<boolean>
|
||||
|
||||
// Modal control
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
|
||||
stageConfigs: StageConfigInput<MyModalContext>[]
|
||||
|
||||
// Business logic
|
||||
handleSubmit: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectMyModalContext, provideMyModalContext] =
|
||||
createContext<MyModalContext>('MyModal')
|
||||
|
||||
export function createMyModalContext(
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
|
||||
): MyModalContext {
|
||||
const formData = ref<MyFormData>({ ... })
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await saveData(formData.value)
|
||||
modal.value?.hide()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { formData, isSubmitting, modal, stageConfigs, handleSubmit }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Define stage configs
|
||||
|
||||
Each stage is a `StageConfigInput<T>` where `T` is your context type. Most fields accept either a static value or a function receiving the context (`MaybeCtxFn<T, R>`).
|
||||
|
||||
```ts
|
||||
// providers/my-feature/stages/details-stage.ts
|
||||
import { markRaw } from 'vue'
|
||||
import type { StageConfigInput } from '@modrinth/ui'
|
||||
import type { MyModalContext } from '../my-modal'
|
||||
import DetailsStage from './DetailsStage.vue'
|
||||
import { RightArrowIcon, SaveIcon } from '@modrinth/assets'
|
||||
|
||||
export const detailsStageConfig: StageConfigInput<MyModalContext> = {
|
||||
id: 'details',
|
||||
stageContent: markRaw(DetailsStage),
|
||||
title: 'Details',
|
||||
|
||||
// Conditional behavior based on context
|
||||
skip: (ctx) => ctx.shouldSkipDetails.value,
|
||||
cannotNavigateForward: (ctx) => !ctx.formData.value.name,
|
||||
disableClose: (ctx) => ctx.isSubmitting.value,
|
||||
|
||||
leftButtonConfig: (ctx) => ({
|
||||
label: 'Cancel',
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}),
|
||||
|
||||
rightButtonConfig: (ctx) => ({
|
||||
label: 'Next',
|
||||
icon: RightArrowIcon,
|
||||
iconPosition: 'after',
|
||||
disabled: !ctx.formData.value.name,
|
||||
onClick: () => ctx.modal.value?.nextStage(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**Stage config fields:**
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `id` | `string` | Unique stage identifier (used with `setStage()`) |
|
||||
| `stageContent` | `Component` | Vue component to render (wrap with `markRaw()`) |
|
||||
| `title` | `MaybeCtxFn<T, string>` | Stage title in breadcrumbs |
|
||||
| `skip` | `MaybeCtxFn<T, boolean>` | Skip this stage conditionally |
|
||||
| `nonProgressStage` | `MaybeCtxFn<T, boolean>` | Exclude from progress bar (for edit sub-flows) |
|
||||
| `hideStageInBreadcrumb` | `MaybeCtxFn<T, boolean>` | Hide from breadcrumb nav |
|
||||
| `cannotNavigateForward` | `MaybeCtxFn<T, boolean>` | Block forward navigation (validation) |
|
||||
| `disableClose` | `MaybeCtxFn<T, boolean>` | Disable closing the modal |
|
||||
| `leftButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Left action button |
|
||||
| `rightButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Right action button |
|
||||
| `maxWidth` | `MaybeCtxFn<T, string>` | Per-stage max width (default `560px`) |
|
||||
|
||||
**Button config fields:**
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `label` | Button text |
|
||||
| `icon` | Icon component |
|
||||
| `iconPosition` | `'before'` or `'after'` |
|
||||
| `color` | ButtonStyled color prop |
|
||||
| `disabled` | Disable the button |
|
||||
| `onClick` | Click handler |
|
||||
|
||||
### 3. Create stage components
|
||||
|
||||
Stage components inject the context and render their UI:
|
||||
|
||||
```vue
|
||||
<!-- providers/my-feature/stages/DetailsStage.vue -->
|
||||
<script setup lang="ts">
|
||||
import { injectMyModalContext } from '../my-modal'
|
||||
|
||||
const { formData } = injectMyModalContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<StyledInput v-model="formData.name" label="Name" />
|
||||
<StyledInput v-model="formData.description" label="Description" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 4. Create the wrapper component
|
||||
|
||||
The wrapper provides context and renders `MultiStageModal`:
|
||||
|
||||
```vue
|
||||
<!-- components/MyModalWrapper.vue -->
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue'
|
||||
import { MultiStageModal } from '@modrinth/ui'
|
||||
import { createMyModalContext, provideMyModalContext } from '../providers/my-feature/my-modal'
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MultiStageModal> | null>(null)
|
||||
const ctx = createMyModalContext(modal)
|
||||
provideMyModalContext(ctx)
|
||||
|
||||
defineExpose({ show: () => modal.value?.show() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Modal API
|
||||
|
||||
`MultiStageModal` exposes via ref:
|
||||
|
||||
| Method/Property | Description |
|
||||
|----------------|-------------|
|
||||
| `show()` | Open the modal |
|
||||
| `hide()` | Close the modal |
|
||||
| `setStage(indexOrId)` | Jump to stage by index or string id |
|
||||
| `nextStage()` | Advance to next non-skipped stage |
|
||||
| `prevStage()` | Go back to previous stage |
|
||||
| `currentStageIndex` | Ref to current stage index |
|
||||
|
||||
## Non-Progress Stages (Edit Sub-Flows)
|
||||
|
||||
For stages that shouldn't appear in the progress bar (e.g. editing a specific field from a summary page):
|
||||
|
||||
```ts
|
||||
export const editLoadersStageConfig: StageConfigInput<MyContext> = {
|
||||
id: 'edit-loaders',
|
||||
nonProgressStage: true,
|
||||
stageContent: markRaw(EditLoadersStage),
|
||||
title: 'Edit loaders',
|
||||
leftButtonConfig: (ctx) => ({
|
||||
label: 'Back',
|
||||
onClick: () => ctx.modal.value?.setStage('summary'),
|
||||
}),
|
||||
rightButtonConfig: (ctx) => ({
|
||||
...ctx.saveButtonConfig(),
|
||||
label: 'Save',
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
Navigate to it with `modal.value?.setStage('edit-loaders')` — it won't affect the progress indicator.
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
The version creation/edit modal is the most complete example:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/frontend/src/providers/version/manage-version-modal.ts` | Context creation + business logic |
|
||||
| `apps/frontend/src/providers/version/stages/index.ts` | Stage config barrel export |
|
||||
| `apps/frontend/src/providers/version/stages/*-stage.ts` | Individual stage configs |
|
||||
|
||||
The context includes computed properties for conditional UI, watchers for auto-fetching dependencies, loading states for granular button disabling, and both "create" and "edit" flows sharing the same stages with different button configs.
|
||||
@@ -0,0 +1,154 @@
|
||||
# TanStack Query
|
||||
|
||||
TanStack Query (`@tanstack/vue-query` v5) is used for server state management — caching, background refetching, and cache invalidation. Use it instead of manual `ref()` + `await` patterns for any data that comes from an API.
|
||||
|
||||
A TanStack MCP server is available — use `tanstack_doc` and `tanstack_search_docs` tools to look up API details when needed.
|
||||
|
||||
## Setup
|
||||
|
||||
TanStack Query is configured in `apps/frontend/src/plugins/tanstack.ts` as a Nuxt plugin with SSR hydration support. Default stale time is 5 seconds. The `QueryClient` is available via `useQueryClient()` or `useAppQueryClient()` (which also works in middleware).
|
||||
|
||||
## Queries
|
||||
|
||||
Use `useQuery` with the api-client for data fetching:
|
||||
|
||||
```ts
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: ['project', 'v3', projectId],
|
||||
queryFn: () => client.labrinth.projects_v3.get(projectId),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
```
|
||||
|
||||
In templates:
|
||||
|
||||
```vue
|
||||
<span v-if="isPending">Loading...</span>
|
||||
<span v-else-if="isError">Error: {{ error.message }}</span>
|
||||
<div v-else>{{ data.title }}</div>
|
||||
```
|
||||
|
||||
### Query Option Factories
|
||||
|
||||
For queries used across multiple components, define reusable query option factories in `packages/ui/src/queries/`:
|
||||
|
||||
```ts
|
||||
// composables/queries/project.ts
|
||||
export const STALE_TIME = 1000 * 60 * 5
|
||||
export const STALE_TIME_LONG = 1000 * 60 * 10
|
||||
|
||||
export const projectQueryOptions = {
|
||||
v3: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'v3', projectId] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.get(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
members: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'members'] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
Then use them:
|
||||
|
||||
```ts
|
||||
const { data } = useQuery(projectQueryOptions.v3(projectId, client))
|
||||
```
|
||||
|
||||
### Conditional Queries
|
||||
|
||||
Use `enabled` as a computed for queries that depend on other data:
|
||||
|
||||
```ts
|
||||
const { data: members } = useQuery({
|
||||
queryKey: ['project', projectId, 'members'],
|
||||
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
|
||||
enabled: computed(() => !!projectId.value),
|
||||
})
|
||||
```
|
||||
|
||||
## Mutations
|
||||
|
||||
Use `useMutation` for create/update/delete operations. Invalidate related queries on success:
|
||||
|
||||
```ts
|
||||
const queryClient = useQueryClient()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => client.archon.backups_v0.create(serverId, { name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', 'list', serverId] }),
|
||||
})
|
||||
```
|
||||
|
||||
Use `createMutation.isPending.value` to disable buttons during submission.
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
For mutations where responsiveness matters, use optimistic updates with rollback:
|
||||
|
||||
```ts
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: async ({ projectId, data }) => {
|
||||
await client.labrinth.projects_v3.patch(projectId, data)
|
||||
return data
|
||||
},
|
||||
|
||||
onMutate: async ({ projectId, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
|
||||
const previous = queryClient.getQueryData(['project', 'v3', projectId])
|
||||
|
||||
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
|
||||
if (!old) return old
|
||||
return { ...old, ...data }
|
||||
})
|
||||
|
||||
return { previous }
|
||||
},
|
||||
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['project', 'v3', projectId], context.previous)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Query Keys
|
||||
|
||||
Keys use a hierarchical array pattern:
|
||||
|
||||
```ts
|
||||
// Resource type → version/qualifier → ID
|
||||
['project', 'v3', projectId]
|
||||
|
||||
// Resource type → ID → sub-resource
|
||||
['project', projectId, 'members']
|
||||
['project', projectId, 'versions', 'v3']
|
||||
|
||||
// Domain → action → ID
|
||||
['backups', 'list', serverId]
|
||||
['tech-reviews']
|
||||
```
|
||||
|
||||
Use `as const` for type safety. Put the resource ID last when possible — this makes partial key matching work for invalidation:
|
||||
|
||||
```ts
|
||||
// Invalidates all project queries for this ID
|
||||
queryClient.invalidateQueries({ queryKey: ['project', projectId] })
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `apps/frontend/src/plugins/tanstack.ts` — QueryClient setup + SSR hydration
|
||||
- `apps/frontend/src/composables/query-client.ts` — `useAppQueryClient()` helper
|
||||
- `apps/frontend/src/composables/queries/` — reusable query option factories
|
||||
+2
-1
@@ -64,7 +64,8 @@ generated
|
||||
app-playground-data/*
|
||||
|
||||
.astro
|
||||
.claude
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.letta
|
||||
|
||||
# labrinth demo fixtures
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
See [CLAUDE.md](./CLAUDE.md) for all project instructions and guidelines.
|
||||
|
||||
## Skills
|
||||
|
||||
Project-specific skills (patterns, conventions, and implementation guides) are located in [`.claude/skills/`](./.claude/skills/). Each skill has a `SKILL.md` describing the pattern:
|
||||
|
||||
- **[Dependency Injection](./.claude/skills/dependency-injection/SKILL.md)** — Vue provide/inject DI layer using `createContext`
|
||||
- **[Cross-Platform Pages](./.claude/skills/cross-platform-pages/SKILL.md)** — Shared component architecture across Nuxt and Tauri frontends
|
||||
- **[Multistage Modals](./.claude/skills/multistage-modals/SKILL.md)** — Wizard-like modal flows with `MultiStageModal`
|
||||
- **[Figma MCP](./.claude/skills/figma-mcp/SKILL.md)** — Translating Figma designs to Modrinth Vue components
|
||||
- **[i18n Convert](./.claude/skills/i18n-convert/SKILL.md)** — Converting hard-coded strings to vue-i18n localization
|
||||
- **[API Module](./.claude/skills/api-module/SKILL.md)** — Adding new endpoint modules to `@modrinth/api-client`
|
||||
- **[TanStack Query](./.claude/skills/tanstack-query/SKILL.md)** — Server state management with `@tanstack/vue-query` v5
|
||||
@@ -1,63 +1,86 @@
|
||||
# Architecture
|
||||
# Modrinth Monorepo
|
||||
|
||||
Use TAB instead of spaces.
|
||||
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read it's CLAUDE.md.
|
||||
|
||||
## Frontend
|
||||
## Architecture
|
||||
|
||||
There are two similar frontends in the Modrinth monorepo, the website (apps/frontend) and the app frontend (apps/app-frontend).
|
||||
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
|
||||
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
|
||||
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
|
||||
- **Indentation:** Use TAB everywhere, never spaces
|
||||
|
||||
Both use Tailwind v3, and their respective configs can be seen at `tailwind.config.ts` and `tailwind.config.js` respectively.
|
||||
### Apps (`apps/`)
|
||||
|
||||
Both utilize shared and common components from `@modrinth/ui` which can be found at `packages/ui`, and stylings from `@modrinth/assets` which can be found at `packages/assets`.
|
||||
| App | Description |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `frontend` | Main Modrinth website (Nuxt 3) |
|
||||
| `app-frontend` | Desktop/app frontend (Vue 3) |
|
||||
| `app` | Desktop/app shell (Tauri) |
|
||||
| `app-playground` | Testing playground for app |
|
||||
| `labrinth` | Backend API service |
|
||||
| `daedalus_client` | Daedalus client implementation |
|
||||
| `docs` | Documentation site (Astro) |
|
||||
|
||||
Both can utilize icons from `@modrinth/assets`, which are automatically generated based on what's available within the `icons` folder of the `packages/assets` directory. You can see the generated icons list in `generated-icons.ts`.
|
||||
### Packages (`packages/`)
|
||||
|
||||
Both have access to our dependency injection framework, examples as seen in `packages/ui/src/providers/`. Ideally any state which is shared between a page and it's subpages should be shared using this dependency injection framework.
|
||||
| Package | Description |
|
||||
| ------------------ | ----------------------------------------------------- |
|
||||
| `ui` | Shared Vue component library (`@modrinth/ui`) |
|
||||
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
|
||||
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
|
||||
| `app-lib` | Shared app library |
|
||||
| `blog` | Blog system and changelog data |
|
||||
| `utils` | Shared utility functions |
|
||||
| `moderation` | Moderation utilities |
|
||||
| `daedalus` | Daedalus protocol |
|
||||
| `tooling-config` | ESLint, Prettier, TypeScript configs |
|
||||
| `ariadne` | Analytics library |
|
||||
| `modrinth-log` | Logging utilities |
|
||||
| `modrinth-maxmind` | MaxMind GeoIP |
|
||||
| `modrinth-util` | General utilities |
|
||||
| `muralpay` | Payment processing |
|
||||
| `path-util` | Path utilities |
|
||||
| `sqlx-tracing` | SQLx query tracing |
|
||||
|
||||
### Website (apps/frontend)
|
||||
## Pre-PR Commands
|
||||
|
||||
Before a pull request can be opened for the website, run `pnpm prepr:frontend:web` from the root folder, otherwise CI will fail.
|
||||
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
|
||||
|
||||
To run a development version of the frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within the `apps/frontend` folder into `apps/frontend/.env`. Then you can run the frontend by running `pnpm web:dev` in the root folder.
|
||||
- **Website:** `pnpm prepr:frontend:web`
|
||||
- **App frontend:** `pnpm prepr:frontend:app`
|
||||
- **Frontend libs:** `pnpm prepr:frontend:lib`
|
||||
- **All frontend (app+web):** `pnpm prepr`
|
||||
- **Labrinth (backend):** See `apps/labrinth/CLAUDE.md`
|
||||
|
||||
### App Frontend (apps/app-frontend)
|
||||
The website and app `prepr` commands
|
||||
|
||||
Before a pull request can be opened for the app frontend, run `pnpm prepr:frontend:app` from the root folder, otherwise CI will fail.
|
||||
## Dev Commands
|
||||
|
||||
To run a development version of the app frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within `packages/app-lib` into `packages/app-lib/.env`. Then you must run the app itself by running `pnpm app:dev` in the root folder.
|
||||
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
|
||||
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
|
||||
- **Storybook (packages/ui):** `pnpm storybook`
|
||||
|
||||
### Localization
|
||||
## Project-Specific Instructions
|
||||
|
||||
Refer to `.github/instructions/i18n-convert.instructions.md` if the user asks you to perform any i18n conversion work on a component, set of components, pages or sets of pages.
|
||||
Each project may have its own `CLAUDE.md` with detailed instructions:
|
||||
|
||||
## Labrinth
|
||||
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
|
||||
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
|
||||
|
||||
Labrinth is the backend API service for Modrinth.
|
||||
## Code Guidelines
|
||||
|
||||
### Testing
|
||||
### Comments
|
||||
- DO NOT use "heading" comments like: // === Helper methods === .
|
||||
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
|
||||
|
||||
Before a pull request can be opened, run `cargo clippy -p labrinth --all-targets` and make sure there are ZERO warnings, otherwise CI will fail.
|
||||
## Bash Guidelines
|
||||
|
||||
Use `cargo test -p labrinth --all-targets` to test your changes. All tests must pass, otherwise CI will fail.
|
||||
### Output handling
|
||||
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
|
||||
- NEVER use `| head -n X` or `| tail -n X` to truncate output
|
||||
- Run commands directly without pipes when possible
|
||||
- If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
|
||||
- ALWAYS read the full output — never pipe through filters
|
||||
|
||||
To prepare the sqlx cache, cd into `apps/labrinth` and run `cargo sqlx prepare`. Make sure to NEVER run `cargo sqlx prepare --workspace`.
|
||||
|
||||
Read the root `docker-compose.yml` to see what running services are available while developing. Use `docker exec` to access these services.
|
||||
|
||||
When the user refers to "performing pre-PR checks", do the following:
|
||||
|
||||
- Run clippy as described above
|
||||
- DO NOT run tests unless explicitly requested (they take a long time)
|
||||
- Prepare the sqlx cache
|
||||
|
||||
### Clickhouse
|
||||
|
||||
Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse instance. We use the `staging_ariadne` database to store data in testing.
|
||||
|
||||
### Postgres
|
||||
|
||||
Use `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "SELECT 1"` to access the PostgreSQL instance, replacing the `SELECT 1` with your query.
|
||||
|
||||
# Guidelines
|
||||
|
||||
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to.
|
||||
### General
|
||||
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# apps/frontend — Modrinth Website
|
||||
|
||||
Nuxt 3 application serving the main Modrinth website. Uses Vue 3, Tailwind CSS v3, and file-based routing.
|
||||
|
||||
## Architecture
|
||||
|
||||
Nuxt 3 with SSR — pages are server-rendered and hydrated on the client. Uses `$fetch` for server-side data fetching and `@modrinth/api-client` (via `NuxtModrinthClient`) for client-side API calls.
|
||||
|
||||
## Key Directories
|
||||
|
||||
- **`src/pages/`** — file-based routing (`[param].vue` for dynamic segments, nested folders for nested routes)
|
||||
- **`src/components/`** — website-specific components (not shared with the app)
|
||||
- **`src/composables/`** — Vue composables, including `queries/` for TanStack Query options
|
||||
- **`src/providers/`** — page-level DI context providers (e.g., version modal, project page)
|
||||
- **`src/plugins/`** — Nuxt plugins (TanStack Query setup, theme, etc.)
|
||||
- **`src/middleware/`** — route guards and auth checks
|
||||
- **`src/layouts/`** — Nuxt layout components
|
||||
- **`src/server/`** — server-side plugins, routes, and utilities
|
||||
- **`src/store/`** — Pinia state management
|
||||
- **`src/helpers/`** — utility functions
|
||||
- **`src/locales/`** — i18n translation files
|
||||
|
||||
## Components
|
||||
|
||||
**Website-specific components go in `src/components/`.** These are components that only make sense in the website context — admin panels, moderation tools, dashboard widgets, brand components, etc.
|
||||
|
||||
**Shared components go in `packages/ui`.** If a component could be used by both the website and the desktop app, it belongs in `packages/ui/src/components/`. See `packages/ui/CLAUDE.md` for UI standards, color rules, and component patterns.
|
||||
|
||||
Rule of thumb: if it doesn't depend on Nuxt-specific APIs or website-only features, it should be in `packages/ui`.
|
||||
|
||||
## Data Fetching
|
||||
|
||||
Use `@modrinth/api-client` via `injectModrinthClient()` for all API calls. See `packages/api-client/CLAUDE.md` for the full API client documentation.
|
||||
|
||||
For caching and server state, use TanStack Query (`@tanstack/vue-query`). See the `tanstack-query` skill (`.claude/skills/tanstack-query/SKILL.md`) for patterns and conventions used in this codebase.
|
||||
|
||||
### Deprecated Composables
|
||||
|
||||
These composables are deprecated and should not be used in new code:
|
||||
|
||||
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
|
||||
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
|
||||
- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information.
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
|
||||
* This composable is kept for legacy code that hasn't been migrated yet.
|
||||
*/
|
||||
|
||||
let cachedRateLimitKey = undefined
|
||||
let rateLimitKeyPromise = undefined
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
|
||||
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
|
||||
* retry, and circuit breaking automatically. This composable is kept for legacy
|
||||
* code that hasn't been migrated yet.
|
||||
*/
|
||||
|
||||
import { PANEL_VERSION } from '@modrinth/api-client'
|
||||
import type { V1ErrorInfo } from '@modrinth/utils'
|
||||
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
|
||||
|
||||
@@ -4,120 +4,8 @@
|
||||
|
||||
# Migrations existing before Prettier formatted them shall always be ignored,
|
||||
# as any changes to them will break existing deployments
|
||||
migrations/20200716160921_init.sql
|
||||
migrations/20200717192808_Make_categories_non-null.sql
|
||||
migrations/20200722031742_initial-release-channels.sql
|
||||
migrations/20200722033157_rename-release-channels.sql
|
||||
migrations/20200722153930_version-filename.sql
|
||||
migrations/20200730223151_more-not-null.sql
|
||||
migrations/20200812183213_unique-loaders.sql
|
||||
migrations/20200928020509_states.sql
|
||||
migrations/20200928033759_edit-states.sql
|
||||
migrations/20200928053955_make-url-not-null.sql
|
||||
migrations/20200928170310_create-users.sql
|
||||
migrations/20200928195220_add-roles-to-users.sql
|
||||
migrations/20200929034101_add-author-to-versions.sql
|
||||
migrations/20201001015631_not-null-github-avatar.sql
|
||||
migrations/20201003211651_make-name-null.sql
|
||||
migrations/20201014165954_create-statuses.sql
|
||||
migrations/20201021214908_extend-game-version.sql
|
||||
migrations/20201029190804_add-game-version-datetime.sql
|
||||
migrations/20201109200208_edit-teams.sql
|
||||
migrations/20201112052516_moderation.sql
|
||||
migrations/20201122043349_more-mod-data.sql
|
||||
migrations/20201213013358_remove-member-name.sql
|
||||
migrations/20210113202021_add-descriptions.sql
|
||||
migrations/20210118161307_remove-version-access.sql
|
||||
migrations/20210129224854_dependency-types.sql
|
||||
migrations/20210201001429_reports.sql
|
||||
migrations/20210224174945_notifications.sql
|
||||
migrations/20210301041252_follows.sql
|
||||
migrations/20210509010206_project_types.sql
|
||||
migrations/20210611024943_archived-status-notifications-icon-rejection-reasons.sql
|
||||
migrations/20210718223710_gallery.sql
|
||||
migrations/20210727160151_gallery_featuring_rejection_rename.sql
|
||||
migrations/20210805044459_more_gallery_info.sql
|
||||
migrations/20210820053031_version-optimization.sql
|
||||
migrations/20220210032959_remove-categories-unique.sql
|
||||
migrations/20220220035037_remove_downloads_table.sql
|
||||
migrations/20220329182356_file-sizes.sql
|
||||
migrations/20220526040434_dep-file-names.sql
|
||||
migrations/20220725204351_more-project-data.sql
|
||||
migrations/20220801184215_banned-users.sql
|
||||
migrations/20220902025606_initial-payouts.sql
|
||||
migrations/20220928044123_payouts-scheduling.sql
|
||||
migrations/20221107171016_payouts-overhaul.sql
|
||||
migrations/20221111163753_fix-precision.sql
|
||||
migrations/20221111202802_fix-precision-again.sql
|
||||
migrations/20221116200727_flame-anvil-integration.sql
|
||||
migrations/20221126222222_spdx-licenses.sql
|
||||
migrations/20221129161609_status-types-changes.sql
|
||||
migrations/20221206221021_webhook-sent.sql
|
||||
migrations/20221217041358_ordering-galore.sql
|
||||
migrations/20221217215337_drop-body_url-changelog_url.sql
|
||||
migrations/20221223192812_file-labels.sql
|
||||
migrations/20221227010515_project-colors.sql
|
||||
migrations/20230104214503_random-projects.sql
|
||||
migrations/20230127233123_loader-gv-mod.sql
|
||||
migrations/20230221212958_queue-date.sql
|
||||
migrations/20230324202117_messaging.sql
|
||||
migrations/20230414203933_threads-fix.sql
|
||||
migrations/20230416033024_deps-project-mandatory.sql
|
||||
migrations/20230421174120_remove-threads-ref.sql
|
||||
migrations/20230502141522_minos-support.sql
|
||||
migrations/20230628180115_kill-ory.sql
|
||||
migrations/20230710034250_flows.sql
|
||||
migrations/20230711004131_2fa.sql
|
||||
migrations/20230714235551_fix-2fa-type.sql
|
||||
migrations/20230808043323_threads-index.sql
|
||||
migrations/20230808162652_gv-loader-fixes.sql
|
||||
migrations/20230816085700_collections_and_more.sql
|
||||
migrations/20230913024611_organizations.sql
|
||||
migrations/20231005230721_dynamic-fields.sql
|
||||
migrations/20231016190056_oauth_provider.sql
|
||||
migrations/20231027195838_version_ordering.sql
|
||||
migrations/20231110010322_adds_game_version_minmax.sql
|
||||
migrations/20231113104902_games_metadata.sql
|
||||
migrations/20231114175920_new-payment-methods.sql
|
||||
migrations/20231115105022_plugins_datapacks_v3.sql
|
||||
migrations/20231116112800_side_types_overhaul.sql
|
||||
migrations/20231117073600_links_overhaul.sql
|
||||
migrations/20231122230639_oauth_client_metadata.sql
|
||||
migrations/20231124070100_renaming_consistency.sql
|
||||
migrations/20231125080100_drops_mods_dp_plugins.sql
|
||||
migrations/20231130153100_loader_fields_loaders.sql
|
||||
migrations/20231205095400_remaining_loader_field_loaders.sql
|
||||
migrations/20231211184922_collections_description_nullable.sql
|
||||
migrations/20231213103100_enforces-owner-unique.sql
|
||||
migrations/20240104203711_orgs-names.sql
|
||||
migrations/20240115052708_base62-helper-functions.sql
|
||||
migrations/20240131224610_moderation_packs.sql
|
||||
migrations/20240221215354_moderation_pack_fixes.sql
|
||||
migrations/20240319195753_threads-updates.sql
|
||||
migrations/20240701213559_remove-user-names.sql
|
||||
migrations/20240702213250_subscriptions.sql
|
||||
migrations/20240907192840_raw-images.sql
|
||||
migrations/20240911044738_payouts-updates.sql
|
||||
migrations/20240923163452_charges-fix.sql
|
||||
migrations/20241121232522_friends.sql
|
||||
migrations/20241204190127_revenue_updates.sql
|
||||
migrations/20241224001114_teams-precision-fix.sql
|
||||
migrations/20250117013050_missing-primary-keys.sql
|
||||
migrations/20250519184051_shared-instances.sql
|
||||
migrations/20250523174544_project-versions-environments.sql
|
||||
migrations/20250608183828_random-project-index.sql
|
||||
migrations/20250609134334_spatial-random-project-index.sql
|
||||
migrations/20250611164523_lowercase-user-email-index.sql
|
||||
migrations/20250628213541_payout-sources-recording.sql
|
||||
migrations/20250725230041_reports-closed-status-index.sql
|
||||
migrations/20250727184120_user-newsletter-subscription-column.sql
|
||||
migrations/20250804221014_users-redeemals.sql
|
||||
migrations/20250805001654_product-prices-public.sql
|
||||
migrations/20250823233518_user-compliance.sql
|
||||
migrations/20250902133943_notification-extension.sql
|
||||
migrations/20250914190749_affiliate_codes.sql
|
||||
migrations/20250927120742_user_limits.sql
|
||||
migrations/**
|
||||
|
||||
# Prettier reformats some of the PostgreSQL-specific COPY syntax here,
|
||||
# which is very likely to break things
|
||||
fixtures/labrinth-seed-data-202508052143.sql
|
||||
fixtures/**
|
||||
|
||||
Generated
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status: PayoutStatus",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286"
|
||||
}
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO sessions (\n id, session, user_id, os, platform,\n city, country, ip, user_agent\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9\n )\n ",
|
||||
"query": "\n INSERT INTO sessions (\n id, session, user_id, os, platform,\n city, country, ip, user_agent,\n expires, refresh_expires\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -13,10 +13,12 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01"
|
||||
"hash": "914f186afcc80dcbaed00a2cb16d398ba57e653c1a2d896201ccc39b2912cae2"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "status: PayoutStatus",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3"
|
||||
}
|
||||
+18
-4
@@ -1,8 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH\n period_payouts AS (\n SELECT\n ids.notification_id,\n ids.user_id,\n ids.date_available,\n FLOOR(COALESCE(SUM(pv.amount), 0.0) * 100) :: BIGINT sum -- Convert to cents\n FROM UNNEST($1::bigint[], $2::bigint[], $3::timestamptz[]) AS ids(notification_id, user_id, date_available)\n LEFT JOIN payouts_values pv ON pv.user_id = ids.user_id AND pv.date_available = ids.date_available\n GROUP BY ids.user_id, ids.notification_id, ids.date_available\n )\n INSERT INTO notifications (\n id, user_id, body\n )\n SELECT\n notification_id id,\n user_id,\n JSONB_BUILD_OBJECT(\n 'type', 'payout_available',\n 'date_available', to_jsonb(date_available),\n 'amount', to_jsonb(sum)\n ) body\n FROM period_payouts\n WHERE sum >= 100\n ",
|
||||
"query": "\n WITH\n period_payouts AS (\n SELECT\n ids.notification_id,\n ids.user_id,\n ids.date_available,\n FLOOR(COALESCE(SUM(pv.amount), 0.0) * 100) :: BIGINT sum -- Convert to cents\n FROM UNNEST($1::bigint[], $2::bigint[], $3::timestamptz[]) AS ids(notification_id, user_id, date_available)\n LEFT JOIN payouts_values pv ON pv.user_id = ids.user_id AND pv.date_available = ids.date_available\n GROUP BY ids.user_id, ids.notification_id, ids.date_available\n )\n INSERT INTO notifications (\n id, user_id, body\n )\n SELECT\n notification_id id,\n user_id,\n JSONB_BUILD_OBJECT(\n 'type', 'payout_available',\n 'date_available', to_jsonb(date_available),\n 'amount', to_jsonb(sum)\n ) body\n FROM period_payouts\n WHERE sum >= 100\n RETURNING id, user_id\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
@@ -10,7 +21,10 @@
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "807d42d4aab8312fc6dfc60c93c6cecf947c2f96b43926ce5147c4aafe9089cd"
|
||||
"hash": "ccdee0f8f60e0ac8997eacba6993ed0c61cd80c0d9eacd3053fadd09f7c67e6d"
|
||||
}
|
||||
Generated
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02"
|
||||
}
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Labrinth
|
||||
|
||||
Labrinth is the backend API service for Modrinth, written in Rust.
|
||||
|
||||
## Pre-PR Checks
|
||||
|
||||
When the user refers to "perform[ing] pre-PR checks", do the following:
|
||||
|
||||
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
|
||||
- DO NOT run tests unless explicitly requested (they take a long time)
|
||||
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare`
|
||||
- NEVER run `cargo sqlx prepare --workspace`
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
|
||||
|
||||
## Local Services
|
||||
|
||||
- Read the root `docker-compose.yml` to see what running services are available while developing
|
||||
- Use `docker exec` to access these services
|
||||
|
||||
### Clickhouse
|
||||
|
||||
- Access: `docker exec labrinth-clickhouse clickhouse-client`
|
||||
- Database: `staging_ariadne`
|
||||
|
||||
### Postgres
|
||||
|
||||
- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "<query>"`
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE sessions ALTER COLUMN expires DROP DEFAULT;
|
||||
ALTER TABLE sessions ALTER COLUMN refresh_expires DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE threads
|
||||
DROP CONSTRAINT IF EXISTS threads_mod_id_fkey;
|
||||
@@ -32,6 +32,7 @@ where
|
||||
executor,
|
||||
redis,
|
||||
session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
@@ -61,6 +62,7 @@ where
|
||||
executor,
|
||||
redis,
|
||||
session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
@@ -95,12 +97,38 @@ where
|
||||
Ok((scopes, User::from_full(db_user)))
|
||||
}
|
||||
|
||||
pub async fn get_user_from_bearer_token<'a, E>(
|
||||
req: &HttpRequest,
|
||||
token: Option<&str>,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
allow_expired: bool,
|
||||
) -> Result<(Scopes, User), AuthenticationError>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let (scopes, db_user) = get_user_record_from_bearer_token(
|
||||
req,
|
||||
token,
|
||||
executor,
|
||||
redis,
|
||||
session_queue,
|
||||
allow_expired,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
Ok((scopes, User::from_full(db_user)))
|
||||
}
|
||||
|
||||
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
||||
req: &HttpRequest,
|
||||
token: Option<&str>,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
allow_expired: bool,
|
||||
) -> Result<Option<(Scopes, user_item::DBUser)>, AuthenticationError>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
@@ -120,7 +148,7 @@ where
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if pat.expires < Utc::now() {
|
||||
if !allow_expired && pat.expires < Utc::now() {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
@@ -139,7 +167,7 @@ where
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if session.expires < Utc::now() {
|
||||
if !allow_expired && session.expires < Utc::now() {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
@@ -169,7 +197,7 @@ where
|
||||
.await?
|
||||
.ok_or(AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if access_token.expires < Utc::now() {
|
||||
if !allow_expired && access_token.expires < Utc::now() {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,13 @@ impl NotificationBuilder {
|
||||
let notification_ids =
|
||||
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
|
||||
|
||||
sqlx::query!(
|
||||
// Use RETURNING to get back only the notification_ids and user_ids
|
||||
// that were actually inserted (i.e. those with sum >= 100).
|
||||
// This is necessary because insert_many_deliveries references
|
||||
// notifications(id) via a foreign key, passing notification_ids
|
||||
// that were filtered out by the WHERE clause would cause a
|
||||
// FK constraint violation and fail the entire transaction.
|
||||
let inserted_rows = sqlx::query!(
|
||||
"
|
||||
WITH
|
||||
period_payouts AS (
|
||||
@@ -83,15 +89,29 @@ impl NotificationBuilder {
|
||||
) body
|
||||
FROM period_payouts
|
||||
WHERE sum >= 100
|
||||
RETURNING id, user_id
|
||||
",
|
||||
¬ification_ids[..],
|
||||
&users_raw_ids[..],
|
||||
&dates_available[..],
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let notification_types = notification_ids
|
||||
if inserted_rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let inserted_notification_ids: Vec<i64> =
|
||||
inserted_rows.iter().map(|r| r.id).collect();
|
||||
let inserted_user_raw_ids: Vec<i64> =
|
||||
inserted_rows.iter().map(|r| r.user_id).collect();
|
||||
let inserted_users: Vec<DBUserId> = inserted_user_raw_ids
|
||||
.iter()
|
||||
.map(|&id| DBUserId(id))
|
||||
.collect();
|
||||
|
||||
let notification_types = inserted_notification_ids
|
||||
.iter()
|
||||
.map(|_| NotificationType::PayoutAvailable.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -99,10 +119,10 @@ impl NotificationBuilder {
|
||||
NotificationBuilder::insert_many_deliveries(
|
||||
transaction,
|
||||
redis,
|
||||
¬ification_ids,
|
||||
&users_raw_ids,
|
||||
&inserted_notification_ids,
|
||||
&inserted_user_raw_ids,
|
||||
¬ification_types,
|
||||
&users,
|
||||
&inserted_users,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ pub struct SessionBuilder {
|
||||
|
||||
pub ip: String,
|
||||
pub user_agent: String,
|
||||
|
||||
// When None, database default of 14 days will be used
|
||||
pub expires: Option<DateTime<Utc>>,
|
||||
// When None, database default of 60 days will be used
|
||||
pub session_expires: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl SessionBuilder {
|
||||
@@ -38,11 +43,13 @@ impl SessionBuilder {
|
||||
"
|
||||
INSERT INTO sessions (
|
||||
id, session, user_id, os, platform,
|
||||
city, country, ip, user_agent
|
||||
city, country, ip, user_agent,
|
||||
expires, refresh_expires
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9
|
||||
$6, $7, $8, $9,
|
||||
$10, $11
|
||||
)
|
||||
",
|
||||
id as DBSessionId,
|
||||
@@ -54,6 +61,10 @@ impl SessionBuilder {
|
||||
self.country,
|
||||
self.ip,
|
||||
self.user_agent,
|
||||
self.expires
|
||||
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(14)),
|
||||
self.session_expires
|
||||
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(60)),
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
@@ -1393,3 +1393,179 @@ async fn check_balance_with_webhook(
|
||||
|
||||
Ok(result.ok().flatten())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test::{
|
||||
api_v3::ApiV3,
|
||||
database::USER_USER_ID_PARSED,
|
||||
environment::{TestEnvironment, with_test_environment},
|
||||
};
|
||||
use rust_decimal::dec;
|
||||
|
||||
async fn setup_payouts_values(
|
||||
db: &PgPool,
|
||||
entries: Vec<(i64, Decimal, DateTime<Utc>)>, // (user_id, amount, date_available)
|
||||
) {
|
||||
for (user_id, amount, date_available) in &entries {
|
||||
sqlx::query!(
|
||||
"INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)
|
||||
VALUES ($1, NULL, $2, NOW(), $3)",
|
||||
user_id,
|
||||
amount,
|
||||
date_available,
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for (user_id, _amount, date_available) in &entries {
|
||||
sqlx::query!(
|
||||
"INSERT INTO payouts_values_notifications (date_available, user_id, notified)
|
||||
VALUES ($1, $2, FALSE)
|
||||
ON CONFLICT (date_available, user_id) DO NOTHING",
|
||||
date_available,
|
||||
user_id,
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// When a user's payout amount is below
|
||||
/// the $1.00 (100 cents) threshold, the `WHERE sum >= 100` filter in
|
||||
/// insert_many_payout_notifications skips the INSERT into notifications
|
||||
/// for that user, but the old code still passed ALL pre-generated
|
||||
/// notification_ids to insert_many_deliveries. Since
|
||||
/// notifications_deliveries.notification_id has a FK constraint on
|
||||
/// notifications(id), this caused a violation that failed
|
||||
/// the entire transaction. The notified flag was never set to `true`,
|
||||
/// so the rows accumulated and the same failure repeated every run.
|
||||
#[actix_rt::test]
|
||||
async fn test_payout_notification_below_threshold_does_not_fail() {
|
||||
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
|
||||
let db = &env.db.pool;
|
||||
let redis = &env.db.redis_pool;
|
||||
|
||||
let user_id = USER_USER_ID_PARSED;
|
||||
|
||||
// date_available must be in the past so the notification query
|
||||
// picks it up (date_available <= NOW()).
|
||||
let date_available = Utc::now() - Duration::hours(1);
|
||||
|
||||
// Amount of $0.50 -- below the $1.00 threshold (sum < 100 cents).
|
||||
setup_payouts_values(
|
||||
db,
|
||||
vec![(user_id, dec!(0.50), date_available)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// This should succeed, NOT return an error.
|
||||
// Before the fix, this would fail with a FK constraint violation.
|
||||
let result = index_payouts_notifications(db, redis).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"index_payouts_notifications should succeed for below-threshold payouts, got: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
// Verify the notification row was marked as notified (not stuck).
|
||||
let remaining = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1",
|
||||
user_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or(0);
|
||||
|
||||
assert_eq!(
|
||||
remaining, 0,
|
||||
"All payouts_values_notifications rows should be marked notified"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// When there is a mix of users, some above and some below the
|
||||
/// threshold, the above-threshold users should get notifications
|
||||
/// while the below-threshold ones are silently skipped. The entire
|
||||
/// transaction must succeed (not fail due to the below-threshold user).
|
||||
#[actix_rt::test]
|
||||
async fn test_payout_notification_mixed_threshold_users() {
|
||||
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
|
||||
let db = &env.db.pool;
|
||||
let redis = &env.db.redis_pool;
|
||||
|
||||
let above_user_id = USER_USER_ID_PARSED; // user 3
|
||||
let below_user_id = 4i64; // FRIEND_USER_ID
|
||||
let date_available = Utc::now() - Duration::hours(1);
|
||||
|
||||
setup_payouts_values(
|
||||
db,
|
||||
vec![
|
||||
// Above threshold
|
||||
(above_user_id, dec!(5.00), date_available),
|
||||
// Below threshold
|
||||
(below_user_id, dec!(0.25), date_available),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = index_payouts_notifications(db, redis).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"index_payouts_notifications should succeed with mixed users, got: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
// Above-threshold user should have a notification.
|
||||
let above_count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
|
||||
above_user_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or(0);
|
||||
|
||||
assert!(
|
||||
above_count > 0,
|
||||
"Above-threshold user should have a payout notification"
|
||||
);
|
||||
|
||||
// Below-threshold user should NOT have a notification.
|
||||
let below_count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
|
||||
below_user_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or(0);
|
||||
|
||||
assert_eq!(
|
||||
below_count, 0,
|
||||
"Below-threshold user should NOT have a payout notification"
|
||||
);
|
||||
|
||||
// Both should be marked notified.
|
||||
let remaining = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or(0);
|
||||
|
||||
assert_eq!(
|
||||
remaining, 0,
|
||||
"All payouts_values_notifications rows should be marked notified"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ pub async fn count_download(
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
|
||||
@@ -1079,6 +1079,7 @@ pub async fn init(
|
||||
&**client,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
@@ -1114,6 +1115,7 @@ pub async fn init(
|
||||
&**client,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
@@ -1308,7 +1310,8 @@ pub async fn auth_callback(
|
||||
};
|
||||
|
||||
let session =
|
||||
issue_session(req, user_id, &mut transaction, &redis).await?;
|
||||
issue_session(req, user_id, &mut transaction, &redis, None)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
let redirect_url = format!(
|
||||
@@ -1533,7 +1536,8 @@ pub async fn create_account_with_password(
|
||||
.insert(&mut transaction)
|
||||
.await?;
|
||||
|
||||
let session = issue_session(req, user_id, &mut transaction, &redis).await?;
|
||||
let session =
|
||||
issue_session(req, user_id, &mut transaction, &redis, None).await?;
|
||||
let res = crate::models::sessions::Session::from(session, true, None);
|
||||
|
||||
let mailbox: Mailbox = new_account.email.parse().map_err(|_| {
|
||||
@@ -1627,7 +1631,7 @@ pub async fn login_password(
|
||||
} else {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let session =
|
||||
issue_session(req, user.id, &mut transaction, &redis).await?;
|
||||
issue_session(req, user.id, &mut transaction, &redis, None).await?;
|
||||
let res = crate::models::sessions::Session::from(session, true, None);
|
||||
transaction.commit().await?;
|
||||
|
||||
@@ -1757,7 +1761,7 @@ pub async fn login_2fa(
|
||||
DBFlow::remove(&login.flow, &redis).await?;
|
||||
|
||||
let session =
|
||||
issue_session(req, user_id, &mut transaction, &redis).await?;
|
||||
issue_session(req, user_id, &mut transaction, &redis, None).await?;
|
||||
let res = crate::models::sessions::Session::from(session, true, None);
|
||||
transaction.commit().await?;
|
||||
|
||||
@@ -1945,6 +1949,7 @@ pub async fn remove_2fa(
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
@@ -2150,6 +2155,7 @@ pub async fn change_password(
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::auth::validate::get_user_from_bearer_token;
|
||||
use crate::auth::{AuthenticationError, get_user_from_headers};
|
||||
use crate::database::models::DBUserId;
|
||||
use crate::database::models::session_item::DBSession;
|
||||
@@ -12,7 +13,7 @@ use crate::routes::ApiError;
|
||||
use actix_web::http::header::AUTHORIZATION;
|
||||
use actix_web::web::{Data, ServiceConfig, scope};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
||||
use chrono::Utc;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
@@ -88,6 +89,7 @@ pub async fn issue_session(
|
||||
user_id: DBUserId,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
session_expires: Option<DateTime<Utc>>,
|
||||
) -> Result<DBSession, AuthenticationError> {
|
||||
let metadata = get_session_metadata(&req).await?;
|
||||
|
||||
@@ -108,6 +110,8 @@ pub async fn issue_session(
|
||||
country: metadata.country,
|
||||
ip: metadata.ip,
|
||||
user_agent: metadata.user_agent,
|
||||
expires: None,
|
||||
session_expires,
|
||||
}
|
||||
.insert(transaction)
|
||||
.await?;
|
||||
@@ -212,15 +216,6 @@ pub async fn refresh(
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let current_user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
let session = req
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
@@ -229,6 +224,25 @@ pub async fn refresh(
|
||||
ApiError::Authentication(AuthenticationError::InvalidCredentials)
|
||||
})?;
|
||||
|
||||
// We should ensure that the authorization given is a session token, and not some other type of token (like a PAT), since this endpoint is only for refreshing sessions.
|
||||
// This is done by checking the prefix of the token, which should be "mra_" for session tokens.
|
||||
if !session.starts_with("mra_") {
|
||||
return Err(ApiError::Authentication(
|
||||
AuthenticationError::InvalidCredentials,
|
||||
));
|
||||
}
|
||||
|
||||
let current_user = get_user_from_bearer_token(
|
||||
&req,
|
||||
Some(session),
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
true, // Allow expired sessions, since we want to allow refreshing expired sessions
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let session = DBSession::get(session, &**pool, &redis).await?;
|
||||
|
||||
if let Some(session) = session {
|
||||
@@ -243,9 +257,14 @@ pub async fn refresh(
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
DBSession::remove(session.id, &mut transaction).await?;
|
||||
let new_session =
|
||||
issue_session(req, session.user_id, &mut transaction, &redis)
|
||||
.await?;
|
||||
let new_session = issue_session(
|
||||
req,
|
||||
session.user_id,
|
||||
&mut transaction,
|
||||
&redis,
|
||||
Some(session.refresh_expires),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
DBSession::clear_cache(
|
||||
vec![(
|
||||
|
||||
@@ -58,6 +58,7 @@ pub async fn ws_init(
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
|
||||
@@ -440,6 +440,7 @@ pub async fn calculate_fees(
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
@@ -472,6 +473,7 @@ pub async fn create_payout(
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# @modrinth/api-client
|
||||
|
||||
Platform-agnostic API client for Modrinth's services. Works in Nuxt (SSR + CSR), Tauri (desktop app), and plain Node/browser environments.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Request Flow:
|
||||
Module Method → client.request() → Feature Chain (middleware) → Platform executeRequest()
|
||||
```
|
||||
|
||||
### Key Directories
|
||||
|
||||
- **`src/core/`** — base classes (`AbstractModrinthClient`, `AbstractModule`, `AbstractFeature`, etc.)
|
||||
- **`src/platform/`** — platform implementations (generic, nuxt, tauri, xhr-upload, websocket)
|
||||
- **`src/features/`** — middleware plugins (auth, retry, circuit-breaker, etc.)
|
||||
- **`src/modules/`** — API endpoint modules organized by service (`labrinth/`, `archon/`, `kyros/`, `iso3166/`)
|
||||
- **`src/types/`** — core type definitions (client config, request options, upload types, errors)
|
||||
|
||||
### Client Hierarchy
|
||||
|
||||
All platform clients extend `XHRUploadClient` → `AbstractModrinthClient`:
|
||||
|
||||
- **`GenericModrinthClient`** — uses `ofetch`, attaches WebSocket client to `archon.sockets`
|
||||
- **`NuxtModrinthClient`** — uses Nuxt's `$fetch`, SSR-aware, blocks `upload()` during SSR
|
||||
- **`TauriModrinthClient`** — uses `@tauri-apps/plugin-http`
|
||||
|
||||
### Module Access
|
||||
|
||||
Modules are lazy-loaded and accessed as a nested structure:
|
||||
|
||||
```ts
|
||||
client.labrinth.projects_v2
|
||||
client.labrinth.projects_v3
|
||||
client.labrinth.versions_v3
|
||||
client.labrinth.collections
|
||||
client.labrinth.billing_internal
|
||||
client.archon.servers_v0
|
||||
client.archon.servers_v1
|
||||
client.archon.backups_v0
|
||||
client.archon.backups_v1
|
||||
client.archon.content_v0
|
||||
client.kyros.files_v0
|
||||
client.iso3166.data
|
||||
... ect.
|
||||
```
|
||||
|
||||
This structure is derived at runtime from the flat `MODULE_REGISTRY` in `modules/index.ts` via `buildModuleStructure()`, and the TypeScript types are inferred automatically via `InferredClientModules`.
|
||||
|
||||
## Usage
|
||||
|
||||
The client is provided to the component tree via DI (see the `dependency-injection` skill). Each app creates a platform-specific client and provides it at the root:
|
||||
|
||||
```ts
|
||||
// apps/frontend/src/app.vue (Nuxt)
|
||||
const client = new NuxtModrinthClient({ ... })
|
||||
provideModrinthClient(client)
|
||||
|
||||
// apps/app-frontend/src/App.vue (Tauri)
|
||||
const client = new TauriModrinthClient({ ... })
|
||||
provideModrinthClient(client)
|
||||
```
|
||||
|
||||
Components anywhere in the tree then inject it:
|
||||
|
||||
```ts
|
||||
const { labrinth, archon, kyros } = injectModrinthClient()
|
||||
|
||||
// Fetch data
|
||||
const project = await labrinth.projects_v3.get(projectId)
|
||||
|
||||
// Use with TanStack Query
|
||||
const { data } = useQuery({
|
||||
queryKey: ['project', projectId],
|
||||
queryFn: () => labrinth.projects_v3.get(projectId),
|
||||
})
|
||||
```
|
||||
|
||||
`provideModrinthClient` and `injectModrinthClient` are exported from `@modrinth/ui` (defined in `packages/ui/src/providers/api-client.ts`). The provider is typed as `AbstractModrinthClient`, so shared components in `packages/ui` work with any platform client.
|
||||
|
||||
## Types
|
||||
|
||||
Types must match 1:1 with how they are returned from the backend API they are fetching from. Do not reshape, rename, or omit fields — the types should be a direct representation of the API response.
|
||||
|
||||
Types are organized in namespaces that mirror the backend services:
|
||||
|
||||
```ts
|
||||
import type { Labrinth, Archon, Kyros, ISO3166 } from '@modrinth/api-client'
|
||||
|
||||
const project: Labrinth.Projects.v3.Project = ...
|
||||
const server: Archon.Servers.v0.Server = ...
|
||||
const auth: Archon.Websocket.v0.WSAuth = ...
|
||||
```
|
||||
|
||||
Each API has a `types.ts` in its module directory (`modules/labrinth/types.ts`, `modules/archon/types.ts`, etc.) using nested namespaces: `Namespace.Domain.Version.Type`.
|
||||
|
||||
## Features (Middleware)
|
||||
|
||||
Features wrap requests in a chain. Each feature can modify the request, retry, or short-circuit:
|
||||
|
||||
- **`AuthFeature`** — injects `Authorization: Bearer <token>`, supports async token providers
|
||||
- **`RetryFeature`** — exponential/linear/constant backoff, retries on 408/429/5xx and network errors
|
||||
- **`CircuitBreakerFeature`** — opens after N consecutive failures per endpoint, resets after timeout
|
||||
|
||||
## XHR Upload
|
||||
|
||||
File uploads use `XMLHttpRequest` for progress tracking (not available via `fetch`). The `upload()` method returns an `UploadHandle<T>`:
|
||||
|
||||
```ts
|
||||
interface UploadHandle<T> {
|
||||
promise: Promise<T>
|
||||
onProgress(callback: (progress: UploadProgress) => void): UploadHandle<T> // chainable
|
||||
cancel(): void
|
||||
}
|
||||
```
|
||||
|
||||
Supports two modes:
|
||||
|
||||
- **Single file** — `{ file: File | Blob }` sends with `Content-Type: application/octet-stream`
|
||||
- **FormData** — `{ formData: FormData }` for multipart uploads (browser/platform sets boundary)
|
||||
|
||||
Uploads go through the feature chain (auth, retry, etc.). Features detect uploads via `context.metadata.isUpload`.
|
||||
|
||||
### Usage Example (server file upload)
|
||||
|
||||
```ts
|
||||
const uploader = client.kyros.files_v0.uploadFile(path, file, {
|
||||
onProgress: ({ progress }) => {
|
||||
uploadProgress.value = Math.round(progress * 100)
|
||||
},
|
||||
})
|
||||
// Cancel if needed: uploader.cancel()
|
||||
await uploader.promise
|
||||
```
|
||||
|
||||
### Usage Example (version creation with FormData)
|
||||
|
||||
```ts
|
||||
const handle = client.labrinth.versions_v3.createVersion(draftVersion, files, projectType)
|
||||
handle.onProgress((progress) => {
|
||||
uploadProgress.value = progress
|
||||
})
|
||||
await handle.promise
|
||||
```
|
||||
|
||||
See `packages/ui/src/components/servers/files/upload/FileUploadDropdown.vue` and `apps/frontend/src/providers/version/manage-version-modal.ts` for real usage.
|
||||
|
||||
## WebSocket
|
||||
|
||||
WebSocket support is attached to `client.archon.sockets` (only on `GenericModrinthClient`). It provides event-based communication with Modrinth Hosting servers.
|
||||
|
||||
### Connection Flow
|
||||
|
||||
```
|
||||
client.archon.sockets.safeConnect(serverId)
|
||||
→ fetches JWT auth via archon.servers_v0.getWebSocketAuth()
|
||||
→ opens wss:// connection
|
||||
→ sends { event: 'auth', jwt: token }
|
||||
→ server responds with { event: 'auth-ok' }
|
||||
→ ready to receive events
|
||||
```
|
||||
|
||||
Auto-reconnects on unexpected disconnection with exponential backoff (base 1s, max 30s, up to 10 attempts).
|
||||
|
||||
### Subscribing to Events
|
||||
|
||||
```ts
|
||||
const unsub = client.archon.sockets.on(serverId, 'stats', (data) => {
|
||||
// data is typed as Archon.Websocket.v0.WSStatsEvent
|
||||
cpuUsage.value = data.cpu_percent
|
||||
})
|
||||
|
||||
// Clean up
|
||||
onUnmounted(() => {
|
||||
unsub()
|
||||
client.archon.sockets.disconnect(serverId)
|
||||
})
|
||||
```
|
||||
|
||||
Event types: `log`, `stats`, `power-state`, `uptime`, `backup-progress`, `installation-result`, `filesystem-ops`, `new-mod`, `auth-expiring`, `auth-incorrect`, `auth-ok`.
|
||||
|
||||
### Sending Commands
|
||||
|
||||
```ts
|
||||
client.archon.sockets.send(serverId, { event: 'command', cmd: '/say hello' })
|
||||
```
|
||||
|
||||
See `apps/frontend/src/pages/hosting/manage/[id].vue` for the full server panel WebSocket usage.
|
||||
|
||||
## Adding a New API Module
|
||||
|
||||
See the `api-module` skill (`.claude/skills/api-module/SKILL.md`) for step-by-step instructions.
|
||||
@@ -5,13 +5,4 @@ java/build
|
||||
|
||||
# Migrations existing before Prettier formatted them shall always be ignored,
|
||||
# as any changes to them will break existing deployments
|
||||
migrations/20240711194701_init.sql
|
||||
migrations/20240813205023_drop-active-unique.sql
|
||||
migrations/20240930001852_disable-personalized-ads.sql
|
||||
migrations/20241222013857_feature-flags.sql
|
||||
migrations/20250318160526_protocol-versions.sql
|
||||
migrations/20250408181656_add-join-log.sql
|
||||
migrations/20250413162050_skin-selector.sql
|
||||
migrations/20250428171350_add-profile-feature-version.sql
|
||||
migrations/20250429150111_add-attached-world-data.sql
|
||||
migrations/20250514181748_skin_nametag_setting.sql
|
||||
migrations/**
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||
|
||||
export type IconComponent = FunctionalComponent<SVGAttributes>
|
||||
|
||||
import _AffiliateIcon from './icons/affiliate.svg?component'
|
||||
import _AlignLeftIcon from './icons/align-left.svg?component'
|
||||
import _ArchiveIcon from './icons/archive.svg?component'
|
||||
@@ -328,6 +326,8 @@ import _XCircleIcon from './icons/x-circle.svg?component'
|
||||
import _ZoomInIcon from './icons/zoom-in.svg?component'
|
||||
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
||||
|
||||
export type IconComponent = FunctionalComponent<SVGAttributes>
|
||||
|
||||
export const AffiliateIcon = _AffiliateIcon
|
||||
export const AlignLeftIcon = _AlignLeftIcon
|
||||
export const ArchiveIcon = _ArchiveIcon
|
||||
|
||||
@@ -46,13 +46,21 @@ impl PostgresContainer {
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute() {
|
||||
let observability = opentelemetry_testing::ObservabilityContainer::create().await;
|
||||
let observability =
|
||||
opentelemetry_testing::ObservabilityContainer::create().await;
|
||||
let provider = observability.install().await;
|
||||
|
||||
let container = PostgresContainer::create().await;
|
||||
let pool = container.client().await;
|
||||
|
||||
common::should_trace("trace_pool", "postgresql", &observability, &provider, &pool).await;
|
||||
common::should_trace(
|
||||
"trace_pool",
|
||||
"postgresql",
|
||||
&observability,
|
||||
&provider,
|
||||
&pool,
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
@@ -67,7 +75,8 @@ async fn execute() {
|
||||
}
|
||||
|
||||
{
|
||||
let mut tx: sqlx_tracing::Transaction<'_, Postgres> = pool.begin().await.unwrap();
|
||||
let mut tx: sqlx_tracing::Transaction<'_, Postgres> =
|
||||
pool.begin().await.unwrap();
|
||||
common::should_trace(
|
||||
"trace_tx",
|
||||
"postgresql",
|
||||
|
||||
@@ -6,21 +6,37 @@ mod common;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute() {
|
||||
let observability = opentelemetry_testing::ObservabilityContainer::create().await;
|
||||
let observability =
|
||||
opentelemetry_testing::ObservabilityContainer::create().await;
|
||||
let provider = observability.install().await;
|
||||
|
||||
let pool = sqlx::SqlitePool::connect(":memory:").await.unwrap();
|
||||
let pool = sqlx_tracing::Pool::from(pool);
|
||||
|
||||
common::should_trace("trace_pool", "sqlite", &observability, &provider, &pool).await;
|
||||
common::should_trace(
|
||||
"trace_pool",
|
||||
"sqlite",
|
||||
&observability,
|
||||
&provider,
|
||||
&pool,
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
common::should_trace("trace_conn", "sqlite", &observability, &provider, &mut conn).await;
|
||||
common::should_trace(
|
||||
"trace_conn",
|
||||
"sqlite",
|
||||
&observability,
|
||||
&provider,
|
||||
&mut conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
{
|
||||
let mut tx: sqlx_tracing::Transaction<'_, Sqlite> = pool.begin().await.unwrap();
|
||||
let mut tx: sqlx_tracing::Transaction<'_, Sqlite> =
|
||||
pool.begin().await.unwrap();
|
||||
common::should_trace(
|
||||
"trace_tx",
|
||||
"sqlite",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Architecture
|
||||
|
||||
The shared UI package used by both `apps/frontend` (Nuxt 3) and `apps/app-frontend` (Vue 3 + Tauri). Components here must be platform-agnostic — use dependency injection for platform-specific behavior.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Vue components organized by feature domain
|
||||
├── composables/ # Vue 3 composition API hooks
|
||||
├── providers/ # Dependency injection contexts (createContext pattern)
|
||||
├── utils/ # Utility functions and constants
|
||||
├── pages/ # Cross platform page components (used in both app-frontend and frontend)
|
||||
├── locales/ # 34 language locale files (FormatJS)
|
||||
├── styles/ # Tailwind CSS utilities
|
||||
└── stories/ # Storybook story files
|
||||
```
|
||||
|
||||
Each subdirectory under `components/` has an `index.ts` barrel file. All public API is re-exported from the root `index.ts`.
|
||||
|
||||
# Code Guidelines
|
||||
|
||||
### Tailwind Configuration
|
||||
|
||||
All frontend packages share a Tailwind preset at `packages/tooling-config/tailwind/tailwind-preset.ts`. This package's `tailwind.config.ts` extends it:
|
||||
|
||||
```ts
|
||||
import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
|
||||
```
|
||||
|
||||
CSS custom properties are defined in `packages/assets/styles/variables.scss` with light, dark, and OLED theme variants.
|
||||
|
||||
### Color Usage Rules
|
||||
|
||||
**Use `surface-*` variables for backgrounds — never aliased `bg-*` color variables:**
|
||||
|
||||
| Token | Usage |
|
||||
| ---------------- | ----------------------------------------- |
|
||||
| `bg-surface-1` | Deepest background layer |
|
||||
| `bg-surface-1.5` | Odd row background (tables) |
|
||||
| `bg-surface-2` | Even row background, secondary panels |
|
||||
| `bg-surface-3` | Headers, floating bar backgrounds, inputs |
|
||||
| `bg-surface-4` | Cards, elevated surfaces |
|
||||
| `bg-surface-5` | Borders, dividers |
|
||||
|
||||
**For text colors:**
|
||||
|
||||
| Class | Usage |
|
||||
| ---------------- | -------------------------------- |
|
||||
| `text-contrast` | Primary headings |
|
||||
| `text-primary` | Default body text |
|
||||
| `text-secondary` | Reduced emphasis, secondary info |
|
||||
|
||||
**Brand and semantic colors** not all exposed as Figma variables — refer to `packages/assets/styles/variables.scss` for the full set:
|
||||
|
||||
- `bg-{color}`, `text-{color}` etc. — Primary brand colors
|
||||
- `bg-{color}-highlight` — 25% opacity semantic highlights
|
||||
|
||||
**Color palette** (each with shades 50–950): red, orange, green, blue, purple, gray. Platform-specific colors also exist (fabric, forge, quilt, neoforge, etc.).
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
This package defines the DI layer using `createContext` from `src/providers/index.ts`. See the `dependency-injection` skill (`.claude/skills/dependency-injection/SKILL.md`) for full documentation.
|
||||
|
||||
Key providers exported from this package:
|
||||
|
||||
- `provideModrinthClient` / `injectModrinthClient` — API client
|
||||
- `provideNotificationManager` / `injectNotificationManager` — Notifications
|
||||
Reference in New Issue
Block a user