Files
Modrinth-plus/standards/frontend/CROSS_PLATFORM_PAGES.md
Calum H. d0c7575a23 feat: move notion docs to standards folder (#5590)
* feat: move notion docs to standards folder

* fix: remove skills mention (automatic now)
2026-03-16 17:30:05 +00:00

5.3 KiB

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

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

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

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

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

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