diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 9bbde1b0af..601fe5a7cd 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1571,11 +1571,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload) .app-grid-navbar { grid-area: nav; + position: relative; + z-index: 2; } .app-grid-statusbar { grid-area: status; padding-right: var(--window-controls-width, 0px); + position: relative; + z-index: 2; } [data-tauri-drag-region-exclude] { @@ -1665,7 +1669,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) } .app-contents::before { - z-index: 1; + z-index: 30; content: ''; position: fixed; left: var(--left-bar-width); diff --git a/apps/app-frontend/src/composables/browse/use-app-server-browse.ts b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts new file mode 100644 index 0000000000..1a0fe431d2 --- /dev/null +++ b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts @@ -0,0 +1,332 @@ +import type { Labrinth } from '@modrinth/api-client' +import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets' +import type { CardAction } from '@modrinth/ui' +import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui' +import { openUrl } from '@tauri-apps/plugin-opener' +import type { ComputedRef, Ref } from 'vue' +import { onUnmounted, ref, shallowRef } from 'vue' +import type { Router } from 'vue-router' + +import { process_listener } from '@/helpers/events' +import { get_by_profile_path } from '@/helpers/process' +import { kill, list as listInstances } from '@/helpers/profile.js' +import type { GameInstance } from '@/helpers/types' +import { add_server_to_profile, getServerLatency } from '@/helpers/worlds' +import { getServerAddress } from '@/store/install.js' + +interface BrowseServerInstance { + name: string + path: string +} + +interface ContextMenuHandle { + showMenu: ( + event: MouseEvent, + result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject, + options: { name: string }[], + ) => void +} + +interface ContextMenuOptionClick { + option: 'open_link' | 'copy_link' + item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject +} + +export interface UseAppServerBrowseOptions { + instance: Ref + isFromWorlds: ComputedRef + allInstalledIds: ComputedRef> + newlyInstalled: Ref + installingServerProjects: Ref + playServerProject: (projectId: string) => Promise + showAddServerToInstanceModal: (serverName: string, serverAddress: string) => void + handleError: (error: unknown) => void + router: Router +} + +const messages = defineMessages({ + addToInstance: { + id: 'app.browse.add-to-instance', + defaultMessage: 'Add to instance', + }, + addToInstanceName: { + id: 'app.browse.add-to-instance-name', + defaultMessage: 'Add to {instanceName}', + }, + added: { + id: 'app.browse.added', + defaultMessage: 'Added', + }, + alreadyAdded: { + id: 'app.browse.already-added', + defaultMessage: 'Already added', + }, +}) + +export function useAppServerBrowse(options: UseAppServerBrowseOptions) { + const { formatMessage } = useVIntl() + const debugLog = useDebugLogger('BrowseServer') + const serverPings = shallowRef>({}) + const serverPingCache = new Map() + const pendingServerPings = new Map>() + const runningServerProjects = ref>({}) + const lastServerHits = shallowRef([]) + const contextMenuRef = ref(null) + let serverPingCacheActive = true + let unlistenProcesses: (() => void) | null = null + + async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) { + debugLog('checkServerRunningStates', { hitCount: hits.length }) + const packs = await listInstances().catch((error) => { + options.handleError(error) + return [] + }) + const newRunning: Record = {} + for (const hit of hits) { + const inst = packs.find( + (pack: GameInstance) => pack.linked_data?.project_id === hit.project_id, + ) + if (inst) { + const processes = await get_by_profile_path(inst.path).catch(() => []) + if (Array.isArray(processes) && processes.length > 0) { + newRunning[hit.project_id] = inst.path + } + } + } + debugLog('runningServerProjects updated', newRunning) + runningServerProjects.value = newRunning + } + + async function handleStopServerProject(projectId: string) { + debugLog('handleStopServerProject', projectId) + const instancePath = runningServerProjects.value[projectId] + if (!instancePath) return + await kill(instancePath).catch(() => {}) + const { [projectId]: _, ...rest } = runningServerProjects.value + runningServerProjects.value = rest + } + + async function handlePlayServerProject(projectId: string) { + debugLog('handlePlayServerProject', projectId) + await options.playServerProject(projectId) + checkServerRunningStates(lastServerHits.value) + } + + async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) { + debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name }) + const address = getServerAddress(project.minecraft_java_server) + if (!address) return + + if (options.instance.value) { + try { + await add_server_to_profile( + options.instance.value.path, + project.name, + address, + 'prompt', + project.project_id, + project.minecraft_java_server?.content?.kind, + ) + options.newlyInstalled.value.push(project.project_id) + } catch (error) { + options.handleError(error) + } + } else { + options.showAddServerToInstanceModal(project.name, address) + } + } + + async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) { + debugLog('pingServerHits', { hitCount: hits.length }) + const pingsToFetch = hits.flatMap((hit) => { + const address = hit.minecraft_java_server?.address + if (!address) return [] + return [{ hit, address }] + }) + const nextPings = { ...serverPings.value } + for (const { hit, address } of pingsToFetch) { + if (serverPingCache.has(address)) { + nextPings[hit.project_id] = serverPingCache.get(address) + } + } + serverPings.value = nextPings + + await Promise.all( + pingsToFetch.map(async ({ hit, address }) => { + if (serverPingCache.has(address)) return + + let pending = pendingServerPings.get(address) + if (!pending) { + pending = getServerLatency(address) + .then((latency) => { + if (serverPingCacheActive) serverPingCache.set(address, latency) + return latency + }) + .catch((error) => { + console.error(`Failed to ping server ${address}:`, error) + if (serverPingCacheActive) serverPingCache.set(address, undefined) + return undefined + }) + .finally(() => { + pendingServerPings.delete(address) + }) + pendingServerPings.set(address, pending) + } + + const latency = await pending + if (!serverPingCacheActive) return + serverPings.value = { ...serverPings.value, [hit.project_id]: latency } + }), + ) + } + + function updateServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) { + lastServerHits.value = hits + pingServerHits(hits) + checkServerRunningStates(hits) + } + + function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) { + const content = project.minecraft_java_server?.content + if (content?.kind === 'modpack') { + const { project_name, project_icon, project_id } = content + if (!project_name) return undefined + return { + name: project_name, + icon: project_icon ?? undefined, + onclick: + project_id !== project.project_id + ? () => { + options.router.push(`/project/${project_id}`) + } + : undefined, + showCustomModpackTooltip: project_id === project.project_id, + } + } + return undefined + } + + function getServerCardActions( + serverResult: Labrinth.Search.v3.ResultSearchProject, + ): CardAction[] { + const isInstalled = options.allInstalledIds.value.has(serverResult.project_id) + + if (options.isFromWorlds.value && options.instance.value) { + return [ + { + key: 'add-to-instance', + label: formatMessage(isInstalled ? messages.added : messages.addToInstance), + icon: isInstalled ? CheckIcon : PlusIcon, + disabled: isInstalled, + color: 'brand', + type: 'outlined', + onClick: () => handleAddServerToInstance(serverResult), + }, + ] + } + + const actions: CardAction[] = [] + + actions.push({ + key: 'add', + label: '', + icon: isInstalled ? CheckIcon : PlusIcon, + disabled: isInstalled, + circular: true, + tooltip: isInstalled + ? formatMessage(messages.alreadyAdded) + : options.instance.value + ? formatMessage(messages.addToInstanceName, { + instanceName: options.instance.value.name, + }) + : formatMessage(commonMessages.addServerToInstanceButton), + onClick: () => handleAddServerToInstance(serverResult), + }) + + if (runningServerProjects.value[serverResult.project_id]) { + actions.push({ + key: 'stop', + label: formatMessage(commonMessages.stopButton), + icon: StopCircleIcon, + color: 'red', + type: 'outlined', + onClick: () => handleStopServerProject(serverResult.project_id), + }) + } else { + const isInstalling = options.installingServerProjects.value.includes(serverResult.project_id) + actions.push({ + key: 'play', + label: formatMessage( + isInstalling ? commonMessages.installingLabel : commonMessages.playButton, + ), + icon: PlayIcon, + disabled: isInstalling, + color: 'brand', + type: 'outlined', + onClick: () => handlePlayServerProject(serverResult.project_id), + }) + } + + return actions + } + + function handleRightClick( + event: MouseEvent, + result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject, + ) { + contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }]) + } + + function handleOptionsClick(args: ContextMenuOptionClick) { + const url = getProjectUrl(args.item) + switch (args.option) { + case 'open_link': + openUrl(url) + break + case 'copy_link': + navigator.clipboard.writeText(url) + break + } + } + + process_listener((event: { event: string; profile_path_id: string }) => { + debugLog('process event', event) + if (event.event === 'finished') { + const projectId = Object.entries(runningServerProjects.value).find( + ([, path]) => path === event.profile_path_id, + )?.[0] + if (projectId) { + const { [projectId]: _, ...rest } = runningServerProjects.value + runningServerProjects.value = rest + } + } + }) + .then((unlisten) => { + unlistenProcesses = unlisten + }) + .catch(options.handleError) + + onUnmounted(() => { + serverPingCacheActive = false + unlistenProcesses?.() + serverPingCache.clear() + pendingServerPings.clear() + }) + + return { + serverPings, + contextMenuRef, + updateServerHits, + getServerModpackContent, + getServerCardActions, + handleRightClick, + handleOptionsClick, + } +} + +function getProjectUrl( + item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject, +) { + const projectType = 'project_types' in item ? item.project_types?.[0] : item.project_type + return `https://modrinth.com/${projectType ?? 'project'}/${item.slug ?? item.project_id}` +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 671b0f8bc6..1d748cc381 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -104,11 +104,11 @@ "app.auth-servers.unreachable.header": { "message": "Cannot reach authentication servers" }, - "app.browse.add-server-to-instance": { - "message": "Add server to instance" - }, "app.browse.add-servers-to-instance": { - "message": "Add servers to your instance" + "message": "Adding server to instance" + }, + "app.browse.add-to-an-instance": { + "message": "Add to an instance" }, "app.browse.add-to-instance": { "message": "Add to instance" @@ -122,6 +122,9 @@ "app.browse.already-added": { "message": "Already added" }, + "app.browse.back-to-instance": { + "message": "Back to instance" + }, "app.browse.discover-content": { "message": "Discover content" }, @@ -131,20 +134,11 @@ "app.browse.hide-added-servers": { "message": "Hide already added servers" }, - "app.browse.hide-installed-content": { - "message": "Hide already installed content" - }, - "app.browse.install-content-to-instance": { - "message": "Install content to instance" - }, "app.browse.project-type.modpacks": { "message": "Modpacks" }, - "app.browse.server.install": { - "message": "Install" - }, - "app.browse.server.installed": { - "message": "Installed" + "app.browse.server-instance-content-warning": { + "message": "Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content." }, "app.browse.server.installing": { "message": "Installing" @@ -305,6 +299,15 @@ "app.modal.update-to-play.update-required-description": { "message": "An update is required to play {name}. Please update to the latest version to launch the game." }, + "app.project.install-button.already-installed": { + "message": "This project is already installed" + }, + "app.project.install-context.back-to-browse": { + "message": "Back to browse" + }, + "app.project.install-context.install-content-to-instance": { + "message": "Install content to instance" + }, "app.settings.developer-mode-enabled": { "message": "Developer mode enabled." }, diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index 048f699b4a..e256d5b024 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -5,46 +5,49 @@ import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, - PlayIcon, PlusIcon, SpinnerIcon, - StopCircleIcon, } from '@modrinth/assets' -import type { CardAction, ProjectType, Tags } from '@modrinth/ui' +import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui' import { BrowsePageLayout, BrowseSidebar, commonMessages, CreationFlowModal, defineMessages, + getLatestMatchingInstallVersion, + getSelectedInstallPreferences, + getTargetInstallPreferences, injectNotificationManager, + preferencesDiffer, provideBrowseManager, + requestInstall, useBrowseSearch, useDebugLogger, useVIntl, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' import { convertFileSrc } from '@tauri-apps/api/core' -import { openUrl } from '@tauri-apps/plugin-opener' import type { Ref } from 'vue' -import { computed, onUnmounted, ref, shallowRef, watch } from 'vue' +import { computed, ref, watch } from 'vue' import type { LocationQuery } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import ContextMenu from '@/components/ui/ContextMenu.vue' -import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js' -import { process_listener } from '@/helpers/events' +import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse' +import { + get_project, + get_project_v3, + get_search_results_v3, + get_version_many, +} from '@/helpers/cache.js' import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' -import { get_by_profile_path } from '@/helpers/process' import { get as getInstance, get_installed_project_ids as getInstalledProjectIds, - kill, - list as listInstances, } from '@/helpers/profile.js' import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' -import type { GameInstance } from '@/helpers/types' -import { add_server_to_profile, get_profile_worlds, getServerLatency } from '@/helpers/worlds' +import { get_profile_worlds } from '@/helpers/worlds' import { injectContentInstall } from '@/providers/content-install' import { injectServerInstall } from '@/providers/server-install' import { @@ -52,7 +55,6 @@ import { provideServerInstallContent, } from '@/providers/setup/server-install-content' import { useBreadcrumbs } from '@/store/breadcrumbs' -import { getServerAddress } from '@/store/install.js' const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() @@ -76,15 +78,27 @@ const { effectiveServerWorldId, serverContextServerData, serverContentProjectIds, + queuedServerInstallProjectIds, + queuedServerInstallCount, + selectedServerInstallProjects, + isInstallingQueuedServerInstalls, + queuedInstallProgress, serverBackUrl, serverBackLabel, serverBrowseHeading, + clearQueuedServerInstalls, + removeQueuedServerInstall, + flushQueuedServerInstalls, + discardQueuedServerInstallsAndBack, + installQueuedServerInstallsAndBack, initServerContext, watchServerContextChanges, searchServerModpacks, getServerProjectVersions, enforceSetupModpackRoute, - installProjectToServer, + getQueuedServerInstallPlans, + setQueuedServerInstallPlans, + openServerModpackInstallFlow, onServerFlowBack, handleServerModpackFlowCreate, markServerProjectInstalled, @@ -254,6 +268,7 @@ const instanceFilters = computed(() => { }) const serverHideInstalled = ref(false) +const hideSelectedServerInstalls = ref(false) if (route.query.shi) { serverHideInstalled.value = route.query.shi === 'true' } @@ -291,6 +306,12 @@ const serverContextFilters = computed(() => { filters.push({ type: 'plugin_loader', option: platform }) if (pt === 'mod') filters.push({ type: 'environment', option: 'server' }) + + if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) { + for (const id of queuedServerInstallProjectIds.value) { + filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true }) + } + } } if (pt === 'modpack') { @@ -313,134 +334,24 @@ const combinedProvidedFilters = computed(() => isServerContext.value ? serverContextFilters.value : instanceFilters.value, ) -const serverPings = shallowRef>({}) -const serverPingCache = new Map() -const pendingServerPings = new Map>() -let serverPingCacheActive = true -const runningServerProjects = ref>({}) - -async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) { - debugLog('checkServerRunningStates', { hitCount: hits.length }) - const packs = await listInstances() - const newRunning: Record = {} - for (const hit of hits) { - const inst = packs.find((p: GameInstance) => p.linked_data?.project_id === hit.project_id) - if (inst) { - const processes = await get_by_profile_path(inst.path).catch(() => []) - if (Array.isArray(processes) && processes.length > 0) { - newRunning[hit.project_id] = inst.path - } - } - } - debugLog('runningServerProjects updated', newRunning) - runningServerProjects.value = newRunning -} - -async function handleStopServerProject(projectId: string) { - debugLog('handleStopServerProject', projectId) - const instancePath = runningServerProjects.value[projectId] - if (!instancePath) return - await kill(instancePath).catch(() => {}) - const { [projectId]: _, ...rest } = runningServerProjects.value - runningServerProjects.value = rest -} - -async function handlePlayServerProject(projectId: string) { - debugLog('handlePlayServerProject', projectId) - await playServerProject(projectId) - checkServerRunningStates(lastServerHits.value) -} - -const lastServerHits = shallowRef([]) - -async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) { - debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name }) - const address = getServerAddress(project.minecraft_java_server) - if (!address) return - - if (instance.value) { - try { - await add_server_to_profile( - instance.value.path, - project.name, - address, - 'prompt', - project.project_id, - project.minecraft_java_server?.content?.kind, - ) - newlyInstalled.value.push(project.project_id) - } catch (err) { - handleError(err as Error) - } - } else { - showAddServerToInstanceModal(project.name, address) - } -} - -async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) { - debugLog('pingServerHits', { hitCount: hits.length }) - const pingsToFetch = hits.flatMap((hit) => { - const address = hit.minecraft_java_server?.address - if (!address) return [] - return [{ hit, address }] - }) - const nextPings = { ...serverPings.value } - for (const { hit, address } of pingsToFetch) { - if (serverPingCache.has(address)) { - nextPings[hit.project_id] = serverPingCache.get(address) - } - } - serverPings.value = nextPings - - await Promise.all( - pingsToFetch.map(async ({ hit, address }) => { - if (serverPingCache.has(address)) return - - let pending = pendingServerPings.get(address) - if (!pending) { - pending = getServerLatency(address) - .then((latency) => { - if (serverPingCacheActive) serverPingCache.set(address, latency) - return latency - }) - .catch((err) => { - console.error(`Failed to ping server ${address}:`, err) - if (serverPingCacheActive) serverPingCache.set(address, undefined) - return undefined - }) - .finally(() => { - pendingServerPings.delete(address) - }) - pendingServerPings.set(address, pending) - } - - const latency = await pending - if (!serverPingCacheActive) return - serverPings.value = { ...serverPings.value, [hit.project_id]: latency } - }), - ) -} - -const unlistenProcesses = await process_listener( - (e: { event: string; profile_path_id: string }) => { - debugLog('process event', e) - if (e.event === 'finished') { - const projectId = Object.entries(runningServerProjects.value).find( - ([, path]) => path === e.profile_path_id, - )?.[0] - if (projectId) { - const { [projectId]: _, ...rest } = runningServerProjects.value - runningServerProjects.value = rest - } - } - }, -) - -onUnmounted(() => { - serverPingCacheActive = false - unlistenProcesses() - serverPingCache.clear() - pendingServerPings.clear() +const { + serverPings, + contextMenuRef, + updateServerHits, + getServerModpackContent, + getServerCardActions, + handleRightClick, + handleOptionsClick, +} = useAppServerBrowse({ + instance, + isFromWorlds, + allInstalledIds, + newlyInstalled, + installingServerProjects, + playServerProject, + showAddServerToInstanceModal, + handleError, + router, }) const offline = ref(!navigator.onLine) @@ -454,29 +365,13 @@ window.addEventListener('online', () => { }) const messages = defineMessages({ - addServerToInstance: { - id: 'app.browse.add-server-to-instance', - defaultMessage: 'Add server to instance', - }, addServersToInstance: { id: 'app.browse.add-servers-to-instance', - defaultMessage: 'Add servers to your instance', - }, - addToInstance: { - id: 'app.browse.add-to-instance', - defaultMessage: 'Add to instance', - }, - addToInstanceName: { - id: 'app.browse.add-to-instance-name', - defaultMessage: 'Add to {instanceName}', + defaultMessage: 'Adding server to instance', }, - added: { - id: 'app.browse.added', - defaultMessage: 'Added', - }, - alreadyAdded: { - id: 'app.browse.already-added', - defaultMessage: 'Already added', + addToAnInstance: { + id: 'app.browse.add-to-an-instance', + defaultMessage: 'Add to an instance', }, discoverContent: { id: 'app.browse.discover-content', @@ -502,26 +397,19 @@ const messages = defineMessages({ id: 'app.browse.hide-added-servers', defaultMessage: 'Hide already added servers', }, - hideInstalledContent: { - id: 'app.browse.hide-installed-content', - defaultMessage: 'Hide already installed content', - }, - installContentToInstance: { - id: 'app.browse.install-content-to-instance', - defaultMessage: 'Install content to instance', - }, - installToServer: { - id: 'app.browse.server.install', - defaultMessage: 'Install', - }, - installedToServer: { - id: 'app.browse.server.installed', - defaultMessage: 'Installed', - }, installingToServer: { id: 'app.browse.server.installing', defaultMessage: 'Installing', }, + backToInstance: { + id: 'app.browse.back-to-instance', + defaultMessage: 'Back to instance', + }, + serverInstanceContentWarning: { + id: 'app.browse.server-instance-content-warning', + defaultMessage: + 'Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content.', + }, modLoaderProvidedByInstance: { id: 'search.filter.locked.instance-loader.title', defaultMessage: 'Loader is provided by the instance', @@ -654,46 +542,6 @@ const selectableProjectTypes = computed(() => { ] }) -const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => { - const content = project.minecraft_java_server?.content - if (content?.kind === 'modpack') { - const { project_name, project_icon, project_id } = content - if (!project_name) return undefined - return { - name: project_name, - icon: project_icon ?? undefined, - onclick: - project_id !== project.project_id - ? () => { - router.push(`/project/${project_id}`) - } - : undefined, - showCustomModpackTooltip: project_id === project.project_id, - } - } - return undefined -} - -const contextMenuRef = ref(null) -// @ts-expect-error - no event types -const handleRightClick = (event, result) => { - // @ts-ignore - contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }]) -} -// @ts-expect-error - no event types -const handleOptionsClick = (args) => { - switch (args.option) { - case 'open_link': - openUrl(`https://modrinth.com/${args.item.project_types?.[0] ?? 'project'}/${args.item.slug}`) - break - case 'copy_link': - navigator.clipboard.writeText( - `https://modrinth.com/${args.item.project_types?.[0] ?? 'project'}/${args.item.slug}`, - ) - break - } -} - const installContext = computed(() => { if (isServerContext.value && serverContextServerData.value) { return { @@ -707,6 +555,15 @@ const installContext = computed(() => { backUrl: serverBackUrl.value, backLabel: serverBackLabel.value, heading: serverBrowseHeading.value, + queuedCount: queuedServerInstallCount.value, + selectedProjects: selectedServerInstallProjects.value, + isInstallingSelected: isInstallingQueuedServerInstalls.value, + installProgress: queuedInstallProgress.value, + clearQueued: clearQueuedServerInstalls, + clearSelected: clearQueuedServerInstalls, + onBack: flushQueuedServerInstalls, + discardSelectedAndBack: discardQueuedServerInstallsAndBack, + installSelected: installQueuedServerInstallsAndBack, } } if (instance.value) { @@ -716,13 +573,13 @@ const installContext = computed(() => { gameVersion: instance.value.game_version, iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null, backUrl: `/instance/${encodeURIComponent(instance.value.path)}${isFromWorlds.value ? '/worlds' : ''}`, - backLabel: 'Back to instance', + backLabel: formatMessage(messages.backToInstance), heading: formatMessage( - isFromWorlds.value ? messages.addServersToInstance : messages.installContentToInstance, + isFromWorlds.value ? messages.addServersToInstance : commonMessages.installingContentLabel, ), warning: isServerInstance.value && !isFromWorlds.value - ? 'Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content.' + ? formatMessage(messages.serverInstanceContentWarning) : undefined, } } @@ -741,71 +598,82 @@ function setProjectInstalling(projectId: string, installing: boolean) { installingProjectIds.value = next } +const serverInstallQueue = { + get: getQueuedServerInstallPlans, + set: setQueuedServerInstallPlans, +} + +function getCurrentSelectedInstallPreferences(projectTypeValue: string) { + return getSelectedInstallPreferences({ + contentType: projectTypeValue, + selectedFilters: searchState.currentFilters.value, + providedFilters: combinedProvidedFilters.value, + overriddenProvidedFilterTypes: searchState.overriddenProvidedFilterTypes.value, + }) +} + +function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) { + return getTargetInstallPreferences( + { + gameVersion: serverContextServerData.value?.mc_version, + loader: serverContextServerData.value?.loader, + }, + contentType, + ) +} + +function getInstanceInstallTargetPreferences(projectTypeValue: string) { + return getTargetInstallPreferences( + { + gameVersion: instance.value?.game_version, + loader: instance.value?.loader, + }, + projectTypeValue, + ) +} + +async function getInstallProjectVersions(projectId: string) { + const project = await get_project(projectId, 'must_revalidate') + return (await get_version_many( + project.versions, + 'must_revalidate', + )) as Labrinth.Versions.v2.Version[] +} + +async function chooseInstanceInstallVersion( + project: Labrinth.Search.v2.ResultSearchProject & Labrinth.Search.v3.ResultSearchProject, + projectTypeValue: string, +) { + const targetInstance = instance.value + if (!targetInstance) { + return { versionId: null as string | null } + } + + const selectedPreferences = getCurrentSelectedInstallPreferences(projectTypeValue) + const targetPreferences = getInstanceInstallTargetPreferences(projectTypeValue) + if (!preferencesDiffer(selectedPreferences, targetPreferences)) { + return { versionId: null as string | null } + } + + const selectedVersion = getLatestMatchingInstallVersion( + await getInstallProjectVersions(project.project_id), + selectedPreferences, + projectTypeValue, + ) + + if (!selectedVersion) { + return { versionId: null as string | null } + } + + return { versionId: selectedVersion.id } +} + function getCardActions( result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject, currentProjectType: string, ): CardAction[] { if (currentProjectType === 'server') { - const serverResult = result as Labrinth.Search.v3.ResultSearchProject - const isInstalled = allInstalledIds.value.has(serverResult.project_id) - - if (isFromWorlds.value && instance.value) { - return [ - { - key: 'add-to-instance', - label: formatMessage(isInstalled ? messages.added : messages.addToInstance), - icon: isInstalled ? CheckIcon : PlusIcon, - disabled: isInstalled, - color: 'brand', - type: 'outlined', - onClick: () => handleAddServerToInstance(serverResult), - }, - ] - } - - const actions: CardAction[] = [] - - actions.push({ - key: 'add', - label: '', - icon: isInstalled ? CheckIcon : PlusIcon, - disabled: isInstalled, - circular: true, - tooltip: isInstalled - ? formatMessage(messages.alreadyAdded) - : instance.value - ? formatMessage(messages.addToInstanceName, { instanceName: instance.value.name }) - : formatMessage(messages.addServerToInstance), - onClick: () => handleAddServerToInstance(serverResult), - }) - - if (runningServerProjects.value[serverResult.project_id]) { - actions.push({ - key: 'stop', - label: formatMessage(commonMessages.stopButton), - icon: StopCircleIcon, - color: 'red', - type: 'outlined', - onClick: () => handleStopServerProject(serverResult.project_id), - }) - } else { - const isInstalling = (installingServerProjects.value as string[]).includes( - serverResult.project_id, - ) - actions.push({ - key: 'play', - label: formatMessage( - isInstalling ? commonMessages.installingLabel : commonMessages.playButton, - ), - icon: PlayIcon, - disabled: isInstalling, - color: 'brand', - type: 'outlined', - onClick: () => handlePlayServerProject(serverResult.project_id), - }) - } - - return actions + return getServerCardActions(result as Labrinth.Search.v3.ResultSearchProject) } // Non-server project actions @@ -817,39 +685,84 @@ function getCardActions( const isInstalled = projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '') || - serverContentProjectIds.value.has(projectResult.project_id || '') + serverContentProjectIds.value.has(projectResult.project_id || '') || + serverContextServerData.value?.upstream?.project_id === projectResult.project_id const isInstalling = installingProjectIds.value.has(projectResult.project_id) if ( isServerContext.value && ['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType) ) { + const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id) + const isInstallingSelection = isInstallingQueuedServerInstalls.value + const validatingInstall = + isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection + const installLabel = isInstalled + ? commonMessages.installedLabel + : isQueued + ? isInstalling || isInstallingSelection + ? validatingInstall + ? commonMessages.validatingLabel + : messages.installingToServer + : commonMessages.selectedLabel + : isInstalling || isInstallingSelection + ? validatingInstall + ? commonMessages.validatingLabel + : messages.installingToServer + : commonMessages.installButton return [ { key: 'install', - label: formatMessage( - isInstalling - ? messages.installingToServer - : isInstalled - ? messages.installedToServer - : messages.installToServer, - ), - icon: isInstalled ? CheckIcon : PlusIcon, - iconClass: isInstalling ? 'animate-spin' : undefined, - disabled: isInstalled || isInstalling, - color: 'brand', + label: formatMessage(installLabel), + icon: + isInstalling || isInstallingSelection + ? SpinnerIcon + : isQueued || isInstalled + ? CheckIcon + : PlusIcon, + iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined, + disabled: isInstalled || isInstalling || isInstallingSelection, + color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand', type: 'outlined', onClick: async () => { - setProjectInstalling(projectResult.project_id, true) + if (isQueued) { + removeQueuedServerInstall(projectResult.project_id) + return + } + + const contentType = currentProjectType as BrowseInstallContentType + const isModpack = contentType === 'modpack' + const shouldShowInstalling = isModpack || !isQueued + if (shouldShowInstalling) { + setProjectInstalling(projectResult.project_id, true) + } try { - const didInstall = await installProjectToServer(projectResult) - if (didInstall !== false) { - onSearchResultInstalled(projectResult.project_id) - } + await requestInstall({ + project: projectResult, + contentType, + mode: isModpack ? 'immediate' : 'queue', + selectedFilters: isModpack ? [] : searchState.currentFilters.value, + providedFilters: isModpack ? [] : combinedProvidedFilters.value, + overriddenProvidedFilterTypes: isModpack + ? [] + : searchState.overriddenProvidedFilterTypes.value, + targetPreferences: getServerInstallTargetPreferences(contentType), + getProjectVersions: getInstallProjectVersions, + queue: serverInstallQueue, + install: (plan) => + openServerModpackInstallFlow({ + projectId: plan.projectId, + versionId: plan.versionId, + name: plan.project.name, + iconUrl: plan.project.icon_url ?? undefined, + }), + }) } catch (err) { handleError(err as Error) } finally { - setProjectInstalling(projectResult.project_id, false) + if (shouldShowInstalling) { + setProjectInstalling(projectResult.project_id, false) + } } }, }, @@ -862,13 +775,15 @@ function getCardActions( return [ { key: 'install', - label: isInstalling - ? 'Installing' - : isInstalled - ? 'Installed' - : shouldUseInstallIcon - ? 'Install' - : 'Add to an instance', + label: formatMessage( + isInstalling + ? messages.installingToServer + : isInstalled + ? commonMessages.installedLabel + : shouldUseInstallIcon + ? commonMessages.installButton + : messages.addToAnInstance, + ), icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon, iconClass: isInstalling ? 'animate-spin' : undefined, disabled: isInstalled || isInstalling, @@ -876,28 +791,39 @@ function getCardActions( type: 'outlined', onClick: async () => { setProjectInstalling(projectResult.project_id, true) - await installVersion( - projectResult.project_id, - null, - instance.value ? instance.value.path : null, - 'SearchCard', - (versionId) => { + try { + const selectedInstall = instance.value + ? await chooseInstanceInstallVersion(projectResult, currentProjectType) + : { versionId: null as string | null } + if (selectedInstall === null) { setProjectInstalling(projectResult.project_id, false) - if (versionId) { - onSearchResultInstalled(projectResult.project_id) - } - }, - (profile) => { - router.push(`/instance/${profile}`) - }, - { - preferredLoader: instance.value?.loader ?? undefined, - preferredGameVersion: instance.value?.game_version ?? undefined, - }, - ).catch((err) => { + return + } + const selectedPreferences = getCurrentSelectedInstallPreferences(currentProjectType) + await installVersion( + projectResult.project_id, + selectedInstall.versionId, + instance.value ? instance.value.path : null, + 'SearchCard', + (versionId) => { + setProjectInstalling(projectResult.project_id, false) + if (versionId) { + onSearchResultInstalled(projectResult.project_id) + } + }, + (profile) => { + router.push(`/instance/${profile}`) + }, + { + preferredLoader: instance.value?.loader ?? selectedPreferences.loaders?.[0], + preferredGameVersion: + instance.value?.game_version ?? selectedPreferences.gameVersions?.[0], + }, + ) + } catch (err) { setProjectInstalling(projectResult.project_id, false) handleError(err) - }) + } }, }, ] @@ -937,9 +863,7 @@ async function search(requestParams: string) { if (isServer) { const hits = rawResults.result.hits ?? [] - lastServerHits.value = hits - pingServerHits(hits) - checkServerRunningStates(hits) + updateServerHits(hits) return { projectHits: [], serverHits: hits, @@ -1024,6 +948,12 @@ watch( { deep: true }, ) +watch(queuedServerInstallCount, (count) => { + if (count === 0) { + hideSelectedServerInstalls.value = false + } +}) + if (instance.value?.game_version) { const gv = instance.value.game_version const alreadyHasGv = searchState.serverCurrentFilters.value.some( @@ -1036,16 +966,26 @@ if (instance.value?.game_version) { await searchState.refreshSearch() +function getProjectBrowseQuery() { + if (!installContext.value) return undefined + return { + ...route.query, + b: route.fullPath, + } +} + provideBrowseManager({ tags, projectType, ...searchState, getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({ path: `/project/${result.project_id ?? result.slug}`, - query: instance.value ? { i: instance.value.path } : undefined, + query: getProjectBrowseQuery(), + }), + getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => ({ + path: `/project/${result.slug ?? result.project_id}`, + query: getProjectBrowseQuery(), }), - getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => - `/project/${result.slug ?? result.project_id}`, selectableProjectTypes, showProjectTypeTabs: computed(() => !isServerContext.value), variant: 'app', @@ -1068,8 +1008,18 @@ provideBrowseManager({ () => (isServerContext.value && projectType.value !== 'modpack') || !!instance.value, ), hideInstalledLabel: computed(() => - formatMessage(isFromWorlds.value ? messages.hideAddedServers : messages.hideInstalledContent), + formatMessage( + isFromWorlds.value ? messages.hideAddedServers : commonMessages.hideInstalledContentLabel, + ), + ), + hideSelected: hideSelectedServerInstalls, + showHideSelected: computed( + () => + isServerContext.value && + projectType.value !== 'modpack' && + queuedServerInstallCount.value > 0, ), + hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)), onInstalled: onSearchResultInstalled, serverPings, getServerModpackContent, @@ -1084,8 +1034,12 @@ provideBrowseManager({ diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 8dffb6f609..4127274038 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -45,7 +45,13 @@ />
- +
+ +
+ @@ -216,10 +261,16 @@ import { PlayIcon, PlusIcon, ReportIcon, + SpinnerIcon, StopCircleIcon, } from '@modrinth/assets' import { + BrowseInstallHeader, ButtonStyled, + commonMessages, + CreationFlowModal, + defineMessages, + getTargetInstallPreferences, injectNotificationManager, NavTabs, OverflowMenu, @@ -231,7 +282,11 @@ import { ProjectSidebarLinks, ProjectSidebarServerInfo, ProjectSidebarTags, + requestInstall, + SelectedProjectsFloatingBar, + useVIntl, } from '@modrinth/ui' +import { convertFileSrc } from '@tauri-apps/api/core' import { openUrl } from '@tauri-apps/plugin-opener' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' @@ -249,6 +304,7 @@ import { get_version_many, } from '@/helpers/cache.js' import { process_listener } from '@/helpers/events' +import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' import { get_by_profile_path } from '@/helpers/process' import { get as getInstance, @@ -260,6 +316,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import { getServerLatency } from '@/helpers/worlds' import { injectContentInstall } from '@/providers/content-install' import { injectServerInstall } from '@/providers/server-install' +import { createServerInstallContent } from '@/providers/setup/server-install-content' import { useBreadcrumbs } from '@/store/breadcrumbs' import { getServerAddress } from '@/store/install.js' import { useTheming } from '@/store/state.js' @@ -272,6 +329,22 @@ const route = useRoute() const router = useRouter() const breadcrumbs = useBreadcrumbs() const themeStore = useTheming() +const { formatMessage } = useVIntl() + +const messages = defineMessages({ + backToBrowse: { + id: 'app.project.install-context.back-to-browse', + defaultMessage: 'Back to browse', + }, + installContentToInstance: { + id: 'app.project.install-context.install-content-to-instance', + defaultMessage: 'Install content to instance', + }, + alreadyInstalled: { + id: 'app.project.install-button.already-installed', + defaultMessage: 'This project is already installed', + }, +}) const { installingServerProjects, playServerProject, showAddServerToInstanceModal } = injectServerInstall() @@ -296,6 +369,11 @@ const serverPing = ref(undefined) const serverStatusOnline = ref(false) const serverInstancePath = ref(null) const serverPlaying = ref(false) +const serverSetupModalRef = ref(null) +const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) + +serverInstallContent.watchServerContextChanges() +await serverInstallContent.initServerContext() const instanceFilters = computed(() => { if (!instance.value) { @@ -315,11 +393,9 @@ const instanceFilters = computed(() => { return { l: loaders, g: instance.value.game_version } }) -const versionsHref = computed(() => { - const base = `/project/${route.params.id}/versions` - const filters = instanceFilters.value +function buildProjectHref(path, extraQuery = {}) { const params = new URLSearchParams() - for (const [key, val] of Object.entries(filters)) { + for (const [key, val] of Object.entries({ ...route.query, ...extraQuery })) { if (Array.isArray(val)) { for (const v of val) params.append(key, v) } else if (val) { @@ -327,7 +403,102 @@ const versionsHref = computed(() => { } } const qs = params.toString() - return qs ? `${base}?${qs}` : base + return qs ? `${path}?${qs}` : path +} + +const projectDescriptionHref = computed(() => buildProjectHref(`/project/${route.params.id}`)) +const versionsHref = computed(() => + buildProjectHref(`/project/${route.params.id}/versions`, instanceFilters.value), +) +const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.params.id}/gallery`)) + +const projectBrowseBackUrl = computed(() => { + const browsePath = route.query.b + if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath + const type = data.value?.project_type ? `${data.value.project_type}s` : 'mods' + return `/browse/${type}` +}) + +const projectInstallContext = computed(() => { + const serverData = serverInstallContent.serverContextServerData.value + if (serverData) { + return { + name: serverData.name, + loader: serverData.loader ?? '', + gameVersion: serverData.mc_version ?? '', + serverId: serverInstallContent.serverIdQuery.value, + upstream: serverData.upstream, + iconSrc: null, + isMedal: serverData.is_medal, + backUrl: projectBrowseBackUrl.value, + backLabel: formatMessage(messages.backToBrowse), + heading: serverInstallContent.serverBrowseHeading.value, + queuedCount: serverInstallContent.queuedServerInstallCount.value, + selectedProjects: serverInstallContent.selectedServerInstallProjects.value, + isInstallingSelected: serverInstallContent.isInstallingQueuedServerInstalls.value, + installProgress: serverInstallContent.queuedInstallProgress.value, + clearQueued: serverInstallContent.clearQueuedServerInstalls, + clearSelected: serverInstallContent.clearQueuedServerInstalls, + discardSelectedAndBack: serverInstallContent.discardQueuedServerInstallsAndBack, + installSelected: serverInstallContent.installQueuedServerInstallsAndBack, + } + } + + if (instance.value) { + return { + name: instance.value.name, + loader: instance.value.loader, + gameVersion: instance.value.game_version, + iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null, + backUrl: projectBrowseBackUrl.value, + backLabel: formatMessage(messages.backToBrowse), + heading: formatMessage(messages.installContentToInstance), + } + } + + return null +}) + +const serverProjectInstallContext = computed( + () => + !!serverInstallContent.serverContextServerData.value && + ['modpack', 'mod', 'plugin', 'datapack'].includes(data.value?.project_type), +) +const serverProjectSelected = computed( + () => !!data.value && serverInstallContent.queuedServerInstallProjectIds.value.has(data.value.id), +) +const serverProjectInstalled = computed( + () => + !!data.value && + (serverInstallContent.serverContentProjectIds.value.has(data.value.id) || + serverInstallContent.serverContextServerData.value?.upstream?.project_id === data.value.id), +) +const installButtonLoading = computed( + () => installing.value || serverInstallContent.isInstallingQueuedServerInstalls.value, +) +const installButtonValidating = computed( + () => + serverProjectInstallContext.value && + installing.value && + data.value?.project_type !== 'modpack' && + !serverInstallContent.isInstallingQueuedServerInstalls.value, +) +const installButtonInstalled = computed(() => + serverProjectInstallContext.value ? serverProjectInstalled.value : installed.value, +) +const installButtonDisabled = computed( + () => installButtonInstalled.value || installButtonLoading.value, +) +const installButtonLabel = computed(() => { + if (installButtonInstalled.value) return formatMessage(commonMessages.installedLabel) + if (installButtonValidating.value) return formatMessage(commonMessages.validatingLabel) + if (installButtonLoading.value) return formatMessage(commonMessages.installingLabel) + if (serverProjectSelected.value) return formatMessage(commonMessages.selectedLabel) + return formatMessage(commonMessages.installButton) +}) +const installButtonTooltip = computed(() => { + if (installButtonInstalled.value) return formatMessage(messages.alreadyInstalled) + return null }) const [allLoaders, allGameVersions] = await Promise.all([ @@ -499,6 +670,55 @@ watch( ) async function install(version) { + if (serverProjectInstallContext.value && data.value) { + if (serverProjectSelected.value) { + serverInstallContent.removeQueuedServerInstall(data.value.id) + return + } + if (installButtonDisabled.value) return + + installing.value = true + try { + const contentType = data.value.project_type + await requestInstall({ + project: { + ...data.value, + project_id: data.value.id, + icon_url: data.value.icon_url, + }, + contentType, + mode: contentType === 'modpack' ? 'immediate' : 'queue', + selectedFilters: [], + providedFilters: [], + overriddenProvidedFilterTypes: [], + targetPreferences: getTargetInstallPreferences( + { + gameVersion: serverInstallContent.serverContextServerData.value?.mc_version, + loader: serverInstallContent.serverContextServerData.value?.loader, + }, + contentType, + ), + getProjectVersions: async () => versions.value, + queue: { + get: serverInstallContent.getQueuedServerInstallPlans, + set: serverInstallContent.setQueuedServerInstallPlans, + }, + install: (plan) => + serverInstallContent.openServerModpackInstallFlow({ + projectId: plan.projectId, + versionId: plan.versionId, + name: plan.project.title ?? plan.project.name ?? data.value.title, + iconUrl: plan.project.icon_url ?? undefined, + }), + }) + } catch (err) { + handleError(err) + } finally { + installing.value = false + } + return + } + installing.value = true await installVersion( data.value.id, diff --git a/apps/app-frontend/src/providers/setup/server-install-content.ts b/apps/app-frontend/src/providers/setup/server-install-content.ts index 35919d9d73..08ae522596 100644 --- a/apps/app-frontend/src/providers/setup/server-install-content.ts +++ b/apps/app-frontend/src/providers/setup/server-install-content.ts @@ -1,22 +1,32 @@ -import type { Archon, Labrinth } from '@modrinth/api-client' +import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client' import { + addPendingServerContentInstalls, + type BrowseInstallPlan, + type BrowseSelectedProject, createContext, type CreationFlowContextValue, + flushInstallQueue, injectModrinthClient, injectNotificationManager, + type PendingServerContentInstall, + type PendingServerContentInstallType, + readPendingServerContentInstalls, + removePendingServerContentInstall, + writePendingServerContentInstallBaseline, } from '@modrinth/ui' import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' type ServerFlowFrom = 'onboarding' | 'reset-server' -type ServerInstallableType = 'modpack' | 'mod' | 'plugin' | 'datapack' type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & { + title?: string installing?: boolean installed?: boolean } +type PendingServerContentInstallInput = Omit -interface ServerModpackSelectionRequest { +export interface ServerModpackSelectionRequest { projectId: string versionId: string name: string @@ -40,9 +50,19 @@ export interface ServerInstallContentContext { effectiveServerWorldId: ComputedRef serverContextServerData: Ref serverContentProjectIds: Ref> + queuedServerInstallProjectIds: ComputedRef> + queuedServerInstallCount: ComputedRef + selectedServerInstallProjects: ComputedRef + isInstallingQueuedServerInstalls: Ref + queuedInstallProgress: Ref<{ completed: number; total: number }> serverBackUrl: ComputedRef serverBackLabel: ComputedRef serverBrowseHeading: ComputedRef + clearQueuedServerInstalls: () => void + removeQueuedServerInstall: (projectId: string) => void + flushQueuedServerInstalls: () => Promise + discardQueuedServerInstallsAndBack: () => Promise + installQueuedServerInstallsAndBack: () => Promise initServerContext: () => Promise watchServerContextChanges: () => void searchServerModpacks: ( @@ -51,7 +71,11 @@ export interface ServerInstallContentContext { ) => Promise getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]> enforceSetupModpackRoute: (currentProjectType: string | undefined) => void - installProjectToServer: (project: InstallableSearchResult) => Promise + getQueuedServerInstallPlans: () => Map> + setQueuedServerInstallPlans: ( + plans: Map>, + ) => void + openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise onServerFlowBack: () => void handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise markServerProjectInstalled: (id: string) => void @@ -65,6 +89,145 @@ function readQueryString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } +function getQueueStorageKey(serverId: string | null, worldId: string | null) { + if (!serverId || !worldId) return null + return `server-install-queue:${serverId}:${worldId}` +} + +function readStoredQueue(serverId: string | null, worldId: string | null) { + const key = getQueueStorageKey(serverId, worldId) + if (!key) return new Map>() + try { + const raw = localStorage.getItem(key) + if (!raw) return new Map>() + return new Map>(JSON.parse(raw)) + } catch { + return new Map>() + } +} + +function writeStoredQueue( + serverId: string | null, + worldId: string | null, + plans: Map>, +) { + const key = getQueueStorageKey(serverId, worldId) + if (!key) return + if (plans.size === 0) { + localStorage.removeItem(key) + return + } + localStorage.setItem(key, JSON.stringify(Array.from(plans.entries()))) +} + +function getQueuedInstallOwnerFallback(project: InstallableSearchResult) { + if (project.organization) { + const ownerId = project.organization_id ?? project.organization + return { + id: ownerId, + name: project.organization, + type: 'organization' as const, + link: `https://modrinth.com/organization/${ownerId}`, + } + } + + if (!project.author) return null + + const ownerId = project.author_id ?? project.author + return { + id: ownerId, + name: project.author, + type: 'user' as const, + link: `https://modrinth.com/user/${ownerId}`, + } +} + +async function getQueuedInstallOwner( + client: AbstractModrinthClient, + project: InstallableSearchResult, +) { + const fallback = getQueuedInstallOwnerFallback(project) + + try { + if (project.organization) { + const organization = await client.labrinth.projects_v3.getOrganization(project.project_id) + if (organization) { + return { + id: organization.id, + name: organization.name, + type: 'organization' as const, + avatar_url: organization.icon_url ?? undefined, + link: `https://modrinth.com/organization/${organization.slug}`, + } + } + } + + const members = await client.labrinth.projects_v3.getMembers(project.project_id) + const owner = + members.find((member) => member.user.id === project.author_id)?.user ?? + members.find((member) => member.is_owner || member.role === 'Owner')?.user ?? + members[0]?.user + + if (owner) { + return { + id: owner.id, + name: owner.username, + type: 'user' as const, + avatar_url: owner.avatar_url, + link: `https://modrinth.com/user/${owner.username}`, + } + } + } catch { + return fallback + } + + return fallback +} + +function getQueuedAddonInstallPlans( + plans: Map>, +) { + return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack') +} + +function getQueuedInstallPlaceholder( + plan: BrowseInstallPlan, + owner: PendingServerContentInstallInput['owner'], +): PendingServerContentInstallInput { + const project = plan.project as InstallableSearchResult & { slug?: string | null } + return { + projectId: plan.projectId, + versionId: plan.versionId, + contentType: plan.contentType as PendingServerContentInstallType, + title: project.title ?? project.name ?? 'Project', + versionName: plan.versionName ?? null, + versionNumber: plan.versionNumber ?? null, + fileName: plan.fileName ?? null, + owner, + slug: project.slug ?? plan.projectId, + iconUrl: project.icon_url ?? null, + } +} + +function getQueuedInstallPlaceholderFallbacks( + plans: Map>, +) { + return getQueuedAddonInstallPlans(plans).map((plan) => + getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)), + ) +} + +async function getQueuedInstallPlaceholders( + client: AbstractModrinthClient, + plans: Map>, +) { + return Promise.all( + getQueuedAddonInstallPlans(plans).map(async (plan) => + getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)), + ), + ) +} + export function createServerInstallContent(opts: { serverSetupModalRef: Ref }) { @@ -72,7 +235,7 @@ export function createServerInstallContent(opts: { const route = useRoute() const router = useRouter() const client = injectModrinthClient() - const { handleError } = injectNotificationManager() + const { addNotification, handleError } = injectNotificationManager() const serverIdQuery = computed(() => readQueryString(route.query.sid)) const worldIdQuery = computed(() => readQueryString(route.query.wid)) @@ -90,8 +253,22 @@ export function createServerInstallContent(opts: { const serverContextWorldId = ref(worldIdQuery.value) const serverContextServerData = ref(null) const serverContentProjectIds = ref>(new Set()) + const serverContentInstallKeys = ref>(new Set()) + const queuedServerInstalls = ref>>( + new Map(), + ) + const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys())) + const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size) + const selectedServerInstallProjects = computed(() => + Array.from(queuedServerInstalls.value.values()).map((plan) => ({ + id: plan.projectId, + name: plan.project.title ?? plan.project.name ?? 'Project', + iconUrl: plan.project.icon_url ?? null, + })), + ) + const isInstallingQueuedServerInstalls = ref(false) + const queuedInstallProgress = ref({ completed: 0, total: 0 }) const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value) - const serverBackUrl = computed(() => { const sid = serverIdQuery.value if (!sid) return '/hosting/manage' @@ -110,9 +287,9 @@ export function createServerInstallContent(opts: { }) const serverBrowseHeading = computed(() => { if (serverFlowFrom.value === 'reset-server') { - return 'Select modpack to install after reset' + return 'Selecting modpack to install after reset' } - return 'Install content to server' + return 'Installing content' }) async function resolveServerContextWorldId(serverId: string) { @@ -134,7 +311,11 @@ export function createServerInstallContent(opts: { .map((addon) => addon.project_id) .filter((projectId): projectId is string => !!projectId), ) + const keys = new Set( + (content.addons ?? []).map((addon) => addon.project_id ?? addon.filename), + ) serverContentProjectIds.value = ids + serverContentInstallKeys.value = keys } catch (err) { handleError(err as Error) } @@ -159,6 +340,7 @@ export function createServerInstallContent(opts: { } if (resolvedWorldId) { + queuedServerInstalls.value = readStoredQueue(sid, resolvedWorldId) await refreshServerInstalledContent(sid, resolvedWorldId) } } @@ -168,11 +350,15 @@ export function createServerInstallContent(opts: { if (!sid) { serverContextServerData.value = null serverContentProjectIds.value = new Set() + serverContentInstallKeys.value = new Set() + setQueuedServerInstallPlans(new Map()) return } if (sid !== prevSid) { serverContentProjectIds.value = new Set() + serverContentInstallKeys.value = new Set() + queuedServerInstalls.value = readStoredQueue(sid, wid) try { serverContextServerData.value = await client.archon.servers_v0.get(sid) } catch (err) { @@ -180,28 +366,16 @@ export function createServerInstallContent(opts: { } } + if (wid !== prevWid) { + queuedServerInstalls.value = readStoredQueue(sid, wid) + } + if (wid && (sid !== prevSid || wid !== prevWid)) { await refreshServerInstalledContent(sid, wid) } }) } - function normalizeLoader(loader: string) { - return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '') - } - - function getCompatibleLoaders(loader: string) { - const normalized = normalizeLoader(loader) - if (!normalized) return new Set() - if (normalized === 'paper' || normalized === 'purpur' || normalized === 'spigot') { - return new Set(['paper', 'purpur', 'spigot', 'bukkit']) - } - if (normalized === 'neoforge' || normalized === 'neo') { - return new Set(['neoforge', 'neo']) - } - return new Set([normalized]) - } - function enforceSetupModpackRoute(currentProjectType: string | undefined) { if (!isSetupServerContext.value || currentProjectType === 'modpack') return router.replace({ @@ -248,82 +422,132 @@ export function createServerInstallContent(opts: { ctx.modal.value?.setStage('final-config') } - function getCurrentServerInstallType(): ServerInstallableType { - const raw = Array.isArray(route.params.projectType) - ? route.params.projectType[0] - : route.params.projectType - if (raw === 'modpack' || raw === 'mod' || raw === 'plugin' || raw === 'datapack') { - return raw - } - throw new Error('This content type cannot be installed to a server from browse.') + function clearQueuedServerInstalls() { + setQueuedServerInstallPlans(new Map()) } - async function installProjectToServer(project: InstallableSearchResult) { - const contentType = getCurrentServerInstallType() - const sid = serverIdQuery.value - const wid = effectiveServerWorldId.value - if (!sid || !wid) { - throw new Error('No server world is available for install.') - } + function removeQueuedServerInstall(projectId: string) { + const nextPlans = new Map(queuedServerInstalls.value) + nextPlans.delete(projectId) + setQueuedServerInstallPlans(nextPlans) + } - if (contentType === 'modpack') { - const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { - include_changelog: false, - }) - const versionId = versions[0]?.id ?? project.version_id - if (!versionId) { - throw new Error('No version found for this modpack') - } + async function flushQueuedServerInstalls( + serverId: string | null = serverIdQuery.value, + worldId: string | null = effectiveServerWorldId.value, + ) { + if (queuedServerInstalls.value.size === 0) return true + if (isInstallingQueuedServerInstalls.value) return false - await openServerModpackInstallFlow({ - projectId: project.project_id, - versionId, - name: project.name, - iconUrl: project.icon_url ?? undefined, - }) + if (!serverId || !worldId) { + handleError(new Error('No server world is available for install.')) return false } - const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { - include_changelog: false, - }) - const serverLoader = (serverContextServerData.value?.loader ?? '').toLowerCase() - const serverGameVersion = (serverContextServerData.value?.mc_version ?? '').trim() - const compatibleLoaders = getCompatibleLoaders(serverLoader) - - const hasGameVersionMatch = (version: Labrinth.Versions.v2.Version) => - !serverGameVersion || version.game_versions.includes(serverGameVersion) - const hasLoaderMatch = (version: Labrinth.Versions.v2.Version) => { - if (contentType === 'datapack') return true - if (compatibleLoaders.size === 0) return true - return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoader(loader))) + const installedProjectIds = new Set() + isInstallingQueuedServerInstalls.value = true + queuedInstallProgress.value = { + completed: 0, + total: queuedServerInstalls.value.size, } - let matchingVersion = versions.find( - (version) => hasGameVersionMatch(version) && hasLoaderMatch(version), - ) - if (!matchingVersion) { - matchingVersion = versions.find((version) => hasLoaderMatch(version)) - } - if (!matchingVersion) { - matchingVersion = versions.find((version) => hasGameVersionMatch(version)) + try { + const result = await flushInstallQueue({ + queue: { + get: () => queuedServerInstalls.value, + set: (plans: Map>) => { + queuedServerInstalls.value = plans + writeStoredQueue(serverId, worldId, plans) + }, + }, + install: async (plan) => { + await client.archon.content_v1.addAddon(serverId, worldId, { + project_id: plan.projectId, + version_id: plan.versionId, + }) + installedProjectIds.add(plan.projectId) + }, + onError: (error, plan) => { + removePendingServerContentInstall(serverId, worldId, plan.projectId) + handleError(error as Error) + }, + onProgress: (completed, total) => { + queuedInstallProgress.value = { completed, total } + }, + }) + + if (installedProjectIds.size > 0) { + serverContentProjectIds.value = new Set([ + ...serverContentProjectIds.value, + ...installedProjectIds, + ]) + serverContentInstallKeys.value = new Set([ + ...serverContentInstallKeys.value, + ...installedProjectIds, + ]) + } + + return result.ok + } finally { + isInstallingQueuedServerInstalls.value = false + queuedInstallProgress.value = { completed: 0, total: 0 } } - if (!matchingVersion) { - matchingVersion = versions[0] + } + + async function discardQueuedServerInstallsAndBack() { + clearQueuedServerInstalls() + await router.push(serverBackUrl.value) + } + + async function installQueuedServerInstallsAndBack() { + const sid = serverIdQuery.value + const wid = effectiveServerWorldId.value + const backUrl = serverBackUrl.value + const plans = new Map(queuedServerInstalls.value) + + if (sid && wid) { + writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value) + addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) + void getQueuedInstallPlaceholders(client, plans) + .then((items) => { + const pendingProjectIds = new Set( + readPendingServerContentInstalls(sid, wid).map((item) => item.projectId), + ) + addPendingServerContentInstalls( + sid, + wid, + items.filter((item) => pendingProjectIds.has(item.projectId)), + ) + }) + .catch((err) => handleError(err as Error)) } - if (!matchingVersion) { - throw new Error('No installable version was found for this project.') + await router.push(backUrl) + + const ok = await flushQueuedServerInstalls(sid, wid) + if (!ok) { + queuedServerInstalls.value = new Map() + writeStoredQueue(sid, wid, new Map()) + addNotification({ + type: 'error', + title: 'Some projects failed to install', + text: 'Failed projects were not added. You can try installing them again.', + }) } - await client.archon.content_v1.addAddon(sid, wid, { - project_id: matchingVersion.project_id, - version_id: matchingVersion.id, - }) - - serverContentProjectIds.value = new Set([...serverContentProjectIds.value, project.project_id]) return true } + function getQueuedServerInstallPlans() { + return queuedServerInstalls.value + } + + function setQueuedServerInstallPlans( + plans: Map>, + ) { + queuedServerInstalls.value = plans + writeStoredQueue(serverIdQuery.value, effectiveServerWorldId.value, plans) + } + function onServerFlowBack() { serverSetupModalRef.value?.hide() } @@ -377,15 +601,27 @@ export function createServerInstallContent(opts: { effectiveServerWorldId, serverContextServerData, serverContentProjectIds, + queuedServerInstallProjectIds, + queuedServerInstallCount, + selectedServerInstallProjects, + isInstallingQueuedServerInstalls, + queuedInstallProgress, serverBackUrl, serverBackLabel, serverBrowseHeading, + clearQueuedServerInstalls, + removeQueuedServerInstall, + flushQueuedServerInstalls, + discardQueuedServerInstallsAndBack, + installQueuedServerInstallsAndBack, initServerContext, watchServerContextChanges, searchServerModpacks, getServerProjectVersions, enforceSetupModpackRoute, - installProjectToServer, + getQueuedServerInstallPlans, + setQueuedServerInstallPlans, + openServerModpackInstallFlow, onServerFlowBack, handleServerModpackFlowCreate, markServerProjectInstalled, diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 102787cbf4..d5d6610c34 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -1229,6 +1229,39 @@ "dashboard.withdraw.error.tax-form.title": { "message": "Please complete tax form" }, + "discover.install.back-to-server": { + "message": "Back to server" + }, + "discover.install.back-to-setup": { + "message": "Back to setup" + }, + "discover.install.cancel-reset": { + "message": "Cancel reset" + }, + "discover.install.error.no-server-world": { + "message": "No server world is available for install." + }, + "discover.install.error.some-projects-failed.description": { + "message": "Failed projects were not added. You can try installing them again." + }, + "discover.install.error.some-projects-failed.title": { + "message": "Some projects failed to install" + }, + "discover.install.error.unsupported-content-type": { + "message": "This content type cannot be installed to a server from browse." + }, + "discover.install.heading.reset-modpack": { + "message": "Selecting modpack to install after reset" + }, + "discover.seo.description": { + "message": "Search and browse thousands of Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}." + }, + "discover.seo.title": { + "message": "Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}" + }, + "discover.seo.title-with-query": { + "message": "Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} | {query}" + }, "error.collection.404.list_item.1": { "message": "You may have mistyped the collection's URL." }, @@ -3062,6 +3095,9 @@ "search.filter.locked.server.sync": { "message": "Sync with server" }, + "servers.manage.content.title": { + "message": "Content - {serverName} - Modrinth" + }, "servers.notice.actions": { "message": "Actions" }, diff --git a/apps/frontend/src/pages/discover/[type]/index.vue b/apps/frontend/src/pages/discover/[type]/index.vue index 95a85e9fc9..34fc58a29e 100644 --- a/apps/frontend/src/pages/discover/[type]/index.vue +++ b/apps/frontend/src/pages/discover/[type]/index.vue @@ -11,20 +11,37 @@ import { MoreVerticalIcon, SpinnerIcon, } from '@modrinth/assets' -import type { CardAction, CreationFlowContextValue } from '@modrinth/ui' +import type { + BrowseInstallContentType, + BrowseInstallPlan, + CardAction, + CreationFlowContextValue, + PendingServerContentInstall, + PendingServerContentInstallType, +} from '@modrinth/ui' import { + addPendingServerContentInstalls, BrowseInstallHeader, BrowsePageLayout, BrowseSidebar, + commonMessages, CreationFlowModal, defineMessages, + flushInstallQueue, + getTargetInstallPreferences, injectModrinthClient, injectNotificationManager, PROJECT_DEP_MARKER_QUERY, provideBrowseManager, + readPendingServerContentInstalls, + removePendingServerContentInstall, + requestInstall, + SelectedProjectsFloatingBar, useBrowseSearch, useDebugLogger, + useStickyObserver, useVIntl, + writePendingServerContentInstallBaseline, } from '@modrinth/ui' import { cycleValue } from '@modrinth/utils' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' @@ -47,6 +64,9 @@ const client = injectModrinthClient() const queryClient = useQueryClient() const filtersMenuOpen = ref(false) +const stickyInstallHeaderRef = ref(null) + +useStickyObserver(stickyInstallHeaderRef, 'DiscoverInstallHeader') const route = useRoute() @@ -55,7 +75,7 @@ const tags = useGeneratedState() const flags = useFeatureFlags() const auth = await useAuth() -const { handleError } = injectNotificationManager() +const { addNotification, handleError } = injectNotificationManager() let prefetchTimeout: ReturnType | null = null const HOVER_DURATION_TO_PREFETCH_MS = 500 @@ -143,7 +163,7 @@ function cycleSearchDisplayMode() { const currentServerId = computed(() => queryAsString(route.query.sid) || null) const fromContext = computed(() => queryAsString(route.query.from) || null) -const currentWorldId = computed(() => queryAsString(route.query.wid) || undefined) +const currentWorldId = computed(() => queryAsString(route.query.wid) || null) const { data: serverData, @@ -176,11 +196,139 @@ const serverIcon = computed(() => { }) const serverHideInstalled = ref(false) +const hideSelectedServerInstalls = ref(false) const installingProjectIds = ref>(new Set()) const optimisticallyInstalledProjectIds = ref>(new Set()) const hiddenInstalledProjectIds = ref>(new Set()) const hiddenInstalledProjectIdsInitialized = ref(false) +interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject { + installed?: boolean +} +type PendingServerContentInstallInput = Omit + +const queuedServerInstalls = ref>>(new Map()) +const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys())) +const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size) +const selectedServerInstallProjects = computed(() => + Array.from(queuedServerInstalls.value.values()).map((plan) => ({ + id: plan.projectId, + name: plan.project.title ?? formatMessage(commonMessages.projectLabel), + iconUrl: plan.project.icon_url ?? null, + })), +) +const isInstallingQueuedServerInstalls = ref(false) +const queuedInstallProgress = ref({ completed: 0, total: 0 }) +const serverInstallQueue = { + get: () => queuedServerInstalls.value, + set: (plans: Map>) => { + queuedServerInstalls.value = plans + }, +} + +function getQueuedInstallOwnerFallback(project: InstallableSearchResult) { + if (project.organization) { + const ownerId = project.organization_id ?? project.organization + return { + id: ownerId, + name: project.organization, + type: 'organization' as const, + link: `/organization/${ownerId}`, + } + } + + if (!project.author) return null + + const ownerId = project.author_id ?? project.author + return { + id: ownerId, + name: project.author, + type: 'user' as const, + link: `/user/${ownerId}`, + } +} + +async function getQueuedInstallOwner(project: InstallableSearchResult) { + const fallback = getQueuedInstallOwnerFallback(project) + + try { + if (project.organization) { + const organization = await client.labrinth.projects_v3.getOrganization(project.project_id) + if (organization) { + return { + id: organization.id, + name: organization.name, + type: 'organization' as const, + avatar_url: organization.icon_url ?? undefined, + link: `/organization/${organization.slug}`, + } + } + } + + const members = await client.labrinth.projects_v3.getMembers(project.project_id) + const owner = + members.find((member) => member.user.id === project.author_id)?.user ?? + members.find((member) => member.is_owner || member.role === 'Owner')?.user ?? + members[0]?.user + + if (owner) { + return { + id: owner.id, + name: owner.username, + type: 'user' as const, + avatar_url: owner.avatar_url, + link: `/user/${owner.username}`, + } + } + } catch { + return fallback + } + + return fallback +} + +function getQueuedAddonInstallPlans( + plans: Map>, +) { + return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack') +} + +function getQueuedInstallPlaceholder( + plan: BrowseInstallPlan, + owner: PendingServerContentInstallInput['owner'], +): PendingServerContentInstallInput { + return { + projectId: plan.projectId, + versionId: plan.versionId, + contentType: plan.contentType as PendingServerContentInstallType, + title: plan.project.title ?? formatMessage(commonMessages.projectLabel), + versionName: plan.versionName ?? null, + versionNumber: plan.versionNumber ?? null, + fileName: plan.fileName ?? null, + owner, + slug: plan.project.slug ?? plan.projectId, + iconUrl: plan.project.icon_url ?? null, + } +} + +function getQueuedInstallPlaceholderFallbacks( + plans: Map>, +) { + return getQueuedAddonInstallPlans(plans).map((plan) => + getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)), + ) +} + +async function getQueuedInstallPlaceholders( + plans: Map>, +) { + return Promise.all( + getQueuedAddonInstallPlans(plans).map(async (plan) => + getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)), + ), + ) +} + function setProjectInstalling(projectId: string, installing: boolean) { const next = new Set(installingProjectIds.value) if (installing) { @@ -206,6 +354,10 @@ function getServerInstalledProjectIds(data = serverContentData.value) { ) } +function getServerInstalledContentKeys(data = serverContentData.value) { + return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename)) +} + function syncHiddenInstalledProjectIds() { hiddenInstalledProjectIds.value = new Set([ ...getServerInstalledProjectIds(), @@ -242,21 +394,22 @@ watch( const installContentMutation = useMutation({ mutationFn: ({ serverId, + worldId, projectId, versionId, }: { serverId: string + worldId: string projectId: string versionId: string }) => - client.archon.content_v1.addAddon(serverId, currentWorldId.value!, { + client.archon.content_v1.addAddon(serverId, worldId, { project_id: projectId, version_id: versionId, }), - onSuccess: () => { - if (currentServerId.value) { - queryClient.refetchQueries({ queryKey: ['content', 'list', currentServerId.value] }) - } + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', variables.serverId] }) + queryClient.invalidateQueries({ queryKey: ['content', 'list'] }) }, }) @@ -309,6 +462,16 @@ const serverFilters = computed(() => { }) } } + + if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) { + for (const id of queuedServerInstallProjectIds.value) { + filters.push({ + type: 'project_id', + option: `project_id:${id}`, + negative: true, + }) + } + } } if (currentServerId.value && projectType.value?.id === 'modpack') { @@ -321,78 +484,199 @@ const serverFilters = computed(() => { return filters }) -interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject { - installed?: boolean +function getCurrentServerInstallType(): BrowseInstallContentType { + const type = projectType.value?.id + if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') { + return type + } + throw new Error(formatMessage(messages.unsupportedContentType)) +} + +function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) { + return getTargetInstallPreferences( + { + gameVersion: serverData.value?.mc_version, + loader: serverData.value?.loader, + }, + contentType, + ) +} + +function getInstallProjectVersions(projectId: string) { + return client.labrinth.versions_v2.getProjectVersions(projectId, { + include_changelog: false, + }) +} + +function clearQueuedServerInstalls() { + queuedServerInstalls.value = new Map() +} + +function removeQueuedServerInstall(projectId: string) { + const nextPlans = new Map(queuedServerInstalls.value) + nextPlans.delete(projectId) + queuedServerInstalls.value = nextPlans +} + +watch([currentServerId, currentWorldId], ([serverId, worldId], [prevServerId, prevWorldId]) => { + if (serverId !== prevServerId || worldId !== prevWorldId) { + clearQueuedServerInstalls() + } +}) + +async function flushQueuedServerInstalls( + serverId: string | null = currentServerId.value, + worldId: string | null = currentWorldId.value, +) { + if (queuedServerInstalls.value.size === 0) return true + if (isInstallingQueuedServerInstalls.value) return false + + if (!serverId || !worldId) { + handleError(new Error(formatMessage(messages.noServerWorld))) + return false + } + + isInstallingQueuedServerInstalls.value = true + queuedInstallProgress.value = { + completed: 0, + total: queuedServerInstalls.value.size, + } + + try { + const result = await flushInstallQueue({ + queue: serverInstallQueue, + install: async (plan) => { + await installContentMutation.mutateAsync({ + serverId, + worldId, + projectId: plan.projectId, + versionId: plan.versionId, + }) + markProjectInstalled(plan.projectId) + }, + onError: (error, plan) => { + removePendingServerContentInstall(serverId, worldId, plan.projectId) + handleError(error as Error) + }, + onProgress: (completed, total) => { + queuedInstallProgress.value = { completed, total } + }, + }) + + return result.ok + } finally { + isInstallingQueuedServerInstalls.value = false + queuedInstallProgress.value = { completed: 0, total: 0 } + } +} + +async function discardQueuedServerInstallsAndBack() { + clearQueuedServerInstalls() + await navigateTo(serverBackUrl.value) +} + +async function installQueuedServerInstallsAndBack() { + const sid = currentServerId.value + const wid = currentWorldId.value + const backUrl = serverBackUrl.value + const plans = new Map(queuedServerInstalls.value) + + if (sid && wid) { + writePendingServerContentInstallBaseline(sid, wid, [ + ...getServerInstalledContentKeys(), + ...optimisticallyInstalledProjectIds.value, + ]) + addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) + void getQueuedInstallPlaceholders(plans) + .then((items) => { + const pendingProjectIds = new Set( + readPendingServerContentInstalls(sid, wid).map((item) => item.projectId), + ) + addPendingServerContentInstalls( + sid, + wid, + items.filter((item) => pendingProjectIds.has(item.projectId)), + ) + }) + .catch((err) => handleError(err as Error)) + } + await navigateTo(backUrl) + + const ok = await flushQueuedServerInstalls(sid, wid) + if (!ok) { + queuedServerInstalls.value = new Map() + addNotification({ + type: 'error', + title: formatMessage(messages.someProjectsFailedTitle), + text: formatMessage(messages.someProjectsFailedText), + }) + } + + return true } async function serverInstall(project: InstallableSearchResult) { - if (!serverData.value || !currentServerId.value) { + if (!serverData.value || !currentServerId.value || !currentWorldId.value) { handleError(new Error('No server to install to.')) return } - setProjectInstalling(project.project_id, true) + const contentType = getCurrentServerInstallType() + const isModpack = contentType === 'modpack' + try { - if (projectType.value?.id === 'modpack') { - const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { - include_changelog: false, - }) - const versionId = versions[0]?.id ?? project.latest_version - if (!versionId) { - handleError(new Error('No version found for this modpack')) - setProjectInstalling(project.project_id, false) - return - } - const modalInstance = onboardingModalRef.value - if (modalInstance) { - onboardingInstallingProject.value = project + if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) { + removeQueuedServerInstall(project.project_id) + return + } + + if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) { + setProjectInstalling(project.project_id, true) + } + + await requestInstall({ + project, + contentType, + mode: isModpack ? 'immediate' : 'queue', + selectedFilters: isModpack ? [] : searchState.currentFilters.value, + providedFilters: isModpack ? [] : serverFilters.value, + overriddenProvidedFilterTypes: isModpack + ? [] + : searchState.overriddenProvidedFilterTypes.value, + targetPreferences: getServerInstallTargetPreferences(contentType), + getProjectVersions: getInstallProjectVersions, + queue: serverInstallQueue, + install: async (plan) => { + const modalInstance = onboardingModalRef.value + if (!modalInstance) { + setProjectInstalling(plan.projectId, false) + return + } + + onboardingInstallingProject.value = plan.project modalInstance.show() await nextTick() const ctx = modalInstance.ctx ctx.setupType.value = 'modpack' ctx.modpackSelection.value = { - projectId: project.project_id, - versionId, - name: project.title, - iconUrl: project.icon_url ?? undefined, + projectId: plan.projectId, + versionId: plan.versionId, + name: plan.project.title, + iconUrl: plan.project.icon_url ?? undefined, } ctx.modal.value?.setStage('final-config') - } - return - } else if ( - projectType.value?.id === 'mod' || - projectType.value?.id === 'plugin' || - projectType.value?.id === 'datapack' - ) { - const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id) - const isDatapack = projectType.value?.id === 'datapack' - const version = versions.find((x) => { - if (!x.game_versions.includes(serverData.value!.mc_version!)) return false - if (isDatapack) return true - return x.loaders.includes(serverData.value!.loader!.toLowerCase()) - }) - if (!version) { - handleError( - new Error( - isDatapack - ? `No compatible version found for ${serverData.value!.mc_version}` - : `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`, - ), - ) - setProjectInstalling(project.project_id, false) - return - } - await installContentMutation.mutateAsync({ - serverId: currentServerId.value, - projectId: version.project_id, - versionId: version.id, - }) - markProjectInstalled(project.project_id) - } + }, + }) } catch (e) { console.error(e) - handleError(new Error(`Error installing content ${e}`)) + if (isModpack) { + setProjectInstalling(project.project_id, false) + } + handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`)) + } finally { + if (!isModpack) { + setProjectInstalling(project.project_id, false) + } } - setProjectInstalling(project.project_id, false) } function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) { @@ -503,6 +787,7 @@ function getCardActions( } if (serverData.value) { + const isQueued = queuedServerInstallProjectIds.value.has(result.project_id) const isInstalled = projectResult.installed || optimisticallyInstalledProjectIds.value.has(result.project_id) || @@ -510,15 +795,36 @@ function getCardActions( (serverContentData.value.addons ?? []).find((x) => x.project_id === result.project_id)) || serverData.value.upstream?.project_id === result.project_id const isInstalling = installingProjectIds.value.has(result.project_id) + const isInstallingSelection = isInstallingQueuedServerInstalls.value + const validatingInstall = + isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection + const installLabel = isInstalled + ? formatMessage(commonMessages.installedLabel) + : isQueued + ? isInstalling || isInstallingSelection + ? validatingInstall + ? formatMessage(commonMessages.validatingLabel) + : formatMessage(commonMessages.installingLabel) + : formatMessage(commonMessages.selectedLabel) + : isInstalling || isInstallingSelection + ? validatingInstall + ? formatMessage(commonMessages.validatingLabel) + : formatMessage(commonMessages.installingLabel) + : formatMessage(commonMessages.installButton) return [ { key: 'install', - label: isInstalling ? 'Installing...' : isInstalled ? 'Installed' : 'Install', - icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : DownloadIcon, - iconClass: isInstalling ? 'animate-spin' : undefined, - disabled: !!isInstalled || isInstalling, - color: 'brand', + label: installLabel, + icon: + isInstalling || isInstallingSelection + ? SpinnerIcon + : isQueued || isInstalled + ? CheckIcon + : DownloadIcon, + iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined, + disabled: !!isInstalled || isInstalling || isInstallingSelection, + color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand', type: 'outlined', onClick: () => serverInstall(projectResult), }, @@ -579,15 +885,15 @@ const serverBackUrl = computed(() => { }) const serverBackLabel = computed(() => { - if (fromContext.value === 'onboarding') return 'Back to setup' - if (fromContext.value === 'reset-server') return 'Cancel reset' - return 'Back to server' + if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup) + if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset) + return formatMessage(messages.backToServer) }) const serverBrowseHeading = computed(() => fromContext.value === 'reset-server' - ? 'Select modpack to install after reset' - : 'Install content to server', + ? formatMessage(messages.resetModpackHeading) + : formatMessage(commonMessages.installingContentLabel), ) const installContext = computed(() => { @@ -603,10 +909,51 @@ const installContext = computed(() => { backUrl: serverBackUrl.value, backLabel: serverBackLabel.value, heading: serverBrowseHeading.value, + queuedCount: queuedServerInstallCount.value, + selectedProjects: selectedServerInstallProjects.value, + isInstallingSelected: isInstallingQueuedServerInstalls.value, + installProgress: queuedInstallProgress.value, + clearQueued: clearQueuedServerInstalls, + clearSelected: clearQueuedServerInstalls, + onBack: flushQueuedServerInstalls, + discardSelectedAndBack: discardQueuedServerInstallsAndBack, + installSelected: installQueuedServerInstallsAndBack, } }) const messages = defineMessages({ + unsupportedContentType: { + id: 'discover.install.error.unsupported-content-type', + defaultMessage: 'This content type cannot be installed to a server from browse.', + }, + noServerWorld: { + id: 'discover.install.error.no-server-world', + defaultMessage: 'No server world is available for install.', + }, + someProjectsFailedTitle: { + id: 'discover.install.error.some-projects-failed.title', + defaultMessage: 'Some projects failed to install', + }, + someProjectsFailedText: { + id: 'discover.install.error.some-projects-failed.description', + defaultMessage: 'Failed projects were not added. You can try installing them again.', + }, + backToSetup: { + id: 'discover.install.back-to-setup', + defaultMessage: 'Back to setup', + }, + cancelReset: { + id: 'discover.install.cancel-reset', + defaultMessage: 'Cancel reset', + }, + backToServer: { + id: 'discover.install.back-to-server', + defaultMessage: 'Back to server', + }, + resetModpackHeading: { + id: 'discover.install.heading.reset-modpack', + defaultMessage: 'Selecting modpack to install after reset', + }, gameVersionProvidedByServer: { id: 'search.filter.locked.server-game-version.title', defaultMessage: 'Game version is provided by the server', @@ -623,6 +970,21 @@ const messages = defineMessages({ id: 'search.filter.locked.server.sync', defaultMessage: 'Sync with server', }, + seoTitle: { + id: 'discover.seo.title', + defaultMessage: + 'Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}', + }, + seoTitleWithQuery: { + id: 'discover.seo.title-with-query', + defaultMessage: + 'Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} | {query}', + }, + seoDescription: { + id: 'discover.seo.description', + defaultMessage: + 'Search and browse thousands of Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}.', + }, gameVersionShaderMessage: { id: 'search.filter.game-version-shader-message', defaultMessage: @@ -648,6 +1010,12 @@ const searchState = useBrowseSearch({ displayMode: resultsDisplayMode, }) +watch(queuedServerInstallCount, (count) => { + if (count === 0) { + hideSelectedServerInstalls.value = false + } +}) + watch( () => searchState.isServerType.value @@ -673,13 +1041,16 @@ watch( debug('calling initial refreshSearch') searchState.refreshSearch() -const ogTitle = computed( - () => - `Search ${projectType.value?.display ?? 'project'}s${searchState.query.value ? ' | ' + searchState.query.value : ''}`, +const ogTitle = computed(() => + searchState.query.value + ? formatMessage(messages.seoTitleWithQuery, { + projectType: projectType.value?.id ?? 'project', + query: searchState.query.value, + }) + : formatMessage(messages.seoTitle, { projectType: projectType.value?.id ?? 'project' }), ) -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 description = computed(() => + formatMessage(messages.seoDescription, { projectType: projectType.value?.id ?? 'project' }), ) useSeoMeta({ @@ -705,7 +1076,15 @@ provideBrowseManager({ providedFilters: serverFilters, hideInstalled: serverHideInstalled, showHideInstalled: computed(() => !!serverData.value && projectType.value?.id !== 'modpack'), - hideInstalledLabel: computed(() => 'Hide already installed content'), + hideInstalledLabel: computed(() => formatMessage(commonMessages.hideInstalledContentLabel)), + hideSelected: hideSelectedServerInstalls, + showHideSelected: computed( + () => + !!serverData.value && + projectType.value?.id !== 'modpack' && + queuedServerInstallCount.value > 0, + ), + hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)), displayMode: resultsDisplayMode, cycleDisplayMode: cycleSearchDisplayMode, maxResultsOptions: currentMaxResultsOptions, @@ -728,10 +1107,15 @@ provideBrowseManager({
-
+
-