diff --git a/CLAUDE.md b/CLAUDE.md
index 6161ef281f..12cb942a03 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,10 +67,22 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`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
### Comments
-- DO NOT use "heading" comments like: // === Helper methods === .
+- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json
index c7561a588b..2352839df3 100644
--- a/apps/app-frontend/package.json
+++ b/apps/app-frontend/package.json
@@ -28,17 +28,18 @@
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
- "intl-messageformat": "^10.7.7",
- "vue-i18n": "^10.0.0",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
+ "fuse.js": "^6.6.2",
+ "intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"pinia": "^3.0.0",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
+ "vue-i18n": "^10.0.0",
"vue-multiselect": "3.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 601d7172a6..654d859256 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -31,12 +31,14 @@ import {
Button,
ButtonStyled,
commonMessages,
+ CreationFlowModal,
defineMessages,
I18nDebugPanel,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
ProgressSpinner,
+ provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
@@ -64,7 +66,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
-import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
@@ -84,6 +85,7 @@ import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
+import { create_profile_and_install_from_file } from '@/helpers/pack'
import { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -100,11 +102,11 @@ import {
provideAppUpdateDownloadProgress,
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
+import { setupProviders } from '@/providers/setup'
import { useError } from '@/store/error.js'
import { useInstall } from '@/store/install.js'
import { useLoading, useTheming } from '@/store/state'
-import { create_profile_and_install_from_file } from './helpers/pack'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
@@ -129,6 +131,15 @@ providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
})
+provideModalBehavior({
+ noblur: computed(() => !themeStore.advancedRendering),
+ onShow: () => hide_ads_window(),
+ onHide: () => show_ads_window(),
+})
+
+const { installationModal, handleCreate, handleBrowseModpacks } =
+ setupProviders(notificationManager)
+
const news = ref([])
const availableSurvey = ref(false)
@@ -801,9 +812,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
-
-
+
@@ -849,7 +864,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -937,9 +952,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
-
-
+
+
+
-
+
-
-
-
-
-
+
-
-
- {{ breadcrumbData.resetToNames(breadcrumbs) }}
-
- {{
- breadcrumb.name.charAt(0) === '?'
- ? breadcrumbData.getName(breadcrumb.name.slice(1))
- : breadcrumb.name
- }}
-
- {{
- breadcrumb.name.charAt(0) === '?'
- ? breadcrumbData.getName(breadcrumb.name.slice(1))
- : breadcrumb.name
- }}
-
-
+ {{ breadcrumbData.resetToNames(breadcrumbs) }}
+
+
+ {{ resolveLabel(breadcrumb.name) }}
+
+
+ {{ resolveLabel(breadcrumb.name) }}
+
+
+
+
-
+
+
diff --git a/apps/app-frontend/src/components/ui/ExportModal.vue b/apps/app-frontend/src/components/ui/ExportModal.vue
index 4966b1c7bd..fa605f3206 100644
--- a/apps/app-frontend/src/components/ui/ExportModal.vue
+++ b/apps/app-frontend/src/components/ui/ExportModal.vue
@@ -1,6 +1,14 @@
-
+
@@ -143,7 +178,7 @@ const exportPack = async () => {
- Select files and folders to include in pack
+ {{ formatMessage(messages.selectFilesLabel) }}
{
diff --git a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue
deleted file mode 100644
index b2920fbb5e..0000000000
--- a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue
+++ /dev/null
@@ -1,662 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Select icon
-
-
-
- Remove icon
-
-
-
-
-
-
-
-
-
-
-
-
Import from file
-
Or drag and drop your .mrpack file
-
-
-
-
-
{{ selectedProfileType.name }} path
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- profiles
- .get(selectedProfileType.name)
- ?.forEach((child) => (child.selected = newValue))
- "
- />
-
-
Profile name
-
-
-
-
-
-
-
- {{ profile.name }}
-
-
-
-
No profiles found
-
-
-
-
-
-
-
-
-
diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
index 93cf9bc553..b18aa765d7 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
@@ -1,37 +1,34 @@
- repairProfile(true)"
- />
- {
- changingVersion = false
- modpackVersion =
- modpackVersions?.find(
- (version: Version) => version.id === props.instance.linked_data?.version_id,
- ) ?? null
- }
- "
- />
- unpairProfile()"
- />
- repairModpack()"
- />
-
-
-
- {{ formatMessage(messages.noConnection) }}
-
-
-
-
- {{ formatMessage(messages.noModpackFound) }}
-
-
{{ formatMessage(messages.debugInformation) }}
-
- {{ instance.linked_data }}
-
-
-
-
-
- {{ formatMessage(messages.fetchingModpackDetails) }}
-
-
-
-
-
-
-
-
-
-
- {{
- modpackProject
- ? modpackProject.title
- : formatMessage(messages.minecraftVersion, {
- version: instance.game_version,
+
+
+
+
+
+ {{ formatMessage(messages.repairInstanceTitle) }}
+
+
+ {{ formatMessage(messages.repairInstanceDescription) }}
+
+
+
- {{
- modpackProject
- ? modpackVersion
- ? modpackVersion?.version_number
- : 'Unknown version'
- : formatLoader(formatMessage, instance.loader)
- }}
-
- {{ instance.loader_version || formatMessage(messages.unknownVersion) }}
-
-
-
-
-
-
-
-
-
- {{
- repairing
- ? formatMessage(messages.repairingButton)
- : formatMessage(messages.repairButton)
- }}
-
-
-
- {
- changingVersion = true
- modpackVersionModal.show()
- }
- "
- >
-
-
- {{
- changingVersion
- ? formatMessage(messages.installingButton)
- : formatMessage(messages.changeVersionButton)
- }}
-
-
-
-
-
-
-
-
-
-
-
-
+ : null
+ "
+ class="mt-2 !border max-w-fit"
+ :disabled="installing || repairing || reinstalling || offline"
+ @click="repairConfirmModal.show()"
+ >
+
+
+ {{
+ repairing
+ ? formatMessage(messages.repairingButton)
+ : formatMessage(messages.repairButton)
+ }}
+
+
-
-
-
-
-
- {{
- formatMessage(messages.noLoaderVersions, {
- loader: loader,
- version: gameVersion,
- })
- }}
-
-
-
+
+
+
+
-
-
- {{ formatMessage(messages.unlinkInstanceDescription) }}
-
-
-
- {{ formatMessage(messages.unlinkInstanceButton) }}
-
-
-
-
-
-
- {{ formatMessage(messages.reinstallModpackDescription) }}
-
-
-
-
-
-
- {{
- reinstalling
- ? formatMessage(messages.reinstallingModpackButton)
- : formatMessage(messages.reinstallModpackButton)
- }}
-
-
-
-
-
-
+
+
+ repairProfile(true)"
+ />
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue
index 06e23d2e8e..3b63e07c26 100644
--- a/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue
+++ b/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue
@@ -1,13 +1,9 @@
+
diff --git a/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue
index 9358ee908c..3ac556f5a0 100644
--- a/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue
+++ b/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue
@@ -1,12 +1,8 @@
+
@@ -56,7 +46,5 @@ function onModalHide() {
:share-text="shareText"
:link="link"
:open-in-new-tab="openInNewTab"
- :on-hide="onModalHide"
- :noblur="!themeStore.advancedRendering"
/>
diff --git a/apps/app-frontend/src/helpers/cache.js b/apps/app-frontend/src/helpers/cache.js
index 6c63126646..d19be21693 100644
--- a/apps/app-frontend/src/helpers/cache.js
+++ b/apps/app-frontend/src/helpers/cache.js
@@ -51,3 +51,17 @@ export async function get_search_results_many(ids, cacheBehaviour) {
export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
}
+
+/**
+ * Get versions for a project (without changelogs for fast loading).
+ * Uses the cache system - versions are cached for 30 minutes.
+ * @param {string} projectId - The project ID
+ * @param {string} [cacheBehaviour] - Cache behaviour ('must_revalidate', etc.)
+ * @returns {Promise} Array of version objects (without changelogs) or null
+ */
+export async function get_project_versions(projectId, cacheBehaviour) {
+ return await invoke('plugin:cache|get_project_versions', {
+ projectId,
+ cacheBehaviour,
+ })
+}
diff --git a/apps/app-frontend/src/helpers/pack.js b/apps/app-frontend/src/helpers/pack.js
deleted file mode 100644
index 312dfc62aa..0000000000
--- a/apps/app-frontend/src/helpers/pack.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * All theseus API calls return serialized values (both return values and errors);
- * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
- * and deserialized into a usable JS object.
- */
-import { invoke } from '@tauri-apps/api/core'
-
-import { create } from './profile'
-
-// Installs pack from a version ID
-export async function create_profile_and_install(
- projectId,
- versionId,
- packTitle,
- iconUrl,
- createInstanceCallback = () => {},
-) {
- const location = {
- type: 'fromVersionId',
- project_id: projectId,
- version_id: versionId,
- title: packTitle,
- icon_url: iconUrl,
- }
- const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
- const profile = await create(
- profile_creator.name,
- profile_creator.gameVersion,
- profile_creator.modloader,
- profile_creator.loaderVersion,
- null,
- true,
- )
- createInstanceCallback(profile)
-
- return await invoke('plugin:pack|pack_install', { location, profile })
-}
-
-export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
- const location = {
- type: 'fromVersionId',
- project_id: projectId,
- version_id: versionId,
- title,
- }
- return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
-}
-
-// Installs pack from a path
-export async function create_profile_and_install_from_file(path) {
- const location = {
- type: 'fromFile',
- path: path,
- }
- const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
- const profile = await create(
- profile_creator.name,
- profile_creator.gameVersion,
- profile_creator.modloader,
- profile_creator.loaderVersion,
- null,
- true,
- )
- return await invoke('plugin:pack|pack_install', { location, profile })
-}
diff --git a/apps/app-frontend/src/helpers/pack.ts b/apps/app-frontend/src/helpers/pack.ts
new file mode 100644
index 0000000000..ae1131a3f8
--- /dev/null
+++ b/apps/app-frontend/src/helpers/pack.ts
@@ -0,0 +1,90 @@
+import { invoke } from '@tauri-apps/api/core'
+
+import { create } from './profile'
+import type { InstanceLoader } from './types'
+
+interface PackProfileCreator {
+ name: string
+ gameVersion: string
+ modloader: InstanceLoader
+ loaderVersion: string | null
+}
+
+interface PackLocationVersionId {
+ type: 'fromVersionId'
+ project_id: string
+ version_id: string
+ title: string
+ icon_url?: string
+}
+
+interface PackLocationFile {
+ type: 'fromFile'
+ path: string
+}
+
+export async function create_profile_and_install(
+ projectId: string,
+ versionId: string,
+ packTitle: string,
+ iconUrl?: string,
+ createInstanceCallback: (profile: string) => void = () => {},
+): Promise {
+ const location: PackLocationVersionId = {
+ type: 'fromVersionId',
+ project_id: projectId,
+ version_id: versionId,
+ title: packTitle,
+ icon_url: iconUrl,
+ }
+ const profile_creator = await invoke(
+ 'plugin:pack|pack_get_profile_from_pack',
+ { location },
+ )
+ const profile = await create(
+ profile_creator.name,
+ profile_creator.gameVersion,
+ profile_creator.modloader,
+ profile_creator.loaderVersion,
+ null,
+ true,
+ )
+ createInstanceCallback(profile)
+
+ return await invoke('plugin:pack|pack_install', { location, profile })
+}
+
+export async function install_to_existing_profile(
+ projectId: string,
+ versionId: string,
+ title: string,
+ profilePath: string,
+): Promise {
+ const location: PackLocationVersionId = {
+ type: 'fromVersionId',
+ project_id: projectId,
+ version_id: versionId,
+ title,
+ }
+ return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
+}
+
+export async function create_profile_and_install_from_file(path: string): Promise {
+ const location: PackLocationFile = {
+ type: 'fromFile',
+ path,
+ }
+ const profile_creator = await invoke(
+ 'plugin:pack|pack_get_profile_from_pack',
+ { location },
+ )
+ const profile = await create(
+ profile_creator.name,
+ profile_creator.gameVersion,
+ profile_creator.modloader,
+ profile_creator.loaderVersion,
+ null,
+ true,
+ )
+ return await invoke('plugin:pack|pack_install', { location, profile })
+}
diff --git a/apps/app-frontend/src/helpers/profile.js b/apps/app-frontend/src/helpers/profile.js
deleted file mode 100644
index 7c3f1090f4..0000000000
--- a/apps/app-frontend/src/helpers/profile.js
+++ /dev/null
@@ -1,207 +0,0 @@
-/**
- * All theseus API calls return serialized values (both return values and errors);
- * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
- * and deserialized into a usable JS object.
- */
-import { invoke } from '@tauri-apps/api/core'
-
-import { install_to_existing_profile } from '@/helpers/pack.js'
-
-/// Add instance
-/*
- name: String, // the name of the profile, and relative path to create
- game_version: String, // the game version of the profile
- modloader: ModLoader, // the modloader to use
- - ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
- loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
- icon: Path, // the icon for the profile
- - icon is a path to an image file, which will be copied into the profile directory
-*/
-
-export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall) {
- //Trim string name to avoid "Unable to find directory"
- name = name.trim()
- return await invoke('plugin:profile-create|profile_create', {
- name,
- gameVersion,
- modloader,
- loaderVersion,
- icon,
- skipInstall,
- })
-}
-
-// duplicate a profile
-export async function duplicate(path) {
- return await invoke('plugin:profile-create|profile_duplicate', { path })
-}
-
-// Remove a profile
-export async function remove(path) {
- return await invoke('plugin:profile|profile_remove', { path })
-}
-
-// Get a profile by path
-// Returns a Profile
-export async function get(path) {
- return await invoke('plugin:profile|profile_get', { path })
-}
-
-export async function get_many(paths) {
- return await invoke('plugin:profile|profile_get_many', { paths })
-}
-
-// Get a profile's projects
-// Returns a map of a path to profile file
-export async function get_projects(path, cacheBehaviour) {
- return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
-}
-
-// Get a profile's full fs path
-// Returns a path
-export async function get_full_path(path) {
- return await invoke('plugin:profile|profile_get_full_path', { path })
-}
-
-// Get's a mod's full fs path
-// Returns a path
-export async function get_mod_full_path(path, projectPath) {
- return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
-}
-
-// Get optimal java version from profile
-// Returns a java version
-export async function get_optimal_jre_key(path) {
- return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
-}
-
-// Get a copy of the profile set
-// Returns hashmap of path -> Profile
-export async function list() {
- return await invoke('plugin:profile|profile_list')
-}
-
-export async function check_installed(path, projectId) {
- return await invoke('plugin:profile|profile_check_installed', { path, projectId })
-}
-
-// Installs/Repairs a profile
-export async function install(path, force) {
- return await invoke('plugin:profile|profile_install', { path, force })
-}
-
-// Updates all of a profile's projects
-export async function update_all(path) {
- return await invoke('plugin:profile|profile_update_all', { path })
-}
-
-// Updates a specified project
-export async function update_project(path, projectPath) {
- return await invoke('plugin:profile|profile_update_project', { path, projectPath })
-}
-
-// Add a project to a profile from a version
-// Returns a path to the new project file
-export async function add_project_from_version(path, versionId) {
- return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
-}
-
-// Add a project to a profile from a path + project_type
-// Returns a path to the new project file
-export async function add_project_from_path(path, projectPath, projectType) {
- return await invoke('plugin:profile|profile_add_project_from_path', {
- path,
- projectPath,
- projectType,
- })
-}
-
-// Toggle disabling a project
-export async function toggle_disable_project(path, projectPath) {
- return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
-}
-
-// Remove a project
-export async function remove_project(path, projectPath) {
- return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
-}
-
-// Update a managed Modrinth profile to a specific version
-export async function update_managed_modrinth_version(path, versionId) {
- return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
- path,
- versionId,
- })
-}
-
-// Repair a managed Modrinth profile
-export async function update_repair_modrinth(path) {
- return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
-}
-
-// Export a profile to .mrpack
-/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
-// Version id is optional (ie: 1.1.5)
-export async function export_profile_mrpack(
- path,
- exportLocation,
- includedOverrides,
- versionId,
- description,
- name,
-) {
- return await invoke('plugin:profile|profile_export_mrpack', {
- path,
- exportLocation,
- includedOverrides,
- versionId,
- description,
- name,
- })
-}
-
-// Given a folder path, populate an array of all the subfolders
-// Intended to be used for finding potential override folders
-// profile
-// -- mods
-// -- resourcepacks
-// -- file1
-// => [mods, resourcepacks]
-// allows selection for 'included_overrides' in export_profile_mrpack
-export async function get_pack_export_candidates(profilePath) {
- return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
-}
-
-// Run Minecraft using a pathed profile
-// Returns PID of child
-export async function run(path) {
- return await invoke('plugin:profile|profile_run', { path })
-}
-
-export async function kill(path) {
- return await invoke('plugin:profile|profile_kill', { path })
-}
-
-// Edits a profile
-export async function edit(path, editProfile) {
- return await invoke('plugin:profile|profile_edit', { path, editProfile })
-}
-
-// Edits a profile's icon
-export async function edit_icon(path, iconPath) {
- return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
-}
-
-export async function finish_install(instance) {
- if (instance.install_stage !== 'pack_installed') {
- let linkedData = instance.linked_data
- await install_to_existing_profile(
- linkedData.project_id,
- linkedData.version_id,
- instance.name,
- instance.path,
- )
- } else {
- await install(instance.path, false)
- }
-}
diff --git a/apps/app-frontend/src/helpers/profile.ts b/apps/app-frontend/src/helpers/profile.ts
new file mode 100644
index 0000000000..5107c34b93
--- /dev/null
+++ b/apps/app-frontend/src/helpers/profile.ts
@@ -0,0 +1,287 @@
+/**
+ * All theseus API calls return serialized values (both return values and errors);
+ * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
+ * and deserialized into a usable JS object.
+ */
+import type { Labrinth } from '@modrinth/api-client'
+import type { ContentItem, ContentOwner } from '@modrinth/ui'
+import { invoke } from '@tauri-apps/api/core'
+
+import { install_to_existing_profile } from '@/helpers/pack'
+
+import type {
+ CacheBehaviour,
+ ContentFile,
+ ContentFileProjectType,
+ GameInstance,
+ InstanceLoader,
+} from './types'
+
+// Add instance
+/*
+ name: String, // the name of the profile, and relative path to create
+ game_version: String, // the game version of the profile
+ modloader: ModLoader, // the modloader to use
+ - ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
+ loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
+ icon: Path, // the icon for the profile
+ - icon is a path to an image file, which will be copied into the profile directory
+*/
+
+export async function create(
+ name: string,
+ gameVersion: string,
+ modloader: InstanceLoader,
+ loaderVersion: string | null,
+ icon: string | null,
+ skipInstall: boolean,
+): Promise {
+ // Trim string name to avoid "Unable to find directory"
+ name = name.trim()
+ return await invoke('plugin:profile-create|profile_create', {
+ name,
+ gameVersion,
+ modloader,
+ loaderVersion,
+ icon,
+ skipInstall,
+ })
+}
+
+// duplicate a profile
+export async function duplicate(path: string): Promise {
+ return await invoke('plugin:profile-create|profile_duplicate', { path })
+}
+
+// Remove a profile
+export async function remove(path: string): Promise {
+ return await invoke('plugin:profile|profile_remove', { path })
+}
+
+// Get a profile by path
+// Returns a Profile
+export async function get(path: string): Promise {
+ return await invoke('plugin:profile|profile_get', { path })
+}
+
+export async function get_many(paths: string[]): Promise {
+ return await invoke('plugin:profile|profile_get_many', { paths })
+}
+
+// Get a profile's projects
+// Returns a map of a path to profile file
+export async function get_projects(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise> {
+ return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
+}
+
+// Get content items with rich metadata for a profile
+// Returns content items filtered to exclude modpack files (if linked),
+// sorted alphabetically by project name
+export async function get_content_items(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_content_items', { path, cacheBehaviour })
+}
+
+// Linked modpack info returned from backend
+export interface LinkedModpackInfo {
+ project: Labrinth.Projects.v2.Project
+ version: Labrinth.Versions.v2.Version
+ owner: ContentOwner | null
+ has_update: boolean
+ update_version_id: string | null
+ update_version: Labrinth.Versions.v2.Version | null
+}
+
+// Get linked modpack info for a profile
+// Returns project, version, and owner information for the linked modpack,
+// or null if the profile is not linked to a modpack
+export async function get_linked_modpack_info(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_linked_modpack_info', { path, cacheBehaviour })
+}
+
+// Get content items that are part of the linked modpack
+// Returns the modpack's dependencies as ContentItem list
+// Returns empty array if the profile is not linked to a modpack
+export async function get_linked_modpack_content(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_linked_modpack_content', { path, cacheBehaviour })
+}
+
+// Convert a list of dependencies into ContentItems with rich metadata
+export async function get_dependencies_as_content_items(
+ dependencies: Labrinth.Versions.v3.Dependency[],
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_dependencies_as_content_items', {
+ dependencies,
+ cacheBehaviour,
+ })
+}
+
+// Get a profile's full fs path
+// Returns a path
+export async function get_full_path(path: string): Promise {
+ return await invoke('plugin:profile|profile_get_full_path', { path })
+}
+
+// Get's a mod's full fs path
+// Returns a path
+export async function get_mod_full_path(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
+}
+
+// Get optimal java version from profile
+// Returns a java version
+export async function get_optimal_jre_key(path: string): Promise {
+ return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
+}
+
+// Get a copy of the profile set
+// Returns hashmap of path -> Profile
+export async function list(): Promise {
+ return await invoke('plugin:profile|profile_list')
+}
+
+export async function check_installed(path: string, projectId: string): Promise {
+ return await invoke('plugin:profile|profile_check_installed', { path, projectId })
+}
+
+// Installs/Repairs a profile
+export async function install(path: string, force: boolean): Promise {
+ return await invoke('plugin:profile|profile_install', { path, force })
+}
+
+// Updates all of a profile's projects
+export async function update_all(path: string): Promise> {
+ return await invoke('plugin:profile|profile_update_all', { path })
+}
+
+// Updates a specified project
+export async function update_project(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_update_project', { path, projectPath })
+}
+
+// Add a project to a profile from a version
+// Returns a path to the new project file
+export async function add_project_from_version(path: string, versionId: string): Promise {
+ return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
+}
+
+// Add a project to a profile from a path + project_type
+// Returns a path to the new project file
+export async function add_project_from_path(
+ path: string,
+ projectPath: string,
+ projectType?: ContentFileProjectType,
+): Promise {
+ return await invoke('plugin:profile|profile_add_project_from_path', {
+ path,
+ projectPath,
+ projectType,
+ })
+}
+
+// Toggle disabling a project
+export async function toggle_disable_project(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
+}
+
+// Remove a project
+export async function remove_project(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
+}
+
+// Update a managed Modrinth profile to a specific version
+export async function update_managed_modrinth_version(
+ path: string,
+ versionId: string,
+): Promise {
+ return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
+ path,
+ versionId,
+ })
+}
+
+// Repair a managed Modrinth profile
+export async function update_repair_modrinth(path: string): Promise {
+ return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
+}
+
+// Export a profile to .mrpack
+// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
+// Version id is optional (ie: 1.1.5)
+export async function export_profile_mrpack(
+ path: string,
+ exportLocation: string,
+ includedOverrides: string[],
+ versionId?: string,
+ description?: string,
+ name?: string,
+): Promise {
+ return await invoke('plugin:profile|profile_export_mrpack', {
+ path,
+ exportLocation,
+ includedOverrides,
+ versionId,
+ description,
+ name,
+ })
+}
+
+// Given a folder path, populate an array of all the subfolders
+// Intended to be used for finding potential override folders
+// profile
+// -- mods
+// -- resourcepacks
+// -- file1
+// => [mods, resourcepacks]
+// allows selection for 'included_overrides' in export_profile_mrpack
+export async function get_pack_export_candidates(profilePath: string): Promise {
+ return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
+}
+
+// Run Minecraft using a pathed profile
+// Returns PID of child
+export async function run(path: string): Promise {
+ return await invoke('plugin:profile|profile_run', { path })
+}
+
+export async function kill(path: string): Promise {
+ return await invoke('plugin:profile|profile_kill', { path })
+}
+
+// Edits a profile
+export async function edit(path: string, editProfile: Partial): Promise {
+ return await invoke('plugin:profile|profile_edit', { path, editProfile })
+}
+
+// Edits a profile's icon
+export async function edit_icon(path: string, iconPath: string | null): Promise {
+ return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
+}
+
+export async function finish_install(instance: GameInstance): Promise {
+ if (instance.install_stage !== 'pack_installed') {
+ const linkedData = instance.linked_data
+ if (linkedData) {
+ await install_to_existing_profile(
+ linkedData.project_id,
+ linkedData.version_id,
+ instance.name,
+ instance.path,
+ )
+ }
+ } else {
+ await install(instance.path, false)
+ }
+}
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index 8e6ac8b416..c3b46e3d49 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -49,17 +49,10 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
- hash: string
- file_name: string
- size: number
- metadata?: FileMetadata
- update_version_id?: string
- project_type: ContentFileProjectType
-}
-
-type FileMetadata = {
- project_id: string
- version_id: string
+ metadata?: {
+ project_id: string
+ version_id: string
+ }
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index d621401151..e00dfc9aec 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -5,6 +5,66 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
+ "app.export-modal.description-placeholder": {
+ "message": "Enter modpack description..."
+ },
+ "app.export-modal.export-button": {
+ "message": "Export"
+ },
+ "app.export-modal.header": {
+ "message": "Export modpack"
+ },
+ "app.export-modal.modpack-name-label": {
+ "message": "Modpack Name"
+ },
+ "app.export-modal.modpack-name-placeholder": {
+ "message": "Modpack name"
+ },
+ "app.export-modal.select-files-label": {
+ "message": "Select files and folders to include in pack"
+ },
+ "app.export-modal.version-number-label": {
+ "message": "Version number"
+ },
+ "app.export-modal.version-number-placeholder": {
+ "message": "1.0.0"
+ },
+ "app.instance.mods.content-type-project": {
+ "message": "project"
+ },
+ "app.instance.mods.copy-link": {
+ "message": "Copy link"
+ },
+ "app.instance.mods.installing": {
+ "message": "Installing..."
+ },
+ "app.instance.mods.modpack-fallback": {
+ "message": "Modpack"
+ },
+ "app.instance.mods.project-was-added": {
+ "message": "\"{name}\" was added"
+ },
+ "app.instance.mods.projects-were-added": {
+ "message": "{count} projects were added"
+ },
+ "app.instance.mods.share-text": {
+ "message": "Check out the projects I'm using in my modpack!"
+ },
+ "app.instance.mods.share-title": {
+ "message": "Sharing modpack content"
+ },
+ "app.instance.mods.show-file": {
+ "message": "Show file"
+ },
+ "app.instance.mods.successfully-uploaded": {
+ "message": "Successfully uploaded"
+ },
+ "app.instance.mods.unknown-version": {
+ "message": "Unknown"
+ },
+ "app.instance.mods.updating": {
+ "message": "Updating..."
+ },
"app.modal.install-to-play.header": {
"message": "Install to play"
},
@@ -230,12 +290,6 @@
"instance.edit-world.title": {
"message": "Edit world"
},
- "instance.filter.disabled": {
- "message": "Disabled projects"
- },
- "instance.filter.updates-available": {
- "message": "Updates available"
- },
"instance.server-modal.address": {
"message": "Address"
},
@@ -350,54 +404,24 @@
"instance.settings.tabs.installation.change-version.already-installed.vanilla": {
"message": "Vanilla {game_version} already installed"
},
- "instance.settings.tabs.installation.change-version.button": {
- "message": "Change version"
- },
"instance.settings.tabs.installation.change-version.button.install": {
"message": "Install"
},
"instance.settings.tabs.installation.change-version.button.installing": {
"message": "Installing"
},
- "instance.settings.tabs.installation.change-version.cannot-while-fetching": {
- "message": "Fetching modpack versions"
- },
- "instance.settings.tabs.installation.change-version.in-progress": {
- "message": "Installing new version"
- },
- "instance.settings.tabs.installation.currently-installed": {
- "message": "Currently installed"
- },
- "instance.settings.tabs.installation.debug-information": {
- "message": "Debug information:"
- },
- "instance.settings.tabs.installation.fetching-modpack-details": {
- "message": "Fetching modpack details"
- },
"instance.settings.tabs.installation.game-version": {
"message": "Game version"
},
- "instance.settings.tabs.installation.install": {
- "message": "Install"
- },
"instance.settings.tabs.installation.install.in-progress": {
"message": "Installation in progress"
},
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} version"
},
- "instance.settings.tabs.installation.minecraft-version": {
- "message": "Minecraft {version}"
- },
- "instance.settings.tabs.installation.no-connection": {
- "message": "Cannot fetch linked modpack details. Please check your internet connection."
- },
"instance.settings.tabs.installation.no-loader-versions": {
"message": "{loader} is not available for Minecraft {version}. Try another mod loader."
},
- "instance.settings.tabs.installation.no-modpack-found": {
- "message": "This instance is linked to a modpack, but the modpack could not be found on Modrinth."
- },
"instance.settings.tabs.installation.platform": {
"message": "Platform"
},
@@ -413,12 +437,6 @@
"instance.settings.tabs.installation.reinstall.confirm.title": {
"message": "Are you sure you want to reinstall this instance?"
},
- "instance.settings.tabs.installation.reinstall.description": {
- "message": "Resets the instance's content to its original state, removing any mods or content you have added on top of the original modpack."
- },
- "instance.settings.tabs.installation.reinstall.title": {
- "message": "Reinstall modpack"
- },
"instance.settings.tabs.installation.repair.button": {
"message": "Repair"
},
@@ -431,18 +449,18 @@
"instance.settings.tabs.installation.repair.confirm.title": {
"message": "Repair instance?"
},
+ "instance.settings.tabs.installation.repair.description": {
+ "message": "Reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors."
+ },
"instance.settings.tabs.installation.repair.in-progress": {
"message": "Repair in progress"
},
+ "instance.settings.tabs.installation.repair.title": {
+ "message": "Repair instance"
+ },
"instance.settings.tabs.installation.reset-selections": {
"message": "Reset to current"
},
- "instance.settings.tabs.installation.show-all-versions": {
- "message": "Show all versions"
- },
- "instance.settings.tabs.installation.tooltip.action.change-version": {
- "message": "change version"
- },
"instance.settings.tabs.installation.tooltip.action.install": {
"message": "install"
},
@@ -461,24 +479,6 @@
"instance.settings.tabs.installation.tooltip.cannot-while-repairing": {
"message": "Cannot {action} while repairing"
},
- "instance.settings.tabs.installation.unknown-version": {
- "message": "(unknown version)"
- },
- "instance.settings.tabs.installation.unlink.button": {
- "message": "Unlink instance"
- },
- "instance.settings.tabs.installation.unlink.confirm.description": {
- "message": "If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal."
- },
- "instance.settings.tabs.installation.unlink.confirm.title": {
- "message": "Are you sure you want to unlink this instance?"
- },
- "instance.settings.tabs.installation.unlink.description": {
- "message": "This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack."
- },
- "instance.settings.tabs.installation.unlink.title": {
- "message": "Unlink from modpack"
- },
"instance.settings.tabs.java": {
"message": "Java and memory"
},
diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue
index 76b00f318f..01e2bad568 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -72,6 +72,10 @@ const instance: Ref = ref(null)
const instanceProjects: Ref = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref([])
+// Non-reactive snapshot used by instanceFilters to avoid triggering a search
+// refresh when an item is installed mid-browse (which causes content shift).
+// Synced before each search triggered by filter/page/query changes.
+let newlyInstalledSnapshot: string[] = []
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
@@ -128,7 +132,7 @@ const instanceFilters = computed(() => {
.filter((x) => x.metadata)
.map((x) => x.metadata.project_id)
- installedMods.push(...newlyInstalled.value)
+ installedMods.push(...newlyInstalledSnapshot)
installedMods
?.map((x) => ({
@@ -206,6 +210,7 @@ watch(requestParams, () => {
})
async function refreshSearch() {
+ newlyInstalledSnapshot = [...newlyInstalled.value]
let rawResults = await get_search_results(requestParams.value)
if (!rawResults) {
rawResults = {
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index 5e8b0a856e..5146b30286 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -1,1178 +1,704 @@
-
-
-
-
-
-
-
- {{ filter.formattedName }}
-
-
-
(currentPage = page)"
- />
-
-
-
+
+
+
-
-
-
- Update
-
-
-
- Share
- Project names
- File names
- Project links
- Markdown links
-
-
-
- Enable
-
-
- Disable
-
-
- Remove
-
-
-
-
-
-
-
- Refresh
-
-
-
- Update all
-
-
-
- Update pack
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Show file
- Copy link
-
-
-
-
-
-
(currentPage = page)"
- />
-
+ :loading="loadingVersions"
+ :loading-changelog="loadingChangelog"
+ @update="handleModalUpdate"
+ @cancel="resetUpdateState"
+ @version-select="handleVersionSelect"
+ @version-hover="handleVersionHover"
+ />
-
-
-
-
-
You haven't added any content to this instance yet.
-
-
-
-
-
-
-
-
+
+
+ await nextTick()
-
+ await initProjects()
+})
-
+onUnmounted(() => {
+ removeBeforeEach()
+ unlisten()
+ unlistenProfiles()
+})
+
diff --git a/apps/app-frontend/src/pages/library/Index.vue b/apps/app-frontend/src/pages/library/Index.vue
index df3e39b154..c21af76acd 100644
--- a/apps/app-frontend/src/pages/library/Index.vue
+++ b/apps/app-frontend/src/pages/library/Index.vue
@@ -1,17 +1,17 @@
diff --git a/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue b/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue
index c918fe974a..9ad8d20819 100644
--- a/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue
+++ b/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue
@@ -4,6 +4,7 @@
:stages="ctx.stageConfigs"
:context="ctx"
:breadcrumbs="!editingVersion"
+ :close-on-click-outside="false"
@hide="() => (modalOpen = false)"
/>
-
-
-
-
-
-
-
-
-
- {{ type }} version
-
-
-
-
-
-
- Show any beta and alpha releases
-
-
-
-
-
-
-
-
{{ type }} version
-
-
- {{ formattedVersions.game_versions[0] }}
-
-
- {{ formattedVersions.loaders[0] }}
-
-
-
-
-
-
- Show any beta and alpha releases
-
-
-
-
-
-
- {{
- noCompatibleVersions
- ? `No compatible versions of this ${type.toLowerCase()} were found`
- : versionFilter
- ? 'Game version and platform is provided by the server'
- : 'Incompatible game version and platform versions are unlocked'
- }}
-
-
-
- {{
- noCompatibleVersions
- ? `No versions compatible with your server were found. You can still select any available version.`
- : versionFilter
- ? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
- to an incompatible version.`
- : "You might see versions listed that aren't compatible with your server configuration."
- }}
-
-
-
-
-
-
-
- {{
- filtersRef?.selectedPlatforms.length === 0
- ? 'All platforms'
- : filtersRef?.selectedPlatforms
- .map((x) => {
- return formatLoader(formatMessage, x)
- })
- .join(', ')
- }}
-
-
-
-
-
- {{
- filtersRef?.selectedGameVersions.length === 0
- ? 'All game versions'
- : filtersRef?.selectedGameVersions.join(', ')
- }}
-
-
-
-
-
- {
- versionFilter = !versionFilter
- setInitialFilters()
- updateFiltersToUi()
- }
- "
- >
-
- {{
- gameVersions.length < 2 && platforms.length < 2
- ? 'No other platforms or versions available'
- : versionFilter
- ? 'Unlock'
- : 'Return to compatibility'
- }}
-
-
-
-
-
-
-
- Something went wrong trying to load versions for this
- {{ type.toLocaleLowerCase() }}. Please try again later or contact support if the issue
- persists.
-
-
-
-
-
-
- Your server was created using a modpack. It's recommended to use the modpack's version of
- the mod.
-
- Modify modpack version
-
-
-
-
-
-
-
- Install
-
-
-
-
-
- Cancel
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FileItem.vue b/apps/frontend/src/components/ui/servers/FileItem.vue
deleted file mode 100644
index 338d351181..0000000000
--- a/apps/frontend/src/components/ui/servers/FileItem.vue
+++ /dev/null
@@ -1,352 +0,0 @@
-
- e.key === 'Enter' && selectItem()"
- @mouseenter="handleMouseEnter"
- @dragstart="handleDragStart"
- @dragend="handleDragEnd"
- @dragenter.prevent="handleDragEnter"
- @dragover.prevent="handleDragOver"
- @dragleave.prevent="handleDragLeave"
- @drop.prevent="handleDrop"
- >
-
-
-
-
-
-
-
- {{ name }}
-
-
-
-
-
- {{ formattedSize }}
-
-
- {{ formattedCreationDate }}
-
-
- {{ formattedModifiedDate }}
-
-
-
-
- Extract
- Rename
- Move
- Download
- Delete
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FileManagerError.vue b/apps/frontend/src/components/ui/servers/FileManagerError.vue
deleted file mode 100644
index 84adf75b66..0000000000
--- a/apps/frontend/src/components/ui/servers/FileManagerError.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
{{ title }}
-
- {{ message }}
-
-
-
-
-
- Try again
-
-
-
-
-
- Go to home folder
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FileVirtualList.vue b/apps/frontend/src/components/ui/servers/FileVirtualList.vue
deleted file mode 100644
index 800783aa0e..0000000000
--- a/apps/frontend/src/components/ui/servers/FileVirtualList.vue
+++ /dev/null
@@ -1,128 +0,0 @@
-
-
-
-
- $emit('contextmenu', item, x, y)"
- @toggle-select="$emit('toggle-select', item.path)"
- />
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue b/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
deleted file mode 100644
index 6c70ee9ae9..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
+++ /dev/null
@@ -1,173 +0,0 @@
-
-
-
-
-
-
-
-
- Home
-
-
-
-
-
-
-
-
-
-
- {{ segment || '' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- New file
- New folder
- Upload file
-
- Upload from .zip file
-
-
- Upload from .zip URL
-
-
- Install CurseForge pack
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesContextMenu.vue b/apps/frontend/src/components/ui/servers/FilesContextMenu.vue
deleted file mode 100644
index 2a3a5aaad1..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesContextMenu.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue b/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue
deleted file mode 100644
index 4ac3e3044e..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue b/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue
deleted file mode 100644
index d5cd6f1382..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue b/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue
deleted file mode 100644
index cb5cf5111d..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue
+++ /dev/null
@@ -1,136 +0,0 @@
-
-
-
-
-
-
-
-
- Home
-
-
-
-
-
-
-
-
- {{ segment || '' }}
-
-
-
-
-
- {{
- fileName
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Save as...
-
-
-
-
- Save & restart
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesEditor.vue b/apps/frontend/src/components/ui/servers/FilesEditor.vue
deleted file mode 100644
index f9961a130a..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesEditor.vue
+++ /dev/null
@@ -1,260 +0,0 @@
-
-
-
-
-
saveFileContent(true)"
- @save-as="saveFileContentAs"
- @save-restart="saveFileContentRestart"
- @share="requestShareLink"
- @navigate="(index) => emit('navigate', index)"
- />
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue b/apps/frontend/src/components/ui/servers/FilesImageViewer.vue
deleted file mode 100644
index e05024c8a5..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue
+++ /dev/null
@@ -1,180 +0,0 @@
-
-
-
-
-
-
-
{{ state.errorMessage || 'Invalid or empty image file.' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ Math.round(state.scale * 100) }}%
- Reset
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesLabelBar.vue b/apps/frontend/src/components/ui/servers/FilesLabelBar.vue
deleted file mode 100644
index 72bf21a295..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesLabelBar.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-
-
-
-
-
- Name
-
-
-
-
-
-
- Size
-
-
-
-
- Created
-
-
-
-
- Modified
-
-
-
- Actions
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue b/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue
deleted file mode 100644
index 3798073117..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue b/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue
deleted file mode 100644
index bcb3fdeece..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue b/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue
deleted file mode 100644
index b655d29d5e..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
-
-
- Over 100 files will be overwritten if you proceed with extraction; here is just some of
- them:
-
-
- The following {{ files.length }} files already exist on your server, and will be
- overwritten if you proceed with extraction:
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue b/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue
deleted file mode 100644
index 773e015ad1..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
-
- Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
deleted file mode 100644
index 344e3d06c5..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
+++ /dev/null
@@ -1,335 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {{ props.fileType ? props.fileType : 'File' }} uploads
-
- {{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ item.file.name }}
-
{{ item.size }}
-
-
-
- Done
-
-
- Failed - File already exists
-
-
- Failed - {{ item.error?.message || 'An unexpected error occured.' }}
-
-
- Failed - Incorrect file type
-
-
-
- {{ item.progress }}%
-
-
- Cancel
-
-
-
- Cancelled
-
-
- {{ item.progress }}%
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue b/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue
deleted file mode 100644
index 98a7ae24ec..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/InstallingTicker.vue b/apps/frontend/src/components/ui/servers/InstallingTicker.vue
deleted file mode 100644
index 5d43a284cb..0000000000
--- a/apps/frontend/src/components/ui/servers/InstallingTicker.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue
index 2c9072a731..e7b0c74ed7 100644
--- a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue
+++ b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue
@@ -64,15 +64,22 @@
diff --git a/apps/frontend/src/components/ui/servers/ServerInstallation.vue b/apps/frontend/src/components/ui/servers/ServerInstallation.vue
deleted file mode 100644
index 0168d4f203..0000000000
--- a/apps/frontend/src/components/ui/servers/ServerInstallation.vue
+++ /dev/null
@@ -1,286 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
Modpack
-
- Update available
-
-
-
-
-
- Import .mrpack
-
-
-
-
-
-
-
- Switch modpack
-
-
-
-
- Switch modpack
-
-
-
-
-
-
-
Something went wrong while loading your modpack.
-
- {{ versionsError || currentVersionError }}
-
-
- Retry
-
-
-
-
-
-
-
-
- Change version
-
-
-
-
-
-
-
-
- Find a modpack
-
-
- or
-
-
- Upload .mrpack file
-
-
-
-
-
-
-
-
Platform
-
Your server's platform is the software that runs mods and plugins.
-
-
-
- The current platform was automatically selected based on your modpack.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/ServerSidebar.vue b/apps/frontend/src/components/ui/servers/ServerSidebar.vue
index 32534c0a52..1f517a9645 100644
--- a/apps/frontend/src/components/ui/servers/ServerSidebar.vue
+++ b/apps/frontend/src/components/ui/servers/ServerSidebar.vue
@@ -24,12 +24,7 @@
-
+
@@ -38,7 +33,6 @@
import { RightArrowIcon } from '@modrinth/assets'
import type { RouteLocationNormalized } from 'vue-router'
-import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const emit = defineEmits(['reinstall'])
@@ -52,7 +46,6 @@ defineProps<{
shown?: boolean
}[]
route: RouteLocationNormalized
- server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts
index 69195a8982..2e5f498640 100644
--- a/apps/frontend/src/composables/featureFlags.ts
+++ b/apps/frontend/src/composables/featureFlags.ts
@@ -43,6 +43,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
hidePreviewBanner: false,
i18nDebug: false,
showDiscoverProjectButtons: false,
+ useV1ContentTabAPI: true,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/apps/frontend/src/composables/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts
deleted file mode 100644
index 2b02457a28..0000000000
--- a/apps/frontend/src/composables/servers/modrinth-servers.ts
+++ /dev/null
@@ -1,287 +0,0 @@
-import type { AbstractWebNotificationManager } from '@modrinth/ui'
-import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
-import { ModrinthServerError } from '@modrinth/utils'
-
-import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
-import { useServersFetch } from './servers-fetch.ts'
-
-export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
- if (err instanceof ModrinthServerError && err.v1Error) {
- notifications.addNotification({
- title: err.v1Error?.context ?? `An error occurred`,
- type: 'error',
- text: err.v1Error.description,
- errorCode: err.v1Error.error,
- })
- } else {
- notifications.addNotification({
- title: 'An error occurred',
- type: 'error',
- text: err.message ?? (err.data ? err.data.description : err),
- })
- }
-}
-
-export class ModrinthServer {
- readonly serverId: string
- private errors: Partial
> = {}
-
- readonly general: GeneralModule
- readonly content: ContentModule
- readonly network: NetworkModule
- readonly startup: StartupModule
-
- constructor(serverId: string) {
- this.serverId = serverId
-
- this.general = new GeneralModule(this)
- this.content = new ContentModule(this)
- this.network = new NetworkModule(this)
- this.startup = new StartupModule(this)
- }
-
- async fetchConfigFile(fileName: string): Promise {
- return await useServersFetch(`servers/${this.serverId}/config/${fileName}`)
- }
-
- constructServerProperties(properties: any): string {
- let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
-
- for (const [key, value] of Object.entries(properties)) {
- if (typeof value === 'object') {
- fileContent += `${key}=${JSON.stringify(value)}\n`
- } else if (typeof value === 'boolean') {
- fileContent += `${key}=${value ? 'true' : 'false'}\n`
- } else {
- fileContent += `${key}=${value}\n`
- }
- }
-
- return fileContent
- }
-
- async processImage(iconUrl: string | undefined): Promise {
- const sharedImage = useState(`server-icon-${this.serverId}`)
-
- if (sharedImage.value) {
- return sharedImage.value
- }
-
- try {
- const auth = await useServersFetch(`servers/${this.serverId}/fs`)
- try {
- const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
- override: auth,
- retry: 1, // Reduce retries for optional resources
- })
-
- if (fileData instanceof Blob && import.meta.client) {
- const dataURL = await new Promise((resolve) => {
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d')
- const img = new Image()
- img.onload = () => {
- canvas.width = 512
- canvas.height = 512
- ctx?.drawImage(img, 0, 0, 512, 512)
- const dataURL = canvas.toDataURL('image/png')
- sharedImage.value = dataURL
- resolve(dataURL)
- URL.revokeObjectURL(img.src)
- }
- img.src = URL.createObjectURL(fileData)
- })
- return dataURL
- }
- } catch (error) {
- if (error instanceof ModrinthServerError) {
- if (error.statusCode && error.statusCode >= 500) {
- console.debug('Service unavailable, skipping icon processing')
- sharedImage.value = undefined
- return undefined
- }
-
- if (error.statusCode === 404 && iconUrl) {
- try {
- const response = await fetch(iconUrl)
- if (!response.ok) throw new Error('Failed to fetch icon')
- const file = await response.blob()
- const originalFile = new File([file], 'server-icon-original.png', {
- type: 'image/png',
- })
-
- if (import.meta.client) {
- const dataURL = await new Promise((resolve) => {
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d')
- const img = new Image()
- img.onload = () => {
- canvas.width = 64
- canvas.height = 64
- ctx?.drawImage(img, 0, 0, 64, 64)
- canvas.toBlob(async (blob) => {
- if (blob) {
- const scaledFile = new File([blob], 'server-icon.png', {
- type: 'image/png',
- })
- await useServersFetch(`/create?path=/server-icon.png&type=file`, {
- method: 'POST',
- contentType: 'application/octet-stream',
- body: scaledFile,
- override: auth,
- })
- await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
- method: 'POST',
- contentType: 'application/octet-stream',
- body: originalFile,
- override: auth,
- })
- }
- }, 'image/png')
- const dataURL = canvas.toDataURL('image/png')
- sharedImage.value = dataURL
- resolve(dataURL)
- URL.revokeObjectURL(img.src)
- }
- img.src = URL.createObjectURL(file)
- })
- return dataURL
- }
- } catch (externalError: any) {
- console.debug('Could not process external icon:', externalError.message)
- }
- }
- } else {
- throw error
- }
- }
- } catch (error: any) {
- console.debug('Icon processing failed:', error.message)
- }
-
- sharedImage.value = undefined
- return undefined
- }
-
- async testNodeReachability(): Promise {
- if (!this.general?.node?.instance) {
- console.warn('No node instance available for ping test')
- return false
- }
-
- const wsUrl = `wss://${this.general.node.instance}/pingtest`
-
- try {
- return await new Promise((resolve) => {
- const socket = new WebSocket(wsUrl)
- const timeout = setTimeout(() => {
- socket.close()
- resolve(false)
- }, 5000)
-
- socket.onopen = () => {
- clearTimeout(timeout)
- socket.send(performance.now().toString())
- }
-
- socket.onmessage = () => {
- clearTimeout(timeout)
- socket.close()
- resolve(true)
- }
-
- socket.onerror = () => {
- clearTimeout(timeout)
- resolve(false)
- }
- })
- } catch (error) {
- console.error(`Failed to ping node ${wsUrl}:`, error)
- return false
- }
- }
-
- async refresh(
- modules: ModuleName[] = [],
- options?: {
- preserveConnection?: boolean
- preserveInstallState?: boolean
- },
- ): Promise {
- const modulesToRefresh =
- modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
-
- for (const module of modulesToRefresh) {
- this.errors[module] = undefined
-
- try {
- switch (module) {
- case 'general': {
- if (options?.preserveConnection) {
- const currentImage = this.general.image
- const currentMotd = this.general.motd
- const currentStatus = this.general.status
-
- await this.general.fetch()
-
- if (currentImage) {
- this.general.image = currentImage
- }
- if (currentMotd) {
- this.general.motd = currentMotd
- }
- if (options.preserveInstallState && currentStatus === 'installing') {
- this.general.status = 'installing'
- }
- } else {
- await this.general.fetch()
- }
- break
- }
- case 'content':
- await this.content.fetch()
- break
- case 'network':
- await this.network.fetch()
- break
- case 'startup':
- await this.startup.fetch()
- break
- }
- } catch (error) {
- if (error instanceof ModrinthServerError) {
- if (error.statusCode === 404 && module === 'content') {
- console.debug(`Optional ${module} resource not found:`, error.message)
- continue
- }
-
- if (error.statusCode && error.statusCode >= 500) {
- console.debug(`Temporary ${module} unavailable:`, error.message)
- continue
- }
- }
-
- this.errors[module] = {
- error:
- error instanceof ModrinthServerError
- ? error
- : new ModrinthServerError('Unknown error', undefined, error as Error),
- timestamp: Date.now(),
- }
- }
- }
- }
-
- get moduleErrors() {
- return this.errors
- }
-}
-
-export const useModrinthServers = async (
- serverId: string,
- includedModules: ModuleName[] = ['general'],
-) => {
- const server = new ModrinthServer(serverId)
- await server.refresh(includedModules)
- return reactive(server)
-}
diff --git a/apps/frontend/src/composables/servers/modules/backups.ts b/apps/frontend/src/composables/servers/modules/backups.ts
deleted file mode 100644
index 921b9a350e..0000000000
--- a/apps/frontend/src/composables/servers/modules/backups.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import type { AutoBackupSettings, Backup } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class BackupsModule extends ServerModule {
- data: Backup[] = []
-
- async fetch(): Promise {
- this.data = await useServersFetch(`servers/${this.serverId}/backups`, {}, 'backups')
- }
-
- async create(backupName: string): Promise {
- const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
- const tempBackup: Backup = {
- id: tempId,
- name: backupName,
- created_at: new Date().toISOString(),
- locked: false,
- automated: false,
- interrupted: false,
- ongoing: true,
- task: { create: { progress: 0, state: 'ongoing' } },
- }
- this.data.push(tempBackup)
-
- try {
- const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
- method: 'POST',
- body: { name: backupName },
- })
-
- const backup = this.data.find((b) => b.id === tempId)
- if (backup) {
- backup.id = response.id
- }
-
- return response.id
- } catch (error) {
- this.data = this.data.filter((b) => b.id !== tempId)
- throw error
- }
- }
-
- async rename(backupId: string, newName: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/rename`, {
- method: 'POST',
- body: { name: newName },
- })
- await this.fetch()
- }
-
- async delete(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
- method: 'DELETE',
- })
- await this.fetch()
- }
-
- async restore(backupId: string): Promise {
- const backup = this.data.find((b) => b.id === backupId)
- if (backup) {
- if (!backup.task) backup.task = {}
- backup.task.restore = { progress: 0, state: 'ongoing' }
- }
-
- try {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
- method: 'POST',
- })
- } catch (error) {
- if (backup?.task?.restore) {
- delete backup.task.restore
- }
- throw error
- }
- }
-
- async lock(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
- method: 'POST',
- })
- await this.fetch()
- }
-
- async unlock(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
- method: 'POST',
- })
- await this.fetch()
- }
-
- async retry(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/retry`, {
- method: 'POST',
- })
- }
-
- async updateAutoBackup(autoBackup: 'enable' | 'disable', interval: number): Promise {
- await useServersFetch(`servers/${this.serverId}/autobackup`, {
- method: 'POST',
- body: { set: autoBackup, interval },
- })
- }
-
- async getAutoBackup(): Promise {
- return await useServersFetch(`servers/${this.serverId}/autobackup`)
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/base.ts b/apps/frontend/src/composables/servers/modules/base.ts
deleted file mode 100644
index 151fadf231..0000000000
--- a/apps/frontend/src/composables/servers/modules/base.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { ModrinthServer } from '../modrinth-servers.ts'
-
-export abstract class ServerModule {
- protected server: ModrinthServer
-
- constructor(server: ModrinthServer) {
- this.server = server
- }
-
- protected get serverId(): string {
- return this.server.serverId
- }
-
- abstract fetch(): Promise
-}
diff --git a/apps/frontend/src/composables/servers/modules/content.ts b/apps/frontend/src/composables/servers/modules/content.ts
deleted file mode 100644
index 2db34b7691..0000000000
--- a/apps/frontend/src/composables/servers/modules/content.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { ContentType, Mod } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class ContentModule extends ServerModule {
- data: Mod[] = []
-
- async fetch(): Promise {
- const mods = await useServersFetch(`servers/${this.serverId}/mods`, {}, 'content')
- this.data = mods.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? ''))
- }
-
- async install(contentType: ContentType, projectId: string, versionId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/mods`, {
- method: 'POST',
- body: {
- rinth_ids: { project_id: projectId, version_id: versionId },
- install_as: contentType,
- },
- })
- }
-
- async remove(path: string): Promise {
- await useServersFetch(`servers/${this.serverId}/deleteMod`, {
- method: 'POST',
- body: { path },
- })
- }
-
- async reinstall(replace: string, projectId: string, versionId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/mods/update`, {
- method: 'POST',
- body: { replace, project_id: projectId, version_id: versionId },
- })
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/general.ts b/apps/frontend/src/composables/servers/modules/general.ts
deleted file mode 100644
index 77776b78d0..0000000000
--- a/apps/frontend/src/composables/servers/modules/general.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import type { JWTAuth, PowerAction, Project, ServerGeneral } from '@modrinth/utils'
-import { $fetch } from 'ofetch'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class GeneralModule extends ServerModule implements ServerGeneral {
- server_id!: string
- name!: string
- owner_id!: string
- net!: { ip: string; port: number; domain: string }
- game!: string
- backup_quota!: number
- used_backup_quota!: number
- status!: string
- suspension_reason!: string
- loader!: string
- loader_version!: string
- mc_version!: string
- upstream!: {
- kind: 'modpack' | 'mod' | 'resourcepack'
- version_id: string
- project_id: string
- } | null
-
- motd?: string
- image?: string
- project?: Project
- sftp_username!: string
- sftp_password!: string
- sftp_host!: string
- datacenter?: string
- notices?: any[]
- node!: { token: string; instance: string }
- flows?: { intro?: boolean }
-
- is_medal?: boolean
-
- async fetch(): Promise {
- const data = await useServersFetch(`servers/${this.serverId}`, {}, 'general')
-
- if (data.upstream?.project_id) {
- const project = await $fetch(
- `https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
- )
- data.project = project as Project
- }
-
- if (import.meta.client) {
- data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
- }
-
- // Copy data to this module
- Object.assign(this, data)
- }
-
- async updateName(newName: string): Promise {
- await useServersFetch(`servers/${this.serverId}/name`, {
- method: 'POST',
- body: { name: newName },
- })
- }
-
- async power(action: PowerAction): Promise {
- await useServersFetch(`servers/${this.serverId}/power`, {
- method: 'POST',
- body: { action },
- })
- await new Promise((resolve) => setTimeout(resolve, 1000))
- await this.fetch() // Refresh this module
- }
-
- async reinstall(
- loader: boolean,
- projectId: string,
- versionId?: string,
- loaderVersionId?: string,
- hardReset: boolean = false,
- ): Promise {
- const hardResetParam = hardReset ? 'true' : 'false'
- if (loader) {
- if (projectId.toLowerCase() === 'neoforge') {
- projectId = 'NeoForge'
- }
- await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
- method: 'POST',
- body: {
- loader: projectId,
- loader_version: loaderVersionId,
- game_version: versionId,
- },
- })
- } else {
- await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
- method: 'POST',
- body: { project_id: projectId, version_id: versionId },
- })
- }
- }
-
- reinstallFromMrpack(
- mrpack: File,
- hardReset: boolean = false,
- ): {
- promise: Promise
- onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
- } {
- const hardResetParam = hardReset ? 'true' : 'false'
-
- const progressSubject = new EventTarget()
-
- const uploadPromise = (async () => {
- try {
- const auth = await useServersFetch(`servers/${this.serverId}/reinstallFromMrpack`)
-
- await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest()
-
- xhr.upload.addEventListener('progress', (e) => {
- if (e.lengthComputable) {
- progressSubject.dispatchEvent(
- new CustomEvent('progress', {
- detail: {
- loaded: e.loaded,
- total: e.total,
- progress: (e.loaded / e.total) * 100,
- },
- }),
- )
- }
- })
-
- xhr.onload = () =>
- xhr.status >= 200 && xhr.status < 300
- ? resolve()
- : reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`))
-
- xhr.onerror = () => reject(new Error('[pyroservers] .mrpack upload failed'))
- xhr.onabort = () => reject(new Error('[pyroservers] .mrpack upload cancelled'))
- xhr.ontimeout = () => reject(new Error('[pyroservers] .mrpack upload timed out'))
- xhr.timeout = 30 * 60 * 1000
-
- xhr.open('POST', `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`)
- xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`)
-
- const formData = new FormData()
- formData.append('file', mrpack)
- xhr.send(formData)
- })
- } catch (err) {
- console.error('Error reinstalling from mrpack:', err)
- throw err
- }
- })()
-
- return {
- promise: uploadPromise,
- onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
- progressSubject.addEventListener('progress', ((e: CustomEvent) =>
- cb(e.detail)) as EventListener),
- }
- }
-
- async suspend(status: boolean): Promise {
- await useServersFetch(`servers/${this.serverId}/suspend`, {
- method: 'POST',
- body: { suspended: status },
- })
- }
-
- async endIntro(): Promise {
- await useServersFetch(`servers/${this.serverId}/flows/intro`, {
- method: 'DELETE',
- version: 1,
- })
- await this.fetch() // Refresh this module
- }
-
- async setMotd(motd: string): Promise {
- try {
- const props = (await this.server.fetchConfigFile('ServerProperties')) as any
- if (props) {
- props.motd = motd
- const newProps = this.server.constructServerProperties(props)
- const octetStream = new Blob([newProps], { type: 'application/octet-stream' })
- const auth = await useServersFetch(`servers/${this.serverId}/fs`)
-
- await useServersFetch(`/update?path=/server.properties`, {
- method: 'PUT',
- contentType: 'application/octet-stream',
- body: octetStream,
- override: auth,
- })
- }
- } catch {
- console.error(
- '[Modrinth Hosting] [General] Failed to set MOTD due to lack of server properties file.',
- )
- }
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/index.ts b/apps/frontend/src/composables/servers/modules/index.ts
deleted file mode 100644
index 62fe2c45f8..0000000000
--- a/apps/frontend/src/composables/servers/modules/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export * from './backups.ts'
-export * from './base.ts'
-export * from './content.ts'
-export * from './general.ts'
-export * from './network.ts'
-export * from './startup.ts'
-export * from './ws.ts'
diff --git a/apps/frontend/src/composables/servers/modules/network.ts b/apps/frontend/src/composables/servers/modules/network.ts
deleted file mode 100644
index d434f77001..0000000000
--- a/apps/frontend/src/composables/servers/modules/network.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { Allocation } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class NetworkModule extends ServerModule {
- allocations: Allocation[] = []
-
- async fetch(): Promise {
- this.allocations = await useServersFetch(
- `servers/${this.serverId}/allocations`,
- {},
- 'network',
- )
- }
-
- async reserveAllocation(name: string): Promise {
- return await useServersFetch(`servers/${this.serverId}/allocations?name=${name}`, {
- method: 'POST',
- })
- }
-
- async updateAllocation(port: number, name: string): Promise {
- await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, {
- method: 'PUT',
- })
- }
-
- async deleteAllocation(port: number): Promise {
- await useServersFetch(`servers/${this.serverId}/allocations/${port}`, {
- method: 'DELETE',
- })
- }
-
- async checkSubdomainAvailability(subdomain: string): Promise {
- const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as {
- available: boolean
- }
- return result.available
- }
-
- async changeSubdomain(subdomain: string): Promise {
- await useServersFetch(`servers/${this.serverId}/subdomain`, {
- method: 'POST',
- body: { subdomain },
- })
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/startup.ts b/apps/frontend/src/composables/servers/modules/startup.ts
deleted file mode 100644
index a47c031f69..0000000000
--- a/apps/frontend/src/composables/servers/modules/startup.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { JDKBuild, JDKVersion, Startup } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class StartupModule extends ServerModule implements Startup {
- invocation!: string
- original_invocation!: string
- jdk_version!: JDKVersion
- jdk_build!: JDKBuild
-
- async fetch(): Promise {
- const data = await useServersFetch(`servers/${this.serverId}/startup`, {}, 'startup')
- Object.assign(this, data)
- }
-
- async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise {
- await useServersFetch(`servers/${this.serverId}/startup`, {
- method: 'POST',
- body: {
- invocation: invocation || null,
- jdk_version: jdkVersion || null,
- jdk_build: jdkBuild || null,
- },
- })
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/ws.ts b/apps/frontend/src/composables/servers/modules/ws.ts
deleted file mode 100644
index aa10a30294..0000000000
--- a/apps/frontend/src/composables/servers/modules/ws.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { JWTAuth } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class WSModule extends ServerModule implements JWTAuth {
- url!: string
- token!: string
-
- async fetch(): Promise {
- const data = await useServersFetch(`servers/${this.serverId}/ws`, {}, 'ws')
- Object.assign(this, data)
- }
-}
diff --git a/apps/frontend/src/composables/servers/use-server-image.ts b/apps/frontend/src/composables/servers/use-server-image.ts
new file mode 100644
index 0000000000..c613a79ae5
--- /dev/null
+++ b/apps/frontend/src/composables/servers/use-server-image.ts
@@ -0,0 +1,131 @@
+import type { Archon } from '@modrinth/api-client'
+import { injectModrinthClient } from '@modrinth/ui'
+import { type ComputedRef, ref, watch } from 'vue'
+
+// TODO: Remove and use V1 when available
+export function useServerImage(
+ serverId: string,
+ upstream: ComputedRef,
+) {
+ const client = injectModrinthClient()
+ const image = ref()
+
+ const sharedImage = useState(`server-icon-${serverId}`)
+ if (sharedImage.value) {
+ image.value = sharedImage.value
+ }
+
+ async function loadImage() {
+ if (sharedImage.value) {
+ image.value = sharedImage.value
+ return
+ }
+
+ if (import.meta.server) return
+
+ const cached = localStorage.getItem(`server-icon-${serverId}`)
+ if (cached) {
+ sharedImage.value = cached
+ image.value = cached
+ return
+ }
+
+ let projectIconUrl: string | undefined
+ const upstreamVal = upstream.value
+ if (upstreamVal?.project_id) {
+ try {
+ const project = await $fetch<{ icon_url?: string }>(
+ `https://api.modrinth.com/v2/project/${upstreamVal.project_id}`,
+ )
+ projectIconUrl = project.icon_url
+ } catch {
+ // project fetch failed, continue without icon url
+ }
+ }
+
+ try {
+ const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png')
+
+ if (fileData instanceof Blob) {
+ const dataURL = await resizeImage(fileData, 512)
+ sharedImage.value = dataURL
+ localStorage.setItem(`server-icon-${serverId}`, dataURL)
+ image.value = dataURL
+ return
+ }
+ } catch (error: any) {
+ if (error?.statusCode >= 500) {
+ image.value = undefined
+ return
+ }
+
+ if (error?.statusCode === 404 && projectIconUrl) {
+ try {
+ const response = await fetch(projectIconUrl)
+ if (!response.ok) throw new Error('Failed to fetch icon')
+ const file = await response.blob()
+ const originalFile = new File([file], 'server-icon-original.png', {
+ type: 'image/png',
+ })
+
+ const dataURL = await new Promise((resolve) => {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ const img = new Image()
+ img.onload = () => {
+ canvas.width = 64
+ canvas.height = 64
+ ctx?.drawImage(img, 0, 0, 64, 64)
+ canvas.toBlob(async (blob) => {
+ if (blob) {
+ const scaledFile = new File([blob], 'server-icon.png', {
+ type: 'image/png',
+ })
+ client.kyros.files_v0
+ .uploadFile('/server-icon.png', scaledFile)
+ .promise.catch(() => {})
+ client.kyros.files_v0
+ .uploadFile('/server-icon-original.png', originalFile)
+ .promise.catch(() => {})
+ }
+ }, 'image/png')
+ const result = canvas.toDataURL('image/png')
+ sharedImage.value = result
+ localStorage.setItem(`server-icon-${serverId}`, result)
+ resolve(result)
+ URL.revokeObjectURL(img.src)
+ }
+ img.src = URL.createObjectURL(file)
+ })
+ image.value = dataURL
+ return
+ } catch (externalError: any) {
+ console.debug('Could not process external icon:', externalError.message)
+ }
+ }
+ }
+
+ image.value = undefined
+ }
+
+ watch(upstream, () => loadImage(), { immediate: true })
+
+ return image
+}
+
+function resizeImage(blob: Blob, size: number): Promise {
+ return new Promise((resolve) => {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ const img = new Image()
+ img.onload = () => {
+ canvas.width = size
+ canvas.height = size
+ ctx?.drawImage(img, 0, 0, size, size)
+ const dataURL = canvas.toDataURL('image/png')
+ resolve(dataURL)
+ URL.revokeObjectURL(img.src)
+ }
+ img.src = URL.createObjectURL(blob)
+ })
+}
diff --git a/apps/frontend/src/composables/servers/use-server-project.ts b/apps/frontend/src/composables/servers/use-server-project.ts
new file mode 100644
index 0000000000..eeb22a8b52
--- /dev/null
+++ b/apps/frontend/src/composables/servers/use-server-project.ts
@@ -0,0 +1,17 @@
+import type { Archon } from '@modrinth/api-client'
+import type { Project } from '@modrinth/utils'
+import { useQuery } from '@tanstack/vue-query'
+import { $fetch } from 'ofetch'
+import { computed, type ComputedRef } from 'vue'
+
+// TODO: Remove and use v1
+export function useServerProject(
+ upstream: ComputedRef,
+) {
+ return useQuery({
+ queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
+ queryFn: () =>
+ $fetch(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`),
+ enabled: computed(() => !!upstream.value?.project_id),
+ })
+}
diff --git a/apps/frontend/src/pages/[type]/[id]/settings/members.vue b/apps/frontend/src/pages/[type]/[id]/settings/members.vue
index a80d7cb676..962b1f10d8 100644
--- a/apps/frontend/src/pages/[type]/[id]/settings/members.vue
+++ b/apps/frontend/src/pages/[type]/[id]/settings/members.vue
@@ -5,7 +5,6 @@
title="Are you sure you want to remove this project from the organization?"
description="If you proceed, this project will no longer be managed by the organization."
proceed-label="Remove"
- :noblur="!(cosmetics?.advancedRendering ?? true)"
@proceed="onRemoveFromOrg"
/>
@@ -352,7 +351,7 @@
-
- {{
- formatMessage(messages.noTransactions)
- }}
- {{
- formatMessage(messages.noTransactionsDesc)
- }}
-
+
+
+
+
+
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue
deleted file mode 100644
index 5e15864881..0000000000
--- a/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue
+++ /dev/null
@@ -1,704 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
Failed to load content
-
-
- We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
- {{
- JSON.stringify(server.moduleErrors.content.error)
- }}
-
-
server.refresh(['content'])">
- Retry
-
-
-
-
-
-
-
-
-
-
-
-
- Search
-
-
-
-
-
- {{ filterMethodLabel }}
-
-
-
- All {{ type.toLocaleLowerCase() }}s
- Only enabled
- Only disabled
-
-
-
-
-
-
- Add file
-
-
-
-
-
- Add {{ type.toLocaleLowerCase() }}
-
-
-
-
-
-
props.server.refresh(['content'])"
- />
-
-
-
-
-
-
-
-
-
-
-
- {{ friendlyModName(mod) }}
- Disabled
-
-
- by {{ mod.owner }}
-
- {{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
-
-
-
-
-
-
- {{
- mod.version_number || `External ${type.toLocaleLowerCase()}`
- }}
-
-
-
- {{ mod.filename }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Edit
-
-
-
- Delete
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- No {{ type.toLocaleLowerCase() }}s found for your query!
-
-
Try another query, or show everything.
-
-
-
- Show everything
-
-
-
-
-
-
No {{ type.toLocaleLowerCase() }}s found!
-
- Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
-
-
-
-
-
- Add file
-
-
-
-
-
- Add {{ type.toLocaleLowerCase() }}
-
-
-
-
-
-
-
-
Your server is running Vanilla Minecraft
-
- Add content to your server by installing a modpack or choosing a different platform that
- supports {{ type }}s.
-
-
-
-
-
- Find a modpack
-
-
-
or
-
-
-
- Change platform
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/index.vue
index 4d39cbff51..be987c76cf 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/index.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/index.vue
@@ -182,13 +182,12 @@
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
index 397e67402e..f9fd5f60eb 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
@@ -58,7 +58,7 @@
@@ -72,10 +72,10 @@
We couldn't load your server's network settings. Here's what we know:
{{
- JSON.stringify(server.moduleErrors.network.error)
+ allocationsError?.message ?? 'Unknown error'
}}
-
server.refresh(['network'])">
+ refetchAllocations()">
Retry
@@ -249,7 +249,7 @@
()
+const { server, serverId } = injectModrinthServerContext()
+const client = injectModrinthClient()
+const queryClient = useQueryClient()
const isUpdating = ref(false)
-const data = computed(() => props.server.general)
+const data = server
const serverIP = ref(data?.value?.net?.ip ?? '')
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
@@ -296,8 +298,15 @@ const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
const userDomain = ref('')
const exampleDomain = 'play.example.com'
-const network = computed(() => props.server.network)
-const allocations = computed(() => network.value?.allocations)
+const {
+ data: allocationsData,
+ error: allocationsError,
+ refetch: refetchAllocations,
+} = useQuery({
+ queryKey: ['servers', 'allocations', serverId] as const,
+ queryFn: () => client.archon.servers_v0.getAllocations(serverId),
+})
+const allocations = allocationsData
const newAllocationModal = ref()
const editAllocationModal = ref()
@@ -316,8 +325,8 @@ const addNewAllocation = async () => {
if (!newAllocationName.value) return
try {
- await props.server.network?.reserveAllocation(newAllocationName.value)
- await props.server.refresh(['network'])
+ await client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value)
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
newAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -360,8 +369,8 @@ const showConfirmDeleteModal = (port: number) => {
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return
- await props.server.network?.deleteAllocation(allocationToDelete.value)
- await props.server.refresh(['network'])
+ await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
@@ -376,8 +385,12 @@ const editAllocation = async () => {
if (!newAllocationName.value) return
try {
- await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
- await props.server.refresh(['network'])
+ await client.archon.servers_v0.updateAllocation(
+ serverId,
+ newAllocationPort.value,
+ newAllocationName.value,
+ )
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
editAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -397,7 +410,8 @@ const saveNetwork = async () => {
try {
isUpdating.value = true
- const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
+ const result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value)
+ const available = result.available
if (!available) {
addNotification({
type: 'error',
@@ -407,13 +421,18 @@ const saveNetwork = async () => {
return
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
- await props.server.network?.changeSubdomain(serverSubdomain.value)
+ await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
- await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
+ await client.archon.servers_v0.updateAllocation(
+ serverId,
+ serverPrimaryPort.value,
+ newAllocationName.value,
+ )
}
await new Promise((resolve) => setTimeout(resolve, 500))
- await props.server.refresh()
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
title: 'Server settings updated',
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue
index 4102a6b5c7..d3c84bf5b0 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue
@@ -32,7 +32,7 @@
()
-
const preferences = {
ramAsNumber: {
displayName: 'RAM as bytes',
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
index 4d2d524038..7fffab1586 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
@@ -121,7 +121,7 @@
()
+const { server, serverId } = injectModrinthServerContext()
+const queryClient = useQueryClient()
const tags = useGeneratedState()
@@ -158,10 +157,8 @@ const isUpdating = ref(false)
const searchInput = ref('')
-const data = computed(() => props.server.general)
-const modulesLoaded = inject>('modulesLoaded')
+const data = server
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
- await modulesLoaded
try {
const blob = await client.kyros.files_v0.downloadFile('/server.properties')
const rawProps = await blob.text()
@@ -292,7 +289,7 @@ const saveProperties = async () => {
await client.kyros.files_v0.updateFile('/server.properties', constructServerProperties())
await new Promise((resolve) => setTimeout(resolve, 500))
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
- await props.server.refresh()
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
addNotification({
type: 'success',
title: 'Server properties updated',
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
index 6f90633822..e92ab956b4 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
@@ -1,32 +1,6 @@
-
-
-
-
-
-
-
-
Failed to load startup settings
-
-
- We couldn't load your server's startup settings. Here's what we know:
-
-
- {{
- JSON.stringify(server.moduleErrors.startup.error)
- }}
-
-
server.refresh(['startup'])">
- Retry
-
-
-
-
-
+
@@ -42,7 +16,7 @@
@@ -51,13 +25,22 @@
-
+
@@ -74,164 +57,190 @@
Show all Java versions
-
+
Runtime
The Java runtime your server will use.
-
+
diff --git a/apps/frontend/src/providers/setup.ts b/apps/frontend/src/providers/setup.ts
new file mode 100644
index 0000000000..c558dc4c79
--- /dev/null
+++ b/apps/frontend/src/providers/setup.ts
@@ -0,0 +1,16 @@
+import { provideNotificationManager } from '@modrinth/ui'
+
+import { FrontendNotificationManager } from './frontend-notifications'
+import { setupFilePickerProvider } from './setup/file-picker'
+import { setupModrinthClientProvider } from './setup/modrinth-client'
+import { setupPageContextProvider } from './setup/page-context'
+import { setupTagsProvider } from './setup/tags'
+
+export function setupProviders(auth: Awaited
>) {
+ provideNotificationManager(new FrontendNotificationManager())
+
+ setupModrinthClientProvider(auth)
+ setupTagsProvider()
+ setupFilePickerProvider()
+ setupPageContextProvider()
+}
diff --git a/apps/frontend/src/providers/setup/file-picker.ts b/apps/frontend/src/providers/setup/file-picker.ts
new file mode 100644
index 0000000000..9d16e279f9
--- /dev/null
+++ b/apps/frontend/src/providers/setup/file-picker.ts
@@ -0,0 +1,23 @@
+import { provideFilePicker } from '@modrinth/ui'
+
+function pickFile(accept: string): Promise<{ file: File; previewUrl: string } | null> {
+ return new Promise((resolve) => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = accept
+ input.onchange = () => {
+ const file = input.files?.[0]
+ if (!file) return resolve(null)
+ resolve({ file, previewUrl: URL.createObjectURL(file) })
+ }
+ input.oncancel = () => resolve(null)
+ input.click()
+ })
+}
+
+export function setupFilePickerProvider() {
+ provideFilePicker({
+ pickImage: () => pickFile('image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/gif'),
+ pickModpackFile: () => pickFile('.mrpack,application/x-modrinth-modpack+zip,application/zip'),
+ })
+}
diff --git a/apps/frontend/src/providers/setup/modrinth-client.ts b/apps/frontend/src/providers/setup/modrinth-client.ts
new file mode 100644
index 0000000000..0f5d13ede0
--- /dev/null
+++ b/apps/frontend/src/providers/setup/modrinth-client.ts
@@ -0,0 +1,14 @@
+import { provideModrinthClient } from '@modrinth/ui'
+
+import { createModrinthClient } from '~/helpers/api.ts'
+
+export function setupModrinthClientProvider(auth: Awaited>) {
+ const config = useRuntimeConfig()
+ const client = createModrinthClient(auth, {
+ apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
+ archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
+ rateLimitKey: config.rateLimitKey,
+ })
+ provideModrinthClient(client)
+ return client
+}
diff --git a/apps/frontend/src/providers/setup/page-context.ts b/apps/frontend/src/providers/setup/page-context.ts
new file mode 100644
index 0000000000..dd5d0ef5ac
--- /dev/null
+++ b/apps/frontend/src/providers/setup/page-context.ts
@@ -0,0 +1,14 @@
+import { provideModalBehavior, providePageContext } from '@modrinth/ui'
+import { computed, ref } from 'vue'
+
+export function setupPageContextProvider() {
+ const cosmetics = useCosmetics()
+
+ providePageContext({
+ hierarchicalSidebarAvailable: ref(false),
+ showAds: ref(false),
+ })
+ provideModalBehavior({
+ noblur: computed(() => !(cosmetics.value?.advancedRendering ?? true)),
+ })
+}
diff --git a/apps/frontend/src/providers/setup/tags.ts b/apps/frontend/src/providers/setup/tags.ts
new file mode 100644
index 0000000000..3ea7aa2653
--- /dev/null
+++ b/apps/frontend/src/providers/setup/tags.ts
@@ -0,0 +1,10 @@
+import { provideTags } from '@modrinth/ui'
+import { computed } from 'vue'
+
+export function setupTagsProvider() {
+ const generatedState = useGeneratedState()
+ provideTags({
+ gameVersions: computed(() => generatedState.value.gameVersions),
+ loaders: computed(() => generatedState.value.loaders),
+ })
+}
diff --git a/package.json b/package.json
index 3953ee543f..315a0e3ff0 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@modrinth/tooling-config": "workspace:*",
+ "@tailwindcss/container-queries": "^0.1.1",
"@types/node": "^20.1.0",
"@vue/compiler-dom": "^3.5.26",
"@vue/compiler-sfc": "^3.5.26",
diff --git a/packages/api-client/src/modules/archon/backups/v0.ts b/packages/api-client/src/modules/archon/backups/v0.ts
deleted file mode 100644
index e1e402faf7..0000000000
--- a/packages/api-client/src/modules/archon/backups/v0.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { AbstractModule } from '../../../core/abstract-module'
-import type { Archon } from '../types'
-
-export class ArchonBackupsV0Module extends AbstractModule {
- public getModuleID(): string {
- return 'archon_backups_v0'
- }
-
- /** GET /modrinth/v0/servers/:server_id/backups */
- public async list(serverId: string): Promise {
- return this.client.request(`/servers/${serverId}/backups`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'GET',
- })
- }
-
- /** GET /modrinth/v0/servers/:server_id/backups/:backup_id */
- public async get(serverId: string, backupId: string): Promise {
- return this.client.request(
- `/servers/${serverId}/backups/${backupId}`,
- { api: 'archon', version: 'modrinth/v0', method: 'GET' },
- )
- }
-
- /** POST /modrinth/v0/servers/:server_id/backups */
- public async create(
- serverId: string,
- request: Archon.Backups.v1.BackupRequest,
- ): Promise {
- return this.client.request(
- `/servers/${serverId}/backups`,
- { api: 'archon', version: 'modrinth/v0', method: 'POST', body: request },
- )
- }
-
- /** POST /modrinth/v0/servers/:server_id/backups/:backup_id/restore */
- public async restore(serverId: string, backupId: string): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}/restore`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- })
- }
-
- /** DELETE /modrinth/v0/servers/:server_id/backups/:backup_id */
- public async delete(serverId: string, backupId: string): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'DELETE',
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/backups/:backup_id/retry */
- public async retry(serverId: string, backupId: string): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}/retry`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- })
- }
-
- /** PATCH /modrinth/v0/servers/:server_id/backups/:backup_id */
- public async rename(
- serverId: string,
- backupId: string,
- request: Archon.Backups.v1.PatchBackup,
- ): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'PATCH',
- body: request,
- })
- }
-}
diff --git a/packages/api-client/src/modules/archon/backups/v1.ts b/packages/api-client/src/modules/archon/backups/v1.ts
index 65537876ee..cb9d21fc27 100644
--- a/packages/api-client/src/modules/archon/backups/v1.ts
+++ b/packages/api-client/src/modules/archon/backups/v1.ts
@@ -15,88 +15,94 @@ export class ArchonBackupsV1Module extends AbstractModule {
return 'archon_backups_v1'
}
- /** GET /v1/:server_id/worlds/:world_id/backups */
+ /** GET /v1/servers/:server_id/worlds/:world_id/backups */
public async list(
serverId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise {
return this.client.request(
- `/${serverId}/worlds/${worldId}/backups`,
+ `/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
- /** GET /v1/:server_id/worlds/:world_id/backups/:backup_id */
+ /** GET /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async get(
serverId: string,
- backupId: string,
worldId: string = DEFAULT_WORLD_ID,
+ backupId: string,
): Promise {
return this.client.request(
- `/${serverId}/worlds/${worldId}/backups/${backupId}`,
+ `/servers/${serverId}/worlds/${worldId}/backups/${backupId}`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
- /** POST /v1/:server_id/worlds/:world_id/backups */
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups */
public async create(
serverId: string,
- request: Archon.Backups.v1.BackupRequest,
worldId: string = DEFAULT_WORLD_ID,
+ request: Archon.Backups.v1.BackupRequest,
): Promise {
return this.client.request(
- `/${serverId}/worlds/${worldId}/backups`,
+ `/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
- /** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/restore */
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/restore */
public async restore(
serverId: string,
- backupId: string,
worldId: string = DEFAULT_WORLD_ID,
+ backupId: string,
): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}/restore`, {
- api: 'archon',
- version: 1,
- method: 'POST',
- })
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/backups/${backupId}/restore`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
}
- /** DELETE /v1/:server_id/worlds/:world_id/backups/:backup_id */
+ /** DELETE /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async delete(
serverId: string,
- backupId: string,
worldId: string = DEFAULT_WORLD_ID,
+ backupId: string,
): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
- /** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/retry */
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/retry */
public async retry(
serverId: string,
- backupId: string,
worldId: string = DEFAULT_WORLD_ID,
+ backupId: string,
): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}/retry`, {
- api: 'archon',
- version: 1,
- method: 'POST',
- })
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/backups/${backupId}/retry`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
}
- /** PATCH /v1/:server_id/worlds/:world_id/backups/:backup_id */
+ /** PATCH /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async rename(
serverId: string,
+ worldId: string = DEFAULT_WORLD_ID,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
- worldId: string = DEFAULT_WORLD_ID,
): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
diff --git a/packages/api-client/src/modules/archon/content/v0.ts b/packages/api-client/src/modules/archon/content/v0.ts
deleted file mode 100644
index 384fd38572..0000000000
--- a/packages/api-client/src/modules/archon/content/v0.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { AbstractModule } from '../../../core/abstract-module'
-import type { Archon } from '../types'
-
-export class ArchonContentV0Module extends AbstractModule {
- public getModuleID(): string {
- return 'archon_content_v0'
- }
-
- /** GET /modrinth/v0/servers/:server_id/mods */
- public async list(serverId: string): Promise {
- return this.client.request(`/servers/${serverId}/mods`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'GET',
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/mods */
- public async install(
- serverId: string,
- request: Archon.Content.v0.InstallModRequest,
- ): Promise {
- await this.client.request(`/servers/${serverId}/mods`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- body: request,
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/deleteMod */
- public async delete(
- serverId: string,
- request: Archon.Content.v0.DeleteModRequest,
- ): Promise {
- await this.client.request(`/servers/${serverId}/deleteMod`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- body: request,
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/mods/update */
- public async update(
- serverId: string,
- request: Archon.Content.v0.UpdateModRequest,
- ): Promise {
- await this.client.request(`/servers/${serverId}/mods/update`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- body: request,
- })
- }
-}
diff --git a/packages/api-client/src/modules/archon/content/v1.ts b/packages/api-client/src/modules/archon/content/v1.ts
new file mode 100644
index 0000000000..7e990419a0
--- /dev/null
+++ b/packages/api-client/src/modules/archon/content/v1.ts
@@ -0,0 +1,226 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+/**
+ * Default world ID - Uuid::nil() which the backend treats as "first/active world"
+ * See: apps/archon/src/routes/v1/servers/worlds/mod.rs - world_id_nullish()
+ * TODO:
+ * - Make sure world ID is being passed before we ship worlds.
+ */
+const DEFAULT_WORLD_ID: string = '00000000-0000-0000-0000-000000000000' as const
+
+export class ArchonContentV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'archon_content_v1'
+ }
+
+ /** GET /v1/:server_id/worlds/:world_id/addons */
+ public async getAddons(
+ serverId: string,
+ worldId: string = DEFAULT_WORLD_ID,
+ options?: {
+ from_modpack?: boolean
+ disabled?: boolean
+ addons?: boolean
+ updates?: boolean
+ },
+ ): Promise {
+ const params = new URLSearchParams()
+ if (options?.from_modpack !== undefined)
+ params.set('from_modpack', String(options.from_modpack))
+ if (options?.disabled !== undefined) params.set('disabled', String(options.disabled))
+ if (options?.addons !== undefined) params.set('addons', String(options.addons))
+ if (options?.updates !== undefined) params.set('updates', String(options.updates))
+ const query = params.toString()
+
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons${query ? `?${query}` : ''}`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons */
+ public async addAddon(
+ serverId: string,
+ request: Archon.Content.v1.AddAddonRequest,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/delete */
+ public async deleteAddon(
+ serverId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/delete`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/disable */
+ public async disableAddon(
+ serverId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/disable`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/enable */
+ public async enableAddon(
+ serverId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/enable`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/delete-many */
+ public async deleteAddons(
+ serverId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/delete-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { items },
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/disable-many */
+ public async disableAddons(
+ serverId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/disable-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { items },
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/enable-many */
+ public async enableAddons(
+ serverId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/enable-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { items },
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/content */
+ public async installContent(
+ serverId: string,
+ request: Archon.Content.v1.InstallWorldContent,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/content`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/content/unlink-modpack */
+ public async unlinkModpack(serverId: string, worldId: string = DEFAULT_WORLD_ID): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/content/unlink-modpack`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
+ }
+
+ /** GET /v1/:server_id/worlds/:world_id/addons/update?filename=... */
+ public async getAddonUpdate(
+ serverId: string,
+ filename: string,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons/update?filename=${encodeURIComponent(filename)}`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/update */
+ public async updateAddon(
+ serverId: string,
+ request: Archon.Content.v1.UpdateAddonRequest,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/update`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** GET /v1/:server_id/worlds/:world_id/addons/modpack/update */
+ public async getModpackUpdate(
+ serverId: string,
+ worldId: string = DEFAULT_WORLD_ID,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons/modpack/update`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/modpack/update */
+ public async updateModpack(serverId: string, worldId: string = DEFAULT_WORLD_ID): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons/modpack/update`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/archon/servers/v0.ts b/packages/api-client/src/modules/archon/servers/v0.ts
index a21301e2b4..39d4af543d 100644
--- a/packages/api-client/src/modules/archon/servers/v0.ts
+++ b/packages/api-client/src/modules/archon/servers/v0.ts
@@ -1,4 +1,5 @@
import { AbstractModule } from '../../../core/abstract-module'
+import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Archon } from '../types'
export class ArchonServersV0Module extends AbstractModule {
@@ -94,4 +95,222 @@ export class ArchonServersV0Module extends AbstractModule {
body: { action },
})
}
+
+ /**
+ * Reinstall a server with a new loader or modpack
+ * POST /modrinth/v0/servers/:id/reinstall
+ */
+ public async reinstall(
+ serverId: string,
+ request: Archon.Servers.v0.ReinstallRequest,
+ hardReset: boolean = false,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/reinstall`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ params: { hard: String(hardReset) },
+ body: request,
+ })
+ }
+
+ /**
+ * Get authentication credentials for .mrpack file upload
+ * GET /modrinth/v0/servers/:id/reinstallFromMrpack
+ */
+ public async getReinstallMrpackAuth(
+ serverId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/reinstallFromMrpack`,
+ {
+ api: 'archon',
+ version: 'modrinth/v0',
+ method: 'GET',
+ },
+ )
+ }
+
+ /**
+ * Reinstall a server from a .mrpack file with progress tracking
+ *
+ * Two-step flow: fetches upload auth, then uploads the .mrpack file to the node.
+ *
+ * @param serverId - Server ID
+ * @param file - .mrpack file to upload
+ * @param hardReset - Whether to erase all server data
+ * @param options - Optional progress callback
+ * @returns Promise resolving to an UploadHandle with progress tracking and cancellation
+ */
+ public async reinstallFromMrpack(
+ serverId: string,
+ file: File,
+ hardReset: boolean = false,
+ options?: {
+ onProgress?: (progress: UploadProgress) => void
+ },
+ ): Promise> {
+ const auth = await this.getReinstallMrpackAuth(serverId)
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ return this.client.upload('', {
+ api: `https://${auth.url}`,
+ version: 'reinstallMrpackMultiparted',
+ formData,
+ params: { hard: String(hardReset) },
+ headers: { Authorization: `Bearer ${auth.token}` },
+ skipAuth: true,
+ onProgress: options?.onProgress,
+ retry: false,
+ })
+ }
+
+ /**
+ * Update a server's name
+ * POST /modrinth/v0/servers/:id/name
+ */
+ public async updateName(serverId: string, name: string): Promise {
+ await this.client.request(`/servers/${serverId}/name`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ body: { name },
+ })
+ }
+
+ /**
+ * Get allocations for a server
+ * GET /modrinth/v0/servers/:id/allocations
+ */
+ public async getAllocations(serverId: string): Promise {
+ return this.client.request(`/servers/${serverId}/allocations`, {
+ api: 'archon',
+ method: 'GET',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Reserve a new allocation for a server
+ * POST /modrinth/v0/servers/:id/allocations?name=...
+ */
+ public async reserveAllocation(
+ serverId: string,
+ name: string,
+ ): Promise {
+ return this.client.request(`/servers/${serverId}/allocations`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ params: { name },
+ })
+ }
+
+ /**
+ * Update an allocation's name
+ * PUT /modrinth/v0/servers/:id/allocations/:port?name=...
+ */
+ public async updateAllocation(serverId: string, port: number, name: string): Promise {
+ await this.client.request(`/servers/${serverId}/allocations/${port}`, {
+ api: 'archon',
+ method: 'PUT',
+ version: 'modrinth/v0',
+ params: { name },
+ })
+ }
+
+ /**
+ * Delete an allocation
+ * DELETE /modrinth/v0/servers/:id/allocations/:port
+ */
+ public async deleteAllocation(serverId: string, port: number): Promise {
+ await this.client.request(`/servers/${serverId}/allocations/${port}`, {
+ api: 'archon',
+ method: 'DELETE',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Check if a subdomain is available
+ * GET /modrinth/v0/subdomains/:subdomain/isavailable
+ */
+ public async checkSubdomainAvailability(subdomain: string): Promise<{ available: boolean }> {
+ return this.client.request<{ available: boolean }>(`/subdomains/${subdomain}/isavailable`, {
+ api: 'archon',
+ method: 'GET',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Change a server's subdomain
+ * POST /modrinth/v0/servers/:id/subdomain
+ */
+ public async changeSubdomain(serverId: string, subdomain: string): Promise {
+ await this.client.request(`/servers/${serverId}/subdomain`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ body: { subdomain },
+ })
+ }
+
+ /**
+ * Get startup configuration for a server
+ * GET /modrinth/v0/servers/:id/startup
+ */
+ public async getStartupConfig(serverId: string): Promise {
+ return this.client.request(`/servers/${serverId}/startup`, {
+ api: 'archon',
+ method: 'GET',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Update startup configuration for a server
+ * POST /modrinth/v0/servers/:id/startup
+ */
+ public async updateStartupConfig(
+ serverId: string,
+ config: {
+ invocation: string | null
+ jdk_version: string | null
+ jdk_build: string | null
+ },
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/startup`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ body: config,
+ })
+ }
+
+ /**
+ * Dismiss a server notice
+ * POST /modrinth/v0/servers/:id/notices/:noticeId/dismiss
+ */
+ public async dismissNotice(serverId: string, noticeId: number): Promise {
+ await this.client.request(`/servers/${serverId}/notices/${noticeId}/dismiss`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * End the intro flow for a server
+ * DELETE /v1/servers/:id/flows/intro
+ */
+ public async endIntro(serverId: string): Promise {
+ await this.client.request(`/servers/${serverId}/flows/intro`, {
+ api: 'archon',
+ version: 1,
+ method: 'DELETE',
+ })
+ }
}
diff --git a/packages/api-client/src/modules/archon/servers/v1.ts b/packages/api-client/src/modules/archon/servers/v1.ts
index 06edb14447..5bcb9503b1 100644
--- a/packages/api-client/src/modules/archon/servers/v1.ts
+++ b/packages/api-client/src/modules/archon/servers/v1.ts
@@ -6,6 +6,30 @@ export class ArchonServersV1Module extends AbstractModule {
return 'archon_servers_v1'
}
+ /**
+ * Get list of servers for the authenticated user
+ * GET /v1/servers
+ */
+ public async list(): Promise {
+ return this.client.request('/servers', {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ })
+ }
+
+ /**
+ * Get full server details including worlds, backups, and content
+ * GET /v1/servers/:server_id
+ */
+ public async get(serverId: string): Promise {
+ return this.client.request(`/servers/${serverId}`, {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ })
+ }
+
/**
* Get available regions
* GET /v1/regions
@@ -17,4 +41,16 @@ export class ArchonServersV1Module extends AbstractModule {
method: 'GET',
})
}
+
+ /**
+ * End the intro flow for a server
+ * DELETE /v1/servers/:id/flows/intro
+ */
+ public async endIntro(serverId: string): Promise {
+ await this.client.request(`/servers/${serverId}/flows/intro`, {
+ api: 'archon',
+ version: 1,
+ method: 'DELETE',
+ })
+ }
}
diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts
index 24c02b6c21..432fc9f5f2 100644
--- a/packages/api-client/src/modules/archon/types.ts
+++ b/packages/api-client/src/modules/archon/types.ts
@@ -1,37 +1,100 @@
export namespace Archon {
export namespace Content {
- export namespace v0 {
- export type ContentKind = 'mod' | 'plugin'
+ export namespace v1 {
+ export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack'
+
+ export type ContentOwnerType = 'user' | 'organization'
+
+ export type ContentOwner = {
+ id: string
+ name: string
+ type: ContentOwnerType
+ icon_url: string | null
+ }
- export type Mod = {
+ export type AddonVersion = {
+ id: string
+ name: string | null
+ }
+
+ export type Addon = {
+ id: string
filename: string
- project_id: string | undefined
- version_id: string | undefined
- name: string | undefined
- version_number: string | undefined
- icon_url: string | undefined
- owner: string | undefined
+ filesize: number
disabled: boolean
- installing: boolean
+ kind: AddonKind
+ from_modpack: boolean
+ has_update: boolean
+ name: string | null
+ project_id: string | null
+ version: AddonVersion | null
+ owner: ContentOwner | null
+ icon_url: string | null
}
- export type InstallModRequest = {
- rinth_ids: {
- project_id: string
- version_id: string
- }
- install_as: ContentKind
+ export type Addons = {
+ modloader: string | null
+ modloader_version: string | null
+ game_version: string | null
+ modpack: ModpackFields | null
+ addons: Addon[] | null
}
- export type DeleteModRequest = {
- path: string
+ export type AddAddonRequest = {
+ project_id: string
+ version_id?: string
+ kind?: AddonKind
}
- export type UpdateModRequest = {
- replace: string
+ export type RemoveAddonRequest = {
+ kind: AddonKind
+ filename: string
+ }
+
+ export type UpdateAddonRequest = {
+ filename: string
+ }
+
+ export type Modloader =
+ | 'forge'
+ | 'neo_forge'
+ | 'fabric'
+ | 'quilt'
+ | 'paper'
+ | 'purpur'
+ | 'vanilla'
+
+ export type ModpackSpec = {
+ platform: 'modrinth'
project_id: string
version_id: string
}
+
+ export type ModpackFields = {
+ spec: ModpackSpec
+ has_update: boolean
+ title: string | null
+ description: string | null
+ icon_url: string | null
+ version_number: string | null
+ date_published: string | null
+ downloads: number | null
+ followers: number | null
+ }
+
+ export type InstallWorldContent =
+ | {
+ content_variant: 'modpack'
+ spec: ModpackSpec
+ soft_override: boolean
+ }
+ | {
+ content_variant: 'bare'
+ loader: Modloader
+ version: string
+ game_version?: string
+ soft_override: boolean
+ }
}
}
@@ -148,9 +211,95 @@ export namespace Archon {
url: string // e.g., "node-xyz.modrinth.com/modrinth/v0/fs"
token: string // JWT token for filesystem access
}
+
+ export type ReinstallLoaderRequest = {
+ loader: string
+ loader_version?: string
+ game_version?: string
+ }
+
+ export type ReinstallModpackRequest = {
+ project_id: string
+ version_id?: string
+ }
+
+ export type ReinstallRequest = ReinstallLoaderRequest | ReinstallModpackRequest
+
+ export type MrpackReinstallAuth = {
+ url: string
+ token: string
+ }
+
+ export type Allocation = {
+ port: number
+ name: string
+ }
+
+ export type StartupConfig = {
+ invocation: string
+ original_invocation: string
+ jdk_version: 'lts8' | 'lts11' | 'lts17' | 'lts21'
+ jdk_build: 'corretto' | 'temurin' | 'graal'
+ }
}
export namespace v1 {
+ export type ServerFull = {
+ id: string
+ name: string
+ subdomain: string
+ specs: ServerResources
+ sftp_username: string
+ sftp_password: string
+ tags: string[]
+ location: ServerLocation
+ worlds: WorldFull[]
+ }
+
+ export type ServerResources = {
+ cpu: number
+ memory_mb: number
+ storage_mb: number
+ swap_mb: number
+ }
+
+ export type ServerLocation =
+ | {
+ status: 'assigned'
+ location_metadata: {
+ region: string
+ region_should_be_user_displayed: boolean
+ hostname: string
+ is_decommissioned_node: boolean
+ }
+ }
+ | {
+ status: 'unassigned'
+ }
+
+ export type WorldFull = {
+ id: string
+ name: string
+ created_at: string
+ is_active: boolean
+ backups: Archon.Backups.v1.Backup[]
+ content: WorldContentInfo | null
+ readiness: WorldReadiness
+ }
+
+ export type WorldReadiness = {
+ data_synchronized_fetched: boolean
+ }
+
+ export type WorldContentInfo = {
+ modloader: string
+ modloader_version: string
+ game_version: string
+ java_version: number
+ invocation: string
+ original_invocation: string
+ }
+
export type Region = {
shortcode: string
country_code: string
@@ -174,19 +323,18 @@ export namespace Archon {
export type Backup = {
id: string
+ physical_id: string
name: string
created_at: string
automated: boolean
interrupted: boolean
ongoing: boolean
+ locked: boolean
task?: {
file?: BackupTaskProgress
create?: BackupTaskProgress
restore?: BackupTaskProgress
}
- // TODO: Uncomment when API supports these fields
- // size?: number // bytes
- // creator_id?: string // user ID, or 'auto' for automated backups
}
export type BackupRequest = {
@@ -319,6 +467,38 @@ export namespace Archon {
all: FilesystemOperation[]
}
+ export type ReadinessState =
+ | 'deprovisioned'
+ | 'waiting_active_world'
+ | 'waiting_world_spec_details_for_progress'
+ | 'pulling_world_data'
+ | 'migration_zfs'
+ | 'sync_content'
+ | 'container_readying'
+ | 'ready'
+
+ export type FlattenedPowerState = 'not_ready' | 'starting' | 'running' | 'stopping' | 'idle'
+
+ export type SyncInstallPhase = 'install_modloader' | 'install_modpack' | 'install_addons'
+
+ export type SyncContentProgress = {
+ started_at: string
+ phase: SyncInstallPhase | null
+ percent: number
+ }
+
+ export type WSStateEvent = {
+ event: 'state'
+ debug: string
+ readiness: ReadinessState
+ power_variant: FlattenedPowerState
+ exit_code?: number | null
+ was_oom?: boolean
+ target: 'start' | 'stop' | 'restart' | null
+ uptime: number
+ progress: SyncContentProgress | null
+ }
+
// Outgoing messages (client -> server)
export type WSOutgoingMessage = WSAuthMessage | WSCommandMessage
@@ -337,6 +517,7 @@ export namespace Archon {
| WSLogEvent
| WSStatsEvent
| WSPowerStateEvent
+ | WSStateEvent
| WSAuthExpiringEvent
| WSAuthIncorrectEvent
| WSAuthOkEvent
diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts
index 82880a682d..850553d856 100644
--- a/packages/api-client/src/modules/index.ts
+++ b/packages/api-client/src/modules/index.ts
@@ -1,13 +1,13 @@
import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
-import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
-import { ArchonContentV0Module } from './archon/content/v0'
+import { ArchonContentV1Module } from './archon/content/v1'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
+import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
-import { LabrinthVersionsV3Module } from './labrinth'
+import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
@@ -27,12 +27,12 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
* TODO: Better way? Probably not
*/
export const MODULE_REGISTRY = {
- archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
- archon_content_v0: ArchonContentV0Module,
+ archon_content_v1: ArchonContentV1Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,
+ kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
@@ -40,6 +40,7 @@ export const MODULE_REGISTRY = {
labrinth_projects_v3: LabrinthProjectsV3Module,
labrinth_state: LabrinthStateModule,
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
+ labrinth_versions_v2: LabrinthVersionsV2Module,
labrinth_versions_v3: LabrinthVersionsV3Module,
} as const satisfies Record
diff --git a/packages/api-client/src/modules/kyros/content/v1.ts b/packages/api-client/src/modules/kyros/content/v1.ts
new file mode 100644
index 0000000000..a5169cc44b
--- /dev/null
+++ b/packages/api-client/src/modules/kyros/content/v1.ts
@@ -0,0 +1,37 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { UploadHandle, UploadProgress } from '../../../types/upload'
+
+export class KyrosContentV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'kyros_content_v1'
+ }
+
+ /**
+ * Upload addon files to a world via multipart form data
+ *
+ * @param worldId - World UUID
+ * @param files - Files to upload as addons
+ * @param options - Optional progress callback
+ * @returns UploadHandle with promise, onProgress, and cancel
+ */
+ public uploadAddonFile(
+ worldId: string,
+ files: (File | Blob)[],
+ options?: {
+ onProgress?: (progress: UploadProgress) => void
+ },
+ ): UploadHandle {
+ const formData = new FormData()
+ for (const file of files) {
+ formData.append('file', file, file instanceof File ? file.name : 'file')
+ }
+
+ return this.client.upload(`/worlds/${worldId}/content/upload-addon-file`, {
+ api: '',
+ version: 'v1',
+ formData,
+ onProgress: options?.onProgress,
+ useNodeAuth: true,
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts
index 38bc3f22b6..c11216dfe6 100644
--- a/packages/api-client/src/modules/labrinth/index.ts
+++ b/packages/api-client/src/modules/labrinth/index.ts
@@ -4,4 +4,5 @@ export * from './projects/v2'
export * from './projects/v3'
export * from './state'
export * from './tech-review/internal'
+export * from './versions/v2'
export * from './versions/v3'
diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
index 3f56ea7d4c..3405f36cf1 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -469,6 +469,12 @@ export namespace Labrinth {
game_versions: string[]
loaders: string[]
}
+
+ export interface GetProjectVersionsParams {
+ game_versions?: string[]
+ loaders?: string[]
+ include_changelog?: boolean
+ }
}
// TODO: consolidate duplicated types between v2 and v3 versions
@@ -484,7 +490,6 @@ export namespace Labrinth {
game_versions?: string[]
loaders?: string[]
include_changelog?: boolean
- apiVersion?: 2 | 3
}
export type VersionChannel = 'release' | 'beta' | 'alpha'
diff --git a/packages/api-client/src/modules/labrinth/versions/v2.ts b/packages/api-client/src/modules/labrinth/versions/v2.ts
new file mode 100644
index 0000000000..750d91d53d
--- /dev/null
+++ b/packages/api-client/src/modules/labrinth/versions/v2.ts
@@ -0,0 +1,135 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Labrinth } from '../types'
+
+export class LabrinthVersionsV2Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'labrinth_versions_v2'
+ }
+
+ /**
+ * Get versions for a project (v2)
+ *
+ * @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
+ * @param options - Optional query parameters to filter versions
+ * @returns Promise resolving to an array of v2 versions
+ *
+ * @example
+ * ```typescript
+ * const versions = await client.labrinth.versions_v2.getProjectVersions('sodium')
+ * const filteredVersions = await client.labrinth.versions_v2.getProjectVersions('sodium', {
+ * game_versions: ['1.20.1'],
+ * loaders: ['fabric'],
+ * include_changelog: false
+ * })
+ * console.log(versions[0].version_number)
+ * ```
+ */
+ public async getProjectVersions(
+ id: string,
+ options?: Labrinth.Versions.v2.GetProjectVersionsParams,
+ ): Promise {
+ const params: Record = {}
+ if (options?.game_versions?.length) {
+ params.game_versions = JSON.stringify(options.game_versions)
+ }
+ if (options?.loaders?.length) {
+ params.loaders = JSON.stringify(options.loaders)
+ }
+ if (options?.include_changelog === false) {
+ params.include_changelog = 'false'
+ }
+
+ return this.client.request(`/project/${id}/version`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ params: Object.keys(params).length > 0 ? params : undefined,
+ })
+ }
+
+ /**
+ * Get a specific version by ID (v2)
+ *
+ * @param id - Version ID
+ * @returns Promise resolving to the v2 version data
+ *
+ * @example
+ * ```typescript
+ * const version = await client.labrinth.versions_v2.getVersion('DXtmvS8i')
+ * console.log(version.version_number)
+ * ```
+ */
+ public async getVersion(id: string): Promise {
+ return this.client.request(`/version/${id}`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ })
+ }
+
+ /**
+ * Get multiple versions by IDs (v2)
+ *
+ * @param ids - Array of version IDs
+ * @returns Promise resolving to an array of v2 versions
+ *
+ * @example
+ * ```typescript
+ * const versions = await client.labrinth.versions_v2.getVersions(['DXtmvS8i', 'abc123'])
+ * console.log(versions[0].version_number)
+ * ```
+ */
+ public async getVersions(ids: string[]): Promise {
+ return this.client.request(`/versions`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ params: { ids: JSON.stringify(ids) },
+ })
+ }
+
+ /**
+ * Get a version from a project by version ID or number (v2)
+ *
+ * @param projectId - Project ID or slug
+ * @param versionId - Version ID or version number
+ * @returns Promise resolving to the v2 version data
+ *
+ * @example
+ * ```typescript
+ * const version = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', 'DXtmvS8i')
+ * const versionByNumber = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', '0.4.12')
+ * ```
+ */
+ public async getVersionFromIdOrNumber(
+ projectId: string,
+ versionId: string,
+ ): Promise {
+ return this.client.request(
+ `/project/${projectId}/version/${versionId}`,
+ {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ },
+ )
+ }
+
+ /**
+ * Delete a version by ID (v2)
+ *
+ * @param versionId - Version ID
+ *
+ * @example
+ * ```typescript
+ * await client.labrinth.versions_v2.deleteVersion('DXtmvS8i')
+ * ```
+ */
+ public async deleteVersion(versionId: string): Promise {
+ return this.client.request(`/version/${versionId}`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'DELETE',
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/labrinth/versions/v3.ts b/packages/api-client/src/modules/labrinth/versions/v3.ts
index 1a3a023242..7de277f616 100644
--- a/packages/api-client/src/modules/labrinth/versions/v3.ts
+++ b/packages/api-client/src/modules/labrinth/versions/v3.ts
@@ -35,8 +35,8 @@ export class LabrinthVersionsV3Module extends AbstractModule {
if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders)
}
- if (options?.include_changelog !== undefined) {
- params.include_changelog = options.include_changelog
+ if (options?.include_changelog === false) {
+ params.include_changelog = 'false'
}
return this.client.request(`/project/${id}/version`, {
diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts
index f3a9079ba6..566471ab61 100644
--- a/packages/api-client/src/platform/nuxt.ts
+++ b/packages/api-client/src/platform/nuxt.ts
@@ -13,27 +13,32 @@ import { XHRUploadClient } from './xhr-upload-client'
*
* This provides cross-request persistence in SSR while also working in client-side.
* State is shared between requests in the same Nuxt context.
+ *
+ * Note: useState must be called during initialization (in setup context) and cached,
+ * as it won't work during async operations when the Nuxt context may be lost.
*/
export class NuxtCircuitBreakerStorage implements CircuitBreakerStorage {
- private getState(): Map {
+ private state: Map
+
+ constructor() {
// @ts-expect-error - useState is provided by Nuxt runtime
- const state = useState>(
+ const stateRef = useState>(
'circuit-breaker-state',
() => new Map(),
)
- return state.value
+ this.state = stateRef.value
}
get(key: string): CircuitBreakerState | undefined {
- return this.getState().get(key)
+ return this.state.get(key)
}
set(key: string, state: CircuitBreakerState): void {
- this.getState().set(key, state)
+ this.state.set(key, state)
}
clear(key: string): void {
- this.getState().delete(key)
+ this.state.delete(key)
}
}
diff --git a/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json b/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json
new file mode 100644
index 0000000000..1d65ebe354
--- /dev/null
+++ b/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "SQLite",
+ "query": "\n SELECT data as \"data?: sqlx::types::Json\"\n FROM cache\n WHERE data_type = $1 AND id = $2\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "data?: sqlx::types::Json",
+ "ordinal": 0,
+ "type_info": "Null"
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec"
+}
diff --git a/packages/app-lib/src/api/cache.rs b/packages/app-lib/src/api/cache.rs
index e1cdbd0339..7eda0cea1d 100644
--- a/packages/app-lib/src/api/cache.rs
+++ b/packages/app-lib/src/api/cache.rs
@@ -51,3 +51,20 @@ pub async fn purge_cache_types(
Ok(())
}
+
+/// Get versions for a project (without changelogs for fast loading).
+/// Uses the cache system with the ProjectVersions cache type.
+#[tracing::instrument]
+pub async fn get_project_versions(
+ project_id: &str,
+ cache_behaviour: Option,
+) -> crate::Result>> {
+ let state = crate::State::get().await?;
+ CachedEntry::get_project_versions(
+ project_id,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await
+}
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 1243c244f3..6bcea96b04 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -18,11 +18,13 @@ pub mod worlds;
pub mod data {
pub use crate::state::{
- CacheBehaviour, CacheValueType, Credentials, Dependency, DirectoryInfo,
- Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
- ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
- Project, ProjectType, SearchResult, SearchResults, Settings,
- TeamMember, Theme, User, UserFriend, Version, WindowSize,
+ CacheBehaviour, CacheValueType, ContentItem, ContentItemOwner,
+ ContentItemProject, ContentItemVersion, Credentials, Dependency,
+ DirectoryInfo, Hooks, JavaVersion, LinkedData, LinkedModpackInfo,
+ MemorySettings, ModLoader, ModrinthCredentials, Organization,
+ OwnerType, ProcessMetadata, ProfileFile, Project, ProjectType,
+ SearchResult, SearchResults, Settings, TeamMember, Theme, User,
+ UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
}
diff --git a/packages/app-lib/src/api/pack/install_mrpack.rs b/packages/app-lib/src/api/pack/install_mrpack.rs
index 0fdead311c..908a65b9ac 100644
--- a/packages/app-lib/src/api/pack/install_mrpack.rs
+++ b/packages/app-lib/src/api/pack/install_mrpack.rs
@@ -8,7 +8,7 @@ use crate::pack::install_from::{
use crate::state::{
CacheBehaviour, CachedEntry, ProfileInstallStage, SideType, cache_file_hash,
};
-use crate::util::fetch::{fetch_mirrors, write};
+use crate::util::fetch::{fetch_mirrors, sha1_async, write};
use crate::util::io;
use crate::{State, profile};
use async_zip::base::read::seek::ZipFileReader;
@@ -115,6 +115,44 @@ pub async fn install_zipped_mrpack_files(
.into());
}
+ // Cache the modpack file hashes for later filtering of user-added content
+ // Includes both manifest file hashes and computed hashes for override files
+ if let Some(ref version_id) = version_id {
+ let mut file_hashes: Vec = pack
+ .files
+ .iter()
+ .filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
+ .collect();
+
+ // Also hash files from overrides folders (these aren't in modrinth.index.json)
+ let override_entries: Vec = zip_reader
+ .file()
+ .entries()
+ .iter()
+ .enumerate()
+ .filter_map(|(index, entry)| {
+ let filename = entry.filename().as_str().ok()?;
+ let is_override = (filename.starts_with("overrides/")
+ || filename.starts_with("client-overrides/")
+ || filename.starts_with("server-overrides/"))
+ && !filename.ends_with('/');
+ is_override.then_some(index)
+ })
+ .collect();
+
+ for index in override_entries {
+ let mut file_bytes = Vec::new();
+ let mut entry_reader = zip_reader.reader_with_entry(index).await?;
+ entry_reader.read_to_end_checked(&mut file_bytes).await?;
+
+ let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
+ file_hashes.push(hash);
+ }
+
+ CachedEntry::cache_modpack_files(version_id, file_hashes, &state.pool)
+ .await?;
+ }
+
// Sets generated profile attributes to the pack ones (using profile::edit)
set_profile_information(
profile_path.clone(),
diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs
index 935c837a2d..40283e776e 100644
--- a/packages/app-lib/src/api/profile/mod.rs
+++ b/packages/app-lib/src/api/profile/mod.rs
@@ -8,8 +8,9 @@ use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
use crate::state::{
- CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata,
- ProfileFile, ProfileInstallStage, ProjectType, SideType,
+ CacheBehaviour, CachedEntry, ContentItem, Credentials, Dependency,
+ JavaVersion, LinkedModpackInfo, ProcessMetadata, ProfileFile,
+ ProfileInstallStage, ProjectType, SideType,
};
use crate::event::{ProfilePayloadType, emit::emit_profile};
@@ -91,6 +92,102 @@ pub async fn get_projects(
}
}
+/// Get content items with rich metadata for a profile
+///
+/// Returns content items filtered to exclude modpack files (if linked),
+/// sorted alphabetically by project name.
+#[tracing::instrument]
+pub async fn get_content_items(
+ path: &str,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ if let Some(profile) = get(path).await? {
+ let items = crate::state::get_content_items(
+ &profile,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(items)
+ } else {
+ Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
+ .as_error())
+ }
+}
+
+/// Get content items that are part of the linked modpack
+///
+/// Returns the modpack's dependencies as ContentItem list.
+/// Returns empty vec if the profile is not linked to a modpack.
+#[tracing::instrument]
+pub async fn get_linked_modpack_content(
+ path: &str,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ if let Some(profile) = get(path).await? {
+ let items = crate::state::get_linked_modpack_content(
+ &profile,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(items)
+ } else {
+ Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
+ .as_error())
+ }
+}
+
+/// Convert a list of dependencies into ContentItems with rich metadata
+#[tracing::instrument]
+pub async fn get_dependencies_as_content_items(
+ dependencies: Vec,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ let items = crate::state::dependencies_to_content_items(
+ &dependencies,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(items)
+}
+
+/// Get linked modpack info for a profile
+///
+/// Returns project, version, and owner information for the linked modpack,
+/// or None if the profile is not linked to a modpack.
+#[tracing::instrument]
+pub async fn get_linked_modpack_info(
+ path: &str,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ if let Some(profile) = get(path).await? {
+ let info = crate::state::get_linked_modpack_info(
+ &profile,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(info)
+ } else {
+ Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
+ .as_error())
+ }
+}
+
/// Get profile's full path in the filesystem
#[tracing::instrument]
pub async fn get_full_path(path: &str) -> crate::Result {
diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs
index eee2fb108c..9229dc1bb8 100644
--- a/packages/app-lib/src/state/cache.rs
+++ b/packages/app-lib/src/state/cache.rs
@@ -34,6 +34,9 @@ pub enum CacheValueType {
FileHash,
FileUpdate,
SearchResults,
+ ModpackFiles,
+ /// Cached list of versions for a project (without changelogs for fast loading)
+ ProjectVersions,
}
impl CacheValueType {
@@ -55,6 +58,8 @@ impl CacheValueType {
CacheValueType::FileHash => "file_hash",
CacheValueType::FileUpdate => "file_update",
CacheValueType::SearchResults => "search_results",
+ CacheValueType::ModpackFiles => "modpack_files",
+ CacheValueType::ProjectVersions => "project_versions",
}
}
@@ -76,6 +81,8 @@ impl CacheValueType {
"file_hash" => CacheValueType::FileHash,
"file_update" => CacheValueType::FileUpdate,
"search_results" => CacheValueType::SearchResults,
+ "modpack_files" => CacheValueType::ModpackFiles,
+ "project_versions" => CacheValueType::ProjectVersions,
_ => CacheValueType::Project,
}
}
@@ -85,7 +92,10 @@ impl CacheValueType {
match self {
CacheValueType::File => 30 * 24 * 60 * 60, // 30 days
CacheValueType::FileHash => 30 * 24 * 60 * 60, // 30 days
- _ => 30 * 60, // 30 minutes
+ // ModpackFiles never expire - version_id is immutable so hashes never change
+ // TODO: There has to be a way to exclude this from the "Purge cache" stuff?
+ CacheValueType::ModpackFiles => 100 * 365 * 24 * 60 * 60, // 100 years (effectively never)
+ _ => 30 * 60, // 30 minutes
}
}
@@ -118,11 +128,27 @@ impl CacheValueType {
| CacheValueType::File
| CacheValueType::LoaderManifest
| CacheValueType::FileUpdate
- | CacheValueType::SearchResults => None,
+ | CacheValueType::SearchResults
+ | CacheValueType::ModpackFiles
+ | CacheValueType::ProjectVersions => None,
}
}
}
+/// Cached modpack file hashes for filtering content
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct CachedModpackFiles {
+ pub version_id: String,
+ pub file_hashes: Vec,
+}
+
+/// Cached list of versions for a project (without changelogs for fast loading)
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct CachedProjectVersions {
+ pub project_id: String,
+ pub versions: Vec,
+}
+
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
@@ -151,6 +177,8 @@ pub enum CacheValue {
FileHash(CachedFileHash),
FileUpdate(CachedFileUpdate),
SearchResults(SearchResults),
+ ModpackFiles(CachedModpackFiles),
+ ProjectVersions(CachedProjectVersions),
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -308,7 +336,8 @@ pub struct Version {
pub name: String,
pub version_number: String,
- pub changelog: String,
+ #[serde(default)]
+ pub changelog: Option,
pub changelog_url: Option,
pub date_published: DateTime,
@@ -456,6 +485,8 @@ impl CacheValue {
CacheValue::FileHash(_) => CacheValueType::FileHash,
CacheValue::FileUpdate(_) => CacheValueType::FileUpdate,
CacheValue::SearchResults(_) => CacheValueType::SearchResults,
+ CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles,
+ CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions,
}
}
@@ -496,6 +527,8 @@ impl CacheValue {
)
}
CacheValue::SearchResults(search) => search.search.clone(),
+ CacheValue::ModpackFiles(files) => files.version_id.clone(),
+ CacheValue::ProjectVersions(pv) => pv.project_id.clone(),
}
}
@@ -520,7 +553,9 @@ impl CacheValue {
| CacheValue::File { .. }
| CacheValue::LoaderManifest { .. }
| CacheValue::FileUpdate(_)
- | CacheValue::SearchResults(_) => None,
+ | CacheValue::SearchResults(_)
+ | CacheValue::ModpackFiles(_)
+ | CacheValue::ProjectVersions(_) => None,
}
}
}
@@ -1411,6 +1446,56 @@ impl CachedEntry {
})
.collect()
}
+ CacheValueType::ModpackFiles => {
+ // ModpackFiles are only stored locally during modpack installation,
+ // not fetched from an external API
+ vec![]
+ }
+ CacheValueType::ProjectVersions => {
+ let mut values = vec![];
+
+ for key in keys {
+ let project_id = key.to_string();
+ let url = format!(
+ "{}project/{}/version?include_changelog=false",
+ env!("MODRINTH_API_URL"),
+ project_id
+ );
+
+ match fetch_json::>(
+ Method::GET,
+ &url,
+ None,
+ None,
+ fetch_semaphore,
+ pool,
+ )
+ .await
+ {
+ Ok(versions) => {
+ values.push((
+ CacheValue::ProjectVersions(
+ CachedProjectVersions {
+ project_id,
+ versions,
+ },
+ )
+ .get_entry(),
+ true,
+ ));
+ }
+ Err(e) => {
+ tracing::warn!(
+ "Failed to fetch versions for project {}: {:?}",
+ project_id,
+ e
+ );
+ }
+ }
+ }
+
+ values
+ }
})
}
@@ -1463,6 +1548,86 @@ impl CachedEntry {
Ok(())
}
+
+ /// Store modpack file hashes in cache
+ pub async fn cache_modpack_files(
+ version_id: &str,
+ file_hashes: Vec,
+ pool: &SqlitePool,
+ ) -> crate::Result<()> {
+ let data = CachedModpackFiles {
+ version_id: version_id.to_string(),
+ file_hashes,
+ };
+
+ let entry = CachedEntry {
+ id: version_id.to_string(),
+ alias: None,
+ expires: Utc::now().timestamp()
+ + CacheValueType::ModpackFiles.expiry(),
+ type_: CacheValueType::ModpackFiles,
+ data: Some(CacheValue::ModpackFiles(data)),
+ };
+
+ Self::upsert_many(&[entry], pool).await
+ }
+
+ /// Get modpack file hashes from cache
+ pub async fn get_modpack_files(
+ version_id: &str,
+ pool: &SqlitePool,
+ ) -> crate::Result> {
+ let type_str = CacheValueType::ModpackFiles.as_str();
+
+ let result = sqlx::query!(
+ r#"
+ SELECT data as "data?: sqlx::types::Json"
+ FROM cache
+ WHERE data_type = $1 AND id = $2
+ "#,
+ type_str,
+ version_id
+ )
+ .fetch_optional(pool)
+ .await?;
+
+ if let Some(row) = result
+ && let Some(sqlx::types::Json(CacheValue::ModpackFiles(files))) =
+ row.data
+ {
+ return Ok(Some(files));
+ }
+
+ Ok(None)
+ }
+
+ /// Get versions for a project (without changelogs for fast loading)
+ #[tracing::instrument(skip(pool, fetch_semaphore))]
+ pub async fn get_project_versions(
+ project_id: &str,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+ ) -> crate::Result>> {
+ let entry = Self::get(
+ CacheValueType::ProjectVersions,
+ project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ if let Some(CachedEntry {
+ data: Some(CacheValue::ProjectVersions(pv)),
+ ..
+ }) = entry
+ {
+ return Ok(Some(pv.versions));
+ }
+
+ Ok(None)
+ }
}
pub async fn cache_file_hash(
diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs
new file mode 100644
index 0000000000..da81587a09
--- /dev/null
+++ b/packages/app-lib/src/state/instances/content.rs
@@ -0,0 +1,832 @@
+//! # Content API
+//!
+//! ## Data Flow
+//!
+//! 1. Frontend calls `get_content_items(profile_path)`
+//! 2. Backend fetches all installed files via `Profile::get_projects()`
+//! 3. If profile is linked to a modpack:
+//! - Fetch modpack file hashes from cache (populated during installation)
+//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
+//! - Filter out files that belong to the modpack
+//! 4. For remaining files, fetch project/version/owner metadata in parallel
+//! 5. Return sorted `ContentItem` list
+//!
+//! ## Caching
+//!
+//! Modpack file hashes are cached in `CacheValueType::ModpackFiles`
+//! during modpack installation. The cache never expires (version_id is
+//! immutable), so re-download is only needed if cache was cleared or
+//! profile predates this caching mechanism.
+
+use crate::pack::install_from::{PackFileHash, PackFormat};
+use crate::state::profiles::{Profile, ProfileFile, ProjectType};
+use crate::state::{CacheBehaviour, CachedEntry};
+use crate::util::fetch::{FetchSemaphore, fetch_mirrors, sha1_async};
+use async_zip::base::read::seek::ZipFileReader;
+use serde::{Deserialize, Serialize};
+use sqlx::SqlitePool;
+use std::collections::HashSet;
+use std::io::Cursor;
+
+/// Content item with rich metadata for frontend display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItem {
+ /// Unique identifier (the file name)
+ pub file_name: String,
+ /// Relative path to the file within the profile
+ pub file_path: String,
+ /// SHA1 hash of the file
+ pub hash: String,
+ /// File size in bytes
+ pub size: u64,
+ /// Whether the file is enabled (not .disabled)
+ pub enabled: bool,
+ /// Type of project (mod, resourcepack, etc.)
+ pub project_type: ProjectType,
+ /// Modrinth project info if recognized
+ pub project: Option,
+ /// Version info if recognized
+ pub version: Option,
+ /// Owner info (organization or user)
+ pub owner: Option,
+ /// Whether an update is available
+ pub has_update: bool,
+ /// The recommended version ID to update to (if has_update is true)
+ pub update_version_id: Option,
+ /// When the file was added to the instance (file modification time)
+ pub date_added: Option,
+}
+
+/// Project information for content item display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItemProject {
+ pub id: String,
+ pub slug: Option,
+ pub title: String,
+ pub icon_url: Option,
+}
+
+/// Version information for content item display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItemVersion {
+ pub id: String,
+ pub version_number: String,
+ pub file_name: String,
+ pub date_published: Option,
+}
+
+/// Owner information for content item display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItemOwner {
+ pub id: String,
+ pub name: String,
+ pub avatar_url: Option,
+ #[serde(rename = "type")]
+ pub owner_type: OwnerType,
+}
+
+/// Type of content owner
+#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum OwnerType {
+ User,
+ Organization,
+}
+
+use crate::state::cache::{Dependency, Organization, TeamMember};
+use crate::state::{Project, Version};
+
+/// Full linked modpack information including owner and update status
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct LinkedModpackInfo {
+ pub project: Project,
+ pub version: Version,
+ pub owner: Option,
+ /// Whether an update is available for this modpack
+ pub has_update: bool,
+ /// The version ID to update to (if has_update is true)
+ pub update_version_id: Option,
+ /// The full version info for the update (if has_update is true)
+ pub update_version: Option,
+}
+
+/// Get linked modpack info including project, version, owner, and update status.
+/// Returns None if the profile is not linked to a modpack.
+pub async fn get_linked_modpack_info(
+ profile: &Profile,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let Some(linked_data) = &profile.linked_data else {
+ return Ok(None);
+ };
+
+ // Fetch project, version, and all project versions in parallel
+ let (project, version, all_versions) = tokio::try_join!(
+ CachedEntry::get_project(
+ &linked_data.project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ CachedEntry::get_version(
+ &linked_data.version_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ CachedEntry::get_project_versions(
+ &linked_data.project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ )?;
+
+ let project = project.ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "Linked modpack project {} not found",
+ linked_data.project_id
+ ))
+ })?;
+
+ let version = version.ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "Linked modpack version {} not found",
+ linked_data.version_id
+ ))
+ })?;
+
+ // Resolve owner - prefer organization, fall back to team owner
+ let owner = if let Some(org_id) = &project.organization {
+ let org = CachedEntry::get_organization(
+ org_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+ org.map(|o| ContentItemOwner {
+ id: o.id,
+ name: o.name,
+ avatar_url: o.icon_url,
+ owner_type: OwnerType::Organization,
+ })
+ } else {
+ let team = CachedEntry::get_team(
+ &project.team,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+ team.and_then(|t| {
+ t.into_iter()
+ .find(|m| m.is_owner)
+ .map(|m| ContentItemOwner {
+ id: m.user.id,
+ name: m.user.username,
+ avatar_url: m.user.avatar_url,
+ owner_type: OwnerType::User,
+ })
+ })
+ };
+
+ // Check for updates
+ let (has_update, update_version_id, update_version) = check_modpack_update(
+ profile,
+ &linked_data.version_id,
+ &version,
+ all_versions,
+ );
+
+ Ok(Some(LinkedModpackInfo {
+ project,
+ version,
+ owner,
+ has_update,
+ update_version_id,
+ update_version,
+ }))
+}
+
+/// Check if a newer compatible version exists for the linked modpack.
+/// Returns (has_update, update_version_id, update_version).
+fn check_modpack_update(
+ profile: &Profile,
+ installed_version_id: &str,
+ installed_version: &Version,
+ all_versions: Option>,
+) -> (bool, Option, Option) {
+ let Some(versions) = all_versions else {
+ return (false, None, None);
+ };
+
+ // Get the loader as a string for comparison
+ let loader_str = profile.loader.as_str().to_lowercase();
+ let game_version = &profile.game_version;
+
+ // Filter to compatible versions
+ let mut compatible_versions: Vec<&Version> = versions
+ .iter()
+ .filter(|v| {
+ // Must support the profile's game version
+ let supports_game = v.game_versions.contains(game_version);
+
+ // Must support the profile's loader
+ // The v2 API replaces "mrpack" with actual loaders from mrpack_loaders,
+ // but if mrpack_loaders is missing, loaders may be just ["mrpack"].
+ // In that case we can't filter by loader, so accept the version.
+ let real_loaders: Vec<_> = v
+ .loaders
+ .iter()
+ .filter(|l| l.to_lowercase() != "mrpack")
+ .collect();
+ let supports_loader = real_loaders.is_empty()
+ || real_loaders.iter().any(|l| l.to_lowercase() == loader_str);
+
+ supports_game && supports_loader
+ })
+ .collect();
+
+ // Sort by date_published descending (newest first)
+ compatible_versions.sort_by(|a, b| b.date_published.cmp(&a.date_published));
+
+ // Find the newest compatible version
+ if let Some(newest) = compatible_versions.first() {
+ // Check if the newest version is different and newer than installed
+ if newest.id != installed_version_id
+ && newest.date_published > installed_version.date_published
+ {
+ return (true, Some(newest.id.clone()), Some((*newest).clone()));
+ }
+ }
+
+ (false, None, None)
+}
+
+/// Get content items with rich metadata, filtered to exclude modpack content.
+/// Returns only user-added content (not part of the linked modpack).
+pub async fn get_content_items(
+ profile: &Profile,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let all_files = profile
+ .get_projects(cache_behaviour, pool, fetch_semaphore)
+ .await?;
+
+ let modpack_hashes: HashSet = if let Some(ref linked_data) =
+ profile.linked_data
+ {
+ match get_modpack_file_hashes(
+ &linked_data.version_id,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ {
+ Ok(hashes) => hashes,
+ Err(e) => {
+ tracing::warn!("Failed to fetch modpack file hashes: {}", e);
+ HashSet::new()
+ }
+ }
+ } else {
+ HashSet::new()
+ };
+
+ let user_files: Vec<(String, ProfileFile)> = all_files
+ .into_iter()
+ .filter(|(_, file)| !modpack_hashes.contains(&file.hash))
+ .collect();
+
+ profile_files_to_content_items(
+ &profile.path,
+ &user_files,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+}
+
+/// Pre-fetched metadata for projects, versions, teams, and organizations.
+struct ResolvedMetadata {
+ projects: Vec,
+ versions: Vec,
+ teams: Vec>,
+ organizations: Vec,
+}
+
+/// Fetch project, version, team, and organization metadata in parallel batches.
+async fn resolve_metadata(
+ project_ids: &HashSet,
+ version_ids: &HashSet,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result {
+ let project_ids_vec: Vec<&str> =
+ project_ids.iter().map(|s| s.as_str()).collect();
+ let version_ids_vec: Vec<&str> =
+ version_ids.iter().map(|s| s.as_str()).collect();
+
+ let (projects, versions) =
+ if !project_ids.is_empty() || !version_ids.is_empty() {
+ tokio::try_join!(
+ async {
+ if project_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_project_many(
+ &project_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ },
+ async {
+ if version_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_version_many(
+ &version_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ }
+ )?
+ } else {
+ (Vec::new(), Vec::new())
+ };
+
+ let team_ids: HashSet =
+ projects.iter().map(|p| p.team.clone()).collect();
+ let org_ids: HashSet = projects
+ .iter()
+ .filter_map(|p| p.organization.clone())
+ .collect();
+
+ let team_ids_vec: Vec<&str> = team_ids.iter().map(|s| s.as_str()).collect();
+ let org_ids_vec: Vec<&str> = org_ids.iter().map(|s| s.as_str()).collect();
+
+ let (teams, organizations) = if !team_ids.is_empty() || !org_ids.is_empty()
+ {
+ tokio::try_join!(
+ async {
+ if team_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_team_many(
+ &team_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ },
+ async {
+ if org_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_organization_many(
+ &org_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ }
+ )?
+ } else {
+ (Vec::new(), Vec::new())
+ };
+
+ Ok(ResolvedMetadata {
+ projects,
+ versions,
+ teams,
+ organizations,
+ })
+}
+
+/// Shared helper: convert profile files to ContentItems with rich metadata.
+/// Used by both `get_content_items` (user-added files) and
+/// `get_linked_modpack_content` (modpack-bundled files).
+async fn profile_files_to_content_items(
+ profile_path: &str,
+ files: &[(String, ProfileFile)],
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let project_ids: HashSet = files
+ .iter()
+ .filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.project_id.clone()))
+ .collect();
+
+ let version_ids: HashSet = files
+ .iter()
+ .filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.version_id.clone()))
+ .collect();
+
+ let meta = resolve_metadata(
+ &project_ids,
+ &version_ids,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ let profile_base_path =
+ crate::api::profile::get_full_path(profile_path).await?;
+
+ // Batch-read file modification times off the main async runtime
+ let paths: Vec = files
+ .iter()
+ .map(|(path, _)| profile_base_path.join(path))
+ .collect();
+
+ let modification_times: Vec> =
+ tokio::task::spawn_blocking(move || {
+ paths
+ .iter()
+ .map(|path| {
+ std::fs::metadata(path).and_then(|m| m.modified()).ok().map(
+ |t| {
+ chrono::DateTime::::from(t)
+ .to_rfc3339()
+ },
+ )
+ })
+ .collect()
+ })
+ .await?;
+
+ let mut items: Vec = files
+ .iter()
+ .enumerate()
+ .map(|(i, (path, file))| {
+ let project = file.metadata.as_ref().and_then(|m| {
+ meta.projects.iter().find(|p| p.id == m.project_id)
+ });
+
+ let version = file.metadata.as_ref().and_then(|m| {
+ meta.versions.iter().find(|v| v.id == m.version_id)
+ });
+
+ let owner = project.and_then(|p| {
+ resolve_owner(p, &meta.teams, &meta.organizations)
+ });
+
+ ContentItem {
+ file_name: file.file_name.clone(),
+ file_path: path.clone(),
+ hash: file.hash.clone(),
+ size: file.size,
+ enabled: !file.file_name.ends_with(".disabled"),
+ project_type: file.project_type,
+ project: project.map(|p| ContentItemProject {
+ id: p.id.clone(),
+ slug: p.slug.clone(),
+ title: p.title.clone(),
+ icon_url: p.icon_url.clone(),
+ }),
+ version: version.map(|v| ContentItemVersion {
+ id: v.id.clone(),
+ version_number: v.version_number.clone(),
+ file_name: file.file_name.clone(),
+ date_published: Some(v.date_published.to_rfc3339()),
+ }),
+ owner,
+ has_update: file.update_version_id.is_some(),
+ update_version_id: file.update_version_id.clone(),
+ date_added: modification_times[i].clone(),
+ }
+ })
+ .collect();
+
+ items.sort_by(|a, b| {
+ let name_a = a
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&a.file_name);
+ let name_b = b
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&b.file_name);
+ name_a.to_lowercase().cmp(&name_b.to_lowercase())
+ });
+
+ Ok(items)
+}
+
+/// Resolve the owner of a project from pre-fetched teams and organizations.
+fn resolve_owner(
+ project: &Project,
+ teams: &[Vec],
+ organizations: &[Organization],
+) -> Option {
+ if let Some(org_id) = &project.organization {
+ organizations.iter().find(|o| &o.id == org_id).map(|o| {
+ ContentItemOwner {
+ id: o.id.clone(),
+ name: o.name.clone(),
+ avatar_url: o.icon_url.clone(),
+ owner_type: OwnerType::Organization,
+ }
+ })
+ } else {
+ teams
+ .iter()
+ .find(|t| t.first().is_some_and(|m| m.team_id == project.team))
+ .and_then(|t| t.iter().find(|m| m.is_owner))
+ .map(|m| ContentItemOwner {
+ id: m.user.id.clone(),
+ name: m.user.username.clone(),
+ avatar_url: m.user.avatar_url.clone(),
+ owner_type: OwnerType::User,
+ })
+ }
+}
+
+/// Get content items that are part of the linked modpack (not user-added).
+/// Returns modpack-bundled files with full on-disk metadata (file_path, enabled, etc).
+/// Returns empty vec if the profile is not linked to a modpack.
+pub async fn get_linked_modpack_content(
+ profile: &Profile,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let Some(linked_data) = &profile.linked_data else {
+ return Ok(Vec::new());
+ };
+
+ let all_files = profile
+ .get_projects(cache_behaviour, pool, fetch_semaphore)
+ .await?;
+
+ let modpack_hashes: HashSet = match get_modpack_file_hashes(
+ &linked_data.version_id,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ {
+ Ok(hashes) => hashes,
+ Err(e) => {
+ tracing::warn!("Failed to fetch modpack file hashes: {}", e);
+ return Ok(Vec::new());
+ }
+ };
+
+ // Inverse of get_content_items: keep only modpack-bundled files
+ let modpack_files: Vec<(String, ProfileFile)> = all_files
+ .into_iter()
+ .filter(|(_, file)| modpack_hashes.contains(&file.hash))
+ .collect();
+
+ profile_files_to_content_items(
+ &profile.path,
+ &modpack_files,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+}
+
+/// Convert a list of dependencies into ContentItems with rich metadata.
+/// Fetches project, version, and owner info for each dependency.
+pub async fn dependencies_to_content_items(
+ dependencies: &[Dependency],
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let project_ids: HashSet = dependencies
+ .iter()
+ .filter_map(|d| d.project_id.clone())
+ .collect();
+
+ if project_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let version_ids: HashSet = dependencies
+ .iter()
+ .filter_map(|d| d.version_id.clone())
+ .collect();
+
+ let meta = resolve_metadata(
+ &project_ids,
+ &version_ids,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ let mut items: Vec = dependencies
+ .iter()
+ .filter_map(|dep| {
+ let project_id = dep.project_id.as_ref()?;
+ let project = meta.projects.iter().find(|p| &p.id == project_id)?;
+
+ let version = dep
+ .version_id
+ .as_ref()
+ .and_then(|vid| meta.versions.iter().find(|v| &v.id == vid));
+
+ let owner =
+ resolve_owner(project, &meta.teams, &meta.organizations);
+
+ let project_type = match project.project_type.as_str() {
+ "mod" => ProjectType::Mod,
+ "resourcepack" => ProjectType::ResourcePack,
+ "shader" => ProjectType::ShaderPack,
+ "datapack" => ProjectType::DataPack,
+ _ => ProjectType::Mod,
+ };
+
+ Some(ContentItem {
+ file_name: version
+ .and_then(|v| v.files.first())
+ .map(|f| f.filename.clone())
+ .unwrap_or_else(|| {
+ format!(
+ "{}.jar",
+ project.slug.as_deref().unwrap_or(&project.id)
+ )
+ }),
+ file_path: String::new(),
+ hash: String::new(),
+ size: version
+ .and_then(|v| v.files.first())
+ .map(|f| f.size as u64)
+ .unwrap_or(0),
+ enabled: true,
+ project_type,
+ project: Some(ContentItemProject {
+ id: project.id.clone(),
+ slug: project.slug.clone(),
+ title: project.title.clone(),
+ icon_url: project.icon_url.clone(),
+ }),
+ version: version.map(|v| ContentItemVersion {
+ id: v.id.clone(),
+ version_number: v.version_number.clone(),
+ file_name: v
+ .files
+ .first()
+ .map(|f| f.filename.clone())
+ .unwrap_or_default(),
+ date_published: Some(v.date_published.to_rfc3339()),
+ }),
+ owner,
+ has_update: false,
+ update_version_id: None,
+ date_added: None,
+ })
+ })
+ .collect();
+
+ items.sort_by(|a, b| {
+ let name_a = a
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&a.file_name);
+ let name_b = b
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&b.file_name);
+ name_a.to_lowercase().cmp(&name_b.to_lowercase())
+ });
+
+ Ok(items)
+}
+
+/// Gets SHA1 hashes of all files in a modpack version.
+/// Checks cache first, falls back to downloading mrpack if not cached.
+async fn get_modpack_file_hashes(
+ version_id: &str,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ if let Some(cached) =
+ CachedEntry::get_modpack_files(version_id, pool).await?
+ {
+ return Ok(cached.file_hashes.into_iter().collect());
+ }
+
+ tracing::debug!(
+ "Modpack files not cached, downloading mrpack for version {}",
+ version_id
+ );
+
+ let version =
+ CachedEntry::get_version(version_id, None, pool, fetch_semaphore)
+ .await?
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "Modpack version {version_id} not found"
+ ))
+ })?;
+
+ let primary_file = version
+ .files
+ .iter()
+ .find(|f| f.primary)
+ .or_else(|| version.files.first())
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "No files found for modpack version {version_id}"
+ ))
+ })?;
+
+ let mrpack_bytes = fetch_mirrors(
+ &[&primary_file.url],
+ primary_file.hashes.get("sha1").map(|s| s.as_str()),
+ fetch_semaphore,
+ pool,
+ )
+ .await?;
+
+ let reader = Cursor::new(&mrpack_bytes);
+ let mut zip_reader =
+ ZipFileReader::with_tokio(reader).await.map_err(|_| {
+ crate::ErrorKind::InputError(
+ "Failed to read modpack zip".to_string(),
+ )
+ })?;
+
+ let manifest_idx = zip_reader
+ .file()
+ .entries()
+ .iter()
+ .position(|f| {
+ matches!(f.filename().as_str(), Ok("modrinth.index.json"))
+ })
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError(
+ "No modrinth.index.json found in mrpack".to_string(),
+ )
+ })?;
+
+ let mut manifest = String::new();
+ let mut entry_reader = zip_reader.reader_with_entry(manifest_idx).await?;
+ entry_reader.read_to_string_checked(&mut manifest).await?;
+
+ let pack: PackFormat = serde_json::from_str(&manifest)?;
+
+ let mut hashes: Vec = pack
+ .files
+ .iter()
+ .filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
+ .collect();
+
+ // Also hash files from overrides folders (these aren't in modrinth.index.json)
+ let override_entries: Vec = zip_reader
+ .file()
+ .entries()
+ .iter()
+ .enumerate()
+ .filter_map(|(index, entry)| {
+ let filename = entry.filename().as_str().ok()?;
+ let is_override = (filename.starts_with("overrides/")
+ || filename.starts_with("client-overrides/")
+ || filename.starts_with("server-overrides/"))
+ && !filename.ends_with('/');
+ is_override.then_some(index)
+ })
+ .collect();
+
+ for index in override_entries {
+ let mut file_bytes = Vec::new();
+ let mut entry_reader = zip_reader.reader_with_entry(index).await?;
+ entry_reader.read_to_end_checked(&mut file_bytes).await?;
+
+ let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
+ hashes.push(hash);
+ }
+
+ CachedEntry::cache_modpack_files(version_id, hashes.clone(), pool).await?;
+
+ Ok(hashes.into_iter().collect())
+}
diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs
new file mode 100644
index 0000000000..931e32a6c1
--- /dev/null
+++ b/packages/app-lib/src/state/instances/mod.rs
@@ -0,0 +1,4 @@
+//! Instance-related modules for profile/instance management.
+
+mod content;
+pub use self::content::*;
diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs
index df043de35c..a5b9fdd136 100644
--- a/packages/app-lib/src/state/legacy_converter.rs
+++ b/packages/app-lib/src/state/legacy_converter.rs
@@ -622,7 +622,7 @@ impl From for Version {
featured: value.featured,
name: value.name,
version_number: value.version_number,
- changelog: value.changelog,
+ changelog: Some(value.changelog),
changelog_url: value.changelog_url,
date_published: value.date_published,
downloads: value.downloads,
diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs
index 6c3a69126b..b44306f92e 100644
--- a/packages/app-lib/src/state/mod.rs
+++ b/packages/app-lib/src/state/mod.rs
@@ -13,6 +13,9 @@ pub use self::dirs::*;
mod profiles;
pub use self::profiles::*;
+mod instances;
+pub use self::instances::*;
+
mod settings;
pub use self::settings::*;
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index 7cf957dbc6..3a0d8046a4 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -13,6 +13,7 @@ import _ArrowDownLeftIcon from './icons/arrow-down-left.svg?component'
import _ArrowLeftIcon from './icons/arrow-left.svg?component'
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
import _ArrowUpIcon from './icons/arrow-up.svg?component'
+import _ArrowUpDownIcon from './icons/arrow-up-down.svg?component'
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component'
@@ -45,6 +46,7 @@ import _ChevronDownIcon from './icons/chevron-down.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _ChevronUpIcon from './icons/chevron-up.svg?component'
+import _CircleAlertIcon from './icons/circle-alert.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
import _ClearIcon from './icons/clear.svg?component'
import _ClientIcon from './icons/client.svg?component'
@@ -161,10 +163,13 @@ import _PackageClosedIcon from './icons/package-closed.svg?component'
import _PackageOpenIcon from './icons/package-open.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
+import _PencilIcon from './icons/pencil.svg?component'
import _PickaxeIcon from './icons/pickaxe.svg?component'
import _PlayIcon from './icons/play.svg?component'
import _PlugIcon from './icons/plug.svg?component'
import _PlusIcon from './icons/plus.svg?component'
+import _PowerIcon from './icons/power.svg?component'
+import _PowerOffIcon from './icons/power-off.svg?component'
import _RadioButtonIcon from './icons/radio-button.svg?component'
import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
@@ -294,6 +299,7 @@ import _TagLoaderVelocityIcon from './icons/tags/loaders/velocity.svg?component'
import _TagLoaderWaterfallIcon from './icons/tags/loaders/waterfall.svg?component'
import _TerminalSquareIcon from './icons/terminal-square.svg?component'
import _TestIcon from './icons/test.svg?component'
+import _TextCursorInputIcon from './icons/text-cursor-input.svg?component'
import _TextQuoteIcon from './icons/text-quote.svg?component'
import _TimerIcon from './icons/timer.svg?component'
import _ToggleLeftIcon from './icons/toggle-left.svg?component'
@@ -338,6 +344,7 @@ export const ArrowDownLeftIcon = _ArrowDownLeftIcon
export const ArrowLeftIcon = _ArrowLeftIcon
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
export const ArrowUpIcon = _ArrowUpIcon
+export const ArrowUpDownIcon = _ArrowUpDownIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
@@ -370,6 +377,7 @@ export const ChevronDownIcon = _ChevronDownIcon
export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon
export const ChevronUpIcon = _ChevronUpIcon
+export const CircleAlertIcon = _CircleAlertIcon
export const CircleUserIcon = _CircleUserIcon
export const ClearIcon = _ClearIcon
export const ClientIcon = _ClientIcon
@@ -486,10 +494,13 @@ export const PackageClosedIcon = _PackageClosedIcon
export const PackageOpenIcon = _PackageOpenIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
+export const PencilIcon = _PencilIcon
export const PickaxeIcon = _PickaxeIcon
export const PlayIcon = _PlayIcon
export const PlugIcon = _PlugIcon
export const PlusIcon = _PlusIcon
+export const PowerIcon = _PowerIcon
+export const PowerOffIcon = _PowerOffIcon
export const RadioButtonIcon = _RadioButtonIcon
export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
export const ReceiptTextIcon = _ReceiptTextIcon
@@ -619,6 +630,7 @@ export const TagLoaderVelocityIcon = _TagLoaderVelocityIcon
export const TagLoaderWaterfallIcon = _TagLoaderWaterfallIcon
export const TerminalSquareIcon = _TerminalSquareIcon
export const TestIcon = _TestIcon
+export const TextCursorInputIcon = _TextCursorInputIcon
export const TextQuoteIcon = _TextQuoteIcon
export const TimerIcon = _TimerIcon
export const ToggleLeftIcon = _ToggleLeftIcon
diff --git a/packages/assets/icons/arrow-up-down.svg b/packages/assets/icons/arrow-up-down.svg
new file mode 100644
index 0000000000..0607f68e0b
--- /dev/null
+++ b/packages/assets/icons/arrow-up-down.svg
@@ -0,0 +1 @@
+
diff --git a/packages/assets/icons/circle-alert.svg b/packages/assets/icons/circle-alert.svg
new file mode 100644
index 0000000000..5c87e85c4d
--- /dev/null
+++ b/packages/assets/icons/circle-alert.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/packages/assets/icons/pencil.svg b/packages/assets/icons/pencil.svg
new file mode 100644
index 0000000000..78e3b38604
--- /dev/null
+++ b/packages/assets/icons/pencil.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/packages/assets/icons/power-off.svg b/packages/assets/icons/power-off.svg
new file mode 100644
index 0000000000..b976bd39b2
--- /dev/null
+++ b/packages/assets/icons/power-off.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/packages/assets/icons/power.svg b/packages/assets/icons/power.svg
new file mode 100644
index 0000000000..f15b1aeecb
--- /dev/null
+++ b/packages/assets/icons/power.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/packages/assets/icons/text-cursor-input.svg b/packages/assets/icons/text-cursor-input.svg
new file mode 100644
index 0000000000..534a20a791
--- /dev/null
+++ b/packages/assets/icons/text-cursor-input.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/done.svg b/packages/assets/illustrations/done.svg
new file mode 100644
index 0000000000..2cdf09253b
--- /dev/null
+++ b/packages/assets/illustrations/done.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/empty-inbox.svg b/packages/assets/illustrations/empty-inbox.svg
new file mode 100644
index 0000000000..9133aa9360
--- /dev/null
+++ b/packages/assets/illustrations/empty-inbox.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/error.svg b/packages/assets/illustrations/error.svg
new file mode 100644
index 0000000000..c6cfc0cc42
--- /dev/null
+++ b/packages/assets/illustrations/error.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-connection.svg b/packages/assets/illustrations/no-connection.svg
new file mode 100644
index 0000000000..edfee6971b
--- /dev/null
+++ b/packages/assets/illustrations/no-connection.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-credit-card.svg b/packages/assets/illustrations/no-credit-card.svg
new file mode 100644
index 0000000000..70d0f761fe
--- /dev/null
+++ b/packages/assets/illustrations/no-credit-card.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-documents.svg b/packages/assets/illustrations/no-documents.svg
new file mode 100644
index 0000000000..9fe6cc8409
--- /dev/null
+++ b/packages/assets/illustrations/no-documents.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-gps.svg b/packages/assets/illustrations/no-gps.svg
new file mode 100644
index 0000000000..7624cc48e9
--- /dev/null
+++ b/packages/assets/illustrations/no-gps.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-images.svg b/packages/assets/illustrations/no-images.svg
new file mode 100644
index 0000000000..bb7c10c419
--- /dev/null
+++ b/packages/assets/illustrations/no-images.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-items-cart.svg b/packages/assets/illustrations/no-items-cart.svg
new file mode 100644
index 0000000000..a291537fc7
--- /dev/null
+++ b/packages/assets/illustrations/no-items-cart.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-messages.svg b/packages/assets/illustrations/no-messages.svg
new file mode 100644
index 0000000000..15ab4eb02e
--- /dev/null
+++ b/packages/assets/illustrations/no-messages.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-search-result.svg b/packages/assets/illustrations/no-search-result.svg
new file mode 100644
index 0000000000..7dc23ba662
--- /dev/null
+++ b/packages/assets/illustrations/no-search-result.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/illustrations/no-tasks.svg b/packages/assets/illustrations/no-tasks.svg
new file mode 100644
index 0000000000..4fc5b23f4b
--- /dev/null
+++ b/packages/assets/illustrations/no-tasks.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/assets/index.ts b/packages/assets/index.ts
index 615a7f18b4..784909282a 100644
--- a/packages/assets/index.ts
+++ b/packages/assets/index.ts
@@ -66,7 +66,19 @@ import _YouTubeShortsIcon from './external/youtubeshorts.svg?component'
// Tag icon helpers - import maps from generated-icons
import type { IconComponent } from './generated-icons'
import { categoryIconMap, loaderIconMap } from './generated-icons'
+import _DoneIllustration from './illustrations/done.svg?component'
import _EmptyIllustration from './illustrations/empty.svg?component'
+import _EmptyInboxIllustration from './illustrations/empty-inbox.svg?component'
+import _ErrorIllustration from './illustrations/error.svg?component'
+import _NoConnectionIllustration from './illustrations/no-connection.svg?component'
+import _NoCreditCardIllustration from './illustrations/no-credit-card.svg?component'
+import _NoDocumentsIllustration from './illustrations/no-documents.svg?component'
+import _NoGPSIllustration from './illustrations/no-gps.svg?component'
+import _NoImagesIllustration from './illustrations/no-images.svg?component'
+import _NoItemsCartIllustration from './illustrations/no-items-cart.svg?component'
+import _NoMessagesIllustration from './illustrations/no-messages.svg?component'
+import _NoSearchResultIllustration from './illustrations/no-search-result.svg?component'
+import _NoTasksIllustration from './illustrations/no-tasks.svg?component'
export const ModrinthIcon = _ModrinthIcon
export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration
@@ -126,7 +138,19 @@ export * from './generated-icons'
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
export { default as SlimPlayerModel } from './models/slim-player.gltf?url'
+export const DoneIllustration = _DoneIllustration
export const EmptyIllustration = _EmptyIllustration
+export const EmptyInboxIllustration = _EmptyInboxIllustration
+export const ErrorIllustration = _ErrorIllustration
+export const NoConnectionIllustration = _NoConnectionIllustration
+export const NoCreditCardIllustration = _NoCreditCardIllustration
+export const NoDocumentsIllustration = _NoDocumentsIllustration
+export const NoGPSIllustration = _NoGPSIllustration
+export const NoImagesIllustration = _NoImagesIllustration
+export const NoItemsCartIllustration = _NoItemsCartIllustration
+export const NoMessagesIllustration = _NoMessagesIllustration
+export const NoSearchResultIllustration = _NoSearchResultIllustration
+export const NoTasksIllustration = _NoTasksIllustration
export function getCategoryIcon(categoryName: string): IconComponent | undefined {
if (!categoryName) {
diff --git a/packages/tooling-config/tailwind/tailwind-preset.ts b/packages/tooling-config/tailwind/tailwind-preset.ts
index 3ff6f79732..1d6abbe9a7 100644
--- a/packages/tooling-config/tailwind/tailwind-preset.ts
+++ b/packages/tooling-config/tailwind/tailwind-preset.ts
@@ -1,3 +1,4 @@
+import containerQueries from '@tailwindcss/container-queries'
import type { Config } from 'tailwindcss'
const config: Config = {
@@ -253,7 +254,7 @@ const config: Config = {
},
},
},
- plugins: [],
+ plugins: [containerQueries],
corePlugins: {
preflight: false,
},
diff --git a/packages/ui/.prettierignore b/packages/ui/.prettierignore
index 754475f4e8..56744bfd5a 100644
--- a/packages/ui/.prettierignore
+++ b/packages/ui/.prettierignore
@@ -1 +1,2 @@
src/locales/**
+storybook-static/**
diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts
index 53ae7e64c1..ae3e615405 100644
--- a/packages/ui/.storybook/preview.ts
+++ b/packages/ui/.storybook/preview.ts
@@ -2,6 +2,7 @@ import '@modrinth/assets/omorphia.scss'
import 'floating-vue/dist/style.css'
import '../src/styles/tailwind.css'
+import type { Labrinth } from '@modrinth/api-client'
import { GenericModrinthClient } from '@modrinth/api-client'
import { withThemeByClassName } from '@storybook/addon-themes'
import type { Preview } from '@storybook/vue3-vite'
@@ -21,8 +22,10 @@ import {
I18N_INJECTION_KEY,
type I18nContext,
type NotificationPanelLocation,
+ provideFilePicker,
provideModrinthClient,
provideNotificationManager,
+ provideTags,
type WebNotification,
} from '../src/providers'
@@ -124,6 +127,23 @@ const StorybookProvider = defineComponent({
})
provideModrinthClient(modrinthClient)
+ const gameVersions = ref([])
+ const loaders = ref([])
+ modrinthClient.labrinth.state.build().then((state) => {
+ gameVersions.value = state.gameVersions
+ loaders.value = state.loaders
+ })
+ provideTags({ gameVersions, loaders })
+
+ provideFilePicker({
+ async pickImage() {
+ return null
+ },
+ async pickModpackFile() {
+ return null
+ },
+ })
+
return () => slots.default?.()
},
})
diff --git a/packages/ui/src/components/base/Accordion.vue b/packages/ui/src/components/base/Accordion.vue
index 506cdee613..13542d7547 100644
--- a/packages/ui/src/components/base/Accordion.vue
+++ b/packages/ui/src/components/base/Accordion.vue
@@ -10,14 +10,18 @@
-
+
@@ -39,6 +43,7 @@ const props = withDefaults(
contentClass?: string
titleWrapperClass?: string
forceOpen?: boolean
+ overflowVisible?: boolean
}>(),
{
type: 'standard',
@@ -47,11 +52,13 @@ const props = withDefaults(
contentClass: null,
titleWrapperClass: null,
forceOpen: false,
+ overflowVisible: false,
},
)
const toggledOpen = ref(props.openByDefault)
const isOpen = computed(() => toggledOpen.value || props.forceOpen)
+const showOverflow = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
@@ -71,9 +78,15 @@ function open() {
emit('onOpen')
}
function close() {
+ showOverflow.value = false
toggledOpen.value = false
emit('onClose')
}
+function onTransitionEnd() {
+ if (isOpen.value) {
+ showOverflow.value = true
+ }
+}
defineExpose({
open,
@@ -105,4 +118,8 @@ defineOptions({
.accordion-content > div {
overflow: hidden;
}
+
+.accordion-content.overflow-visible > div {
+ overflow: visible;
+}
diff --git a/packages/ui/src/components/base/BigOptionButton.vue b/packages/ui/src/components/base/BigOptionButton.vue
new file mode 100644
index 0000000000..019faf6194
--- /dev/null
+++ b/packages/ui/src/components/base/BigOptionButton.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+ {{ title }}
+ {{ description }}
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/Collapsible.vue b/packages/ui/src/components/base/Collapsible.vue
index a45b249477..f0b93dff31 100644
--- a/packages/ui/src/components/base/Collapsible.vue
+++ b/packages/ui/src/components/base/Collapsible.vue
@@ -1,5 +1,17 @@
-
+
@@ -7,15 +19,78 @@
+
diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue
index 82e3704c68..4e30d293ae 100644
--- a/packages/ui/src/components/base/Combobox.vue
+++ b/packages/ui/src/components/base/Combobox.vue
@@ -1,23 +1,48 @@
+
+
+
+
+
+
+
+
@@ -35,86 +60,77 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ item.label }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+ {{ noOptionsMessage }}
+
+
+
-
-
- {{ noOptionsMessage }}
-
-
+
@@ -122,16 +138,7 @@
diff --git a/packages/ui/src/components/base/EmptyState.vue b/packages/ui/src/components/base/EmptyState.vue
new file mode 100644
index 0000000000..e909f31d42
--- /dev/null
+++ b/packages/ui/src/components/base/EmptyState.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+ {{ heading }}
+
+
+ {{ description }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/FloatingActionBar.vue b/packages/ui/src/components/base/FloatingActionBar.vue
index ee221d6ff1..a1fa4ab47f 100644
--- a/packages/ui/src/components/base/FloatingActionBar.vue
+++ b/packages/ui/src/components/base/FloatingActionBar.vue
@@ -20,9 +20,9 @@ onUnmounted(() => {
-
+
@@ -32,6 +32,8 @@ onUnmounted(() => {
+
+
diff --git a/packages/ui/src/components/base/MultiStageModal.vue b/packages/ui/src/components/base/MultiStageModal.vue
index 296d1b7570..52e922678b 100644
--- a/packages/ui/src/components/base/MultiStageModal.vue
+++ b/packages/ui/src/components/base/MultiStageModal.vue
@@ -5,7 +5,7 @@
max-content-height="72vh"
:on-hide="onModalHide"
:closable="true"
- :close-on-click-outside="false"
+ :close-on-click-outside="closeOnClickOutside"
:width="resolvedMaxWidth"
:disable-close="resolveCtxFn(currentStage.disableClose, context)"
>
@@ -58,7 +58,7 @@
@@ -82,16 +82,28 @@
-
+
+
{{ rightButtonConfig.label }}
+
@@ -102,7 +114,7 @@
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue
new file mode 100644
index 0000000000..9fa31217e5
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue
@@ -0,0 +1,422 @@
+
+
+
+
+
+
+
+
+
+ Select icon
+
+
+
+
+
+ Remove icon
+
+
+
+
+
+
+
+ Name (Optional)
+
+
+
+
+
+ {{
+ ctx.flowType === 'instance' ? 'Loader' : 'Content loader'
+ }}
+
+
+
+
+
+ Game version
+
+
+
+
+
+ {{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }}
+
+
+
+
+
+
+
+
+
+
{{
+ isPaperLike ? 'Build number' : 'Loader version'
+ }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue
new file mode 100644
index 0000000000..ae0389d32d
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue
@@ -0,0 +1,174 @@
+
+
+
+ World name
+
+
+
+
+ Game version
+
+
+
+
+
+ {{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }}
+
+
+
+
+
+
+ Gamemode
+
+
+
+
+ Difficulty
+
+
+
+
+ World type
+
+
+
+
+ World seed (Optional)
+
+ Leave blank for a random seed.
+
+
+
+
+
+
+
+ Additional settings
+
+
+
+
+ Generate structures
+
+ Controls whether villages, strongholds, and other structures generate in new chunks.
+
+
+
+
+
+
+ Generator settings
+
+
+
+ Used for advanced world customization such as custom Superflat layers.
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
new file mode 100644
index 0000000000..c2972428bb
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
@@ -0,0 +1,283 @@
+
+
+
+
+ Launcher instances
+
+ Clear all
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ launcher.name }}
+
+
+
+
+
+
+
+
+ {{ instance }}
+
+
+
+
+
+
+
+
+
+ No launcher instances detected
+
+
+
+
+ Detecting launcher instances...
+
+
+
+
+
+ Add launcher path
+
+
+
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
new file mode 100644
index 0000000000..cfa6f27240
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
@@ -0,0 +1,169 @@
+
+
+
Already know the modpack you want to install?
+
handleSearch(query)"
+ />
+
+
+
+
+
+ Import modpack
+
+
+
+
+
+ Browse modpacks
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue
new file mode 100644
index 0000000000..8ad6723c14
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue
@@ -0,0 +1,74 @@
+
+
+
+ {{
+ ctx.flowType === 'instance'
+ ? 'Choose instance type'
+ : ctx.flowType === 'server-onboarding'
+ ? 'Select installation type'
+ : 'Select world type'
+ }}
+
+
+
+
+
+
+
+
+
+
+ An instance is a Minecraft setup with a specific loader, version, and mods.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts
new file mode 100644
index 0000000000..c9ea388d91
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts
@@ -0,0 +1,332 @@
+import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue'
+import type { ComponentExposed } from 'vue-component-type-helpers'
+
+import { createContext } from '../../../providers'
+import type { ImportableLauncher } from '../../../providers/instance-import'
+import type { MultiStageModal, StageConfigInput } from '../../base'
+import type { ComboboxOption } from '../../base/Combobox.vue'
+import { stageConfigs } from './stages'
+
+export type FlowType = 'world' | 'server-onboarding' | 'instance'
+export type SetupType = 'modpack' | 'custom' | 'vanilla'
+export type Gamemode = 'survival' | 'creative' | 'hardcore'
+export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard'
+export type LoaderVersionType = 'stable' | 'latest' | 'other'
+export type GeneratorSettingsMode = 'default' | 'flat' | 'custom'
+
+export interface ModpackSelection {
+ projectId: string
+ versionId: string
+ name: string
+ iconUrl?: string
+}
+
+export interface ModpackSearchHit {
+ title: string
+ iconUrl?: string
+ latestVersion?: string
+}
+
+export const flowTypeHeadings: Record = {
+ world: 'Create world',
+ 'server-onboarding': 'Set up server',
+ instance: 'Create instance',
+}
+
+export interface CreationFlowContextValue {
+ // Flow
+ flowType: FlowType
+
+ // Configuration
+ availableLoaders: string[]
+ showSnapshotToggle: boolean
+ disableClose: boolean
+ isInitialSetup: boolean
+
+ // Initial values
+ initialLoader: string | null
+ initialGameVersion: string | null
+
+ // State
+ setupType: Ref
+ isImportMode: Ref
+ worldName: Ref
+ gamemode: Ref
+ difficulty: Ref
+ worldSeed: Ref
+ worldTypeOption: Ref
+ generateStructures: Ref
+ generatorSettingsMode: Ref
+ generatorSettingsCustom: Ref
+
+ // Instance-specific state
+ instanceName: Ref
+ instanceIcon: Ref
+ instanceIconUrl: Ref
+ instanceIconPath: Ref
+
+ // Loader/version state (custom setup)
+ selectedLoader: Ref
+ selectedGameVersion: Ref
+ loaderVersionType: Ref
+ selectedLoaderVersion: Ref
+ hideLoaderChips: ComputedRef
+ hideLoaderVersion: ComputedRef
+ showSnapshots: Ref
+
+ // Modpack state
+ modpackSelection: Ref
+ modpackFile: Ref
+ modpackFilePath: Ref
+
+ // Modpack search state (persisted across stage navigation)
+ modpackSearchProjectId: Ref
+ modpackSearchVersionId: Ref
+ modpackSearchOptions: Ref[]>
+ modpackVersionOptions: Ref[]>
+ modpackSearchHits: Ref>
+
+ // Import state (instance flow only)
+ importLaunchers: Ref
+ importSelectedInstances: Ref>>
+ importSearchQuery: Ref
+
+ // Confirm stage
+ hardReset: Ref
+
+ // Loading state (set when finish() is called, cleared on reset)
+ loading: Ref
+
+ // Modal
+ modal: ShallowRef | null>
+ stageConfigs: StageConfigInput[]
+
+ // Callbacks
+ onBack: (() => void) | null
+
+ // Methods
+ reset: () => void
+ setSetupType: (type: SetupType) => void
+ setImportMode: () => void
+ browseModpacks: () => void
+ finish: () => void
+}
+
+export const [injectCreationFlowContext, provideCreationFlowContext] =
+ createContext('CreationFlowModal')
+
+// TODO: replace with actual world count from the world list once available
+let worldCounter = 0
+
+export interface CreationFlowOptions {
+ availableLoaders?: string[]
+ showSnapshotToggle?: boolean
+ disableClose?: boolean
+ isInitialSetup?: boolean
+ initialLoader?: string
+ initialGameVersion?: string
+ onBack?: () => void
+}
+
+export function createCreationFlowContext(
+ modal: ShallowRef | null>,
+ flowType: FlowType,
+ emit: {
+ browseModpacks: () => void
+ create: (config: CreationFlowContextValue) => void
+ },
+ options: CreationFlowOptions = {},
+): CreationFlowContextValue {
+ const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt']
+ const showSnapshotToggle = options.showSnapshotToggle ?? false
+ const disableClose = options.disableClose ?? false
+ const isInitialSetup = options.isInitialSetup ?? false
+ const initialLoader = options.initialLoader ?? null
+ const initialGameVersion = options.initialGameVersion ?? null
+ const onBack = options.onBack ?? null
+
+ const setupType = ref(null)
+ const isImportMode = ref(false)
+ const worldName = ref('')
+ const gamemode = ref('survival')
+ const difficulty = ref('normal')
+ const worldSeed = ref('')
+ const worldTypeOption = ref('minecraft:normal')
+ const generateStructures = ref(true)
+ const generatorSettingsMode = ref('default')
+ const generatorSettingsCustom = ref('')
+
+ // Instance-specific state
+ const instanceName = ref('')
+ const instanceIcon = ref(null)
+ const instanceIconUrl = ref(null)
+ const instanceIconPath = ref(null)
+
+ // Revoke old object URL when icon is cleared to avoid memory leaks
+ watch(instanceIconUrl, (_newUrl, oldUrl) => {
+ if (oldUrl && oldUrl.startsWith('blob:')) {
+ URL.revokeObjectURL(oldUrl)
+ }
+ })
+
+ const selectedLoader = ref(null)
+ const selectedGameVersion = ref(null)
+ const loaderVersionType = ref('stable')
+ const selectedLoaderVersion = ref(null)
+ const showSnapshots = ref(false)
+
+ const modpackSelection = ref(null)
+ const modpackFile = ref(null)
+ const modpackFilePath = ref(null)
+
+ // Modpack search state (persisted across stage navigation)
+ const modpackSearchProjectId = ref()
+ const modpackSearchVersionId = ref()
+ const modpackSearchOptions = ref[]>([])
+ const modpackVersionOptions = ref[]>([])
+ const modpackSearchHits = ref>({})
+
+ // Import state (instance flow only)
+ const importLaunchers = ref([])
+ const importSelectedInstances = ref>>({})
+ const importSearchQuery = ref('')
+
+ const hardReset = ref(isInitialSetup)
+ const loading = ref(false)
+
+ // hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows)
+ const hideLoaderChips = computed(() => setupType.value === 'vanilla')
+
+ // hideLoaderVersion: hides the loader version section (vanilla world type OR vanilla selected as loader chip)
+ const hideLoaderVersion = computed(
+ () => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla',
+ )
+
+ function reset() {
+ setupType.value = null
+ isImportMode.value = false
+ worldCounter++
+ worldName.value = flowType === 'world' ? `World ${worldCounter}` : ''
+ gamemode.value = 'survival'
+ difficulty.value = 'normal'
+ worldSeed.value = ''
+ worldTypeOption.value = 'minecraft:normal'
+ generateStructures.value = true
+ generatorSettingsMode.value = 'default'
+ generatorSettingsCustom.value = ''
+
+ // Instance-specific
+ instanceName.value = ''
+ instanceIconUrl.value = null
+ instanceIcon.value = null
+ instanceIconPath.value = null
+
+ selectedLoader.value = null
+ selectedGameVersion.value = null
+ loaderVersionType.value = 'stable'
+ selectedLoaderVersion.value = null
+ showSnapshots.value = false
+ modpackSelection.value = null
+ modpackFile.value = null
+ modpackFilePath.value = null
+ modpackSearchProjectId.value = undefined
+ modpackSearchVersionId.value = undefined
+ modpackSearchOptions.value = []
+ modpackVersionOptions.value = []
+ modpackSearchHits.value = {}
+
+ // Import state
+ importLaunchers.value = []
+ importSelectedInstances.value = {}
+ importSearchQuery.value = ''
+
+ hardReset.value = isInitialSetup
+ loading.value = false
+ }
+
+ function setSetupType(type: SetupType) {
+ isImportMode.value = false
+ setupType.value = type
+ if (type === 'modpack') {
+ modal.value?.setStage('modpack')
+ } else {
+ // both custom and vanilla go to custom-setup
+ // vanilla just hides loader chips via hideLoaderChips computed
+ modal.value?.setStage('custom-setup')
+ }
+ }
+
+ function setImportMode() {
+ isImportMode.value = true
+ setupType.value = null
+ modal.value?.setStage('import-instance')
+ }
+
+ function browseModpacks() {
+ modal.value?.hide()
+ emit.browseModpacks()
+ }
+
+ function finish() {
+ loading.value = true
+ emit.create(contextValue)
+ }
+
+ const resolvedStageConfigs = disableClose
+ ? stageConfigs.map((stage) => ({ ...stage, disableClose: true }))
+ : stageConfigs
+
+ const contextValue: CreationFlowContextValue = {
+ flowType,
+ availableLoaders,
+ showSnapshotToggle,
+ disableClose,
+ isInitialSetup,
+ initialLoader,
+ initialGameVersion,
+ setupType,
+ isImportMode,
+ worldName,
+ gamemode,
+ difficulty,
+ worldSeed,
+ worldTypeOption,
+ generateStructures,
+ generatorSettingsMode,
+ generatorSettingsCustom,
+ instanceName,
+ instanceIcon,
+ instanceIconUrl,
+ instanceIconPath,
+ selectedLoader,
+ selectedGameVersion,
+ loaderVersionType,
+ selectedLoaderVersion,
+ hideLoaderChips,
+ hideLoaderVersion,
+ showSnapshots,
+ modpackSelection,
+ modpackFile,
+ modpackFilePath,
+ modpackSearchProjectId,
+ modpackSearchVersionId,
+ modpackSearchOptions,
+ modpackVersionOptions,
+ modpackSearchHits,
+ importLaunchers,
+ importSelectedInstances,
+ importSearchQuery,
+ hardReset,
+ loading,
+ modal,
+ stageConfigs: resolvedStageConfigs,
+ onBack,
+ reset,
+ setSetupType,
+ setImportMode,
+ browseModpacks,
+ finish,
+ }
+
+ return contextValue
+}
diff --git a/packages/ui/src/components/flows/creation-flow-modal/index.vue b/packages/ui/src/components/flows/creation-flow-modal/index.vue
new file mode 100644
index 0000000000..c1f095143b
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/index.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
diff --git a/packages/ui/src/components/flows/creation-flow-modal/shared.ts b/packages/ui/src/components/flows/creation-flow-modal/shared.ts
new file mode 100644
index 0000000000..73f69bf0c8
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/shared.ts
@@ -0,0 +1,14 @@
+export const loaderDisplayNames: Record = {
+ fabric: 'Fabric',
+ neoforge: 'NeoForge',
+ forge: 'Forge',
+ quilt: 'Quilt',
+ paper: 'Paper',
+ purpur: 'Purpur',
+ vanilla: 'Vanilla',
+}
+
+export const formatLoaderLabel = (item: string) =>
+ loaderDisplayNames[item] ?? item.charAt(0).toUpperCase() + item.slice(1)
+
+export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1)
diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/confirm-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/confirm-stage.ts
new file mode 100644
index 0000000000..095c478953
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/confirm-stage.ts
@@ -0,0 +1,30 @@
+import { DownloadIcon, LeftArrowIcon, TrashIcon } from '@modrinth/assets'
+import { markRaw } from 'vue'
+
+import type { StageConfigInput } from '../../../base'
+import ConfirmStage from '../components/ConfirmStage.vue'
+import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
+
+export const stageConfig: StageConfigInput = {
+ id: 'confirm',
+ title: (ctx) => flowTypeHeadings[ctx.flowType],
+ stageContent: markRaw(ConfirmStage),
+ skip: () => true,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.prevStage(),
+ }),
+ rightButtonConfig: (ctx) => {
+ const isErase = ctx.hardReset.value && !ctx.isInitialSetup
+ return {
+ label: isErase ? 'Erase and install' : 'Install',
+ icon: isErase ? TrashIcon : DownloadIcon,
+ iconPosition: 'before' as const,
+ color: isErase ? ('red' as const) : ('brand' as const),
+ loading: ctx.loading.value,
+ onClick: () => ctx.finish(),
+ }
+ },
+ maxWidth: '520px',
+}
diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts
new file mode 100644
index 0000000000..6650a886af
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts
@@ -0,0 +1,67 @@
+import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
+import { markRaw } from 'vue'
+
+import type { StageConfigInput } from '../../../base'
+import CustomSetupStage from '../components/CustomSetupStage.vue'
+import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
+
+function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
+ if (!ctx.selectedGameVersion.value) return true
+ if (!ctx.hideLoaderChips.value && !ctx.selectedLoader.value) return true
+ if (
+ !ctx.hideLoaderVersion.value &&
+ ctx.loaderVersionType.value === 'other' &&
+ !ctx.selectedLoaderVersion.value
+ )
+ return true
+ return false
+}
+
+export const stageConfig: StageConfigInput = {
+ id: 'custom-setup',
+ title: (ctx) => flowTypeHeadings[ctx.flowType],
+ stageContent: markRaw(CustomSetupStage),
+ skip: (ctx) =>
+ ctx.setupType.value === 'modpack' ||
+ ctx.setupType.value === 'vanilla' ||
+ ctx.isImportMode.value,
+ cannotNavigateForward: isForwardBlocked,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.setStage('setup-type'),
+ }),
+ rightButtonConfig: (ctx) => {
+ const isInstance = ctx.flowType === 'instance'
+ const goesToNextStage = ctx.flowType === 'world' || ctx.flowType === 'server-onboarding'
+ const disabled = isForwardBlocked(ctx)
+
+ if (isInstance) {
+ return {
+ label: 'Create instance',
+ icon: PlusIcon,
+ iconPosition: 'before' as const,
+ color: 'brand' as const,
+ disabled,
+ loading: ctx.loading.value,
+ onClick: () => ctx.finish(),
+ }
+ }
+
+ return {
+ label: goesToNextStage ? 'Continue' : 'Finish',
+ icon: goesToNextStage ? RightArrowIcon : null,
+ iconPosition: 'after' as const,
+ color: goesToNextStage ? undefined : ('brand' as const),
+ disabled,
+ onClick: () => {
+ if (goesToNextStage) {
+ ctx.modal.value?.nextStage()
+ } else {
+ ctx.finish()
+ }
+ },
+ }
+ },
+ maxWidth: '520px',
+}
diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts
new file mode 100644
index 0000000000..536b80bdde
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts
@@ -0,0 +1,52 @@
+import { DownloadIcon, LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
+import { markRaw } from 'vue'
+
+import type { StageConfigInput } from '../../../base'
+import FinalConfigStage from '../components/FinalConfigStage.vue'
+import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
+
+function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
+ if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true
+ if (ctx.setupType.value === 'vanilla' && !ctx.selectedGameVersion.value) return true
+ return false
+}
+
+export const stageConfig: StageConfigInput = {
+ id: 'final-config',
+ title: (ctx) => flowTypeHeadings[ctx.flowType],
+ stageContent: markRaw(FinalConfigStage),
+ skip: (ctx) => ctx.flowType === 'instance' || ctx.isImportMode.value,
+ cannotNavigateForward: isForwardBlocked,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => {
+ if (ctx.onBack) {
+ ctx.onBack()
+ } else {
+ ctx.modal.value?.prevStage()
+ }
+ },
+ }),
+ rightButtonConfig: (ctx) => {
+ const isWorld = ctx.flowType === 'world'
+ const isOnboarding = ctx.flowType === 'server-onboarding'
+ const isFinish = isWorld || isOnboarding
+ return {
+ label: isWorld ? 'Create world' : isOnboarding ? 'Setup server' : 'Continue',
+ icon: isFinish ? PlusIcon : RightArrowIcon,
+ iconPosition: isFinish ? ('before' as const) : ('after' as const),
+ color: isFinish ? ('brand' as const) : undefined,
+ disabled: isForwardBlocked(ctx),
+ loading: isFinish && ctx.loading.value,
+ onClick: () => {
+ if (isFinish) {
+ ctx.finish()
+ } else {
+ ctx.modal.value?.nextStage()
+ }
+ },
+ }
+ },
+ maxWidth: '520px',
+}
diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts
new file mode 100644
index 0000000000..7d6b94399c
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts
@@ -0,0 +1,41 @@
+import { DownloadIcon, LeftArrowIcon } from '@modrinth/assets'
+import { markRaw } from 'vue'
+
+import type { StageConfigInput } from '../../../base'
+import ImportInstanceStage from '../components/ImportInstanceStage.vue'
+import type { CreationFlowContextValue } from '../creation-flow-context'
+
+function getSelectedCount(ctx: CreationFlowContextValue): number {
+ let count = 0
+ for (const set of Object.values(ctx.importSelectedInstances.value)) {
+ count += set.size
+ }
+ return count
+}
+
+export const stageConfig: StageConfigInput = {
+ id: 'import-instance',
+ title: 'Import instance',
+ stageContent: markRaw(ImportInstanceStage),
+ skip: (ctx) => !ctx.isImportMode.value,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => {
+ ctx.isImportMode.value = false
+ ctx.modal.value?.setStage('setup-type')
+ },
+ }),
+ rightButtonConfig: (ctx) => {
+ const count = getSelectedCount(ctx)
+ return {
+ label: count > 0 ? `Import ${count} instance${count !== 1 ? 's' : ''}` : 'Import',
+ icon: DownloadIcon,
+ iconPosition: 'before' as const,
+ color: 'brand' as const,
+ disabled: count === 0,
+ onClick: () => ctx.finish(),
+ }
+ },
+ maxWidth: '520px',
+}
diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts
new file mode 100644
index 0000000000..ad44962b9d
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts
@@ -0,0 +1,17 @@
+import type { StageConfigInput } from '../../../base'
+import type { CreationFlowContextValue } from '../creation-flow-context'
+import { stageConfig as confirmStageConfig } from './confirm-stage'
+import { stageConfig as customSetupStageConfig } from './custom-setup-stage'
+import { stageConfig as finalConfigStageConfig } from './final-config-stage'
+import { stageConfig as importInstanceStageConfig } from './import-instance-stage'
+import { stageConfig as modpackStageConfig } from './modpack-stage'
+import { stageConfig as setupTypeStageConfig } from './setup-type-stage'
+
+export const stageConfigs: StageConfigInput[] = [
+ setupTypeStageConfig,
+ modpackStageConfig,
+ importInstanceStageConfig,
+ customSetupStageConfig,
+ finalConfigStageConfig,
+ confirmStageConfig,
+]
diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts
new file mode 100644
index 0000000000..1e83daa86d
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts
@@ -0,0 +1,20 @@
+import { LeftArrowIcon } from '@modrinth/assets'
+import { markRaw } from 'vue'
+
+import type { StageConfigInput } from '../../../base'
+import ModpackStage from '../components/ModpackStage.vue'
+import type { CreationFlowContextValue } from '../creation-flow-context'
+
+export const stageConfig: StageConfigInput = {
+ id: 'modpack',
+ title: 'Choose modpack',
+ stageContent: markRaw(ModpackStage),
+ skip: (ctx) => ctx.setupType.value !== 'modpack' || ctx.isImportMode.value,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.setStage('setup-type'),
+ }),
+ rightButtonConfig: null,
+ maxWidth: '520px',
+}
diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts
new file mode 100644
index 0000000000..9d3db57914
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts
@@ -0,0 +1,14 @@
+import { markRaw } from 'vue'
+
+import type { StageConfigInput } from '../../../base'
+import SetupTypeStage from '../components/SetupTypeStage.vue'
+import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
+
+export const stageConfig: StageConfigInput = {
+ id: 'setup-type',
+ title: (ctx) => flowTypeHeadings[ctx.flowType],
+ stageContent: markRaw(SetupTypeStage),
+ leftButtonConfig: null,
+ rightButtonConfig: null,
+ maxWidth: '520px',
+}
diff --git a/packages/ui/src/components/instances/ContentCardItem.vue b/packages/ui/src/components/instances/ContentCardItem.vue
index 078166a704..1da41af28d 100644
--- a/packages/ui/src/components/instances/ContentCardItem.vue
+++ b/packages/ui/src/components/instances/ContentCardItem.vue
@@ -1,6 +1,6 @@
-
-
-
-
+
-
-
- {{ project.title }}
-
+
+
+
+
+ {{ project.title }}
+
+
+
+
+
+
+ {{ owner.name }}
+
+
+
+
+ {{ version.version_number }}
+
+
+
+
+
+
+
+
+
-
-
- {{ owner.name }}
+ {{
+ version.version_number.slice(0, Math.ceil(version.version_number.length / 2))
+ }}
+ {{
+ version.version_number.slice(Math.ceil(version.version_number.length / 2))
+ }}
-
-
-
-
-
- {{ version.version_number }}
- {{ truncateMiddle(version.file_name, MAX_FILENAME_LENGTH) }}
+ {{
+ version.file_name.slice(0, Math.ceil(version.file_name.length / 2))
+ }}
+ {{
+ version.file_name.slice(Math.ceil(version.file_name.length / 2))
+ }}
-
+
@@ -169,10 +213,11 @@ function truncateMiddle(str: string, maxLength: number): string {
:model-value="enabled"
:disabled="disabled"
small
+ class="mr-2 my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"
/>
-
+
-import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
+import { computed, getCurrentInstance, ref, toRef } from 'vue'
import { useVIntl } from '../../composables/i18n'
+import { useStickyObserver } from '../../composables/sticky-observer'
+import { useVirtualScroll } from '../../composables/virtual-scroll'
import { commonMessages } from '../../utils/common-messages'
import Checkbox from '../base/Checkbox.vue'
import ContentCardItem from './ContentCardItem.vue'
-import type { ContentCardTableItem } from './types'
+import type {
+ ContentCardTableItem,
+ ContentCardTableSortColumn,
+ ContentCardTableSortDirection,
+} from './types'
const { formatMessage } = useVIntl()
-const BUFFER_SIZE = 5
-
interface Props {
items: ContentCardTableItem[]
showSelection?: boolean
+ sortable?: boolean
+ sortBy?: ContentCardTableSortColumn
+ sortDirection?: ContentCardTableSortDirection
virtualized?: boolean
+ hideDelete?: boolean
+ hideHeader?: boolean
+ flat?: boolean
}
const props = withDefaults(defineProps(), {
showSelection: false,
+ sortable: false,
+ sortBy: undefined,
+ sortDirection: 'asc',
virtualized: true,
+ hideDelete: false,
+ hideHeader: false,
+ flat: false,
})
+const stickyHeaderRef = ref(null)
+const { isStuck } = useStickyObserver(stickyHeaderRef, 'ContentCardTable')
+
const selectedIds = defineModel('selectedIds', { default: () => [] })
const emit = defineEmits<{
'update:enabled': [id: string, value: boolean]
delete: [id: string]
update: [id: string]
+ sort: [column: ContentCardTableSortColumn, direction: ContentCardTableSortDirection]
}>()
-// Virtualization state
-const listContainer = ref(null)
-const scrollContainer = ref(null)
-const scrollTop = ref(0)
-const viewportHeight = ref(0)
-const itemHeight = 74
-
-const totalHeight = computed(() => props.items.length * itemHeight)
-
-// Find the nearest scrollable ancestor
-function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
- if (!element) return window
-
- let current: HTMLElement | null = element.parentElement
- while (current) {
- const style = getComputedStyle(current)
- const overflowY = style.overflowY
- const isScrollable =
- (overflowY === 'auto' || overflowY === 'scroll') &&
- current.scrollHeight > current.clientHeight
-
- if (isScrollable) {
- return current
- }
- current = current.parentElement
- }
- return window
-}
-
-function getScrollTop(container: HTMLElement | Window): number {
- if (container instanceof Window) {
- return window.scrollY
- }
- return container.scrollTop
-}
-
-function getViewportHeight(container: HTMLElement | Window): number {
- if (container instanceof Window) {
- return window.innerHeight
- }
- return container.clientHeight
-}
-
-function getContainerOffset(listEl: HTMLElement, container: HTMLElement | Window): number {
- if (container instanceof Window) {
- return listEl.getBoundingClientRect().top + window.scrollY
- }
- // For element containers, get the offset relative to the scroll container
- const listRect = listEl.getBoundingClientRect()
- const containerRect = container.getBoundingClientRect()
- return listRect.top - containerRect.top + container.scrollTop
-}
-
-const visibleRange = computed(() => {
- if (!props.virtualized) {
- return { start: 0, end: props.items.length }
- }
-
- if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 }
-
- const containerOffset = getContainerOffset(listContainer.value, scrollContainer.value)
- const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset)
-
- const start = Math.floor(relativeScrollTop / itemHeight)
- const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
+// Check if any actions are available
+const instance = getCurrentInstance()
+const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
+const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
+const hasEnabledListener = computed(
+ () => typeof instance?.vnode.props?.['onUpdate:enabled'] === 'function',
+)
- return {
- start: Math.max(0, start - BUFFER_SIZE),
- end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
- }
+const hasAnyActions = computed(() => {
+ // Check if there are listeners for actions
+ const hasListeners =
+ (hasDeleteListener.value && !props.hideDelete) ||
+ hasUpdateListener.value ||
+ hasEnabledListener.value
+
+ // Check if any items have overflow options or updates
+ const hasItemActions = props.items.some(
+ (item) =>
+ (item.overflowOptions && item.overflowOptions.length > 0) ||
+ item.hasUpdate ||
+ item.enabled !== undefined,
+ )
+
+ return hasListeners || hasItemActions
})
-const visibleTop = computed(() => (props.virtualized ? visibleRange.value.start * itemHeight : 0))
-
-const visibleItems = computed(() =>
- props.items.slice(visibleRange.value.start, visibleRange.value.end),
+// Virtualization
+const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
+ toRef(props, 'items'),
+ {
+ itemHeight: 74,
+ bufferSize: 5,
+ enabled: toRef(props, 'virtualized'),
+ },
)
// Expose for perf monitoring
@@ -114,34 +93,6 @@ defineExpose({
visibleItems,
})
-function handleScroll() {
- if (scrollContainer.value) {
- scrollTop.value = getScrollTop(scrollContainer.value)
- }
-}
-
-function handleResize() {
- if (scrollContainer.value) {
- viewportHeight.value = getViewportHeight(scrollContainer.value)
- }
-}
-
-onMounted(() => {
- scrollContainer.value = findScrollableAncestor(listContainer.value)
- viewportHeight.value = getViewportHeight(scrollContainer.value)
- scrollTop.value = getScrollTop(scrollContainer.value)
-
- scrollContainer.value.addEventListener('scroll', handleScroll, { passive: true })
- window.addEventListener('resize', handleResize, { passive: true })
-})
-
-onUnmounted(() => {
- if (scrollContainer.value) {
- scrollContainer.value.removeEventListener('scroll', handleScroll)
- }
- window.removeEventListener('resize', handleResize)
-})
-
// Selection logic
const allSelected = computed(() => {
if (props.items.length === 0) return false
@@ -153,7 +104,7 @@ const someSelected = computed(() => {
})
function toggleSelectAll() {
- if (allSelected.value) {
+ if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = props.items.map((item) => item.id)
@@ -173,35 +124,85 @@ function toggleItemSelection(itemId: string, selected: boolean) {
function isItemSelected(itemId: string): boolean {
return selectedIds.value.includes(itemId)
}
+
+function handleSort(column: ContentCardTableSortColumn) {
+ if (!props.sortable) return
+
+ const newDirection: ContentCardTableSortDirection =
+ props.sortBy === column && props.sortDirection === 'asc' ? 'desc' : 'asc'
+
+ emit('sort', column, newDirection)
+}
-
+
-
-
-
- {{ formatMessage(commonMessages.projectLabel) }}
-
+
+
+
+
+ {{ formatMessage(commonMessages.projectLabel) }}
+
+
+
+ {{
+ formatMessage(commonMessages.projectLabel)
+ }}
+
-
- {{ formatMessage(commonMessages.versionLabel) }}
-
+
+
+ {{ formatMessage(commonMessages.versionLabel) }}
+
+
+
+ {{
+ formatMessage(commonMessages.versionLabel)
+ }}
+
-
+
{{
formatMessage(commonMessages.actionsLabel)
}}
@@ -211,7 +212,8 @@ function isItemSelected(itemId: string): boolean {
@@ -222,17 +224,20 @@ function isItemSelected(itemId: string): boolean {
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
+ :version-link="item.versionLink"
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
+ :hide-delete="hideDelete"
+ :hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
(visibleRange.start + idx) % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2',
- 'border-t border-solid border-[1px] border-surface-3',
- visibleRange.start + idx === items.length - 1 ? 'rounded-b-[20px] !border-none' : '',
+ 'border-0 border-t border-solid border-surface-4',
+ visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@@ -249,7 +254,7 @@ function isItemSelected(itemId: string): boolean {
-
+
toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@@ -283,7 +291,11 @@ function isItemSelected(itemId: string): boolean {
-
+
{{ formatMessage(commonMessages.noItemsLabel) }}
diff --git a/packages/ui/src/components/instances/ContentModpackCard.vue b/packages/ui/src/components/instances/ContentModpackCard.vue
index 36373f6202..1cf55b58a6 100644
--- a/packages/ui/src/components/instances/ContentModpackCard.vue
+++ b/packages/ui/src/components/instances/ContentModpackCard.vue
@@ -1,11 +1,12 @@
-
-
-
+
+
+
+
+
{{ project.title }}
-
-
-
-
-
- {{ owner.name }}
-
-
-
-
-
+
+
+
+ {{ owner.name }}
+
- v{{ version.version_number }}
+
+
+ v{{ version.version_number }}
+
-
+
{{ formatTimeAgo(new Date(version.date_published)) }}
@@ -127,34 +153,86 @@ const formatCompact = (n: number | undefined) => {
-
-
-
- {{ formatMessage(commonMessages.updateButton) }}
-
-
-
-
-
- {{ formatMessage(commonMessages.contentLabel) }}
-
-
-
-
-
+
+
+ {{
+ disabledText ?? formatMessage(messages.updating)
+ }}
+
+
+
+
+
+
+
+
+
+ {{
+ formatMessage(
+ hasUpdate ? commonMessages.updateButton : commonMessages.switchVersionButton,
+ )
+ }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.contentLabel) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.switchVersionButton) }}
+
+
+
+ {{ formatMessage(commonMessages.contentLabel) }}
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -174,9 +252,14 @@ const formatCompact = (n: number | undefined) => {
diff --git a/packages/ui/src/components/instances/ContentPageLayout.vue b/packages/ui/src/components/instances/ContentPageLayout.vue
new file mode 100644
index 0000000000..611a5eb566
--- /dev/null
+++ b/packages/ui/src/components/instances/ContentPageLayout.vue
@@ -0,0 +1,812 @@
+
+
+
+
+
+
+ {{ formatMessage(messages.loadingContent) }}
+
+
+
+
+
{{ formatMessage(messages.failedToLoad) }}
+
{{ ctx.error.value.message }}
+
+ {{ formatMessage(commonMessages.retryButton) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ formatMessage(messages.uploadingFiles, {
+ completed: ctx.uploadState?.value?.completedFiles ?? 0,
+ total: ctx.uploadState?.value?.totalFiles ?? 0,
+ })
+ }}
+
+ {{ ctx.uploadState?.value?.currentFileName }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.additionalContent) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.browseContent) }}
+
+
+
+
+
+ {{ formatMessage(messages.uploadFiles) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.allProjectType) }}
+
+
+ {{ option.label }}
+
+
+
+
+
+
+ {{ sortLabels[sortMode]() }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.updateAll) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.refreshButton) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.noContentFound) }}
+
+
+
+
+
+
+
+ {{
+ formatMessage(
+ ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
+ )
+ }}
+
+
+ {{
+ ctx.modpack.value
+ ? formatMessage(messages.emptyModpackHint)
+ : formatMessage(messages.emptyHint, {
+ contentType: `${ctx.contentTypeLabel.value}s`,
+ })
+ }}
+
+
+
+
+
+ {{ formatMessage(messages.uploadFiles) }}
+
+
+
+
+
+ {{ formatMessage(messages.browseContent) }}
+
+
+
+
+
+
+
+
+
+
+ {{
+ formatMessage(messages.selectedCount, {
+ count: selectedItems.length,
+ contentType: `${ctx.contentTypeLabel.value}${selectedItems.length === 1 ? '' : 's'}`,
+ })
+ }}
+
+
+
+
+ {{ formatMessage(commonMessages.clearButton) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.updateButton) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.share) }}
+
+
+
+ {{ formatMessage(messages.shareProjectNames) }}
+
+
+
+ {{ formatMessage(messages.shareFileNames) }}
+
+
+
+ {{ formatMessage(messages.shareProjectLinks) }}
+
+
+
+ {{ formatMessage(messages.shareMarkdownLinks) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.enable) }}
+
+
+
+
+
+ {{ formatMessage(messages.disable) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
+
+
+
+
+
+
+
+
+ {{
+ formatMessage(
+ bulkOperation === 'enable'
+ ? messages.bulkEnablingWaiting
+ : bulkOperation === 'disable'
+ ? messages.bulkDisablingWaiting
+ : bulkOperation === 'update'
+ ? messages.bulkUpdatingWaiting
+ : messages.bulkDeletingWaiting,
+ )
+ }}
+
+
+ {{
+ formatMessage(
+ bulkOperation === 'enable'
+ ? messages.bulkEnabling
+ : bulkOperation === 'disable'
+ ? messages.bulkDisabling
+ : bulkOperation === 'update'
+ ? messages.bulkUpdating
+ : messages.bulkDeleting,
+ { progress: bulkProgress, total: bulkTotal },
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/instances/InstallationSettingsLayout.vue b/packages/ui/src/components/instances/InstallationSettingsLayout.vue
new file mode 100644
index 0000000000..37eae92bc0
--- /dev/null
+++ b/packages/ui/src/components/instances/InstallationSettingsLayout.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+ {{ formatMessage(messages.installedModpack) }}
+
+
+
+
+
+
+
+ {{ ctx.modpack.value.title }}
+
+
+
+
+ {{ ctx.modpack.value.owner.name }}
+
+
+
+ {{ ctx.modpack.value.versionName }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.changeVersion) }}
+
+
+
+
+
+ {{ formatMessage(messages.unlink) }}
+
+
+
+
+
+
+
+ {{ action.loading && action.loadingLabel ? action.loadingLabel : action.label }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.installationInfo) }}
+
+
+
+ {{ row.label }}
+ {{ row.value }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.installationInfo) }}
+
+
+
+
+ {{ formatMessage(messages.platform) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.gameVersion) }}
+
+
+
+
+
+
+ {{
+ ctx.showSnapshots.value
+ ? formatMessage(messages.hideSnapshots)
+ : formatMessage(messages.showAllVersions)
+ }}
+
+
+
+
+
+
+
+ {{
+ formatMessage(messages.loaderVersion, {
+ loader: ctx.formattedLoaderName?.value,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/instances/index.ts b/packages/ui/src/components/instances/index.ts
index fd997a6865..1a568fc9b0 100644
--- a/packages/ui/src/components/instances/index.ts
+++ b/packages/ui/src/components/instances/index.ts
@@ -1,16 +1,28 @@
export { default as ContentCardItem } from './ContentCardItem.vue'
export { default as ContentCardTable } from './ContentCardTable.vue'
+export { default as ContentPageLayout } from './ContentPageLayout.vue'
+export { default as ConfirmBulkUpdateModal } from './modals/ConfirmBulkUpdateModal.vue'
+export { default as ConfirmDeletionModal } from './modals/ConfirmDeletionModal.vue'
+export { default as ConfirmReinstallModal } from './modals/ConfirmReinstallModal.vue'
+export { default as ConfirmUnlinkModal } from './modals/ConfirmUnlinkModal.vue'
/**
* @deprecated Use `ContentCardTable` with `ContentCardItem` instead.
* This alias is kept for backwards compatibility and will be removed in a future version.
*/
+export type { ContentModpackData } from '../../providers/content-manager'
export { default as ContentCard } from './ContentCardItem.vue'
export { default as ContentModpackCard } from './ContentModpackCard.vue'
-// export { default as ContentUpdaterModal } from './modals/ContentUpdaterModal.vue'
+export { default as InstallationSettingsLayout } from './InstallationSettingsLayout.vue'
+export { default as ContentUpdaterModal } from './modals/ContentUpdaterModal.vue'
+export type { ModpackContentModalState } from './modals/ModpackContentModal.vue'
+export { default as ModpackContentModal } from './modals/ModpackContentModal.vue'
export type {
ContentCardProject,
ContentCardTableItem,
+ ContentCardTableSortColumn,
+ ContentCardTableSortDirection,
ContentCardVersion,
+ ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
diff --git a/packages/ui/src/components/instances/modals/ConfirmBulkUpdateModal.vue b/packages/ui/src/components/instances/modals/ConfirmBulkUpdateModal.vue
new file mode 100644
index 0000000000..6b2758456a
--- /dev/null
+++ b/packages/ui/src/components/instances/modals/ConfirmBulkUpdateModal.vue
@@ -0,0 +1,88 @@
+
+
+
+
+ {{ formatMessage(messages.admonitionBody, { count }) }}
+
+
{{ formatMessage(messages.warningBody) }}
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.updateButton, { count }) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue b/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue
new file mode 100644
index 0000000000..d9e2ec21e3
--- /dev/null
+++ b/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue
@@ -0,0 +1,147 @@
+
+
+
+
+ {{
+ formatMessage(
+ variant === 'server' ? messages.admonitionBodyServer : messages.admonitionBody,
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.deleteButton, { count, itemType }) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/instances/modals/ConfirmReinstallModal.vue b/packages/ui/src/components/instances/modals/ConfirmReinstallModal.vue
new file mode 100644
index 0000000000..9d76a8e2aa
--- /dev/null
+++ b/packages/ui/src/components/instances/modals/ConfirmReinstallModal.vue
@@ -0,0 +1,86 @@
+
+
+
+
+ {{ formatMessage(messages.admonitionBody) }}
+
+
+ {{ formatMessage(messages.warningBody) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.reinstallButton) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/instances/modals/ConfirmUnlinkModal.vue b/packages/ui/src/components/instances/modals/ConfirmUnlinkModal.vue
new file mode 100644
index 0000000000..6541a1aefe
--- /dev/null
+++ b/packages/ui/src/components/instances/modals/ConfirmUnlinkModal.vue
@@ -0,0 +1,99 @@
+
+
+
+
+ {{ formatMessage(server ? messages.serverAdmonitionBody : messages.admonitionBody) }}
+
+
+ {{ formatMessage(server ? messages.serverWarningBody : messages.warningBody) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(server ? messages.header : messages.unlinkButton) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue b/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
index d736cb8266..3ec24e673c 100644
--- a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
+++ b/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
@@ -1,12 +1,22 @@
-
+
-
+
-
-
-
-
- v{{ version.version_number }}
-
-
- {{ getBadgeLabel(version) }}
-
-
-
-
-
- {{ formatMessage(messages.noVersionsFound) }}
+
+
+ {{
+ formatMessage(messages.loadingVersions)
+ }}
+
+
+
+
+
+
+
+ {{ version.version_number }}
+
+
+
+
+ {{ getBadgeLabel(version) }}
+
+
+
+
+
+ {{ formatMessage(messages.noVersionsFound) }}
+
+
-
-
-
+
+
-
+
- v{{ selectedVersion.version_number }}
+ {{ selectedVersion.version_number }}
- {{ getBadgeLabel(selectedVersion) }}
+ {{ capitalizeString(selectedVersion.version_type) }}
@@ -114,7 +153,16 @@
+
+ {{
+ formatMessage(messages.loadingChangelog)
+ }}
+
+
@@ -134,34 +182,35 @@
-
- {{
- formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
- }}
-
-
-
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
- {{
- formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, {
- version: selectedVersion?.version_number ?? '...',
- })
- }}
-
-
+
+
+ {{
+ formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
+ }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{
+ formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, {
+ version: selectedVersion?.version_number ?? '...',
+ })
+ }}
+
+
+
@@ -169,16 +218,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.allFilter) }}
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+
+
+
+ {{ formatMessage(messages.emptyTitle) }}
+
+ {{ formatMessage(messages.emptyDescription) }}
+
+
+
+
+ {{ formatMessage(messages.noResults) }}
+
+
+
+
+
+
+
+ {{
+ formatMessage(commonMessages.projectLabel)
+ }}
+
+
+ {{
+ formatMessage(commonMessages.versionLabel)
+ }}
+
+
+ {{
+ formatMessage(commonMessages.actionsLabel)
+ }}
+
+
+
+ handleEnabledChange(id, val)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ count }} {{ formatProjectType(type as string) }}{{ count !== 1 ? 's' : '' }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.selectedCount, { count: selectedItems.length }) }}
+
+
+
+
+ {{ formatMessage(commonMessages.clearButton) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.enable) }}
+
+
+
+
+
+ {{ formatMessage(messages.disable) }}
+
+
+
+
+
+
diff --git a/packages/ui/src/components/instances/types.ts b/packages/ui/src/components/instances/types.ts
index 95a44d7e98..540d8b4ced 100644
--- a/packages/ui/src/components/instances/types.ts
+++ b/packages/ui/src/components/instances/types.ts
@@ -10,6 +10,7 @@ export type ContentCardProject = Pick<
export type ContentCardVersion = Pick & {
file_name: string
+ date_published?: string
}
export interface ContentOwner {
@@ -17,7 +18,7 @@ export interface ContentOwner {
name: string
avatar_url?: string
type: 'user' | 'organization'
- link?: string | RouteLocationRaw
+ link?: string | RouteLocationRaw | (() => void)
}
export interface ContentCardTableItem {
@@ -25,6 +26,7 @@ export interface ContentCardTableItem {
project: ContentCardProject
projectLink?: string | RouteLocationRaw
version?: ContentCardVersion
+ versionLink?: string | RouteLocationRaw
owner?: ContentOwner
enabled?: boolean
disabled?: boolean
@@ -32,6 +34,24 @@ export interface ContentCardTableItem {
overflowOptions?: OverflowMenuOption[]
}
+export type ContentCardTableSortColumn = 'project' | 'version'
+export type ContentCardTableSortDirection = 'asc' | 'desc'
+
+/** Content item returned from the app backend API - maps to ContentCardTableItem for display */
+export interface ContentItem extends Omit<
+ ContentCardTableItem,
+ 'id' | 'projectLink' | 'disabled' | 'overflowOptions'
+> {
+ file_name: string
+ file_path?: string
+ hash?: string
+ size?: number
+ project_type: string
+ has_update: boolean
+ update_version_id: string | null
+ date_added?: string
+}
+
export type ContentModpackCardProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url' | 'description' | 'downloads' | 'followers'
diff --git a/packages/ui/src/components/modal/InstallToPlayModal.vue b/packages/ui/src/components/modal/InstallToPlayModal.vue
new file mode 100644
index 0000000000..972e396be2
--- /dev/null
+++ b/packages/ui/src/components/modal/InstallToPlayModal.vue
@@ -0,0 +1,111 @@
+
+
+
+
+ This server requires modded content to play. Accept to install the needed files from
+ Modrinth.
+
+
+
+
+
+ {{ sharedBy.name }}
+ shared this instance with you today.
+
+
+
+
+
Shared instance
+
+
+
+ {{ project.title }}
+
+ {{ loaderDisplay }} {{ project.game_versions?.[0] }}
+ · {{ modCount }} mods
+
+
+
+
+
+
+
+
+
+
+
+ Decline
+
+
+
+
+
+ Accept
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/modal/NewModal.vue b/packages/ui/src/components/modal/NewModal.vue
index 750cc7310a..6f3f366eb5 100644
--- a/packages/ui/src/components/modal/NewModal.vue
+++ b/packages/ui/src/components/modal/NewModal.vue
@@ -14,7 +14,7 @@
'modal-overlay',
{
shown: visible,
- noblur: props.noblur,
+ noblur: effectiveNoblur,
},
computedFade,
]"
@@ -38,7 +38,7 @@
>
-
@@ -64,14 +64,14 @@
@@ -91,14 +91,14 @@
@@ -113,7 +113,7 @@
You just lost the game.
-
@@ -124,11 +124,21 @@
+
+
diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue
index cc705dd27d..ef40ef9114 100644
--- a/packages/ui/src/components/servers/ServerListing.vue
+++ b/packages/ui/src/components/servers/ServerListing.vue
@@ -201,8 +201,8 @@ async function dataURLToBlob(dataURL: string): Promise
{
const { data: image } = useQuery({
queryKey: ['server-icon', props.server_id] as const,
- queryFn: async (): Promise => {
- if (!props.server_id || props.status !== 'available') return undefined
+ queryFn: async (): Promise => {
+ if (!props.server_id || props.status !== 'available') return null
try {
const auth = await archon.servers_v0.getFilesystemAuth(props.server_id)
@@ -242,8 +242,10 @@ const { data: image } = useQuery({
}
} catch (error) {
console.debug('Icon processing failed:', error)
- return undefined
+ return null
}
+
+ return null
},
enabled: computed(() => !!props.server_id && props.status === 'available'),
})
diff --git a/packages/ui/src/components/servers/ServerSetupModal.vue b/packages/ui/src/components/servers/ServerSetupModal.vue
new file mode 100644
index 0000000000..f3527f37b2
--- /dev/null
+++ b/packages/ui/src/components/servers/ServerSetupModal.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
Please don't close this page while uploading.
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/backups/BackupCreateModal.vue b/packages/ui/src/components/servers/backups/BackupCreateModal.vue
index e4f4dab7aa..2dd4fbd33b 100644
--- a/packages/ui/src/components/servers/backups/BackupCreateModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupCreateModal.vue
@@ -90,7 +90,7 @@ const props = defineProps<{
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const createMutation = useMutation({
- mutationFn: (name: string) => client.archon.backups_v0.create(ctx.serverId, { name }),
+ mutationFn: (name: string) => client.archon.backups_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
diff --git a/packages/ui/src/components/servers/backups/BackupRenameModal.vue b/packages/ui/src/components/servers/backups/BackupRenameModal.vue
index 8d3fdcc21e..8efe286aa7 100644
--- a/packages/ui/src/components/servers/backups/BackupRenameModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupRenameModal.vue
@@ -71,7 +71,7 @@ const backupsQueryKey = ['backups', 'list', ctx.serverId]
const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
- client.archon.backups_v0.rename(ctx.serverId, backupId, { name }),
+ client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
diff --git a/packages/ui/src/components/servers/backups/BackupRestoreModal.vue b/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
index b4f15562f5..82460fa8a9 100644
--- a/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
@@ -58,7 +58,7 @@ const ctx = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const restoreMutation = useMutation({
- mutationFn: (backupId: string) => client.archon.backups_v0.restore(ctx.serverId, backupId),
+ mutationFn: (backupId: string) => client.archon.backups_v1.restore(ctx.serverId, ctx.worldId.value!, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
diff --git a/apps/frontend/src/components/ui/servers/ContentVersionFilter.vue b/packages/ui/src/components/servers/content/ContentVersionFilter.vue
similarity index 88%
rename from apps/frontend/src/components/ui/servers/ContentVersionFilter.vue
rename to packages/ui/src/components/servers/content/ContentVersionFilter.vue
index f59b4549d5..e60cb5f9bb 100644
--- a/apps/frontend/src/components/ui/servers/ContentVersionFilter.vue
+++ b/packages/ui/src/components/servers/content/ContentVersionFilter.vue
@@ -57,13 +57,12 @@
-
-
diff --git a/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue b/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue
index 085e496629..dc7c3b53cd 100644
--- a/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue
+++ b/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue
@@ -47,8 +47,9 @@
diff --git a/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue b/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue
index 1757ed036b..5f21c7ef89 100644
--- a/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue
+++ b/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue
@@ -3,6 +3,7 @@
@@ -205,6 +149,7 @@ import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import ButtonStyled from '../../../components/base/ButtonStyled.vue'
+import EmptyState from '../../../components/base/EmptyState.vue'
import BackupCreateModal from '../../../components/servers/backups/BackupCreateModal.vue'
import BackupDeleteModal from '../../../components/servers/backups/BackupDeleteModal.vue'
import BackupItem from '../../../components/servers/backups/BackupItem.vue'
@@ -219,7 +164,7 @@ import {
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
-const { server, backupsState, markBackupCancelled } = injectModrinthServerContext()
+const { server, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext()
const props = defineProps<{
isServerRunning: boolean
@@ -238,11 +183,11 @@ const {
refetch,
} = useQuery({
queryKey: backupsQueryKey,
- queryFn: () => client.archon.backups_v0.list(serverId),
+ queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
})
const deleteMutation = useMutation({
- mutationFn: (backupId: string) => client.archon.backups_v0.delete(serverId, backupId),
+ mutationFn: (backupId: string) => client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
onSuccess: (_data, backupId) => {
markBackupCancelled(backupId)
backupsState.delete(backupId)
@@ -251,7 +196,7 @@ const deleteMutation = useMutation({
})
const retryMutation = useMutation({
- mutationFn: (backupId: string) => client.archon.backups_v0.retry(serverId, backupId),
+ mutationFn: (backupId: string) => client.archon.backups_v1.retry(serverId, worldId.value!, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
diff --git a/packages/ui/src/pages/hosting/manage/content.vue b/packages/ui/src/pages/hosting/manage/content.vue
new file mode 100644
index 0000000000..8af0e7a123
--- /dev/null
+++ b/packages/ui/src/pages/hosting/manage/content.vue
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/pages/hosting/manage/files.vue b/packages/ui/src/pages/hosting/manage/files.vue
index 941c197e1e..08812a4d12 100644
--- a/packages/ui/src/pages/hosting/manage/files.vue
+++ b/packages/ui/src/pages/hosting/manage/files.vue
@@ -46,8 +46,8 @@
/>
-
@@ -291,6 +291,7 @@ import {
FileUploadConflictModal,
FileUploadZipUrlModal,
} from '../../../components/servers/files/modals'
+import { useStickyObserver } from '../../../composables/sticky-observer'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -407,9 +408,9 @@ const uploadDropdownRef = ref>()
const VAceEditor = ref()
-const labelBarSentinel = ref()
-const isLabelBarStuck = ref(false)
-let labelBarObserver: IntersectionObserver | null = null
+const fileUploadRef = ref>()
+const fileUploadEl = computed(() => fileUploadRef.value?.$el as HTMLElement | null)
+const { isStuck: isLabelBarStuck } = useStickyObserver(fileUploadEl)
const viewFilter = ref('all')
@@ -1118,7 +1119,6 @@ onMounted(async () => {
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
- labelBarObserver?.disconnect()
})
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
@@ -1165,29 +1165,6 @@ watch(
},
)
-watch(
- labelBarSentinel,
- (newSentinel) => {
- // Disconnect any existing observer
- if (labelBarObserver) {
- labelBarObserver.disconnect()
- labelBarObserver = null
- }
-
- // Create new observer when sentinel becomes available
- if (newSentinel) {
- labelBarObserver = new IntersectionObserver(
- ([entry]) => {
- isLabelBarStuck.value = !entry.isIntersecting
- },
- { threshold: 0 },
- )
- labelBarObserver.observe(newSentinel)
- }
- },
- { flush: 'post' },
-)
-
watch(
() => route.query,
(newQuery, oldQuery) => {
diff --git a/packages/ui/src/pages/hosting/manage/index.vue b/packages/ui/src/pages/hosting/manage/index.vue
index db00e110bc..f999c2b433 100644
--- a/packages/ui/src/pages/hosting/manage/index.vue
+++ b/packages/ui/src/pages/hosting/manage/index.vue
@@ -177,7 +177,7 @@ import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import Fuse from 'fuse.js'
import type { ComponentPublicInstance } from 'vue'
-import { computed, onMounted, ref, watch } from 'vue'
+import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ServersUpgradeModalWrapper from '../../../components/billing/ServersUpgradeModalWrapper.vue'
@@ -234,15 +234,17 @@ const {
pollingState.value.count++
if (response.servers.length !== pollingState.value.initialServers.length) {
pollingState.value.enabled = false
+ isPollingForNewServers.value = false
router.replace({ query: {} })
} else if (pollingState.value.count >= 5) {
pollingState.value.enabled = false
+ isPollingForNewServers.value = false
}
}
return response
},
- refetchInterval: () => (pollingState.value.enabled ? 5000 : false),
+ refetchInterval: computed(() => (pollingState.value.enabled ? 5000 : false)),
})
watch([fetchError, serverResponse], ([error, response]) => {
@@ -280,13 +282,19 @@ const filteredData = computed(() => {
: []
})
-onMounted(() => {
- if (route.query.redirect_status === 'succeeded') {
+// Start polling only after initial data is available so the baseline is correct
+watch(serverResponse, (response) => {
+ if (
+ route.query.redirect_status === 'succeeded' &&
+ response &&
+ !pollingState.value.enabled &&
+ pollingState.value.count === 0
+ ) {
isPollingForNewServers.value = true
pollingState.value = {
enabled: true,
count: 0,
- initialServers: [...(serverResponse.value?.servers ?? [])],
+ initialServers: [...response.servers],
}
}
})
diff --git a/packages/ui/src/pages/index.ts b/packages/ui/src/pages/index.ts
index 528a1761c5..96eb189947 100644
--- a/packages/ui/src/pages/index.ts
+++ b/packages/ui/src/pages/index.ts
@@ -1,3 +1,5 @@
+export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
+export { default as ServersManageContentPage } from './hosting/manage/content.vue'
export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
diff --git a/packages/ui/src/providers/content-manager.ts b/packages/ui/src/providers/content-manager.ts
new file mode 100644
index 0000000000..94ee9b854f
--- /dev/null
+++ b/packages/ui/src/providers/content-manager.ts
@@ -0,0 +1,96 @@
+import type { ComputedRef, Ref } from 'vue'
+import type { RouteLocationRaw } from 'vue-router'
+
+import type { Option as OverflowMenuOption } from '../components/base/OverflowMenu.vue'
+import type {
+ ContentCardTableItem,
+ ContentItem,
+ ContentModpackCardCategory,
+ ContentModpackCardProject,
+ ContentModpackCardVersion,
+ ContentOwner,
+} from '../components/instances/types'
+import { createContext } from '.'
+
+export interface ContentModpackData {
+ project: ContentModpackCardProject
+ projectLink?: string | RouteLocationRaw
+ version?: ContentModpackCardVersion
+ versionLink?: string | RouteLocationRaw
+ owner?: ContentOwner
+ categories: ContentModpackCardCategory[]
+ hasUpdate: boolean
+ disabled?: boolean
+ disabledText?: string
+}
+
+export interface UploadState {
+ isUploading: boolean
+ currentFileName: string | null
+ currentFileProgress: number
+ completedFiles: number
+ totalFiles: number
+}
+
+export interface ContentManagerContext {
+ // Data
+ items: Ref | ComputedRef
+ loading: Ref
+ error: Ref
+
+ // Modpack
+ modpack: Ref | ComputedRef
+ isPackLocked: Ref | ComputedRef
+
+ // Guards
+ isBusy: Ref | ComputedRef
+
+ // Identity & labelling
+ getItemId: (item: ContentItem) => string
+ contentTypeLabel: Ref | ComputedRef
+
+ // Core actions
+ toggleEnabled: (item: ContentItem) => Promise
+ deleteItem: (item: ContentItem) => Promise
+ refresh: () => Promise
+ browse: () => void
+ uploadFiles: () => void
+
+ // Bulk actions (optional — when provided, used instead of one-by-one loops)
+ bulkDeleteItems?: (items: ContentItem[]) => Promise
+ bulkEnableItems?: (items: ContentItem[]) => Promise
+ bulkDisableItems?: (items: ContentItem[]) => Promise
+
+ // Update support (optional per-platform)
+ hasUpdateSupport: boolean
+ updateItem?: (id: string) => void
+ bulkUpdateItem?: (item: ContentItem) => Promise
+
+ // Modpack actions (optional)
+ updateModpack?: () => void
+ viewModpackContent?: () => void
+ unlinkModpack?: () => void
+
+ // Per-item overflow menu (optional)
+ getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
+
+ // Share support (optional — when undefined, share button becomes hidden entirely)
+ shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
+
+ // Upload progress (optional)
+ uploadState?: Ref | ComputedRef
+
+ // Deletion context (controls modal variant)
+ deletionContext?: 'instance' | 'server'
+
+ // Backup link (shown in deletion modal for server variant)
+ backupLink?: string
+
+ // Table item mapping (link generation differs per platform)
+ mapToTableItem: (item: ContentItem) => ContentCardTableItem
+}
+
+export const [injectContentManager, provideContentManager] = createContext(
+ 'ContentPageLayout',
+ 'contentManagerContext',
+)
diff --git a/packages/ui/src/providers/file-picker.ts b/packages/ui/src/providers/file-picker.ts
new file mode 100644
index 0000000000..d9eb81ae70
--- /dev/null
+++ b/packages/ui/src/providers/file-picker.ts
@@ -0,0 +1,19 @@
+import { createContext } from '.'
+
+export interface PickedFile {
+ /** Browser File object */
+ file: File
+ /** Native file system path (available on Tauri, undefined on web) */
+ path?: string
+ /** URL suitable for display (blob URL on web, convertFileSrc URL on Tauri) */
+ previewUrl: string
+}
+
+export interface FilePickerProvider {
+ /** Pick an image file (for icons) */
+ pickImage: () => Promise
+ /** Pick a .mrpack modpack file */
+ pickModpackFile: () => Promise
+}
+
+export const [injectFilePicker, provideFilePicker] = createContext('FilePicker')
diff --git a/packages/ui/src/providers/index.ts b/packages/ui/src/providers/index.ts
index 8281575551..86b42060d1 100644
--- a/packages/ui/src/providers/index.ts
+++ b/packages/ui/src/providers/index.ts
@@ -79,8 +79,14 @@ export function createContext(
}
export * from './api-client'
+export * from './content-manager'
+export * from './file-picker'
export * from './i18n'
+export * from './installation-settings'
+export * from './instance-import'
+export * from './modal-behavior'
export * from './page-context'
export * from './project-page'
export * from './server-context'
+export * from './tags'
export * from './web-notifications'
diff --git a/packages/ui/src/providers/installation-settings.ts b/packages/ui/src/providers/installation-settings.ts
new file mode 100644
index 0000000000..f4d66afbb1
--- /dev/null
+++ b/packages/ui/src/providers/installation-settings.ts
@@ -0,0 +1,83 @@
+import type { Component, ComputedRef, Ref } from 'vue'
+import type { RouteLocationRaw } from 'vue-router'
+
+import type { ComboboxOption } from '../components/base/Combobox.vue'
+import { createContext } from '.'
+
+type AutoLinkTarget = string | RouteLocationRaw | (() => void)
+
+export interface InstallationSettingsModpackInfo {
+ title: string
+ iconUrl?: string
+ projectLink?: AutoLinkTarget
+ versionName?: string
+ versionLink?: AutoLinkTarget
+ owner?: {
+ name: string
+ avatarUrl?: string
+ type?: 'user' | 'organization'
+ link?: AutoLinkTarget
+ }
+}
+
+export interface InstallationInfoRow {
+ label: string
+ value: string
+}
+
+export interface InstallationSettingsLinkedAction {
+ label: string
+ icon: Component
+ color: 'standard' | 'red' | 'orange'
+ disabled?: boolean
+ loading?: boolean
+ loadingLabel?: string
+ tooltip?: string | null
+ handler: () => void
+}
+
+export interface InstallationSettingsContext {
+ // Linked state
+ /**
+ * Controls which UI branch is rendered: when true, the linked modpack info
+ * and action buttons are shown; when false, the platform/version selectors
+ * (unlinked fields below) are shown instead.
+ */
+ isLinked: Ref | ComputedRef
+ modpack:
+ | Ref
+ | ComputedRef
+ installationInfo: Ref | ComputedRef
+ isBusy: Ref | ComputedRef
+
+ // Linked actions (Change version + Unlink are built-in)
+ changeVersion: () => void
+ unlink: () => void
+ extraLinkedActions?:
+ | Ref
+ | ComputedRef
+
+ // Unlinked state — only accessed when isLinked is false
+ platforms?: Ref | ComputedRef
+ selectedPlatform?: Ref
+ gameVersionOptions?: Ref[]> | ComputedRef[]>
+ selectedGameVersion?: Ref
+ loaderVersionOptions?: Ref[]> | ComputedRef[]>
+ selectedLoaderVersion?: Ref
+ loaderVersionDisplayValue?: Ref | ComputedRef
+ formattedLoaderName?: Ref | ComputedRef
+ hasChanges?: Ref | ComputedRef
+ isValid?: Ref | ComputedRef
+ isSaving?: Ref
+ save?: () => Promise
+
+ // Optional
+ showSnapshots?: Ref
+ hasSnapshots?: Ref | ComputedRef
+}
+
+export const [injectInstallationSettings, provideInstallationSettings] =
+ createContext(
+ 'InstallationSettingsLayout',
+ 'installationSettingsContext',
+ )
diff --git a/packages/ui/src/providers/instance-import.ts b/packages/ui/src/providers/instance-import.ts
new file mode 100644
index 0000000000..13c73a1d77
--- /dev/null
+++ b/packages/ui/src/providers/instance-import.ts
@@ -0,0 +1,23 @@
+import { createContext } from '.'
+
+export interface ImportableLauncher {
+ name: string
+ path: string
+ instances: string[]
+}
+
+export interface InstanceImportProvider {
+ /** Returns launchers with instances already populated (one round trip on mount) */
+ getDetectedLaunchers: () => Promise
+ /** Only needed for manually-added launcher paths */
+ getImportableInstances: (launcherName: string, path: string) => Promise
+ /** Perform the actual import */
+ importInstances: (
+ selections: { launcher: string; path: string; instanceNames: string[] }[],
+ ) => Promise
+ /** Open a directory picker (platform-specific) */
+ selectDirectory: () => Promise
+}
+
+export const [injectInstanceImport, provideInstanceImport] =
+ createContext('InstanceImport')
diff --git a/packages/ui/src/providers/modal-behavior.ts b/packages/ui/src/providers/modal-behavior.ts
new file mode 100644
index 0000000000..2f842aeed1
--- /dev/null
+++ b/packages/ui/src/providers/modal-behavior.ts
@@ -0,0 +1,14 @@
+import type { Ref } from 'vue'
+
+import { createContext } from './index'
+
+export interface ModalBehavior {
+ noblur: Ref
+ onShow?: () => void
+ onHide?: () => void
+}
+
+export const [injectModalBehavior, provideModalBehavior] = createContext(
+ 'root',
+ 'modalBehavior',
+)
diff --git a/packages/ui/src/providers/server-context.ts b/packages/ui/src/providers/server-context.ts
index 82026ca57f..e20ff20557 100644
--- a/packages/ui/src/providers/server-context.ts
+++ b/packages/ui/src/providers/server-context.ts
@@ -23,6 +23,7 @@ export interface FilesystemAuth {
export interface ModrinthServerContext {
readonly serverId: string
+ readonly worldId: Ref
readonly server: Ref
// Websocket state
diff --git a/packages/ui/src/providers/tags.ts b/packages/ui/src/providers/tags.ts
new file mode 100644
index 0000000000..32ea2f933f
--- /dev/null
+++ b/packages/ui/src/providers/tags.ts
@@ -0,0 +1,11 @@
+import type { Labrinth } from '@modrinth/api-client'
+import type { Ref } from 'vue'
+
+import { createContext } from './index'
+
+export interface TagsContext {
+ gameVersions: Ref
+ loaders: Ref
+}
+
+export const [injectTags, provideTags] = createContext('root', 'tags')
diff --git a/packages/ui/src/stories/base/Combobox.stories.ts b/packages/ui/src/stories/base/Combobox.stories.ts
index 5ed52f9a3b..90a63b82d3 100644
--- a/packages/ui/src/stories/base/Combobox.stories.ts
+++ b/packages/ui/src/stories/base/Combobox.stories.ts
@@ -1,5 +1,7 @@
import {
DownloadIcon,
+ EyeIcon,
+ EyeOffIcon,
HeartIcon,
SettingsIcon,
ShareIcon,
@@ -39,7 +41,6 @@ export const Searchable: Story = {
{ value: '4', label: 'NeoForge' },
{ value: '5', label: 'Quilt' },
],
- triggerText: 'Select a loader',
searchable: true,
searchPlaceholder: 'Search loaders...',
},
@@ -70,7 +71,7 @@ export const IconSlot: Story = {
},
}
-export const IconSlotSearchable: Story = {
+export const SearchableWithIcons: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
@@ -97,6 +98,83 @@ export const WithSelectedOption: Story = {
},
}
+export const SearchableNoFilter: Story = {
+ args: {
+ options: [
+ { value: 'download', label: 'Download', icon: DownloadIcon },
+ { value: 'share', label: 'Share', icon: ShareIcon },
+ { value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
+ { value: 'settings', label: 'Settings', icon: SettingsIcon },
+ { value: 'profile', label: 'Profile', icon: UserIcon },
+ ],
+ searchable: true,
+ searchPlaceholder: 'Search actions...',
+ disableSearchFilter: true,
+ },
+}
+
+export const SearchableModpacks: Story = {
+ args: {
+ options: [
+ { value: 'download', label: 'Download', icon: DownloadIcon },
+ { value: 'share', label: 'Share', icon: ShareIcon },
+ { value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
+ { value: 'settings', label: 'Settings', icon: SettingsIcon },
+ ],
+ searchable: true,
+ searchPlaceholder: 'Search modpacks...',
+ noOptionsMessage: 'No modpacks found',
+ },
+}
+
+export const WithDropdownFooter: StoryObj = {
+ render: () => ({
+ components: { Combobox, EyeIcon, EyeOffIcon },
+ data: () => ({
+ selected: '1.20.4',
+ showAll: false,
+ }),
+ computed: {
+ options() {
+ const releases = [
+ { value: '1.20.4', label: '1.20.4' },
+ { value: '1.20.3', label: '1.20.3' },
+ { value: '1.20.2', label: '1.20.2' },
+ { value: '1.20.1', label: '1.20.1' },
+ { value: '1.20', label: '1.20' },
+ ]
+ const snapshots = [
+ { value: '24w03a', label: '24w03a' },
+ { value: '23w51b', label: '23w51b' },
+ ]
+ // @ts-ignore
+ return this.showAll ? [...releases, ...snapshots] : releases
+ },
+ },
+ template: /*html*/ `
+
+
+
+
+
+ {{ showAll ? 'Hide snapshots' : 'Show all versions' }}
+
+
+
+ `,
+ }),
+}
+
export const WithSelectedOptionAndIcon: Story = {
args: {
options: [
diff --git a/packages/ui/src/stories/base/EmptyState.stories.ts b/packages/ui/src/stories/base/EmptyState.stories.ts
new file mode 100644
index 0000000000..f698148b9b
--- /dev/null
+++ b/packages/ui/src/stories/base/EmptyState.stories.ts
@@ -0,0 +1,90 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import EmptyState from '../../components/base/EmptyState.vue'
+
+const meta = {
+ title: 'Base/EmptyState',
+ component: EmptyState,
+ argTypes: {
+ type: {
+ control: 'select',
+ options: [
+ undefined,
+ 'done',
+ 'empty',
+ 'empty-inbox',
+ 'error',
+ 'no-connection',
+ 'no-credit-card',
+ 'no-documents',
+ 'no-gps',
+ 'no-images',
+ 'no-items-cart',
+ 'no-messages',
+ 'no-search-result',
+ 'no-tasks',
+ ],
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ type: 'empty-inbox',
+ heading: 'No content installed',
+ description: 'Browse or upload mods to get started',
+ },
+}
+
+export const WithActions: StoryObj = {
+ render: () => ({
+ components: { EmptyState, ButtonStyled },
+ template: /*html*/ `
+
+
+
+ Create backup
+
+
+
+ `,
+ }),
+}
+
+export const TextOnly: Story = {
+ args: {
+ heading: 'No transactions',
+ description: 'Your transaction history will appear here.',
+ },
+}
+
+export const AllIllustrations: StoryObj = {
+ render: () => ({
+ components: { EmptyState },
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/instances/ContentCardTable.stories.ts b/packages/ui/src/stories/instances/ContentCardTable.stories.ts
index e1dc89a3ef..ac18dea81b 100644
--- a/packages/ui/src/stories/instances/ContentCardTable.stories.ts
+++ b/packages/ui/src/stories/instances/ContentCardTable.stories.ts
@@ -7,101 +7,239 @@ import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentCardTable from '../../components/instances/ContentCardTable.vue'
import type { ContentCardTableItem } from '../../components/instances/types'
-// ============================================
-// Fixtures
-// ============================================
-
-const fixtures = {
- sodium: {
+// Sample data
+const sodiumItem: ContentCardTableItem = {
+ id: 'AANobbMI',
+ project: {
id: 'AANobbMI',
- project: {
- id: 'AANobbMI',
- slug: 'sodium',
- title: 'Sodium',
- icon_url:
- 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
- },
- version: {
- id: '59wygFUQ',
- version_number: 'mc1.21.11-0.8.2-fabric',
- file_name: 'sodium-fabric-0.8.2+mc1.21.11.jar',
- },
- owner: {
- id: 'DzLrfrbK',
- name: 'IMS',
- avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
- type: 'user' as const,
- },
- enabled: true,
+ slug: 'sodium',
+ title: 'Sodium',
+ icon_url:
+ 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
- modMenu: {
+ version: {
+ id: '59wygFUQ',
+ version_number: 'mc1.21.11-0.8.2-fabric',
+ file_name: 'sodium-fabric-0.8.2+mc1.21.11.jar',
+ },
+ owner: {
+ id: 'DzLrfrbK',
+ name: 'IMS',
+ avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
+ type: 'user',
+ },
+ enabled: true,
+}
+
+const modMenuItem: ContentCardTableItem = {
+ id: 'mOgUt4GM',
+ project: {
id: 'mOgUt4GM',
- project: {
- id: 'mOgUt4GM',
- slug: 'modmenu',
- title: 'Mod Menu',
- icon_url:
- 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
- },
- version: {
- id: 'QuU0ciaR',
- version_number: '16.0.0',
- file_name: 'modmenu-16.0.0.jar',
- },
- owner: { id: 'u2', name: 'Prospector', type: 'user' as const },
- enabled: true,
+ slug: 'modmenu',
+ title: 'Mod Menu',
+ icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
+ },
+ version: {
+ id: 'QuU0ciaR',
+ version_number: '16.0.0',
+ file_name: 'modmenu-16.0.0.jar',
+ },
+ owner: {
+ id: 'u2',
+ name: 'Prospector',
+ type: 'user',
},
- fabricApi: {
+ enabled: true,
+}
+
+const fabricApiItem: ContentCardTableItem = {
+ id: 'P7dR8mSH',
+ project: {
id: 'P7dR8mSH',
- project: {
- id: 'P7dR8mSH',
- slug: 'fabric-api',
- title: 'Fabric API',
- icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
- },
- version: {
- id: 'Lwa1Q6e4',
- version_number: '0.141.3+26.1',
- file_name: 'fabric-api-0.141.3+26.1.jar',
- },
- owner: {
- id: 'BZoBsPo6',
- name: 'FabricMC',
- avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
- type: 'organization' as const,
- },
- enabled: false,
+ slug: 'fabric-api',
+ title: 'Fabric API',
+ icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
+ },
+ version: {
+ id: 'Lwa1Q6e4',
+ version_number: '0.141.3+26.1',
+ file_name: 'fabric-api-0.141.3+26.1.jar',
+ },
+ owner: {
+ id: 'BZoBsPo6',
+ name: 'FabricMC',
+ avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
+ type: 'organization',
+ },
+ enabled: false,
+}
+
+const emfItem: ContentCardTableItem = {
+ id: 'emf123',
+ project: {
+ id: 'emf123',
+ slug: 'entity-model-features',
+ title: '[EMF] Entity Model Features',
+ icon_url:
+ 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
+ },
+ version: {
+ id: 'v1',
+ version_number: '2.4.1',
+ file_name: 'Entity_model_features_fabric_1.21.1-2.4.1.jar',
},
-} satisfies Record
+ owner: {
+ id: 'u1',
+ name: 'Traben',
+ type: 'user',
+ },
+ enabled: true,
+}
-const defaultItems: ContentCardTableItem[] = [fixtures.sodium, fixtures.modMenu, fixtures.fabricApi]
+const etfItem: ContentCardTableItem = {
+ id: 'etf456',
+ project: {
+ id: 'etf456',
+ slug: 'entity-texture-features',
+ title: '[ETF] Entity Texture Features',
+ icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
+ },
+ version: {
+ id: 'v2',
+ version_number: '6.2.9',
+ file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar',
+ },
+ owner: {
+ id: 'u1',
+ name: 'Traben',
+ type: 'user',
+ },
+ enabled: true,
+}
+
+const importedModItem: ContentCardTableItem = {
+ id: 'imported123',
+ project: {
+ id: 'imported123',
+ slug: 'import-mod',
+ title: 'Import mod',
+ icon_url: undefined,
+ },
+ version: {
+ id: 'v3',
+ version_number: 'Unknown',
+ file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar',
+ },
+ enabled: false,
+}
-/** Generate n items for stress testing */
-function generateItems(count: number): ContentCardTableItem[] {
- return Array.from({ length: count }, (_, i) => ({
- ...fixtures.sodium,
- id: `item-${i}`,
- project: { ...fixtures.sodium.project, title: `Mod ${i + 1}` },
- version: { ...fixtures.sodium.version!, version_number: `1.0.${i}` },
- enabled: i % 3 !== 0,
- }))
+// Edge case items
+const longNameItem: ContentCardTableItem = {
+ id: 'long-name',
+ project: {
+ id: 'long-name',
+ slug: 'very-long-project-name',
+ title: '[EMF] Entity Model Features - The Ultimate Entity Rendering Mod for Minecraft',
+ icon_url: sodiumItem.project.icon_url,
+ },
+ version: {
+ id: 'v1',
+ version_number: '2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0',
+ file_name: 'Entity_model_features_fabric_1.21.1-2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0.jar',
+ },
+ owner: {
+ id: 'u1',
+ name: 'Traben',
+ type: 'user',
+ },
+ enabled: true,
}
-// ============================================
-// Meta
-// ============================================
+const noOwnerAvatarItem: ContentCardTableItem = {
+ id: 'no-avatar',
+ project: {
+ id: 'no-avatar',
+ slug: 'no-avatar-mod',
+ title: 'Mod Without Owner Avatar',
+ icon_url: modMenuItem.project.icon_url,
+ },
+ version: {
+ id: 'v1',
+ version_number: '1.0.0',
+ file_name: 'no-avatar-mod-1.0.0.jar',
+ },
+ owner: {
+ id: 'u1',
+ name: 'Anonymous User',
+ avatar_url: undefined,
+ type: 'user',
+ },
+ enabled: true,
+}
+
+const updateAvailableItem: ContentCardTableItem = {
+ id: 'update-available',
+ project: {
+ id: 'update-available',
+ slug: 'outdated-mod',
+ title: 'Outdated Mod',
+ icon_url: fabricApiItem.project.icon_url,
+ },
+ version: {
+ id: 'v1',
+ version_number: '1.0.0',
+ file_name: 'outdated-mod-1.0.0.jar',
+ },
+ owner: fabricApiItem.owner,
+ enabled: true,
+ hasUpdate: true,
+}
+
+const sampleItems: ContentCardTableItem[] = [sodiumItem, modMenuItem, fabricApiItem]
+
+const figmaDesignItems: ContentCardTableItem[] = [emfItem, etfItem, importedModItem]
+
+// Comprehensive items showing all possible states
+const allStatesItems: ContentCardTableItem[] = [
+ { ...sodiumItem, enabled: true, hasUpdate: false },
+ { ...modMenuItem, enabled: true, hasUpdate: true },
+ { ...fabricApiItem, enabled: false },
+ longNameItem,
+ importedModItem,
+ noOwnerAvatarItem,
+ updateAvailableItem,
+ { ...emfItem, disabled: true, enabled: false },
+]
const meta = {
title: 'Instances/ContentCardTable',
component: ContentCardTable,
- parameters: { layout: 'padded' },
- args: {
- items: defaultItems,
- showSelection: false,
- virtualized: true,
- 'onUpdate:enabled': fn(),
- onDelete: fn(),
- onUpdate: fn(),
+ parameters: {
+ layout: 'padded',
+ },
+ argTypes: {
+ items: {
+ control: 'object',
+ description: 'Array of items to display in the table',
+ },
+ showSelection: {
+ control: 'boolean',
+ description: 'Show checkboxes for selection',
+ },
+ sortable: {
+ control: 'boolean',
+ description: 'Enable column sorting',
+ },
+ sortBy: {
+ control: 'select',
+ options: ['project', 'version', undefined],
+ description: 'Current sort column',
+ },
+ sortDirection: {
+ control: 'select',
+ options: ['asc', 'desc'],
+ description: 'Sort direction',
+ },
},
} satisfies Meta
@@ -109,217 +247,255 @@ export default meta
type Story = StoryObj
// ============================================
-// Core Stories
+// Basic Stories
// ============================================
-export const Default: Story = {}
+export const Default: Story = {
+ args: {
+ items: sampleItems,
+ },
+}
-export const WithSelection: Story = {
- args: { showSelection: true },
- render: (args) => ({
+/**
+ * Comprehensive story showing all possible item states in one view:
+ * - Normal enabled item
+ * - Item with update available
+ * - Disabled toggle (enabled: false)
+ * - Long project name and version (truncation)
+ * - No project icon
+ * - No owner avatar
+ * - Item with hasUpdate flag
+ * - Fully disabled item (disabled: true)
+ */
+export const AllStates: Story = {
+ render: () => ({
components: { ContentCardTable },
setup() {
- const selectedIds = ref([])
- return { args, selectedIds }
+ const items = ref(allStatesItems)
+ return { items }
},
- template: `
+ template: /*html*/ `
-
-
- Selected: {{ selectedIds.length }}
- ({{ selectedIds.join(', ') }})
-
+
+
This story demonstrates all possible item states:
+
+ Sodium - Normal enabled item
+ Mod Menu - Has update available (green button)
+ Fabric API - Toggle off (enabled: false)
+ EMF - Long name/version (truncation)
+ Import mod - No project icon
+ No avatar - Owner without avatar
+ Outdated Mod - hasUpdate flag
+ ETF - Fully disabled (disabled: true, grayed out)
+
+
+
console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ @update="(id) => console.log('Update', id)"
+ />
`,
}),
}
-export const Empty: Story = {
- args: { items: [] },
-}
-
-export const EmptyCustom: Story = {
- args: { items: [] },
- render: (args) => ({
- components: { ContentCardTable, ButtonStyled },
- setup: () => ({ args }),
- template: `
-
-
-
- No mods installed
- Browse mods
-
-
-
+/**
+ * Shows items with update available - displays green download button
+ */
+export const WithUpdatesAvailable: Story = {
+ render: () => ({
+ components: { ContentCardTable },
+ setup() {
+ const items: ContentCardTableItem[] = [
+ { ...sodiumItem, hasUpdate: true },
+ { ...modMenuItem, hasUpdate: true },
+ { ...fabricApiItem, hasUpdate: false },
+ ]
+ return { items }
+ },
+ template: /*html*/ `
+ console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ @update="(id) => console.log('Update clicked', id)"
+ />
`,
}),
}
-// ============================================
-// States
-// ============================================
-
-/** All possible item states in one view */
-export const AllStates: Story = {
+/**
+ * Shows difference between user and organization owners
+ */
+export const UserVsOrganizationOwners: Story = {
args: {
- showSelection: true,
items: [
- { ...fixtures.sodium, enabled: true },
- { ...fixtures.modMenu, hasUpdate: true },
- { ...fixtures.fabricApi, enabled: false },
- {
- id: 'long-name',
- project: {
- id: 'long-name',
- slug: 'long-mod',
- title: '[EMF] Entity Model Features - The Ultimate Entity Rendering Mod',
- icon_url: fixtures.sodium.project.icon_url,
- },
- version: {
- id: 'v1',
- version_number: '2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0',
- file_name: 'emf-2.4.1-beta.15.jar',
- },
- owner: { id: 'u1', name: 'Traben', type: 'user' },
- enabled: true,
- },
- {
- id: 'no-icon',
- project: { id: 'no-icon', slug: 'imported', title: 'Imported mod', icon_url: undefined },
- version: { id: 'v1', version_number: 'Unknown', file_name: 'imported.jar' },
- enabled: true,
- },
- {
- id: 'no-avatar',
- project: {
- id: 'no-avatar',
- slug: 'no-avatar',
- title: 'No Owner Avatar',
- icon_url: fixtures.modMenu.project.icon_url,
- },
- version: { id: 'v1', version_number: '1.0.0', file_name: 'mod.jar' },
- owner: { id: 'u1', name: 'Anonymous', avatar_url: undefined, type: 'user' },
- enabled: true,
- },
- { ...fixtures.modMenu, id: 'disabled-item', disabled: true, enabled: false },
+ { ...sodiumItem }, // User owner (circular avatar)
+ { ...fabricApiItem }, // Organization owner (rounded + icon)
],
},
- parameters: {
- docs: {
- description: {
- story:
- 'Demonstrates: enabled, update available, disabled toggle, long names (truncation), missing icon, missing avatar, fully disabled item.',
- },
- },
- },
}
-/** Items with update badges */
-export const WithUpdates: Story = {
+/**
+ * Edge cases: long names, missing icons, missing avatars
+ */
+export const EdgeCases: Story = {
args: {
- items: [
- { ...fixtures.sodium, hasUpdate: true },
- { ...fixtures.modMenu, hasUpdate: true },
- fixtures.fabricApi,
- ],
+ items: [longNameItem, importedModItem, noOwnerAvatarItem],
},
}
-/** Per-item disabled state (e.g., during async operations) */
-export const ItemsDisabled: Story = {
+export const FigmaDesign: Story = {
args: {
+ items: figmaDesignItems,
showSelection: true,
- items: [
- fixtures.sodium,
- { ...fixtures.modMenu, disabled: true },
- { ...fixtures.fabricApi, disabled: true },
- ],
},
- parameters: {
- docs: {
- description: { story: 'Items with `disabled: true` have all interactions disabled.' },
+ render: (args) => ({
+ components: { ContentCardTable },
+ setup() {
+ const selectedIds = ref([emfItem.id, etfItem.id])
+ return { args, selectedIds }
},
- },
+ template: /*html*/ `
+ console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ />
+ `,
+ }),
}
-// ============================================
-// Slots
-// ============================================
+export const WithSelection: Story = {
+ args: {
+ items: sampleItems,
+ showSelection: true,
+ },
+ render: (args) => ({
+ components: { ContentCardTable },
+ setup() {
+ const selectedIds = ref([])
+ return { args, selectedIds }
+ },
+ template: /*html*/ `
+
+
console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ />
+
+ Selected: {{ selectedIds.length }} items
+ ({{ selectedIds.join(', ') }})
+
+
+ `,
+ }),
+}
-export const CustomButtons: Story = {
- args: { showSelection: true },
+export const WithSorting: Story = {
+ args: {
+ items: sampleItems,
+ sortable: true,
+ sortBy: 'project',
+ sortDirection: 'asc',
+ },
render: (args) => ({
- components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon },
- setup: () => ({ args }),
- template: `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ components: { ContentCardTable },
+ setup() {
+ const sortBy = ref<'project' | 'version' | undefined>(args.sortBy)
+ const sortDirection = ref<'asc' | 'desc'>(args.sortDirection || 'asc')
+
+ const handleSort = (column: 'project' | 'version', direction: 'asc' | 'desc') => {
+ sortBy.value = column
+ sortDirection.value = direction
+ console.log('Sort:', column, direction)
+ }
+
+ return { args, sortBy, sortDirection, handleSort }
+ },
+ template: /*html*/ `
+
+
+
+ Sorted by: {{ sortBy || 'none' }} ({{ sortDirection }})
+
+
`,
}),
}
-export const WithOverflowMenu: Story = {
+export const WithSelectionAndSorting: Story = {
args: {
+ items: sampleItems,
showSelection: true,
- items: [
- {
- ...fixtures.sodium,
- overflowOptions: [
- { id: 'view', action: () => console.log('View') },
- { id: 'folder', action: () => console.log('Folder') },
- { divider: true },
- { id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
- ],
- },
- {
- ...fixtures.modMenu,
- overflowOptions: [
- { id: 'view', action: () => console.log('View') },
- { divider: true },
- { id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
- ],
- },
- ],
+ sortable: true,
+ sortBy: 'project',
+ sortDirection: 'asc',
},
render: (args) => ({
components: { ContentCardTable },
- setup: () => ({ args }),
- template: `
-
- View on Modrinth
- Open folder
- Remove
-
+ setup() {
+ const selectedIds = ref([])
+ const sortBy = ref<'project' | 'version' | undefined>(args.sortBy)
+ const sortDirection = ref<'asc' | 'desc'>(args.sortDirection || 'asc')
+
+ const handleSort = (column: 'project' | 'version', direction: 'asc' | 'desc') => {
+ sortBy.value = column
+ sortDirection.value = direction
+ }
+
+ return { args, selectedIds, sortBy, sortDirection, handleSort }
+ },
+ template: /*html*/ `
+ console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ />
`,
}),
}
// ============================================
-// Interactive
+// Action Stories
// ============================================
-export const Interactive: Story = {
- args: { showSelection: true },
- render: (args) => ({
+export const WithActions: Story = {
+ args: {
+ items: sampleItems,
+ showSelection: true,
+ 'onUpdate:enabled': fn(),
+ onDelete: fn(),
+ onUpdate: fn(),
+ },
+}
+
+export const InteractiveActions: Story = {
+ render: () => ({
components: { ContentCardTable },
setup() {
- const items = ref(
- defaultItems.map((item) => ({ ...item, enabled: item.id !== fixtures.fabricApi.id })),
- )
+ const items = ref([
+ { ...sodiumItem, enabled: true },
+ { ...modMenuItem, enabled: true },
+ { ...fabricApiItem, enabled: false },
+ ])
const selectedIds = ref([])
const handleToggle = (id: string, value: boolean) => {
@@ -332,136 +508,340 @@ export const Interactive: Story = {
selectedIds.value = selectedIds.value.filter((i) => i !== id)
}
- return { args, items, selectedIds, handleToggle, handleDelete }
+ const handleUpdate = (id: string) => {
+ console.log('Update available clicked for:', id)
+ }
+
+ return { items, selectedIds, handleToggle, handleDelete, handleUpdate }
},
- template: `
+ template: /*html*/ `
-
- Items: {{ items.length }} · Selected: {{ selectedIds.length }}
-
+
+ Items: {{ items.length }}
+ Selected: {{ selectedIds.length }}
+
`,
}),
}
-export const BulkActions: Story = {
+// ============================================
+// Slot Stories
+// ============================================
+
+export const WithCustomItemButtons: Story = {
render: () => ({
- components: { ContentCardTable, ButtonStyled },
+ components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon },
setup() {
- const items = ref(
- defaultItems.map((item, i) => ({ ...item, enabled: i !== 2 })),
- )
- const selectedIds = ref([])
+ return { items: sampleItems }
+ },
+ template: /*html*/ `
+ console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
- const setEnabled = (value: boolean) => {
- items.value.forEach((item) => {
- if (selectedIds.value.includes(item.id)) item.enabled = value
- })
- }
+export const WithEmptyState: Story = {
+ args: {
+ items: [],
+ },
+}
- const deleteSelected = () => {
- items.value = items.value.filter((item) => !selectedIds.value.includes(item.id))
- selectedIds.value = []
- }
+export const WithCustomEmptyState: Story = {
+ render: () => ({
+ components: { ContentCardTable, ButtonStyled },
+ template: /*html*/ `
+
+
+
+ No mods installed
+
+ Browse mods
+
+
+
+
+ `,
+ }),
+}
- const handleToggle = (id: string, value: boolean) => {
- const item = items.value.find((i) => i.id === id)
- if (item) item.enabled = value
- }
+// ============================================
+// State Stories
+// ============================================
- return { items, selectedIds, setEnabled, deleteSelected, handleToggle }
+export const PerItemDisabled: Story = {
+ render: () => ({
+ components: { ContentCardTable },
+ setup() {
+ // Simulates items being modified (e.g., toggled, deleted)
+ const items: ContentCardTableItem[] = [
+ { ...sodiumItem, enabled: true },
+ { ...modMenuItem, enabled: true, disabled: true }, // Being modified
+ { ...fabricApiItem, enabled: false, disabled: true }, // Being modified
+ ]
+ return { items }
},
- template: `
+ template: /*html*/ `
-
- {{ selectedIds.length }} selected
-
-
- Enable
-
-
- Disable
-
-
- Delete
-
-
-
+
+ Items with disabled: true have all interactions disabled (simulating items being modified).
+
console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
/>
`,
}),
}
-// ============================================
-// Performance
-// ============================================
-
-export const Virtualization: Story = {
- parameters: {
- docs: {
- description: {
- story:
- '2000 items with virtualization. Toggle to compare DOM node count. Virtualized should render ~20-30 nodes vs 2000.',
- },
- },
+export const SingleItem: Story = {
+ args: {
+ items: [sodiumItem],
+ showSelection: true,
},
+}
+
+export const ManyItems: Story = {
render: () => ({
components: { ContentCardTable },
setup() {
- const items = ref(generateItems(2000))
+ const items = ref(
+ Array.from({ length: 2000 }, (_, i) => ({
+ ...sodiumItem,
+ id: `item-${i}`,
+ project: {
+ ...sodiumItem.project,
+ title: `Mod ${i + 1}`,
+ },
+ version: {
+ ...sodiumItem.version!,
+ version_number: `1.0.${i}`,
+ },
+ enabled: i % 3 !== 0,
+ })),
+ )
const selectedIds = ref([])
const virtualized = ref(true)
const tableRef = ref | null>(null)
+
+ // Perf monitoring
const domNodes = ref(0)
- let raf: number
+ let animationId: number
- const updateNodeCount = () => {
+ const updatePerf = () => {
+ // Count ContentCardItem elements (they have h-20 class)
if (tableRef.value?.$el) {
- domNodes.value = (tableRef.value.$el as HTMLElement).querySelectorAll(
- '[data-content-card-item]',
- ).length
+ const container = tableRef.value.$el as HTMLElement
+ domNodes.value = container.querySelectorAll('.h-20').length
}
- raf = requestAnimationFrame(updateNodeCount)
+ animationId = requestAnimationFrame(updatePerf)
}
onMounted(() => {
- raf = requestAnimationFrame(updateNodeCount)
+ animationId = requestAnimationFrame(updatePerf)
+ })
+
+ onUnmounted(() => {
+ cancelAnimationFrame(animationId)
})
- onUnmounted(() => cancelAnimationFrame(raf))
- return { items, selectedIds, virtualized, tableRef, domNodes }
+ return {
+ items,
+ selectedIds,
+ virtualized,
+ tableRef,
+ domNodes,
+ }
},
- template: `
+ template: /*html*/ `
+
-
- Virtualization
+
+ Enable Virtualization
-
- DOM: {{ domNodes }}
- / {{ items.length }}
-
+
+
+
+
Performance
+
+ Total Items:
+ {{ items.length }}
+ DOM Nodes:
+ {{ domNodes }}
+ Mode:
+ {{ virtualized ? 'Virtual' : 'Full DOM' }}
+
+
+
console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ />
+
+ `,
+ }),
+}
+
+// ============================================
+// With Overflow Menu
+// ============================================
+
+export const WithOverflowMenu: Story = {
+ render: () => ({
+ components: { ContentCardTable },
+ setup() {
+ const items: ContentCardTableItem[] = [
+ {
+ ...sodiumItem,
+ overflowOptions: [
+ { id: 'view', action: () => console.log('View sodium') },
+ { id: 'folder', action: () => console.log('Open folder') },
+ { divider: true },
+ { id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
+ ],
+ },
+ {
+ ...modMenuItem,
+ overflowOptions: [
+ { id: 'view', action: () => console.log('View modmenu') },
+ { divider: true },
+ { id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
+ ],
+ },
+ ]
+
+ return { items }
+ },
+ template: /*html*/ `
+ console.log('Toggle', id, val)"
+ @delete="(id) => console.log('Delete', id)"
+ >
+ View on Modrinth
+ Open folder
+ Remove
+
+ `,
+ }),
+}
+
+// ============================================
+// Bulk Actions Demo
+// ============================================
+
+export const BulkActionsDemo: Story = {
+ render: () => ({
+ components: { ContentCardTable, ButtonStyled },
+ setup() {
+ const items = ref([
+ { ...sodiumItem, enabled: true },
+ { ...modMenuItem, enabled: true },
+ { ...fabricApiItem, enabled: false },
+ { ...emfItem, enabled: true },
+ { ...etfItem, enabled: true },
+ ])
+ const selectedIds = ref([])
+
+ const enableSelected = () => {
+ items.value.forEach((item) => {
+ if (selectedIds.value.includes(item.id)) {
+ item.enabled = true
+ }
+ })
+ }
+
+ const disableSelected = () => {
+ items.value.forEach((item) => {
+ if (selectedIds.value.includes(item.id)) {
+ item.enabled = false
+ }
+ })
+ }
+
+ const deleteSelected = () => {
+ items.value = items.value.filter((item) => !selectedIds.value.includes(item.id))
+ selectedIds.value = []
+ }
+
+ const handleToggle = (id: string, value: boolean) => {
+ const item = items.value.find((i) => i.id === id)
+ if (item) item.enabled = value
+ }
+
+ return { items, selectedIds, enableSelected, disableSelected, deleteSelected, handleToggle }
+ },
+ template: /*html*/ `
+
+
+ {{ selectedIds.length }} selected
+
+
+ Enable
+
+
+ Disable
+
+
+ Delete
+
+
+
+
console.log('Delete', id)"
/>
`,
diff --git a/packages/ui/src/stories/instances/ModpackContentModal.stories.ts b/packages/ui/src/stories/instances/ModpackContentModal.stories.ts
new file mode 100644
index 0000000000..d778225539
--- /dev/null
+++ b/packages/ui/src/stories/instances/ModpackContentModal.stories.ts
@@ -0,0 +1,618 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { ref } from 'vue'
+
+import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import ModpackContentModal from '../../components/instances/modals/ModpackContentModal.vue'
+import type { ContentItem } from '../../components/instances/types'
+
+// Sample modpack content items (representing mods included in a modpack)
+const sodiumItem: ContentItem = {
+ file_name: 'sodium-fabric-0.8.2+mc1.21.1.jar',
+ file_path: '',
+ hash: '',
+ size: 1024000,
+ enabled: true,
+ project_type: 'mod',
+ project: {
+ id: 'AANobbMI',
+ slug: 'sodium',
+ title: 'Sodium',
+ icon_url:
+ 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
+ },
+ version: {
+ id: '59wygFUQ',
+ version_number: 'mc1.21.1-0.8.2-fabric',
+ file_name: 'sodium-fabric-0.8.2+mc1.21.1.jar',
+ },
+ owner: {
+ id: 'DzLrfrbK',
+ name: 'IMS',
+ avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const lithiumItem: ContentItem = {
+ file_name: 'lithium-fabric-0.14.3+mc1.21.1.jar',
+ file_path: '',
+ hash: '',
+ size: 512000,
+ enabled: true,
+ project_type: 'mod',
+ project: {
+ id: 'gvQqBUqZ',
+ slug: 'lithium',
+ title: 'Lithium',
+ icon_url:
+ 'https://cdn.modrinth.com/data/gvQqBUqZ/d6a1873d52b7d1c82b9a8d9b1889c9c1a29ae92d_96.webp',
+ },
+ version: {
+ id: 'abc123',
+ version_number: 'mc1.21.1-0.14.3',
+ file_name: 'lithium-fabric-0.14.3+mc1.21.1.jar',
+ },
+ owner: {
+ id: 'DzLrfrbK',
+ name: 'IMS',
+ avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const fabricApiItem: ContentItem = {
+ file_name: 'fabric-api-0.141.3+26.1.jar',
+ file_path: '',
+ hash: '',
+ size: 2048000,
+ enabled: true,
+ project_type: 'mod',
+ project: {
+ id: 'P7dR8mSH',
+ slug: 'fabric-api',
+ title: 'Fabric API',
+ icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
+ },
+ version: {
+ id: 'Lwa1Q6e4',
+ version_number: '0.141.3+26.1',
+ file_name: 'fabric-api-0.141.3+26.1.jar',
+ },
+ owner: {
+ id: 'BZoBsPo6',
+ name: 'FabricMC',
+ avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
+ type: 'organization',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const modMenuItem: ContentItem = {
+ file_name: 'modmenu-16.0.0.jar',
+ file_path: '',
+ hash: '',
+ size: 256000,
+ enabled: true,
+ project_type: 'mod',
+ project: {
+ id: 'mOgUt4GM',
+ slug: 'modmenu',
+ title: 'Mod Menu',
+ icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
+ },
+ version: {
+ id: 'QuU0ciaR',
+ version_number: '16.0.0',
+ file_name: 'modmenu-16.0.0.jar',
+ },
+ owner: {
+ id: 'u2',
+ name: 'Prospector',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const irisItem: ContentItem = {
+ file_name: 'iris-1.8.0+mc1.21.1.jar',
+ file_path: '',
+ hash: '',
+ size: 1536000,
+ enabled: true,
+ project_type: 'mod',
+ project: {
+ id: 'YL57xq9U',
+ slug: 'iris',
+ title: 'Iris Shaders',
+ icon_url: 'https://cdn.modrinth.com/data/YL57xq9U/icon.png',
+ },
+ version: {
+ id: 'iris123',
+ version_number: '1.8.0+mc1.21.1',
+ file_name: 'iris-1.8.0+mc1.21.1.jar',
+ },
+ owner: {
+ id: 'coderbot',
+ name: 'coderbot',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const entityModelFeaturesItem: ContentItem = {
+ file_name: 'entity-model-features-fabric-2.4.1+mc1.21.1.jar',
+ file_path: '',
+ hash: '',
+ size: 768000,
+ enabled: true,
+ project_type: 'mod',
+ project: {
+ id: 'emf123',
+ slug: 'entity-model-features',
+ title: '[EMF] Entity Model Features',
+ icon_url:
+ 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
+ },
+ version: {
+ id: 'emfv1',
+ version_number: '2.4.1',
+ file_name: 'Entity_model_features_fabric_1.21.1-2.4.1.jar',
+ },
+ owner: {
+ id: 'traben',
+ name: 'Traben',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const entityTextureFeaturesItem: ContentItem = {
+ file_name: 'entity-texture-features-fabric-6.2.9+mc1.21.1.jar',
+ file_path: '',
+ hash: '',
+ size: 640000,
+ enabled: true,
+ project_type: 'mod',
+ project: {
+ id: 'etf456',
+ slug: 'entity-texture-features',
+ title: '[ETF] Entity Texture Features',
+ icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
+ },
+ version: {
+ id: 'etfv1',
+ version_number: '6.2.9',
+ file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar',
+ },
+ owner: {
+ id: 'traben',
+ name: 'Traben',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+// Shader pack item
+const complementaryShaderItem: ContentItem = {
+ file_name: 'ComplementaryReimagined_r5.3.zip',
+ file_path: '',
+ hash: '',
+ size: 2048000,
+ enabled: true,
+ project_type: 'shader',
+ project: {
+ id: 'shader1',
+ slug: 'complementary-reimagined',
+ title: 'Complementary Reimagined',
+ icon_url: 'https://cdn.modrinth.com/data/HVnmMxH1/icon.png',
+ },
+ version: {
+ id: 'shaderv1',
+ version_number: 'r5.3',
+ file_name: 'ComplementaryReimagined_r5.3.zip',
+ },
+ owner: {
+ id: 'emin',
+ name: 'EminGT',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const bslShaderItem: ContentItem = {
+ file_name: 'BSL_v8.2.09.zip',
+ file_path: '',
+ hash: '',
+ size: 1024000,
+ enabled: true,
+ project_type: 'shader',
+ project: {
+ id: 'shader2',
+ slug: 'bsl-shaders',
+ title: 'BSL Shaders',
+ icon_url: 'https://cdn.modrinth.com/data/Q1vvjJYV/icon.png',
+ },
+ version: {
+ id: 'shaderv2',
+ version_number: 'v8.2.09',
+ file_name: 'BSL_v8.2.09.zip',
+ },
+ owner: {
+ id: 'capt',
+ name: 'CaptTatsu',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+// Resource pack items
+const faithfulItem: ContentItem = {
+ file_name: 'Faithful 32x - 1.21.zip',
+ file_path: '',
+ hash: '',
+ size: 8192000,
+ enabled: true,
+ project_type: 'resourcepack',
+ project: {
+ id: 'rp1',
+ slug: 'faithful-32x',
+ title: 'Faithful 32x',
+ icon_url: 'https://cdn.modrinth.com/data/tAnpCviC/icon.png',
+ },
+ version: {
+ id: 'rpv1',
+ version_number: '1.21',
+ file_name: 'Faithful 32x - 1.21.zip',
+ },
+ owner: {
+ id: 'faithful',
+ name: 'Faithful Resource Pack',
+ avatar_url: 'https://cdn.modrinth.com/data/tAnpCviC/icon.png',
+ type: 'organization',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const vanillaTweaksItem: ContentItem = {
+ file_name: 'VanillaTweaks_r3.zip',
+ file_path: '',
+ hash: '',
+ size: 512000,
+ enabled: true,
+ project_type: 'resourcepack',
+ project: {
+ id: 'rp2',
+ slug: 'vanilla-tweaks',
+ title: 'Vanilla Tweaks',
+ icon_url: null,
+ },
+ version: {
+ id: 'rpv2',
+ version_number: 'r3',
+ file_name: 'VanillaTweaks_r3.zip',
+ },
+ owner: {
+ id: 'xisuma',
+ name: 'Xisumavoid',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+const stayTrueItem: ContentItem = {
+ file_name: 'Stay_True_1.21.zip',
+ file_path: '',
+ hash: '',
+ size: 4096000,
+ enabled: true,
+ project_type: 'resourcepack',
+ project: {
+ id: 'rp3',
+ slug: 'stay-true',
+ title: 'Stay True',
+ icon_url: 'https://cdn.modrinth.com/data/HVnmMxH1/icon.png',
+ },
+ version: {
+ id: 'rpv3',
+ version_number: '1.21',
+ file_name: 'Stay_True_1.21.zip',
+ },
+ owner: {
+ id: 'hallowed',
+ name: 'HallowedST',
+ type: 'user',
+ },
+ has_update: false,
+ update_version_id: null,
+}
+
+// Mixed content (mods + shaders + resource packs)
+const mixedModpackContent: ContentItem[] = [
+ sodiumItem,
+ lithiumItem,
+ fabricApiItem,
+ modMenuItem,
+ irisItem,
+ entityModelFeaturesItem,
+ entityTextureFeaturesItem,
+ complementaryShaderItem,
+ bslShaderItem,
+ faithfulItem,
+ vanillaTweaksItem,
+ stayTrueItem,
+]
+
+// Mods only
+const modsOnlyContent: ContentItem[] = [
+ sodiumItem,
+ lithiumItem,
+ fabricApiItem,
+ modMenuItem,
+ irisItem,
+ entityModelFeaturesItem,
+ entityTextureFeaturesItem,
+]
+
+// Large modpack content (40+ items for testing scrolling)
+const largeModpackContent: ContentItem[] = [
+ ...mixedModpackContent,
+ ...Array.from({ length: 35 }, (_, i) => ({
+ ...sodiumItem,
+ file_name: `mod-${i + 1}-1.0.0.jar`,
+ project: {
+ id: `mod-${i + 1}`,
+ slug: `mod-${i + 1}`,
+ title: `Example Mod ${i + 1}`,
+ icon_url:
+ i % 3 === 0
+ ? sodiumItem.project!.icon_url
+ : i % 3 === 1
+ ? fabricApiItem.project!.icon_url
+ : modMenuItem.project!.icon_url,
+ },
+ version: {
+ id: `v${i + 1}`,
+ version_number: `1.${i}.0`,
+ file_name: `mod-${i + 1}-1.0.0.jar`,
+ },
+ owner: i % 2 === 0 ? sodiumItem.owner : fabricApiItem.owner,
+ })),
+]
+
+const meta = {
+ title: 'Instances/ModpackContentModal',
+ component: ModpackContentModal,
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+// ============================================
+// Basic Examples
+// ============================================
+
+export const Default: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const openModal = () => modalRef.value?.show(mixedModpackContent)
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ View Modpack Content (Mixed)
+
+
+
+ `,
+ }),
+}
+
+export const ModsOnly: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const openModal = () => modalRef.value?.show(modsOnlyContent)
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ View Modpack Content (Mods Only)
+
+
+
+ `,
+ }),
+}
+
+// ============================================
+// Loading State
+// ============================================
+
+export const LoadingState: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const openModal = () => {
+ modalRef.value?.showLoading()
+ // Simulate loading delay
+ setTimeout(() => {
+ modalRef.value?.show(mixedModpackContent)
+ }, 2000)
+ }
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ View Content (With Loading)
+
+
+
+ `,
+ }),
+}
+
+// ============================================
+// Empty State
+// ============================================
+
+export const EmptyContent: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const openModal = () => modalRef.value?.show([])
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ View Empty Modpack
+
+
+
+ `,
+ }),
+}
+
+// ============================================
+// Large Content List
+// ============================================
+
+export const LargeModpack: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const openModal = () => modalRef.value?.show(largeModpackContent)
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ View Large Modpack (47 items)
+
+
+
+ `,
+ }),
+}
+
+// ============================================
+// Search Functionality
+// ============================================
+
+export const SearchDemo: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const openModal = () => modalRef.value?.show(mixedModpackContent)
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ Click the button and try searching for "sodium", "shader", or "faithful" to test the search functionality.
+
+
+ Test Search
+
+
+
+ `,
+ }),
+}
+
+// ============================================
+// Filter Demo
+// ============================================
+
+export const FilterDemo: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const openModal = () => modalRef.value?.show(mixedModpackContent)
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ Click the button and try the filter chips (Mods, Shaders, Resource Packs) to filter content by type.
+
+
+ Test Filters
+
+
+
+ `,
+ }),
+}
+
+// ============================================
+// Mixed Owner Types
+// ============================================
+
+export const MixedOwnerTypes: Story = {
+ render: () => ({
+ components: { ModpackContentModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ // Mix of user and organization owners
+ const mixedContent = [
+ sodiumItem, // User owner
+ fabricApiItem, // Organization owner
+ modMenuItem, // User owner without avatar
+ faithfulItem, // Organization owner
+ ]
+ const openModal = () => modalRef.value?.show(mixedContent)
+ return { modalRef, openModal }
+ },
+ template: /*html*/ `
+
+
+ Shows content with different owner types: users (circular avatar) and organizations (rounded + icon).
+
+
+ View Mixed Owners
+
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/servers/CreationFlowModal.stories.ts b/packages/ui/src/stories/servers/CreationFlowModal.stories.ts
new file mode 100644
index 0000000000..a3007db595
--- /dev/null
+++ b/packages/ui/src/stories/servers/CreationFlowModal.stories.ts
@@ -0,0 +1,128 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { ref } from 'vue'
+
+import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import type { CreationFlowContextValue } from '../../components/flows/creation-flow-modal/creation-flow-context'
+import CreationFlowModal from '../../components/flows/creation-flow-modal/index.vue'
+
+const meta = {
+ title: 'Servers/CreationFlowModal',
+ component: CreationFlowModal,
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+// ============================================
+// Create World (Hosting)
+// ============================================
+
+export const CreateWorld: Story = {
+ name: 'Create World (Hosting)',
+ render: () => ({
+ components: { CreationFlowModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const lastEvent = ref('')
+ const openModal = () => modalRef.value?.show()
+
+ const onCreate = (config: CreationFlowContextValue) => {
+ lastEvent.value = `create emitted — name: "${config.worldName.value}", mode: ${config.gamemode.value}`
+ }
+ return { modalRef, openModal, lastEvent, onCreate }
+ },
+ template: /*html*/ `
+
+
+ Create World
+
+
Last event: {{ lastEvent }}
+
{}"
+ @browse-modpacks="() => console.log('browse-modpacks emitted')"
+ @create="onCreate"
+ />
+
+ `,
+ }),
+}
+
+// ============================================
+// Server Setup (Legacy) (Hosting)
+// ============================================
+
+export const ServerOnboarding: Story = {
+ name: 'Server Setup (Legacy) (Hosting)',
+ render: () => ({
+ components: { CreationFlowModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const lastEvent = ref('')
+ const openModal = () => modalRef.value?.show()
+
+ const onCreate = (config: CreationFlowContextValue) => {
+ lastEvent.value = `create emitted — loader: ${config.selectedLoader.value}, version: ${config.selectedGameVersion.value}`
+ }
+ return { modalRef, openModal, lastEvent, onCreate }
+ },
+ template: /*html*/ `
+
+
+ Set Up Server
+
+
Last event: {{ lastEvent }}
+
{}"
+ @browse-modpacks="() => console.log('browse-modpacks emitted')"
+ @create="onCreate"
+ />
+
+ `,
+ }),
+}
+
+// ============================================
+// Create Instance (App)
+// ============================================
+
+export const Instance: Story = {
+ name: 'Create Instance (App)',
+ render: () => ({
+ components: { CreationFlowModal, ButtonStyled },
+ setup() {
+ const modalRef = ref | null>(null)
+ const lastEvent = ref('')
+ const openModal = () => modalRef.value?.show()
+
+ const onCreate = (config: CreationFlowContextValue) => {
+ lastEvent.value = `create emitted — loader: ${config.selectedLoader.value}, version: ${config.selectedGameVersion.value}`
+ }
+ return { modalRef, openModal, lastEvent, onCreate }
+ },
+ template: /*html*/ `
+
+
+ Create Instance
+
+
Last event: {{ lastEvent }}
+
{}"
+ @browse-modpacks="() => console.log('browse-modpacks emitted')"
+ @create="onCreate"
+ />
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/utils/auto-icons.ts b/packages/ui/src/utils/auto-icons.ts
index cdcb76df9f..102b559de1 100644
--- a/packages/ui/src/utils/auto-icons.ts
+++ b/packages/ui/src/utils/auto-icons.ts
@@ -32,6 +32,13 @@ import {
import type { ProjectStatus, ProjectType } from '@modrinth/utils'
import type { Component } from 'vue'
+import {
+ FILE_ARCHIVE_EXTENSIONS,
+ FILE_CODE_EXTENSIONS,
+ FILE_IMAGE_EXTENSIONS,
+ FILE_TEXT_EXTENSIONS,
+} from './file-extensions'
+
export const PROJECT_TYPE_ICONS: Record = {
mod: BoxIcon,
modpack: PackageOpenIcon,
@@ -88,53 +95,6 @@ const BLOCKCHAIN_CONFIG: Record = {
polygon: { icon: PolygonIcon, color: 'text-purple' },
}
-export const CODE_EXTENSIONS: readonly string[] = [
- 'json',
- 'json5',
- 'jsonc',
- 'java',
- 'kt',
- 'kts',
- 'sh',
- 'bat',
- 'ps1',
- 'yml',
- 'yaml',
- 'toml',
- 'js',
- 'ts',
- 'py',
- 'rb',
- 'php',
- 'html',
- 'css',
- 'cpp',
- 'c',
- 'h',
- 'rs',
- 'go',
-] as const
-
-export const TEXT_EXTENSIONS: readonly string[] = [
- 'txt',
- 'md',
- 'log',
- 'cfg',
- 'conf',
- 'properties',
- 'ini',
- 'sk',
-] as const
-export const IMAGE_EXTENSIONS: readonly string[] = [
- 'png',
- 'jpg',
- 'jpeg',
- 'gif',
- 'svg',
- 'webp',
-] as const
-const ARCHIVE_EXTENSIONS: string[] = ['zip', 'jar', 'tar', 'gz', 'rar', '7z'] as const
-
export function getProjectTypeIcon(projectType: ProjectType): Component {
return PROJECT_TYPE_ICONS[projectType] ?? BoxIcon
}
@@ -162,16 +122,16 @@ export function getDirectoryIcon(name: string): Component {
export function getFileExtensionIcon(extension: string): Component {
const ext: string = extension.toLowerCase()
- if (CODE_EXTENSIONS.includes(ext)) {
+ if ((FILE_CODE_EXTENSIONS as readonly string[]).includes(ext)) {
return FileCodeIcon
}
- if (TEXT_EXTENSIONS.includes(ext)) {
+ if ((FILE_TEXT_EXTENSIONS as readonly string[]).includes(ext)) {
return FileTextIcon
}
- if (IMAGE_EXTENSIONS.includes(ext)) {
+ if ((FILE_IMAGE_EXTENSIONS as readonly string[]).includes(ext)) {
return FileImageIcon
}
- if (ARCHIVE_EXTENSIONS.includes(ext)) {
+ if ((FILE_ARCHIVE_EXTENSIONS as readonly string[]).includes(ext)) {
return FileArchiveIcon
}
diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts
index e2c1db7914..10e9c6be5b 100644
--- a/packages/ui/src/utils/common-messages.ts
+++ b/packages/ui/src/utils/common-messages.ts
@@ -369,6 +369,10 @@ export const commonMessages = defineMessages({
id: 'label.no-items',
defaultMessage: 'No items',
},
+ switchVersionButton: {
+ id: 'button.switch-version',
+ defaultMessage: 'Switch version',
+ },
projectCreated: {
id: 'project.about.details.created',
defaultMessage: 'Created {date}',
diff --git a/packages/ui/src/utils/file-extensions.ts b/packages/ui/src/utils/file-extensions.ts
index 38f11b974c..7908f52b06 100644
--- a/packages/ui/src/utils/file-extensions.ts
+++ b/packages/ui/src/utils/file-extensions.ts
@@ -1,5 +1,5 @@
// File extension constants
-export const CODE_EXTENSIONS = [
+export const FILE_CODE_EXTENSIONS = [
'json',
'json5',
'jsonc',
@@ -26,7 +26,7 @@ export const CODE_EXTENSIONS = [
'go',
] as const
-export const TEXT_EXTENSIONS = [
+export const FILE_TEXT_EXTENSIONS = [
'txt',
'md',
'log',
@@ -37,15 +37,15 @@ export const TEXT_EXTENSIONS = [
'sk',
] as const
-export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'] as const
+export const FILE_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'] as const
-export const ARCHIVE_EXTENSIONS = ['zip'] as const
+export const FILE_ARCHIVE_EXTENSIONS = ['zip', 'jar', 'tar', 'gz', 'rar', '7z'] as const
// Type for extension strings
-export type CodeExtension = (typeof CODE_EXTENSIONS)[number]
-export type TextExtension = (typeof TEXT_EXTENSIONS)[number]
-export type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
-export type ArchiveExtension = (typeof ARCHIVE_EXTENSIONS)[number]
+export type CodeExtension = (typeof FILE_CODE_EXTENSIONS)[number]
+export type TextExtension = (typeof FILE_TEXT_EXTENSIONS)[number]
+export type ImageExtension = (typeof FILE_IMAGE_EXTENSIONS)[number]
+export type ArchiveExtension = (typeof FILE_ARCHIVE_EXTENSIONS)[number]
/**
* Extract file extension from filename (lowercase)
@@ -58,28 +58,28 @@ export function getFileExtension(filename: string): string {
* Check if extension is a code file
*/
export function isCodeFile(ext: string): boolean {
- return (CODE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
+ return (FILE_CODE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
* Check if extension is a text file
*/
export function isTextFile(ext: string): boolean {
- return (TEXT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
+ return (FILE_TEXT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
* Check if extension is an image file
*/
export function isImageFile(ext: string): boolean {
- return (IMAGE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
+ return (FILE_IMAGE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
* Check if extension is an archive file
*/
export function isArchiveFile(ext: string): boolean {
- return (ARCHIVE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
+ return (FILE_ARCHIVE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
diff --git a/packages/ui/src/utils/search.ts b/packages/ui/src/utils/search.ts
index 8d525edf18..d886533105 100644
--- a/packages/ui/src/utils/search.ts
+++ b/packages/ui/src/utils/search.ts
@@ -468,11 +468,13 @@ export function useSearch(
/*
Add environment facets, separate from the rest because it oddly depends on the combination
of filters selected to determine which facets to add.
+ Uses filterValues (merged user + provided filters) so server-provided environment
+ filters are respected.
*/
- const client = currentFilters.value.some(
+ const client = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'client',
)
- const server = currentFilters.value.some(
+ const server = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'server',
)
andFacets.push(...createEnvironmentFacets(client, server))
diff --git a/packages/utils/servers/types/api.ts b/packages/utils/servers/types/api.ts
index 8cc6271f10..d589d6034d 100644
--- a/packages/utils/servers/types/api.ts
+++ b/packages/utils/servers/types/api.ts
@@ -16,4 +16,4 @@ export interface ModuleError {
timestamp: number
}
-export type ModuleName = 'general' | 'content' | 'network' | 'startup'
+export type ModuleName = 'general' | 'network' | 'startup'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f30610492f..801d2c914e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -16,6 +16,9 @@ importers:
'@modrinth/tooling-config':
specifier: workspace:*
version: link:packages/tooling-config
+ '@tailwindcss/container-queries':
+ specifier: ^0.1.1
+ version: 0.1.1(tailwindcss@4.1.18)
'@types/node':
specifier: ^20.1.0
version: 20.19.27
@@ -113,6 +116,9 @@ importers:
floating-vue:
specifier: ^5.2.2
version: 5.2.2(@nuxt/kit@3.20.2(magicast@0.5.1))(vue@3.5.26(typescript@5.9.3))
+ fuse.js:
+ specifier: ^6.6.2
+ version: 6.6.2
intl-messageformat:
specifier: ^10.7.7
version: 10.7.18
@@ -3704,6 +3710,11 @@ packages:
peerDependencies:
vue: ^3.0.0
+ '@tailwindcss/container-queries@0.1.1':
+ resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
+ peerDependencies:
+ tailwindcss: '>=3.2.0'
+
'@tailwindcss/node@4.1.18':
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
@@ -5981,6 +5992,7 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
global-directory@4.0.1:
@@ -12498,6 +12510,10 @@ snapshots:
dependencies:
vue: 3.5.26(typescript@5.9.3)
+ '@tailwindcss/container-queries@0.1.1(tailwindcss@4.1.18)':
+ dependencies:
+ tailwindcss: 4.1.18
+
'@tailwindcss/node@4.1.18':
dependencies:
'@jridgewell/remapping': 2.3.5