diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 0fbc724f6e..dbdc2a35dc 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -31,6 +31,7 @@ import { Button, ButtonStyled, commonMessages, + CreationFlowModal, defineMessages, I18nDebugPanel, NewsArticleCard, @@ -65,7 +66,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue' import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue' import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue' import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue' -import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' import NavButton from '@/components/ui/NavButton.vue' @@ -84,6 +84,7 @@ import { get_user } from '@/helpers/cache.js' import { command_listener, warning_listener } from '@/helpers/events.js' import { useFetch } from '@/helpers/fetch.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' +import { create_profile_and_install_from_file } from '@/helpers/pack' import { list } from '@/helpers/profile.js' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' @@ -100,11 +101,11 @@ import { provideAppUpdateDownloadProgress, subscribeToDownloadProgress, } from '@/providers/download-progress.ts' +import { setupProviders } from '@/providers/setup' import { useError } from '@/store/error.js' import { useInstall } from '@/store/install.js' import { useLoading, useTheming } from '@/store/state' -import { create_profile_and_install_from_file } from './helpers/pack' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { get_available_capes, get_available_skins } from './helpers/skins' import { AppNotificationManager } from './providers/app-notifications' @@ -134,6 +135,10 @@ provideModalBehavior({ onShow: () => hide_ads_window(), onHide: () => show_ads_window(), }) + +const { installationModal, handleCreate, handleBrowseModpacks } = + setupProviders(notificationManager) + const news = ref([]) const availableSurvey = ref(false) @@ -804,9 +809,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - - - +
@@ -852,7 +861,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) 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 @@ - - - - - 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.ts b/apps/app-frontend/src/helpers/profile.ts index 96b6676712..5107c34b93 100644 --- a/apps/app-frontend/src/helpers/profile.ts +++ b/apps/app-frontend/src/helpers/profile.ts @@ -7,7 +7,7 @@ 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.js' +import { install_to_existing_profile } from '@/helpers/pack' import type { CacheBehaviour, diff --git a/apps/app-frontend/src/pages/library/Index.vue b/apps/app-frontend/src/pages/library/Index.vue index 93d039fae9..ae96dcac63 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/servers/PlatformChangeModpackVersionModal.vue b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue index 2c9072a731..e7b0c74ed7 100644 --- a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue +++ b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue @@ -64,15 +64,22 @@ diff --git a/apps/frontend/src/components/ui/servers/ServerInstallation.vue b/apps/frontend/src/components/ui/servers/ServerInstallation.vue index 0168d4f203..4f7b0378a1 100644 --- a/apps/frontend/src/components/ui/servers/ServerInstallation.vue +++ b/apps/frontend/src/components/ui/servers/ServerInstallation.vue @@ -1,22 +1,16 @@ - + Switch modpack @@ -105,7 +99,7 @@ v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined" :class="{ disabled: backupInProgress }" class="!w-full sm:!w-auto" - :to="`/discover/modpacks?sid=${props.server.serverId}`" + :to="`/discover/modpacks?sid=${serverId}`" > Find a modpack @@ -139,9 +133,9 @@ class="flex w-full flex-col gap-1 rounded-2xl" :class="{ 'pointer-events-none cursor-not-allowed select-none opacity-50': - props.server.general?.status === 'installing', + server.value?.status === 'installing', }" - :tabindex="props.server.general?.status === 'installing' ? -1 : 0" + :tabindex="server.value?.status === 'installing' ? -1 : 0" > import { CompassIcon, InfoIcon, SettingsIcon, TransferIcon, UploadIcon } from '@modrinth/assets' -import { ButtonStyled, ProjectCard, useVIntl } from '@modrinth/ui' +import { ButtonStyled, injectModrinthServerContext, ProjectCard, useVIntl } from '@modrinth/ui' import type { Loaders } from '@modrinth/utils' +import { useQueryClient } from '@tanstack/vue-query' -import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts' import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue' import LoaderSelector from './LoaderSelector.vue' @@ -176,10 +170,11 @@ import PlatformChangeModpackVersionModal from './PlatformChangeModpackVersionMod import PlatformMrpackModal from './PlatformMrpackModal.vue' import PlatformVersionSelectModal from './PlatformVersionSelectModal.vue' +const { server, serverId } = injectModrinthServerContext() +const queryClient = useQueryClient() const { formatMessage } = useVIntl() -const props = defineProps<{ - server: ModrinthServer +defineProps<{ ignoreCurrentInstallation?: boolean backupInProgress?: BackupInProgressReason }>() @@ -188,13 +183,13 @@ const emit = defineEmits<{ reinstall: [any?] }>() -const isInstalling = computed(() => props.server.general?.status === 'installing') +const isInstalling = computed(() => server.value?.status === 'installing') const versionSelectModal = ref() const mrpackModal = ref() const modpackVersionModal = ref() -const data = computed(() => props.server.general) +const data = server const { data: versions, @@ -266,13 +261,13 @@ const updateAvailable = computed(() => { }) watch( - () => props.server.general?.status, + () => server.value?.status, async (newStatus, oldStatus) => { if (oldStatus === 'installing' && newStatus === 'available') { await Promise.all([ refreshVersions(), refreshCurrentVersion(), - props.server.refresh(['general']), + queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }), ]) } }, 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/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts deleted file mode 100644 index c449a83e41..0000000000 --- a/apps/frontend/src/composables/servers/modrinth-servers.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { AbstractWebNotificationManager } from '@modrinth/ui' -import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils' -import { ModrinthServerError } from '@modrinth/utils' - -import { 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 network: NetworkModule - readonly startup: StartupModule - - constructor(serverId: string) { - this.serverId = serverId - - this.general = new GeneralModule(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 - } - - const cached = localStorage.getItem(`server-icon-${this.serverId}`) - if (cached) { - sharedImage.value = cached - return cached - } - - 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 - localStorage.setItem(`server-icon-${this.serverId}`, 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 - localStorage.setItem(`server-icon-${this.serverId}`, 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', '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 'network': - await this.network.fetch() - break - case 'startup': - await this.startup.fetch() - break - } - } catch (error) { - if (error instanceof ModrinthServerError) { - 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/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 ea54fce52d..0000000000 --- a/apps/frontend/src/composables/servers/modules/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './backups.ts' -export * from './base.ts' -export * from './general.ts' -export * from './network.ts' -export * from './startup.ts' -export * from './ws.ts' diff --git a/apps/frontend/src/composables/servers/modules/network.ts b/apps/frontend/src/composables/servers/modules/network.ts deleted file mode 100644 index d434f77001..0000000000 --- a/apps/frontend/src/composables/servers/modules/network.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Allocation } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class NetworkModule extends ServerModule { - allocations: Allocation[] = [] - - async fetch(): Promise { - this.allocations = await useServersFetch( - `servers/${this.serverId}/allocations`, - {}, - 'network', - ) - } - - async reserveAllocation(name: string): Promise { - return await useServersFetch(`servers/${this.serverId}/allocations?name=${name}`, { - method: 'POST', - }) - } - - async updateAllocation(port: number, name: string): Promise { - await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, { - method: 'PUT', - }) - } - - async deleteAllocation(port: number): Promise { - await useServersFetch(`servers/${this.serverId}/allocations/${port}`, { - method: 'DELETE', - }) - } - - async checkSubdomainAvailability(subdomain: string): Promise { - const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as { - available: boolean - } - return result.available - } - - async changeSubdomain(subdomain: string): Promise { - await useServersFetch(`servers/${this.serverId}/subdomain`, { - method: 'POST', - body: { subdomain }, - }) - } -} diff --git a/apps/frontend/src/composables/servers/modules/startup.ts b/apps/frontend/src/composables/servers/modules/startup.ts deleted file mode 100644 index a47c031f69..0000000000 --- a/apps/frontend/src/composables/servers/modules/startup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { JDKBuild, JDKVersion, Startup } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class StartupModule extends ServerModule implements Startup { - invocation!: string - original_invocation!: string - jdk_version!: JDKVersion - jdk_build!: JDKBuild - - async fetch(): Promise { - const data = await useServersFetch(`servers/${this.serverId}/startup`, {}, 'startup') - Object.assign(this, data) - } - - async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise { - await useServersFetch(`servers/${this.serverId}/startup`, { - method: 'POST', - body: { - invocation: invocation || null, - jdk_version: jdkVersion || null, - jdk_build: jdkBuild || null, - }, - }) - } -} diff --git a/apps/frontend/src/composables/servers/modules/ws.ts b/apps/frontend/src/composables/servers/modules/ws.ts deleted file mode 100644 index aa10a30294..0000000000 --- a/apps/frontend/src/composables/servers/modules/ws.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { JWTAuth } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class WSModule extends ServerModule implements JWTAuth { - url!: string - token!: string - - async fetch(): Promise { - const data = await useServersFetch(`servers/${this.serverId}/ws`, {}, 'ws') - Object.assign(this, data) - } -} diff --git a/apps/frontend/src/composables/servers/use-server-image.ts b/apps/frontend/src/composables/servers/use-server-image.ts new file mode 100644 index 0000000000..c613a79ae5 --- /dev/null +++ b/apps/frontend/src/composables/servers/use-server-image.ts @@ -0,0 +1,131 @@ +import type { Archon } from '@modrinth/api-client' +import { injectModrinthClient } from '@modrinth/ui' +import { type ComputedRef, ref, watch } from 'vue' + +// TODO: Remove and use V1 when available +export function useServerImage( + serverId: string, + upstream: ComputedRef, +) { + const client = injectModrinthClient() + const image = ref() + + const sharedImage = useState(`server-icon-${serverId}`) + if (sharedImage.value) { + image.value = sharedImage.value + } + + async function loadImage() { + if (sharedImage.value) { + image.value = sharedImage.value + return + } + + if (import.meta.server) return + + const cached = localStorage.getItem(`server-icon-${serverId}`) + if (cached) { + sharedImage.value = cached + image.value = cached + return + } + + let projectIconUrl: string | undefined + const upstreamVal = upstream.value + if (upstreamVal?.project_id) { + try { + const project = await $fetch<{ icon_url?: string }>( + `https://api.modrinth.com/v2/project/${upstreamVal.project_id}`, + ) + projectIconUrl = project.icon_url + } catch { + // project fetch failed, continue without icon url + } + } + + try { + const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png') + + if (fileData instanceof Blob) { + const dataURL = await resizeImage(fileData, 512) + sharedImage.value = dataURL + localStorage.setItem(`server-icon-${serverId}`, dataURL) + image.value = dataURL + return + } + } catch (error: any) { + if (error?.statusCode >= 500) { + image.value = undefined + return + } + + if (error?.statusCode === 404 && projectIconUrl) { + try { + const response = await fetch(projectIconUrl) + if (!response.ok) throw new Error('Failed to fetch icon') + const file = await response.blob() + const originalFile = new File([file], 'server-icon-original.png', { + type: 'image/png', + }) + + const dataURL = await new Promise((resolve) => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.onload = () => { + canvas.width = 64 + canvas.height = 64 + ctx?.drawImage(img, 0, 0, 64, 64) + canvas.toBlob(async (blob) => { + if (blob) { + const scaledFile = new File([blob], 'server-icon.png', { + type: 'image/png', + }) + client.kyros.files_v0 + .uploadFile('/server-icon.png', scaledFile) + .promise.catch(() => {}) + client.kyros.files_v0 + .uploadFile('/server-icon-original.png', originalFile) + .promise.catch(() => {}) + } + }, 'image/png') + const result = canvas.toDataURL('image/png') + sharedImage.value = result + localStorage.setItem(`server-icon-${serverId}`, result) + resolve(result) + URL.revokeObjectURL(img.src) + } + img.src = URL.createObjectURL(file) + }) + image.value = dataURL + return + } catch (externalError: any) { + console.debug('Could not process external icon:', externalError.message) + } + } + } + + image.value = undefined + } + + watch(upstream, () => loadImage(), { immediate: true }) + + return image +} + +function resizeImage(blob: Blob, size: number): Promise { + return new Promise((resolve) => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.onload = () => { + canvas.width = size + canvas.height = size + ctx?.drawImage(img, 0, 0, size, size) + const dataURL = canvas.toDataURL('image/png') + resolve(dataURL) + URL.revokeObjectURL(img.src) + } + img.src = URL.createObjectURL(blob) + }) +} diff --git a/apps/frontend/src/composables/servers/use-server-project.ts b/apps/frontend/src/composables/servers/use-server-project.ts new file mode 100644 index 0000000000..eeb22a8b52 --- /dev/null +++ b/apps/frontend/src/composables/servers/use-server-project.ts @@ -0,0 +1,17 @@ +import type { Archon } from '@modrinth/api-client' +import type { Project } from '@modrinth/utils' +import { useQuery } from '@tanstack/vue-query' +import { $fetch } from 'ofetch' +import { computed, type ComputedRef } from 'vue' + +// TODO: Remove and use v1 +export function useServerProject( + upstream: ComputedRef, +) { + return useQuery({ + queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]), + queryFn: () => + $fetch(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`), + enabled: computed(() => !!upstream.value?.project_id), + }) +} diff --git a/apps/frontend/src/pages/discover/[type]/index.vue b/apps/frontend/src/pages/discover/[type]/index.vue index 60a2dd8a47..114d3b57fd 100644 --- a/apps/frontend/src/pages/discover/[type]/index.vue +++ b/apps/frontend/src/pages/discover/[type]/index.vue @@ -106,6 +106,7 @@ const resultsDisplayMode = computed(() => ) const currentServerId = computed(() => queryAsString(route.query.sid) || null) +const fromContext = computed(() => queryAsString(route.query.from) || null) debug('currentServerId:', currentServerId.value) const { @@ -181,7 +182,7 @@ const installContentMutation = useMutation({ }, }) -const PERSISTENT_QUERY_PARAMS = ['sid', 'shi'] +const PERSISTENT_QUERY_PARAMS = ['sid', 'shi', 'from'] if (route.query.shi && projectType.value?.id !== 'modpack') { serverHideInstalled.value = route.query.shi === 'true' @@ -368,6 +369,16 @@ async function serverInstall(project: InstallableSearchResult) { ) ?? versions[0] if (projectType.value?.id === 'modpack') { + if (fromContext.value === 'onboarding') { + const params = new URLSearchParams({ + resumeModal: 'modpack', + mp_pid: project.project_id, + mp_vid: version.id, + mp_name: project.title, + }) + navigateTo(`/hosting/manage/${currentServerId.value}?${params}`) + return + } const hardResetParam = eraseDataOnInstall.value ? 'true' : 'false' await useServersFetch(`servers/${currentServerId.value}/reinstall?hard=${hardResetParam}`, { method: 'POST', @@ -519,6 +530,14 @@ const description = computed( `Search and browse thousands of Minecraft ${projectType.value?.display ?? 'project'}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value?.display ?? 'project'}s.`, ) +const serverBackUrl = computed(() => { + if (!serverData.value) return '' + const id = serverData.value.server_id + return fromContext.value === 'onboarding' + ? `/hosting/manage/${id}?resumeModal=modpack` + : `/hosting/manage/${id}/content` +}) + useSeoMeta({ description, ogTitle, @@ -536,7 +555,7 @@ useSeoMeta({ - diff --git a/apps/frontend/src/pages/hosting/manage/[id].vue b/apps/frontend/src/pages/hosting/manage/[id].vue index 05ec9bdf7a..f8d4543ef1 100644 --- a/apps/frontend/src/pages/hosting/manage/[id].vue +++ b/apps/frontend/src/pages/hosting/manage/[id].vue @@ -51,10 +51,7 @@ />
-
@@ -135,7 +111,7 @@
@@ -188,26 +164,7 @@
- + @@ -374,7 +330,7 @@ diff --git a/packages/ui/src/components/base/Collapsible.vue b/packages/ui/src/components/base/Collapsible.vue index a45b249477..f0b93dff31 100644 --- a/packages/ui/src/components/base/Collapsible.vue +++ b/packages/ui/src/components/base/Collapsible.vue @@ -1,5 +1,17 @@ + diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index 82e3704c68..6ea71d76a2 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -1,23 +1,39 @@ @@ -122,16 +129,7 @@ diff --git a/packages/ui/src/components/base/MultiStageModal.vue b/packages/ui/src/components/base/MultiStageModal.vue index 296d1b7570..bb822bbbb0 100644 --- a/packages/ui/src/components/base/MultiStageModal.vue +++ b/packages/ui/src/components/base/MultiStageModal.vue @@ -58,7 +58,7 @@ - @@ -102,7 +114,7 @@ diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue new file mode 100644 index 0000000000..b33f1eebb7 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue @@ -0,0 +1,361 @@ + + + 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..f84bf82cf3 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue @@ -0,0 +1,177 @@ + + + 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 @@ + + + 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..1c70a3352f --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue @@ -0,0 +1,166 @@ + + + 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..ce6ac7ccf2 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue @@ -0,0 +1,79 @@ + + + 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..8ef575c93c --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts @@ -0,0 +1,330 @@ +import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue' +import type { ComponentExposed } from 'vue-component-type-helpers' + +import { createContext } from '../../../providers' +import type { ImportableLauncher } from '../../../providers/instance-import' +import type { MultiStageModal, StageConfigInput } from '../../base' +import type { ComboboxOption } from '../../base/Combobox.vue' +import { stageConfigs } from './stages' + +export type FlowType = 'world' | 'server-onboarding' | 'instance' +export type SetupType = 'modpack' | 'custom' | 'vanilla' +export type Gamemode = 'survival' | 'creative' | 'hardcore' +export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard' +export type LoaderVersionType = 'stable' | 'latest' | 'other' +export type GeneratorSettingsMode = 'default' | 'flat' | 'custom' + +export interface ModpackSelection { + projectId: string + versionId: string + name: string + iconUrl?: string +} + +export interface ModpackSearchHit { + title: string + iconUrl?: string + latestVersion?: string +} + +export const flowTypeHeadings: Record = { + world: 'Create world', + 'server-onboarding': 'Set up server', + instance: 'Create instance', +} + +export interface CreationFlowContextValue { + // Flow + flowType: FlowType + + // Configuration + availableLoaders: string[] + showSnapshotToggle: boolean + disableClose: boolean + isInitialSetup: boolean + + // Initial values (for pre-selection when re-opening modal) + initialSetupType: SetupType | null + 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[] + + // Methods + reset: () => void + setSetupType: (type: SetupType) => void + setImportMode: () => void + browseModpacks: () => void + finish: () => void +} + +export const [injectCreationFlowContext, provideCreationFlowContext] = + createContext('CreationFlowModal') + +// TODO: replace with actual world count from the world list once available +let worldCounter = 0 + +export interface CreationFlowOptions { + availableLoaders?: string[] + showSnapshotToggle?: boolean + disableClose?: boolean + isInitialSetup?: boolean + initialSetupType?: SetupType + initialLoader?: string + initialGameVersion?: string +} + +export function createCreationFlowContext( + modal: ShallowRef | null>, + flowType: FlowType, + emit: { + browseModpacks: () => void + create: (config: CreationFlowContextValue) => void + }, + options: CreationFlowOptions = {}, +): CreationFlowContextValue { + const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt'] + const showSnapshotToggle = options.showSnapshotToggle ?? false + const disableClose = options.disableClose ?? false + const isInitialSetup = options.isInitialSetup ?? false + const initialSetupType = options.initialSetupType ?? null + const initialLoader = options.initialLoader ?? null + const initialGameVersion = options.initialGameVersion ?? null + + const setupType = ref(null) + const isImportMode = ref(false) + const worldName = ref('') + const gamemode = ref('survival') + const difficulty = ref('normal') + const worldSeed = ref('') + const worldTypeOption = ref('minecraft:normal') + const generateStructures = ref(true) + const generatorSettingsMode = ref('default') + const generatorSettingsCustom = ref('') + + // Instance-specific state + const instanceName = ref('') + const instanceIcon = ref(null) + const instanceIconUrl = ref(null) + const instanceIconPath = ref(null) + + // Revoke old object URL when icon is cleared to avoid memory leaks + watch(instanceIconUrl, (_newUrl, oldUrl) => { + if (oldUrl && oldUrl.startsWith('blob:')) { + URL.revokeObjectURL(oldUrl) + } + }) + + const selectedLoader = ref(null) + const selectedGameVersion = ref(null) + const loaderVersionType = ref('stable') + const selectedLoaderVersion = ref(null) + const showSnapshots = ref(false) + + const modpackSelection = ref(null) + const modpackFile = ref(null) + const modpackFilePath = ref(null) + + // Modpack search state (persisted across stage navigation) + const modpackSearchProjectId = ref() + const modpackSearchVersionId = ref() + const modpackSearchOptions = ref[]>([]) + const modpackVersionOptions = ref[]>([]) + const modpackSearchHits = ref>({}) + + // Import state (instance flow only) + const importLaunchers = ref([]) + const importSelectedInstances = ref>>({}) + const importSearchQuery = ref('') + + const hardReset = ref(isInitialSetup) + const loading = ref(false) + + // hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows) + const hideLoaderChips = computed(() => setupType.value === 'vanilla') + + // hideLoaderVersion: hides the loader version section (vanilla world type OR vanilla selected as loader chip) + const hideLoaderVersion = computed( + () => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla', + ) + + function reset() { + setupType.value = null + isImportMode.value = false + worldCounter++ + worldName.value = flowType === 'world' ? `World ${worldCounter}` : '' + gamemode.value = 'survival' + difficulty.value = 'normal' + worldSeed.value = '' + worldTypeOption.value = 'minecraft:normal' + generateStructures.value = true + generatorSettingsMode.value = 'default' + generatorSettingsCustom.value = '' + + // Instance-specific + instanceName.value = '' + instanceIconUrl.value = null + instanceIcon.value = null + instanceIconPath.value = null + + selectedLoader.value = null + selectedGameVersion.value = null + loaderVersionType.value = 'stable' + selectedLoaderVersion.value = null + showSnapshots.value = false + modpackSelection.value = null + modpackFile.value = null + modpackFilePath.value = null + modpackSearchProjectId.value = undefined + modpackSearchVersionId.value = undefined + modpackSearchOptions.value = [] + modpackVersionOptions.value = [] + modpackSearchHits.value = {} + + // Import state + importLaunchers.value = [] + importSelectedInstances.value = {} + importSearchQuery.value = '' + + hardReset.value = isInitialSetup + loading.value = false + } + + function setSetupType(type: SetupType) { + isImportMode.value = false + setupType.value = type + if (type === 'modpack') { + modal.value?.setStage('modpack') + } else { + // both custom and vanilla go to custom-setup + // vanilla just hides loader chips via hideLoaderChips computed + modal.value?.setStage('custom-setup') + } + } + + function setImportMode() { + isImportMode.value = true + setupType.value = null + modal.value?.setStage('import-instance') + } + + function browseModpacks() { + modal.value?.hide() + emit.browseModpacks() + } + + function finish() { + loading.value = true + emit.create(contextValue) + } + + const resolvedStageConfigs = disableClose + ? stageConfigs.map((stage) => ({ ...stage, disableClose: true })) + : stageConfigs + + const contextValue: CreationFlowContextValue = { + flowType, + availableLoaders, + showSnapshotToggle, + disableClose, + isInitialSetup, + initialSetupType, + 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, + reset, + setSetupType, + setImportMode, + browseModpacks, + finish, + } + + return contextValue +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/index.vue b/packages/ui/src/components/flows/creation-flow-modal/index.vue new file mode 100644 index 0000000000..dc3eca9c3f --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/index.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/ui/src/components/flows/creation-flow-modal/shared.ts b/packages/ui/src/components/flows/creation-flow-modal/shared.ts new file mode 100644 index 0000000000..73f69bf0c8 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/shared.ts @@ -0,0 +1,14 @@ +export const loaderDisplayNames: Record = { + fabric: 'Fabric', + neoforge: 'NeoForge', + forge: 'Forge', + quilt: 'Quilt', + paper: 'Paper', + purpur: 'Purpur', + vanilla: 'Vanilla', +} + +export const formatLoaderLabel = (item: string) => + loaderDisplayNames[item] ?? item.charAt(0).toUpperCase() + item.slice(1) + +export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1) diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/confirm-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/confirm-stage.ts new file mode 100644 index 0000000000..095c478953 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/confirm-stage.ts @@ -0,0 +1,30 @@ +import { DownloadIcon, LeftArrowIcon, TrashIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import ConfirmStage from '../components/ConfirmStage.vue' +import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' + +export const stageConfig: StageConfigInput = { + id: 'confirm', + title: (ctx) => flowTypeHeadings[ctx.flowType], + stageContent: markRaw(ConfirmStage), + skip: () => true, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => ctx.modal.value?.prevStage(), + }), + rightButtonConfig: (ctx) => { + const isErase = ctx.hardReset.value && !ctx.isInitialSetup + return { + label: isErase ? 'Erase and install' : 'Install', + icon: isErase ? TrashIcon : DownloadIcon, + iconPosition: 'before' as const, + color: isErase ? ('red' as const) : ('brand' as const), + loading: ctx.loading.value, + onClick: () => ctx.finish(), + } + }, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts new file mode 100644 index 0000000000..24347197cc --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts @@ -0,0 +1,59 @@ +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 { + return !ctx.selectedGameVersion.value || (!ctx.hideLoaderChips.value && !ctx.selectedLoader.value) +} + +export const stageConfig: StageConfigInput = { + id: 'custom-setup', + title: (ctx) => flowTypeHeadings[ctx.flowType], + stageContent: markRaw(CustomSetupStage), + skip: (ctx) => + ctx.setupType.value === 'modpack' || + ctx.setupType.value === 'vanilla' || + ctx.isImportMode.value, + cannotNavigateForward: isForwardBlocked, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => ctx.modal.value?.setStage('setup-type'), + }), + rightButtonConfig: (ctx) => { + const isInstance = ctx.flowType === 'instance' + const goesToNextStage = ctx.flowType === 'world' || ctx.flowType === 'server-onboarding' + const disabled = isForwardBlocked(ctx) + + if (isInstance) { + return { + label: 'Create instance', + icon: PlusIcon, + iconPosition: 'before' as const, + color: 'brand' as const, + disabled, + loading: ctx.loading.value, + onClick: () => ctx.finish(), + } + } + + return { + label: goesToNextStage ? 'Continue' : 'Finish', + icon: goesToNextStage ? RightArrowIcon : null, + iconPosition: 'after' as const, + color: goesToNextStage ? undefined : ('brand' as const), + disabled, + onClick: () => { + if (goesToNextStage) { + ctx.modal.value?.nextStage() + } else { + ctx.finish() + } + }, + } + }, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts new file mode 100644 index 0000000000..d45079ebf8 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts @@ -0,0 +1,46 @@ +import { DownloadIcon, LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import FinalConfigStage from '../components/FinalConfigStage.vue' +import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' + +function isForwardBlocked(ctx: CreationFlowContextValue): boolean { + if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true + if (ctx.setupType.value === 'vanilla' && !ctx.selectedGameVersion.value) return true + return false +} + +export const stageConfig: StageConfigInput = { + id: 'final-config', + title: (ctx) => flowTypeHeadings[ctx.flowType], + stageContent: markRaw(FinalConfigStage), + skip: (ctx) => ctx.flowType === 'instance' || ctx.isImportMode.value, + cannotNavigateForward: isForwardBlocked, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => ctx.modal.value?.prevStage(), + }), + rightButtonConfig: (ctx) => { + const isWorld = ctx.flowType === 'world' + const isOnboarding = ctx.flowType === 'server-onboarding' + const isFinish = isWorld || isOnboarding + return { + label: isWorld ? 'Create world' : isOnboarding ? 'Install' : 'Continue', + icon: isFinish ? (isWorld ? PlusIcon : DownloadIcon) : RightArrowIcon, + iconPosition: isFinish ? ('before' as const) : ('after' as const), + color: isFinish ? ('brand' as const) : undefined, + disabled: isForwardBlocked(ctx), + loading: isFinish && ctx.loading.value, + onClick: () => { + if (isFinish) { + ctx.finish() + } else { + ctx.modal.value?.nextStage() + } + }, + } + }, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts new file mode 100644 index 0000000000..7d6b94399c --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts @@ -0,0 +1,41 @@ +import { DownloadIcon, LeftArrowIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import ImportInstanceStage from '../components/ImportInstanceStage.vue' +import type { CreationFlowContextValue } from '../creation-flow-context' + +function getSelectedCount(ctx: CreationFlowContextValue): number { + let count = 0 + for (const set of Object.values(ctx.importSelectedInstances.value)) { + count += set.size + } + return count +} + +export const stageConfig: StageConfigInput = { + id: 'import-instance', + title: 'Import instance', + stageContent: markRaw(ImportInstanceStage), + skip: (ctx) => !ctx.isImportMode.value, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => { + ctx.isImportMode.value = false + ctx.modal.value?.setStage('setup-type') + }, + }), + rightButtonConfig: (ctx) => { + const count = getSelectedCount(ctx) + return { + label: count > 0 ? `Import ${count} instance${count !== 1 ? 's' : ''}` : 'Import', + icon: DownloadIcon, + iconPosition: 'before' as const, + color: 'brand' as const, + disabled: count === 0, + onClick: () => ctx.finish(), + } + }, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts new file mode 100644 index 0000000000..ad44962b9d --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts @@ -0,0 +1,17 @@ +import type { StageConfigInput } from '../../../base' +import type { CreationFlowContextValue } from '../creation-flow-context' +import { stageConfig as confirmStageConfig } from './confirm-stage' +import { stageConfig as customSetupStageConfig } from './custom-setup-stage' +import { stageConfig as finalConfigStageConfig } from './final-config-stage' +import { stageConfig as importInstanceStageConfig } from './import-instance-stage' +import { stageConfig as modpackStageConfig } from './modpack-stage' +import { stageConfig as setupTypeStageConfig } from './setup-type-stage' + +export const stageConfigs: StageConfigInput[] = [ + setupTypeStageConfig, + modpackStageConfig, + importInstanceStageConfig, + customSetupStageConfig, + finalConfigStageConfig, + confirmStageConfig, +] diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts new file mode 100644 index 0000000000..d6e4c016a5 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts @@ -0,0 +1,15 @@ +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: null, + rightButtonConfig: null, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts new file mode 100644 index 0000000000..9d3db57914 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts @@ -0,0 +1,14 @@ +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import SetupTypeStage from '../components/SetupTypeStage.vue' +import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' + +export const stageConfig: StageConfigInput = { + id: 'setup-type', + title: (ctx) => flowTypeHeadings[ctx.flowType], + stageContent: markRaw(SetupTypeStage), + leftButtonConfig: null, + rightButtonConfig: null, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/instances/ContentModpackCard.vue b/packages/ui/src/components/instances/ContentModpackCard.vue index 31b8ed6410..5956077876 100644 --- a/packages/ui/src/components/instances/ContentModpackCard.vue +++ b/packages/ui/src/components/instances/ContentModpackCard.vue @@ -191,7 +191,6 @@ const collapsedOptions = computed(() => { {{ formatMessage(commonMessages.contentLabel) }} - diff --git a/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue b/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue index 98a05b7081..d9e2ec21e3 100644 --- a/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue +++ b/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue @@ -40,7 +40,7 @@