Files
Modrinth-plus/.claude/skills/cross-platform-pages/SKILL.md
Calum H. 51066c476a devex: fix claude.md (#5439)
* feat: start on agents.md/claude.md

* feat: set up

* feat: api-client claude + skills

* feat: apps/frontend

* feat: skills list

* fix: lint issues
2026-02-27 17:21:35 +00:00

4.9 KiB

Cross-Platform Page System

When a page needs to exist in both the Modrinth App (apps/app-frontend) and the Modrinth Website (apps/frontend), use the cross-platform page system.

How It Works

  1. Pages live as Vue SFCs in packages/ui — either in src/pages/ or src/layout/ (if src/pages/ doesn't exist, it's been renamed to src/layout/).
  2. Platform-dependent data flows via DI — the app uses Tauri invoke commands, the website uses api-client or the legacy useBaseFetch composable. The shared page never knows which. See the dependency-injection skill for full DI docs.
  3. Non-platform-dependent data flows via props — if data doesn't change based on how it's fetched, just pass it as a prop.

Example: Content Page

ContentPageLayout demonstrates the full pattern.

1. Define a DI contract in packages/ui/src/providers/

The provider interface abstracts all platform-specific operations:

// 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:

<!-- 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:

<!-- 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:

<!-- 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