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,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/`).
|
||||
2. **Platform-dependent data flows via DI** — the app uses Tauri `invoke` commands, the website uses `api-client` or the legacy `useBaseFetch` composable. The shared page never knows which. See the `dependency-injection` skill for full DI docs.
|
||||
3. **Non-platform-dependent data flows via props** — if data doesn't change based on _how_ it's fetched, just pass it as a prop.
|
||||
|
||||
## Example: Content Page
|
||||
|
||||
`ContentPageLayout` demonstrates the full pattern.
|
||||
|
||||
### 1. Define a DI contract in `packages/ui/src/providers/`
|
||||
|
||||
The provider interface abstracts all platform-specific operations:
|
||||
|
||||
```ts
|
||||
// packages/ui/src/providers/content-manager.ts
|
||||
export interface ContentManagerContext {
|
||||
items: Ref<ContentItem[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<Error | null>
|
||||
contentTypeLabel: Ref<string>
|
||||
|
||||
// These are the platform-abstracted operations:
|
||||
// App uses invoke(), website uses api-client
|
||||
toggleEnabled: (item: ContentItem) => Promise<void>
|
||||
deleteItem: (item: ContentItem) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
browse: () => void
|
||||
uploadFiles: () => void
|
||||
|
||||
// Optional capabilities — not every platform supports everything
|
||||
hasUpdateSupport: boolean
|
||||
updateItem?: (item: ContentItem) => Promise<void>
|
||||
bulkUpdateItem?: (items: ContentItem[]) => Promise<void>
|
||||
|
||||
mapToTableItem: (item: ContentItem) => ContentCardTableItem
|
||||
}
|
||||
|
||||
export const [injectContentManager, provideContentManager] =
|
||||
createContext<ContentManagerContext>('ContentManager')
|
||||
```
|
||||
|
||||
### 2. Build the shared page in `packages/ui`
|
||||
|
||||
The page component injects the context and handles all UI logic (search, filtering, selection, bulk operations, empty states, modals) without knowing the platform:
|
||||
|
||||
```vue
|
||||
<!-- packages/ui/src/components/instances/ContentPageLayout.vue -->
|
||||
<script setup lang="ts">
|
||||
import { injectContentManager } from '../../providers/content-manager'
|
||||
|
||||
const { items, loading, toggleEnabled, deleteItem, refresh, mapToTableItem } =
|
||||
injectContentManager()
|
||||
|
||||
// All UI logic lives here — search, filters, sort, bulk ops, etc.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentCardTable :items="filteredItems" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. Each platform provides its implementation
|
||||
|
||||
**Website (Nuxt)** — uses `api-client` or `useBaseFetch`:
|
||||
|
||||
```vue
|
||||
<!-- apps/frontend/src/pages/hosting/manage/[id]/content.vue -->
|
||||
<script setup lang="ts">
|
||||
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const { data: items } = useQuery({
|
||||
queryKey: ['content', serverId],
|
||||
queryFn: () => labrinth.servers_v0.getAddons(serverId),
|
||||
})
|
||||
|
||||
provideContentManager({
|
||||
items: computed(() => items.value?.map(addonToContentItem) ?? []),
|
||||
deleteItem: async (item) => {
|
||||
await labrinth.servers_v0.deleteAddon(serverId, item.id)
|
||||
},
|
||||
// ... rest of the contract
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentPageLayout />
|
||||
</template>
|
||||
```
|
||||
|
||||
**App (Tauri)** — uses `invoke`:
|
||||
|
||||
```vue
|
||||
<!-- apps/app-frontend/src/pages/instance/Content.vue -->
|
||||
<script setup lang="ts">
|
||||
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const items = ref<ContentItem[]>([])
|
||||
await invoke('get_instance_content', { instanceId }).then(/* map to ContentItem[] */)
|
||||
|
||||
provideContentManager({
|
||||
items,
|
||||
deleteItem: async (item) => {
|
||||
await invoke('delete_content', { instanceId, path: item.file_path })
|
||||
},
|
||||
// ... rest of the contract
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentPageLayout />
|
||||
</template>
|
||||
```
|
||||
|
||||
## When to Use Props vs DI
|
||||
|
||||
| Use | When |
|
||||
| --------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **DI** | The data depends on _how_ it's fetched (different per platform) — API calls, file operations, navigation |
|
||||
| **Props** | The data is the same regardless of platform — configuration flags, display options |
|
||||
|
||||
## Composables for Shared Logic
|
||||
|
||||
Extract reusable stateful logic into composables in `packages/ui/src/composables/`. The shared page orchestrates them internally:
|
||||
|
||||
- Search (Fuse.js fuzzy search over items)
|
||||
- Filtering (dynamic filter pills)
|
||||
- Selection (multi-select with bulk operations)
|
||||
- Bulk operations (sequential execution with progress tracking)
|
||||
|
||||
## Key Files
|
||||
|
||||
- `packages/ui/src/pages/` (or `src/layout/`) — shared page components
|
||||
- `packages/ui/src/providers/` — DI contracts
|
||||
- `packages/ui/src/composables/` — shared stateful logic
|
||||
- `apps/frontend/src/app.vue` — website root provider setup
|
||||
- `apps/app-frontend/src/App.vue` — app root provider setup
|
||||
- `apps/app-frontend/src/routes.js` — app route definitions
|
||||
1. **Read the target page** at `$ARGUMENTS` and understand its data sources, mutations, and navigation.
|
||||
2. **Read the standards above** to understand the shared vs wrapped distinction and the DI pattern.
|
||||
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.
|
||||
- **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:**
|
||||
- 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.
|
||||
- Extract reusable stateful logic (search, filtering, selection) into `composables/`.
|
||||
- Implement the contract separately in each frontend (`apps/frontend/`, `apps/app-frontend/`).
|
||||
5. **For wrapped pages:**
|
||||
- Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure.
|
||||
- Replace any platform-specific imports with shared utilities.
|
||||
- Import and render the wrapped page from both frontends as a simple component.
|
||||
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.
|
||||
|
||||
Reference in New Issue
Block a user