diff --git a/CLAUDE.md b/CLAUDE.md
index 7cb1dad1b6..da0ec7725a 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
@@ -78,12 +90,13 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
-- Run commands directly without pipes when possible
-- If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
+- IMPORTANT: Run commands directly without pipes when possible
+- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
+- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
## Skills
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 eed52f8356..872ad5dc16 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -31,6 +31,8 @@ import {
Button,
ButtonStyled,
commonMessages,
+ ContentInstallModal,
+ CreationFlowModal,
defineMessages,
I18nDebugPanel,
NewsArticleCard,
@@ -38,6 +40,7 @@ import {
OverflowMenu,
PopupNotificationPanel,
ProgressSpinner,
+ provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
@@ -66,8 +69,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.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'
@@ -86,6 +87,7 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.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'
@@ -98,15 +100,16 @@ import {
isNetworkMetered,
} from '@/helpers/utils.js'
import i18n from '@/i18n.config'
+import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
+import { createServerInstall, provideServerInstall } from '@/providers/server-install'
+import { setupProviders } from '@/providers/setup'
import { useError } from '@/store/error.js'
-import { playServerProject, 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'
@@ -136,6 +139,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)
@@ -392,7 +404,33 @@ const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
-const install = useInstall()
+const contentInstall = createContentInstall({ router, handleError })
+provideContentInstall(contentInstall)
+const {
+ instances: contentInstallInstances,
+ compatibleLoaders: contentInstallLoaders,
+ gameVersions: contentInstallGameVersions,
+ loading: contentInstallLoading,
+ defaultTab: contentInstallDefaultTab,
+ preferredLoader: contentInstallPreferredLoader,
+ preferredGameVersion: contentInstallPreferredGameVersion,
+ releaseGameVersions: contentInstallReleaseGameVersions,
+ handleInstallToInstance,
+ handleCreateAndInstall,
+ handleCancel: handleContentInstallCancel,
+ setContentInstallModal,
+ setInstallConfirmModal: setContentInstallConfirmModal,
+ setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
+} = contentInstall
+
+const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
+provideServerInstall(serverInstall)
+const {
+ setInstallToPlayModal: setServerInstallToPlayModal,
+ setUpdateToPlayModal: setServerUpdateToPlayModal,
+ setAddServerToInstanceModal: setServerAddServerToInstanceModal,
+} = serverInstall
+
const modInstallModal = ref()
const addServerToInstanceModal = ref()
const installConfirmModal = ref()
@@ -474,13 +512,12 @@ onMounted(() => {
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
- install.setIncompatibilityWarningModal(incompatibilityWarningModal)
- install.setInstallConfirmModal(installConfirmModal)
- install.setModInstallModal(modInstallModal)
- install.setAddServerToInstanceModal(addServerToInstanceModal)
- install.setInstallToPlayModal(installToPlayModal)
- install.setUpdateToPlayModal(updateToPlayModal)
- install.setPopupNotificationManager(popupNotificationManager)
+ setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
+ setContentInstallConfirmModal(installConfirmModal.value)
+ setContentInstallModal(modInstallModal.value)
+ setServerAddServerToInstanceModal(addServerToInstanceModal.value)
+ setServerInstallToPlayModal(installToPlayModal.value)
+ setServerUpdateToPlayModal(updateToPlayModal.value)
})
const accounts = ref(null)
@@ -898,9 +935,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
-
-
+
@@ -946,7 +987,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -1021,9 +1062,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
-
-
+
+
+
-
+
-
+
diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue
index a8e8970aed..56c65fa0b4 100644
--- a/apps/app-frontend/src/components/RowDisplay.vue
+++ b/apps/app-frontend/src/components/RowDisplay.vue
@@ -24,10 +24,11 @@ import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js'
+import { injectContentInstall } from '@/providers/content-install'
import { handleSevereError } from '@/store/error.js'
-import { install as installVersion } from '@/store/install.js'
const { handleError } = injectNotificationManager()
+const { install: installVersion } = injectContentInstall()
const router = useRouter()
diff --git a/apps/app-frontend/src/components/ui/Breadcrumbs.vue b/apps/app-frontend/src/components/ui/Breadcrumbs.vue
index ee12e17ea7..144ed5ebc0 100644
--- a/apps/app-frontend/src/components/ui/Breadcrumbs.vue
+++ b/apps/app-frontend/src/components/ui/Breadcrumbs.vue
@@ -1,64 +1,147 @@
-
-
-
-
-
+
-
-
- {{ 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/SearchCard.vue b/apps/app-frontend/src/components/ui/SearchCard.vue
index f544cd52e9..9933e78053 100644
--- a/apps/app-frontend/src/components/ui/SearchCard.vue
+++ b/apps/app-frontend/src/components/ui/SearchCard.vue
@@ -67,7 +67,10 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
-import { install as installVersion } from '@/store/install.js'
+import { injectContentInstall } from '@/providers/content-install'
+
+const { install: installVersion } = injectContentInstall()
+
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
@@ -99,6 +102,14 @@ const props = defineProps({
type: String,
default: undefined,
},
+ activeLoader: {
+ type: String,
+ default: null,
+ },
+ activeGameVersion: {
+ type: String,
+ default: null,
+ },
})
const emit = defineEmits(['open', 'install'])
@@ -112,13 +123,19 @@ async function install() {
null,
props.instance ? props.instance.path : null,
'SearchCard',
- () => {
+ (versionId) => {
installing.value = false
- emit('install', props.project.project_id ?? props.project.id)
+ if (versionId) {
+ emit('install', props.project.project_id ?? props.project.id)
+ }
},
(profile) => {
router.push(`/instance/${profile}`)
},
+ {
+ preferredLoader: props.activeLoader ?? undefined,
+ preferredGameVersion: props.activeGameVersion ?? undefined,
+ },
).catch(handleError)
}
diff --git a/apps/app-frontend/src/components/ui/URLConfirmModal.vue b/apps/app-frontend/src/components/ui/URLConfirmModal.vue
index a2595061ec..aa7917a5df 100644
--- a/apps/app-frontend/src/components/ui/URLConfirmModal.vue
+++ b/apps/app-frontend/src/components/ui/URLConfirmModal.vue
@@ -6,9 +6,10 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
-import { install as installVersion } from '@/store/install.js'
+import { injectContentInstall } from '@/providers/content-install'
const { handleError } = injectNotificationManager()
+const { install: installVersion } = injectContentInstall()
const confirmModal = ref(null)
const project = ref(null)
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 c8e6267d62..bc8898322e 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
@@ -1,63 +1,41 @@
- 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,
- })
- }}
-
-
- {{
- modpackProject
- ? modpackVersion
- ? modpackVersion?.version_number
- : props.isMinecraftServer
- ? ''
- : 'Unknown version'
- : formatLoader(formatMessage, instance.loader)
- }}
-
- {{ instance.loader_version || formatMessage(messages.unknownVersion) }}
-
-
- {{ instance.loader }}
- {{ instance.loader_version }}
-
-
-
-
-
-
-
-
-
- {{
- repairing
- ? formatMessage(messages.repairingButton)
- : formatMessage(messages.repairButton)
- }}
-
-
-
- {
- changingVersion = true
- modpackVersionModal.show()
- }
- "
- >
-
-
- {{
- changingVersion
- ? formatMessage(messages.installingButton)
- : formatMessage(messages.changeVersionButton)
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- formatMessage(messages.noLoaderVersions, {
- loader: loader,
- version: gameVersion,
- })
- }}
-
-
-
-
-
-
-
- {{
- editing
- ? formatMessage(messages.installingButton)
- : formatMessage(messages.installButton)
- }}
-
-
-
- {
- loader = instance.loader
- gameVersion = instance.game_version
- resetLoaderVersionIndex()
- }
- "
- >
-
- {{ formatMessage(messages.resetSelections) }}
-
-
-
-
-
-
-
-
- {{
- formatMessage(
- props.isMinecraftServer
- ? instance.loader === 'vanilla'
- ? messages.unlinkServerVanillaDescription
- : messages.unlinkServerDescription
- : messages.unlinkInstanceDescription,
- )
- }}
-
-
-
- {{ formatMessage(messages.unlinkInstanceButton) }}
-
-
-
-
-
-
- {{ formatMessage(messages.reinstallModpackDescription) }}
-
-
-
-
-
-
- {{
- reinstalling
- ? formatMessage(messages.reinstallingModpackButton)
- : formatMessage(messages.reinstallModpackButton)
- }}
-
-
-
-
-
-
+
diff --git a/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue
index 6964a83f68..f27f874921 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/components/ui/modal/UpdateToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
index b635383ec7..899ed04256 100644
--- a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
+++ b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
@@ -146,7 +146,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
-import { useInstall } from '@/store/install.js'
+import { injectServerInstall } from '@/providers/server-install'
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
@@ -186,7 +186,7 @@ type ProjectInfo = {
}
const { formatMessage } = useVIntl()
-const installStore = useInstall()
+const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise
const modal = ref>()
@@ -353,7 +353,7 @@ watch(
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.linked_data?.project_id
- if (serverProjectId) installStore.startInstallingServer(serverProjectId)
+ if (serverProjectId) startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
@@ -362,7 +362,7 @@ async function handleUpdate() {
} catch (error) {
console.error('Error updating instance:', error)
} finally {
- if (serverProjectId) installStore.stopInstallingServer(serverProjectId)
+ if (serverProjectId) stopInstallingServer(serverProjectId)
}
}
diff --git a/apps/app-frontend/src/helpers/cache.js b/apps/app-frontend/src/helpers/cache.js
index bdf4bc6a47..ab10222526 100644
--- a/apps/app-frontend/src/helpers/cache.js
+++ b/apps/app-frontend/src/helpers/cache.js
@@ -67,3 +67,17 @@ export async function get_search_results_v3_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 ef920b7c4f..0000000000
--- a/apps/app-frontend/src/helpers/profile.js
+++ /dev/null
@@ -1,216 +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,
- linkedData,
-) {
- //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,
- linkedData,
- })
-}
-
-// 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, serverAddress = null) {
- return await invoke('plugin:profile|profile_run', { path, serverAddress })
-}
-
-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..b2405c512a
--- /dev/null
+++ b/apps/app-frontend/src/helpers/profile.ts
@@ -0,0 +1,289 @@
+/**
+ * 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,
+ linkedData?: { project_id: string; version_id: string; locked: boolean } | null,
+): 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,
+ linkedData,
+ })
+}
+
+// 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, serverAddress: string | null = null): Promise {
+ return await invoke('plugin:profile|profile_run', { path, serverAddress })
+}
+
+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 4f5487a0d6..de90ff404d 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 e31e04aa33..4b723d710a 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.content-required": {
"message": "Content required"
},
@@ -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"
},
@@ -344,150 +398,9 @@
"instance.settings.tabs.installation": {
"message": "Installation"
},
- "instance.settings.tabs.installation.change-version.already-installed.modded": {
- "message": "{platform} {version} for Minecraft {game_version} already installed"
- },
- "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"
- },
- "instance.settings.tabs.installation.reinstall.button": {
- "message": "Reinstall modpack"
- },
- "instance.settings.tabs.installation.reinstall.button.reinstalling": {
- "message": "Reinstalling modpack"
- },
- "instance.settings.tabs.installation.reinstall.confirm.description": {
- "message": "Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation. This may fix unexpected behavior if changes have been made to the instance, but if your worlds now depend on additional installed content, it may break existing worlds."
- },
- "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"
- },
- "instance.settings.tabs.installation.repair.button.repairing": {
- "message": "Repairing"
- },
- "instance.settings.tabs.installation.repair.confirm.description": {
- "message": "Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods."
- },
- "instance.settings.tabs.installation.repair.confirm.title": {
- "message": "Repair instance?"
- },
- "instance.settings.tabs.installation.repair.in-progress": {
- "message": "Repair in progress"
- },
- "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"
- },
- "instance.settings.tabs.installation.tooltip.action.reinstall": {
- "message": "reinstall"
- },
- "instance.settings.tabs.installation.tooltip.action.repair": {
- "message": "repair"
- },
- "instance.settings.tabs.installation.tooltip.cannot-while-installing": {
- "message": "Cannot {action} while installing"
- },
- "instance.settings.tabs.installation.tooltip.cannot-while-offline": {
- "message": "Cannot {action} while offline"
- },
- "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-server-vanilla.description": {
- "message": "This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server."
- },
- "instance.settings.tabs.installation.unlink-server.description": {
- "message": "This instance is linked to a server, 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 server."
- },
- "instance.settings.tabs.installation.unlink-server.title": {
- "message": "Unlink from server"
- },
- "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 0578390317..c5de22ad9a 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -51,12 +51,14 @@ import {
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { getServerLatency } from '@/helpers/worlds'
+import { injectServerInstall } from '@/providers/server-install'
import { useBreadcrumbs } from '@/store/breadcrumbs'
-import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
+import { getServerAddress } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
-const installStore = useInstall()
+const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
+ injectServerInstall()
const router = useRouter()
const route = useRoute()
@@ -108,6 +110,10 @@ const instanceProjects: Ref = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref([])
const isServerInstance = ref(false)
+// 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']
@@ -185,7 +191,7 @@ const instanceFilters = computed(() => {
.filter((x) => x.metadata)
.map((x) => x.metadata.project_id)
- installedMods.push(...newlyInstalled.value)
+ installedMods.push(...newlyInstalledSnapshot)
installedMods
?.map((x) => ({
@@ -221,6 +227,19 @@ const {
createPageParams,
} = useSearch(projectTypes, tags, instanceFilters)
+const activeLoader = computed(() => {
+ const filter = currentFilters.value.find((f) => f.type === 'mod_loader')
+ if (filter) return filter.option
+ if (projectType.value === 'datapack' || projectType.value === 'resourcepack') return 'vanilla'
+ return instance.value?.loader ?? null
+})
+
+const activeGameVersion = computed(() => {
+ const filter = currentFilters.value.find((f) => f.type === 'game_version')
+ if (filter) return filter.option
+ return instance.value?.game_version ?? null
+})
+
const serverHits = shallowRef([])
const serverPings = shallowRef>({})
const runningServerProjects = ref>({})
@@ -256,7 +275,7 @@ async function handlePlayServerProject(projectId: string) {
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
- installStore.showAddServerToInstanceModal(project.name, address)
+ showAddServerToInstanceModal(project.name, address)
}
const unlistenProcesses = await process_listener(
@@ -346,6 +365,7 @@ watch(effectiveRequestParams, async () => {
async function refreshSearch() {
if (isRefreshing.value) return
isRefreshing.value = true
+ newlyInstalledSnapshot = [...newlyInstalled.value]
try {
const isServer = projectType.value === 'server'
@@ -854,18 +874,12 @@ previousFilterState.value = JSON.stringify({
handlePlayServerProject(project.project_id)"
>
{{
- (installStore.installingServerProjects as string[]).includes(
- project.project_id,
- )
+ (installingServerProjects as string[]).includes(project.project_id)
? 'Installing...'
: 'Play'
}}
@@ -882,6 +896,19 @@ previousFilterState.value = JSON.stringify({
:project-type="projectType"
:project="result"
:instance="instance ?? undefined"
+ :active-loader="activeLoader ?? undefined"
+ :active-game-version="activeGameVersion ?? undefined"
+ :categories="[
+ ...(categories ?? []).filter(
+ (cat) =>
+ result?.display_categories.includes(cat.name) && cat.project_type === projectType,
+ ),
+ ...(loaders ?? []).filter(
+ (loader) =>
+ result?.display_categories.includes(loader.name) &&
+ loader.supported_project_types?.includes(projectType),
+ ),
+ ]"
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
@install="
(id) => {
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index fa2c554ad4..88759e93d8 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -110,9 +110,13 @@
-
-
-
-
-
-
-
- {{ 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.
-
-
-
-
-
-
-
-
+
+
-
-
-
-
diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue
index 6623d140bd..3c7f8673df 100644
--- a/apps/app-frontend/src/pages/instance/Worlds.vue
+++ b/apps/app-frontend/src/pages/instance/Worlds.vue
@@ -175,13 +175,11 @@ import {
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds.ts'
-import {
- ensureManagedServerWorldExists,
- getServerAddress,
- playServerProject,
-} from '@/store/install'
+import { injectServerInstall } from '@/providers/server-install'
+import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
const { handleError } = injectNotificationManager()
+const { playServerProject } = injectServerInstall()
const route = useRoute()
const addServerModal = ref>()
diff --git a/apps/app-frontend/src/pages/library/Index.vue b/apps/app-frontend/src/pages/library/Index.vue
index b6ab192d80..9694143aad 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/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue
index c465180bb2..6e4c7c3db8 100644
--- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue
+++ b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue
@@ -152,10 +152,7 @@
This does not affect your backups, which are stored off-site.
-
+
@@ -205,6 +202,8 @@ import {
BackupWarning,
ButtonStyled,
Combobox,
+ injectModrinthClient,
+ injectModrinthServerContext,
injectNotificationManager,
NewModal,
Toggle,
@@ -213,12 +212,13 @@ import {
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
-import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoaderIcon from './icons/LoaderIcon.vue'
import LoadingIcon from './icons/LoadingIcon.vue'
+const { server, serverId } = injectModrinthServerContext()
+const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -236,7 +236,6 @@ type VersionMap = Record
type VersionCache = Record
const props = defineProps<{
- server: ModrinthServer
currentLoader: Loaders | undefined
backupInProgress?: BackupInProgressReason
initialSetup?: boolean
@@ -472,11 +471,14 @@ const handleReinstall = async () => {
isLoading.value = true
try {
- await props.server.general?.reinstall(
- true,
- selectedLoader.value,
- selectedMCVersion.value,
- selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
+ await client.archon.servers_v0.reinstall(
+ serverId,
+ {
+ loader: selectedLoader.value,
+ loader_version:
+ selectedLoader.value === 'Vanilla' ? undefined : selectedLoaderVersion.value || undefined,
+ game_version: selectedMCVersion.value,
+ },
props.initialSetup ? true : hardReset.value,
)
@@ -507,7 +509,7 @@ const handleReinstall = async () => {
}
const onShow = () => {
- selectedMCVersion.value = props.server.general?.mc_version || ''
+ selectedMCVersion.value = server.value?.mc_version || ''
if (isSnapshotSelected.value) {
showSnapshots.value = true
}
@@ -530,7 +532,7 @@ const show = (loader: Loaders) => {
selectedLoaderVersion.value = ''
}
selectedLoader.value = loader
- selectedMCVersion.value = props.server.general?.mc_version || ''
+ selectedMCVersion.value = server.value?.mc_version || ''
versionSelectModal.value?.show()
}
const hide = () => versionSelectModal.value?.hide()
diff --git a/apps/frontend/src/components/ui/servers/SaveBanner.vue b/apps/frontend/src/components/ui/servers/SaveBanner.vue
index 91f73f917f..d06b16f6bf 100644
--- a/apps/frontend/src/components/ui/servers/SaveBanner.vue
+++ b/apps/frontend/src/components/ui/servers/SaveBanner.vue
@@ -30,9 +30,7 @@
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 96748eec55..3be4de5ba1 100644
--- a/apps/frontend/src/composables/featureFlags.ts
+++ b/apps/frontend/src/composables/featureFlags.ts
@@ -42,6 +42,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/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index 68a0e1c992..c79718c514 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -1292,6 +1292,33 @@
"hosting-marketing.why.your-favorite-mods.description": {
"message": "Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can run on your server."
},
+ "hosting.loader.failed-to-change-version": {
+ "message": "Failed to change modpack version"
+ },
+ "hosting.loader.failed-to-load-versions": {
+ "message": "Failed to load versions"
+ },
+ "hosting.loader.failed-to-reinstall": {
+ "message": "Failed to reinstall modpack"
+ },
+ "hosting.loader.failed-to-repair": {
+ "message": "Failed to repair server"
+ },
+ "hosting.loader.failed-to-save-settings": {
+ "message": "Failed to save installation settings"
+ },
+ "hosting.loader.failed-to-unlink": {
+ "message": "Failed to unlink modpack"
+ },
+ "hosting.loader.loader-version": {
+ "message": "{loader} version"
+ },
+ "hosting.loader.reset-server": {
+ "message": "Reset server"
+ },
+ "hosting.loader.reset-server-description": {
+ "message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored."
+ },
"hosting.plan.out-of-stock": {
"message": "Out of stock"
},
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)
- }}
-
+
-
+
-
- {{ server.general.loader }} {{ server.general.mc_version }}
+ {{ serverData.loader }} {{ serverData.mc_version }}
-
+
-
+
- Back to server
-
+ {{
+ fromContext === 'onboarding'
+ ? 'Back to setup'
+ : fromContext === 'reset-server'
+ ? 'Cancel reset'
+ : 'Back to server'
+ }}
+
-
+
}"
aria-label="Filters"
>
-
+
-
-
Options
-
-
- Erase all data on install
-
-
-
- If enabled, existing mods, worlds, and configurations, will be deleted before installing
- the selected modpack.
-
-
-
@update:model-value="updateSearchResults()"
/>
-
-
-
- {{ filterType.formatted_name }}
-
-
-
-
-
-
- {{ filter.formatted_name }}
-
-
-
-
- {{ formatMessage(messages.gameVersionShaderMessage) }}
-
-
-
- {{ formatMessage(messages.gameVersionProvidedByServer) }}
-
-
- {{ formatMessage(messages.modLoaderProvidedByServer) }}
-
- {{ formatMessage(messages.syncFilterButton) }}
-
-
+
+
+ {{ filter.formatted_name }}
+
+
+
+
+ {{ formatMessage(messages.gameVersionShaderMessage) }}
+
+
+
+ {{ formatMessage(messages.gameVersionProvidedByServer) }}
+
+
+ {{ formatMessage(messages.modLoaderProvidedByServer) }}
+
+ {{ formatMessage(messages.syncFilterButton) }}
+
@@ -727,10 +769,10 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@@ -776,14 +818,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
/>
-
:provided-message="messages.providedByServer"
/>
-
+
No results found for your query!
@@ -808,37 +835,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
-
+
-
-
-
-
:environment="
['mod', 'modpack'].includes(currentType)
? {
- clientSide: result.client_side as Labrinth.Projects.v2.Environment,
- serverSide: result.server_side as Labrinth.Projects.v2.Environment,
+ clientSide: result.client_side,
+ serverSide: result.server_side,
}
: undefined
"
@@ -869,7 +867,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
-
+
@@ -893,16 +891,16 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
-
+
@@ -933,6 +931,18 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
+
+ {}"
+ @create="onModpackFlowCreate"
+ />
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/index.vue
index 4d39cbff51..dbea1bbf87 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/index.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/index.vue
@@ -1,78 +1,55 @@
-
-
-
-
-
-
-
- {{ serverData?.name }} shut down unexpectedly. We've automatically analyzed the logs
- and found the following problems:
-
-
-
-
- {{ problem.message }}
-
-
-
- {{ solution.message }}
-
-
-
-
-
-
-
-
-
-
{{ serverData?.name }} shut down unexpectedly.
-
-
- The server stopped because it ran out of memory. There may be a memory leak caused
- by a mod or plugin, or you may need to upgrade your Modrinth Server.
-
-
- We could not automatically determine the specific cause of the crash, but your
- server exited with code
- {{ props.powerStateDetails.exit_code }}.
- {{
- props.powerStateDetails.exit_code === 1
- ? 'There may be a mod or plugin causing the issue, or an issue with your server configuration.'
- : ''
- }}
-
-
We could not determine the specific cause of the crash.
-
You can try restarting the server.
-
-
-
-
-
-
-
-
{{ serverData?.name }} shut down unexpectedly.
-
- We could not find any specific problems, but you can try restarting the server.
-
+
+
+ We automatically analyzed the logs and found the following:
+
+
+
+
{{ problem.message }}
+
+
+ {{ solution.message }}
+
+
-
-
-
-
-
-
-
+
+
+
+ The server stopped because it ran out of memory. There may be a memory leak caused by a
+ mod or plugin, or you may need to upgrade your Modrinth Server.
+
+
+ Your server exited with code {{ props.powerStateDetails.exit_code }}.
+
+ There may be a mod or plugin causing the issue, or an issue with your server
+ configuration.
+
+
+ We could not determine the specific cause of the crash.
+ You can try restarting the server.
+
+
+ We could not find any specific problems, but you can try restarting the server.
+
+
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..4dc0d63606 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
@@ -1,9 +1,11 @@
-
+
+
Server properties
@@ -22,7 +24,7 @@
- Search server properties
+ Search server properties
-
- {{ formatPropertyName(index) }}
-
-
-
-
+
{{ formatPropertyName(key) }}
+
-
-
-
- The server properties file has not been generated yet. Start up your server to generate it.
-
+
+
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..c48452832c 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 @@
-
+
@@ -70,168 +53,203 @@
different Java version to work properly.
-
-
-
Show all Java versions
+
+
+
+
+
+
+ {{ showAllVersions ? 'Hide extra versions' : 'Show all 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/apps/frontend/wrangler.jsonc b/apps/frontend/wrangler.jsonc
index 5239054eb4..1502e7bdab 100644
--- a/apps/frontend/wrangler.jsonc
+++ b/apps/frontend/wrangler.jsonc
@@ -50,8 +50,8 @@
"vars": {
"ENVIRONMENT": "staging",
"SENTRY_ENVIRONMENT": "staging",
- "BASE_URL": "https://staging-api.modrinth.com/v2/",
- "BROWSER_BASE_URL": "https://staging-api.modrinth.com/v2/",
+ "BASE_URL": "https://api.modrinth.com/v2/",
+ "BROWSER_BASE_URL": "https://api.modrinth.com/v2/",
"PYRO_BASE_URL": "https://staging-archon.modrinth.com/",
"STRIPE_PUBLISHABLE_KEY": "pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b"
},
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/features/node-auth.ts b/packages/api-client/src/features/node-auth.ts
index cee76feb78..62424d5d09 100644
--- a/packages/api-client/src/features/node-auth.ts
+++ b/packages/api-client/src/features/node-auth.ts
@@ -8,6 +8,8 @@ import type { RequestContext } from '../types/request'
export interface NodeAuth {
/** Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") */
url: string
+ /** Base URL without path suffix (e.g., "node-xyz.modrinth.com") — used when available */
+ baseUrl?: string
/** JWT token */
token: string
}
@@ -105,7 +107,7 @@ export class NodeAuthFeature extends AbstractFeature {
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
- const baseUrl = `https://${auth.url.replace('v0/fs', '')}`
+ const baseUrl = `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {
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..d2d8da14e9 100644
--- a/packages/api-client/src/modules/archon/backups/v1.ts
+++ b/packages/api-client/src/modules/archon/backups/v1.ts
@@ -1,102 +1,84 @@
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.
- * - The schema will change when Backups v4 (routes stay as v1) so remember to do that.
- */
-const DEFAULT_WORLD_ID: string = '00000000-0000-0000-0000-000000000000' as const
-
export class ArchonBackupsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v1'
}
- /** GET /v1/:server_id/worlds/:world_id/backups */
- public async list(
- serverId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
+ /** GET /v1/servers/:server_id/worlds/:world_id/backups */
+ public async list(serverId: string, worldId: string): 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,
+ worldId: string,
backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
): 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,
+ worldId: string,
request: Archon.Backups.v1.BackupRequest,
- worldId: string = DEFAULT_WORLD_ID,
): 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 */
- public async restore(
- serverId: string,
- backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}/restore`, {
- api: 'archon',
- version: 1,
- method: 'POST',
- })
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/restore */
+ public async restore(serverId: string, worldId: string, backupId: string): Promise {
+ 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 */
- public async delete(
- serverId: string,
- backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
+ /** DELETE /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
+ public async delete(serverId: string, worldId: string, backupId: string): Promise {
+ 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 */
- public async retry(
- serverId: string,
- backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}/retry`, {
- api: 'archon',
- version: 1,
- method: 'POST',
- })
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/retry */
+ public async retry(serverId: string, worldId: string, backupId: string): Promise {
+ 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,
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..1ffa4a36f3
--- /dev/null
+++ b/packages/api-client/src/modules/archon/content/v1.ts
@@ -0,0 +1,232 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+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,
+ 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,
+ worldId: string,
+ request: Archon.Content.v1.AddAddonRequest,
+ ): 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,
+ worldId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ ): 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,
+ worldId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ ): 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,
+ worldId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ ): 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,
+ worldId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ ): 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,
+ worldId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ ): 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,
+ worldId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ ): 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,
+ worldId: string,
+ request: Archon.Content.v1.InstallWorldContent,
+ ): 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): 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,
+ worldId: string,
+ filename: string,
+ ): 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,
+ worldId: string,
+ request: Archon.Content.v1.UpdateAddonRequest,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/update`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/update-many */
+ public async updateAddons(
+ serverId: string,
+ worldId: string,
+ addons: Archon.Content.v1.UpdateAddonRequest[],
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/update-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { addons },
+ })
+ }
+
+ /** GET /v1/:server_id/worlds/:world_id/content/modpack/update */
+ public async getModpackUpdate(
+ serverId: string,
+ worldId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/content/modpack/update`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/content/modpack/update */
+ public async updateModpack(serverId: string, worldId: string): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/content/modpack/update`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/archon/index.ts b/packages/api-client/src/modules/archon/index.ts
index 304edc0749..917630f840 100644
--- a/packages/api-client/src/modules/archon/index.ts
+++ b/packages/api-client/src/modules/archon/index.ts
@@ -1,6 +1,6 @@
-export * from './backups/v0'
export * from './backups/v1'
-export * from './content/v0'
+export * from './content/v1'
+export * from './properties/v1'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'
diff --git a/packages/api-client/src/modules/archon/options/v1.ts b/packages/api-client/src/modules/archon/options/v1.ts
new file mode 100644
index 0000000000..00b978c22a
--- /dev/null
+++ b/packages/api-client/src/modules/archon/options/v1.ts
@@ -0,0 +1,37 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+export class ArchonOptionsV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'archon_options_v1'
+ }
+
+ /** GET /v1/servers/:server_id/worlds/:world_id/options/startup */
+ public async getStartup(
+ serverId: string,
+ worldId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/options/startup`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** PATCH /v1/servers/:server_id/worlds/:world_id/options/startup */
+ public async patchStartup(
+ serverId: string,
+ worldId: string,
+ body: Archon.Content.v1.PatchRuntimeOptions,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/options/startup`, {
+ api: 'archon',
+ version: 1,
+ method: 'PATCH',
+ body,
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/archon/properties/v1.ts b/packages/api-client/src/modules/archon/properties/v1.ts
new file mode 100644
index 0000000000..d62fa911fd
--- /dev/null
+++ b/packages/api-client/src/modules/archon/properties/v1.ts
@@ -0,0 +1,40 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+export class ArchonPropertiesV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'archon_properties_v1'
+ }
+
+ /** GET /v1/servers/:server_id/worlds/:world_id/properties */
+ public async getProperties(
+ serverId: string,
+ worldId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/properties`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** PATCH /v1/servers/:server_id/worlds/:world_id/properties */
+ public async patchProperties(
+ serverId: string,
+ worldId: string,
+ body: Archon.Content.v1.PatchPropertiesFields,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/properties`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'PATCH',
+ body,
+ },
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/archon/servers/v0.ts b/packages/api-client/src/modules/archon/servers/v0.ts
index a21301e2b4..14097a8e5c 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,210 @@ 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',
+ })
+ }
}
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..1ae59c86ed 100644
--- a/packages/api-client/src/modules/archon/types.ts
+++ b/packages/api-client/src/modules/archon/types.ts
@@ -1,37 +1,167 @@
+import type { Labrinth } from '../labrinth/types'
+
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
+ environment?: Labrinth.Projects.v3.Environment | 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: string | null
+ 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
+ version_id?: string | null
+ }
+
+ export type Modloader =
+ | 'forge'
+ | 'neo_forge'
+ | 'fabric'
+ | 'quilt'
+ | 'paper'
+ | 'purpur'
+ | 'vanilla'
+
+ export type ModpackSpec = {
+ platform: 'modrinth'
project_id: string
version_id: string
}
+
+ export type ModpackOwner = {
+ id: string
+ name: string
+ type: 'user' | 'organization'
+ icon_url: string | null
+ }
+
+ export type ModpackFields = {
+ spec: ModpackSpec
+ has_update: string | null
+ title: string | null
+ description: string | null
+ icon_url: string | null
+ owner: ModpackOwner | null
+ version_number: string | null
+ date_published: string | null
+ downloads: number | null
+ followers: number | null
+ }
+
+ export type KnownPropertiesFields = {
+ allow_cheats?: string | null
+ allow_flight?: string | null
+ difficulty?: string | null
+ enforce_whitelist?: string | null
+ force_gamemode?: string | null
+ gamemode?: string | null
+ generate_structures?: string | null
+ generator_settings?: string | null
+ hardcore?: string | null
+ level_seed?: string | null
+ level_type?: string | null
+ max_players?: string | null
+ max_tick_time?: string | null
+ motd?: string | null
+ pause_when_empty_seconds?: string | null
+ player_idle_timeout?: string | null
+ require_resource_pack?: string | null
+ resource_pack?: string | null
+ resource_pack_id?: string | null
+ resource_pack_sha1?: string | null
+ simulation_distance?: string | null
+ spawn_protection?: string | null
+ sync_chunk_writes?: string | null
+ view_distance?: string | null
+ white_list?: string | null
+ }
+
+ export type PropertiesFields = {
+ known: KnownPropertiesFields
+ custom?: Record
+ }
+
+ export type PatchPropertiesFields = {
+ known?: KnownPropertiesFields
+ custom?: Record
+ }
+
+ export type JreVendor = 'temurin' | 'corretto' | 'graal'
+
+ export type RuntimeOptions = {
+ java_version: number | null
+ jre_vendor: JreVendor | null
+ original_invocation: string | null
+ startup_command: string | null
+ }
+
+ export type PatchRuntimeOptions = {
+ java_version?: number | null
+ jre_vendor?: JreVendor | null
+ startup_command?: string | null
+ }
+
+ export type InstallWorldContent =
+ | {
+ content_variant: 'modpack'
+ spec: ModpackSpec
+ soft_override: boolean
+ properties?: PropertiesFields | null
+ }
+ | {
+ content_variant: 'bare'
+ loader: Modloader
+ version: string
+ game_version?: string
+ soft_override: boolean
+ properties?: PropertiesFields | null
+ }
}
}
@@ -148,9 +278,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 +390,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 +534,37 @@ 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 = 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
+
+ export type SyncContentProgress = {
+ started_at: string
+ phase: SyncInstallPhase
+ percent: number
+ }
+
+ export type WSStateEvent = {
+ event: 'state'
+ debug: string
+ 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 +583,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 884a59b496..d7cfe81457 100644
--- a/packages/api-client/src/modules/index.ts
+++ b/packages/api-client/src/modules/index.ts
@@ -1,13 +1,15 @@
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 { ArchonOptionsV1Module } from './archon/options/v1'
+import { ArchonPropertiesV1Module } from './archon/properties/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'
@@ -17,6 +19,9 @@ import { LabrinthStateModule } from './labrinth/state'
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
import { LabrinthUsersV2Module } from './labrinth/users/v2'
+import { LauncherMetaManifestV0Module } from './launcher-meta/v0'
+import { PaperVersionsV3Module } from './paper/v3'
+import { PurpurVersionsV2Module } from './purpur/v2'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
@@ -30,12 +35,15 @@ 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_options_v1: ArchonOptionsV1Module,
+ archon_properties_v1: ArchonPropertiesV1Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,
+ launchermeta_manifest_v0: LauncherMetaManifestV0Module,
+ kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
@@ -46,7 +54,10 @@ export const MODULE_REGISTRY = {
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
labrinth_threads_v3: LabrinthThreadsV3Module,
labrinth_users_v2: LabrinthUsersV2Module,
+ labrinth_versions_v2: LabrinthVersionsV2Module,
labrinth_versions_v3: LabrinthVersionsV3Module,
+ paper_versions_v3: PaperVersionsV3Module,
+ purpur_versions_v2: PurpurVersionsV2Module,
} as const satisfies Record
export type ModuleID = keyof typeof MODULE_REGISTRY
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..205c2ff69d
--- /dev/null
+++ b/packages/api-client/src/modules/kyros/content/v1.ts
@@ -0,0 +1,65 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { UploadHandle, UploadProgress } from '../../../types/upload'
+import type { Archon } from '../../archon/types'
+
+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,
+ })
+ }
+
+ /** POST /v1/worlds/:world_id/content/upload-modpack-file */
+ public uploadModpackFile(
+ worldId: string,
+ file: File | Blob,
+ properties: Archon.Content.v1.PropertiesFields,
+ options?: {
+ softOverride?: boolean
+ onProgress?: (progress: UploadProgress) => void
+ },
+ ): UploadHandle {
+ const formData = new FormData()
+ formData.append('file', file, file instanceof File ? file.name : 'file')
+ formData.append('properties', JSON.stringify(properties))
+
+ return this.client.upload(`/worlds/${worldId}/content/upload-modpack-file`, {
+ api: '',
+ version: 'v1',
+ formData,
+ params:
+ options?.softOverride !== undefined
+ ? { soft_override: String(options.softOverride) }
+ : undefined,
+ onProgress: options?.onProgress,
+ useNodeAuth: true,
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts
index 10ed33dfa7..4712c08379 100644
--- a/packages/api-client/src/modules/kyros/files/v0.ts
+++ b/packages/api-client/src/modules/kyros/files/v0.ts
@@ -22,7 +22,7 @@ export class KyrosFilesV0Module extends AbstractModule {
): Promise {
return this.client.request('/fs/list', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'GET',
params: { path, page, page_size: pageSize },
useNodeAuth: true,
@@ -38,7 +38,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise {
return this.client.request('/fs/create', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'POST',
params: { path, type },
headers: { 'Content-Type': 'application/octet-stream' },
@@ -55,7 +55,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async downloadFile(path: string): Promise {
return this.client.request('/fs/download', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'GET',
params: { path },
useNodeAuth: true,
@@ -80,7 +80,7 @@ export class KyrosFilesV0Module extends AbstractModule {
): UploadHandle {
return this.client.upload('/fs/create', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
file,
params: { path, type: 'file' },
onProgress: options?.onProgress,
@@ -100,7 +100,7 @@ export class KyrosFilesV0Module extends AbstractModule {
return this.client.request('/fs/update', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'PUT',
params: { path },
body: blob,
@@ -118,7 +118,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async moveFileOrFolder(sourcePath: string, destPath: string): Promise {
return this.client.request('/fs/move', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'POST',
body: { source: sourcePath, destination: destPath },
useNodeAuth: true,
@@ -145,7 +145,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async deleteFileOrFolder(path: string, recursive: boolean): Promise {
return this.client.request('/fs/delete', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'DELETE',
params: { path, recursive },
useNodeAuth: true,
diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts
index 0de97699fd..0ec5552091 100644
--- a/packages/api-client/src/modules/labrinth/index.ts
+++ b/packages/api-client/src/modules/labrinth/index.ts
@@ -7,4 +7,5 @@ export * from './state'
export * from './tech-review/internal'
export * from './threads/v3'
export * from './users/v2'
+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 c8018c014a..f98ac2bf96 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -617,6 +617,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
@@ -632,7 +638,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/modules/launcher-meta/types.ts b/packages/api-client/src/modules/launcher-meta/types.ts
new file mode 100644
index 0000000000..309bf1eca0
--- /dev/null
+++ b/packages/api-client/src/modules/launcher-meta/types.ts
@@ -0,0 +1,19 @@
+export namespace LauncherMeta {
+ export namespace Manifest {
+ export namespace v0 {
+ export type LoaderVersion = {
+ id: string
+ stable: boolean
+ }
+
+ export type GameVersionEntry = {
+ id: string
+ loaders: LoaderVersion[]
+ }
+
+ export type Manifest = {
+ gameVersions: GameVersionEntry[]
+ }
+ }
+ }
+}
diff --git a/packages/api-client/src/modules/launcher-meta/v0.ts b/packages/api-client/src/modules/launcher-meta/v0.ts
new file mode 100644
index 0000000000..2135051e5d
--- /dev/null
+++ b/packages/api-client/src/modules/launcher-meta/v0.ts
@@ -0,0 +1,23 @@
+import { $fetch } from 'ofetch'
+
+import { AbstractModule } from '../../core/abstract-module'
+import type { LauncherMeta } from './types'
+
+export type { LauncherMeta } from './types'
+
+const BASE_URL = 'https://launcher-meta.modrinth.com'
+
+export class LauncherMetaManifestV0Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'launchermeta_manifest_v0'
+ }
+
+ /**
+ * Get the loader manifest for a given loader platform.
+ *
+ * @param loader - Loader platform (fabric, forge, quilt, neo)
+ */
+ public async getManifest(loader: string): Promise {
+ return $fetch(`${BASE_URL}/${loader}/v0/manifest.json`)
+ }
+}
diff --git a/packages/api-client/src/modules/paper/types.ts b/packages/api-client/src/modules/paper/types.ts
new file mode 100644
index 0000000000..f8febff0c0
--- /dev/null
+++ b/packages/api-client/src/modules/paper/types.ts
@@ -0,0 +1,9 @@
+export namespace Paper {
+ export namespace Versions {
+ export namespace v3 {
+ export type VersionBuilds = {
+ builds: number[]
+ }
+ }
+ }
+}
diff --git a/packages/api-client/src/modules/paper/v3.ts b/packages/api-client/src/modules/paper/v3.ts
new file mode 100644
index 0000000000..83ea2fcad2
--- /dev/null
+++ b/packages/api-client/src/modules/paper/v3.ts
@@ -0,0 +1,25 @@
+import { $fetch } from 'ofetch'
+
+import { AbstractModule } from '../../core/abstract-module'
+import type { Paper } from './types'
+
+export type { Paper } from './types'
+
+const BASE_URL = 'https://fill.papermc.io/v3'
+
+export class PaperVersionsV3Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'paper_versions_v3'
+ }
+
+ /**
+ * Get available Paper builds for a Minecraft version.
+ *
+ * @param mcVersion - Minecraft version (e.g. "1.21.4")
+ */
+ public async getBuilds(mcVersion: string): Promise {
+ return $fetch(
+ `${BASE_URL}/projects/paper/versions/${mcVersion}`,
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/purpur/types.ts b/packages/api-client/src/modules/purpur/types.ts
new file mode 100644
index 0000000000..998b818095
--- /dev/null
+++ b/packages/api-client/src/modules/purpur/types.ts
@@ -0,0 +1,11 @@
+export namespace Purpur {
+ export namespace Versions {
+ export namespace v2 {
+ export type VersionBuilds = {
+ builds: {
+ all: string[]
+ }
+ }
+ }
+ }
+}
diff --git a/packages/api-client/src/modules/purpur/v2.ts b/packages/api-client/src/modules/purpur/v2.ts
new file mode 100644
index 0000000000..441be34e02
--- /dev/null
+++ b/packages/api-client/src/modules/purpur/v2.ts
@@ -0,0 +1,23 @@
+import { $fetch } from 'ofetch'
+
+import { AbstractModule } from '../../core/abstract-module'
+import type { Purpur } from './types'
+
+export type { Purpur } from './types'
+
+const BASE_URL = 'https://api.purpurmc.org/v2'
+
+export class PurpurVersionsV2Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'purpur_versions_v2'
+ }
+
+ /**
+ * Get available Purpur builds for a Minecraft version.
+ *
+ * @param mcVersion - Minecraft version (e.g. "1.21.4")
+ */
+ public async getBuilds(mcVersion: string): Promise {
+ return $fetch(`${BASE_URL}/purpur/${mcVersion}`)
+ }
+}
diff --git a/packages/api-client/src/modules/types.ts b/packages/api-client/src/modules/types.ts
index f0aff3b3c7..3baa5cf661 100644
--- a/packages/api-client/src/modules/types.ts
+++ b/packages/api-client/src/modules/types.ts
@@ -2,3 +2,6 @@ export * from './archon/types'
export * from './iso3166/types'
export * from './kyros/types'
export * from './labrinth/types'
+export * from './launcher-meta/types'
+export * from './paper/types'
+export * from './purpur/types'
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/api-client/src/platform/websocket-generic.ts b/packages/api-client/src/platform/websocket-generic.ts
index 135007706e..00cbca570e 100644
--- a/packages/api-client/src/platform/websocket-generic.ts
+++ b/packages/api-client/src/platform/websocket-generic.ts
@@ -57,14 +57,30 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
ws.onclose = (event) => {
+ console.debug(`[WebSocket] Closed for server ${serverId}:`, {
+ code: event.code,
+ reason: event.reason,
+ wasClean: event.wasClean,
+ })
if (event.code !== NORMAL_CLOSURE) {
this.scheduleReconnect(serverId, auth)
}
}
- ws.onerror = (error) => {
- console.error(`[WebSocket] Error for server ${serverId}:`, error)
- reject(new Error(`WebSocket connection failed for server ${serverId}`))
+ ws.onerror = (event) => {
+ const url = ws.url
+ const readyState = ws.readyState
+ console.error(`[WebSocket] Error for server ${serverId}:`, {
+ url,
+ readyState,
+ readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
+ type: (event as Event).type,
+ })
+ reject(
+ new Error(
+ `WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
+ ),
+ )
}
} catch (error) {
reject(error)
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 123b5d429c..62dc68a24d 100644
--- a/packages/app-lib/src/api/cache.rs
+++ b/packages/app-lib/src/api/cache.rs
@@ -53,3 +53,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 efa9b20752..5cafca7249 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -18,12 +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, ProjectV3, SearchResult, SearchResults,
- SearchResultsV3, 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,
+ ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
+ TeamMember, Theme, User, UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
}
diff --git a/packages/app-lib/src/api/pack/install_from.rs b/packages/app-lib/src/api/pack/install_from.rs
index 6f68af259e..29f940d6cc 100644
--- a/packages/app-lib/src/api/pack/install_from.rs
+++ b/packages/app-lib/src/api/pack/install_from.rs
@@ -1,4 +1,5 @@
use crate::State;
+use crate::api::profile;
use crate::data::ModLoader;
use crate::event::emit::{emit_loading, init_loading};
use crate::event::{LoadingBarId, LoadingBarType};
@@ -226,6 +227,24 @@ pub async fn generate_pack_from_version_id(
})?;
emit_loading(&loading_bar, 10.0, None)?;
+ // Update profile with correct loader and game version from the API version metadata,
+ // so the UI shows accurate info while the pack file is still downloading.
+ if let Some(game_version) = version.game_versions.first() {
+ let loader = version
+ .loaders
+ .first()
+ .map(|l| ModLoader::from_string(l))
+ .unwrap_or(ModLoader::Vanilla);
+ let game_version = game_version.clone();
+ let profile_path_clone = profile_path.clone();
+ profile::edit(&profile_path_clone, |prof| {
+ prof.game_version.clone_from(&game_version);
+ prof.loader = loader;
+ async { Ok(()) }
+ })
+ .await?;
+ }
+
let (url, hash) =
if let Some(file) = version.files.iter().find(|x| x.primary) {
Some((file.url.clone(), file.hashes.get("sha1")))
@@ -303,6 +322,12 @@ pub async fn generate_pack_from_version_id(
None
};
+ // Set the icon immediately so the UI shows it during download.
+ if let Some(ref icon_path) = icon {
+ let _ =
+ profile::edit_icon(&profile_path, Some(icon_path.as_path())).await;
+ }
+
Ok(CreatePack {
file,
description: CreatePackDescription {
diff --git a/packages/app-lib/src/api/pack/install_mrpack.rs b/packages/app-lib/src/api/pack/install_mrpack.rs
index 0fdead311c..91b14b3644 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,53 @@ 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);
+ }
+
+ tracing::info!(
+ "Caching {} modpack file hashes for version {}",
+ file_hashes.len(),
+ version_id
+ );
+ CachedEntry::cache_modpack_files(version_id, file_hashes, &state.pool)
+ .await?;
+ } else {
+ tracing::warn!(
+ "No version_id available, skipping modpack file hash caching"
+ );
+ }
+
// 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 6f122fa964..68c9b35e33 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};
@@ -92,6 +93,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 803b0a01e0..1346b6d887 100644
--- a/packages/app-lib/src/state/cache.rs
+++ b/packages/app-lib/src/state/cache.rs
@@ -36,6 +36,9 @@ pub enum CacheValueType {
FileUpdate,
SearchResults,
SearchResultsV3,
+ ModpackFiles,
+ /// Cached list of versions for a project (without changelogs for fast loading)
+ ProjectVersions,
}
impl CacheValueType {
@@ -59,6 +62,8 @@ impl CacheValueType {
CacheValueType::FileUpdate => "file_update",
CacheValueType::SearchResults => "search_results",
CacheValueType::SearchResultsV3 => "search_results_v3",
+ CacheValueType::ModpackFiles => "modpack_files",
+ CacheValueType::ProjectVersions => "project_versions",
}
}
@@ -82,6 +87,8 @@ impl CacheValueType {
"file_update" => CacheValueType::FileUpdate,
"search_results" => CacheValueType::SearchResults,
"search_results_v3" => CacheValueType::SearchResultsV3,
+ "modpack_files" => CacheValueType::ModpackFiles,
+ "project_versions" => CacheValueType::ProjectVersions,
_ => CacheValueType::Project,
}
}
@@ -91,7 +98,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
}
}
@@ -126,11 +136,27 @@ impl CacheValueType {
| CacheValueType::LoaderManifest
| CacheValueType::FileUpdate
| CacheValueType::SearchResults
- | CacheValueType::SearchResultsV3 => None,
+ | CacheValueType::SearchResultsV3
+ | 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,
+}
+
// De/serialization strategy:
// - on serialize:
// - in the `cache` table, save the `data_type` (variant of this value) alongside
@@ -165,6 +191,8 @@ pub enum CacheValue {
FileUpdate(CachedFileUpdate),
SearchResults(SearchResults),
SearchResultsV3(SearchResultsV3),
+ ModpackFiles(CachedModpackFiles),
+ ProjectVersions(CachedProjectVersions),
ProjectV3(ProjectV3),
}
@@ -349,7 +377,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,
@@ -499,6 +528,8 @@ impl CacheValue {
CacheValue::FileUpdate(_) => CacheValueType::FileUpdate,
CacheValue::SearchResults(_) => CacheValueType::SearchResults,
CacheValue::SearchResultsV3(_) => CacheValueType::SearchResultsV3,
+ CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles,
+ CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions,
}
}
@@ -541,6 +572,8 @@ impl CacheValue {
}
CacheValue::SearchResults(search) => search.search.clone(),
CacheValue::SearchResultsV3(search) => search.search.clone(),
+ CacheValue::ModpackFiles(files) => files.version_id.clone(),
+ CacheValue::ProjectVersions(pv) => pv.project_id.clone(),
}
}
@@ -567,7 +600,9 @@ impl CacheValue {
| CacheValue::LoaderManifest { .. }
| CacheValue::FileUpdate(_)
| CacheValue::SearchResults(_)
- | CacheValue::SearchResultsV3(_) => None,
+ | CacheValue::SearchResultsV3(_)
+ | CacheValue::ModpackFiles(_)
+ | CacheValue::ProjectVersions(_) => None,
}
}
@@ -601,6 +636,8 @@ impl CacheValue {
CacheValue::FileUpdate(update) => serde_json::to_value(update),
CacheValue::SearchResults(search) => serde_json::to_value(search),
CacheValue::SearchResultsV3(search) => serde_json::to_value(search),
+ CacheValue::ModpackFiles(files) => serde_json::to_value(files),
+ CacheValue::ProjectVersions(pv) => serde_json::to_value(pv),
}
.map_err(|err| {
crate::ErrorKind::OtherError(format!(
@@ -1515,6 +1552,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
+ }
CacheValueType::SearchResultsV3 => {
let fetch_urls = keys
.iter()
@@ -1628,6 +1715,12 @@ impl CachedEntry {
CacheValueType::SearchResultsV3 => CacheValue::SearchResultsV3(
parse(data, id, "search_results_v3")?,
),
+ CacheValueType::ModpackFiles => {
+ CacheValue::ModpackFiles(parse(data, id, "modpack_files")?)
+ }
+ CacheValueType::ProjectVersions => CacheValue::ProjectVersions(
+ parse(data, id, "project_versions")?,
+ ),
};
Ok(value)
@@ -1700,6 +1793,83 @@ 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,
+ fetch_semaphore: &FetchSemaphore,
+ ) -> crate::Result> {
+ let entry = Self::get(
+ CacheValueType::ModpackFiles,
+ version_id,
+ None,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ if let Some(CachedEntry {
+ data: Some(CacheValue::ModpackFiles(files)),
+ ..
+ }) = entry
+ {
+ 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..91c8d436aa
--- /dev/null
+++ b/packages/app-lib/src/state/instances/content.rs
@@ -0,0 +1,863 @@
+//! # 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);
+ };
+
+ // Vanilla server projects have linked_data with an empty version_id
+ if linked_data.version_id.is_empty() {
+ 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
+ {
+ if linked_data.version_id.is_empty() {
+ HashSet::new()
+ } else {
+ tracing::info!(
+ "Fetching modpack file hashes for version_id={}, project_id={}",
+ linked_data.version_id,
+ linked_data.project_id
+ );
+ match get_modpack_file_hashes(
+ &linked_data.version_id,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ {
+ Ok(hashes) => {
+ tracing::info!(
+ "Got {} modpack file hashes for version {}",
+ hashes.len(),
+ linked_data.version_id
+ );
+ hashes
+ }
+ Err(e) => {
+ tracing::error!(
+ "Failed to fetch modpack file hashes for version {}: {}",
+ linked_data.version_id,
+ 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, fetch_semaphore)
+ .await?
+ {
+ tracing::info!(
+ "Cache hit: {} modpack file hashes for version {}",
+ cached.file_hashes.len(),
+ version_id
+ );
+ return Ok(cached.file_hashes.into_iter().collect());
+ }
+
+ tracing::warn!(
+ "Cache miss: 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/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs
index 6669ee129a..063e1d138d 100644
--- a/packages/app-lib/src/state/profiles.rs
+++ b/packages/app-lib/src/state/profiles.rs
@@ -640,6 +640,8 @@ impl Profile {
&& let Some(file_name) = subdirectory
.file_name()
.and_then(|x| x.to_str())
+ && !(project_type == ProjectType::ShaderPack
+ && file_name.ends_with(".txt"))
{
let file_size = subdirectory
.metadata()
@@ -934,6 +936,8 @@ impl Profile {
if subdirectory.is_file()
&& let Some(file_name) =
subdirectory.file_name().and_then(|x| x.to_str())
+ && !(project_type == ProjectType::ShaderPack
+ && file_name.ends_with(".txt"))
{
let file_size = subdirectory
.metadata()
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index ea1d254f20..6a7f30daf4 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'
@@ -165,6 +167,7 @@ import _PackageOpenIcon from './icons/package-open.svg?component'
import _PackagePlusIcon from './icons/package-plus.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'
@@ -346,6 +349,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'
@@ -390,6 +394,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
@@ -422,6 +427,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
@@ -542,6 +548,7 @@ export const PackageOpenIcon = _PackageOpenIcon
export const PackagePlusIcon = _PackagePlusIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
+export const PencilIcon = _PencilIcon
export const PickaxeIcon = _PickaxeIcon
export const PlayIcon = _PlayIcon
export const PlugIcon = _PlugIcon
@@ -723,6 +730,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/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 02e39d825d..4fb4028886 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 ac9a731481..d775c85df0 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'
@@ -24,9 +25,11 @@ import {
type I18nContext,
type NotificationPanelLocation,
type PopupNotification,
+ provideFilePicker,
provideModrinthClient,
provideNotificationManager,
providePopupNotificationManager,
+ provideTags,
type WebNotification,
} from '../src/providers'
@@ -152,6 +155,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/CLAUDE.md b/packages/ui/CLAUDE.md
index afa8be97fe..4096abac8e 100644
--- a/packages/ui/CLAUDE.md
+++ b/packages/ui/CLAUDE.md
@@ -8,6 +8,7 @@ The shared UI package used by both `apps/frontend` (Nuxt 3) and `apps/app-fronte
src/
├── components/ # Vue components organized by feature domain
├── composables/ # Vue 3 composition API hooks
+├── layouts/ # Self-contained page layouts (see below)
├── providers/ # Dependency injection contexts (createContext pattern)
├── utils/ # Utility functions and constants
├── pages/ # Cross platform page components (used in both app-frontend and frontend)
@@ -18,6 +19,15 @@ src/
Each subdirectory under `components/` has an `index.ts` barrel file. All public API is re-exported from the root `index.ts`.
+### `src/layouts/`
+
+Self-contained page layouts shared across frontends. Split into two categories:
+
+- **`shared/`** — Reusable layout modules with their own components, composables, providers, and types. Each module is a self-contained unit (e.g. `shared/content-tab/` contains the content/mods tab layout with its own `layout.vue`, `components/`, `composables/`, `providers/`, and `types.ts`).
+- **`wrapped/`** — Page-level Vue components that mirror route structures (e.g. `wrapped/hosting/manage/`). These are full page implementations consumed by both `apps/frontend` and `apps/app-frontend`.
+
+Files inside `layouts/` use the `#ui/*` import alias (resolved via the `"imports"` field in `package.json`) to reference other `src/` modules like `#ui/components/base/ButtonStyled.vue` or `#ui/composables/i18n`.
+
# Code Guidelines
### Tailwind Configuration
diff --git a/packages/ui/index.ts b/packages/ui/index.ts
index 0ec2785326..f9844f0e27 100644
--- a/packages/ui/index.ts
+++ b/packages/ui/index.ts
@@ -1,6 +1,6 @@
export * from './src/components'
export * from './src/composables'
+export * from './src/layouts'
export * from './src/locales'
-export * from './src/pages'
export * from './src/providers'
export * from './src/utils'
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 6bae52de85..dc5f433c2b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -5,6 +5,9 @@
"private": true,
"main": "./index.ts",
"types": "./index.ts",
+ "imports": {
+ "#ui/*": "./src/*"
+ },
"exports": {
".": {
"types": "./index.ts",
diff --git a/packages/ui/src/components/base/Accordion.vue b/packages/ui/src/components/base/Accordion.vue
index 4f9b6ba5d8..0871fc2e99 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/Admonition.vue b/packages/ui/src/components/base/Admonition.vue
index e00bac0c1a..77d1387764 100644
--- a/packages/ui/src/components/base/Admonition.vue
+++ b/packages/ui/src/components/base/Admonition.vue
@@ -1,11 +1,28 @@
-
+
+
+
+
+
+
+
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/Chips.vue b/packages/ui/src/components/base/Chips.vue
index 3b772f83aa..71d3d5866d 100644
--- a/packages/ui/src/components/base/Chips.vue
+++ b/packages/ui/src/components/base/Chips.vue
@@ -1,8 +1,10 @@
-
+
+
@@ -7,15 +19,78 @@
+
diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue
index cbced13e78..5bc3e86cce 100644
--- a/packages/ui/src/components/base/Combobox.vue
+++ b/packages/ui/src/components/base/Combobox.vue
@@ -1,24 +1,49 @@
+
+
+
+
+
+
+
+
@@ -36,95 +61,86 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ item.label }}
-
-
- {{ item.subLabel }}
-
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.subLabel }}
+
+
-
-
-
-
-
+
+
+
+
+
+
+ {{ noOptionsMessage }}
+
-
- {{ noOptionsMessage }}
+
-
+
@@ -132,16 +148,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 2d65d00812..3cd8b01b47 100644
--- a/packages/ui/src/components/base/FloatingActionBar.vue
+++ b/packages/ui/src/components/base/FloatingActionBar.vue
@@ -3,6 +3,7 @@ import { onUnmounted, watch } from 'vue'
const props = defineProps<{
shown: boolean
+ ariaLabel?: string
}>()
watch(
@@ -20,9 +21,15 @@ onUnmounted(() => {
-
+
@@ -32,6 +39,8 @@ onUnmounted(() => {
+
+
diff --git a/packages/ui/src/components/base/MultiStageModal.vue b/packages/ui/src/components/base/MultiStageModal.vue
index 2e06806594..82910ae3fb 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"
:fade="fade"
:disable-close="resolveCtxFn(currentStage.disableClose, context)"
@@ -59,7 +59,7 @@
+
{{ rightButtonConfig.label }}
+
@@ -108,7 +117,7 @@
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..347d1b3722
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue
@@ -0,0 +1,191 @@
+
+
+
+ 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..2dee3119df
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
@@ -0,0 +1,166 @@
+
+
+
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..71598537ce
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue
@@ -0,0 +1,96 @@
+
+
+
+ {{
+ ctx.flowType === 'instance'
+ ? 'Choose instance type'
+ : ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server'
+ ? 'Select installation type'
+ : 'Select world type'
+ }}
+
+
+
+
+
+
+
+
+
+
+ An instance is a Minecraft setup with a specific loader, version, and mods.
+
+
+
+
+
+
+
+
+
+
+
+ We recommend creating a
+ backup
+ before proceeding so you can restore your world if anything breaks.
+
+
+
+
+
+
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..aeb65b8749
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts
@@ -0,0 +1,368 @@
+import type { Archon } from '@modrinth/api-client'
+import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue'
+import type { ComponentExposed } from 'vue-component-type-helpers'
+
+import { useDebugLogger } from '#ui/composables/debug-logger'
+
+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' | 'reset-server' | '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',
+ 'reset-server': 'Reset 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
+ buildProperties: () => Archon.Content.v1.PropertiesFields
+}
+
+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 debug = useDebugLogger('CreationFlow')
+ 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) {
+ debug('setSetupType:', type)
+ 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() {
+ debug('finish() called, state:', {
+ setupType: setupType.value,
+ selectedLoader: selectedLoader.value,
+ selectedGameVersion: selectedGameVersion.value,
+ selectedLoaderVersion: selectedLoaderVersion.value,
+ modpackSelection: modpackSelection.value,
+ hasModpackFile: !!modpackFile.value,
+ })
+ loading.value = true
+ emit.create(contextValue)
+ }
+
+ function buildProperties(): Archon.Content.v1.PropertiesFields {
+ const isHardcore = gamemode.value === 'hardcore'
+ const known: Archon.Content.v1.KnownPropertiesFields = {
+ gamemode: isHardcore ? 'survival' : gamemode.value,
+ hardcore: isHardcore ? 'true' : 'false',
+ difficulty: difficulty.value,
+ level_seed: worldSeed.value || null,
+ level_type: worldTypeOption.value,
+ generate_structures: String(generateStructures.value),
+ }
+
+ if (generatorSettingsMode.value === 'flat') {
+ known.generator_settings = ''
+ } else if (generatorSettingsMode.value === 'custom' && generatorSettingsCustom.value) {
+ known.generator_settings = generatorSettingsCustom.value
+ }
+
+ return { known }
+ }
+
+ 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,
+ buildProperties,
+ }
+
+ 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..8be58ff7cb
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/index.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
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..0adb134824
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/shared.ts
@@ -0,0 +1,3 @@
+export { formatLoaderLabel, loaderDisplayNames } from '#ui/utils/loaders'
+
+export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1)
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..701b018a7d
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts
@@ -0,0 +1,70 @@
+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' ||
+ ctx.flowType === 'reset-server'
+ 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..d2ee28b0d6
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts
@@ -0,0 +1,59 @@
+import { 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 isReset = ctx.flowType === 'reset-server'
+ const isFinish = isWorld || isOnboarding || isReset
+ return {
+ label: isWorld
+ ? 'Create world'
+ : isReset
+ ? 'Reset server'
+ : isOnboarding
+ ? 'Setup server'
+ : 'Continue',
+ icon: isFinish ? PlusIcon : RightArrowIcon,
+ iconPosition: isFinish ? ('before' as const) : ('after' as const),
+ color: isReset ? ('red' as const) : 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..0cd429c47c
--- /dev/null
+++ b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts
@@ -0,0 +1,15 @@
+import type { StageConfigInput } from '../../../base'
+import type { CreationFlowContextValue } from '../creation-flow-context'
+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,
+]
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/index.ts b/packages/ui/src/components/index.ts
index 7204b200dc..1f00fa63a7 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -5,7 +5,6 @@ export * from './brand'
export * from './changelog'
export * from './chart'
export * from './content'
-export * from './instances'
export * from './modal'
export * from './nav'
export * from './page'
diff --git a/packages/ui/src/components/instances/ContentModpackCard.vue b/packages/ui/src/components/instances/ContentModpackCard.vue
deleted file mode 100644
index 36373f6202..0000000000
--- a/packages/ui/src/components/instances/ContentModpackCard.vue
+++ /dev/null
@@ -1,183 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ project.title }}
-
-
-
-
-
-
- {{ owner.name }}
-
-
-
-
-
-
- v{{ version.version_number }}
-
-
-
-
-
- {{ formatTimeAgo(new Date(version.date_published)) }}
-
-
-
-
-
-
-
-
-
-
- {{ formatMessage(commonMessages.updateButton) }}
-
-
-
-
-
- {{ formatMessage(commonMessages.contentLabel) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ project.description }}
-
-
-
-
-
- {{ formatCompact(project.downloads) }}
-
-
-
-
- {{ formatCompact(project.followers) }}
-
-
-
-
- {{ cat.name }}
-
-
-
-
-
diff --git a/packages/ui/src/components/instances/index.ts b/packages/ui/src/components/instances/index.ts
deleted file mode 100644
index 05edcf31b3..0000000000
--- a/packages/ui/src/components/instances/index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-export { default as ContentCardItem } from './ContentCardItem.vue'
-export { default as ContentCardTable } from './ContentCardTable.vue'
-/**
- * @deprecated Use `ContentCardTable` with `ContentCardItem` instead.
- * This alias is kept for backwards compatibility and will be removed in a future version.
- */
-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 ModpackContentModal } from './modals/ModpackContentModal.vue'
-export type {
- ContentCardProject,
- ContentCardTableItem,
- ContentCardVersion,
- ContentModpackCardCategory,
- ContentModpackCardProject,
- ContentModpackCardVersion,
- ContentOwner,
-} from './types'
diff --git a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue b/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
deleted file mode 100644
index d736cb8266..0000000000
--- a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
+++ /dev/null
@@ -1,391 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- v{{ version.version_number }}
-
-
- {{ getBadgeLabel(version) }}
-
-
-
-
-
- {{ formatMessage(messages.noVersionsFound) }}
-
-
-
-
-
-
-
-
-
-
- {{
- hideIncompatibleState
- ? formatMessage(messages.showIncompatible)
- : formatMessage(messages.hideIncompatible)
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- v{{ selectedVersion.version_number }}
-
-
- {{ getBadgeLabel(selectedVersion) }}
-
-
-
- {{ formatLongDate(selectedVersion.date_published) }}
-
-
-
-
-
- {{
- formatMessage(commonMessages.changelogLabel)
- }}
-
-
-
- {{ formatLoaderGameVersion(selectedVersion) }}
-
-
-
-
-
-
-
-
-
-
- {{ formatMessage(messages.noChangelog) }}
-
-
-
-
-
-
- {{ formatMessage(messages.selectVersionPrompt) }}
-
-
-
-
-
-
- {{
- formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
- }}
-
-
-
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
- {{
- formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, {
- version: selectedVersion?.version_number ?? '...',
- })
- }}
-
-
-
-
-
-
-
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 4b609687b3..c50e1779d1 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,
]"
@@ -29,7 +29,12 @@
}"
>
-
-
+
@@ -55,7 +65,12 @@
class="absolute top-4 right-4 z-10"
circular
>
-
+
@@ -64,14 +79,14 @@
@@ -91,14 +106,14 @@
@@ -113,7 +128,7 @@
You just lost the game.
-
@@ -123,11 +138,25 @@
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..6035aee9de
--- /dev/null
+++ b/packages/ui/src/components/servers/ServerSetupModal.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
{{ formatMessage(messages.uploadWarningText) }}
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/backups/BackupCreateModal.vue b/packages/ui/src/components/servers/backups/BackupCreateModal.vue
index e4f4dab7aa..b907c8487c 100644
--- a/packages/ui/src/components/servers/backups/BackupCreateModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupCreateModal.vue
@@ -90,7 +90,8 @@ 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..f9b5a5c57d 100644
--- a/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
@@ -58,7 +58,8 @@ 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 @@