feat: move notion docs to standards folder (#5590)
* feat: move notion docs to standards folder * fix: remove skills mention (automatic now)
This commit is contained in:
@@ -1,156 +1,18 @@
|
|||||||
# Adding a New API Module
|
---
|
||||||
|
name: api-module
|
||||||
|
description: Add a new API endpoint module to packages/api-client from an OpenAPI schema. Use when adding new backend endpoints, creating API client modules, or when an openapi.yml is provided.
|
||||||
|
argument-hint: <path-to-openapi.yml>
|
||||||
|
---
|
||||||
|
|
||||||
How to add a new API endpoint module to `packages/api-client`.
|
Refer to the standard: @standards/frontend/ADDING_API_MODULES.md
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
### 1. Define types in the module's `types.ts`
|
1. **Read the OpenAPI schema** at `$ARGUMENTS` — identify the endpoints, request/response shapes, and path parameters.
|
||||||
|
2. **Read the standard above** for naming conventions, type rules, and the module registration pattern.
|
||||||
Types must match 1:1 with the backend API response. Do not reshape, rename, or omit fields.
|
3. **Determine the service and version** — the URL path prefix tells you which service directory and version namespace to use (e.g. `/v3/projects` → `labrinth/v3/`).
|
||||||
|
4. **Define types in `types.ts`** — types must match the API response 1:1. Use the OpenAPI schema as the source of truth. Do not reshape or rename fields.
|
||||||
Add to an existing namespace or create a new one:
|
5. **Create the module class** — extend `BaseModule`, implement each endpoint as a method. Use the correct HTTP verb and request options pattern from the standard.
|
||||||
|
6. **Register in `MODULE_REGISTRY`** — add the module entry so it's auto-instantiated on the client.
|
||||||
```ts
|
7. **Export types** from the service's barrel `index.ts`.
|
||||||
// modules/labrinth/types.ts (existing namespace)
|
8. **Verify** — check that the module compiles and the types are accessible from `@modrinth/api-client`.
|
||||||
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`
|
|
||||||
|
|||||||
@@ -1,144 +1,25 @@
|
|||||||
# Cross-Platform Page System
|
---
|
||||||
|
name: cross-platform-pages
|
||||||
|
description: Convert a page to the cross-platform page system so it works in both the website and the desktop app. Use when moving a page into packages/ui/src/layouts/, creating shared or wrapped layouts, or setting up DI contracts for platform abstraction.
|
||||||
|
argument-hint: <path-to-page>
|
||||||
|
---
|
||||||
|
|
||||||
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.
|
Refer to the standards: @standards/frontend/CROSS_PLATFORM_PAGES.md and @standards/frontend/DEPENDENCY_INJECTION.md
|
||||||
|
|
||||||
## How It Works
|
## Steps
|
||||||
|
|
||||||
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/`).
|
1. **Read the target page** at `$ARGUMENTS` and understand its data sources, mutations, and navigation.
|
||||||
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.
|
2. **Read the standards above** to understand the shared vs wrapped distinction and the DI pattern.
|
||||||
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.
|
3. **Decide the category:**
|
||||||
|
- **Wrapped** (`layouts/wrapped/`) — if the page uses the same API source on both platforms (e.g. web requests, not Tauri plugins). Just move the page component into `packages/ui` and import it from both frontends.
|
||||||
## Example: Content Page
|
- **Shared** (`layouts/shared/`) — if the page has different data-fetching logic per platform (e.g. website uses `api-client`, app uses Tauri `invoke`). Requires a DI contract.
|
||||||
|
4. **For shared layouts:**
|
||||||
`ContentPageLayout` demonstrates the full pattern.
|
- Define a DI contract interface in `providers/` capturing all platform-specific operations.
|
||||||
|
- Create the layout component that injects the context and handles all UI logic.
|
||||||
### 1. Define a DI contract in `packages/ui/src/providers/`
|
- Extract reusable stateful logic (search, filtering, selection) into `composables/`.
|
||||||
|
- Implement the contract separately in each frontend (`apps/frontend/`, `apps/app-frontend/`).
|
||||||
The provider interface abstracts all platform-specific operations:
|
5. **For wrapped pages:**
|
||||||
|
- Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure.
|
||||||
```ts
|
- Replace any platform-specific imports with shared utilities.
|
||||||
// packages/ui/src/providers/content-manager.ts
|
- Import and render the wrapped page from both frontends as a simple component.
|
||||||
export interface ContentManagerContext {
|
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,45 +1,22 @@
|
|||||||
# Figma MCP Usage
|
---
|
||||||
|
name: figma-mcp
|
||||||
|
description: Use the Figma MCP server to translate a Figma design into a Vue page or component layout. Use when the user provides a Figma URL, asks to implement a design, or wants to draft a page layout from Figma.
|
||||||
|
argument-hint: <figma-url>
|
||||||
|
---
|
||||||
|
|
||||||
When the Figma MCP server is connected, use it to translate Figma designs into production-ready Vue components for this monorepo.
|
Refer to the standard: @standards/frontend/FIGMA_MCP_USAGE.md
|
||||||
|
Also read @packages/ui/CLAUDE.md for color token mapping and component conventions.
|
||||||
|
|
||||||
## Workflow
|
## Steps
|
||||||
|
|
||||||
### 1. Get the design context
|
1. **Parse the Figma URL** from `$ARGUMENTS` — extract the `fileKey` and `nodeId`. Convert `-` to `:` in the node ID.
|
||||||
|
2. **Read the standards above** for the available tools, adaptation rules, and color usage.
|
||||||
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`.
|
3. **Call `get_design_context`** with the extracted `nodeId` and `fileKey`, using `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`. This is always the first tool to call.
|
||||||
|
5. **Adapt the output to the Modrinth codebase:**
|
||||||
```
|
- Map Figma color variables to `surface-*` / `text-*` tokens — never use Figma's aliased names directly.
|
||||||
get_design_context(nodeId: "1:2", clientLanguages: "typescript,html,css", clientFrameworks: "vue")
|
- Check `packages/ui/src/components/` for existing components that match elements in the design (buttons, cards, modals, inputs, etc.).
|
||||||
```
|
- Check `packages/assets/styles/variables.scss` for tokens not exposed in Figma.
|
||||||
|
- Match spacing values exactly from the design.
|
||||||
This returns reference code, a screenshot, and metadata. Always start here.
|
6. **Use `get_screenshot`** if you need a closer visual reference of specific nodes.
|
||||||
|
7. **Use `get_variable_defs`** to verify which design tokens are applied to ambiguous elements.
|
||||||
### 2. Get a screenshot for visual reference
|
8. **Build the component** as a Vue SFC using Tailwind classes and the project's existing component library.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
# 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>`.
|
|
||||||
24
.claude/skills/i18n-pass/SKILL.md
Normal file
24
.claude/skills/i18n-pass/SKILL.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: i18n-pass
|
||||||
|
description: Perform an i18n localization pass on changed files or a pull request, converting hard-coded English strings to the @modrinth/ui i18n system. Use when internationalizing a set of changes, reviewing a PR for untranslated strings, or converting a specific component.
|
||||||
|
argument-hint: [file-path-or-pr-number]
|
||||||
|
---
|
||||||
|
|
||||||
|
Refer to the standard: @standards/frontend/INTERNATIONALIZATION.md
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Identify the scope of changes:**
|
||||||
|
- If `$ARGUMENTS` is a PR number, run `gh pr diff $ARGUMENTS` to get the changed files.
|
||||||
|
- If `$ARGUMENTS` is a file path, use that directly.
|
||||||
|
- If no argument, check `git diff` for uncommitted changes.
|
||||||
|
2. **Read the standard above** for the message definition pattern, ICU format rules, and `IntlFormatted` usage.
|
||||||
|
3. **Filter to Vue SFCs** — only `.vue` files need i18n passes. Skip non-component files.
|
||||||
|
4. **For each file, scan for hard-coded strings:**
|
||||||
|
- `<template>`: inner text, `alt`, `placeholder`, `aria-label`, button labels, tooltip text.
|
||||||
|
- `<script>`: string literals passed to user-visible UI (notification messages, dropdown labels, error messages).
|
||||||
|
- Skip: dynamic expressions, HTML tag names, CSS classes, internal identifiers, log messages.
|
||||||
|
5. **Define messages** with `defineMessages` — use descriptive, stable `id`s based on the component's domain (e.g. `project.settings.title`).
|
||||||
|
6. **Replace strings in templates** with `formatMessage()` calls, or `<IntlFormatted>` for strings containing links or markup.
|
||||||
|
7. **Handle ICU edge cases** — add a space before `}}` if an ICU placeholder ends at a Vue template delimiter boundary.
|
||||||
|
8. **Verify** no hard-coded English strings remain in the changed templates. Do not alter logic, layout, or reactivity.
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,154 +1,27 @@
|
|||||||
# TanStack Query
|
---
|
||||||
|
name: tanstack-query
|
||||||
|
description: Convert a page or component from useAsyncData/manual ref patterns to TanStack Query for server state management. Use when migrating data fetching to useQuery/useMutation, adding cache invalidation, or replacing useAsyncData with TanStack Query.
|
||||||
|
argument-hint: <path-to-file>
|
||||||
|
---
|
||||||
|
|
||||||
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.
|
Refer to the standard: @standards/frontend/FETCHING_DATA.md
|
||||||
|
|
||||||
A TanStack MCP server is available — use `tanstack_doc` and `tanstack_search_docs` tools to look up API details when needed.
|
## Steps
|
||||||
|
|
||||||
## Setup
|
1. **Read the target file** at `$ARGUMENTS` and identify all data-fetching patterns: `useAsyncData`, `useFetch`, manual `ref()` + `await`, or `onMounted` fetch calls.
|
||||||
|
2. **Read the standard above** for the query/mutation patterns, query key conventions, and optimistic update approach.
|
||||||
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).
|
3. **Convert queries:**
|
||||||
|
- Replace `useAsyncData` / `useFetch` / manual fetches with `useQuery`.
|
||||||
## Queries
|
- Use the `api-client` via `injectModrinthClient()` for the `queryFn`.
|
||||||
|
- Design query keys with the `['resource', 'version', ...params]` convention.
|
||||||
Use `useQuery` with the api-client for data fetching:
|
- Use `computed` query keys for reactive parameters.
|
||||||
|
- Use the `enabled` option for conditional queries that depend on other data.
|
||||||
```ts
|
4. **Convert mutations:**
|
||||||
const client = injectModrinthClient()
|
- Replace manual `try/catch` + `ref` patterns with `useMutation`.
|
||||||
|
- Add `onSuccess` handlers that invalidate or update related query caches.
|
||||||
const { data, isPending, isError, error } = useQuery({
|
- Consider optimistic updates for UI-critical mutations (follow the pattern in the standard).
|
||||||
queryKey: ['project', 'v3', projectId],
|
5. **Clean up:**
|
||||||
queryFn: () => client.labrinth.projects_v3.get(projectId),
|
- Remove manual loading/error `ref()`s that are now handled by TanStack Query's return values (`isPending`, `isError`, `error`).
|
||||||
staleTime: 1000 * 60 * 5,
|
- Remove manual `onMounted` fetch calls.
|
||||||
})
|
- Ensure SSR compatibility — queries in Nuxt pages are automatically awaited during SSR.
|
||||||
```
|
6. **Verify** the page still renders correctly and that cache invalidation triggers re-fetches where expected.
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -67,18 +67,6 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
|
|||||||
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
|
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
|
||||||
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
|
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
|
||||||
|
|
||||||
## Skills (`.claude/skills/`)
|
|
||||||
|
|
||||||
Project-specific skill files with detailed patterns. Use them when the task matches:
|
|
||||||
|
|
||||||
- **`api-module`** — Adding a new API endpoint module to `packages/api-client` (types, module class, registry registration)
|
|
||||||
- **`cross-platform-pages`** — Building a page that needs to work in both the website (`apps/frontend`) and the desktop app (`apps/app-frontend`)
|
|
||||||
- **`dependency-injection`** — Creating or wiring up a `provide`/`inject` context for platform abstraction or deep component state sharing
|
|
||||||
- **`figma-mcp`** — Translating a Figma design into Vue components using the Figma MCP tools
|
|
||||||
- **`i18n-convert`** — Converting hardcoded English strings in Vue SFCs into the `@modrinth/ui` i18n system (`defineMessages`, `formatMessage`, `IntlFormatted`)
|
|
||||||
- **`multistage-modals`** — Building a wizard-like modal with multiple stages, progress tracking, and per-stage buttons using `MultiStageModal`
|
|
||||||
- **`tanstack-query`** — Fetching, caching, or mutating server data with `@tanstack/vue-query` (queries, mutations, invalidation, optimistic updates)
|
|
||||||
|
|
||||||
## Code Guidelines
|
## Code Guidelines
|
||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
@@ -115,14 +103,6 @@ For Edit, use: ` private byte tag;` (copy everything after →, including the t
|
|||||||
|
|
||||||
**IMPORTANT**: Trust the Read tool output. Copy what's after `→` into Edit immediately. DO NOT verify with sed/od/grep first - that's wasting time and the instructions already tell you to stop if Edit fails, not to pre-verify.
|
**IMPORTANT**: Trust the Read tool output. Copy what's after `→` into Edit immediately. DO NOT verify with sed/od/grep first - that's wasting time and the instructions already tell you to stop if Edit fails, not to pre-verify.
|
||||||
|
|
||||||
## Skills
|
## Standards
|
||||||
|
|
||||||
Project-specific skills (patterns, conventions, and implementation guides) are located in [`.claude/skills/`](./.claude/skills/). Each skill has a `SKILL.md` describing the pattern:
|
Standards available at the @standards/ folder.
|
||||||
|
|
||||||
- **[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
|
|
||||||
|
|||||||
5
standards/README.md
Normal file
5
standards/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Standards
|
||||||
|
|
||||||
|
This folder contains technical standards and documentation we use to develop Modrinth. The majority of this has been migrated from our internal documentation.
|
||||||
|
|
||||||
|
For contributing guidelines, please refer to [docs.modrinth.com](https://docs.modrinth.com/contributing/getting-started/) — these documents are more for technical reference, not a guide on making pull requests, features, etc.
|
||||||
167
standards/frontend/ADDING_API_MODULES.md
Normal file
167
standards/frontend/ADDING_API_MODULES.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
- [Adding a New API Module](#adding-a-new-api-module)
|
||||||
|
- [Steps](#steps)
|
||||||
|
- [1. Define types in the module's `types.ts`](#1-define-types-in-the-modules-typests)
|
||||||
|
- [2. Create the module class](#2-create-the-module-class)
|
||||||
|
- [Request options](#request-options)
|
||||||
|
- [For uploads](#for-uploads)
|
||||||
|
- [3. Register in the MODULE\_REGISTRY](#3-register-in-the-module_registry)
|
||||||
|
- [4. Export types](#4-export-types)
|
||||||
|
- [Naming Conventions](#naming-conventions)
|
||||||
|
- [Key Files](#key-files)
|
||||||
|
|
||||||
|
# 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`
|
||||||
157
standards/frontend/CROSS_PLATFORM_PAGES.md
Normal file
157
standards/frontend/CROSS_PLATFORM_PAGES.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Cross-Platform Pages
|
||||||
|
|
||||||
|
Pages that need to exist in both the Modrinth Website (`apps/frontend`) and the Modrinth App (`apps/app-frontend`) live in `packages/ui/src/layouts/`. There are two categories based on whether the page logic differs between platforms.
|
||||||
|
|
||||||
|
## Shared Layouts (`layouts/shared/`)
|
||||||
|
|
||||||
|
For pages where the **logic differs** between the website and app (e.g. the app fetches data via Tauri `invoke` while the website uses `api-client`). Each shared layout is a self-contained module:
|
||||||
|
|
||||||
|
```
|
||||||
|
shared/content-tab/
|
||||||
|
├── layout.vue # Main layout component
|
||||||
|
├── types.ts # TypeScript types
|
||||||
|
├── components/ # Internal UI components
|
||||||
|
├── composables/ # Stateful logic (search, filtering, selection)
|
||||||
|
└── providers/ # DI context definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. A **DI contract** in `providers/` defines all platform-specific operations as an interface.
|
||||||
|
2. The **layout component** injects that context and handles all UI logic (search, filtering, selection, bulk operations, modals) without knowing the platform.
|
||||||
|
3. Each **platform provides its own implementation** of the contract.
|
||||||
|
|
||||||
|
### DI contract example
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// shared/content-tab/providers/content-manager.ts
|
||||||
|
export interface ContentManagerContext {
|
||||||
|
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
|
||||||
|
loading: Ref<boolean> | ComputedRef<boolean>
|
||||||
|
|
||||||
|
// Platform-abstracted operations
|
||||||
|
toggleEnabled: (item: ContentItem) => Promise<void>
|
||||||
|
deleteItem: (item: ContentItem) => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
|
||||||
|
// Optional capabilities — not every platform supports everything
|
||||||
|
hasUpdateSupport: boolean
|
||||||
|
updateItem?: (id: string) => void
|
||||||
|
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
|
||||||
|
|
||||||
|
mapToTableItem: (item: ContentItem) => ContentCardTableItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export const [injectContentManager, provideContentManager] =
|
||||||
|
createContext<ContentManagerContext>('ContentPageLayout', 'contentManagerContext')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform implementations
|
||||||
|
|
||||||
|
**Website** — uses `api-client` and TanStack Query:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- apps/frontend/src/pages/instance/content.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
|
||||||
|
|
||||||
|
const { data: items } = useQuery({
|
||||||
|
queryKey: ['content', instanceId],
|
||||||
|
queryFn: () => client.content_v1.getAddons(instanceId),
|
||||||
|
})
|
||||||
|
|
||||||
|
provideContentManager({
|
||||||
|
items: computed(() => items.value?.map(addonToContentItem) ?? []),
|
||||||
|
deleteItem: async (item) => {
|
||||||
|
await client.content_v1.deleteAddon(instanceId, item.id)
|
||||||
|
},
|
||||||
|
// ... rest of the contract
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContentPageLayout />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**App** — uses Tauri `invoke`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- apps/app-frontend/src/pages/instance/Mods.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>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional capabilities
|
||||||
|
|
||||||
|
The DI contract uses optional fields for features that not every platform supports. The layout checks for them before rendering the corresponding UI:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Contract
|
||||||
|
bulkUpdateItems?: (items: ContentItem[]) => Promise<void>
|
||||||
|
shareItems?: (items: ContentItem[], format: string) => void
|
||||||
|
|
||||||
|
// Layout checks before showing UI
|
||||||
|
v-if="ctx.bulkUpdateItems && hasOutdatedProjects"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props vs DI
|
||||||
|
|
||||||
|
| Use | When |
|
||||||
|
| --------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| **DI** | Data depends on _how_ it's fetched — API calls, file operations, navigation (per-platform) |
|
||||||
|
| **Props** | Data is the same regardless of platform — configuration flags, display options |
|
||||||
|
|
||||||
|
## Wrapped Pages (`layouts/wrapped/`)
|
||||||
|
|
||||||
|
For pages where the **logic is identical** on both platforms — same API source, same data fetching, same state management. These are full page-level Vue components that directly implement routes:
|
||||||
|
|
||||||
|
```
|
||||||
|
wrapped/hosting/manage/
|
||||||
|
├── index.vue
|
||||||
|
├── content.vue
|
||||||
|
├── backups.vue
|
||||||
|
├── files.vue
|
||||||
|
└── [id]/onboarding.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrapped pages handle their own data fetching (typically via TanStack Query and `api-client`) and are consumed as simple component imports in both frontends:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- apps/frontend/src/pages/hosting/manage/[id]/content.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ServersManageContentPage } from '@modrinth/ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageContentPage />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
A wrapped page may still compose shared layouts internally — for example, the hosting content page uses the shared `content-tab` layout, providing its own `ContentManagerContext` with web API calls.
|
||||||
|
|
||||||
|
## Composables
|
||||||
|
|
||||||
|
Reusable stateful logic lives in `packages/ui/src/layouts/shared/*/composables/`. These are consumed internally by the shared layout:
|
||||||
|
|
||||||
|
- **Search** — Fuse.js fuzzy search over items
|
||||||
|
- **Filtering** — Dynamic filter pills
|
||||||
|
- **Selection** — Multi-select with bulk operation support
|
||||||
|
- **Bulk operations** — Sequential execution with progress tracking
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
|
- [Dependency Injection](#dependency-injection)
|
||||||
|
- [The `createContext` Factory](#the-createcontext-factory)
|
||||||
|
- [When to Use DI](#when-to-use-di)
|
||||||
|
- [Platform Abstraction (Primary Use Case)](#platform-abstraction-primary-use-case)
|
||||||
|
- [Page-Level Context](#page-level-context)
|
||||||
|
- [Creating a New Provider](#creating-a-new-provider)
|
||||||
|
- [1. Define the interface in `packages/ui/src/providers/`](#1-define-the-interface-in-packagesuisrcproviders)
|
||||||
|
- [2. For complex platform-specific logic, use an abstract class](#2-for-complex-platform-specific-logic-use-an-abstract-class)
|
||||||
|
- [Wiring Up Providers](#wiring-up-providers)
|
||||||
|
- [App Frontend (Tauri)](#app-frontend-tauri)
|
||||||
|
- [Website Frontend (Nuxt)](#website-frontend-nuxt)
|
||||||
|
- [Consuming Providers](#consuming-providers)
|
||||||
|
- [When NOT to Use DI](#when-not-to-use-di)
|
||||||
|
- [Existing Providers](#existing-providers)
|
||||||
|
- [Key Files](#key-files)
|
||||||
|
|
||||||
# Dependency Injection
|
# 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.
|
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.
|
||||||
@@ -32,7 +48,7 @@ Use DI when:
|
|||||||
`packages/ui` components need capabilities that each frontend fulfils differently:
|
`packages/ui` components need capabilities that each frontend fulfils differently:
|
||||||
|
|
||||||
| Provider | App Frontend | Website Frontend |
|
| Provider | App Frontend | Website Frontend |
|
||||||
|----------|-------------|-----------------|
|
| ------------- | -------------------------------- | ------------------------------ |
|
||||||
| API client | Tauri IPC client | REST fetch client |
|
| API client | Tauri IPC client | REST fetch client |
|
||||||
| Notifications | `ref()` state + app window mgmt | `useState()` for SSR hydration |
|
| Notifications | `ref()` state + app window mgmt | `useState()` for SSR hydration |
|
||||||
| File picker | Native Tauri dialogs | Browser file inputs |
|
| File picker | Native Tauri dialogs | Browser file inputs |
|
||||||
@@ -157,7 +173,7 @@ Default to props and emits. DI adds indirection — only use it with a concrete
|
|||||||
## Existing Providers
|
## Existing Providers
|
||||||
|
|
||||||
| Provider | File | Purpose |
|
| Provider | File | Purpose |
|
||||||
|----------|------|---------|
|
| ---------------------------- | -------------------------------- | ------------------------------ |
|
||||||
| `provideModrinthClient` | `providers/api-client.ts` | API client instance |
|
| `provideModrinthClient` | `providers/api-client.ts` | API client instance |
|
||||||
| `provideNotificationManager` | `providers/web-notifications.ts` | Notification management |
|
| `provideNotificationManager` | `providers/web-notifications.ts` | Notification management |
|
||||||
| `providePageContext` | `providers/page-context.ts` | Page config (sidebar, ads) |
|
| `providePageContext` | `providers/page-context.ts` | Page config (sidebar, ads) |
|
||||||
164
standards/frontend/FETCHING_DATA.md
Normal file
164
standards/frontend/FETCHING_DATA.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
- [TanStack Query](#tanstack-query)
|
||||||
|
- [Setup](#setup)
|
||||||
|
- [Queries](#queries)
|
||||||
|
- [Query Option Factories](#query-option-factories)
|
||||||
|
- [Conditional Queries](#conditional-queries)
|
||||||
|
- [Mutations](#mutations)
|
||||||
|
- [Optimistic Updates](#optimistic-updates)
|
||||||
|
- [Query Keys](#query-keys)
|
||||||
|
- [Key Files](#key-files)
|
||||||
|
|
||||||
|
# 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
|
||||||
32
standards/frontend/FIGMA_MCP_USAGE.md
Normal file
32
standards/frontend/FIGMA_MCP_USAGE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
- [Figma MCP Usage](#figma-mcp-usage)
|
||||||
|
- [Available Tools](#available-tools)
|
||||||
|
- [Adapting Figma Output](#adapting-figma-output)
|
||||||
|
|
||||||
|
# Figma MCP Usage
|
||||||
|
|
||||||
|
When the Figma MCP server is connected, it can be used to translate Figma designs into production-ready Vue components for this monorepo.
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| -------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `get_design_context` | Primary tool. Returns reference code, a screenshot, and metadata for a given node. Always start here. |
|
||||||
|
| `get_screenshot` | Returns a visual screenshot of a node without full code context. |
|
||||||
|
| `get_variable_defs` | Returns the design tokens applied to a node. |
|
||||||
|
| `get_metadata` | Returns an XML overview of node IDs, layer types, names, positions, and sizes for understanding structure. |
|
||||||
|
|
||||||
|
Node IDs come from Figma URLs. For `https://figma.com/design/:fileKey/:fileName?node-id=1-2`, the node ID is `1:2` (replace `-` with `:`).
|
||||||
|
|
||||||
|
```
|
||||||
|
get_design_context(nodeId: "1:2", clientLanguages: "typescript,html,css", clientFrameworks: "vue")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adapting Figma Output
|
||||||
|
|
||||||
|
The Figma MCP returns generic reference code. It must be adapted 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.
|
||||||
112
standards/frontend/INTERNATIONALIZATION.md
Normal file
112
standards/frontend/INTERNATIONALIZATION.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
- [Internationalization (i18n)](#internationalization-i18n)
|
||||||
|
- [Translatable Strings](#translatable-strings)
|
||||||
|
- [Message Definitions](#message-definitions)
|
||||||
|
- [Rendering Messages](#rendering-messages)
|
||||||
|
- [ICU Message Format](#icu-message-format)
|
||||||
|
- [Rich-Text Messages](#rich-text-messages)
|
||||||
|
- [Vue/ICU Delimiter Collisions](#vueicu-delimiter-collisions)
|
||||||
|
- [Imports](#imports)
|
||||||
|
- [Reference Examples](#reference-examples)
|
||||||
|
|
||||||
|
# Internationalization (i18n)
|
||||||
|
|
||||||
|
All user-visible strings in Vue SFCs must use the localization system from `@modrinth/ui`. No hard-coded English strings should appear in templates or script — everything comes from `formatMessage` or `<IntlFormatted>`.
|
||||||
|
|
||||||
|
## Translatable Strings
|
||||||
|
|
||||||
|
User-visible strings include: inner text, `alt` attributes, `placeholder` attributes, button labels, dropdown option labels, notification messages, etc.
|
||||||
|
|
||||||
|
Dynamic expressions (`{{ user.name }}`) and HTML tags are not translatable strings — only static human-readable text.
|
||||||
|
|
||||||
|
## Message Definitions
|
||||||
|
|
||||||
|
Messages are defined with `defineMessage` or `defineMessages` from `@modrinth/ui` in `<script setup>`. Each message has a unique `id` and a `defaultMessage` containing the 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…" },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Message `id`s should be descriptive and stable (e.g. `error.generic.default.title`). Group related messages together with `defineMessages`.
|
||||||
|
|
||||||
|
## Rendering Messages
|
||||||
|
|
||||||
|
Use `useVIntl()` from `@modrinth/ui` for simple string formatting:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button>{{ formatMessage(messages.welcomeTitle) }}</button>
|
||||||
|
{{ formatMessage(messages.greeting, { name: user.name }) }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ICU Message Format
|
||||||
|
|
||||||
|
Dynamic values use ICU placeholders in `defaultMessage`:
|
||||||
|
|
||||||
|
- **Variables:** `'Hello, {name}!'`
|
||||||
|
- **Numbers/dates/times:** `'{price, number, ::currency/USD}'`
|
||||||
|
- **Plurals/selects:** `'{count, plural, one {# message} other {# messages}}'`
|
||||||
|
|
||||||
|
## Rich-Text Messages
|
||||||
|
|
||||||
|
When a message contains links or markup, wrap the relevant ranges with named 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 the `<IntlFormatted>` component 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>!'`):
|
||||||
|
|
||||||
|
```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>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vue/ICU Delimiter Collisions
|
||||||
|
|
||||||
|
If an ICU placeholder ends right before `}}` in a Vue template, insert a space (`} }`) to avoid parsing issues.
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
All i18n utilities come from `@modrinth/ui`:
|
||||||
|
|
||||||
|
- `defineMessage` / `defineMessages` — message definitions
|
||||||
|
- `useVIntl` — composable providing `formatMessage`
|
||||||
|
- `IntlFormatted` — component for rich-text messages
|
||||||
|
- `normalizeChildren` — helper for complex rich-text slot children
|
||||||
|
|
||||||
|
## Reference Examples
|
||||||
|
|
||||||
|
- Variables and plurals: `apps/frontend/src/pages/frog.vue`
|
||||||
|
- Rich-text with link tags: `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
|
||||||
370
standards/frontend/MODALS.md
Normal file
370
standards/frontend/MODALS.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
- [Regular Modals](#regular-modals)
|
||||||
|
- [Basic Usage](#basic-usage)
|
||||||
|
- [Props](#props)
|
||||||
|
- [Slots](#slots)
|
||||||
|
- [Default slot](#default-slot)
|
||||||
|
- [`title` slot](#title-slot)
|
||||||
|
- [`actions` slot](#actions-slot)
|
||||||
|
- [Scrollable Content](#scrollable-content)
|
||||||
|
- [Merged Header Mode](#merged-header-mode)
|
||||||
|
- [Modal Stacking](#modal-stacking)
|
||||||
|
- [Exposed Methods](#exposed-methods)
|
||||||
|
- [Multistage Modals](#multistage-modals)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Building a Multistage Modal](#building-a-multistage-modal)
|
||||||
|
- [1. Define the context](#1-define-the-context)
|
||||||
|
- [2. Define stage configs](#2-define-stage-configs)
|
||||||
|
- [3. Create stage components](#3-create-stage-components)
|
||||||
|
- [4. Create the wrapper component](#4-create-the-wrapper-component)
|
||||||
|
- [Modal API](#modal-api)
|
||||||
|
- [Non-Progress Stages (Edit Sub-Flows)](#non-progress-stages-edit-sub-flows)
|
||||||
|
- [Reference Implementation](#reference-implementation)
|
||||||
|
|
||||||
|
# Regular Modals
|
||||||
|
|
||||||
|
Use the `NewModal` component (`packages/ui/src/components/modal/NewModal.vue`) for all standard modals.
|
||||||
|
|
||||||
|
- Set the modal’s width via the `width` or `maxWidth` props. For responsive sizing, use `min(base-size, calc(95vw - 10rem))`.
|
||||||
|
- `ModalWrapper` is deprecated — modal behavior is automatically handled via the `injectModalBehavior` DI utility.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from ‘vue’
|
||||||
|
import { NewModal } from ‘@modrinth/ui’
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button @click="modal?.show($event)">Open</button>
|
||||||
|
|
||||||
|
<NewModal ref="modal" header="My Modal">
|
||||||
|
<p>Modal content here.</p>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `show(event?)` to open the modal. Passing the `MouseEvent` triggers an animation originating from the click position. Call `hide()` to close it programmatically.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| --------------------- | ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| `header` | `string` | — | Title text displayed in the header bar |
|
||||||
|
| `hideHeader` | `boolean` | `false` | Hides the entire header (title + close button) |
|
||||||
|
| `mergeHeader` | `boolean` | `false` | Removes the header bar; renders a floating close button over the content |
|
||||||
|
| `closable` | `boolean` | `true` | Shows the close button and enables ESC / click-outside dismissal |
|
||||||
|
| `disableClose` | `boolean` | `false` | Disables all close actions (close button, ESC, click-outside). The close button appears disabled |
|
||||||
|
| `closeOnEsc` | `boolean` | `true` | Allow closing with the Escape key |
|
||||||
|
| `closeOnClickOutside` | `boolean` | `true` | Allow closing by clicking the overlay |
|
||||||
|
| `scrollable` | `boolean` | `false` | Enables scroll tracking with top/bottom fade indicators |
|
||||||
|
| `maxContentHeight` | `string` | `’70vh’` | Max height of the scrollable content area (only applies when `scrollable`) |
|
||||||
|
| `noPadding` | `boolean` | `false` | Removes padding from the content area for edge-to-edge layouts |
|
||||||
|
| `maxWidth` | `string` | `’60rem’` | Maximum width of the modal |
|
||||||
|
| `width` | `string` | `fit-content` | Width of the modal body |
|
||||||
|
| `noblur` | `boolean` | — | Disables backdrop blur. Defaults to the value from `injectModalBehavior` |
|
||||||
|
| `fade` | `’standard’ \| ‘warning’ \| ‘danger’` | `’standard’` | Overlay color variant |
|
||||||
|
| `danger` | `boolean` | `false` | **Deprecated** — use `fade="danger"` instead |
|
||||||
|
| `onShow` | `() => void` | — | Called when the modal opens |
|
||||||
|
| `onHide` | `() => void` | — | Called when the modal closes |
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
### Default slot
|
||||||
|
|
||||||
|
The main content area. Rendered inside a padded, optionally scrollable container.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NewModal ref="modal" header="Confirm">
|
||||||
|
<p>Are you sure you want to proceed?</p>
|
||||||
|
</NewModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `title` slot
|
||||||
|
|
||||||
|
Replaces the default header text. Use this when you need custom markup in the header (e.g. an icon next to the title or a badge).
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NewModal ref="modal">
|
||||||
|
<template #title>
|
||||||
|
<AlertIcon />
|
||||||
|
<span class="text-2xl font-semibold text-contrast">Custom Title</span>
|
||||||
|
</template>
|
||||||
|
<p>Content here.</p>
|
||||||
|
</NewModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `actions` slot
|
||||||
|
|
||||||
|
Renders a bottom action bar below the content area (with `p-4 pt-0` padding). Use this for confirm/cancel buttons.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NewModal ref="modal" header="Delete Item" fade="danger">
|
||||||
|
<p>This action cannot be undone.</p>
|
||||||
|
<template #actions>
|
||||||
|
<ButtonStyled color="danger">
|
||||||
|
<button @click="handleDelete">Delete</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="modal?.hide()">Cancel</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scrollable Content
|
||||||
|
|
||||||
|
Set `scrollable` to enable scroll tracking. The modal renders animated fade gradients at the top and bottom edges when content is scrolled, giving users a visual cue that more content exists.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NewModal ref="modal" header="Long Content" scrollable max-content-height="60vh">
|
||||||
|
<!-- Long content that may overflow -->
|
||||||
|
</NewModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `checkScrollState` method is exposed via ref — call it after dynamically changing content to re-evaluate whether fade indicators should appear.
|
||||||
|
|
||||||
|
When `scrollable` is `false` (the default), content uses `overflow-y: auto` without fade indicators.
|
||||||
|
|
||||||
|
## Merged Header Mode
|
||||||
|
|
||||||
|
When `mergeHeader` is set, the header bar is hidden and a floating close button is rendered in the top-right corner of the modal. Content receives extra top padding to avoid overlapping the button. This is useful for modals with hero images or full-bleed content at the top.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NewModal ref="modal" merge-header no-padding>
|
||||||
|
<img src="..." class="w-full" />
|
||||||
|
<div class="p-6">
|
||||||
|
<p>Content below the image.</p>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modal Stacking
|
||||||
|
|
||||||
|
`NewModal` integrates with a modal stack (`useModalStack`). Multiple modals can be open simultaneously — only the topmost modal responds to the Escape key. The document body scroll is locked when any modal is open and restored when the last modal closes.
|
||||||
|
|
||||||
|
## Exposed Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| -------------------- | ------------------------------------------------------- |
|
||||||
|
| `show(event?)` | Opens the modal. Pass `MouseEvent` for origin animation |
|
||||||
|
| `hide()` | Closes the modal |
|
||||||
|
| `checkScrollState()` | Re-evaluates scroll fade indicators (when `scrollable`) |
|
||||||
|
|
||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user