feat:移除了弹窗,服务器添加sls
This commit is contained in:
3232
apps/app-frontend/src/pages/Browse.vue
Normal file
3232
apps/app-frontend/src/pages/Browse.vue
Normal file
File diff suppressed because it is too large
Load Diff
125
apps/app-frontend/src/pages/Create.vue
Normal file
125
apps/app-frontend/src/pages/Create.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
const showModal = inject<
|
||||
(options?: {
|
||||
skipSetupType?: boolean
|
||||
initialMode?: 'custom' | 'import'
|
||||
onBack?: () => void
|
||||
}) => void
|
||||
>('showCreationModalWithOptions')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'create.title',
|
||||
defaultMessage: 'Create Instance',
|
||||
},
|
||||
subtitle: {
|
||||
id: 'create.subtitle',
|
||||
defaultMessage: 'Start a new adventure or bring your existing worlds',
|
||||
},
|
||||
newTitle: {
|
||||
id: 'create.new.title',
|
||||
defaultMessage: 'Start Fresh',
|
||||
},
|
||||
newDescription: {
|
||||
id: 'create.new.description',
|
||||
defaultMessage: 'Create a new Minecraft instance from scratch.',
|
||||
},
|
||||
importTitle: {
|
||||
id: 'create.import.title',
|
||||
defaultMessage: 'Import Existing',
|
||||
},
|
||||
importDescription: {
|
||||
id: 'create.import.description',
|
||||
defaultMessage: 'Import instances from other launchers or install a modpack.',
|
||||
},
|
||||
back: {
|
||||
id: 'create.back',
|
||||
defaultMessage: 'Back to Library',
|
||||
},
|
||||
pclHmclHint: {
|
||||
id: 'create.pcl-hmcl-hint',
|
||||
defaultMessage: 'Using PCL / HMCL?',
|
||||
},
|
||||
addMinecraftFolder: {
|
||||
id: 'create.add-minecraft-folder',
|
||||
defaultMessage: 'Add .minecraft folder',
|
||||
},
|
||||
})
|
||||
|
||||
const navigateBack = () => router.push('/library')
|
||||
|
||||
function handleStartFresh() {
|
||||
showModal?.({
|
||||
skipSetupType: true,
|
||||
initialMode: 'custom',
|
||||
onBack: () => router.push('/create'),
|
||||
})
|
||||
}
|
||||
|
||||
function handleImportExisting() {
|
||||
showModal?.({
|
||||
skipSetupType: true,
|
||||
initialMode: 'import',
|
||||
onBack: () => router.push('/create'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col items-center justify-center p-6">
|
||||
<div class="flex w-full max-w-2xl flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h1>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.subtitle) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div data-onboarding-id="creation-methods" class="flex flex-col gap-4 sm:flex-row">
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-custom"
|
||||
:icon="SparklesIcon"
|
||||
:title="formatMessage(messages.newTitle)"
|
||||
:description="formatMessage(messages.newDescription)"
|
||||
no-icon-box
|
||||
@click="handleStartFresh"
|
||||
/>
|
||||
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-import"
|
||||
:icon="FolderOpenIcon"
|
||||
:title="formatMessage(messages.importTitle)"
|
||||
:description="formatMessage(messages.importDescription)"
|
||||
no-icon-box
|
||||
@click="handleImportExisting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.pclHmclHint) }}
|
||||
{{ ' ' }}
|
||||
<RouterLink
|
||||
to="/settings#storage-backups"
|
||||
class="text-brand underline decoration-transparent underline-offset-2 transition-colors hover:decoration-current"
|
||||
>
|
||||
{{ formatMessage(messages.addMinecraftFolder) }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
|
||||
<Button transparent class="self-start" @click="navigateBack">
|
||||
<LeftArrowIcon class="size-4" stroke-width="2" />
|
||||
{{ formatMessage(messages.back) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
1306
apps/app-frontend/src/pages/Downloads.vue
Normal file
1306
apps/app-frontend/src/pages/Downloads.vue
Normal file
File diff suppressed because it is too large
Load Diff
934
apps/app-frontend/src/pages/Favorites.vue
Normal file
934
apps/app-frontend/src/pages/Favorites.vue
Normal file
@ -0,0 +1,934 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BookmarkFilledIcon,
|
||||
BookmarkIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
GenericListIcon,
|
||||
GridIcon,
|
||||
ListIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
type BrowseInstallContext,
|
||||
BrowseInstallHeader,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
getLatestMatchingInstallVersion,
|
||||
getTargetInstallPreferences,
|
||||
injectNotificationManager,
|
||||
LoadingIndicator,
|
||||
NavTabs,
|
||||
Pagination,
|
||||
PopoutMenu,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
SelectedProjectsFloatingBar,
|
||||
StyledInput,
|
||||
useStickyObserver,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useContentFavorites } from '@/composables/useContentFavorites'
|
||||
import { createBrowseProjectTabs, getBrowseProjectTabOptions } from '@/helpers/browse-project-tabs'
|
||||
import {
|
||||
completeBrowseReturnNavigation,
|
||||
consumeBrowseReturnSnapshot,
|
||||
isBrowseReturnSourcePath,
|
||||
saveBrowseReturnSnapshot,
|
||||
} from '@/helpers/browse-return-state.ts'
|
||||
import { get_project, get_project_many, get_version_many } from '@/helpers/cache.js'
|
||||
import {
|
||||
type ContentFavorite,
|
||||
contentFavoriteKey,
|
||||
type FavoriteContentType,
|
||||
type FavoriteProvider,
|
||||
} from '@/helpers/content-favorites'
|
||||
import {
|
||||
type CurseForgeProject,
|
||||
getCurseForgeFiles,
|
||||
getCurseForgeImageUrl,
|
||||
getCurseForgeProjects,
|
||||
} from '@/helpers/curseforge'
|
||||
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
||||
import {
|
||||
getLastBrowseContentDisplayMode,
|
||||
setLastBrowseContentDisplayMode,
|
||||
} from '@/helpers/settings'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { injectContentSelection, makeContentSelectionKey } from '@/providers/content-selection'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
type FavoriteFilter = 'all' | FavoriteContentType
|
||||
type FavoriteDisplayMode = 'list' | 'compact' | 'grid'
|
||||
|
||||
type FavoriteProject = {
|
||||
favorite: ContentFavorite
|
||||
provider: FavoriteProvider
|
||||
projectId: string
|
||||
title: string
|
||||
description: string
|
||||
slug?: string
|
||||
iconUrl?: string
|
||||
downloads?: number
|
||||
categories: string[]
|
||||
dateCreated?: string
|
||||
dateModified?: string
|
||||
banner?: string
|
||||
color?: string | number
|
||||
environment?: {
|
||||
clientSide: Labrinth.Projects.v2.Environment
|
||||
serverSide: Labrinth.Projects.v2.Environment
|
||||
}
|
||||
unavailable: boolean
|
||||
}
|
||||
|
||||
type FavoritesReturnState = {
|
||||
projects: FavoriteProject[]
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const curseForgeLoaderTypes: Record<string, number> = {
|
||||
forge: 1,
|
||||
fabric: 4,
|
||||
quilt: 5,
|
||||
neoforge: 6,
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const contentSelection = injectContentSelection()
|
||||
const contentFavorites = useContentFavorites()
|
||||
const instanceSelector = ref<InstanceType<typeof BrowseInstanceSelector>>()
|
||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||
stickyInstallHeaderRef,
|
||||
'FavoritesInstallHeader',
|
||||
)
|
||||
const projects = shallowRef<FavoriteProject[]>([])
|
||||
const loadingProjects = ref(false)
|
||||
const installingKeys = ref(new Set<string>())
|
||||
const displayMode = ref<FavoriteDisplayMode>(getLastBrowseContentDisplayMode())
|
||||
let projectRequestId = 0
|
||||
const browseReturnSnapshot = consumeBrowseReturnSnapshot<FavoritesReturnState>(route.fullPath)
|
||||
if (browseReturnSnapshot) projects.value = browseReturnSnapshot.state.projects
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.content-favorites.title',
|
||||
defaultMessage: 'Favorites',
|
||||
},
|
||||
search: {
|
||||
id: 'app.content-favorites.search',
|
||||
defaultMessage: 'Search favorites',
|
||||
},
|
||||
allContentTypes: {
|
||||
id: 'app.content-favorites.type.all',
|
||||
defaultMessage: 'All content types',
|
||||
},
|
||||
mods: {
|
||||
id: 'app.browse.project-type.mods',
|
||||
defaultMessage: 'Mods',
|
||||
},
|
||||
resourcepacks: {
|
||||
id: 'app.browse.project-type.resourcepacks',
|
||||
defaultMessage: 'Resource Packs',
|
||||
},
|
||||
datapacks: {
|
||||
id: 'app.browse.project-type.datapacks',
|
||||
defaultMessage: 'Data Packs',
|
||||
},
|
||||
shaders: {
|
||||
id: 'app.browse.project-type.shaders',
|
||||
defaultMessage: 'Shaders',
|
||||
},
|
||||
modpacks: {
|
||||
id: 'app.browse.project-type.modpacks',
|
||||
defaultMessage: 'Modpacks',
|
||||
},
|
||||
maps: {
|
||||
id: 'app.browse.project-type.maps',
|
||||
defaultMessage: 'Maps',
|
||||
},
|
||||
servers: {
|
||||
id: 'app.browse.project-type.servers',
|
||||
defaultMessage: 'Servers',
|
||||
},
|
||||
chooseInstance: {
|
||||
id: 'app.browse.choose-instance',
|
||||
defaultMessage: 'Choose instance',
|
||||
},
|
||||
backToInstance: {
|
||||
id: 'app.browse.back-to-instance',
|
||||
defaultMessage: 'Back to instance',
|
||||
},
|
||||
installSelected: {
|
||||
id: 'app.browse.install-selected',
|
||||
defaultMessage: 'Install {count} content',
|
||||
},
|
||||
preparingSelected: {
|
||||
id: 'app.browse.preparing-selected',
|
||||
defaultMessage: 'Preparing {completed}/{total}',
|
||||
},
|
||||
selected: {
|
||||
id: 'app.browse.selected',
|
||||
defaultMessage: 'Selected',
|
||||
},
|
||||
noCompatibleVersion: {
|
||||
id: 'app.browse.no-compatible-version',
|
||||
defaultMessage: 'No compatible version was found for the selected instance.',
|
||||
},
|
||||
remove: {
|
||||
id: 'app.content-favorites.remove',
|
||||
defaultMessage: 'Remove from favorites',
|
||||
},
|
||||
view: {
|
||||
id: 'app.browse.display-mode.switch',
|
||||
defaultMessage: 'Switch view',
|
||||
},
|
||||
listView: {
|
||||
id: 'app.browse.display-mode.list',
|
||||
defaultMessage: 'List',
|
||||
},
|
||||
compactView: {
|
||||
id: 'app.browse.display-mode.compact-list',
|
||||
defaultMessage: 'Compact list',
|
||||
},
|
||||
gridView: {
|
||||
id: 'app.browse.display-mode.grid',
|
||||
defaultMessage: 'Grid',
|
||||
},
|
||||
recentlySaved: {
|
||||
id: 'app.content-favorites.sort.recently-saved',
|
||||
defaultMessage: 'Recently saved',
|
||||
},
|
||||
emptyTitle: {
|
||||
id: 'app.content-favorites.empty-title',
|
||||
defaultMessage: 'No favorites yet',
|
||||
},
|
||||
emptyDescription: {
|
||||
id: 'app.content-favorites.empty-description',
|
||||
defaultMessage: 'Bookmark mods, resource packs, data packs, and shaders to install them later.',
|
||||
},
|
||||
noMatchesTitle: {
|
||||
id: 'app.content-favorites.no-matches-title',
|
||||
defaultMessage: 'No matching favorites',
|
||||
},
|
||||
noMatchesDescription: {
|
||||
id: 'app.content-favorites.no-matches-description',
|
||||
defaultMessage: 'Try a different search or content type.',
|
||||
},
|
||||
unavailableTitle: {
|
||||
id: 'app.content-favorites.unavailable-title',
|
||||
defaultMessage: '{provider} project {projectId}',
|
||||
},
|
||||
unavailableDescription: {
|
||||
id: 'app.content-favorites.unavailable-description',
|
||||
defaultMessage: 'This project is currently unavailable. You can remove it from favorites.',
|
||||
},
|
||||
})
|
||||
|
||||
function queryValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function parseFavoriteFilter(value: unknown): FavoriteFilter {
|
||||
return value === 'mod' || value === 'resourcepack' || value === 'datapack' || value === 'shader'
|
||||
? value
|
||||
: 'all'
|
||||
}
|
||||
|
||||
function parsePage(value: unknown): number {
|
||||
const page = Number.parseInt(queryValue(value), 10)
|
||||
return Number.isFinite(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
function updateQuery(values: Record<string, string | undefined>) {
|
||||
void router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
...values,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const search = computed({
|
||||
get: () => queryValue(route.query.q),
|
||||
set: (value: string) => updateQuery({ q: value || undefined, page: undefined }),
|
||||
})
|
||||
|
||||
const filter = computed<FavoriteFilter>({
|
||||
get: () => parseFavoriteFilter(route.query.kind),
|
||||
set: (value) => updateQuery({ kind: value === 'all' ? undefined : value, page: undefined }),
|
||||
})
|
||||
|
||||
const currentPage = computed({
|
||||
get: () => parsePage(route.query.page),
|
||||
set: (page: number) => updateQuery({ page: page > 1 ? String(page) : undefined }),
|
||||
})
|
||||
|
||||
const favoriteTypeOptions = computed(() => [
|
||||
{ id: 'all' as const, label: formatMessage(messages.allContentTypes) },
|
||||
{ id: 'mod' as const, label: formatMessage(messages.mods) },
|
||||
{ id: 'resourcepack' as const, label: formatMessage(messages.resourcepacks) },
|
||||
{ id: 'datapack' as const, label: formatMessage(messages.datapacks) },
|
||||
{ id: 'shader' as const, label: formatMessage(messages.shaders) },
|
||||
])
|
||||
|
||||
const currentFavoriteTypeLabel = computed(
|
||||
() => favoriteTypeOptions.value.find((option) => option.id === filter.value)?.label,
|
||||
)
|
||||
|
||||
const displayModeOptions = computed(() => [
|
||||
{ id: 'list' as const, label: formatMessage(messages.listView), icon: ListIcon },
|
||||
{ id: 'compact' as const, label: formatMessage(messages.compactView), icon: GenericListIcon },
|
||||
{ id: 'grid' as const, label: formatMessage(messages.gridView), icon: GridIcon },
|
||||
])
|
||||
|
||||
const currentDisplayMode = computed(() =>
|
||||
displayModeOptions.value.find((option) => option.id === displayMode.value),
|
||||
)
|
||||
|
||||
const projectTabs = computed(() => {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(route.query)) {
|
||||
if (key === 'kind' || key === 'q' || key === 'page') continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
if (entry != null) query.append(key, entry)
|
||||
}
|
||||
} else if (value) {
|
||||
query.set(key, value)
|
||||
}
|
||||
}
|
||||
const suffix = query.size ? `?${query.toString()}` : ''
|
||||
return createBrowseProjectTabs(
|
||||
{
|
||||
modpacks: formatMessage(messages.modpacks),
|
||||
mods: formatMessage(messages.mods),
|
||||
resourcepacks: formatMessage(messages.resourcepacks),
|
||||
datapacks: formatMessage(messages.datapacks),
|
||||
maps: formatMessage(messages.maps),
|
||||
shaders: formatMessage(messages.shaders),
|
||||
servers: formatMessage(messages.servers),
|
||||
favorites: formatMessage(messages.title),
|
||||
},
|
||||
suffix,
|
||||
getBrowseProjectTabOptions({
|
||||
instance: contentSelection.targetInstance.value,
|
||||
hasInstanceContext: !!route.query.i,
|
||||
isServerInstance:
|
||||
contentSelection.targetInstance.value?.link?.type === 'server_project' ||
|
||||
contentSelection.targetInstance.value?.link?.type === 'server_project_modpack',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function typeLabel(type: FavoriteContentType): string {
|
||||
return formatMessage(
|
||||
{
|
||||
mod: messages.mods,
|
||||
resourcepack: messages.resourcepacks,
|
||||
datapack: messages.datapacks,
|
||||
shader: messages.shaders,
|
||||
}[type],
|
||||
)
|
||||
}
|
||||
|
||||
function toUnavailable(favorite: ContentFavorite): FavoriteProject {
|
||||
const provider =
|
||||
favorite.provider === 'curseforge'
|
||||
? 'CurseForge'
|
||||
: favorite.provider === 'mcarchive'
|
||||
? 'MCArchive'
|
||||
: 'Modrinth'
|
||||
return {
|
||||
favorite,
|
||||
provider: favorite.provider,
|
||||
projectId: favorite.project_id,
|
||||
title: formatMessage(messages.unavailableTitle, {
|
||||
provider,
|
||||
projectId: favorite.project_id,
|
||||
}),
|
||||
description: formatMessage(messages.unavailableDescription),
|
||||
categories: [typeLabel(favorite.content_type)],
|
||||
unavailable: true,
|
||||
}
|
||||
}
|
||||
|
||||
function toModrinthFavorite(
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
favorite: ContentFavorite,
|
||||
): FavoriteProject {
|
||||
return {
|
||||
favorite,
|
||||
provider: 'modrinth',
|
||||
projectId: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
slug: project.slug,
|
||||
iconUrl: project.icon_url,
|
||||
downloads: project.downloads,
|
||||
categories: [typeLabel(favorite.content_type), ...project.categories],
|
||||
dateCreated: project.published,
|
||||
dateModified: project.updated,
|
||||
banner: project.gallery?.find((image) => image.featured)?.url,
|
||||
color: project.color,
|
||||
environment: {
|
||||
clientSide: project.client_side,
|
||||
serverSide: project.server_side,
|
||||
},
|
||||
unavailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
function toCurseForgeFavorite(
|
||||
project: CurseForgeProject,
|
||||
favorite: ContentFavorite,
|
||||
): FavoriteProject {
|
||||
return {
|
||||
favorite,
|
||||
provider: 'curseforge',
|
||||
projectId: project.id.toString(),
|
||||
title: project.name,
|
||||
description: project.summary,
|
||||
slug: project.slug,
|
||||
iconUrl: getCurseForgeImageUrl(project.logo?.thumbnailUrl),
|
||||
downloads: project.downloadCount,
|
||||
categories: [
|
||||
typeLabel(favorite.content_type),
|
||||
...project.categories.map((category) => category.slug),
|
||||
],
|
||||
dateCreated: project.dateCreated,
|
||||
dateModified: project.dateModified,
|
||||
banner: getCurseForgeImageUrl(project.screenshots[0]?.thumbnailUrl, 960),
|
||||
unavailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModrinthFavorites(projectIds: string[]) {
|
||||
try {
|
||||
return (await get_project_many(projectIds, 'bypass')) as Labrinth.Projects.v2.Project[]
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return await get_project_many(projectIds, 'cache_only').catch((cacheError) => {
|
||||
handleError(cacheError)
|
||||
return [] as Labrinth.Projects.v2.Project[]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurseForgeFavorites(projectIds: number[]) {
|
||||
try {
|
||||
return await getCurseForgeProjects(projectIds, 'bypass')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return await getCurseForgeProjects(projectIds, 'cache_only').catch((cacheError) => {
|
||||
handleError(cacheError)
|
||||
return [] as CurseForgeProject[]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProjects() {
|
||||
const requestId = ++projectRequestId
|
||||
loadingProjects.value = true
|
||||
try {
|
||||
await contentFavorites.load(true)
|
||||
const favorites = [...contentFavorites.favorites.value]
|
||||
if (favorites.length === 0) {
|
||||
if (requestId === projectRequestId) projects.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const modrinthIds = favorites
|
||||
.filter((favorite) => favorite.provider === 'modrinth')
|
||||
.map((favorite) => favorite.project_id)
|
||||
const curseForgeIds = favorites
|
||||
.filter((favorite) => favorite.provider === 'curseforge')
|
||||
.map((favorite) => Number(favorite.project_id))
|
||||
.filter(Number.isSafeInteger)
|
||||
const [modrinthResult, curseForgeResult] = await Promise.all([
|
||||
modrinthIds.length
|
||||
? loadModrinthFavorites(modrinthIds)
|
||||
: Promise.resolve([] as Labrinth.Projects.v2.Project[]),
|
||||
curseForgeIds.length
|
||||
? loadCurseForgeFavorites(curseForgeIds)
|
||||
: Promise.resolve([] as CurseForgeProject[]),
|
||||
])
|
||||
if (requestId !== projectRequestId) return
|
||||
|
||||
const modrinthById = new Map(modrinthResult.map((project) => [project.id, project]))
|
||||
const curseForgeById = new Map(
|
||||
curseForgeResult.map((project) => [project.id.toString(), project]),
|
||||
)
|
||||
projects.value = favorites.map((favorite) => {
|
||||
if (favorite.provider === 'modrinth') {
|
||||
const project = modrinthById.get(favorite.project_id)
|
||||
return project ? toModrinthFavorite(project, favorite) : toUnavailable(favorite)
|
||||
}
|
||||
if (favorite.provider === 'curseforge') {
|
||||
const project = curseForgeById.get(favorite.project_id)
|
||||
return project ? toCurseForgeFavorite(project, favorite) : toUnavailable(favorite)
|
||||
}
|
||||
return toUnavailable(favorite)
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
if (requestId === projectRequestId) loadingProjects.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const activeFavoriteKeys = computed(
|
||||
() =>
|
||||
new Set(
|
||||
contentFavorites.favorites.value.map((favorite) =>
|
||||
contentFavoriteKey(favorite.provider, favorite.project_id),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const availableProjects = computed(() =>
|
||||
projects.value
|
||||
.filter((project) =>
|
||||
activeFavoriteKeys.value.has(contentFavoriteKey(project.provider, project.projectId)),
|
||||
)
|
||||
.sort((left, right) => right.favorite.saved_at - left.favorite.saved_at),
|
||||
)
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
const normalizedSearch = search.value.trim().toLocaleLowerCase()
|
||||
return availableProjects.value.filter((project) => {
|
||||
if (filter.value !== 'all' && project.favorite.content_type !== filter.value) return false
|
||||
if (!normalizedSearch) return true
|
||||
return [project.title, project.description, project.provider, project.projectId]
|
||||
.join('\n')
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearch)
|
||||
})
|
||||
})
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(filteredProjects.value.length / PAGE_SIZE)))
|
||||
const pagedProjects = computed(() => {
|
||||
const page = Math.min(currentPage.value, pageCount.value)
|
||||
const offset = (page - 1) * PAGE_SIZE
|
||||
return filteredProjects.value.slice(offset, offset + PAGE_SIZE)
|
||||
})
|
||||
|
||||
watch(pageCount, (count) => {
|
||||
if (currentPage.value > count) currentPage.value = count
|
||||
})
|
||||
|
||||
const installContext = computed<BrowseInstallContext | null>(() => {
|
||||
const target = contentSelection.targetInstance.value
|
||||
if (!target) return null
|
||||
const icon = getDisplayInstanceIcon(target.icon_path, target.loader)
|
||||
const processing = ['validating', 'reviewing', 'queueing'].includes(contentSelection.state.value)
|
||||
return {
|
||||
showInstallHeader: !!route.query.i,
|
||||
name: target.name,
|
||||
loader: target.loader,
|
||||
gameVersion: target.game_version,
|
||||
iconSrc: icon.url,
|
||||
iconFrameless: icon.frameless,
|
||||
backUrl: route.query.i ? `/instance/${encodeURIComponent(target.id)}` : route.fullPath,
|
||||
backLabel: formatMessage(messages.backToInstance),
|
||||
heading: formatMessage(commonMessages.installingContentLabel),
|
||||
selectedProjects: contentSelection.selectedProjects.value,
|
||||
isInstallingSelected: processing,
|
||||
installProgress: contentSelection.progress.value,
|
||||
installButtonLabel: formatMessage(messages.installSelected, {
|
||||
count: contentSelection.selectedCount.value,
|
||||
}),
|
||||
processingLabel: formatMessage(messages.preparingSelected, {
|
||||
completed: contentSelection.progress.value.completed,
|
||||
total: contentSelection.progress.value.total,
|
||||
}),
|
||||
clearSelected: contentSelection.clear,
|
||||
installSelected: contentSelection.installSelected,
|
||||
}
|
||||
})
|
||||
|
||||
function isInstalling(project: FavoriteProject) {
|
||||
const key = makeContentSelectionKey(project.provider, project.projectId)
|
||||
return installingKeys.value.has(key) || contentSelection.isInstalling(key)
|
||||
}
|
||||
|
||||
function isSelected(project: FavoriteProject) {
|
||||
return contentSelection.isSelected(makeContentSelectionKey(project.provider, project.projectId))
|
||||
}
|
||||
|
||||
function installLabel(project: FavoriteProject) {
|
||||
if (isInstalling(project)) return formatMessage(commonMessages.validatingLabel)
|
||||
if (isSelected(project)) return formatMessage(messages.selected)
|
||||
return formatMessage(commonMessages.installButton)
|
||||
}
|
||||
|
||||
function setInstalling(key: string, installing: boolean) {
|
||||
const next = new Set(installingKeys.value)
|
||||
if (installing) next.add(key)
|
||||
else next.delete(key)
|
||||
installingKeys.value = next
|
||||
}
|
||||
|
||||
function getInstallPreferences(target: GameInstance, type: FavoriteContentType) {
|
||||
return getTargetInstallPreferences(
|
||||
{ gameVersion: target.game_version, loader: target.loader },
|
||||
type,
|
||||
)
|
||||
}
|
||||
|
||||
async function getModrinthVersions(projectId: string) {
|
||||
const project = (await get_project(projectId, 'must_revalidate')) as Labrinth.Projects.v2.Project
|
||||
return (await get_version_many(
|
||||
project.versions,
|
||||
'must_revalidate',
|
||||
)) as Labrinth.Versions.v2.Version[]
|
||||
}
|
||||
|
||||
async function toggleProjectSelection(project: FavoriteProject) {
|
||||
if (project.unavailable) return
|
||||
if (!contentSelection.targetInstance.value) {
|
||||
await contentSelection.refreshInstances(queryValue(route.query.i) || undefined)
|
||||
instanceSelector.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
const target = contentSelection.targetInstance.value
|
||||
const key = makeContentSelectionKey(project.provider, project.projectId)
|
||||
if (contentSelection.isSelected(key)) {
|
||||
contentSelection.remove(key)
|
||||
return
|
||||
}
|
||||
|
||||
setInstalling(key, true)
|
||||
try {
|
||||
const preferences = getInstallPreferences(target, project.favorite.content_type)
|
||||
const versionId =
|
||||
project.provider === 'modrinth'
|
||||
? getLatestMatchingInstallVersion(await getModrinthVersions(project.projectId), preferences)
|
||||
?.id
|
||||
: (
|
||||
await getCurseForgeFiles(Number(project.projectId), {
|
||||
gameVersion: target.game_version,
|
||||
modLoaderType:
|
||||
project.favorite.content_type === 'mod'
|
||||
? curseForgeLoaderTypes[target.loader]
|
||||
: undefined,
|
||||
})
|
||||
).files
|
||||
.find((file) => file.isAvailable)
|
||||
?.id.toString()
|
||||
if (!versionId) throw new Error(formatMessage(messages.noCompatibleVersion))
|
||||
|
||||
await contentSelection.add({
|
||||
key,
|
||||
provider: project.provider,
|
||||
projectId: project.projectId,
|
||||
providerProjectId: project.projectId,
|
||||
versionId,
|
||||
contentType: project.favorite.content_type,
|
||||
title: project.title,
|
||||
iconUrl: project.iconUrl,
|
||||
slug: project.slug,
|
||||
preferences,
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
setInstalling(key, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFavorite(project: FavoriteProject) {
|
||||
try {
|
||||
await contentFavorites.remove(project.provider, project.projectId)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTargetInstance(target: GameInstance) {
|
||||
contentSelection.setTarget(target)
|
||||
await contentSelection.refreshInstalledIdentities()
|
||||
await router.replace({ query: { ...route.query, i: target.id } })
|
||||
}
|
||||
|
||||
function setDisplayMode(mode: FavoriteDisplayMode) {
|
||||
displayMode.value = mode
|
||||
setLastBrowseContentDisplayMode(mode)
|
||||
}
|
||||
|
||||
function getProjectLink(project: FavoriteProject) {
|
||||
if (project.unavailable) return undefined
|
||||
return {
|
||||
path:
|
||||
project.provider === 'curseforge'
|
||||
? `/project/curseforge/${project.projectId}`
|
||||
: `/project/${project.slug ?? project.projectId}`,
|
||||
query: { ...route.query, b: route.fullPath },
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (isBrowseReturnSourcePath(to.path)) {
|
||||
const viewport = document.querySelector<HTMLElement>('.app-viewport')
|
||||
saveBrowseReturnSnapshot<FavoritesReturnState>({
|
||||
url: route.fullPath,
|
||||
scrollTop: viewport?.scrollTop ?? 0,
|
||||
state: { projects: projects.value },
|
||||
})
|
||||
}
|
||||
|
||||
breadcrumbs.setContext({
|
||||
name: formatMessage(messages.title),
|
||||
link: '/browse/favorites',
|
||||
query: route.query,
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() =>
|
||||
contentFavorites.favorites.value
|
||||
.map((favorite) => `${favorite.provider}:${favorite.project_id}`)
|
||||
.join('|'),
|
||||
() => void refreshProjects(),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
breadcrumbs.setName('FavoritesTitle', formatMessage(messages.title))
|
||||
await contentSelection.refreshInstances(queryValue(route.query.i) || undefined)
|
||||
if (browseReturnSnapshot) {
|
||||
void nextTick().then(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector<HTMLElement>('.app-viewport')?.scrollTo({
|
||||
top: browseReturnSnapshot.scrollTop,
|
||||
})
|
||||
completeBrowseReturnNavigation(route.fullPath)
|
||||
resolve()
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
await refreshProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-onboarding-id="browse-favorites-content" class="flex flex-col gap-3 p-6">
|
||||
<div
|
||||
v-if="installContext?.showInstallHeader"
|
||||
ref="stickyInstallHeaderRef"
|
||||
class="browse-install-header sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
||||
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||
>
|
||||
<BrowseInstallHeader :install-context="installContext" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-12 items-center justify-between">
|
||||
<NavTabs :links="projectTabs" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
wrapper-class="flex-1"
|
||||
input-class="h-12"
|
||||
/>
|
||||
<ButtonStyled size="standard" type="standard">
|
||||
<button class="flex min-w-0 items-center gap-2" @click="instanceSelector?.show()">
|
||||
<InstanceIcon
|
||||
v-if="contentSelection.targetInstance.value"
|
||||
class="shrink-0"
|
||||
size="1.25rem"
|
||||
:icon-path="contentSelection.targetInstance.value.icon_path"
|
||||
:instance-id="contentSelection.targetInstance.value.id"
|
||||
:loader="contentSelection.targetInstance.value.loader"
|
||||
/>
|
||||
<PlusIcon v-else class="size-5 shrink-0" />
|
||||
<span class="max-w-40 truncate font-medium">
|
||||
{{
|
||||
contentSelection.targetInstance.value?.name ?? formatMessage(messages.chooseInstance)
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex size-4 shrink-0 items-center justify-center text-secondary"
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<PopoutMenu placement="bottom-end">
|
||||
<ButtonStyled size="standard" type="standard">
|
||||
<button class="flex items-center gap-2">
|
||||
<BookmarkIcon class="size-5" />
|
||||
<span>{{ currentFavoriteTypeLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #menu>
|
||||
<div class="flex w-48 flex-col gap-1 p-1">
|
||||
<ButtonStyled
|
||||
v-for="option in favoriteTypeOptions"
|
||||
:key="option.id"
|
||||
:type="filter === option.id ? 'filled' : 'transparent'"
|
||||
>
|
||||
<button
|
||||
class="flex w-full !justify-start text-left"
|
||||
:aria-pressed="filter === option.id"
|
||||
@click="filter = option.id"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-secondary">{{
|
||||
formatMessage(messages.recentlySaved)
|
||||
}}</span>
|
||||
<PopoutMenu :tooltip="formatMessage(messages.view)" placement="bottom-end" class="ml-auto">
|
||||
<ButtonStyled circular>
|
||||
<button :aria-label="formatMessage(messages.view)">
|
||||
<component :is="currentDisplayMode?.icon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #menu>
|
||||
<div class="flex w-44 flex-col gap-1 p-1">
|
||||
<ButtonStyled
|
||||
v-for="option in displayModeOptions"
|
||||
:key="option.id"
|
||||
:type="displayMode === option.id ? 'filled' : 'transparent'"
|
||||
>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 !justify-start text-left"
|
||||
:aria-pressed="displayMode === option.id"
|
||||
@click="setDisplayMode(option.id)"
|
||||
>
|
||||
<component :is="option.icon" class="size-4" />
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="currentPage = $event" />
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="loadingProjects && availableProjects.length === 0"
|
||||
class="flex min-h-64 items-center justify-center"
|
||||
>
|
||||
<LoadingIndicator />
|
||||
</section>
|
||||
<EmptyState
|
||||
v-else-if="contentFavorites.loaded.value && contentFavorites.favorites.value.length === 0"
|
||||
type="empty-inbox"
|
||||
:heading="formatMessage(messages.emptyTitle)"
|
||||
:description="formatMessage(messages.emptyDescription)"
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="filteredProjects.length === 0"
|
||||
type="no-search-result"
|
||||
:heading="formatMessage(messages.noMatchesTitle)"
|
||||
:description="formatMessage(messages.noMatchesDescription)"
|
||||
/>
|
||||
<ProjectCardList v-else :layout="displayMode">
|
||||
<ProjectCard
|
||||
v-for="project in pagedProjects"
|
||||
:key="`${project.provider}:${project.projectId}`"
|
||||
:layout="displayMode"
|
||||
:link="getProjectLink(project)"
|
||||
:title="project.title"
|
||||
:summary="project.description"
|
||||
:icon-url="project.iconUrl"
|
||||
:downloads="project.downloads"
|
||||
:tags="project.categories"
|
||||
:date-published="project.dateCreated"
|
||||
:date-updated="project.dateModified"
|
||||
:banner="project.banner"
|
||||
:color="project.color"
|
||||
:environment="project.environment"
|
||||
:provider="project.provider"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="!project.unavailable"
|
||||
color="brand"
|
||||
type="outlined"
|
||||
:size="displayMode === 'compact' ? 'small' : 'standard'"
|
||||
>
|
||||
<button
|
||||
:disabled="isInstalling(project)"
|
||||
@click.stop="toggleProjectSelection(project)"
|
||||
>
|
||||
<SpinnerIcon v-if="isInstalling(project)" class="animate-spin" />
|
||||
<CheckIcon v-else-if="isSelected(project)" />
|
||||
<PlusIcon v-else />
|
||||
{{ installLabel(project) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
circular
|
||||
color="brand"
|
||||
type="transparent"
|
||||
:size="displayMode === 'compact' ? 'small' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.remove)"
|
||||
:disabled="contentFavorites.isPending(project.provider, project.projectId)"
|
||||
:aria-label="formatMessage(messages.remove)"
|
||||
@click.stop="removeFavorite(project)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="contentFavorites.isPending(project.provider, project.projectId)"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<BookmarkFilledIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</ProjectCardList>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="currentPage = $event" />
|
||||
</div>
|
||||
|
||||
<SelectedProjectsFloatingBar :install-context="installContext" />
|
||||
<BrowseInstanceSelector
|
||||
ref="instanceSelector"
|
||||
:instances="contentSelection.instances.value"
|
||||
:selected-instance="contentSelection.targetInstance.value"
|
||||
:selected-count="contentSelection.selectedCount.value"
|
||||
:install-current="contentSelection.installSelected"
|
||||
:clear-current="contentSelection.clear"
|
||||
@select="selectTargetInstance"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
533
apps/app-frontend/src/pages/Index.vue
Normal file
533
apps/app-frontend/src/pages/Index.vue
Normal file
@ -0,0 +1,533 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
GridIcon,
|
||||
LayoutTemplateIcon,
|
||||
MinimizeIcon,
|
||||
MoveIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPageContext,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
createDefaultHomeDashboard,
|
||||
createHomeDashboardSaveQueue,
|
||||
type HomeDashboardConfig,
|
||||
normalizeHomeDashboard,
|
||||
} from '@/components/home/home-dashboard'
|
||||
import { getActivePlayerName } from '@/components/home/home-utils'
|
||||
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
|
||||
import HomeDashboard from '@/components/home/HomeDashboard.vue'
|
||||
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
|
||||
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
|
||||
import HomeMinimal from '@/components/home/HomeMinimal.vue'
|
||||
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { get_default_user, users } from '@/helpers/auth'
|
||||
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { FeatureFlag, HomeLayout } from '@/store/theme'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { offline } = useNetworkStatus()
|
||||
const themeStore = useTheming()
|
||||
const pageContext = injectPageContext()
|
||||
|
||||
const messages = defineMessages({
|
||||
home: { id: 'app.home.breadcrumb', defaultMessage: 'Home' },
|
||||
switchToMinimal: {
|
||||
id: 'app.home.layout.switch-to-minimal',
|
||||
defaultMessage: 'Switch to Minimal Home',
|
||||
},
|
||||
switchToInformation: {
|
||||
id: 'app.home.layout.switch-to-information',
|
||||
defaultMessage: 'Switch to Information Home',
|
||||
},
|
||||
homeLayoutToggle: {
|
||||
id: 'app.home.layout.toggle',
|
||||
defaultMessage: 'Minimal Home',
|
||||
},
|
||||
switchToGridWidgetLayout: {
|
||||
id: 'app.home.widgets.layout.switch-to-grid',
|
||||
defaultMessage: 'Switch to grid widget layout',
|
||||
},
|
||||
switchToFreeWidgetLayout: {
|
||||
id: 'app.home.widgets.layout.switch-to-free',
|
||||
defaultMessage: 'Switch to free widget layout',
|
||||
},
|
||||
widgetLayoutToggle: {
|
||||
id: 'app.home.widgets.layout.toggle',
|
||||
defaultMessage: 'Widget layout mode',
|
||||
},
|
||||
resetWidgets: {
|
||||
id: 'app.home.widgets.reset-confirm',
|
||||
defaultMessage: 'Restore the default widget layout?',
|
||||
},
|
||||
customizeWidgets: {
|
||||
id: 'app.home.widgets.customize',
|
||||
defaultMessage: 'Customize widgets',
|
||||
},
|
||||
doneEditing: { id: 'app.home.widgets.done', defaultMessage: 'Finish editing' },
|
||||
addWidget: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
|
||||
resetWidgetLayout: {
|
||||
id: 'app.home.widgets.reset',
|
||||
defaultMessage: 'Restore default widgets',
|
||||
},
|
||||
})
|
||||
|
||||
const recentProjectsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
||||
|
||||
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
|
||||
|
||||
const instances = ref<GameInstance[]>([])
|
||||
const playerName = ref<string | null>(null)
|
||||
const dashboardConfig = ref<HomeDashboardConfig | null>(null)
|
||||
const dashboard = ref<InstanceType<typeof HomeDashboard>>()
|
||||
const dashboardEditing = ref(false)
|
||||
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
|
||||
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
|
||||
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
|
||||
const switchingLayout = ref(false)
|
||||
const dashboardSaveQueue = createHomeDashboardSaveQueue(
|
||||
async (config) => {
|
||||
const settings = await getSettings()
|
||||
settings.home_widgets = config
|
||||
await setSettings(settings)
|
||||
},
|
||||
(config) => {
|
||||
dashboardConfig.value = config
|
||||
},
|
||||
handleError,
|
||||
)
|
||||
const floatingControlsStyle = computed(() => ({
|
||||
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
|
||||
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
|
||||
}))
|
||||
|
||||
const animateSidebarShow = ref(false)
|
||||
setTimeout(() => {
|
||||
animateSidebarShow.value = true
|
||||
}, 200)
|
||||
|
||||
async function clearMissingMinimalInstance() {
|
||||
const selectedId = themeStore.minimalHomeInstanceId
|
||||
if (!selectedId || instances.value.some((instance) => instance.id === selectedId)) return
|
||||
|
||||
themeStore.minimalHomeInstanceId = null
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
if (settings.minimal_home_instance_id === null) return
|
||||
settings.minimal_home_instance_id = null
|
||||
await setSettings(settings)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInstances() {
|
||||
try {
|
||||
instances.value = await list()
|
||||
await clearMissingMinimalInstance()
|
||||
return true
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlayerName() {
|
||||
const selectedUser = await get_default_user(offline.value).catch(() => undefined)
|
||||
if (!selectedUser) return
|
||||
|
||||
const accounts = await users(offline.value).catch(() => [])
|
||||
playerName.value = getActivePlayerName(selectedUser, accounts)
|
||||
}
|
||||
|
||||
async function loadDashboardConfig() {
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
const normalized = normalizeHomeDashboard(settings.home_widgets)
|
||||
if (normalized) {
|
||||
dashboardConfig.value = normalized
|
||||
return
|
||||
}
|
||||
|
||||
const config = createDefaultHomeDashboard(themeStore.getFeatureFlag(recentProjectsInHomeFlag))
|
||||
dashboardConfig.value = config
|
||||
settings.home_widgets = config
|
||||
await setSettings(settings)
|
||||
} catch (error) {
|
||||
dashboardConfig.value = createDefaultHomeDashboard(
|
||||
themeStore.getFeatureFlag(recentProjectsInHomeFlag),
|
||||
)
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboardConfig(config: HomeDashboardConfig) {
|
||||
const previous = dashboardConfig.value ?? config
|
||||
dashboardConfig.value = config
|
||||
void dashboardSaveQueue.enqueue(config, previous)
|
||||
}
|
||||
|
||||
function resetDashboardConfig() {
|
||||
if (!window.confirm(formatMessage(messages.resetWidgets))) return
|
||||
updateDashboardConfig(createDefaultHomeDashboard())
|
||||
}
|
||||
|
||||
async function selectMinimalInstance(instance: GameInstance) {
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
settings.minimal_home_instance_id = instance.id
|
||||
await setSettings(settings)
|
||||
themeStore.minimalHomeInstanceId = instance.id
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function createInstance() {
|
||||
void router.push('/create')
|
||||
}
|
||||
|
||||
async function toggleHomeLayout() {
|
||||
if (switchingLayout.value) return
|
||||
|
||||
const previousLayout = themeStore.homeLayout
|
||||
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
|
||||
const previousEditing = dashboardEditing.value
|
||||
switchingLayout.value = true
|
||||
themeStore.homeLayout = nextLayout
|
||||
if (nextLayout === 'minimal') dashboardEditing.value = false
|
||||
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
settings.home_layout = nextLayout
|
||||
await setSettings(settings)
|
||||
} catch (error) {
|
||||
themeStore.homeLayout = previousLayout
|
||||
dashboardEditing.value = previousEditing
|
||||
handleError(error)
|
||||
} finally {
|
||||
switchingLayout.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDashboardEditing() {
|
||||
dashboardEditing.value = !dashboardEditing.value
|
||||
}
|
||||
|
||||
function toggleWidgetLayout() {
|
||||
dashboard.value?.setLayout(isFreeWidgetLayout.value ? 'grid' : 'free')
|
||||
}
|
||||
|
||||
function openWidgetPicker() {
|
||||
dashboard.value?.openWidgetPicker()
|
||||
}
|
||||
|
||||
const instancesLoaded = await fetchInstances()
|
||||
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
|
||||
await loadDashboardConfig()
|
||||
|
||||
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
|
||||
|
||||
const unlistenInstance = await instance_listener(async () => {
|
||||
await fetchInstances()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenInstance()
|
||||
window.removeEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomeInstancePickerModal
|
||||
ref="instancePicker"
|
||||
:instances="instances"
|
||||
:selected-instance-id="themeStore.minimalHomeInstanceId"
|
||||
@select="selectMinimalInstance"
|
||||
/>
|
||||
<div class="min-h-full">
|
||||
<HomeDashboard
|
||||
v-if="!isMinimal && dashboardConfig"
|
||||
ref="dashboard"
|
||||
:config="dashboardConfig"
|
||||
:instances="instances"
|
||||
:player-name="playerName"
|
||||
:editing="dashboardEditing"
|
||||
@change="updateDashboardConfig"
|
||||
/>
|
||||
|
||||
<HomeMinimal
|
||||
v-else
|
||||
:instances="instances"
|
||||
:player-name="playerName"
|
||||
:selected-instance-id="themeStore.minimalHomeInstanceId"
|
||||
@choose="instancePicker?.show()"
|
||||
@create="createInstance"
|
||||
/>
|
||||
</div>
|
||||
<div class="home-floating-controls" :style="floatingControlsStyle">
|
||||
<template v-if="!isMinimal">
|
||||
<button
|
||||
v-if="dashboardEditing"
|
||||
v-tooltip="formatMessage(messages.addWidget)"
|
||||
type="button"
|
||||
class="home-floating-action"
|
||||
:aria-label="formatMessage(messages.addWidget)"
|
||||
@click="openWidgetPicker"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
<button
|
||||
v-if="dashboardEditing"
|
||||
v-tooltip="formatMessage(messages.resetWidgetLayout)"
|
||||
type="button"
|
||||
class="home-floating-action"
|
||||
:aria-label="formatMessage(messages.resetWidgetLayout)"
|
||||
@click="resetDashboardConfig"
|
||||
>
|
||||
<RotateCounterClockwiseIcon />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="
|
||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
||||
"
|
||||
data-onboarding-id="home-widget-customize"
|
||||
type="button"
|
||||
class="home-floating-action"
|
||||
:class="{ 'is-active': dashboardEditing }"
|
||||
:aria-label="
|
||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
||||
"
|
||||
:aria-pressed="dashboardEditing"
|
||||
@click="toggleDashboardEditing"
|
||||
>
|
||||
<CheckIcon v-if="dashboardEditing" />
|
||||
<PencilIcon v-else />
|
||||
</button>
|
||||
<button
|
||||
v-if="dashboardEditing"
|
||||
v-tooltip="
|
||||
formatMessage(
|
||||
isFreeWidgetLayout
|
||||
? messages.switchToGridWidgetLayout
|
||||
: messages.switchToFreeWidgetLayout,
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
role="switch"
|
||||
class="home-layout-switch home-widget-layout-switch"
|
||||
:class="{ 'is-free': isFreeWidgetLayout }"
|
||||
:aria-checked="isFreeWidgetLayout"
|
||||
:aria-label="formatMessage(messages.widgetLayoutToggle)"
|
||||
@click="toggleWidgetLayout"
|
||||
>
|
||||
<span class="home-layout-switch-option home-widget-layout-grid" aria-hidden="true">
|
||||
<GridIcon />
|
||||
</span>
|
||||
<span class="home-layout-switch-thumb" aria-hidden="true" />
|
||||
<span class="home-layout-switch-option home-widget-layout-free" aria-hidden="true">
|
||||
<MoveIcon />
|
||||
</span>
|
||||
</button>
|
||||
<span class="home-floating-divider" aria-hidden="true" />
|
||||
</template>
|
||||
<button
|
||||
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
|
||||
data-onboarding-id="home-layout-switch"
|
||||
type="button"
|
||||
role="switch"
|
||||
class="home-layout-switch"
|
||||
:class="{ 'is-minimal': isMinimal }"
|
||||
:disabled="switchingLayout"
|
||||
:aria-checked="isMinimal"
|
||||
:aria-label="formatMessage(messages.homeLayoutToggle)"
|
||||
@click="toggleHomeLayout"
|
||||
>
|
||||
<span class="home-layout-switch-option home-layout-switch-information" aria-hidden="true">
|
||||
<LayoutTemplateIcon />
|
||||
</span>
|
||||
<span class="home-layout-switch-thumb" aria-hidden="true" />
|
||||
<span class="home-layout-switch-option home-layout-switch-minimal" aria-hidden="true">
|
||||
<MinimizeIcon />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<Teleport v-if="!isMinimal" to="#sidebar-default-teleport-target">
|
||||
<div
|
||||
class="flex min-w-0 flex-col slide-enter-active"
|
||||
:class="{ 'slide-enter-from': !animateSidebarShow }"
|
||||
>
|
||||
<HomePlayInsights />
|
||||
<HomeDailyChallenge />
|
||||
<HomeMinecraftNews />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-floating-controls {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
height: 2.5rem;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-raised-bg);
|
||||
box-shadow:
|
||||
var(--shadow-button),
|
||||
0 0.25rem 0.75rem rgb(0 0 0 / 20%);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.home-floating-action {
|
||||
display: flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--color-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
filter 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.home-floating-action:hover {
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.home-floating-action:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.home-floating-action:focus-visible,
|
||||
.home-layout-switch:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 4px var(--color-brand-shadow);
|
||||
}
|
||||
|
||||
.home-floating-action.is-active {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.home-floating-action :deep(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.home-floating-divider {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
margin: 0 0.125rem;
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
.home-layout-switch {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 2rem);
|
||||
align-items: center;
|
||||
/* width: 4.25rem; */ /* closes #210 */
|
||||
height: 2rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-button-bg);
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
transition:
|
||||
filter 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.home-layout-switch:hover:not(:disabled) {
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
.home-layout-switch:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.home-layout-switch:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.home-layout-switch-thumb {
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
left: 0.125rem;
|
||||
z-index: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-brand);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.home-layout-switch.is-minimal .home-layout-switch-thumb {
|
||||
transform: translateX(2rem);
|
||||
}
|
||||
|
||||
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
|
||||
transform: translateX(2rem);
|
||||
}
|
||||
|
||||
.home-layout-switch-option {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 2rem;
|
||||
height: 1.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-secondary);
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.home-layout-switch-option :deep(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
|
||||
.home-layout-switch.is-minimal .home-layout-switch-minimal,
|
||||
.home-widget-layout-switch:not(.is-free) .home-widget-layout-grid,
|
||||
.home-widget-layout-switch.is-free .home-widget-layout-free {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
</style>
|
||||
334
apps/app-frontend/src/pages/Lab.vue
Normal file
334
apps/app-frontend/src/pages/Lab.vue
Normal file
@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, SearchIcon, StarIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Card,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
EmptyState,
|
||||
NewButton,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
import gradientTextToolCover from '@/assets/lab/gradient-text-tool-cover.png'
|
||||
import modTranslationCover from '@/assets/lab/mod-translation-cover.png'
|
||||
import recipeGeneratorToolCover from '@/assets/lab/recipe-generator-tool-cover.png'
|
||||
import schematicPreviewToolCover from '@/assets/lab/schematic-preview-cover.png'
|
||||
import seedMapToolCover from '@/assets/lab/seed-map-tool-cover.png'
|
||||
import skinEditorToolCover from '@/assets/lab/skin-editor-tool-cover.png'
|
||||
import {
|
||||
getLabCategoryFilter,
|
||||
getLabFavoriteFilter,
|
||||
getLabFavoriteToolIds,
|
||||
type LabCategoryFilter,
|
||||
type LabFavoriteFilter,
|
||||
setLabCategoryFilter,
|
||||
setLabFavoriteFilter,
|
||||
setLabFavoriteToolIds,
|
||||
} from '@/helpers/lab-preferences'
|
||||
import { labTools } from '@/lab/registry'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const search = ref('')
|
||||
const category = ref<LabCategoryFilter>(getLabCategoryFilter())
|
||||
const favoriteFilter = ref<LabFavoriteFilter>(getLabFavoriteFilter())
|
||||
const favoriteToolIds = ref<string[]>(getLabFavoriteToolIds())
|
||||
const toolCoverImages: Record<string, string> = {
|
||||
'gradient-text': gradientTextToolCover,
|
||||
'recipe-generator': recipeGeneratorToolCover,
|
||||
'schematic-preview': schematicPreviewToolCover,
|
||||
'seed-map': seedMapToolCover,
|
||||
'mod-translation': modTranslationCover,
|
||||
'skin-editor': skinEditorToolCover,
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.lab.title', defaultMessage: 'Lab' },
|
||||
toolCount: { id: 'app.lab.tool-count', defaultMessage: '{count} tool' },
|
||||
toolCountPlural: { id: 'app.lab.tool-count-plural', defaultMessage: '{count} tools' },
|
||||
search: { id: 'app.lab.search', defaultMessage: 'Search tools' },
|
||||
allTools: { id: 'app.lab.category.all', defaultMessage: 'All tools' },
|
||||
creation: { id: 'app.lab.category.creation', defaultMessage: 'Creation' },
|
||||
maintenance: { id: 'app.lab.category.maintenance', defaultMessage: 'Maintenance' },
|
||||
world: { id: 'app.lab.category.world', defaultMessage: 'World' },
|
||||
enter: { id: 'app.lab.enter', defaultMessage: 'Enter' },
|
||||
favoriteFilterAll: { id: 'app.lab.favorite-filter.all', defaultMessage: 'All' },
|
||||
favoriteFilterFavorite: {
|
||||
id: 'app.lab.favorite-filter.favorite',
|
||||
defaultMessage: 'Favorited',
|
||||
},
|
||||
favoriteFilterUnfavorite: {
|
||||
id: 'app.lab.favorite-filter.unfavorite',
|
||||
defaultMessage: 'Not favorited',
|
||||
},
|
||||
favoriteAdd: { id: 'app.lab.favorite.add', defaultMessage: 'Add to favorites' },
|
||||
favoriteRemove: { id: 'app.lab.favorite.remove', defaultMessage: 'Remove from favorites' },
|
||||
noResults: { id: 'app.lab.no-results', defaultMessage: 'No tools match your search.' },
|
||||
noFavorites: {
|
||||
id: 'app.lab.no-favorites',
|
||||
defaultMessage: 'You have not favorited any tools yet.',
|
||||
},
|
||||
gradientTextTitle: {
|
||||
id: 'app.lab.gradient-text.title',
|
||||
defaultMessage: 'Gradient text generator',
|
||||
},
|
||||
gradientTextDescription: {
|
||||
id: 'app.lab.gradient-text.description',
|
||||
defaultMessage: 'Create Minecraft-ready gradient text without a browser.',
|
||||
},
|
||||
recipeGeneratorTitle: {
|
||||
id: 'app.lab.recipe-generator.title',
|
||||
defaultMessage: 'Recipe generator',
|
||||
},
|
||||
recipeGeneratorDescription: {
|
||||
id: 'app.lab.recipe-generator.description',
|
||||
defaultMessage: 'Create Minecraft Java data pack recipes from local item and tag data.',
|
||||
},
|
||||
seedMapTitle: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
|
||||
seedMapDescription: {
|
||||
id: 'app.lab.seed-map.description',
|
||||
defaultMessage: 'Explore a Minecraft seed locally with biomes, structures, and saved markers.',
|
||||
},
|
||||
schematicPreviewTitle: {
|
||||
id: 'app.lab.schematic-preview.title',
|
||||
defaultMessage: 'Schematic workshop',
|
||||
},
|
||||
schematicPreviewDescription: {
|
||||
id: 'app.lab.schematic-preview.description',
|
||||
defaultMessage: 'Quickly preview and edit your schematics.',
|
||||
},
|
||||
modTranslationTitle: {
|
||||
id: 'app.lab.mod-translation.title',
|
||||
defaultMessage: 'Mod translation',
|
||||
},
|
||||
modTranslationDescription: {
|
||||
id: 'app.lab.mod-translation.description',
|
||||
defaultMessage: 'Translate any Minecraft mod JAR into Simplified Chinese.',
|
||||
},
|
||||
skinEditorTitle: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
|
||||
skinEditorDescription: {
|
||||
id: 'app.lab.skin-editor.description',
|
||||
defaultMessage: 'Create and edit Minecraft player skins locally.',
|
||||
},
|
||||
})
|
||||
|
||||
const categoryOptions: LabCategoryFilter[] = ['all', 'creation', 'maintenance', 'world']
|
||||
const favoriteFilterOptions: LabFavoriteFilter[] = ['all', 'favorite', 'unfavorite']
|
||||
|
||||
watch(category, (value) => setLabCategoryFilter(value))
|
||||
watch(favoriteFilter, (value) => setLabFavoriteFilter(value))
|
||||
watch(favoriteToolIds, (ids) => setLabFavoriteToolIds(ids))
|
||||
|
||||
function isFavorite(toolId: string) {
|
||||
return favoriteToolIds.value.includes(toolId)
|
||||
}
|
||||
|
||||
function toggleFavorite(toolId: string) {
|
||||
favoriteToolIds.value = isFavorite(toolId)
|
||||
? favoriteToolIds.value.filter((id) => id !== toolId)
|
||||
: [...favoriteToolIds.value, toolId]
|
||||
}
|
||||
|
||||
const visibleTools = computed(() => {
|
||||
const normalizedSearch = search.value.trim().toLocaleLowerCase()
|
||||
const favoriteSet = new Set(favoriteToolIds.value)
|
||||
|
||||
const filtered = labTools.filter((tool) => {
|
||||
const matchingCategory = category.value === 'all' || tool.category === category.value
|
||||
const matchingFavorite =
|
||||
favoriteFilter.value === 'all' ||
|
||||
(favoriteFilter.value === 'favorite' && favoriteSet.has(tool.id)) ||
|
||||
(favoriteFilter.value === 'unfavorite' && !favoriteSet.has(tool.id))
|
||||
const matchingSearch =
|
||||
!normalizedSearch ||
|
||||
[toolTitle(tool.id, tool.title), toolDescription(tool.id, tool.description)]
|
||||
.join(' ')
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearch)
|
||||
|
||||
return matchingCategory && matchingFavorite && matchingSearch
|
||||
})
|
||||
|
||||
const favorited = filtered.filter((tool) => favoriteSet.has(tool.id))
|
||||
const unfavorited = filtered.filter((tool) => !favoriteSet.has(tool.id))
|
||||
return [...favorited, ...unfavorited]
|
||||
})
|
||||
|
||||
const emptyHeading = computed(() => {
|
||||
if (favoriteFilter.value === 'favorite' && favoriteToolIds.value.length === 0) {
|
||||
return formatMessage(messages.noFavorites)
|
||||
}
|
||||
return formatMessage(messages.noResults)
|
||||
})
|
||||
|
||||
function toolTitle(toolId: string, fallback: string) {
|
||||
if (toolId === 'skin-editor') return formatMessage(messages.skinEditorTitle)
|
||||
if (toolId === 'gradient-text') return formatMessage(messages.gradientTextTitle)
|
||||
if (toolId === 'recipe-generator') return formatMessage(messages.recipeGeneratorTitle)
|
||||
if (toolId === 'seed-map') return formatMessage(messages.seedMapTitle)
|
||||
if (toolId === 'schematic-preview') return formatMessage(messages.schematicPreviewTitle)
|
||||
if (toolId === 'mod-translation') return formatMessage(messages.modTranslationTitle)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function toolDescription(toolId: string, fallback: string) {
|
||||
if (toolId === 'skin-editor') return formatMessage(messages.skinEditorDescription)
|
||||
if (toolId === 'gradient-text') return formatMessage(messages.gradientTextDescription)
|
||||
if (toolId === 'recipe-generator') return formatMessage(messages.recipeGeneratorDescription)
|
||||
if (toolId === 'seed-map') return formatMessage(messages.seedMapDescription)
|
||||
if (toolId === 'schematic-preview') return formatMessage(messages.schematicPreviewDescription)
|
||||
if (toolId === 'mod-translation') return formatMessage(messages.modTranslationDescription)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function toolOnboardingId(toolId: string) {
|
||||
return ['gradient-text', 'recipe-generator', 'seed-map', 'schematic-preview'].includes(toolId)
|
||||
? `lab-${toolId}-card`
|
||||
: undefined
|
||||
}
|
||||
|
||||
function toolIconClasses(toolId: string) {
|
||||
if (toolId === 'seed-map') return 'bg-highlight-green text-brand'
|
||||
return 'bg-brand-highlight text-brand'
|
||||
}
|
||||
|
||||
function categoryLabel(value: LabCategoryFilter) {
|
||||
if (value === 'all') return formatMessage(messages.allTools)
|
||||
return formatMessage(messages[value])
|
||||
}
|
||||
|
||||
function favoriteFilterLabel(value: LabFavoriteFilter) {
|
||||
if (value === 'all') return formatMessage(messages.favoriteFilterAll)
|
||||
if (value === 'favorite') return formatMessage(messages.favoriteFilterFavorite)
|
||||
return formatMessage(messages.favoriteFilterUnfavorite)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex w-full flex-col gap-6 p-6">
|
||||
<header class="flex min-w-0 items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
|
||||
<p class="m-0 mt-1 text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(labTools.length === 1 ? messages.toolCount : messages.toolCountPlural, {
|
||||
count: labTools.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap gap-2" aria-label="Lab tool filters">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
clearable
|
||||
wrapper-class="min-w-[14rem] flex-1"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-model="category"
|
||||
:options="categoryOptions"
|
||||
:display-name="categoryLabel"
|
||||
name="Lab category"
|
||||
class="w-48 max-[576px]:w-full"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-model="favoriteFilter"
|
||||
:options="favoriteFilterOptions"
|
||||
:display-name="favoriteFilterLabel"
|
||||
name="Lab favorite filter"
|
||||
class="w-48 max-[576px]:w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="visibleTools.length"
|
||||
aria-label="Lab tools"
|
||||
data-onboarding-id="lab-tools"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<Card
|
||||
v-for="tool in visibleTools"
|
||||
:key="tool.id"
|
||||
class="!m-0 relative flex items-end gap-4 !p-4 transition-[border-color,filter] duration-200 hover:border-surface-5 hover:brightness-[1.05]"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-4 z-20 flex size-8 items-center justify-center rounded-md text-secondary transition-colors hover:text-brand focus-visible:outline-none"
|
||||
:aria-label="
|
||||
isFavorite(tool.id)
|
||||
? formatMessage(messages.favoriteRemove)
|
||||
: formatMessage(messages.favoriteAdd)
|
||||
"
|
||||
:aria-pressed="isFavorite(tool.id)"
|
||||
@click="toggleFavorite(tool.id)"
|
||||
>
|
||||
<StarIcon
|
||||
class="size-5"
|
||||
:class="isFavorite(tool.id) ? 'fill-brand text-brand' : 'text-secondary'"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<RouterLink
|
||||
:to="tool.route"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
class="shrink-0 rounded-[var(--radius-lg)] focus-visible:outline-none"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-[2/1] w-56 overflow-hidden rounded-[var(--radius-lg)] bg-surface-2 max-[576px]:w-36"
|
||||
>
|
||||
<img
|
||||
v-if="toolCoverImages[tool.id]"
|
||||
:src="toolCoverImages[tool.id]"
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center"
|
||||
:class="toolIconClasses(tool.id)"
|
||||
>
|
||||
<component :is="tool.icon" class="size-8" />
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<RouterLink
|
||||
:to="tool.route"
|
||||
:data-onboarding-id="toolOnboardingId(tool.id)"
|
||||
class="min-w-0 rounded-[var(--radius-lg)] text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
>
|
||||
<h2 class="m-0 line-clamp-1 pr-10 text-lg font-bold leading-tight text-contrast">
|
||||
{{ toolTitle(tool.id, tool.title) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 line-clamp-2 text-sm leading-5 text-secondary">
|
||||
{{ toolDescription(tool.id, tool.description) }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
|
||||
<TagItem>{{ categoryLabel(tool.category) }}</TagItem>
|
||||
<NewButton
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="sm"
|
||||
class="min-w-20 justify-between px-3"
|
||||
@click="router.push(tool.route)"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
{{ formatMessage(messages.enter) }}
|
||||
</NewButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<EmptyState v-else type="no-search-result" :heading="emptyHeading" aria-live="polite" />
|
||||
</main>
|
||||
</template>
|
||||
1257
apps/app-frontend/src/pages/LabGradientText.vue
Normal file
1257
apps/app-frontend/src/pages/LabGradientText.vue
Normal file
File diff suppressed because it is too large
Load Diff
472
apps/app-frontend/src/pages/LabModTranslation.vue
Normal file
472
apps/app-frontend/src/pages/LabModTranslation.vue
Normal file
@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import { FileArchiveIcon, PlayIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ModTranslationFilePicker from '@/components/lab/mod-translation/ModTranslationFilePicker.vue'
|
||||
import ModTranslationJobList from '@/components/lab/mod-translation/ModTranslationJobList.vue'
|
||||
import ModTranslationSettingsPanel from '@/components/lab/mod-translation/ModTranslationSettingsPanel.vue'
|
||||
import { analyzeMod } from '@/lab/mod-translation/backend'
|
||||
import { modTranslationMessages as messages } from '@/lab/mod-translation/i18n'
|
||||
import type { ModTranslationJob } from '@/lab/mod-translation/types.ts'
|
||||
import { useModTranslationStore } from '@/store/modTranslation'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const store = useModTranslationStore()
|
||||
const { inputPath, analysis, providerId, modelId, options, jobs } = storeToRefs(store)
|
||||
const analyzing = ref(false)
|
||||
const starting = ref(false)
|
||||
|
||||
const analyzeStartedAt = ref<number | null>(null)
|
||||
const analyzeNow = ref(Date.now())
|
||||
let analyzeTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
watch(analyzing, (value) => {
|
||||
if (value) {
|
||||
analyzeStartedAt.value = Date.now()
|
||||
analyzeNow.value = Date.now()
|
||||
analyzeTimer = setInterval(() => {
|
||||
analyzeNow.value = Date.now()
|
||||
}, 1000)
|
||||
} else if (analyzeTimer) {
|
||||
clearInterval(analyzeTimer)
|
||||
analyzeTimer = undefined
|
||||
analyzeStartedAt.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (analyzeTimer) clearInterval(analyzeTimer)
|
||||
})
|
||||
|
||||
const analyzeElapsed = computed(() => {
|
||||
if (analyzeStartedAt.value === null) return 0
|
||||
return Math.max(0, Math.floor((analyzeNow.value - analyzeStartedAt.value) / 1000))
|
||||
})
|
||||
|
||||
const canStart = computed(
|
||||
() =>
|
||||
!!inputPath.value &&
|
||||
!!providerId.value &&
|
||||
!!modelId.value &&
|
||||
!starting.value &&
|
||||
!analyzing.value,
|
||||
)
|
||||
const startHint = computed(() => {
|
||||
if (!inputPath.value) return formatMessage(messages.startHint)
|
||||
if (!providerId.value || !modelId.value) return formatMessage(messages.aiNotConfigured)
|
||||
return formatMessage(messages.outputPath, { path: defaultOutputPath(inputPath.value) })
|
||||
})
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
if (!analysis.value) return []
|
||||
return [
|
||||
{
|
||||
label: formatMessage(messages.loader),
|
||||
value: analysis.value.loader,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.languageEntries),
|
||||
value: String(analysis.value.languageEntries),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.languageCharacters),
|
||||
value: String(analysis.value.languageCharacters),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.classCandidates),
|
||||
value: String(analysis.value.classCandidates.length),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.estimatedQuote),
|
||||
value: formatMessage(messages.estimatedTokens, {
|
||||
tokens: (analysis.value.quote?.estimatedTokens ?? 0).toLocaleString(),
|
||||
}),
|
||||
detail: formatMessage(messages.estimatedTokensDetail, {
|
||||
calls: analysis.value.quote?.estimatedCalls ?? 0,
|
||||
input: (analysis.value.quote?.estimatedInputTokens ?? 0).toLocaleString(),
|
||||
output: (analysis.value.quote?.estimatedOutputTokens ?? 0).toLocaleString(),
|
||||
}),
|
||||
full: true,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function defaultOutputPath(path: string): string {
|
||||
return path.replace(/\.jar$/i, '-zh_cn.jar')
|
||||
}
|
||||
|
||||
async function runAnalyze() {
|
||||
if (!inputPath.value) return
|
||||
analyzing.value = true
|
||||
store.setAnalysis(null)
|
||||
try {
|
||||
store.setAnalysis(await analyzeMod(inputPath.value), inputPath.value)
|
||||
} catch (error) {
|
||||
handleError(new Error(errorMessage(error)))
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startTranslation() {
|
||||
if (!canStart.value) return
|
||||
starting.value = true
|
||||
try {
|
||||
await store.startTranslation()
|
||||
} catch (error) {
|
||||
handleError(new Error(errorMessage(error)))
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelJob(taskId: string) {
|
||||
try {
|
||||
await store.cancelJob(taskId)
|
||||
} catch (error) {
|
||||
handleError(new Error(errorMessage(error)))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeJob(taskId: string) {
|
||||
try {
|
||||
await store.removeJob(taskId)
|
||||
} catch (error) {
|
||||
handleError(new Error(errorMessage(error)))
|
||||
}
|
||||
}
|
||||
|
||||
function openOutput(job: ModTranslationJob) {
|
||||
void revealItemInDir(job.outputPath).catch((error) => {
|
||||
handleError(new Error(String(error)))
|
||||
})
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void store.init().catch((error) => {
|
||||
handleError(new Error(errorMessage(error)))
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mod-translation-page flex min-h-0 flex-col gap-4 p-6">
|
||||
<header class="flex min-w-0 items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
|
||||
<p class="m-0 mt-1 text-sm text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.activeJobs.length"
|
||||
class="background-badge"
|
||||
:title="formatMessage(messages.backgroundRunning)"
|
||||
>
|
||||
<span class="live-dot" />
|
||||
<span>{{ formatMessage(messages.backgroundRunning) }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mod-translation-grid">
|
||||
<!-- 左栏:输入与设置 -->
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<section class="panel">
|
||||
<h2 class="panel-title">{{ formatMessage(messages.inputSection) }}</h2>
|
||||
<ModTranslationFilePicker v-model:path="inputPath" />
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!inputPath || analyzing" @click="runAnalyze">
|
||||
<SpinnerIcon v-if="analyzing" class="animate-spin" />
|
||||
<FileArchiveIcon v-else />
|
||||
{{ formatMessage(analyzing ? messages.analyzing : messages.analyze) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span v-if="!inputPath" class="panel-hint">
|
||||
{{ formatMessage(messages.selectFile) }}…
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="analyzing" class="analyze-status flex items-center gap-2">
|
||||
<span class="analyze-timer">
|
||||
{{ formatMessage(messages.analyzingElapsed, { seconds: analyzeElapsed }) }}
|
||||
</span>
|
||||
<span class="panel-hint">{{ formatMessage(messages.analyzingHint) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="analysis" class="panel">
|
||||
<h2 class="panel-title">{{ formatMessage(messages.analysis) }}</h2>
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
v-for="item in summaryStats"
|
||||
:key="item.label"
|
||||
class="stat"
|
||||
:class="{ 'stat-full': item.full }"
|
||||
>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
<span class="stat-value">{{ item.value }}</span>
|
||||
<span v-if="item.detail" class="stat-detail">{{ item.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition v-if="analysis.signed" type="warning">
|
||||
{{ formatMessage(messages.signedMod) }}
|
||||
</Admonition>
|
||||
<Admonition v-for="warning in analysis.warnings" :key="warning" type="warning">
|
||||
{{ warning }}
|
||||
</Admonition>
|
||||
<div v-if="analysis.languageSources.length" class="flex flex-col gap-1.5">
|
||||
<h3 class="m-0 text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.languageSources) }}
|
||||
</h3>
|
||||
<div class="source-list">
|
||||
<div
|
||||
v-for="source in analysis.languageSources"
|
||||
:key="source.sourcePath"
|
||||
class="source-row"
|
||||
>
|
||||
<span class="source-path" :title="source.sourcePath">
|
||||
{{ source.sourcePath }}
|
||||
</span>
|
||||
<span class="source-count">{{ source.required }} / {{ source.entries }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2 class="panel-title">{{ formatMessage(messages.aiSection) }}</h2>
|
||||
<ModTranslationSettingsPanel
|
||||
v-model="options"
|
||||
v-model:provider-id="providerId"
|
||||
v-model:model-id="modelId"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<ButtonStyled color="brand">
|
||||
<button class="start-button" @click="startTranslation" :disabled="!canStart">
|
||||
<PlayIcon />{{ formatMessage(messages.start) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span class="panel-hint">{{ startHint }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:任务 -->
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<section class="panel jobs-panel flex-1">
|
||||
<h2 class="panel-title">{{ formatMessage(messages.jobsSection) }}</h2>
|
||||
<ModTranslationJobList
|
||||
:jobs="jobs"
|
||||
@cancel="cancelJob"
|
||||
@remove="removeJob"
|
||||
@open-output="openOutput"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mod-translation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1100px) {
|
||||
.mod-translation-grid {
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.analyze-timer {
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.start-button {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.background-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
animation: page-pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes page-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-brand) 50%, transparent);
|
||||
}
|
||||
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0.5rem transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-3);
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
overflow: hidden;
|
||||
color: var(--color-contrast);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-full {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-full .stat-label {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.stat-full .stat-value {
|
||||
flex: 0 0 auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stat-full .stat-detail {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: flex;
|
||||
max-height: 14rem;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.source-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-3);
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
|
||||
.source-path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
2589
apps/app-frontend/src/pages/LabRecipeGenerator.vue
Normal file
2589
apps/app-frontend/src/pages/LabRecipeGenerator.vue
Normal file
File diff suppressed because it is too large
Load Diff
2881
apps/app-frontend/src/pages/LabSchematicPreview.vue
Normal file
2881
apps/app-frontend/src/pages/LabSchematicPreview.vue
Normal file
File diff suppressed because it is too large
Load Diff
3988
apps/app-frontend/src/pages/LabSeedMap.vue
Normal file
3988
apps/app-frontend/src/pages/LabSeedMap.vue
Normal file
File diff suppressed because it is too large
Load Diff
216
apps/app-frontend/src/pages/LabSkinEditor.vue
Normal file
216
apps/app-frontend/src/pages/LabSkinEditor.vue
Normal file
@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
LoadingIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { createSkinEditorTheme } from '@/components/lab/skin-editor/skin-editor-theme'
|
||||
|
||||
const { locale, formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const platformName = ref<string>()
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
|
||||
loading: { id: 'app.lab.skin-editor.loading', defaultMessage: 'Loading skin editor' },
|
||||
loadErrorTitle: {
|
||||
id: 'app.lab.skin-editor.load-error-title',
|
||||
defaultMessage: 'Skin editor could not be loaded',
|
||||
},
|
||||
loadErrorDescription: {
|
||||
id: 'app.lab.skin-editor.load-error-description',
|
||||
defaultMessage: 'The embedded editor did not finish loading. Try again.',
|
||||
},
|
||||
retry: { id: 'app.lab.skin-editor.retry', defaultMessage: 'Try again' },
|
||||
exportSkin: { id: 'app.lab.skin-editor.export-skin', defaultMessage: 'Minecraft skin PNG' },
|
||||
})
|
||||
|
||||
const blockbenchLocale = computed(() => {
|
||||
const normalized = locale.value.toLowerCase().replace('_', '-')
|
||||
if (normalized === 'zh-tw' || normalized === 'zh-hk') return 'zh_tw'
|
||||
if (normalized.startsWith('zh')) return 'zh'
|
||||
if (normalized === 'pt-br') return 'pt_br'
|
||||
return normalized.split('-')[0]
|
||||
})
|
||||
|
||||
const editorState = ref<'loading' | 'ready' | 'error'>('loading')
|
||||
const frameKey = ref(0)
|
||||
let loadTimeout: number | undefined
|
||||
|
||||
const editorUrl = computed(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
return `/__blockbench_skin__/index.html?embed=skin&lang=${encodeURIComponent(blockbenchLocale.value)}`
|
||||
}
|
||||
if (!platformName.value) return ''
|
||||
const baseUrl =
|
||||
platformName.value === 'windows' ? 'http://axolotl-skin.localhost' : 'axolotl-skin://localhost'
|
||||
return `${baseUrl}/index.html?embed=skin&lang=${encodeURIComponent(blockbenchLocale.value)}`
|
||||
})
|
||||
|
||||
function clearLoadTimeout() {
|
||||
if (loadTimeout !== undefined) window.clearTimeout(loadTimeout)
|
||||
loadTimeout = undefined
|
||||
}
|
||||
|
||||
function beginEditorLoad() {
|
||||
clearLoadTimeout()
|
||||
editorState.value = 'loading'
|
||||
loadTimeout = window.setTimeout(() => {
|
||||
editorState.value = 'error'
|
||||
}, 15_000)
|
||||
}
|
||||
|
||||
function markEditorReady() {
|
||||
clearLoadTimeout()
|
||||
editorState.value = 'ready'
|
||||
}
|
||||
|
||||
function markEditorError() {
|
||||
clearLoadTimeout()
|
||||
editorState.value = 'error'
|
||||
}
|
||||
|
||||
async function reloadEditor() {
|
||||
beginEditorLoad()
|
||||
if (!editorUrl.value) {
|
||||
try {
|
||||
platformName.value = await platform()
|
||||
} catch (error) {
|
||||
markEditorError()
|
||||
handleError(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
frameKey.value += 1
|
||||
}
|
||||
|
||||
function sendThemeToEditor() {
|
||||
frame.value?.contentWindow?.postMessage(
|
||||
{ type: 'axolotl-skin-theme', theme: createSkinEditorTheme() },
|
||||
'*',
|
||||
)
|
||||
}
|
||||
|
||||
function handleFrameLoad() {
|
||||
sendThemeToEditor()
|
||||
}
|
||||
|
||||
async function handleEditorMessage(event: MessageEvent<unknown>) {
|
||||
if (event.source !== frame.value?.contentWindow) return
|
||||
if (!event.data || typeof event.data !== 'object') return
|
||||
const message = event.data as { type?: unknown; name?: unknown; dataUrl?: unknown }
|
||||
if (message.type === 'axolotl-skin-theme-ready') {
|
||||
sendThemeToEditor()
|
||||
markEditorReady()
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.type !== 'axolotl-skin-export' ||
|
||||
typeof message.name !== 'string' ||
|
||||
typeof message.dataUrl !== 'string'
|
||||
)
|
||||
return
|
||||
try {
|
||||
const path = await save({
|
||||
defaultPath: message.name,
|
||||
filters: [{ name: formatMessage(messages.exportSkin), extensions: ['png'] }],
|
||||
})
|
||||
if (!path) return
|
||||
const response = await fetch(message.dataUrl)
|
||||
if (!response.ok) throw new Error(`Failed to read exported skin: ${response.status}`)
|
||||
await writeFile(path, new Uint8Array(await response.arrayBuffer()))
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const frame = ref<HTMLIFrameElement>()
|
||||
let themeObserver: MutationObserver | undefined
|
||||
|
||||
watch(
|
||||
editorUrl,
|
||||
(url) => {
|
||||
if (url) beginEditorLoad()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('message', handleEditorMessage)
|
||||
themeObserver = new MutationObserver(sendThemeToEditor)
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
if (!import.meta.env.DEV) {
|
||||
try {
|
||||
platformName.value = await platform()
|
||||
} catch (error) {
|
||||
markEditorError()
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearLoadTimeout()
|
||||
window.removeEventListener('message', handleEditorMessage)
|
||||
themeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="skin-editor-page relative flex h-full min-h-0 w-full flex-1 bg-surface-1">
|
||||
<h1 class="sr-only">{{ formatMessage(messages.title) }}</h1>
|
||||
<div
|
||||
v-if="editorState === 'loading'"
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-surface-1 text-secondary"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<LoadingIndicator />
|
||||
<p class="m-0">{{ formatMessage(messages.loading) }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="editorState === 'error'"
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.loadErrorTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 max-w-md text-secondary">{{ formatMessage(messages.loadErrorDescription) }}</p>
|
||||
<ButtonStyled color="brand" @click="reloadEditor">
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="editorUrl"
|
||||
:key="`${frameKey}:${editorUrl}`"
|
||||
ref="frame"
|
||||
:title="formatMessage(messages.title)"
|
||||
:src="editorUrl"
|
||||
class="h-full min-h-0 w-full flex-1 border-0 transition-opacity duration-150"
|
||||
:class="editorState === 'ready' ? 'opacity-100' : 'pointer-events-none opacity-0'"
|
||||
:aria-label="formatMessage(messages.title)"
|
||||
:aria-hidden="editorState !== 'ready'"
|
||||
@load="handleFrameLoad"
|
||||
@error="markEditorError"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.app-viewport:has(.skin-editor-page),
|
||||
.app-viewport:has(.skin-editor-page) .page-transition-grid,
|
||||
.app-viewport:has(.skin-editor-page) .page-transition-layer {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
59
apps/app-frontend/src/pages/Multiplayer.vue
Normal file
59
apps/app-frontend/src/pages/Multiplayer.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ServerIcon, UsersIcon } from '@modrinth/assets'
|
||||
import { defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.multiplayer.title', defaultMessage: 'Multiplayer' },
|
||||
serversTab: { id: 'app.multiplayer.tab.servers', defaultMessage: 'Servers' },
|
||||
roomsTab: { id: 'app.multiplayer.tab.rooms', defaultMessage: 'Rooms' },
|
||||
})
|
||||
|
||||
const activeTab = computed(() =>
|
||||
route.path.startsWith('/multiplayer/rooms') ? 'rooms' : 'servers',
|
||||
)
|
||||
// 服务器详情页用固定高度布局:控制台内部滚动,命令输入框始终可见
|
||||
const isStudioMode = computed(() => route.name === 'MultiplayerServerFileStudio')
|
||||
const isFixedRender = computed(
|
||||
() => route.name === 'MultiplayerServerDetail' || route.name === 'MultiplayerServerFileStudio',
|
||||
)
|
||||
const tabLinks = computed(() => [
|
||||
{ label: formatMessage(messages.serversTab), href: '/multiplayer/servers', icon: ServerIcon },
|
||||
{ label: formatMessage(messages.roomsTab), href: '/multiplayer/rooms', icon: UsersIcon },
|
||||
])
|
||||
|
||||
function handleTabClick(index: number) {
|
||||
void router.push(tabLinks.value[index]?.href ?? '/multiplayer/servers')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
isStudioMode
|
||||
? 'flex h-full min-h-0 w-full flex-col'
|
||||
: isFixedRender
|
||||
? 'box-border flex h-full min-h-0 w-full flex-col gap-3 p-6'
|
||||
: 'box-border flex min-h-full w-full flex-col gap-3 p-6'
|
||||
"
|
||||
>
|
||||
<template v-if="!isStudioMode">
|
||||
<h1 class="m-0 shrink-0 text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h1>
|
||||
<NavTabs
|
||||
mode="local"
|
||||
:active-index="activeTab === 'rooms' ? 1 : 0"
|
||||
:links="tabLinks"
|
||||
@tab-click="handleTabClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
619
apps/app-frontend/src/pages/Settings.vue
Normal file
619
apps/app-frontend/src/pages/Settings.vue
Normal file
@ -0,0 +1,619 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
type MessageDescriptor,
|
||||
ProgressBar,
|
||||
useLoadingBarToken,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
getVisibleSettingsCategories,
|
||||
getVisibleSettingsGroups,
|
||||
type SettingsCategory,
|
||||
settingsPageTitle,
|
||||
} from '@/components/ui/settings/settings-registry'
|
||||
import {
|
||||
filterSettingsSearchDocuments,
|
||||
normalizeSettingsSearchText,
|
||||
} from '@/components/ui/settings/settings-search'
|
||||
import {
|
||||
getSettingsSearchTargetId,
|
||||
type SettingsSearchEntry,
|
||||
} from '@/components/ui/settings/settings-search-index'
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
interface SettingsSearchResult {
|
||||
category: SettingsCategory
|
||||
entry?: SettingsSearchEntry
|
||||
label: string
|
||||
breadcrumb: string
|
||||
}
|
||||
|
||||
const themeStore = useTheming()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
|
||||
|
||||
const [version, loadedSettings] = await Promise.all([getVersion(), get()])
|
||||
const osPlatform = getOsPlatform()
|
||||
const osVersion = getOsVersion()
|
||||
const settings = ref(loadedSettings)
|
||||
const devModeCounter = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const selectedCategoryId = ref(route.hash.slice(1) || 'interface')
|
||||
const settingsContentPending = ref(false)
|
||||
const contentContainer = ref<HTMLElement | null>(null)
|
||||
const searchHighlightTarget = ref<HTMLElement | null>(null)
|
||||
const expandedGroups = ref<Record<string, boolean>>({
|
||||
launcher: true,
|
||||
game: true,
|
||||
'data-privacy': true,
|
||||
support: true,
|
||||
developer: false,
|
||||
})
|
||||
const hasSearchQuery = computed(() => !!normalizeSettingsSearchText(searchQuery.value))
|
||||
let searchHighlightTimer: ReturnType<typeof window.setTimeout> | undefined
|
||||
|
||||
// The settings registry keeps each category lazy. Track only the currently
|
||||
// selected async component so the shared top loading bar reflects navigation
|
||||
// without eagerly loading every settings page.
|
||||
useLoadingBarToken(settingsContentPending)
|
||||
|
||||
const messages = defineMessages({
|
||||
search: {
|
||||
id: 'app.settings.search.placeholder',
|
||||
defaultMessage: 'Search settings',
|
||||
},
|
||||
clearSearch: {
|
||||
id: 'app.settings.search.clear',
|
||||
defaultMessage: 'Clear settings search',
|
||||
},
|
||||
noResults: {
|
||||
id: 'app.settings.search.empty',
|
||||
defaultMessage: 'No settings match your search.',
|
||||
},
|
||||
results: {
|
||||
id: 'app.settings.search.results',
|
||||
defaultMessage: 'Search results',
|
||||
},
|
||||
downloading: {
|
||||
id: 'app.settings.downloading',
|
||||
defaultMessage: 'Downloading v{version}',
|
||||
},
|
||||
developerModeEnabled: {
|
||||
id: 'app.settings.developer-mode-enabled',
|
||||
defaultMessage: 'Developer mode enabled.',
|
||||
},
|
||||
})
|
||||
|
||||
const visibleCategories = computed(() => getVisibleSettingsCategories(!!themeStore.devMode))
|
||||
const visibleGroups = computed(() => getVisibleSettingsGroups(!!themeStore.devMode))
|
||||
const activeCategory = computed(
|
||||
() =>
|
||||
visibleCategories.value.find((category) => category.id === selectedCategoryId.value) ??
|
||||
visibleCategories.value[0],
|
||||
)
|
||||
const searchResults = computed<SettingsSearchResult[]>(() => {
|
||||
const documents = visibleGroups.value.flatMap((group) =>
|
||||
group.categories.flatMap((category) => {
|
||||
const categoryLabel = categoryName(category)
|
||||
const groupLabel = formatMessage(group.name)
|
||||
|
||||
return [
|
||||
{
|
||||
item: {
|
||||
category,
|
||||
label: categoryLabel,
|
||||
breadcrumb: groupLabel,
|
||||
},
|
||||
text: searchTextVariants([group.name, category.name]),
|
||||
},
|
||||
...category.entries.map((entry) => ({
|
||||
item: {
|
||||
category,
|
||||
entry,
|
||||
label: entryName(entry),
|
||||
breadcrumb: `${groupLabel} > ${categoryLabel}`,
|
||||
},
|
||||
text: [
|
||||
...searchTextVariants([
|
||||
group.name,
|
||||
category.name,
|
||||
entry.label,
|
||||
...(entry.keywords ?? []),
|
||||
]),
|
||||
...messageSearchTexts(entry.description),
|
||||
],
|
||||
})),
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
return filterSettingsSearchDocuments(searchQuery.value, documents).map(({ item }) => item)
|
||||
})
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
await set(settings.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(visibleCategories, (categories) => {
|
||||
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = categories[0]?.id ?? 'interface'
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.hash,
|
||||
(hash) => {
|
||||
const categoryId = hash.slice(1)
|
||||
if (categoryId) selectedCategoryId.value = categoryId
|
||||
},
|
||||
)
|
||||
|
||||
function selectCategory(categoryId: string) {
|
||||
selectedCategoryId.value = categoryId
|
||||
const category = visibleCategories.value.find((item) => item.id === categoryId)
|
||||
if (category) expandedGroups.value[category.group] = true
|
||||
contentContainer.value?.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
function toggleGroup(groupId: string) {
|
||||
expandedGroups.value[groupId] = !expandedGroups.value[groupId]
|
||||
}
|
||||
|
||||
async function selectSearchResult(result: SettingsSearchResult) {
|
||||
selectedCategoryId.value = result.category.id
|
||||
expandedGroups.value[result.category.group] = true
|
||||
searchQuery.value = ''
|
||||
contentContainer.value?.scrollTo({ top: 0 })
|
||||
|
||||
if (!result.entry) return
|
||||
|
||||
const targetId = getSettingsSearchTargetId(result.entry)
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await nextTick()
|
||||
const target = contentContainer.value?.querySelector<HTMLElement>(`#${targetId}`)
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
flashSearchTarget(target)
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDeveloperMode() {
|
||||
devModeCounter.value++
|
||||
if (devModeCounter.value <= 5) return
|
||||
|
||||
themeStore.devMode = !themeStore.devMode
|
||||
settings.value.developer_mode = !!themeStore.devMode
|
||||
devModeCounter.value = 0
|
||||
}
|
||||
|
||||
function categoryName(category: SettingsCategory): string {
|
||||
return formatMessage(category.name)
|
||||
}
|
||||
|
||||
function entryName(entry: SettingsSearchEntry): string {
|
||||
return formatMessage(entry.label)
|
||||
}
|
||||
|
||||
function messageSearchTexts(message?: MessageDescriptor): string[] {
|
||||
// Search metadata is assembled from several registries. Guard the runtime
|
||||
// boundary so a malformed entry cannot abort the entire settings render.
|
||||
if (!message || typeof message !== 'object' || typeof message.id !== 'string') return []
|
||||
|
||||
const texts = [formatMessage(message), message.defaultMessage].filter(
|
||||
(text): text is string => !!text,
|
||||
)
|
||||
return [...new Set(texts)]
|
||||
}
|
||||
|
||||
function searchTextVariants(messages: Array<MessageDescriptor | undefined>): string[] {
|
||||
return messages.flatMap(messageSearchTexts)
|
||||
}
|
||||
|
||||
function searchResultKey(result: SettingsSearchResult): string {
|
||||
return result.entry?.id ?? `category-${result.category.id}`
|
||||
}
|
||||
|
||||
function searchMatchSegments(text: string) {
|
||||
const query = normalizeSettingsSearchText(searchQuery.value)
|
||||
if (!query) return [{ text, matched: false }]
|
||||
|
||||
const index = text.toLocaleLowerCase().indexOf(query)
|
||||
if (index < 0) return [{ text, matched: false }]
|
||||
|
||||
return [
|
||||
{ text: text.slice(0, index), matched: false },
|
||||
{ text: text.slice(index, index + query.length), matched: true },
|
||||
{ text: text.slice(index + query.length), matched: false },
|
||||
].filter((segment) => segment.text)
|
||||
}
|
||||
|
||||
function flashSearchTarget(target: HTMLElement) {
|
||||
const highlightTarget = target.closest<HTMLElement>('.settings-row') ?? target
|
||||
searchHighlightTarget.value?.classList.remove('settings-search-result-highlight')
|
||||
if (searchHighlightTimer) window.clearTimeout(searchHighlightTimer)
|
||||
|
||||
highlightTarget.classList.add('settings-search-result-highlight')
|
||||
searchHighlightTarget.value = highlightTarget
|
||||
searchHighlightTimer = window.setTimeout(() => {
|
||||
highlightTarget.classList.remove('settings-search-result-highlight')
|
||||
if (searchHighlightTarget.value === highlightTarget) searchHighlightTarget.value = null
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
function platformName() {
|
||||
return osPlatform === 'macos' ? 'macOS' : osPlatform.charAt(0).toUpperCase() + osPlatform.slice(1)
|
||||
}
|
||||
|
||||
const pageTitle: MessageDescriptor = settingsPageTitle
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-fixed-render h-full min-h-0 pt-6 pl-6 pb-6">
|
||||
<div class="settings-layout h-full min-h-0">
|
||||
<aside class="settings-sidebar">
|
||||
<div class="relative shrink-0">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-secondary"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
:aria-label="formatMessage(messages.search)"
|
||||
class="w-full rounded-lg border border-surface-4 bg-surface-3 py-2 pl-9 pr-9 text-sm text-contrast outline-none transition-colors placeholder:text-secondary focus:border-surface-5"
|
||||
@keydown.escape="searchQuery = ''"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md border-0 bg-transparent text-secondary transition-colors hover:bg-surface-4 hover:text-contrast"
|
||||
:aria-label="formatMessage(messages.clearSearch)"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasSearchQuery"
|
||||
class="settings-sidebar-list"
|
||||
:aria-label="formatMessage(messages.results)"
|
||||
>
|
||||
<button
|
||||
v-for="result in searchResults"
|
||||
:key="searchResultKey(result)"
|
||||
type="button"
|
||||
class="settings-search-result items-center gap-2 p-2 hover:bg-surface-3 hover:text-contrast"
|
||||
@click="selectSearchResult(result)"
|
||||
>
|
||||
<component :is="result.category.icon" class="size-4 shrink-0 text-secondary" />
|
||||
<span class="settings-search-result-copy">
|
||||
<span class="settings-search-result-label">
|
||||
<template v-for="segment in searchMatchSegments(result.label)" :key="segment.text">
|
||||
<mark v-if="segment.matched" class="settings-search-match">{{
|
||||
segment.text
|
||||
}}</mark>
|
||||
<span v-else>{{ segment.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="truncate text-xs text-secondary">{{ result.breadcrumb }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="searchResults.length === 0" class="m-0 px-3 py-4 text-sm text-secondary">
|
||||
{{ formatMessage(messages.noResults) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav v-else class="settings-sidebar-list" :aria-label="formatMessage(pageTitle)">
|
||||
<section v-for="group in visibleGroups" :key="group.id" class="settings-nav-group">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-group-button hover:bg-surface-3 hover:text-contrast"
|
||||
:aria-expanded="expandedGroups[group.id]"
|
||||
@click="toggleGroup(group.id)"
|
||||
>
|
||||
<component :is="group.icon" class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ formatMessage(group.name) }}</span>
|
||||
<ChevronDownIcon
|
||||
class="ml-auto size-3.5 shrink-0 transition-transform"
|
||||
:class="expandedGroups[group.id] ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div v-show="expandedGroups[group.id]" class="settings-nav-items">
|
||||
<button
|
||||
v-for="category in group.categories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
:data-onboarding-id="category.onboardingId"
|
||||
class="settings-category-button hover:bg-surface-3 hover:text-contrast"
|
||||
:class="{ 'is-active': activeCategory?.id === category.id }"
|
||||
@click="selectCategory(category.id)"
|
||||
>
|
||||
<component :is="category.icon" class="size-4 shrink-0" />
|
||||
<span class="truncate">{{ categoryName(category) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<footer class="mt-auto shrink-0 pt-4 text-sm text-secondary">
|
||||
<div v-if="progress > 0 && progress < 1" class="mb-4">
|
||||
<p class="m-0 mb-2">
|
||||
{{ formatMessage(messages.downloading, { version: downloadingVersion }) }}
|
||||
</p>
|
||||
<ProgressBar :progress="progress" />
|
||||
</div>
|
||||
<p v-if="themeStore.devMode" class="m-0 mb-3 text-brand font-semibold">
|
||||
{{ formatMessage(messages.developerModeEnabled) }}
|
||||
</p>
|
||||
<div class="settings-footer-identity flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="m-0 flex size-9 shrink-0 items-center justify-center rounded-lg border-0 bg-transparent p-0 transition-colors hover:bg-surface-3"
|
||||
:class="themeStore.devMode ? 'text-brand' : 'text-secondary'"
|
||||
@click="toggleDeveloperMode"
|
||||
>
|
||||
<img class="size-8 object-contain" src="@/assets/axolotl.png" alt="" />
|
||||
</button>
|
||||
<div class="settings-footer-version min-w-0">
|
||||
<p class="m-0 break-words">{{ AxolotlBrandConfig.productName }} {{ version }}</p>
|
||||
<p class="m-0 truncate">{{ platformName() }} {{ osVersion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<section class="settings-content" :aria-label="formatMessage(pageTitle)">
|
||||
<header class="settings-content-header">
|
||||
<component :is="activeCategory?.icon" class="size-5 text-secondary" />
|
||||
<h1 class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ activeCategory ? categoryName(activeCategory) : formatMessage(pageTitle) }}
|
||||
</h1>
|
||||
</header>
|
||||
<div
|
||||
ref="contentContainer"
|
||||
class="settings-content-scroll min-h-0 flex-1"
|
||||
:class="activeCategory?.flushContent ? 'overflow-hidden' : 'overflow-y-auto'"
|
||||
>
|
||||
<div
|
||||
v-if="activeCategory"
|
||||
:id="`settings-category-${activeCategory.id}`"
|
||||
class="min-h-0"
|
||||
:class="activeCategory.flushContent ? 'h-full' : 'mx-auto max-w-5xl px-6 pb-6'"
|
||||
tabindex="-1"
|
||||
>
|
||||
<Suspense
|
||||
@pending="settingsContentPending = true"
|
||||
@resolve="settingsContentPending = false"
|
||||
>
|
||||
<component :is="activeCategory.content" :key="activeCategory.id" />
|
||||
<template #fallback>
|
||||
<div class="settings-content-fallback" aria-hidden="true" />
|
||||
</template>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-layout {
|
||||
--settings-divider: color-mix(in srgb, var(--surface-4) 55%, transparent);
|
||||
--settings-card-border: color-mix(in srgb, var(--surface-4) 72%, transparent);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18rem, 20rem) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-lg);
|
||||
}
|
||||
|
||||
.settings-sidebar-list {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-nav-group {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.settings-nav-items {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.settings-group-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
min-height: 1.75rem;
|
||||
padding: 0 var(--gap-sm);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.settings-category-button,
|
||||
.settings-search-result {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.settings-category-button {
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-height: 2.25rem;
|
||||
gap: var(--gap-sm);
|
||||
padding: 0 var(--gap-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-category-button.is-active {
|
||||
background: var(--color-button-bg-selected);
|
||||
color: var(--color-button-text-selected);
|
||||
}
|
||||
|
||||
.settings-search-result-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.settings-search-result-label {
|
||||
overflow: hidden;
|
||||
color: var(--color-contrast);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-search-match {
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content-fallback {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.settings-footer-identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-footer-version {
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-content-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
padding: var(--gap-xs) var(--gap-xl) var(--gap-lg);
|
||||
}
|
||||
|
||||
.settings-content-scroll :deep([id^='settings-target-']),
|
||||
.settings-content-scroll :deep([id^='settings-category-']) {
|
||||
scroll-margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-content-scroll :deep(.settings-search-result-highlight) {
|
||||
border-radius: var(--radius-sm);
|
||||
animation: settings-search-result-highlight 0.9s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes settings-search-result-highlight {
|
||||
0%,
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
50% {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.settings-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
padding-bottom: var(--gap-lg);
|
||||
}
|
||||
|
||||
.settings-sidebar-list {
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-content-header {
|
||||
padding-inline: var(--gap-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.app-viewport:has(.settings-fixed-render) {
|
||||
overflow: hidden;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.app-viewport:has(.settings-fixed-render) .page-transition-grid,
|
||||
.app-viewport:has(.settings-fixed-render) .page-transition-layer {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
1218
apps/app-frontend/src/pages/Skins.vue
Normal file
1218
apps/app-frontend/src/pages/Skins.vue
Normal file
File diff suppressed because it is too large
Load Diff
11
apps/app-frontend/src/pages/Worlds.vue
Normal file
11
apps/app-frontend/src/pages/Worlds.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
worlds: { id: 'app.worlds.title', defaultMessage: 'Worlds' },
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">{{ formatMessage(messages.worlds) }}</div>
|
||||
</template>
|
||||
89
apps/app-frontend/src/pages/download-focus.test.ts
Normal file
89
apps/app-frontend/src/pages/download-focus.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
|
||||
import {
|
||||
createDownloadFocusState,
|
||||
focusedDownloadJobId,
|
||||
reconcileDownloadFocus,
|
||||
stopDownloadFocusAutoFollow,
|
||||
} from './download-focus.ts'
|
||||
|
||||
function job(status: InstallJobStatus): InstallJobSnapshot {
|
||||
return { job_id: 'job-a', status } as InstallJobSnapshot
|
||||
}
|
||||
|
||||
test('focused active job selects Active and expands once', () => {
|
||||
const effect = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('running'))
|
||||
assert.equal(effect.tab, 'active')
|
||||
assert.equal(effect.expand, true)
|
||||
assert.equal(effect.scroll, true)
|
||||
assert.equal(effect.state.autoFollow, true)
|
||||
})
|
||||
|
||||
test('focused terminal job selects History and expands once', () => {
|
||||
const effect = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('succeeded'))
|
||||
assert.equal(effect.tab, 'history')
|
||||
assert.equal(effect.expand, true)
|
||||
assert.equal(effect.state.autoFollow, false)
|
||||
})
|
||||
|
||||
test('active focused job follows completion to History once', () => {
|
||||
const active = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('running'))
|
||||
const completed = reconcileDownloadFocus(active.state, job('succeeded'))
|
||||
assert.equal(completed.tab, 'history')
|
||||
assert.equal(completed.expand, false)
|
||||
assert.equal(completed.scroll, false)
|
||||
assert.equal(completed.state.autoFollow, false)
|
||||
assert.equal(reconcileDownloadFocus(completed.state, job('succeeded')).tab, null)
|
||||
})
|
||||
|
||||
test('manual tab selection stops completion auto-follow', () => {
|
||||
const active = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('running'))
|
||||
const manual = stopDownloadFocusAutoFollow(active.state)
|
||||
assert.equal(reconcileDownloadFocus(manual, job('succeeded')).tab, null)
|
||||
})
|
||||
|
||||
test('malformed or nonexistent focus leaves Downloads unchanged', () => {
|
||||
assert.equal(focusedDownloadJobId(undefined), null)
|
||||
assert.equal(focusedDownloadJobId(['job-a']), null)
|
||||
assert.equal(focusedDownloadJobId(''), null)
|
||||
const state = createDownloadFocusState('missing')
|
||||
assert.deepEqual(reconcileDownloadFocus(state, null), {
|
||||
state,
|
||||
tab: null,
|
||||
expand: false,
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('Downloads focus reuses global manager without another install job listener', () => {
|
||||
const source = readFileSync(new URL('./Downloads.vue', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(source, /install_job_listener/)
|
||||
assert.doesNotMatch(source, /focusedJobId === job\.job_id/)
|
||||
assert.doesNotMatch(source, /ring-brand/)
|
||||
})
|
||||
|
||||
test('Downloads covers upgrade phases and safely falls back for unknown phases', () => {
|
||||
const source = readFileSync(new URL('./Downloads.vue', import.meta.url), 'utf8')
|
||||
const phaseMessages = source.slice(
|
||||
source.indexOf('const phaseMessages ='),
|
||||
source.indexOf('const legacyDownloads'),
|
||||
)
|
||||
|
||||
for (const phase of ['applying_content', 'verifying', 'completed']) {
|
||||
assert.match(phaseMessages, new RegExp(`\\b${phase}:`))
|
||||
}
|
||||
assert.match(phaseMessages, /satisfies Record<InstallPhaseId, MessageDescriptor>/)
|
||||
assert.match(source, /message \?\? messages\.unknownPhase/)
|
||||
})
|
||||
|
||||
test('Downloads history links persisted upgrade result to standalone page', () => {
|
||||
const source = readFileSync(new URL('./Downloads.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /tab === 'history' && isSuccessfulUpgradeJob\(job\)/)
|
||||
assert.match(source, /router\.push\(upgradeResultLocation\(job\)\)/)
|
||||
assert.match(source, /messages\.viewUpgradeResult/)
|
||||
assert.doesNotMatch(source, /UpgradeResultDetails/)
|
||||
})
|
||||
85
apps/app-frontend/src/pages/download-focus.ts
Normal file
85
apps/app-frontend/src/pages/download-focus.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
|
||||
export type DownloadsTab = 'active' | 'history'
|
||||
|
||||
export interface DownloadFocusState {
|
||||
jobId: string | null
|
||||
focused: boolean
|
||||
autoFollow: boolean
|
||||
lastStatus: InstallJobStatus | null
|
||||
}
|
||||
|
||||
export interface DownloadFocusEffect {
|
||||
state: DownloadFocusState
|
||||
tab: DownloadsTab | null
|
||||
expand: boolean
|
||||
scroll: boolean
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set<InstallJobStatus>([
|
||||
'queued',
|
||||
'running',
|
||||
'canceling',
|
||||
'waiting_for_user',
|
||||
])
|
||||
|
||||
export function focusedDownloadJobId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function createDownloadFocusState(jobId: string | null): DownloadFocusState {
|
||||
return {
|
||||
jobId,
|
||||
focused: false,
|
||||
autoFollow: jobId !== null,
|
||||
lastStatus: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadTabForJob(job: InstallJobSnapshot): DownloadsTab {
|
||||
return ACTIVE_STATUSES.has(job.status) ? 'active' : 'history'
|
||||
}
|
||||
|
||||
export function reconcileDownloadFocus(
|
||||
state: DownloadFocusState,
|
||||
job: InstallJobSnapshot | null,
|
||||
): DownloadFocusEffect {
|
||||
if (!state.jobId || !job || job.job_id !== state.jobId) {
|
||||
return { state, tab: null, expand: false, scroll: false }
|
||||
}
|
||||
|
||||
const targetTab = downloadTabForJob(job)
|
||||
if (!state.focused) {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
focused: true,
|
||||
autoFollow: targetTab === 'active',
|
||||
lastStatus: job.status,
|
||||
},
|
||||
tab: targetTab,
|
||||
expand: true,
|
||||
scroll: true,
|
||||
}
|
||||
}
|
||||
|
||||
const completedWhileFollowing =
|
||||
state.autoFollow &&
|
||||
state.lastStatus !== null &&
|
||||
ACTIVE_STATUSES.has(state.lastStatus) &&
|
||||
targetTab === 'history'
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
autoFollow: completedWhileFollowing ? false : state.autoFollow,
|
||||
lastStatus: job.status,
|
||||
},
|
||||
tab: completedWhileFollowing ? 'history' : null,
|
||||
expand: false,
|
||||
scroll: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function stopDownloadFocusAutoFollow(state: DownloadFocusState): DownloadFocusState {
|
||||
return { ...state, autoFollow: false }
|
||||
}
|
||||
207
apps/app-frontend/src/pages/help/DropHelp.vue
Normal file
207
apps/app-frontend/src/pages/help/DropHelp.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-6">
|
||||
<h1 class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<BigOptionButton
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
:icon="section.icon"
|
||||
:title="formatMessage(section.titleMsg)"
|
||||
:description="formatMessage(section.descMsg)"
|
||||
:note="formatMessage(section.formatsMsg)"
|
||||
no-icon-border
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BookIcon,
|
||||
BracesIcon,
|
||||
FolderOpenIcon,
|
||||
GridIcon,
|
||||
MapIcon,
|
||||
PackageOpenIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { BigOptionButton, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'drop.help.title',
|
||||
defaultMessage: 'What can I drop into Axolotl?',
|
||||
},
|
||||
instancesTitle: {
|
||||
id: 'drop.help.instances-title',
|
||||
defaultMessage: 'Launcher Instances',
|
||||
},
|
||||
instancesDesc: {
|
||||
id: 'drop.help.instances-desc',
|
||||
defaultMessage:
|
||||
'Drag folders from other launchers (MultiMC, Prism Launcher, HMCL, etc.) to import Minecraft instances.',
|
||||
},
|
||||
instancesFormats: {
|
||||
id: 'drop.help.instances-formats',
|
||||
defaultMessage: 'MultiMC / Prism Launcher / PolyMC / HMCL instance folders',
|
||||
},
|
||||
modsTitle: {
|
||||
id: 'drop.help.mods-title',
|
||||
defaultMessage: 'Mods',
|
||||
},
|
||||
modsDesc: {
|
||||
id: 'drop.help.mods-desc',
|
||||
defaultMessage: 'Drag mod JAR files to install them into a Minecraft instance.',
|
||||
},
|
||||
modsFormats: {
|
||||
id: 'drop.help.mods-formats',
|
||||
defaultMessage: '.jar',
|
||||
},
|
||||
modpacksTitle: {
|
||||
id: 'drop.help.modpacks-title',
|
||||
defaultMessage: 'Modpacks',
|
||||
},
|
||||
modpacksDesc: {
|
||||
id: 'drop.help.modpacks-desc',
|
||||
defaultMessage: 'Drag modpack ZIP/MRPACK files to create a new instance.',
|
||||
},
|
||||
modpacksFormats: {
|
||||
id: 'drop.help.modpacks-formats',
|
||||
defaultMessage: '.mrpack / .zip',
|
||||
},
|
||||
resourcePacksTitle: {
|
||||
id: 'drop.help.resource-packs-title',
|
||||
defaultMessage: 'Resource Packs',
|
||||
},
|
||||
resourcePacksDesc: {
|
||||
id: 'drop.help.resource-packs-desc',
|
||||
defaultMessage: 'Drag resource pack ZIP files to install them into an instance.',
|
||||
},
|
||||
resourcePacksFormats: {
|
||||
id: 'drop.help.resource-packs-formats',
|
||||
defaultMessage: '.zip',
|
||||
},
|
||||
dataPacksTitle: {
|
||||
id: 'drop.help.data-packs-title',
|
||||
defaultMessage: 'Data Packs',
|
||||
},
|
||||
dataPacksDesc: {
|
||||
id: 'drop.help.data-packs-desc',
|
||||
defaultMessage: "Drag data pack ZIP files to install them into a world's datapacks folder.",
|
||||
},
|
||||
dataPacksFormats: {
|
||||
id: 'drop.help.data-packs-formats',
|
||||
defaultMessage: '.zip',
|
||||
},
|
||||
shaderPacksTitle: {
|
||||
id: 'drop.help.shader-packs-title',
|
||||
defaultMessage: 'Shader Packs',
|
||||
},
|
||||
shaderPacksDesc: {
|
||||
id: 'drop.help.shader-packs-desc',
|
||||
defaultMessage: 'Drag shader pack ZIP files to install them into an instance.',
|
||||
},
|
||||
shaderPacksFormats: {
|
||||
id: 'drop.help.shader-packs-formats',
|
||||
defaultMessage: '.zip',
|
||||
},
|
||||
worldsTitle: {
|
||||
id: 'drop.help.worlds-title',
|
||||
defaultMessage: 'World Saves',
|
||||
},
|
||||
worldsDesc: {
|
||||
id: 'drop.help.worlds-desc',
|
||||
defaultMessage: 'Drag world save folders to import them into an instance.',
|
||||
},
|
||||
worldsFormats: {
|
||||
id: 'drop.help.worlds-formats',
|
||||
defaultMessage: 'Minecraft world save folder (level.dat)',
|
||||
},
|
||||
schematicsTitle: {
|
||||
id: 'drop.help.schematics-title',
|
||||
defaultMessage: 'Schematics',
|
||||
},
|
||||
schematicsDesc: {
|
||||
id: 'drop.help.schematics-desc',
|
||||
defaultMessage: 'Drag .litematic files to import them as schematics.',
|
||||
},
|
||||
schematicsFormats: {
|
||||
id: 'drop.help.schematics-formats',
|
||||
defaultMessage: '.litematic',
|
||||
},
|
||||
})
|
||||
|
||||
interface HelpSection {
|
||||
key: string
|
||||
icon: Component
|
||||
titleMsg: { id: string; defaultMessage: string }
|
||||
descMsg: { id: string; defaultMessage: string }
|
||||
formatsMsg: { id: string; defaultMessage: string }
|
||||
}
|
||||
|
||||
const sections: HelpSection[] = [
|
||||
{
|
||||
key: 'instances',
|
||||
icon: FolderOpenIcon,
|
||||
titleMsg: messages.instancesTitle,
|
||||
descMsg: messages.instancesDesc,
|
||||
formatsMsg: messages.instancesFormats,
|
||||
},
|
||||
{
|
||||
key: 'mods',
|
||||
icon: PackageOpenIcon,
|
||||
titleMsg: messages.modsTitle,
|
||||
descMsg: messages.modsDesc,
|
||||
formatsMsg: messages.modsFormats,
|
||||
},
|
||||
{
|
||||
key: 'modpacks',
|
||||
icon: GridIcon,
|
||||
titleMsg: messages.modpacksTitle,
|
||||
descMsg: messages.modpacksDesc,
|
||||
formatsMsg: messages.modpacksFormats,
|
||||
},
|
||||
{
|
||||
key: 'resource-packs',
|
||||
icon: PaletteIcon,
|
||||
titleMsg: messages.resourcePacksTitle,
|
||||
descMsg: messages.resourcePacksDesc,
|
||||
formatsMsg: messages.resourcePacksFormats,
|
||||
},
|
||||
{
|
||||
key: 'data-packs',
|
||||
icon: BracesIcon,
|
||||
titleMsg: messages.dataPacksTitle,
|
||||
descMsg: messages.dataPacksDesc,
|
||||
formatsMsg: messages.dataPacksFormats,
|
||||
},
|
||||
{
|
||||
key: 'shader-packs',
|
||||
icon: SparklesIcon,
|
||||
titleMsg: messages.shaderPacksTitle,
|
||||
descMsg: messages.shaderPacksDesc,
|
||||
formatsMsg: messages.shaderPacksFormats,
|
||||
},
|
||||
{
|
||||
key: 'worlds',
|
||||
icon: MapIcon,
|
||||
titleMsg: messages.worldsTitle,
|
||||
descMsg: messages.worldsDesc,
|
||||
formatsMsg: messages.worldsFormats,
|
||||
},
|
||||
{
|
||||
key: 'schematics',
|
||||
icon: BookIcon,
|
||||
titleMsg: messages.schematicsTitle,
|
||||
descMsg: messages.schematicsDesc,
|
||||
formatsMsg: messages.schematicsFormats,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
1
apps/app-frontend/src/pages/help/index.ts
Normal file
1
apps/app-frontend/src/pages/help/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as DropHelp } from './DropHelp.vue'
|
||||
25
apps/app-frontend/src/pages/index.js
Normal file
25
apps/app-frontend/src/pages/index.js
Normal file
@ -0,0 +1,25 @@
|
||||
import Browse from './Browse.vue'
|
||||
import Downloads from './Downloads.vue'
|
||||
import Index from './Index.vue'
|
||||
import Lab from './Lab.vue'
|
||||
import LabGradientText from './LabGradientText.vue'
|
||||
import LabRecipeGenerator from './LabRecipeGenerator.vue'
|
||||
import LabSchematicPreview from './LabSchematicPreview.vue'
|
||||
import LabSeedMap from './LabSeedMap.vue'
|
||||
import Multiplayer from './Multiplayer.vue'
|
||||
import Skins from './Skins.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export {
|
||||
Browse,
|
||||
Downloads,
|
||||
Index,
|
||||
Lab,
|
||||
LabGradientText,
|
||||
LabRecipeGenerator,
|
||||
LabSchematicPreview,
|
||||
LabSeedMap,
|
||||
Multiplayer,
|
||||
Skins,
|
||||
Worlds,
|
||||
}
|
||||
1127
apps/app-frontend/src/pages/instance/FileStudio.vue
Normal file
1127
apps/app-frontend/src/pages/instance/FileStudio.vue
Normal file
File diff suppressed because it is too large
Load Diff
360
apps/app-frontend/src/pages/instance/Files.vue
Normal file
360
apps/app-frontend/src/pages/instance/Files.vue
Normal file
@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon, FileArchiveIcon } from '@modrinth/assets'
|
||||
import type { EditingFile, FileContextMenuOption, FileItem } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FilePageLayout,
|
||||
injectNotificationManager,
|
||||
provideFileManager,
|
||||
ReadyTransition,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import {
|
||||
mkdir,
|
||||
readDir,
|
||||
readFile as readFileBytes,
|
||||
readTextFile,
|
||||
remove,
|
||||
rename,
|
||||
stat,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/plugin-fs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { get_full_path } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: unknown
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
isServerInstance: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const debug = useDebugLogger('Files')
|
||||
const router = useRouter()
|
||||
|
||||
const messages = defineMessages({
|
||||
saveAs: {
|
||||
id: 'instance.files.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
addingFiles: {
|
||||
id: 'instance.files.adding-files',
|
||||
defaultMessage: 'Adding files ({completed}/{total})',
|
||||
},
|
||||
openInSchematicWorkshop: {
|
||||
id: 'instance.files.open-in-schematic-workshop',
|
||||
defaultMessage: 'Open in schematic workshop',
|
||||
},
|
||||
openStudio: {
|
||||
id: 'instance.files.open-studio',
|
||||
defaultMessage: 'Open Studio',
|
||||
},
|
||||
})
|
||||
|
||||
const instanceRoot = ref('')
|
||||
const items = ref<FileItem[]>([])
|
||||
/** True until the first directory read for the current instance path finishes (initial load only). */
|
||||
const firstPaintPending = ref(true)
|
||||
const loading = ref(true)
|
||||
const error = ref<Error | null>(null)
|
||||
const currentPath = ref('')
|
||||
const editingFile = ref<EditingFile | null>(null)
|
||||
|
||||
debug('setup: start, instance.id =', props.instance.id)
|
||||
|
||||
instanceRoot.value = await get_full_path(props.instance.id)
|
||||
debug('setup: instanceRoot =', instanceRoot.value)
|
||||
await refresh()
|
||||
debug('setup: refresh complete, items =', items.value.length, 'error =', error.value)
|
||||
|
||||
async function resolvePath(relativePath: string): Promise<string> {
|
||||
return relativePath ? join(instanceRoot.value, ...relativePath.split('/')) : instanceRoot.value
|
||||
}
|
||||
|
||||
async function listDirectory(dirPath: string): Promise<FileItem[]> {
|
||||
const absPath = await resolvePath(dirPath)
|
||||
debug('listDirectory: dirPath =', dirPath, 'absPath =', absPath)
|
||||
const entries = await readDir(absPath)
|
||||
debug('listDirectory: got', entries.length, 'entries')
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryAbsPath = await join(absPath, entry.name)
|
||||
let metadata
|
||||
try {
|
||||
metadata = await stat(entryAbsPath)
|
||||
} catch {
|
||||
debug('listDirectory: stat failed for', entry.name, '- skipping')
|
||||
return null
|
||||
}
|
||||
const item: FileItem = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory ? 'directory' : 'file',
|
||||
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : 0,
|
||||
created: metadata.birthtime ? Math.floor(metadata.birthtime.getTime() / 1000) : 0,
|
||||
}
|
||||
if (!entry.isDirectory) {
|
||||
item.size = metadata.size
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
try {
|
||||
const children = await readDir(entryAbsPath)
|
||||
item.count = children.length
|
||||
} catch {
|
||||
item.count = 0
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
)
|
||||
return results.filter((item): item is FileItem => item !== null)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
debug('refresh: called, currentPath =', currentPath.value, 'instanceRoot =', instanceRoot.value)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await listDirectory(currentPath.value)
|
||||
debug('refresh: success, items =', items.value.length)
|
||||
} catch (e) {
|
||||
debug('refresh: error =', e)
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
firstPaintPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
debug('navigateTo:', path)
|
||||
currentPath.value = path.startsWith('/') ? path.slice(1) : path
|
||||
refresh()
|
||||
}
|
||||
|
||||
function startEditing(file: EditingFile) {
|
||||
editingFile.value = file
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingFile.value = null
|
||||
}
|
||||
|
||||
async function handleCreateItem(name: string, type: 'file' | 'directory') {
|
||||
const targetPath = currentPath.value ? `${currentPath.value}/${name}` : name
|
||||
const absPath = await resolvePath(targetPath)
|
||||
try {
|
||||
if (type === 'directory') {
|
||||
await mkdir(absPath)
|
||||
} else {
|
||||
await writeTextFile(absPath, '')
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.createFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameItem(path: string, newName: string) {
|
||||
const oldAbs = await resolvePath(path)
|
||||
const parentDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''
|
||||
const newPath = parentDir ? `${parentDir}/${newName}` : newName
|
||||
const newAbs = await resolvePath(newPath)
|
||||
try {
|
||||
await rename(oldAbs, newAbs)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.renameFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveItem(source: string, destination: string) {
|
||||
try {
|
||||
await rename(await resolvePath(source), await resolvePath(destination))
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.moveFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(path: string, recursive: boolean) {
|
||||
try {
|
||||
await remove(await resolvePath(path), { recursive })
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.deleteFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReadFile(path: string): Promise<string> {
|
||||
return await readTextFile(await resolvePath(path))
|
||||
}
|
||||
|
||||
async function handleReadFileAsBlob(path: string): Promise<Blob> {
|
||||
const bytes = await readFileBytes(await resolvePath(path))
|
||||
return new Blob([bytes])
|
||||
}
|
||||
|
||||
async function handleWriteFile(path: string, content: string) {
|
||||
await writeTextFile(await resolvePath(path), content)
|
||||
}
|
||||
|
||||
async function handleDownloadFile(path: string, _fileName: string) {
|
||||
await invoke('plugin:files|file_save_as', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: path,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
|
||||
try {
|
||||
return await invoke('plugin:files|file_extract_zip', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: path,
|
||||
overrideConflicts: override,
|
||||
dryRun: dry,
|
||||
})
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.extractFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getAdditionalMenuOptions(
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'>,
|
||||
): FileContextMenuOption[] {
|
||||
if (item.type !== 'file' || !/\.(litematic|schem)$/i.test(item.name)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'open-in-schematic-workshop',
|
||||
label: formatMessage(messages.openInSchematicWorkshop),
|
||||
icon: FileArchiveIcon,
|
||||
action: () => {
|
||||
void router.push({
|
||||
name: 'Schematic workshop',
|
||||
query: { instance: props.instance.id, path: item.path },
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
debug('setup: registering instance_listener')
|
||||
const unlistenInstances = await instance_listener(
|
||||
async (event: { event: string; instance_id: string }) => {
|
||||
debug('instance_listener: event =', event.event, 'path =', event.instance_id)
|
||||
if (event.instance_id === props.instance.id && event.event === 'synced') {
|
||||
debug('instance_listener: synced event matched, calling refresh')
|
||||
await refresh()
|
||||
}
|
||||
},
|
||||
)
|
||||
debug('setup: instance_listener registered')
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenInstances()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance.id,
|
||||
async () => {
|
||||
debug('watch instance.id: changed to', props.instance.id)
|
||||
firstPaintPending.value = true
|
||||
instanceRoot.value = await get_full_path(props.instance.id)
|
||||
currentPath.value = ''
|
||||
await refresh()
|
||||
},
|
||||
)
|
||||
|
||||
provideFileManager({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
currentPath,
|
||||
navigateTo,
|
||||
editingFile,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
createItem: handleCreateItem,
|
||||
renameItem: handleRenameItem,
|
||||
moveItem: handleMoveItem,
|
||||
deleteItem: handleDeleteItem,
|
||||
readFile: handleReadFile,
|
||||
readFileAsBlob: handleReadFileAsBlob,
|
||||
writeFile: handleWriteFile,
|
||||
downloadFile: handleDownloadFile,
|
||||
extractFile: handleExtractFile,
|
||||
refresh,
|
||||
basePath: instanceRoot,
|
||||
openInFolder: (path: string) => highlightInFolder(path),
|
||||
getAdditionalMenuOptions,
|
||||
downloadButtonLabel: formatMessage(messages.saveAs),
|
||||
uploadingLabel: (completed: number, total: number) =>
|
||||
formatMessage(messages.addingFiles, { completed, total }),
|
||||
symlinkTarget: computed(() => props.instance.symlink_target),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReadyTransition :pending="firstPaintPending">
|
||||
<div class="flex flex-col gap-4">
|
||||
<FilePageLayout :show-refresh-button="true">
|
||||
<template #before-refresh>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
type="button"
|
||||
class="!h-10"
|
||||
@click="router.push({ name: 'FileStudio', params: { id: instance.id } })"
|
||||
>
|
||||
<CodeIcon class="size-5" />
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ formatMessage(messages.openStudio) }}
|
||||
<span
|
||||
class="rounded bg-orange px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none text-contrast"
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</FilePageLayout>
|
||||
</div>
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
1160
apps/app-frontend/src/pages/instance/Index.vue
Normal file
1160
apps/app-frontend/src/pages/instance/Index.vue
Normal file
File diff suppressed because it is too large
Load Diff
248
apps/app-frontend/src/pages/instance/Logs.vue
Normal file
248
apps/app-frontend/src/pages/instance/Logs.vue
Normal file
@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<ConsolePageLayout />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ConsolePageLayout,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
provideConsoleManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useCrashAnalysis } from '@/composables/useCrashAnalysis'
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { log_listener, process_listener } from '@/helpers/events.js'
|
||||
import {
|
||||
delete_logs_by_filename,
|
||||
export_crash_context,
|
||||
get_output_by_filename,
|
||||
} from '@/helpers/logs.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
liveLog: { id: 'instance.logs.source.live', defaultMessage: 'Live Log' },
|
||||
unknownLog: { id: 'instance.logs.source.unknown', defaultMessage: 'Unknown' },
|
||||
logName: { id: 'instance.logs.source.numbered', defaultMessage: 'Log {index}' },
|
||||
cannotDeleteLatest: {
|
||||
id: 'instance.logs.delete.latest-running',
|
||||
defaultMessage: 'Cannot delete latest.log while the instance is running',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const instanceId = computed(() => route.params.id)
|
||||
const {
|
||||
analysis: localCrashAnalysis,
|
||||
loading: crashAnalysisLoading,
|
||||
refresh: refreshCrashAnalysis,
|
||||
clear: clearCrashAnalysis,
|
||||
} = useCrashAnalysis(instanceId.value)
|
||||
const {
|
||||
liveConsole,
|
||||
historicalConsole,
|
||||
hydrate,
|
||||
getHistoricalLogs,
|
||||
getHistoricalContent,
|
||||
invalidate,
|
||||
clearLive,
|
||||
} = useInstanceConsole(instanceId.value)
|
||||
|
||||
await hydrate()
|
||||
|
||||
function buildLogList(rawLogs) {
|
||||
return [
|
||||
{ name: formatMessage(messages.liveLog), live: true },
|
||||
...rawLogs
|
||||
.filter(
|
||||
(log) =>
|
||||
log.filename !== 'latest_stdout.log' &&
|
||||
log.filename !== 'latest_stdout' &&
|
||||
log.filename !== 'launcher_log.txt' &&
|
||||
(log.output == null || log.output !== '') &&
|
||||
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
|
||||
)
|
||||
.map((log) => ({
|
||||
...log,
|
||||
name: log.filename || formatMessage(messages.unknownLog),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
const logs = ref(buildLogList([]))
|
||||
|
||||
void getHistoricalLogs()
|
||||
.then((allLogs) => {
|
||||
logs.value = buildLogList(allLogs)
|
||||
})
|
||||
.catch(handleError)
|
||||
|
||||
const selectedLogIndex = ref(0)
|
||||
const isLive = computed(() => selectedLogIndex.value === 0)
|
||||
|
||||
const filteredLogs = computed(() =>
|
||||
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
|
||||
)
|
||||
|
||||
const logSources = computed(() =>
|
||||
filteredLogs.value.map((l, i) => ({
|
||||
id: String(i),
|
||||
name: l?.name ?? formatMessage(messages.logName, { index: i }),
|
||||
live: l?.live ?? false,
|
||||
})),
|
||||
)
|
||||
|
||||
const activeConsole = computed(() => (isLive.value ? liveConsole : historicalConsole))
|
||||
|
||||
const logLines = shallowRef(activeConsole.value.output.value)
|
||||
watchEffect(() => {
|
||||
logLines.value = activeConsole.value.output.value
|
||||
triggerRef(logLines)
|
||||
})
|
||||
|
||||
async function analyseForCrash() {
|
||||
await refreshCrashAnalysis().catch((error) => {
|
||||
handleError(error)
|
||||
})
|
||||
}
|
||||
|
||||
async function exportCrashContext() {
|
||||
await export_crash_context(props.instance.id, props.instance.name).catch(handleError)
|
||||
}
|
||||
|
||||
const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
|
||||
|
||||
const deleteDisabled = computed(() => {
|
||||
const log = selectedLog.value
|
||||
if (!log || log.live) return true
|
||||
return log.filename === 'latest.log' && props.playing
|
||||
})
|
||||
|
||||
async function deleteSelectedLog() {
|
||||
const log = selectedLog.value
|
||||
if (!log || log.live) return
|
||||
await delete_logs_by_filename(props.instance.id, log.log_type, log.filename)
|
||||
invalidate()
|
||||
const freshLogs = await getHistoricalLogs()
|
||||
logs.value = buildLogList(freshLogs)
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
|
||||
provideConsoleManager({
|
||||
logLines,
|
||||
logSources,
|
||||
activeLogSourceIndex: selectedLogIndex,
|
||||
showCommandInput: false,
|
||||
loading: ref(false),
|
||||
onClear: () => {
|
||||
if (!isLive.value) return
|
||||
void clearLive()
|
||||
},
|
||||
onDelete: deleteSelectedLog,
|
||||
deleteDisabled,
|
||||
deleteDisabledTooltip: computed(() => formatMessage(messages.cannotDeleteLatest)),
|
||||
shareDisabled: computed(() => props.offline),
|
||||
emptyStateType: 'instance',
|
||||
localCrashAnalysis,
|
||||
crashAnalysisLoading,
|
||||
onExportCrashContext: exportCrashContext,
|
||||
})
|
||||
|
||||
watch(selectedLogIndex, async (newIndex) => {
|
||||
if (newIndex === 0) return
|
||||
const log = filteredLogs.value[newIndex]
|
||||
if (!log) return
|
||||
|
||||
const cached = getHistoricalContent(log.filename)
|
||||
if (cached) {
|
||||
historicalConsole.clear()
|
||||
await historicalConsole.addLegacyLog(cached)
|
||||
return
|
||||
}
|
||||
|
||||
const output = await get_output_by_filename(props.instance.id, log.log_type, log.filename).catch(
|
||||
handleError,
|
||||
)
|
||||
if (output) {
|
||||
historicalConsole.clear()
|
||||
await historicalConsole.addLegacyLog(output)
|
||||
}
|
||||
})
|
||||
|
||||
selectedLogIndex.value = 0
|
||||
|
||||
if (!props.playing) {
|
||||
void analyseForCrash()
|
||||
}
|
||||
|
||||
const unlistenLog = await log_listener((payload) => {
|
||||
if (payload.instance_id !== instanceId.value) return
|
||||
|
||||
if (payload.type === 'log4j') {
|
||||
liveConsole.addLog4jEvent(payload)
|
||||
} else if (payload.type === 'legacy') {
|
||||
void liveConsole.addLegacyLog(payload.message)
|
||||
}
|
||||
})
|
||||
|
||||
const unlistenProcesses = await process_listener(async (e) => {
|
||||
if (e.instance_id !== instanceId.value) return
|
||||
if (e.event === 'launched') {
|
||||
liveConsole.clear()
|
||||
clearCrashAnalysis()
|
||||
invalidate()
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
if (e.event === 'finished') {
|
||||
invalidate()
|
||||
const freshLogs = await getHistoricalLogs()
|
||||
logs.value = buildLogList(freshLogs)
|
||||
void analyseForCrash()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenLog()
|
||||
unlistenProcesses()
|
||||
})
|
||||
</script>
|
||||
3248
apps/app-frontend/src/pages/instance/Mods.vue
Normal file
3248
apps/app-frontend/src/pages/instance/Mods.vue
Normal file
File diff suppressed because it is too large
Load Diff
13
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
13
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>{{ instance.name }} overview</template>
|
||||
<script setup lang="ts">
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
}>()
|
||||
</script>
|
||||
772
apps/app-frontend/src/pages/instance/Screenshots.vue
Normal file
772
apps/app-frontend/src/pages/instance/Screenshots.vue
Normal file
@ -0,0 +1,772 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
ContractIcon,
|
||||
DownloadIcon,
|
||||
ExpandIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
LeftArrowIcon,
|
||||
RefreshCwIcon,
|
||||
RightArrowIcon,
|
||||
SearchIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
ReadyTransition,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import { exists, mkdir, readDir, readFile, remove, stat } from '@tauri-apps/plugin-fs'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import { get_full_path } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { highlightInFolder, openPath } from '@/helpers/utils'
|
||||
|
||||
interface Screenshot {
|
||||
name: string
|
||||
path: string
|
||||
url: string
|
||||
thumbnailUrl?: string
|
||||
thumbnailFailed?: boolean
|
||||
objectUrl?: string
|
||||
modified: Date
|
||||
size: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const formatDate = useFormatDateTime({
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'app.instance.screenshots.search-placeholder',
|
||||
defaultMessage: 'Search {count} screenshots...',
|
||||
},
|
||||
noScreenshots: {
|
||||
id: 'app.instance.screenshots.empty-title',
|
||||
defaultMessage: 'No screenshots yet',
|
||||
},
|
||||
noScreenshotsDescription: {
|
||||
id: 'app.instance.screenshots.empty-description',
|
||||
defaultMessage: 'Screenshots taken in Minecraft will appear here automatically.',
|
||||
},
|
||||
noSearchResults: {
|
||||
id: 'app.instance.screenshots.no-search-results',
|
||||
defaultMessage: 'No screenshots match your search.',
|
||||
},
|
||||
viewScreenshot: {
|
||||
id: 'app.instance.screenshots.view',
|
||||
defaultMessage: 'View screenshot',
|
||||
},
|
||||
copyScreenshot: {
|
||||
id: 'app.instance.screenshots.copy',
|
||||
defaultMessage: 'Copy image',
|
||||
},
|
||||
copiedScreenshot: {
|
||||
id: 'app.instance.screenshots.copied',
|
||||
defaultMessage: 'Screenshot copied',
|
||||
},
|
||||
copyFailed: {
|
||||
id: 'app.instance.screenshots.copy-failed',
|
||||
defaultMessage: 'Could not copy screenshot',
|
||||
},
|
||||
saveAs: {
|
||||
id: 'app.instance.screenshots.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
deleteScreenshot: {
|
||||
id: 'app.instance.screenshots.delete',
|
||||
defaultMessage: 'Delete screenshot',
|
||||
},
|
||||
deleteDescription: {
|
||||
id: 'app.instance.screenshots.delete-description',
|
||||
defaultMessage: 'Are you sure you want to permanently delete {name}?',
|
||||
},
|
||||
openScreenshotsFolder: {
|
||||
id: 'app.instance.screenshots.open-folder',
|
||||
defaultMessage: 'Open screenshots folder',
|
||||
},
|
||||
loadingFailed: {
|
||||
id: 'app.instance.screenshots.loading-failed',
|
||||
defaultMessage: 'Could not load screenshots',
|
||||
},
|
||||
deleteFailed: {
|
||||
id: 'app.instance.screenshots.delete-failed',
|
||||
defaultMessage: 'Could not delete screenshot',
|
||||
},
|
||||
zoomIn: {
|
||||
id: 'app.instance.screenshots.zoom-in',
|
||||
defaultMessage: 'View at full size',
|
||||
},
|
||||
zoomOut: {
|
||||
id: 'app.instance.screenshots.zoom-out',
|
||||
defaultMessage: 'Fit to window',
|
||||
},
|
||||
actionFailed: {
|
||||
id: 'app.instance.screenshots.action-failed',
|
||||
defaultMessage: 'Screenshot action failed',
|
||||
},
|
||||
})
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp'])
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
}
|
||||
const THUMBNAIL_MAX_DIMENSION = 1024
|
||||
const THUMBNAIL_CONCURRENCY = 2
|
||||
|
||||
const instanceRoot = ref('')
|
||||
const screenshots = ref<Screenshot[]>([])
|
||||
const loading = ref(true)
|
||||
const firstPaintPending = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const selectedScreenshot = ref<Screenshot | null>(null)
|
||||
const pendingDeletion = ref<Screenshot | null>(null)
|
||||
const zoomedIn = ref(false)
|
||||
const viewerModal = ref<InstanceType<typeof NewModal>>()
|
||||
const deleteModal = ref<InstanceType<typeof NewModal>>()
|
||||
const screenshotContextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
|
||||
const screenshotContextMenuOptions = [
|
||||
{ name: 'view_screenshot' },
|
||||
{ name: 'copy_screenshot' },
|
||||
{ name: 'save_screenshot' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'open_screenshot_folder' },
|
||||
{ name: 'copy_screenshot_filename' },
|
||||
{ name: 'copy_screenshot_path' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_screenshot', color: 'danger' },
|
||||
]
|
||||
|
||||
const screenshotsPath = ref('')
|
||||
const filteredScreenshots = computed(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase()
|
||||
if (!query) return screenshots.value
|
||||
return screenshots.value.filter((screenshot) =>
|
||||
screenshot.name.toLocaleLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
|
||||
function extensionOf(fileName: string): string {
|
||||
return fileName.split('.').pop()?.toLocaleLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function showError(title: string, error: unknown) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title,
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
function revokePreviewUrls(items = screenshots.value) {
|
||||
for (const screenshot of items) {
|
||||
if (screenshot.objectUrl) {
|
||||
URL.revokeObjectURL(screenshot.objectUrl)
|
||||
screenshot.objectUrl = undefined
|
||||
}
|
||||
if (screenshot.thumbnailUrl) {
|
||||
URL.revokeObjectURL(screenshot.thumbnailUrl)
|
||||
screenshot.thumbnailUrl = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let thumbnailGeneration = 0
|
||||
|
||||
async function loadScreenshotThumbnail(screenshot: Screenshot, generation: number) {
|
||||
if (screenshot.thumbnailUrl || screenshot.thumbnailFailed) return
|
||||
|
||||
try {
|
||||
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: `screenshots/${screenshot.name}`,
|
||||
maxDimension: THUMBNAIL_MAX_DIMENSION,
|
||||
})
|
||||
if (generation !== thumbnailGeneration) return
|
||||
screenshot.thumbnailUrl = URL.createObjectURL(new Blob([bytes]))
|
||||
} catch {
|
||||
screenshot.thumbnailFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnails(items: Screenshot[]) {
|
||||
const generation = thumbnailGeneration
|
||||
let nextIndex = 0
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < items.length) {
|
||||
await loadScreenshotThumbnail(items[nextIndex++], generation)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: THUMBNAIL_CONCURRENCY }, worker))
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview(screenshot: Screenshot) {
|
||||
if (screenshot.objectUrl) return
|
||||
|
||||
try {
|
||||
const bytes = await readFile(screenshot.path)
|
||||
const extension = extensionOf(screenshot.name)
|
||||
const objectUrl = URL.createObjectURL(
|
||||
new Blob([bytes], { type: MIME_TYPES[extension] ?? 'image/png' }),
|
||||
)
|
||||
screenshot.objectUrl = objectUrl
|
||||
screenshot.url = objectUrl
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.loadingFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
thumbnailGeneration += 1
|
||||
try {
|
||||
if (!(await exists(screenshotsPath.value))) {
|
||||
revokePreviewUrls()
|
||||
screenshots.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await readDir(screenshotsPath.value)
|
||||
const nextScreenshots = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.isDirectory && IMAGE_EXTENSIONS.has(extensionOf(entry.name)))
|
||||
.map(async (entry): Promise<Screenshot | null> => {
|
||||
const path = await join(screenshotsPath.value, entry.name)
|
||||
try {
|
||||
const metadata = await stat(path)
|
||||
return {
|
||||
name: entry.name,
|
||||
path,
|
||||
url: convertFileSrc(path),
|
||||
modified: metadata.mtime ?? new Date(0),
|
||||
size: metadata.size,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
revokePreviewUrls()
|
||||
screenshots.value = nextScreenshots
|
||||
.filter((screenshot): screenshot is Screenshot => screenshot !== null)
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
||||
void generateThumbnails(screenshots.value)
|
||||
} catch (error) {
|
||||
revokePreviewUrls()
|
||||
screenshots.value = []
|
||||
showError(formatMessage(messages.loadingFailed), error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
firstPaintPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewScreenshot(screenshot: Screenshot) {
|
||||
selectedScreenshot.value = screenshot
|
||||
zoomedIn.value = false
|
||||
viewerModal.value?.show()
|
||||
}
|
||||
|
||||
function changeScreenshot(offset: number) {
|
||||
if (!selectedScreenshot.value || screenshots.value.length < 2) return
|
||||
const currentIndex = screenshots.value.findIndex(
|
||||
(screenshot) => screenshot.path === selectedScreenshot.value?.path,
|
||||
)
|
||||
const nextIndex = (currentIndex + offset + screenshots.value.length) % screenshots.value.length
|
||||
selectedScreenshot.value = screenshots.value[nextIndex]
|
||||
zoomedIn.value = false
|
||||
}
|
||||
|
||||
async function imageToPng(blob: Blob): Promise<Blob> {
|
||||
if (blob.type === 'image/png') return blob
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
try {
|
||||
const image = new Image()
|
||||
image.src = objectUrl
|
||||
await image.decode()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
canvas.getContext('2d')?.drawImage(image, 0, 0)
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error('Image conversion failed'))),
|
||||
'image/png',
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyScreenshot(screenshot: Screenshot) {
|
||||
try {
|
||||
const bytes = await readFile(screenshot.path)
|
||||
const extension = extensionOf(screenshot.name)
|
||||
const blob = new Blob([bytes], { type: MIME_TYPES[extension] ?? 'image/png' })
|
||||
const png = await imageToPng(blob)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.copiedScreenshot),
|
||||
})
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.copyFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScreenshot(screenshot: Screenshot) {
|
||||
try {
|
||||
await invoke('plugin:files|file_save_as', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: `screenshots/${screenshot.name}`,
|
||||
})
|
||||
} catch (error) {
|
||||
showError(formatMessage(commonMessages.downloadFailedLabel), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function openScreenshotsFolder() {
|
||||
try {
|
||||
await mkdir(screenshotsPath.value, { recursive: true })
|
||||
await openPath(screenshotsPath.value)
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.loadingFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function showScreenshotInFolder(screenshot: Screenshot) {
|
||||
try {
|
||||
await highlightInFolder(screenshot.path)
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.actionFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyScreenshotText(value: string, successTitle: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: successTitle,
|
||||
})
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.copyFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
function showScreenshotContextMenu(event: MouseEvent, screenshot: Screenshot) {
|
||||
screenshotContextMenu.value?.showMenu(event, screenshot, screenshotContextMenuOptions)
|
||||
}
|
||||
|
||||
async function handleScreenshotContextMenu({ item, option }: { item: Screenshot; option: string }) {
|
||||
switch (option) {
|
||||
case 'view_screenshot':
|
||||
viewScreenshot(item)
|
||||
break
|
||||
case 'copy_screenshot':
|
||||
await copyScreenshot(item)
|
||||
break
|
||||
case 'save_screenshot':
|
||||
await saveScreenshot(item)
|
||||
break
|
||||
case 'open_screenshot_folder':
|
||||
await showScreenshotInFolder(item)
|
||||
break
|
||||
case 'copy_screenshot_filename':
|
||||
await copyScreenshotText(item.name, formatMessage(commonMessages.copiedFilenameLabel))
|
||||
break
|
||||
case 'copy_screenshot_path':
|
||||
await copyScreenshotText(item.path, formatMessage(commonMessages.copiedPathLabel))
|
||||
break
|
||||
case 'delete_screenshot':
|
||||
promptDelete(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function promptDelete(screenshot: Screenshot) {
|
||||
pendingDeletion.value = screenshot
|
||||
deleteModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const screenshot = pendingDeletion.value
|
||||
if (!screenshot) return
|
||||
|
||||
const deletedIndex = screenshots.value.findIndex((item) => item.path === screenshot.path)
|
||||
try {
|
||||
await remove(screenshot.path)
|
||||
deleteModal.value?.hide()
|
||||
pendingDeletion.value = null
|
||||
await refresh()
|
||||
|
||||
if (selectedScreenshot.value?.path === screenshot.path) {
|
||||
if (screenshots.value.length === 0) {
|
||||
viewerModal.value?.hide()
|
||||
selectedScreenshot.value = null
|
||||
} else {
|
||||
selectedScreenshot.value =
|
||||
screenshots.value[Math.min(deletedIndex, screenshots.value.length - 1)]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.deleteFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!selectedScreenshot.value) return
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
changeScreenshot(-1)
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
changeScreenshot(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize(instanceId: string) {
|
||||
firstPaintPending.value = true
|
||||
instanceRoot.value = await get_full_path(instanceId)
|
||||
screenshotsPath.value = await join(instanceRoot.value, 'screenshots')
|
||||
searchQuery.value = ''
|
||||
selectedScreenshot.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('focus', refresh)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('focus', refresh)
|
||||
thumbnailGeneration += 1
|
||||
revokePreviewUrls()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance.id,
|
||||
(instanceId) => initialize(instanceId),
|
||||
)
|
||||
|
||||
await initialize(props.instance.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReadyTransition :pending="firstPaintPending">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div v-if="screenshots.length > 0" class="relative min-w-64 flex-1 sm:max-w-md">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-secondary"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="h-10 w-full rounded-xl border border-solid border-surface-5 bg-surface-2 pl-10 pr-3 text-primary outline-none transition-colors focus:border-brand"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder, { count: screenshots.length })"
|
||||
/>
|
||||
</div>
|
||||
<div v-else />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="openScreenshotsFolder">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(messages.openScreenshotsFolder) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.refreshButton)"
|
||||
:disabled="loading"
|
||||
:aria-label="formatMessage(commonMessages.refreshButton)"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCwIcon :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredScreenshots.length > 0"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-4"
|
||||
>
|
||||
<article
|
||||
v-for="screenshot in filteredScreenshots"
|
||||
:key="screenshot.path"
|
||||
class="group overflow-hidden rounded-2xl border border-solid border-surface-5 bg-surface-2 transition-colors hover:border-brand"
|
||||
@contextmenu.prevent.stop="(event) => showScreenshotContextMenu(event, screenshot)"
|
||||
>
|
||||
<button
|
||||
class="relative block aspect-video w-full cursor-zoom-in overflow-hidden border-0 bg-surface-1 p-0"
|
||||
:aria-label="formatMessage(messages.viewScreenshot)"
|
||||
@click="viewScreenshot(screenshot)"
|
||||
>
|
||||
<img
|
||||
v-if="screenshot.thumbnailUrl || screenshot.thumbnailFailed"
|
||||
:src="screenshot.thumbnailUrl ?? screenshot.url"
|
||||
:alt="screenshot.name"
|
||||
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
@error="loadScreenshotPreview(screenshot)"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-semibold text-contrast" :title="screenshot.name">
|
||||
{{ screenshot.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-secondary">
|
||||
{{ formatDate(screenshot.modified) }} · {{ formatFileSize(screenshot.size) }}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.copyScreenshot)"
|
||||
:aria-label="formatMessage(messages.copyScreenshot)"
|
||||
@click="copyScreenshot(screenshot)"
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.openInFolderButton)"
|
||||
:aria-label="formatMessage(commonMessages.openInFolderButton)"
|
||||
@click="showScreenshotInFolder(screenshot)"
|
||||
>
|
||||
<FolderOpenIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent" color="red" color-fill="text">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteScreenshot)"
|
||||
:aria-label="formatMessage(messages.deleteScreenshot)"
|
||||
@click="promptDelete(screenshot)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="screenshots.length > 0"
|
||||
class="rounded-2xl border border-solid border-surface-5 bg-surface-2 p-8 text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noSearchResults) }}
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else
|
||||
type="no-images"
|
||||
:heading="formatMessage(messages.noScreenshots)"
|
||||
:description="formatMessage(messages.noScreenshotsDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled>
|
||||
<button @click="openScreenshotsFolder">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(messages.openScreenshotsFolder) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</ReadyTransition>
|
||||
|
||||
<NewModal
|
||||
ref="viewerModal"
|
||||
:max-width="'92rem'"
|
||||
:width="'calc(100vw - 4rem)'"
|
||||
:no-padding="true"
|
||||
:header="selectedScreenshot?.name"
|
||||
:on-hide="() => (selectedScreenshot = null)"
|
||||
>
|
||||
<div
|
||||
v-if="selectedScreenshot"
|
||||
class="relative flex w-full min-h-64 max-h-[calc(100vh-13rem)] items-center justify-center overflow-hidden bg-surface-1"
|
||||
>
|
||||
<div
|
||||
class="min-w-0 flex max-h-[calc(100vh-13rem)] w-full items-center justify-center overflow-auto p-4"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
zoomedIn
|
||||
? selectedScreenshot.url
|
||||
: (selectedScreenshot.thumbnailUrl ?? selectedScreenshot.url)
|
||||
"
|
||||
:alt="selectedScreenshot.name"
|
||||
:class="
|
||||
zoomedIn
|
||||
? 'max-w-none cursor-zoom-out'
|
||||
: 'max-h-[calc(100vh-15rem)] max-w-full cursor-zoom-in'
|
||||
"
|
||||
@click="zoomedIn = !zoomedIn"
|
||||
@error="loadScreenshotPreview(selectedScreenshot)"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => showScreenshotContextMenu(event, selectedScreenshot)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled v-if="screenshots.length > 1" circular>
|
||||
<button
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2"
|
||||
:aria-label="formatMessage(commonMessages.backButton)"
|
||||
@click="changeScreenshot(-1)"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="screenshots.length > 1" circular>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
:aria-label="formatMessage(commonMessages.nextButton)"
|
||||
@click="changeScreenshot(1)"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div v-if="selectedScreenshot" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-secondary">
|
||||
{{ formatDate(selectedScreenshot.modified) }} ·
|
||||
{{ formatFileSize(selectedScreenshot.size) }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="zoomedIn = !zoomedIn">
|
||||
<ContractIcon v-if="zoomedIn" />
|
||||
<ExpandIcon v-else />
|
||||
{{ formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="showScreenshotInFolder(selectedScreenshot)">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="saveScreenshot(selectedScreenshot)">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.saveAs) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="copyScreenshot(selectedScreenshot)">
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(messages.copyScreenshot) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" color-fill="text">
|
||||
<button @click="promptDelete(selectedScreenshot)">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<ContextMenu ref="screenshotContextMenu" @option-clicked="handleScreenshotContextMenu">
|
||||
<template #view_screenshot>
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewScreenshot) }}
|
||||
</template>
|
||||
<template #copy_screenshot>
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(messages.copyScreenshot) }}
|
||||
</template>
|
||||
<template #save_screenshot>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.saveAs) }}
|
||||
</template>
|
||||
<template #open_screenshot_folder>
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</template>
|
||||
<template #copy_screenshot_filename>
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}
|
||||
</template>
|
||||
<template #copy_screenshot_path>
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}
|
||||
</template>
|
||||
<template #delete_screenshot>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteScreenshot) }}
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<NewModal
|
||||
ref="deleteModal"
|
||||
fade="danger"
|
||||
:header="formatMessage(messages.deleteScreenshot)"
|
||||
:on-hide="() => (pendingDeletion = null)"
|
||||
max-width="32rem"
|
||||
>
|
||||
<p v-if="pendingDeletion" class="m-0 text-primary">
|
||||
{{ formatMessage(messages.deleteDescription, { name: pendingDeletion.name }) }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="deleteModal?.hide()">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirmDelete">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
775
apps/app-frontend/src/pages/instance/WorldEditor.vue
Normal file
775
apps/app-frontend/src/pages/instance/WorldEditor.vue
Normal file
@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<ModalWrapper ref="unsavedModal">
|
||||
<template #title>
|
||||
<span class="font-extrabold text-lg text-contrast">
|
||||
{{ formatMessage(messages.unsavedTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="w-[400px] max-w-full">
|
||||
<p class="m-0">{{ formatMessage(messages.unsavedBody) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirmLeave">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.leaveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="unsavedModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.stayButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<EmptyState
|
||||
v-if="loadError"
|
||||
type="error"
|
||||
:heading="formatMessage(messages.loadErrorHeading)"
|
||||
:description="loadError"
|
||||
/>
|
||||
<div v-else-if="data" class="flex flex-col gap-6 pb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="group relative">
|
||||
<Avatar :src="form.removeIcon ? undefined : data.icon" size="64px" />
|
||||
<Tooltip>
|
||||
<button
|
||||
v-if="data.icon && !form.removeIcon && !readonly"
|
||||
class="absolute inset-0 hidden cursor-pointer items-center justify-center rounded-xl border-none bg-black/60 text-white group-hover:flex"
|
||||
@click="form.removeIcon = true"
|
||||
>
|
||||
<UndoIcon class="size-5" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.resetIcon) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<h1 class="m-0 truncate text-2xl font-extrabold text-contrast">{{ data.name }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-secondary">
|
||||
<span v-if="data.version_name" class="rounded-full bg-button-bg px-2 py-0.5 font-medium">
|
||||
{{ data.version_name }}
|
||||
</span>
|
||||
<span v-if="data.modded" class="rounded-full bg-button-bg px-2 py-0.5 font-medium">
|
||||
{{ formatMessage(messages.moddedBadge) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="data.hardcore"
|
||||
class="rounded-full bg-bg-red px-2 py-0.5 font-medium text-red"
|
||||
>
|
||||
{{ formatMessage(messages.hardcoreBadge) }}
|
||||
</span>
|
||||
<span v-if="data.last_played">
|
||||
{{
|
||||
formatMessage(messages.lastPlayed, {
|
||||
ago: formatRelativeTime(dayjs(data.last_played).toISOString()),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Admonition v-if="readonly" type="warning" :header="formatMessage(messages.lockedHeading)">
|
||||
{{ formatMessage(messages.lockedBody) }}
|
||||
</Admonition>
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.basicSection) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="font-semibold text-contrast" for="world-name">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="world-name"
|
||||
v-model="form.name"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
autocomplete="off"
|
||||
:disabled="readonly"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<span v-if="nameError" class="text-sm text-red">
|
||||
{{ formatMessage(messages.nameRequired) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.gameModeLabel) }}
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.gameMode"
|
||||
name="world-game-mode"
|
||||
:options="GAME_MODE_OPTIONS"
|
||||
:display-name="gameModeLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.difficulty" class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.difficultyLabel) }}
|
||||
<span v-if="data.difficulty_locked" class="font-normal text-secondary">
|
||||
{{ formatMessage(messages.difficultyLockedHint) }}
|
||||
</span>
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.difficulty"
|
||||
name="world-difficulty"
|
||||
:options="DIFFICULTY_OPTIONS"
|
||||
:display-name="difficultyLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.allowCommands !== undefined" class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.allowCommandsLabel) }}
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.allowCommands"
|
||||
name="world-allow-commands"
|
||||
:options="BOOLEAN_OPTIONS"
|
||||
:display-name="booleanLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="form.seed !== undefined" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.seedSection) }}
|
||||
</h2>
|
||||
<Admonition type="warning" :header="formatMessage(messages.seedWarningHeading)">
|
||||
{{ formatMessage(messages.seedWarningBody) }}
|
||||
</Admonition>
|
||||
<div class="flex max-w-md flex-col gap-1.5">
|
||||
<StyledInput
|
||||
v-model="form.seed"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="font-mono"
|
||||
:disabled="readonly"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<span v-if="seedError" class="text-sm text-red">
|
||||
{{ formatMessage(messages.seedInvalid) }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data.game_rules.length > 0" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.gameRulesSection) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
v-model="ruleSearch"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchRulesPlaceholder, { count: data.game_rules.length })
|
||||
"
|
||||
wrapper-class="max-w-md"
|
||||
/>
|
||||
<div v-if="ruleGroups.length === 0" class="text-secondary">
|
||||
{{ formatMessage(messages.noRulesFound) }}
|
||||
</div>
|
||||
<div v-for="group in ruleGroups" :key="group.category" class="flex flex-col gap-2">
|
||||
<Accordion
|
||||
:open-by-default="false"
|
||||
:force-open="ruleSearchActive"
|
||||
class="min-w-0 overflow-hidden rounded-xl border border-solid border-surface-4 bg-bg-raised"
|
||||
button-class="group flex w-full cursor-pointer items-center gap-3 border-0 bg-transparent px-4 py-3 text-left"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="m-0 text-base font-bold text-contrast">{{ group.label }}</h3>
|
||||
</template>
|
||||
<div class="border-0 border-t border-solid border-surface-4">
|
||||
<div
|
||||
v-for="rule in group.rules"
|
||||
:key="rule.key"
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-solid border-surface-4 px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<span
|
||||
v-if="rule.modifiedFromDefault"
|
||||
class="size-2 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.modifiedFromDefault) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<span class="truncate font-medium text-contrast" :title="rule.key">
|
||||
{{ rule.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<ButtonStyled v-if="rule.canResetToDefault" type="transparent" size="small">
|
||||
<Tooltip>
|
||||
<button
|
||||
:disabled="readonly"
|
||||
@click="resetRuleToDefault(rule.key, rule.defaultValue)"
|
||||
>
|
||||
<UndoIcon />
|
||||
</button>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.resetRuleToDefault) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</ButtonStyled>
|
||||
<DropdownSelect
|
||||
v-if="rule.widget === 'boolean'"
|
||||
v-model="form.rules[rule.key]"
|
||||
:name="`gamerule-${rule.key}`"
|
||||
class="!w-36"
|
||||
:options="BOOLEAN_OPTIONS"
|
||||
:display-name="booleanLabel"
|
||||
:disabled="readonly"
|
||||
render-up
|
||||
/>
|
||||
<StyledInput
|
||||
v-else
|
||||
v-model="form.rules[rule.key]"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
:disabled="readonly"
|
||||
input-class="font-mono !h-9"
|
||||
wrapper-class="w-36"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="invalidRules.includes(rule.key)" class="w-full text-sm text-red">
|
||||
{{ formatMessage(messages.ruleInvalidInteger) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="dirty && !readonly"
|
||||
class="sticky bottom-0 z-10 -mx-2 flex flex-wrap items-center gap-3 rounded-t-xl border border-b-0 border-solid border-button-border bg-bg-raised px-4 py-3 shadow-lg"
|
||||
>
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.unsavedChangesLabel) }}
|
||||
</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSave" @click="save">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="saving" @click="discard">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.discardButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, SearchIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
EmptyState,
|
||||
GAME_MODES,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, type RouteLocationNormalized, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import {
|
||||
gameRuleCategoryMessages,
|
||||
getGameRuleMetadata,
|
||||
resolveGameRuleType,
|
||||
} from '@/components/ui/world/gameRuleRegistry.ts'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
get_world_level_data,
|
||||
reset_world_icon,
|
||||
type SingleplayerGameMode,
|
||||
update_world_settings,
|
||||
type WorldDifficulty,
|
||||
type WorldLevelData,
|
||||
type WorldSettingsPatch,
|
||||
} from '@/helpers/worlds.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options?: unknown
|
||||
offline?: boolean
|
||||
playing?: boolean
|
||||
installed?: boolean
|
||||
}>()
|
||||
|
||||
const worldPath = computed(() => decodeURIComponent(String(route.params.world ?? '')))
|
||||
|
||||
const GAME_MODE_OPTIONS: SingleplayerGameMode[] = ['survival', 'creative', 'adventure', 'spectator']
|
||||
const DIFFICULTY_OPTIONS: WorldDifficulty[] = ['peaceful', 'easy', 'normal', 'hard']
|
||||
const BOOLEAN_OPTIONS = ['true', 'false']
|
||||
|
||||
const RULE_CATEGORY_ORDER = [
|
||||
'player',
|
||||
'mobs',
|
||||
'drops',
|
||||
'world',
|
||||
'chat',
|
||||
'commands',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
gameMode: SingleplayerGameMode
|
||||
difficulty?: WorldDifficulty
|
||||
allowCommands?: string
|
||||
seed?: string
|
||||
rules: Record<string, string>
|
||||
removeIcon: boolean
|
||||
}
|
||||
|
||||
const data = ref<WorldLevelData>()
|
||||
const form = ref<FormState>(emptyForm())
|
||||
const savedState = ref<FormState>(emptyForm())
|
||||
const loadError = ref<string>()
|
||||
const saving = ref(false)
|
||||
const ruleSearch = ref('')
|
||||
|
||||
const ruleSearchActive = computed(() => ruleSearch.value.trim().length > 0)
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { name: '', gameMode: 'survival', rules: {}, removeIcon: false }
|
||||
}
|
||||
|
||||
function snapshotForm(level: WorldLevelData): FormState {
|
||||
return {
|
||||
name: level.name,
|
||||
gameMode: level.game_mode,
|
||||
difficulty: level.difficulty,
|
||||
allowCommands: level.allow_commands === undefined ? undefined : String(level.allow_commands),
|
||||
seed: level.seed,
|
||||
rules: Object.fromEntries(level.game_rules.map((rule) => [rule.key, rule.value])),
|
||||
removeIcon: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const level = await get_world_level_data(props.instance.id, worldPath.value)
|
||||
data.value = level
|
||||
form.value = snapshotForm(level)
|
||||
savedState.value = snapshotForm(level)
|
||||
loadError.value = undefined
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
await load()
|
||||
|
||||
watch(
|
||||
() => props.playing,
|
||||
(playing) => {
|
||||
if (!playing) {
|
||||
setTimeout(() => {
|
||||
if (!dirty.value) {
|
||||
load()
|
||||
} else if (data.value) {
|
||||
reloadLockedStateOnly()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function reloadLockedStateOnly() {
|
||||
try {
|
||||
const level = await get_world_level_data(props.instance.id, worldPath.value)
|
||||
if (data.value) {
|
||||
data.value.locked = level.locked
|
||||
}
|
||||
} catch {
|
||||
// Keep the current editor state when only the lock probe fails
|
||||
}
|
||||
}
|
||||
|
||||
const readonly = computed(() => data.value?.locked ?? true)
|
||||
|
||||
const dirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(savedState.value))
|
||||
|
||||
const nameError = computed(() => form.value.name.trim().length === 0)
|
||||
|
||||
const I64_MIN = -(2n ** 63n)
|
||||
const I64_MAX = 2n ** 63n - 1n
|
||||
|
||||
const seedError = computed(() => {
|
||||
if (form.value.seed === undefined || form.value.seed === savedState.value.seed) {
|
||||
return false
|
||||
}
|
||||
const trimmed = form.value.seed.trim()
|
||||
if (!/^[+-]?\d+$/.test(trimmed)) {
|
||||
return true
|
||||
}
|
||||
const value = BigInt(trimmed)
|
||||
return value < I64_MIN || value > I64_MAX
|
||||
})
|
||||
|
||||
const invalidRules = computed(() =>
|
||||
Object.entries(form.value.rules)
|
||||
.filter(([key, value]) => {
|
||||
if (value === savedState.value.rules[key]) {
|
||||
return false
|
||||
}
|
||||
const widget = resolveGameRuleType(savedState.value.rules[key] ?? value)
|
||||
return widget === 'integer' && !/^[+-]?\d+$/.test(value.trim())
|
||||
})
|
||||
.map(([key]) => key),
|
||||
)
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
dirty.value &&
|
||||
!saving.value &&
|
||||
!nameError.value &&
|
||||
!seedError.value &&
|
||||
invalidRules.value.length === 0,
|
||||
)
|
||||
|
||||
type RuleRow = {
|
||||
key: string
|
||||
label: string
|
||||
widget: 'boolean' | 'integer' | 'text'
|
||||
modifiedFromDefault: boolean
|
||||
canResetToDefault: boolean
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
const ruleGroups = computed(() => {
|
||||
if (!data.value) {
|
||||
return []
|
||||
}
|
||||
const query = ruleSearch.value.trim().toLowerCase()
|
||||
const grouped = new Map<string, RuleRow[]>()
|
||||
|
||||
for (const entry of data.value.game_rules) {
|
||||
const meta = getGameRuleMetadata(entry.key)
|
||||
const label = meta ? formatMessage(meta.name) : entry.key
|
||||
if (query && !label.toLowerCase().includes(query) && !entry.key.toLowerCase().includes(query)) {
|
||||
continue
|
||||
}
|
||||
const currentValue = form.value.rules[entry.key] ?? entry.value
|
||||
const category = meta?.category ?? 'other'
|
||||
const row: RuleRow = {
|
||||
key: entry.key,
|
||||
label,
|
||||
widget: resolveGameRuleType(entry.value),
|
||||
modifiedFromDefault: meta?.defaultValue !== undefined && currentValue !== meta.defaultValue,
|
||||
canResetToDefault: meta?.defaultValue !== undefined && currentValue !== meta.defaultValue,
|
||||
defaultValue: meta?.defaultValue,
|
||||
}
|
||||
const rows = grouped.get(category)
|
||||
if (rows) {
|
||||
rows.push(row)
|
||||
} else {
|
||||
grouped.set(category, [row])
|
||||
}
|
||||
}
|
||||
|
||||
return RULE_CATEGORY_ORDER.filter((category) => grouped.has(category)).map((category) => ({
|
||||
category,
|
||||
label: formatMessage(gameRuleCategoryMessages[category]),
|
||||
rules: grouped.get(category)!,
|
||||
}))
|
||||
})
|
||||
|
||||
function gameModeLabel(mode: SingleplayerGameMode) {
|
||||
return formatMessage(GAME_MODES[mode].message)
|
||||
}
|
||||
|
||||
function difficultyLabel(difficulty: WorldDifficulty) {
|
||||
return formatMessage(messages[`difficulty_${difficulty}`])
|
||||
}
|
||||
|
||||
function booleanLabel(value: string) {
|
||||
return formatMessage(value === 'true' ? messages.ruleEnabled : messages.ruleDisabled)
|
||||
}
|
||||
|
||||
function buildPatch(): WorldSettingsPatch {
|
||||
const patch: WorldSettingsPatch = {}
|
||||
const current = form.value
|
||||
const saved = savedState.value
|
||||
|
||||
if (current.name.trim() !== saved.name) {
|
||||
patch.name = current.name.trim()
|
||||
}
|
||||
if (current.gameMode !== saved.gameMode) {
|
||||
patch.game_mode = current.gameMode
|
||||
}
|
||||
if (current.difficulty && current.difficulty !== saved.difficulty) {
|
||||
patch.difficulty = current.difficulty
|
||||
}
|
||||
if (current.allowCommands !== undefined && current.allowCommands !== saved.allowCommands) {
|
||||
patch.allow_commands = current.allowCommands === 'true'
|
||||
}
|
||||
if (current.seed !== undefined && current.seed.trim() !== saved.seed) {
|
||||
patch.seed = current.seed.trim()
|
||||
}
|
||||
const changedRules = Object.entries(current.rules)
|
||||
.filter(([key, value]) => value !== saved.rules[key])
|
||||
.map(([key, value]) => ({ key, value: value.trim() }))
|
||||
if (changedRules.length > 0) {
|
||||
patch.game_rules = changedRules
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value || !data.value) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const patch = buildPatch()
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await update_world_settings(props.instance.id, worldPath.value, patch)
|
||||
}
|
||||
if (form.value.removeIcon && data.value.icon) {
|
||||
await reset_world_icon(props.instance.id, worldPath.value)
|
||||
}
|
||||
await load()
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', props.instance.id] })
|
||||
addNotification({
|
||||
title: formatMessage(messages.savedNotification),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = JSON.parse(JSON.stringify(savedState.value))
|
||||
}
|
||||
|
||||
function resetRuleToDefault(key: string, defaultValue?: string) {
|
||||
if (defaultValue !== undefined) {
|
||||
form.value.rules[key] = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
const unsavedModal = ref<InstanceType<typeof ModalWrapper>>()
|
||||
let allowLeave = false
|
||||
let pendingNavigation: RouteLocationNormalized | null = null
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (!dirty.value || readonly.value || allowLeave) {
|
||||
return true
|
||||
}
|
||||
pendingNavigation = to
|
||||
unsavedModal.value?.show()
|
||||
return false
|
||||
})
|
||||
|
||||
function confirmLeave() {
|
||||
allowLeave = true
|
||||
unsavedModal.value?.hide()
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
loadErrorHeading: {
|
||||
id: 'app.world-editor.load-error',
|
||||
defaultMessage: 'Failed to load world',
|
||||
},
|
||||
moddedBadge: {
|
||||
id: 'app.world-editor.badge.modded',
|
||||
defaultMessage: 'Modded',
|
||||
},
|
||||
hardcoreBadge: {
|
||||
id: 'app.world-editor.badge.hardcore',
|
||||
defaultMessage: 'Hardcore',
|
||||
},
|
||||
lastPlayed: {
|
||||
id: 'app.world-editor.last-played',
|
||||
defaultMessage: 'Last played {ago}',
|
||||
},
|
||||
lockedHeading: {
|
||||
id: 'app.world-editor.locked.heading',
|
||||
defaultMessage: 'World is in use',
|
||||
},
|
||||
lockedBody: {
|
||||
id: 'app.world-editor.locked.body',
|
||||
defaultMessage:
|
||||
'This world is currently open in Minecraft. Close the world before editing it — the editor is read-only until then.',
|
||||
},
|
||||
basicSection: {
|
||||
id: 'app.world-editor.section.basic',
|
||||
defaultMessage: 'Basic settings',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'app.world-editor.name.label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'app.world-editor.name.placeholder',
|
||||
defaultMessage: 'Minecraft World',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'app.world-editor.name.required',
|
||||
defaultMessage: 'The world name cannot be empty',
|
||||
},
|
||||
gameModeLabel: {
|
||||
id: 'app.world-editor.game-mode.label',
|
||||
defaultMessage: 'Game mode',
|
||||
},
|
||||
difficultyLabel: {
|
||||
id: 'app.world-editor.difficulty.label',
|
||||
defaultMessage: 'Difficulty',
|
||||
},
|
||||
difficultyLockedHint: {
|
||||
id: 'app.world-editor.difficulty.locked-hint',
|
||||
defaultMessage: '(locked in game)',
|
||||
},
|
||||
difficulty_peaceful: {
|
||||
id: 'app.world-editor.difficulty.peaceful',
|
||||
defaultMessage: 'Peaceful',
|
||||
},
|
||||
difficulty_easy: {
|
||||
id: 'app.world-editor.difficulty.easy',
|
||||
defaultMessage: 'Easy',
|
||||
},
|
||||
difficulty_normal: {
|
||||
id: 'app.world-editor.difficulty.normal',
|
||||
defaultMessage: 'Normal',
|
||||
},
|
||||
difficulty_hard: {
|
||||
id: 'app.world-editor.difficulty.hard',
|
||||
defaultMessage: 'Hard',
|
||||
},
|
||||
allowCommandsLabel: {
|
||||
id: 'app.world-editor.allow-commands.label',
|
||||
defaultMessage: 'Allow cheats',
|
||||
},
|
||||
seedSection: {
|
||||
id: 'app.world-editor.section.seed',
|
||||
defaultMessage: 'World seed',
|
||||
},
|
||||
seedWarningHeading: {
|
||||
id: 'app.world-editor.seed.warning-heading',
|
||||
defaultMessage: 'Changing the seed only affects new terrain',
|
||||
},
|
||||
seedWarningBody: {
|
||||
id: 'app.world-editor.seed.warning-body',
|
||||
defaultMessage:
|
||||
'Chunks that have already been generated will not be regenerated, which can create visible borders between old and new terrain.',
|
||||
},
|
||||
seedInvalid: {
|
||||
id: 'app.world-editor.seed.invalid',
|
||||
defaultMessage: 'The seed must be a whole number in the 64-bit integer range',
|
||||
},
|
||||
gameRulesSection: {
|
||||
id: 'app.world-editor.section.game-rules',
|
||||
defaultMessage: 'Game rules',
|
||||
},
|
||||
searchRulesPlaceholder: {
|
||||
id: 'app.world-editor.game-rules.search-placeholder',
|
||||
defaultMessage: 'Search {count} game rules...',
|
||||
},
|
||||
noRulesFound: {
|
||||
id: 'app.world-editor.game-rules.no-results',
|
||||
defaultMessage: 'No game rules match your search',
|
||||
},
|
||||
modifiedFromDefault: {
|
||||
id: 'app.world-editor.game-rules.modified',
|
||||
defaultMessage: 'Differs from the vanilla default',
|
||||
},
|
||||
resetRuleToDefault: {
|
||||
id: 'app.world-editor.game-rules.reset-to-default',
|
||||
defaultMessage: 'Reset to default',
|
||||
},
|
||||
ruleEnabled: {
|
||||
id: 'app.world-editor.game-rules.enabled',
|
||||
defaultMessage: 'Enabled',
|
||||
},
|
||||
ruleDisabled: {
|
||||
id: 'app.world-editor.game-rules.disabled',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
ruleInvalidInteger: {
|
||||
id: 'app.world-editor.game-rules.invalid-integer',
|
||||
defaultMessage: 'This rule requires a whole number',
|
||||
},
|
||||
resetIcon: {
|
||||
id: 'app.world-editor.reset-icon',
|
||||
defaultMessage: 'Reset icon',
|
||||
},
|
||||
unsavedChangesLabel: {
|
||||
id: 'app.world-editor.unsaved-changes',
|
||||
defaultMessage: 'You have unsaved changes',
|
||||
},
|
||||
discardButton: {
|
||||
id: 'app.world-editor.discard',
|
||||
defaultMessage: 'Discard changes',
|
||||
},
|
||||
savedNotification: {
|
||||
id: 'app.world-editor.saved',
|
||||
defaultMessage: 'World settings saved',
|
||||
},
|
||||
unsavedTitle: {
|
||||
id: 'app.world-editor.unsaved-modal.title',
|
||||
defaultMessage: 'Discard unsaved changes?',
|
||||
},
|
||||
unsavedBody: {
|
||||
id: 'app.world-editor.unsaved-modal.body',
|
||||
defaultMessage: 'Your changes to this world have not been saved and will be lost if you leave.',
|
||||
},
|
||||
leaveButton: {
|
||||
id: 'app.world-editor.unsaved-modal.leave',
|
||||
defaultMessage: 'Discard and leave',
|
||||
},
|
||||
stayButton: {
|
||||
id: 'app.world-editor.unsaved-modal.stay',
|
||||
defaultMessage: 'Keep editing',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
775
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
775
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<AddServerModal
|
||||
ref="addServerModal"
|
||||
:instance="instance"
|
||||
@submit="
|
||||
(server, start) => {
|
||||
addServer(server)
|
||||
if (start) {
|
||||
joinWorld(server)
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
|
||||
<ConfirmRemoveWorldModal
|
||||
ref="removeWorldModal"
|
||||
:world="worldToRemove"
|
||||
:symlink-target="instance.symlink_target"
|
||||
@confirm="proceedRemoveWorld"
|
||||
/>
|
||||
<ReadyTransition :pending="worldsReadyPending">
|
||||
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
|
||||
"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({
|
||||
path: '/browse/world',
|
||||
query: { i: instance.id, from: 'world-maps' },
|
||||
})
|
||||
"
|
||||
>
|
||||
<WorldIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseMaps) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<FilterIcon class="size-5 text-secondary" />
|
||||
<button
|
||||
:class="filterPillClass(selectedFilters.length === 0)"
|
||||
@click="selectedFilters = []"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
:class="filterPillClass(selectedFilters.includes(option.id))"
|
||||
@click="toggleFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<WorldItem
|
||||
v-for="world in filteredWorlds"
|
||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||
:world="world"
|
||||
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
|
||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||
:supports-server-quick-play="supportsServerQuickPlay"
|
||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||
:current-protocol="protocolVersion"
|
||||
:playing-instance="playing"
|
||||
:playing-world="worldsMatch(world, worldPlaying)"
|
||||
:starting-instance="startingInstance"
|
||||
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
|
||||
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
|
||||
:rendered-motd="
|
||||
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
|
||||
"
|
||||
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
||||
:shortcut-instance-id="instance.id"
|
||||
@update="refreshAllWorlds"
|
||||
@play="() => joinWorld(world)"
|
||||
@stop="() => emit('stop')"
|
||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||
@edit="
|
||||
() =>
|
||||
world.type === 'singleplayer'
|
||||
? router.push(
|
||||
`/instance/${encodeURIComponent(instance.id)}/worlds/${encodeURIComponent(world.path)}/edit`,
|
||||
)
|
||||
: isManagedServerWorld(world)
|
||||
? undefined
|
||||
: editServerModal?.show(world)
|
||||
"
|
||||
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
|
||||
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.id, world.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
type="empty-inbox"
|
||||
:heading="formatMessage(messages.noWorldsHeading)"
|
||||
:description="formatMessage(messages.noWorldsDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/world', query: { i: instance.id, from: 'world-maps' } })
|
||||
"
|
||||
>
|
||||
<WorldIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseMaps) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CompassIcon,
|
||||
FilterIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
GAME_MODES,
|
||||
type GameVersion,
|
||||
injectNotificationManager,
|
||||
ReadyTransition,
|
||||
StyledInput,
|
||||
useReadyState,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWorldModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_project_v3 } from '@/helpers/cache.js'
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
|
||||
import {
|
||||
delete_world,
|
||||
get_instance_protocol_version,
|
||||
getServerDomainKey,
|
||||
getWorldIdentifier,
|
||||
handleDefaultInstanceUpdateEvent,
|
||||
hasServerQuickPlaySupport,
|
||||
hasWorldQuickPlaySupport,
|
||||
type InstanceEvent,
|
||||
normalizeServerAddress,
|
||||
type ProtocolVersion,
|
||||
refreshServerData,
|
||||
refreshServers,
|
||||
refreshWorld,
|
||||
refreshWorlds,
|
||||
remove_server_from_instance,
|
||||
resolveManagedServerWorld,
|
||||
type ServerData,
|
||||
type ServerWorld,
|
||||
showWorldInFolder,
|
||||
type SingleplayerWorld,
|
||||
sortWorlds,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
type World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const messages = defineMessages({
|
||||
searchWorldsPlaceholder: {
|
||||
id: 'app.instance.worlds.search-worlds-placeholder',
|
||||
defaultMessage: 'Search {count} worlds...',
|
||||
},
|
||||
addServer: {
|
||||
id: 'app.instance.worlds.add-server',
|
||||
defaultMessage: 'Add server',
|
||||
},
|
||||
browseServers: {
|
||||
id: 'app.instance.worlds.browse-servers',
|
||||
defaultMessage: 'Browse servers',
|
||||
},
|
||||
browseMaps: {
|
||||
id: 'app.instance.worlds.browse-maps',
|
||||
defaultMessage: 'Browse maps',
|
||||
},
|
||||
noWorldsHeading: {
|
||||
id: 'app.instance.worlds.no-worlds-heading',
|
||||
defaultMessage: 'No servers or worlds added',
|
||||
},
|
||||
noWorldsDescription: {
|
||||
id: 'app.instance.worlds.no-worlds-description',
|
||||
defaultMessage: 'Add a server or browse to get started',
|
||||
},
|
||||
vanillaFilter: {
|
||||
id: 'app.instance.worlds.filter-vanilla',
|
||||
defaultMessage: 'Vanilla',
|
||||
},
|
||||
moddedFilter: {
|
||||
id: 'app.instance.worlds.filter-modded',
|
||||
defaultMessage: 'Modded',
|
||||
},
|
||||
onlineFilter: {
|
||||
id: 'app.instance.worlds.filter-online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offlineFilter: {
|
||||
id: 'app.instance.worlds.filter-offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const { playServerProject } = injectServerInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||
const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
|
||||
|
||||
const worldToRemove = ref<World | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'play', world: World): void
|
||||
(event: 'stop'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu> | null
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
}>()
|
||||
|
||||
const instance = computed(() => props.instance)
|
||||
const playing = computed(() => props.playing)
|
||||
|
||||
function play(world: World) {
|
||||
emit('play', world)
|
||||
}
|
||||
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const searchFilter = ref('')
|
||||
|
||||
function filterPillClass(isActive: boolean) {
|
||||
return [
|
||||
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
|
||||
isActive
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
|
||||
]
|
||||
}
|
||||
|
||||
function toggleFilter(id: string) {
|
||||
const idx = selectedFilters.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
selectedFilters.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedFilters.value.push(id)
|
||||
if (id === 'singleplayer') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'online' && f !== 'offline')
|
||||
} else if (id === 'online' || id === 'offline') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'singleplayer')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
const hadNoWorlds = ref(true)
|
||||
const startingInstance = ref(false)
|
||||
const worldPlaying = ref<World>()
|
||||
|
||||
const worldsQuery = useQuery({
|
||||
queryKey: computed(() => ['worlds', instance.value.id]),
|
||||
queryFn: () => refreshWorlds(instance.value.id),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const worldsReadyPending = useReadyState(worldsQuery)
|
||||
|
||||
const worlds = ref<World[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
|
||||
// Track servers_updated calls on Linux to prevent server ping spam
|
||||
const MAX_LINUX_REFRESHES = 3
|
||||
const isLinux = platform() === 'linux'
|
||||
const linuxRefreshCount = ref(0)
|
||||
|
||||
const protocolVersion = ref<ProtocolVersion | null>(null)
|
||||
const protocolVersionReady = ref(false)
|
||||
|
||||
const gameVersions = ref<GameVersion[]>([])
|
||||
const supportsServerQuickPlay = computed(() =>
|
||||
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
const supportsWorldQuickPlay = computed(() =>
|
||||
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => worldsQuery.data.value,
|
||||
(data) => {
|
||||
if (data) {
|
||||
worlds.value = [...data]
|
||||
hadNoWorlds.value = worlds.value.length === 0
|
||||
if (!refreshingAll.value) {
|
||||
void refreshServers(
|
||||
worlds.value,
|
||||
serverData.value,
|
||||
protocolVersion.value,
|
||||
protocolVersionReady.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const managedServerName = ref<string | null>(null)
|
||||
const managedServerAddress = ref<string | null>(null)
|
||||
|
||||
const managedServerWorld = computed(() =>
|
||||
resolveManagedServerWorld(worlds.value, managedServerName.value, managedServerAddress.value),
|
||||
)
|
||||
|
||||
function isManagedServerWorld(world: World): world is ServerWorld {
|
||||
return world.type === 'server' && managedServerWorld.value?.index === world.index
|
||||
}
|
||||
|
||||
async function refreshManagedServerMetadata() {
|
||||
await ensureManagedServerWorldExists(
|
||||
instance.value.id,
|
||||
managedServerName.value,
|
||||
managedServerAddress.value,
|
||||
)
|
||||
|
||||
const projectId = instance.value.link?.project_id
|
||||
if (!projectId) {
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const [project, projectV3] = await Promise.all([
|
||||
get_project(projectId),
|
||||
get_project_v3(projectId),
|
||||
])
|
||||
|
||||
if (projectV3?.minecraft_server == null) {
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const serverAddress = getServerAddress(projectV3.minecraft_java_server)
|
||||
if (!serverAddress) {
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
return
|
||||
}
|
||||
|
||||
managedServerName.value = project.title
|
||||
managedServerAddress.value = serverAddress
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to resolve managed server metadata for instance: ${instance.value.id}`,
|
||||
err,
|
||||
)
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance.value.link?.project_id,
|
||||
async () => {
|
||||
await refreshManagedServerMetadata()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
let unlistenInstance: (() => void) | null = null
|
||||
let worldsTabAlive = true
|
||||
|
||||
async function initWorldsTab() {
|
||||
const [_unlistenInstance, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
|
||||
instance_listener(async (e: InstanceEvent) => {
|
||||
if (e.instance_id !== instance.value.id) return
|
||||
|
||||
console.info(`Handling instance event '${e.event}' for instance: ${e.instance_id}`)
|
||||
|
||||
if (e.event === 'servers_updated') {
|
||||
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
|
||||
if (isLinux) linuxRefreshCount.value++
|
||||
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
|
||||
await handleDefaultInstanceUpdateEvent(worlds.value, instance.value.id, e)
|
||||
}),
|
||||
get_instance_protocol_version(instance.value.id).catch(() => null),
|
||||
get_game_versions().catch(() => [] as GameVersion[]),
|
||||
])
|
||||
|
||||
if (!worldsTabAlive) {
|
||||
_unlistenInstance()
|
||||
return
|
||||
}
|
||||
|
||||
unlistenInstance = _unlistenInstance
|
||||
protocolVersion.value = resolvedProtocolVersion
|
||||
gameVersions.value = resolvedGameVersions
|
||||
protocolVersionReady.value = true
|
||||
|
||||
if (worlds.value.length > 0) {
|
||||
void refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
}
|
||||
}
|
||||
|
||||
await initWorldsTab()
|
||||
|
||||
async function refreshServer(address: string) {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
}
|
||||
if (!protocolVersionReady.value) return
|
||||
await refreshServerData(serverData.value[address], protocolVersion.value, address)
|
||||
}
|
||||
|
||||
async function refreshAllWorlds() {
|
||||
if (refreshingAll.value) {
|
||||
console.log(`Already refreshing, cancelling refresh.`)
|
||||
return
|
||||
}
|
||||
|
||||
refreshingAll.value = true
|
||||
try {
|
||||
for (const world of worlds.value) {
|
||||
if (world.type === 'server') {
|
||||
if (!serverData.value[world.address]) {
|
||||
serverData.value[world.address] = { refreshing: true }
|
||||
} else {
|
||||
serverData.value[world.address].refreshing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
|
||||
await refreshServers(
|
||||
worlds.value,
|
||||
serverData.value,
|
||||
protocolVersion.value,
|
||||
protocolVersionReady.value,
|
||||
)
|
||||
} finally {
|
||||
refreshingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addServer(server: ServerWorld) {
|
||||
worlds.value.push(server)
|
||||
sortWorlds(worlds.value)
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
|
||||
async function editServer(server: ServerWorld) {
|
||||
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
|
||||
if (index !== -1) {
|
||||
const oldServer = worlds.value[index] as ServerWorld
|
||||
worlds.value[index] = server
|
||||
sortWorlds(worlds.value)
|
||||
if (oldServer.address !== server.address) {
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
} else {
|
||||
handleError(new Error(`Error refreshing server, refreshing all worlds`))
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(server: ServerWorld) {
|
||||
await remove_server_from_instance(instance.value.id, server.index).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
|
||||
let serverIdx = 0
|
||||
for (const w of worlds.value) {
|
||||
if (w.type === 'server') {
|
||||
w.index = serverIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorld(world: SingleplayerWorld) {
|
||||
await delete_world(instance.value.id, world.path).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
|
||||
}
|
||||
|
||||
async function handleJoinError(err: Error) {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: instance.value.id,
|
||||
instance_name: instance.value.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: instance.value.id })
|
||||
startingInstance.value = false
|
||||
worldPlaying.value = undefined
|
||||
}
|
||||
|
||||
async function joinWorld(world: World) {
|
||||
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||
startingInstance.value = true
|
||||
worldPlaying.value = world
|
||||
if (world.type === 'server') {
|
||||
const managedProjectId = instance.value.link?.project_id
|
||||
if (managedProjectId && isManagedServerWorld(world)) {
|
||||
await playServerProject(managedProjectId).catch(handleJoinError)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: 'WorldsPage',
|
||||
})
|
||||
startingInstance.value = false
|
||||
return
|
||||
}
|
||||
await start_join_server(instance.value.id, world.address).catch(handleJoinError)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: 'WorldsPage',
|
||||
})
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
|
||||
}
|
||||
play(world)
|
||||
startingInstance.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => playing.value,
|
||||
(playing) => {
|
||||
if (!playing) {
|
||||
worldPlaying.value = undefined
|
||||
|
||||
setTimeout(async () => {
|
||||
for (const world of worlds.value) {
|
||||
if (world.type === 'singleplayer' && world.locked) {
|
||||
await refreshWorld(worlds.value, instance.value.id, world.path)
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function worldsMatch(world: World, other: World | undefined) {
|
||||
if (world.type === 'server' && other?.type === 'server') {
|
||||
return world.address === other.address
|
||||
} else if (world.type === 'singleplayer' && other?.type === 'singleplayer') {
|
||||
return world.path === other.path
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const dedupedWorlds = computed(() => {
|
||||
const visibleWorlds: World[] = []
|
||||
const serverIndexByDomain = new Map<string, number>()
|
||||
|
||||
for (const world of worlds.value) {
|
||||
if (world.type !== 'server') {
|
||||
visibleWorlds.push(world)
|
||||
continue
|
||||
}
|
||||
|
||||
const domainKey =
|
||||
getServerDomainKey(world.address) ||
|
||||
normalizeServerAddress(world.address) ||
|
||||
`server-${world.index}`
|
||||
const existingIndex = serverIndexByDomain.get(domainKey)
|
||||
|
||||
if (existingIndex == null) {
|
||||
serverIndexByDomain.set(domainKey, visibleWorlds.length)
|
||||
visibleWorlds.push(world)
|
||||
continue
|
||||
}
|
||||
|
||||
// replace world with managed world if applicable
|
||||
const existingWorld = visibleWorlds[existingIndex]
|
||||
if (
|
||||
existingWorld?.type === 'server' &&
|
||||
!isManagedServerWorld(existingWorld) &&
|
||||
isManagedServerWorld(world)
|
||||
) {
|
||||
visibleWorlds[existingIndex] = world
|
||||
}
|
||||
}
|
||||
|
||||
return visibleWorlds
|
||||
})
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options: { id: string; label: string }[] = []
|
||||
const hasSingleplayer = dedupedWorlds.value.some((x) => x.type === 'singleplayer')
|
||||
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
|
||||
|
||||
if (hasSingleplayer && hasServer) {
|
||||
options.push({ id: 'singleplayer', label: formatMessage(commonMessages.singleplayerLabel) })
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
const servers = dedupedWorlds.value.filter((x) => x.type === 'server')
|
||||
const hasVanilla = servers.some((x) => x.content_kind !== 'modpack')
|
||||
const hasModded = servers.some((x) => x.content_kind === 'modpack')
|
||||
if (hasVanilla && hasModded) {
|
||||
options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
|
||||
options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
|
||||
}
|
||||
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
|
||||
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
|
||||
if (hasOnline && hasOffline) {
|
||||
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
|
||||
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
watch(filterOptions, (options) => {
|
||||
const validIds = new Set(options.map((opt) => opt.id))
|
||||
const cleaned = selectedFilters.value.filter((f) => validIds.has(f))
|
||||
if (cleaned.length !== selectedFilters.value.length) {
|
||||
selectedFilters.value = cleaned
|
||||
}
|
||||
})
|
||||
|
||||
const filteredWorlds = computed(() =>
|
||||
dedupedWorlds.value.filter((x) => {
|
||||
if (searchFilter.value && !x.name.toLowerCase().includes(searchFilter.value.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedFilters.value.length === 0) return true
|
||||
|
||||
const hasSingleplayerFilter = selectedFilters.value.includes('singleplayer')
|
||||
const typeFilters = selectedFilters.value.filter((f) => f === 'vanilla' || f === 'modded')
|
||||
const statusFilters = selectedFilters.value.filter((f) => f === 'online' || f === 'offline')
|
||||
|
||||
if (x.type === 'singleplayer') {
|
||||
return hasSingleplayerFilter || (typeFilters.length === 0 && statusFilters.length === 0)
|
||||
}
|
||||
|
||||
if (hasSingleplayerFilter && typeFilters.length === 0 && statusFilters.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let passesType = true
|
||||
if (typeFilters.length > 0) {
|
||||
const isModded = x.content_kind === 'modpack'
|
||||
passesType =
|
||||
(typeFilters.includes('modded') && isModded) ||
|
||||
(typeFilters.includes('vanilla') && !isModded)
|
||||
}
|
||||
|
||||
let passesStatus = true
|
||||
if (statusFilters.length > 0) {
|
||||
const isOnline = !!serverData.value[x.address]?.status
|
||||
passesStatus =
|
||||
(statusFilters.includes('online') && isOnline) ||
|
||||
(statusFilters.includes('offline') && !isOnline)
|
||||
}
|
||||
|
||||
return passesType && passesStatus
|
||||
}),
|
||||
)
|
||||
|
||||
const highlightedWorld = ref(route.query.highlight)
|
||||
|
||||
function promptToRemoveWorld(world: World): boolean {
|
||||
worldToRemove.value = world
|
||||
removeWorldModal.value?.show()
|
||||
return !!removeWorldModal.value
|
||||
}
|
||||
|
||||
async function proceedRemoveWorld(world: World) {
|
||||
if (world.type === 'server') {
|
||||
await removeServer(world)
|
||||
} else {
|
||||
await deleteWorld(world)
|
||||
}
|
||||
worldToRemove.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
worldsTabAlive = false
|
||||
unlistenInstance?.()
|
||||
})
|
||||
</script>
|
||||
10
apps/app-frontend/src/pages/instance/index.js
Normal file
10
apps/app-frontend/src/pages/instance/index.js
Normal file
@ -0,0 +1,10 @@
|
||||
import Files from './Files.vue'
|
||||
import Index from './Index.vue'
|
||||
import Logs from './Logs.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Screenshots from './Screenshots.vue'
|
||||
import WorldEditor from './WorldEditor.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Files, Index, Logs, Mods, Overview, Screenshots, WorldEditor, Worlds }
|
||||
1110
apps/app-frontend/src/pages/instance/upgrade/Compatibility.vue
Normal file
1110
apps/app-frontend/src/pages/instance/upgrade/Compatibility.vue
Normal file
File diff suppressed because it is too large
Load Diff
819
apps/app-frontend/src/pages/instance/upgrade/Confirm.vue
Normal file
819
apps/app-frontend/src/pages/instance/upgrade/Confirm.vue
Normal file
@ -0,0 +1,819 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header>
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-0 mt-1 text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
</header>
|
||||
|
||||
<Admonition v-if="executionError" type="critical" :header="formatMessage(messages.startError)">
|
||||
{{ executionError }}
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="warning" :header="formatMessage(messages.worldTitle)">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>{{ formatMessage(messages.worldBody) }}</span>
|
||||
<span>{{ formatMessage(messages.datapackNote, { path: datapackPath }) }}</span>
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.environment) }}
|
||||
</h3>
|
||||
<div class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<span class="text-secondary">Minecraft</span>
|
||||
<strong
|
||||
>{{ plan.sourceEnvironment.gameVersion }} <span aria-hidden="true">→</span>
|
||||
{{ plan.targetEnvironment.gameVersion }}</strong
|
||||
>
|
||||
<span class="text-secondary">{{ formatMessage(messages.loader) }}</span>
|
||||
<strong>{{ sourceLoader }} <span aria-hidden="true">→</span> {{ targetLoader }}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.strategy) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-3 text-lg font-semibold text-brand">{{ strategyLabel }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-px overflow-hidden rounded-md bg-divider sm:grid-cols-3 lg:grid-cols-6"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="bg-surface-2 p-3">
|
||||
<div class="text-xl font-semibold text-contrast">{{ metric.value }}</div>
|
||||
<div class="text-sm text-secondary">{{ metric.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="sharedInstance" class="order-first flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.sharedTitle) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.sharedDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-solid p-4 text-left"
|
||||
:class="modeClass('direct')"
|
||||
@click="selectMode('direct')"
|
||||
>
|
||||
<strong class="text-contrast">{{ formatMessage(messages.direct) }}</strong>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.directDescription) }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-solid p-4 text-left"
|
||||
:class="modeClass('copy_and_upgrade')"
|
||||
@click="selectMode('copy_and_upgrade')"
|
||||
>
|
||||
<strong class="text-contrast">{{ formatMessage(messages.copy) }}</strong>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.copyDescription) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-solid border-surface-4 bg-surface-2 p-4">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.backupTitle) }}
|
||||
</h3>
|
||||
<template v-if="effectiveMode === 'copy_and_upgrade'">
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">{{ formatMessage(messages.copyNoBackup) }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Checkbox
|
||||
v-model="flow.createFullBackup.value"
|
||||
class="mt-3"
|
||||
:label="formatMessage(messages.backupToggle)"
|
||||
@update:model-value="rememberBackupPreference"
|
||||
/>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.backupDescription) }}
|
||||
</p>
|
||||
<Admonition
|
||||
v-if="!flow.createFullBackup.value"
|
||||
class="mt-3"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.backupOffTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.backupOffBody) }}
|
||||
</Admonition>
|
||||
</template>
|
||||
<div class="mt-4 border-0 border-t border-solid border-divider pt-3 text-sm text-secondary">
|
||||
<strong class="text-contrast">{{ formatMessage(messages.rollbackTitle) }}</strong>
|
||||
<p class="mb-0 mt-1">{{ formatMessage(messages.rollbackDescription) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preservedTitle) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">{{ formatMessage(messages.preservedBody) }}</p>
|
||||
</section>
|
||||
<section v-if="detailGroups.some((group) => group.items.length)" class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">{{ formatMessage(messages.details) }}</h3>
|
||||
<Accordion
|
||||
v-for="group in detailGroups.filter((entry) => entry.items.length)"
|
||||
:key="group.label"
|
||||
button-class="flex w-full items-center gap-2 border-0 bg-transparent p-0 text-left text-contrast"
|
||||
content-class="pt-2"
|
||||
>
|
||||
<template #title>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<span class="text-sm text-secondary">{{ group.items.length }}</span>
|
||||
</template>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
class="flex min-w-0 items-center gap-3 rounded-md bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar :src="item.icon" :tint-by="item.title" size="2.5rem" no-shadow />
|
||||
<div class="min-w-0 flex-1">
|
||||
<RouterLink
|
||||
v-if="item.projectPath"
|
||||
:to="item.projectPath"
|
||||
class="inline-flex max-w-full cursor-pointer items-center gap-1 font-semibold text-contrast hover:text-brand hover:underline focus-visible:underline"
|
||||
@click="parkProjectReturn"
|
||||
><span class="truncate">{{ item.title }}</span
|
||||
><ExternalIcon class="size-3 shrink-0" aria-hidden="true"
|
||||
/></RouterLink>
|
||||
<div v-else class="truncate font-semibold text-contrast">{{ item.title }}</div>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-2 text-sm text-secondary">
|
||||
<span>{{ item.providerLabel }}</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.projectPath && item.currentReleaseId && item.currentLabel"
|
||||
:label="item.currentLabel"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.currentReleaseId"
|
||||
/>
|
||||
<span v-else-if="item.currentLabel">{{ item.currentLabel }}</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.projectPath && item.targetReleaseId && item.targetLabel"
|
||||
:label="item.targetLabel"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.targetReleaseId"
|
||||
/>
|
||||
<span v-else-if="item.targetLabel">{{ item.targetLabel }}</span>
|
||||
<span v-if="item.stateLabel">{{ item.stateLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Admonition,
|
||||
Avatar,
|
||||
buildUpgradeDisplayNames,
|
||||
Card,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type { InstanceContentSnapshotItem } from '@/helpers/instance'
|
||||
import {
|
||||
type InstanceContentData,
|
||||
loadInstanceContentData,
|
||||
localContentIconUrl,
|
||||
} from '@/helpers/instance-content'
|
||||
import type {
|
||||
ContentProvider,
|
||||
InstanceUpgradeDependencyChange,
|
||||
InstanceUpgradePlanItem,
|
||||
InstanceUpgradeSelection,
|
||||
SharedUpgradeMode,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import { parkUpgradeFlow, upgradeProjectPath } from '@/helpers/upgrade-return-state'
|
||||
import {
|
||||
loadUpgradeProjectDisplayMetadata,
|
||||
loadUpgradeVersionDisplayMetadata,
|
||||
upgradeProjectDisplayCacheKey,
|
||||
type UpgradeProjectIdentity,
|
||||
type UpgradeReleaseIdentity,
|
||||
upgradeVersionDisplayLabel,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
import {
|
||||
confirmSelectionReleaseSlots,
|
||||
confirmSolutionGroups,
|
||||
confirmTargetLoaderLabel,
|
||||
confirmUpgradeOptions,
|
||||
contentIdentityKeys,
|
||||
isSharedUpgradeInstance,
|
||||
normalizeUpgradePath,
|
||||
resolveConfirmDependencyReleases,
|
||||
solutionSummary,
|
||||
upgradeContentDisplayMetadata,
|
||||
} from './analysis'
|
||||
import { attachUpgradeJobToFlow, useInstanceUpgradeFlow } from './flow'
|
||||
import { submitInstanceUpgrade } from './install-job'
|
||||
import UpgradeVersionChangelogPopout from './UpgradeVersionChangelogPopout.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.confirm.title', defaultMessage: 'Confirm upgrade' },
|
||||
description: {
|
||||
id: 'instance.upgrade.confirm.description',
|
||||
defaultMessage: 'Review the changes and backup options before starting the upgrade.',
|
||||
},
|
||||
environment: { id: 'instance.upgrade.confirm.environment', defaultMessage: 'Environment' },
|
||||
loader: { id: 'instance.upgrade.confirm.loader', defaultMessage: 'Loader' },
|
||||
automatic: { id: 'instance.upgrade.confirm.loader.automatic', defaultMessage: 'automatic' },
|
||||
strategy: { id: 'instance.upgrade.confirm.strategy', defaultMessage: 'Upgrade strategy' },
|
||||
newest: {
|
||||
id: 'instance.upgrade.confirm.strategy.newest',
|
||||
defaultMessage: 'Update as much as possible',
|
||||
},
|
||||
minimal: {
|
||||
id: 'instance.upgrade.confirm.strategy.minimal',
|
||||
defaultMessage: 'Change as little as possible',
|
||||
},
|
||||
custom: { id: 'instance.upgrade.confirm.strategy.custom', defaultMessage: 'Custom' },
|
||||
updated: { id: 'instance.upgrade.confirm.updated', defaultMessage: 'Will update' },
|
||||
kept: { id: 'instance.upgrade.confirm.kept', defaultMessage: 'Will keep' },
|
||||
disabled: { id: 'instance.upgrade.confirm.disabled', defaultMessage: 'Will disable' },
|
||||
added: {
|
||||
id: 'instance.upgrade.confirm.dependencies-added',
|
||||
defaultMessage: 'Dependencies added',
|
||||
},
|
||||
dependencyUpdated: {
|
||||
id: 'instance.upgrade.confirm.dependencies-updated',
|
||||
defaultMessage: 'Dependencies updated',
|
||||
},
|
||||
removed: {
|
||||
id: 'instance.upgrade.confirm.dependencies-removed',
|
||||
defaultMessage: 'Dependencies removed',
|
||||
},
|
||||
sharedTitle: {
|
||||
id: 'instance.upgrade.confirm.shared.title',
|
||||
defaultMessage: 'Shared instance handling',
|
||||
},
|
||||
sharedDescription: {
|
||||
id: 'instance.upgrade.confirm.shared.description',
|
||||
defaultMessage:
|
||||
'Choose whether to modify the external target or upgrade an independent local copy.',
|
||||
},
|
||||
direct: { id: 'instance.upgrade.confirm.shared.direct', defaultMessage: 'Direct upgrade' },
|
||||
directDescription: {
|
||||
id: 'instance.upgrade.confirm.shared.direct-description',
|
||||
defaultMessage: 'Modify the real external target folder. The existing link remains in place.',
|
||||
},
|
||||
copy: { id: 'instance.upgrade.confirm.shared.copy', defaultMessage: 'Copy and upgrade' },
|
||||
copyDescription: {
|
||||
id: 'instance.upgrade.confirm.shared.copy-description',
|
||||
defaultMessage:
|
||||
'Create and upgrade a new local instance. The original link and external target remain untouched.',
|
||||
},
|
||||
backupTitle: { id: 'instance.upgrade.confirm.backup.title', defaultMessage: 'Full backup' },
|
||||
backupToggle: {
|
||||
id: 'instance.upgrade.confirm.backup.toggle',
|
||||
defaultMessage: 'Create a full backup before upgrading',
|
||||
},
|
||||
backupDescription: {
|
||||
id: 'instance.upgrade.confirm.backup.description',
|
||||
defaultMessage:
|
||||
'A separate instance copy preserves worlds and configuration for a later return.',
|
||||
},
|
||||
copyNoBackup: {
|
||||
id: 'instance.upgrade.confirm.backup.copy-mode',
|
||||
defaultMessage:
|
||||
'The original shared instance will not be modified, so no additional full backup will be created.',
|
||||
},
|
||||
backupOffTitle: {
|
||||
id: 'instance.upgrade.confirm.backup.off-title',
|
||||
defaultMessage: 'No full backup will be created',
|
||||
},
|
||||
backupOffBody: {
|
||||
id: 'instance.upgrade.confirm.backup.off-body',
|
||||
defaultMessage: 'World changes after launching the upgraded game may not be reversible.',
|
||||
},
|
||||
rollbackTitle: {
|
||||
id: 'instance.upgrade.confirm.rollback.title',
|
||||
defaultMessage: 'Automatic technical rollback',
|
||||
},
|
||||
rollbackDescription: {
|
||||
id: 'instance.upgrade.confirm.rollback.description',
|
||||
defaultMessage:
|
||||
'If file changes fail during the upgrade, the launcher automatically rolls back the operation. This is separate from a full user backup.',
|
||||
},
|
||||
worldTitle: {
|
||||
id: 'instance.upgrade.confirm.world.title',
|
||||
defaultMessage: 'World saves may be migrated irreversibly',
|
||||
},
|
||||
worldBody: {
|
||||
id: 'instance.upgrade.confirm.world.body',
|
||||
defaultMessage:
|
||||
'Launching the upgraded instance may migrate worlds to a newer save format. Opening migrated worlds with an older Minecraft version may be unsafe or unsupported. A full backup is strongly recommended.',
|
||||
},
|
||||
datapackNote: {
|
||||
id: 'instance.upgrade.confirm.world.datapacks',
|
||||
defaultMessage: 'Datapacks inside {path} are preserved but are not automatically upgraded.',
|
||||
},
|
||||
preservedTitle: {
|
||||
id: 'instance.upgrade.confirm.preserved.title',
|
||||
defaultMessage: 'Preserved data',
|
||||
},
|
||||
preservedBody: {
|
||||
id: 'instance.upgrade.confirm.preserved.body',
|
||||
defaultMessage:
|
||||
'Worlds, options, servers, and configuration files are preserved. Mods, resource packs, and shaders follow the selected upgrade solution.',
|
||||
},
|
||||
details: { id: 'instance.upgrade.confirm.details', defaultMessage: 'Change details' },
|
||||
updatedContent: {
|
||||
id: 'instance.upgrade.confirm.details.updated',
|
||||
defaultMessage: 'Updated content',
|
||||
},
|
||||
keptContent: {
|
||||
id: 'instance.upgrade.confirm.details.kept',
|
||||
defaultMessage: 'Kept content',
|
||||
},
|
||||
disabledContent: {
|
||||
id: 'instance.upgrade.confirm.details.disabled',
|
||||
defaultMessage: 'Disabled content',
|
||||
},
|
||||
dependencyChanges: {
|
||||
id: 'instance.upgrade.confirm.details.dependencies',
|
||||
defaultMessage: 'Dependency changes',
|
||||
},
|
||||
providerModrinth: { id: 'instance.upgrade.provider.modrinth', defaultMessage: 'Modrinth' },
|
||||
providerCurseForge: {
|
||||
id: 'instance.upgrade.provider.curseforge',
|
||||
defaultMessage: 'CurseForge',
|
||||
},
|
||||
providerLocal: { id: 'instance.upgrade.provider.local', defaultMessage: 'Local' },
|
||||
providerUnknown: { id: 'instance.upgrade.provider.unknown', defaultMessage: 'Unknown provider' },
|
||||
dependencyFallback: {
|
||||
id: 'instance.upgrade.confirm.details.new-dependency',
|
||||
defaultMessage: 'New {provider} dependency',
|
||||
},
|
||||
contentFallback: {
|
||||
id: 'instance.upgrade.confirm.details.content-item',
|
||||
defaultMessage: 'Content item',
|
||||
},
|
||||
versionTarget: {
|
||||
id: 'instance.upgrade.confirm.details.version-target',
|
||||
defaultMessage: 'Target {version}',
|
||||
},
|
||||
versionCurrent: {
|
||||
id: 'instance.upgrade.confirm.details.version-current',
|
||||
defaultMessage: 'Current {version}',
|
||||
},
|
||||
disabledState: {
|
||||
id: 'instance.upgrade.confirm.details.disabled-state',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
back: { id: 'instance.upgrade.confirm.back', defaultMessage: 'Back' },
|
||||
start: { id: 'instance.upgrade.confirm.start', defaultMessage: 'Start upgrade' },
|
||||
starting: { id: 'instance.upgrade.confirm.starting', defaultMessage: 'Starting upgrade…' },
|
||||
startError: {
|
||||
id: 'instance.upgrade.confirm.start-error',
|
||||
defaultMessage: 'Unable to start upgrade',
|
||||
},
|
||||
activeJob: {
|
||||
id: 'instance.upgrade.confirm.active-job',
|
||||
defaultMessage: 'An upgrade is already in progress',
|
||||
},
|
||||
backupInstanceName: {
|
||||
id: 'instance.upgrade.confirm.backup.instance-name',
|
||||
defaultMessage: '{name} (Pre-upgrade backup)',
|
||||
},
|
||||
copyInstanceName: {
|
||||
id: 'instance.upgrade.confirm.copy.instance-name',
|
||||
defaultMessage: '{name} (Upgraded copy)',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const datapackPath = 'saves/<world>/datapacks'
|
||||
const submissionLock = ref(false)
|
||||
const executionError = ref<string | null>(null)
|
||||
const plan = computed(() => flow.plan.value!)
|
||||
const solution = computed(() => plan.value.selectedSolution!)
|
||||
const sharedInstance = computed(() => isSharedUpgradeInstance(flow.instance.value))
|
||||
const confirmOptions = computed(() =>
|
||||
confirmUpgradeOptions(
|
||||
sharedInstance.value,
|
||||
flow.sharedUpgradeMode.value,
|
||||
flow.directFullBackupPreference.value,
|
||||
),
|
||||
)
|
||||
const effectiveMode = computed(() => confirmOptions.value.effectiveMode)
|
||||
const routeInstanceId = computed(() =>
|
||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
|
||||
)
|
||||
const submissionBusy = computed(() => flow.busy.value || submissionLock.value)
|
||||
const canStartUpgrade = computed(
|
||||
() =>
|
||||
flow.plan.value !== null &&
|
||||
flow.plan.value.blockingIssues.length === 0 &&
|
||||
flow.plan.value.selectedSolution !== null &&
|
||||
!flow.busy.value &&
|
||||
!submissionLock.value &&
|
||||
flow.activeJobId.value === null &&
|
||||
routeInstanceId.value === flow.instanceId.value &&
|
||||
confirmOptions.value.canStart,
|
||||
)
|
||||
const summary = computed(() => solutionSummary(solution.value))
|
||||
const metrics = computed(() => [
|
||||
{ label: formatMessage(messages.updated), value: summary.value.upgraded },
|
||||
{ label: formatMessage(messages.kept), value: summary.value.kept },
|
||||
{ label: formatMessage(messages.disabled), value: summary.value.disabled },
|
||||
{ label: formatMessage(messages.added), value: summary.value.dependencyAdditions },
|
||||
{ label: formatMessage(messages.dependencyUpdated), value: summary.value.dependencyUpdates },
|
||||
{ label: formatMessage(messages.removed), value: summary.value.dependencyRemovals },
|
||||
])
|
||||
const sourceLoader = computed(() =>
|
||||
loaderLabel(
|
||||
plan.value.sourceEnvironment.modLoader,
|
||||
plan.value.sourceEnvironment.modLoaderVersion,
|
||||
),
|
||||
)
|
||||
const targetLoader = computed(() =>
|
||||
confirmTargetLoaderLabel(
|
||||
formatLoaderLabel(plan.value.targetEnvironment.modLoader),
|
||||
plan.value.targetEnvironment.modLoader,
|
||||
plan.value.targetEnvironment.modLoaderVersion,
|
||||
formatMessage(messages.automatic),
|
||||
),
|
||||
)
|
||||
const displayNames = computed(() => {
|
||||
const instance = flow.instance.value
|
||||
if (!instance) return { backup: null, copy: null, upgradedTarget: null, shouldAutoRename: false }
|
||||
return buildUpgradeDisplayNames({
|
||||
sourceName: instance.name,
|
||||
sourceLoader: instance.loader,
|
||||
sourceGameVersion: instance.game_version,
|
||||
sourceLoaderVersion: instance.loader_version ?? null,
|
||||
targetLoader: plan.value.targetEnvironment.modLoader,
|
||||
targetGameVersion: plan.value.targetEnvironment.gameVersion,
|
||||
targetLoaderVersion: plan.value.targetEnvironment.modLoaderVersion,
|
||||
backupName: formatMessage(messages.backupInstanceName, { name: instance.name }),
|
||||
customCopyName: formatMessage(messages.copyInstanceName, { name: instance.name }),
|
||||
})
|
||||
})
|
||||
const strategyLabel = computed(
|
||||
() =>
|
||||
({
|
||||
newest: formatMessage(messages.newest),
|
||||
minimal_change: formatMessage(messages.minimal),
|
||||
custom: formatMessage(messages.custom),
|
||||
})[solution.value.kind],
|
||||
)
|
||||
|
||||
const contentDataQuery = useQuery({
|
||||
queryKey: computed(() => ['instance-upgrade', 'content-data', flow.instanceId.value]),
|
||||
queryFn: () => loadInstanceContentData(flow.instanceId.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const snapshotByContentId = computed(() => {
|
||||
const entries = (contentDataQuery.data.value?.snapshot.items ?? []).flatMap((item) =>
|
||||
contentIdentityKeys({
|
||||
instanceEntryId: item.entryId,
|
||||
instanceMemberId: item.memberId,
|
||||
instanceFileId: item.fileId,
|
||||
relativePath: item.expectedRelativePath,
|
||||
}).map((key) => [key, item] as const),
|
||||
)
|
||||
return new Map<string, InstanceContentSnapshotItem>(entries)
|
||||
})
|
||||
const contentByContentId = computed(() => {
|
||||
const data = contentDataQuery.data.value as InstanceContentData | null | undefined
|
||||
return new Map(
|
||||
[...(data?.contentItems ?? []), ...(data?.linkedContentItems ?? [])].flatMap((item) =>
|
||||
contentIdentityKeys(item).map((key) => [key, item] as const),
|
||||
),
|
||||
)
|
||||
})
|
||||
const itemByContentId = computed(
|
||||
() => new Map(plan.value.items.map((item) => [item.contentId, item])),
|
||||
)
|
||||
const itemByProviderProject = computed(
|
||||
() =>
|
||||
new Map(
|
||||
plan.value.items.flatMap((item) =>
|
||||
item.provider && item.projectId
|
||||
? [[`${item.provider}:${item.projectId}`, item] as const]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const releaseIdentities = computed(() => {
|
||||
const identities: UpgradeReleaseIdentity[] = []
|
||||
const add = (
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null,
|
||||
) => {
|
||||
if ((provider === 'modrinth' || provider === 'curseforge') && projectId && releaseId) {
|
||||
identities.push({ provider, projectId, releaseId })
|
||||
}
|
||||
}
|
||||
for (const selection of solution.value.selections) {
|
||||
add(selection.provider, selection.projectId, selection.currentReleaseId)
|
||||
add(selection.provider, selection.projectId, selection.targetReleaseId)
|
||||
}
|
||||
for (const change of solution.value.dependencyChanges) {
|
||||
add(change.provider, change.projectId, change.currentReleaseId)
|
||||
add(change.provider, change.projectId, change.targetReleaseId)
|
||||
}
|
||||
return identities
|
||||
})
|
||||
const versionMetadataQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'version-display',
|
||||
...releaseIdentities.value.map(
|
||||
(identity) => `${identity.provider}:${identity.projectId}:${identity.releaseId}`,
|
||||
),
|
||||
]),
|
||||
queryFn: () => loadUpgradeVersionDisplayMetadata(releaseIdentities.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const projectIdentities = computed(() => {
|
||||
const identities: UpgradeProjectIdentity[] = []
|
||||
const add = (provider: ContentProvider | null, projectId: string | null) => {
|
||||
if ((provider === 'modrinth' || provider === 'curseforge') && projectId) {
|
||||
identities.push({ provider, projectId })
|
||||
}
|
||||
}
|
||||
for (const selection of solution.value.selections) add(selection.provider, selection.projectId)
|
||||
for (const change of solution.value.dependencyChanges) add(change.provider, change.projectId)
|
||||
return identities
|
||||
})
|
||||
const projectMetadataQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'project-display',
|
||||
...projectIdentities.value.map((identity) => `${identity.provider}:${identity.projectId}`),
|
||||
]),
|
||||
queryFn: () => loadUpgradeProjectDisplayMetadata(projectIdentities.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
|
||||
interface ConfirmDetailRow {
|
||||
key: string
|
||||
title: string
|
||||
icon: string
|
||||
provider: ContentProvider | null
|
||||
providerLabel: string
|
||||
projectId: string | null
|
||||
projectPath: string | null
|
||||
currentReleaseId: string | null
|
||||
currentLabel: string | null
|
||||
targetReleaseId: string | null
|
||||
targetLabel: string | null
|
||||
stateLabel: string | null
|
||||
}
|
||||
|
||||
const groupedChanges = computed(() => confirmSolutionGroups(solution.value))
|
||||
const detailGroups = computed(() => [
|
||||
{
|
||||
label: formatMessage(messages.updatedContent),
|
||||
items: groupedChanges.value.updated.map(selectionDetail),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.keptContent),
|
||||
items: groupedChanges.value.kept.map(selectionDetail),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.disabledContent),
|
||||
items: groupedChanges.value.disabled.map(selectionDetail),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.dependencyChanges),
|
||||
items: groupedChanges.value.dependencyChanges.map(dependencyDetail),
|
||||
},
|
||||
])
|
||||
|
||||
function loaderLabel(
|
||||
loader: typeof plan.value.sourceEnvironment.modLoader,
|
||||
version: string | null,
|
||||
) {
|
||||
const label = formatLoaderLabel(loader)
|
||||
return version ? `${label} ${version}` : label
|
||||
}
|
||||
|
||||
function contentMetadata(item: InstanceUpgradePlanItem) {
|
||||
return upgradeContentDisplayMetadata(
|
||||
item,
|
||||
contentByContentId.value.get(item.contentId) ??
|
||||
contentByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
snapshotByContentId.value.get(item.contentId) ??
|
||||
snapshotByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
)
|
||||
}
|
||||
|
||||
function providerLabel(provider: ContentProvider | null): string {
|
||||
if (provider === 'modrinth') return formatMessage(messages.providerModrinth)
|
||||
if (provider === 'curseforge') return formatMessage(messages.providerCurseForge)
|
||||
if (provider === 'local') return formatMessage(messages.providerLocal)
|
||||
return formatMessage(messages.providerUnknown)
|
||||
}
|
||||
|
||||
function releaseLabel(
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null,
|
||||
fallback?: string | null,
|
||||
): string | null {
|
||||
if (!releaseId) return fallback ?? null
|
||||
if (!provider || !projectId) return fallback ?? releaseId
|
||||
const resolved = upgradeVersionDisplayLabel(versionMetadataQuery.data.value, {
|
||||
provider,
|
||||
projectId,
|
||||
releaseId,
|
||||
})
|
||||
return resolved === releaseId && fallback ? fallback : resolved
|
||||
}
|
||||
|
||||
function projectMetadata(provider: ContentProvider | null, projectId: string | null) {
|
||||
if (!provider || !projectId) return null
|
||||
return (
|
||||
projectMetadataQuery.data.value?.get(upgradeProjectDisplayCacheKey(provider, projectId)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function selectionDetail(selection: InstanceUpgradeSelection): ConfirmDetailRow {
|
||||
const item = itemByContentId.value.get(selection.contentId)
|
||||
const metadata = item ? contentMetadata(item) : null
|
||||
const providerMetadata = projectMetadata(selection.provider, selection.projectId)
|
||||
const releases = confirmSelectionReleaseSlots(selection)
|
||||
const current = releaseLabel(
|
||||
selection.provider,
|
||||
selection.projectId,
|
||||
releases.currentReleaseId,
|
||||
metadata?.currentVersion,
|
||||
)
|
||||
const target = releaseLabel(selection.provider, selection.projectId, releases.targetReleaseId)
|
||||
return {
|
||||
key: selection.contentId,
|
||||
title: metadata?.title ?? providerMetadata?.title ?? formatMessage(messages.contentFallback),
|
||||
icon: localContentIconUrl(metadata?.iconUrl ?? providerMetadata?.iconUrl),
|
||||
provider: selection.provider,
|
||||
providerLabel: providerLabel(selection.provider),
|
||||
projectId: selection.projectId,
|
||||
projectPath: upgradeProjectPath(selection.provider, selection.projectId),
|
||||
currentReleaseId: releases.currentReleaseId,
|
||||
currentLabel: current ? formatMessage(messages.versionCurrent, { version: current }) : null,
|
||||
targetReleaseId: releases.targetReleaseId,
|
||||
targetLabel: target ? formatMessage(messages.versionTarget, { version: target }) : null,
|
||||
stateLabel: selection.action === 'disable' ? formatMessage(messages.disabledState) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyDetail(change: InstanceUpgradeDependencyChange): ConfirmDetailRow {
|
||||
const item =
|
||||
(change.existingContentId ? itemByContentId.value.get(change.existingContentId) : null) ??
|
||||
itemByProviderProject.value.get(`${change.provider}:${change.projectId}`)
|
||||
const metadata = item ? contentMetadata(item) : null
|
||||
const providerMetadata = projectMetadata(change.provider, change.projectId)
|
||||
const releases = resolveConfirmDependencyReleases(change, (releaseId, slot) =>
|
||||
releaseLabel(
|
||||
change.provider,
|
||||
change.projectId,
|
||||
releaseId,
|
||||
slot === 'current' ? metadata?.currentVersion : undefined,
|
||||
),
|
||||
)
|
||||
return {
|
||||
key: `${change.provider}:${change.projectId}:${change.existingContentId ?? 'new'}:${change.kind}`,
|
||||
title:
|
||||
metadata?.title ??
|
||||
providerMetadata?.title ??
|
||||
formatMessage(messages.dependencyFallback, { provider: providerLabel(change.provider) }),
|
||||
icon: localContentIconUrl(metadata?.iconUrl ?? providerMetadata?.iconUrl),
|
||||
provider: change.provider,
|
||||
providerLabel: providerLabel(change.provider),
|
||||
projectId: change.projectId,
|
||||
projectPath: upgradeProjectPath(change.provider, change.projectId),
|
||||
currentReleaseId: releases.currentReleaseId,
|
||||
currentLabel: releases.current
|
||||
? formatMessage(messages.versionCurrent, { version: releases.current })
|
||||
: null,
|
||||
targetReleaseId: releases.targetReleaseId,
|
||||
targetLabel: releases.target
|
||||
? formatMessage(messages.versionTarget, { version: releases.target })
|
||||
: null,
|
||||
stateLabel: null,
|
||||
}
|
||||
}
|
||||
|
||||
function parkProjectReturn() {
|
||||
parkUpgradeFlow({
|
||||
instanceId: flow.instanceId.value,
|
||||
returnFullPath: router.currentRoute.value.fullPath,
|
||||
targetEnvironment: flow.targetEnvironment.value,
|
||||
plan: flow.plan.value,
|
||||
createFullBackup: flow.createFullBackup.value,
|
||||
directFullBackupPreference: flow.directFullBackupPreference.value,
|
||||
sharedUpgradeMode: flow.sharedUpgradeMode.value,
|
||||
activeJobId: flow.activeJobId.value,
|
||||
result: flow.result.value,
|
||||
initialBlockingPlanId: flow.initialBlockingPlanId.value,
|
||||
initialBlockingIssues: flow.initialBlockingIssues.value,
|
||||
customizeActiveStrategy: flow.customizeActiveStrategy.value,
|
||||
})
|
||||
}
|
||||
|
||||
function selectMode(mode: SharedUpgradeMode) {
|
||||
const previousMode = flow.sharedUpgradeMode.value
|
||||
flow.sharedUpgradeMode.value = mode
|
||||
if (mode === 'copy_and_upgrade') {
|
||||
if (previousMode !== 'copy_and_upgrade') {
|
||||
flow.directFullBackupPreference.value = flow.createFullBackup.value
|
||||
}
|
||||
flow.createFullBackup.value = false
|
||||
} else {
|
||||
flow.createFullBackup.value = flow.directFullBackupPreference.value
|
||||
}
|
||||
}
|
||||
|
||||
function rememberBackupPreference(value: boolean) {
|
||||
flow.directFullBackupPreference.value = value
|
||||
}
|
||||
|
||||
function modeClass(mode: SharedUpgradeMode) {
|
||||
return flow.sharedUpgradeMode.value === mode
|
||||
? 'border-brand bg-surface-2 ring-1 ring-brand'
|
||||
: 'border-surface-4 bg-surface-2 hover:bg-surface-3'
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'object' && error && 'message' in error) return String(error.message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function startUpgrade() {
|
||||
if (!canStartUpgrade.value || !flow.plan.value || !effectiveMode.value) return
|
||||
flow.busy.value = true
|
||||
executionError.value = null
|
||||
try {
|
||||
const submitted = await submitInstanceUpgrade(
|
||||
{
|
||||
instanceId: flow.instanceId.value,
|
||||
planId: flow.plan.value.id,
|
||||
createFullBackup: confirmOptions.value.createFullBackup,
|
||||
sharedUpgradeMode: effectiveMode.value,
|
||||
displayNames: displayNames.value,
|
||||
},
|
||||
submissionLock,
|
||||
)
|
||||
if (!submitted) return
|
||||
await router.replace(attachUpgradeJobToFlow(flow, submitted.job))
|
||||
} catch (error) {
|
||||
executionError.value = errorMessage(error)
|
||||
} finally {
|
||||
flow.busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function registerControls() {
|
||||
flow.registerStepControls({
|
||||
canNext: canStartUpgrade,
|
||||
busy: submissionBusy,
|
||||
nextLabel: formatMessage(submissionBusy.value ? messages.starting : messages.start),
|
||||
onNext: startUpgrade,
|
||||
onBack: () =>
|
||||
router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}/upgrade/customize`),
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!sharedInstance.value) {
|
||||
flow.sharedUpgradeMode.value = 'direct'
|
||||
flow.createFullBackup.value = confirmOptions.value.createFullBackup
|
||||
}
|
||||
registerControls()
|
||||
})
|
||||
watch([canStartUpgrade, submissionBusy], registerControls)
|
||||
onBeforeUnmount(() => flow.registerStepControls(null))
|
||||
</script>
|
||||
914
apps/app-frontend/src/pages/instance/upgrade/Customize.vue
Normal file
914
apps/app-frontend/src/pages/instance/upgrade/Customize.vue
Normal file
@ -0,0 +1,914 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header>
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-0 mt-1 max-w-2xl text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<button
|
||||
v-if="availableStrategies.includes('newest') && plan.newestSolution"
|
||||
type="button"
|
||||
class="flex min-h-36 flex-col gap-2 rounded-lg border border-solid p-4 text-left transition-colors"
|
||||
:class="strategyClass('newest')"
|
||||
:disabled="requestBusy"
|
||||
@click="chooseStrategy('newest')"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.newestTitle) }}
|
||||
<CheckIcon
|
||||
v-if="activeStrategy === 'newest'"
|
||||
class="ml-auto text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.newestDescription) }}</p>
|
||||
<span class="mt-auto text-sm text-secondary">{{ summaryText(plan.newestSolution) }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="availableStrategies.includes('minimal_change') && plan.minimalChangeSolution"
|
||||
type="button"
|
||||
class="flex min-h-36 flex-col gap-2 rounded-lg border border-solid p-4 text-left transition-colors"
|
||||
:class="strategyClass('minimal_change')"
|
||||
:disabled="requestBusy"
|
||||
@click="chooseStrategy('minimal_change')"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<MinimizeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.minimalTitle) }}
|
||||
<CheckIcon
|
||||
v-if="activeStrategy === 'minimal_change'"
|
||||
class="ml-auto text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.minimalDescription) }}</p>
|
||||
<span class="mt-auto text-sm text-secondary">{{
|
||||
summaryText(plan.minimalChangeSolution)
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-36 flex-col gap-2 rounded-lg border border-solid p-4 text-left transition-colors"
|
||||
:class="strategyClass('custom')"
|
||||
:disabled="requestBusy"
|
||||
@click="chooseStrategy('custom')"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.customTitle) }}
|
||||
<CheckIcon
|
||||
v-if="activeStrategy === 'custom'"
|
||||
class="ml-auto text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.customDescription) }}</p>
|
||||
<span class="mt-auto text-sm text-secondary">
|
||||
{{ formatMessage(messages.customConstraintCount, { count: draftConstraints.length }) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="pendingStrategy"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unsavedTitle)"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span>{{ formatMessage(messages.unsavedBody) }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button @click="pendingStrategy = null">{{ formatMessage(messages.cancel) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange" size="small">
|
||||
<button @click="discardAndSwitch">
|
||||
{{ formatMessage(messages.discardAndSwitch) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
<Admonition
|
||||
v-if="requestError"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.requestErrorTitle)"
|
||||
>
|
||||
{{ requestError }}
|
||||
</Admonition>
|
||||
|
||||
<section v-if="activeStrategy === 'custom'" class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.customChoices) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.customChoicesDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canApplyCustom" @click="applyCustomChoices">
|
||||
<SpinnerIcon v-if="requestBusy" class="animate-spin" aria-hidden="true" />
|
||||
<RefreshCwIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(customWasResolved ? messages.recalculate : messages.applyCustom) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="customDraftDirty"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unappliedTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.unappliedBody) }}
|
||||
</Admonition>
|
||||
|
||||
<div class="rounded-lg border border-solid border-surface-4">
|
||||
<article
|
||||
v-for="item in editableRoots"
|
||||
:key="item.contentId"
|
||||
class="relative flex flex-col gap-3 border-0 border-b border-solid border-surface-4 bg-surface-2 p-3 first:rounded-t-lg last:rounded-b-lg last:border-b-0 focus-within:z-20 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Avatar :src="itemIcon(item)" :tint-by="itemName(item)" size="2.5rem" no-shadow />
|
||||
<div class="min-w-0">
|
||||
<RouterLink
|
||||
v-if="projectPath(item)"
|
||||
:to="projectPath(item)!"
|
||||
class="inline-flex max-w-full items-center gap-1 font-semibold text-contrast hover:text-brand hover:underline focus-visible:underline"
|
||||
@click="parkProjectReturn"
|
||||
><span class="truncate">{{ itemName(item) }}</span
|
||||
><ExternalIcon class="size-3 shrink-0" aria-hidden="true"
|
||||
/></RouterLink>
|
||||
<div v-else class="truncate font-semibold text-contrast">{{ itemName(item) }}</div>
|
||||
<div class="flex flex-wrap gap-x-3 text-sm text-secondary">
|
||||
<span>{{ providerLabel(item.provider) }}</span>
|
||||
<span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.currentReleaseId"
|
||||
:label="currentVersionLabel(item)"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.currentReleaseId"
|
||||
/>
|
||||
<span v-else>{{ currentVersionLabel(item) }}</span>
|
||||
</span>
|
||||
<span v-if="!item.currentEnabled">{{
|
||||
formatMessage(messages.currentlyDisabled)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-secondary">
|
||||
<span>{{ formatMessage(messages.effectiveTargetPrefix) }}</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="effectiveTargetRelease(item)"
|
||||
:label="effectiveTargetVersion(item)"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="effectiveTargetRelease(item)"
|
||||
/>
|
||||
<span v-else>{{ formatMessage(messages.noTarget) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 w-full shrink-0 sm:w-72 sm:max-w-[45%]">
|
||||
<label
|
||||
class="mb-1 block text-sm font-medium text-contrast"
|
||||
:for="`custom-${item.contentId}`"
|
||||
>
|
||||
{{ formatMessage(messages.choice) }}
|
||||
</label>
|
||||
<DropdownSelect
|
||||
class="!w-full max-w-full min-w-0"
|
||||
:model-value="draftChoice(item.contentId)"
|
||||
:name="`custom-${item.contentId}`"
|
||||
:options="constraintOptions(item)"
|
||||
:display-name="(value) => constraintOptionLabel(item, String(value))"
|
||||
:disabled="requestBusy"
|
||||
@update:model-value="setDraftChoice(item, String($event))"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="customIssues.length" class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.customIssues) }}
|
||||
</h3>
|
||||
<Admonition
|
||||
v-for="(issue, index) in customIssues"
|
||||
:key="`${issue.code}:${issue.contentId ?? issue.projectId ?? index}`"
|
||||
:type="issue.code === 'search_limit_reached' ? 'warning' : 'critical'"
|
||||
:header="customIssueTitle(issue)"
|
||||
>
|
||||
{{ customIssueBody(issue) }}
|
||||
</Admonition>
|
||||
</section>
|
||||
|
||||
<section v-if="effectiveSolution" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.effectiveChanges) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{
|
||||
activeStrategy === 'custom' && !customWasResolved
|
||||
? formatMessage(messages.baselineHint)
|
||||
: summaryText(effectiveSolution)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-surface-4 sm:grid-cols-3 lg:grid-cols-6"
|
||||
>
|
||||
<div v-for="metric in effectiveMetrics" :key="metric.label" class="bg-surface-2 p-3">
|
||||
<div class="text-xl font-semibold text-contrast">{{ metric.value }}</div>
|
||||
<div class="text-sm text-secondary">{{ metric.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveDependencyChanges.length" class="flex flex-col gap-2">
|
||||
<h4 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.dependenciesTitle) }}
|
||||
</h4>
|
||||
<div class="overflow-hidden rounded-lg border border-solid border-surface-4">
|
||||
<div
|
||||
v-for="change in effectiveDependencyChanges"
|
||||
:key="`${change.provider}:${change.projectId}:${change.existingContentId ?? 'new'}`"
|
||||
class="flex items-center justify-between gap-4 border-0 border-b border-solid border-surface-4 bg-surface-2 p-3 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-semibold text-contrast">{{ change.projectId }}</div>
|
||||
<div class="text-sm text-secondary">
|
||||
{{ dependencyChangeDescription(change) }}
|
||||
</div>
|
||||
</div>
|
||||
<strong class="shrink-0 text-sm text-contrast">{{
|
||||
dependencyActionLabel(change.kind)
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ExternalIcon,
|
||||
MinimizeIcon,
|
||||
RefreshCwIcon,
|
||||
SettingsIcon,
|
||||
SparklesIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { InstanceContentSnapshotItem } from '@/helpers/instance'
|
||||
import {
|
||||
type InstanceContentData,
|
||||
loadInstanceContentData,
|
||||
localContentIconUrl,
|
||||
} from '@/helpers/instance-content'
|
||||
import type {
|
||||
ContentProvider,
|
||||
InstanceUpgradeDependencyChange,
|
||||
InstanceUpgradeDependencyChangeKind,
|
||||
InstanceUpgradeFixedConstraint,
|
||||
InstanceUpgradeIssue,
|
||||
InstanceUpgradePlanItem,
|
||||
InstanceUpgradeSolution,
|
||||
InstanceUpgradeSolutionKind,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import {
|
||||
resolve_custom_instance_upgrade_solution,
|
||||
select_instance_upgrade_solution,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import { parkUpgradeFlow, upgradeProjectPath } from '@/helpers/upgrade-return-state'
|
||||
import {
|
||||
loadUpgradeVersionDisplayMetadata,
|
||||
type UpgradeReleaseIdentity,
|
||||
upgradeVersionCacheKey,
|
||||
upgradeVersionDisplayLabel,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
import {
|
||||
availablePredefinedStrategies,
|
||||
contentIdentityKeys,
|
||||
customConstraintsEqual,
|
||||
editableUpgradeRoots,
|
||||
normalizeUpgradePath,
|
||||
setFixedConstraint,
|
||||
solutionSummary,
|
||||
upgradeContentDisplayMetadata,
|
||||
} from './analysis'
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
import { initialCustomizeStrategy } from './flow-controls'
|
||||
import UpgradeVersionChangelogPopout from './UpgradeVersionChangelogPopout.vue'
|
||||
|
||||
const AUTOMATIC = '__automatic__'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.customize.title', defaultMessage: 'Upgrade strategy' },
|
||||
description: {
|
||||
id: 'instance.upgrade.customize.description',
|
||||
defaultMessage: 'Choose how aggressively Axolotl should update content in this instance.',
|
||||
},
|
||||
newestTitle: { id: 'instance.upgrade.customize.newest.title', defaultMessage: 'Newest versions' },
|
||||
newestDescription: {
|
||||
id: 'instance.upgrade.customize.newest.description',
|
||||
defaultMessage:
|
||||
'Update compatible content to the newest versions available for the target environment.',
|
||||
},
|
||||
minimalTitle: {
|
||||
id: 'instance.upgrade.customize.minimal.title',
|
||||
defaultMessage: 'Minimal changes',
|
||||
},
|
||||
minimalDescription: {
|
||||
id: 'instance.upgrade.customize.minimal.description',
|
||||
defaultMessage:
|
||||
'Keep compatible current versions and change as little installed content as possible.',
|
||||
},
|
||||
customTitle: { id: 'instance.upgrade.customize.custom.title', defaultMessage: 'Custom' },
|
||||
customDescription: {
|
||||
id: 'instance.upgrade.customize.custom.description',
|
||||
defaultMessage:
|
||||
'Fix exact versions for selected content and let Axolotl solve the remaining dependency graph.',
|
||||
},
|
||||
customConstraintCount: {
|
||||
id: 'instance.upgrade.customize.custom.constraint-count',
|
||||
defaultMessage: '{count, plural, one {# exact choice} other {# exact choices}}',
|
||||
},
|
||||
rootSummary: {
|
||||
id: 'instance.upgrade.customize.summary.roots',
|
||||
defaultMessage:
|
||||
'{updates} updates, {kept} kept, {disabled} disabled, {dependencies, plural, one {# dependency change} other {# dependency changes}}',
|
||||
},
|
||||
customChoices: {
|
||||
id: 'instance.upgrade.customize.custom.choices',
|
||||
defaultMessage: 'Custom choices',
|
||||
},
|
||||
customChoicesDescription: {
|
||||
id: 'instance.upgrade.customize.custom.choices-description',
|
||||
defaultMessage: 'Only user-owned root content is editable. Dependencies remain solver-managed.',
|
||||
},
|
||||
choice: { id: 'instance.upgrade.customize.custom.choice', defaultMessage: 'Target version' },
|
||||
automatic: { id: 'instance.upgrade.customize.custom.automatic', defaultMessage: 'Automatic' },
|
||||
specificVersion: {
|
||||
id: 'instance.upgrade.customize.custom.specific-version',
|
||||
defaultMessage: 'Exact release {version}',
|
||||
},
|
||||
specificVersionWithChannel: {
|
||||
id: 'instance.upgrade.customize.custom.specific-version-channel',
|
||||
defaultMessage: '{version} ({channel})',
|
||||
},
|
||||
channelRelease: { id: 'instance.upgrade.customize.channel.release', defaultMessage: 'Release' },
|
||||
channelBeta: { id: 'instance.upgrade.customize.channel.beta', defaultMessage: 'Beta' },
|
||||
channelAlpha: { id: 'instance.upgrade.customize.channel.alpha', defaultMessage: 'Alpha' },
|
||||
applyCustom: {
|
||||
id: 'instance.upgrade.customize.custom.apply',
|
||||
defaultMessage: 'Apply custom choices',
|
||||
},
|
||||
recalculate: {
|
||||
id: 'instance.upgrade.customize.custom.recalculate',
|
||||
defaultMessage: 'Recalculate',
|
||||
},
|
||||
unappliedTitle: {
|
||||
id: 'instance.upgrade.customize.custom.unapplied-title',
|
||||
defaultMessage: 'Custom choices have not been applied',
|
||||
},
|
||||
unappliedBody: {
|
||||
id: 'instance.upgrade.customize.custom.unapplied-body',
|
||||
defaultMessage: 'Apply these choices to calculate a globally compatible solution.',
|
||||
},
|
||||
unsavedTitle: {
|
||||
id: 'instance.upgrade.customize.custom.unsaved-title',
|
||||
defaultMessage: 'Discard unapplied custom choices?',
|
||||
},
|
||||
unsavedBody: {
|
||||
id: 'instance.upgrade.customize.custom.unsaved-body',
|
||||
defaultMessage: 'Switching strategy will discard changes that have not been calculated.',
|
||||
},
|
||||
discardAndSwitch: {
|
||||
id: 'instance.upgrade.customize.custom.discard-switch',
|
||||
defaultMessage: 'Discard and switch',
|
||||
},
|
||||
cancel: { id: 'instance.upgrade.customize.cancel', defaultMessage: 'Cancel' },
|
||||
requestErrorTitle: {
|
||||
id: 'instance.upgrade.customize.request-error-title',
|
||||
defaultMessage: 'Strategy could not be updated',
|
||||
},
|
||||
customIssues: {
|
||||
id: 'instance.upgrade.customize.custom.issues',
|
||||
defaultMessage: 'Unable to resolve custom choices',
|
||||
},
|
||||
searchLimitTitle: {
|
||||
id: 'instance.upgrade.customize.search-limit.title',
|
||||
defaultMessage: 'Search limit reached',
|
||||
},
|
||||
searchLimitBody: {
|
||||
id: 'instance.upgrade.customize.search-limit.body',
|
||||
defaultMessage:
|
||||
"Axolotl couldn't find a solution within the search limit. Try relaxing one of your custom choices.",
|
||||
},
|
||||
conflictTitle: {
|
||||
id: 'instance.upgrade.customize.conflict.title',
|
||||
defaultMessage: 'Custom choices conflict',
|
||||
},
|
||||
effectiveChanges: {
|
||||
id: 'instance.upgrade.customize.effective-changes',
|
||||
defaultMessage: 'Effective changes',
|
||||
},
|
||||
dependenciesTitle: {
|
||||
id: 'instance.upgrade.customize.dependencies-title',
|
||||
defaultMessage: 'Dependency changes',
|
||||
},
|
||||
baselineHint: {
|
||||
id: 'instance.upgrade.customize.baseline-hint',
|
||||
defaultMessage:
|
||||
'Current selected solution shown as the baseline. Apply custom choices to recalculate it.',
|
||||
},
|
||||
metricUpdated: {
|
||||
id: 'instance.upgrade.customize.metric.updated',
|
||||
defaultMessage: 'Content updated',
|
||||
},
|
||||
metricKept: { id: 'instance.upgrade.customize.metric.kept', defaultMessage: 'Kept' },
|
||||
metricDisabled: { id: 'instance.upgrade.customize.metric.disabled', defaultMessage: 'Disabled' },
|
||||
metricAdded: {
|
||||
id: 'instance.upgrade.customize.metric.added',
|
||||
defaultMessage: 'Dependencies added',
|
||||
},
|
||||
metricDependencyUpdated: {
|
||||
id: 'instance.upgrade.customize.metric.dependency-updated',
|
||||
defaultMessage: 'Dependencies updated',
|
||||
},
|
||||
metricRemoved: {
|
||||
id: 'instance.upgrade.customize.metric.removed',
|
||||
defaultMessage: 'Dependencies removed',
|
||||
},
|
||||
currentVersion: {
|
||||
id: 'instance.upgrade.customize.current-version',
|
||||
defaultMessage: 'Current: {version}',
|
||||
},
|
||||
effectiveTarget: {
|
||||
id: 'instance.upgrade.customize.effective-target',
|
||||
defaultMessage: 'Calculated target: {version}',
|
||||
},
|
||||
effectiveTargetPrefix: {
|
||||
id: 'instance.upgrade.customize.effective-target-prefix',
|
||||
defaultMessage: 'Calculated target: ',
|
||||
},
|
||||
noTarget: { id: 'instance.upgrade.customize.no-target', defaultMessage: 'No target release' },
|
||||
currentlyDisabled: {
|
||||
id: 'instance.upgrade.customize.currently-disabled',
|
||||
defaultMessage: 'Currently disabled',
|
||||
},
|
||||
providerModrinth: { id: 'instance.upgrade.provider.modrinth', defaultMessage: 'Modrinth' },
|
||||
providerCurseForge: { id: 'instance.upgrade.provider.curseforge', defaultMessage: 'CurseForge' },
|
||||
providerUnknown: { id: 'instance.upgrade.provider.unknown', defaultMessage: 'Unknown provider' },
|
||||
dependencyAdd: {
|
||||
id: 'instance.upgrade.customize.dependency.add',
|
||||
defaultMessage: 'Add dependency',
|
||||
},
|
||||
dependencyUpgrade: {
|
||||
id: 'instance.upgrade.customize.dependency.upgrade',
|
||||
defaultMessage: 'Update dependency',
|
||||
},
|
||||
dependencyRemove: {
|
||||
id: 'instance.upgrade.customize.dependency.remove',
|
||||
defaultMessage: 'Remove dependency',
|
||||
},
|
||||
dependencyKeep: {
|
||||
id: 'instance.upgrade.customize.dependency.keep',
|
||||
defaultMessage: 'Keep dependency',
|
||||
},
|
||||
dependencyReused: {
|
||||
id: 'instance.upgrade.customize.dependency.reused',
|
||||
defaultMessage: 'Reuses existing content: {current} to {target}',
|
||||
},
|
||||
dependencyNew: {
|
||||
id: 'instance.upgrade.customize.dependency.new',
|
||||
defaultMessage: 'New content: {target}',
|
||||
},
|
||||
back: { id: 'instance.upgrade.customize.back', defaultMessage: 'Back' },
|
||||
continue: { id: 'instance.upgrade.customize.continue', defaultMessage: 'Continue' },
|
||||
applyBeforeContinue: {
|
||||
id: 'instance.upgrade.customize.apply-before-continue',
|
||||
defaultMessage: 'Apply your custom choices first.',
|
||||
},
|
||||
resolveBeforeContinue: {
|
||||
id: 'instance.upgrade.customize.resolve-before-continue',
|
||||
defaultMessage: 'Resolve custom conflicts before continuing.',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const plan = computed(() => flow.plan.value!)
|
||||
const activeStrategy = ref<InstanceUpgradeSolutionKind>(
|
||||
initialCustomizeStrategy(
|
||||
flow.customizeActiveStrategy.value,
|
||||
plan.value.selectedSolution?.kind,
|
||||
'custom',
|
||||
),
|
||||
)
|
||||
flow.customizeActiveStrategy.value = activeStrategy.value
|
||||
const draftConstraints = ref<InstanceUpgradeFixedConstraint[]>(
|
||||
plan.value.customConstraints.map((item) => ({ ...item })),
|
||||
)
|
||||
const pendingStrategy = ref<Exclude<InstanceUpgradeSolutionKind, 'custom'> | null>(null)
|
||||
const requestBusy = ref(false)
|
||||
const requestError = ref<string | null>(null)
|
||||
|
||||
const contentDataQuery = useQuery({
|
||||
queryKey: computed(() => ['instance-upgrade', 'content-data', flow.instanceId.value]),
|
||||
queryFn: () => loadInstanceContentData(flow.instanceId.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const releaseIdentities = computed(() => {
|
||||
const identities: UpgradeReleaseIdentity[] = []
|
||||
const add = (
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null,
|
||||
) => {
|
||||
if ((provider === 'modrinth' || provider === 'curseforge') && projectId && releaseId) {
|
||||
identities.push({ provider, projectId, releaseId })
|
||||
}
|
||||
}
|
||||
for (const item of plan.value.items) {
|
||||
add(item.provider, item.projectId, item.currentReleaseId)
|
||||
item.candidateReleaseIds.forEach((releaseId) => add(item.provider, item.projectId, releaseId))
|
||||
}
|
||||
for (const selection of plan.value.selectedSolution?.selections ?? []) {
|
||||
add(selection.provider, selection.projectId, selection.targetReleaseId)
|
||||
}
|
||||
for (const change of plan.value.selectedSolution?.dependencyChanges ?? []) {
|
||||
add(change.provider, change.projectId, change.currentReleaseId)
|
||||
add(change.provider, change.projectId, change.targetReleaseId)
|
||||
}
|
||||
return identities
|
||||
})
|
||||
const versionMetadataQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'version-display',
|
||||
...releaseIdentities.value.map((identity) =>
|
||||
upgradeVersionCacheKey(identity.provider, identity.projectId, identity.releaseId),
|
||||
),
|
||||
]),
|
||||
queryFn: () => loadUpgradeVersionDisplayMetadata(releaseIdentities.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const snapshotByContentId = computed(() => {
|
||||
const entries = (contentDataQuery.data.value?.snapshot.items ?? []).flatMap((item) =>
|
||||
contentIdentityKeys({
|
||||
instanceEntryId: item.entryId,
|
||||
instanceMemberId: item.memberId,
|
||||
instanceFileId: item.fileId,
|
||||
relativePath: item.expectedRelativePath,
|
||||
}).map((key) => [key, item] as const),
|
||||
)
|
||||
return new Map<string, InstanceContentSnapshotItem>(entries)
|
||||
})
|
||||
const contentByContentId = computed(() => {
|
||||
const data = contentDataQuery.data.value as InstanceContentData | null | undefined
|
||||
return new Map(
|
||||
[...(data?.contentItems ?? []), ...(data?.linkedContentItems ?? [])].flatMap((item) =>
|
||||
contentIdentityKeys(item).map((key) => [key, item] as const),
|
||||
),
|
||||
)
|
||||
})
|
||||
const editableRoots = computed(() => editableUpgradeRoots(plan.value))
|
||||
const availableStrategies = computed(() => availablePredefinedStrategies(plan.value))
|
||||
const customDraftDirty = computed(
|
||||
() => !customConstraintsEqual(draftConstraints.value, plan.value.customConstraints),
|
||||
)
|
||||
const customWasResolved = computed(() => plan.value.selectedSolution?.kind === 'custom')
|
||||
const effectiveSolution = computed(() => plan.value.selectedSolution)
|
||||
const effectiveDependencyChanges = computed(() =>
|
||||
(effectiveSolution.value?.dependencyChanges ?? []).filter((entry) => entry.kind !== 'keep'),
|
||||
)
|
||||
const effectiveSummary = computed(() =>
|
||||
effectiveSolution.value ? solutionSummary(effectiveSolution.value) : null,
|
||||
)
|
||||
const effectiveMetrics = computed(() => {
|
||||
const summary = effectiveSummary.value
|
||||
if (!summary) return []
|
||||
return [
|
||||
{ label: formatMessage(messages.metricUpdated), value: summary.upgraded },
|
||||
{ label: formatMessage(messages.metricKept), value: summary.kept },
|
||||
{ label: formatMessage(messages.metricDisabled), value: summary.disabled },
|
||||
{ label: formatMessage(messages.metricAdded), value: summary.dependencyAdditions },
|
||||
{ label: formatMessage(messages.metricDependencyUpdated), value: summary.dependencyUpdates },
|
||||
{ label: formatMessage(messages.metricRemoved), value: summary.dependencyRemovals },
|
||||
]
|
||||
})
|
||||
const customIssues = computed(() =>
|
||||
activeStrategy.value === 'custom' && !customDraftDirty.value ? plan.value.blockingIssues : [],
|
||||
)
|
||||
const canApplyCustom = computed(
|
||||
() =>
|
||||
activeStrategy.value === 'custom' &&
|
||||
!requestBusy.value &&
|
||||
(customDraftDirty.value || !customWasResolved.value),
|
||||
)
|
||||
const canContinue = computed(
|
||||
() =>
|
||||
!requestBusy.value &&
|
||||
!customDraftDirty.value &&
|
||||
plan.value.blockingIssues.length === 0 &&
|
||||
plan.value.selectedSolution !== null &&
|
||||
plan.value.selectedSolution.kind === activeStrategy.value,
|
||||
)
|
||||
function registerControls() {
|
||||
flow.registerStepControls({
|
||||
canNext: canContinue,
|
||||
busy: requestBusy,
|
||||
nextLabel: formatMessage(messages.continue),
|
||||
onNext: continueUpgrade,
|
||||
onBack: goBack,
|
||||
})
|
||||
}
|
||||
onMounted(registerControls)
|
||||
watch([canContinue, requestBusy], registerControls)
|
||||
onBeforeUnmount(() => flow.registerStepControls(null))
|
||||
|
||||
function strategyClass(kind: InstanceUpgradeSolutionKind) {
|
||||
return activeStrategy.value === kind
|
||||
? 'border-brand bg-surface-2 ring-1 ring-brand'
|
||||
: 'border-surface-4 bg-surface-2 hover:bg-surface-3'
|
||||
}
|
||||
|
||||
function summaryText(solution: InstanceUpgradeSolution): string {
|
||||
const summary = solutionSummary(solution)
|
||||
return formatMessage(messages.rootSummary, {
|
||||
updates: summary.upgraded,
|
||||
kept: summary.kept,
|
||||
disabled: summary.disabled,
|
||||
dependencies:
|
||||
summary.dependencyAdditions + summary.dependencyUpdates + summary.dependencyRemovals,
|
||||
})
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'object' && error && 'message' in error) return String(error.message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function chooseStrategy(kind: InstanceUpgradeSolutionKind) {
|
||||
if (requestBusy.value || kind === activeStrategy.value) return
|
||||
requestError.value = null
|
||||
if (kind === 'custom') {
|
||||
activeStrategy.value = 'custom'
|
||||
flow.customizeActiveStrategy.value = 'custom'
|
||||
return
|
||||
}
|
||||
if (customDraftDirty.value) {
|
||||
pendingStrategy.value = kind
|
||||
return
|
||||
}
|
||||
activeStrategy.value = kind
|
||||
flow.customizeActiveStrategy.value = kind
|
||||
await selectPredefined(kind)
|
||||
}
|
||||
|
||||
async function discardAndSwitch() {
|
||||
const target = pendingStrategy.value
|
||||
if (!target) return
|
||||
draftConstraints.value = plan.value.customConstraints.map((item) => ({ ...item }))
|
||||
pendingStrategy.value = null
|
||||
await selectPredefined(target)
|
||||
}
|
||||
|
||||
async function selectPredefined(kind: Exclude<InstanceUpgradeSolutionKind, 'custom'>) {
|
||||
if (plan.value.selectedSolution?.kind === kind) {
|
||||
activeStrategy.value = kind
|
||||
flow.customizeActiveStrategy.value = kind
|
||||
return
|
||||
}
|
||||
requestBusy.value = true
|
||||
try {
|
||||
const updatedPlan = await select_instance_upgrade_solution(plan.value.id, kind)
|
||||
flow.setPlan(updatedPlan)
|
||||
activeStrategy.value = kind
|
||||
flow.customizeActiveStrategy.value = kind
|
||||
} catch (error) {
|
||||
requestError.value = errorMessage(error)
|
||||
} finally {
|
||||
requestBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function contentMetadata(item: InstanceUpgradePlanItem) {
|
||||
return upgradeContentDisplayMetadata(
|
||||
item,
|
||||
contentByContentId.value.get(item.contentId) ??
|
||||
contentByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
snapshotByContentId.value.get(item.contentId) ??
|
||||
snapshotByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
)
|
||||
}
|
||||
|
||||
function itemName(item: InstanceUpgradePlanItem): string {
|
||||
return contentMetadata(item).title
|
||||
}
|
||||
|
||||
function projectPath(item: InstanceUpgradePlanItem): string | null {
|
||||
return upgradeProjectPath(item.provider, item.projectId)
|
||||
}
|
||||
|
||||
function parkProjectReturn() {
|
||||
parkUpgradeFlow({
|
||||
instanceId: flow.instanceId.value,
|
||||
returnFullPath: router.currentRoute.value.fullPath,
|
||||
targetEnvironment: flow.targetEnvironment.value,
|
||||
plan: flow.plan.value,
|
||||
createFullBackup: flow.createFullBackup.value,
|
||||
directFullBackupPreference: flow.directFullBackupPreference.value,
|
||||
sharedUpgradeMode: flow.sharedUpgradeMode.value,
|
||||
activeJobId: flow.activeJobId.value,
|
||||
result: flow.result.value,
|
||||
initialBlockingPlanId: flow.initialBlockingPlanId.value,
|
||||
initialBlockingIssues: flow.initialBlockingIssues.value,
|
||||
customizeActiveStrategy: flow.customizeActiveStrategy.value,
|
||||
})
|
||||
}
|
||||
|
||||
function itemIcon(item: InstanceUpgradePlanItem): string {
|
||||
return localContentIconUrl(contentMetadata(item).iconUrl)
|
||||
}
|
||||
|
||||
function providerLabel(provider: ContentProvider | null): string {
|
||||
if (provider === 'modrinth') return formatMessage(messages.providerModrinth)
|
||||
if (provider === 'curseforge') return formatMessage(messages.providerCurseForge)
|
||||
return formatMessage(messages.providerUnknown)
|
||||
}
|
||||
|
||||
function currentVersionLabel(item: InstanceUpgradePlanItem): string {
|
||||
const version = releaseLabel(
|
||||
item.provider,
|
||||
item.projectId,
|
||||
item.currentReleaseId,
|
||||
contentMetadata(item).currentVersion,
|
||||
)
|
||||
return formatMessage(messages.currentVersion, { version })
|
||||
}
|
||||
|
||||
function effectiveTargetRelease(item: InstanceUpgradePlanItem): string | null {
|
||||
return (
|
||||
effectiveSolution.value?.selections.find((entry) => entry.contentId === item.contentId)
|
||||
?.targetReleaseId ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function effectiveTargetVersion(item: InstanceUpgradePlanItem): string {
|
||||
return releaseLabel(item.provider, item.projectId, effectiveTargetRelease(item))
|
||||
}
|
||||
|
||||
function releaseLabel(
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null | undefined,
|
||||
fallback?: string | null,
|
||||
): string {
|
||||
if (!provider || !projectId || !releaseId) return fallback ?? formatMessage(messages.noTarget)
|
||||
const resolved = upgradeVersionDisplayLabel(versionMetadataQuery.data.value, {
|
||||
provider,
|
||||
projectId,
|
||||
releaseId,
|
||||
})
|
||||
return resolved === releaseId && fallback ? fallback : resolved
|
||||
}
|
||||
|
||||
function draftChoice(contentId: string): string {
|
||||
return (
|
||||
draftConstraints.value.find((constraint) => constraint.contentId === contentId)?.versionId ??
|
||||
AUTOMATIC
|
||||
)
|
||||
}
|
||||
|
||||
function constraintOptions(item: InstanceUpgradePlanItem): string[] {
|
||||
const selected = draftChoice(item.contentId)
|
||||
return [
|
||||
AUTOMATIC,
|
||||
...new Set([...item.candidateReleaseIds, ...(selected === AUTOMATIC ? [] : [selected])]),
|
||||
]
|
||||
}
|
||||
|
||||
function constraintOptionLabel(item: InstanceUpgradePlanItem, value: string): string {
|
||||
if (value === AUTOMATIC) return formatMessage(messages.automatic)
|
||||
const key =
|
||||
item.provider && item.projectId
|
||||
? upgradeVersionCacheKey(item.provider, item.projectId, value)
|
||||
: null
|
||||
const version = key ? versionMetadataQuery.data.value?.get(key) : null
|
||||
if (!version) {
|
||||
return formatMessage(messages.specificVersion, {
|
||||
version: releaseLabel(item.provider, item.projectId, value),
|
||||
})
|
||||
}
|
||||
const channel =
|
||||
version.channel === 'release' || version.channel === 1
|
||||
? formatMessage(messages.channelRelease)
|
||||
: version.channel === 'beta' || version.channel === 2
|
||||
? formatMessage(messages.channelBeta)
|
||||
: formatMessage(messages.channelAlpha)
|
||||
return formatMessage(messages.specificVersionWithChannel, {
|
||||
version: version.version,
|
||||
channel,
|
||||
})
|
||||
}
|
||||
|
||||
function setDraftChoice(item: InstanceUpgradePlanItem, versionId: string) {
|
||||
if (!item.provider || !item.projectId) return
|
||||
const constraint =
|
||||
versionId === AUTOMATIC
|
||||
? null
|
||||
: {
|
||||
contentId: item.contentId,
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
versionId,
|
||||
}
|
||||
draftConstraints.value = setFixedConstraint(draftConstraints.value, constraint, item.contentId)
|
||||
requestError.value = null
|
||||
}
|
||||
|
||||
async function applyCustomChoices() {
|
||||
if (!canApplyCustom.value) return
|
||||
requestBusy.value = true
|
||||
requestError.value = null
|
||||
try {
|
||||
const updatedPlan = await resolve_custom_instance_upgrade_solution(
|
||||
plan.value.id,
|
||||
draftConstraints.value,
|
||||
)
|
||||
flow.setPlan(updatedPlan)
|
||||
draftConstraints.value = updatedPlan.customConstraints.map((item) => ({ ...item }))
|
||||
activeStrategy.value = 'custom'
|
||||
flow.customizeActiveStrategy.value = 'custom'
|
||||
} catch (error) {
|
||||
requestError.value = errorMessage(error)
|
||||
} finally {
|
||||
requestBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function customIssueTitle(issue: InstanceUpgradeIssue): string {
|
||||
return issue.code === 'search_limit_reached'
|
||||
? formatMessage(messages.searchLimitTitle)
|
||||
: formatMessage(messages.conflictTitle)
|
||||
}
|
||||
|
||||
function customIssueBody(issue: InstanceUpgradeIssue): string {
|
||||
if (issue.code === 'search_limit_reached') return formatMessage(messages.searchLimitBody)
|
||||
return issue.message || issue.code
|
||||
}
|
||||
|
||||
function dependencyActionLabel(kind: InstanceUpgradeDependencyChangeKind): string {
|
||||
if (kind === 'add') return formatMessage(messages.dependencyAdd)
|
||||
if (kind === 'upgrade') return formatMessage(messages.dependencyUpgrade)
|
||||
if (kind === 'remove') return formatMessage(messages.dependencyRemove)
|
||||
return formatMessage(messages.dependencyKeep)
|
||||
}
|
||||
|
||||
function dependencyChangeDescription(change: InstanceUpgradeDependencyChange): string {
|
||||
const target = releaseLabel(change.provider, change.projectId, change.targetReleaseId)
|
||||
return change.existingContentId
|
||||
? formatMessage(messages.dependencyReused, {
|
||||
current: releaseLabel(change.provider, change.projectId, change.currentReleaseId),
|
||||
target,
|
||||
})
|
||||
: formatMessage(messages.dependencyNew, { target })
|
||||
}
|
||||
|
||||
async function goBack() {
|
||||
await router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}/upgrade/compatibility`)
|
||||
}
|
||||
|
||||
async function continueUpgrade() {
|
||||
if (!canContinue.value) return
|
||||
await router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}/upgrade/confirm`)
|
||||
}
|
||||
</script>
|
||||
40
apps/app-frontend/src/pages/instance/upgrade/Progress.vue
Normal file
40
apps/app-frontend/src/pages/instance/upgrade/Progress.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<p class="m-0 py-2 text-secondary">{{ formatMessage(messages.opening) }}</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { upgradeProgressDestination, useInstanceUpgradeFlow } from './flow'
|
||||
|
||||
const messages = defineMessages({
|
||||
opening: {
|
||||
id: 'instance.upgrade.progress.opening-downloads',
|
||||
defaultMessage: 'Opening download task…',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
let navigating = false
|
||||
|
||||
flow.registerStepControls(null)
|
||||
|
||||
watch(
|
||||
[flow.jobRecoveryState, flow.activeJobId],
|
||||
async ([recoveryState, jobId]) => {
|
||||
const destination = upgradeProgressDestination(recoveryState, jobId, flow.instanceId.value)
|
||||
if (!destination || navigating) return
|
||||
navigating = true
|
||||
try {
|
||||
await router.replace(destination)
|
||||
} finally {
|
||||
navigating = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
65
apps/app-frontend/src/pages/instance/upgrade/Result.vue
Normal file
65
apps/app-frontend/src/pages/instance/upgrade/Result.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="py-2">
|
||||
<LoadingIndicator v-if="loading" class="pt-8" />
|
||||
<Admonition
|
||||
v-else-if="errorMessage"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.unavailable)"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Admonition>
|
||||
<UpgradeResultDetails v-else-if="job?.upgrade_result" :result="job.upgrade_result" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition, defineMessages, LoadingIndicator, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { install_job_get, type InstallJobSnapshot } from '@/helpers/install'
|
||||
|
||||
import { isSuccessfulUpgradeJob } from './result'
|
||||
import UpgradeResultDetails from './UpgradeResultDetails.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const job = ref<InstallJobSnapshot | null>(null)
|
||||
const messages = defineMessages({
|
||||
unavailable: {
|
||||
id: 'instance.upgrade.result.unavailable',
|
||||
defaultMessage: 'Upgrade result unavailable',
|
||||
},
|
||||
missing: {
|
||||
id: 'instance.upgrade.result.missing',
|
||||
defaultMessage: 'This persisted upgrade result could not be loaded.',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const jobId = typeof route.query.job === 'string' ? route.query.job : null
|
||||
if (!jobId) {
|
||||
await router.replace('/downloads')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const persisted = await install_job_get(jobId)
|
||||
const routeInstanceId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (
|
||||
!isSuccessfulUpgradeJob(persisted) ||
|
||||
persisted.upgrade_result?.sourceInstanceId !== routeInstanceId
|
||||
) {
|
||||
errorMessage.value = formatMessage(messages.missing)
|
||||
return
|
||||
}
|
||||
job.value = persisted
|
||||
} catch {
|
||||
errorMessage.value = formatMessage(messages.missing)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
429
apps/app-frontend/src/pages/instance/upgrade/Select.vue
Normal file
429
apps/app-frontend/src/pages/instance/upgrade/Select.vue
Normal file
@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h2>
|
||||
<p class="m-0 max-w-2xl text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(messages.current) }}
|
||||
</h3>
|
||||
<p class="mb-1 mt-3 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.minecraftVersion, { version: instance.game_version }) }}
|
||||
</p>
|
||||
<p class="m-0 text-secondary">{{ currentLoaderLabel }}</p>
|
||||
</Card>
|
||||
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(messages.target) }}
|
||||
</h3>
|
||||
<label class="mb-2 mt-3 block text-sm font-medium text-contrast">
|
||||
{{ formatMessage(messages.minecraft) }}
|
||||
</label>
|
||||
<div v-if="gameVersionsQuery.isPending.value" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.loadingVersions) }}
|
||||
</div>
|
||||
<DropdownSelect
|
||||
v-else-if="targetVersions.length"
|
||||
v-model="selectedGameVersion"
|
||||
class="max-w-full"
|
||||
:name="formatMessage(messages.targetVersionInput)"
|
||||
:options="targetVersions"
|
||||
:disabled="flow.busy.value"
|
||||
/>
|
||||
<p v-else class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.noNewerRelease) }}
|
||||
</p>
|
||||
<template v-if="isFabric && selectedGameVersion">
|
||||
<label class="mb-2 mt-4 block text-sm font-medium text-contrast">
|
||||
{{ formatMessage(messages.fabricVersion) }}
|
||||
</label>
|
||||
<DropdownSelect
|
||||
v-model="selectedFabricVersion"
|
||||
class="max-w-full"
|
||||
:name="formatMessage(messages.fabricVersion)"
|
||||
:options="fabricLoaderOptions"
|
||||
:display-name="fabricLoaderOptionLabel"
|
||||
:disabled="flow.busy.value"
|
||||
auto-placement
|
||||
/>
|
||||
<p
|
||||
v-if="
|
||||
fabricLoaderVersionsQuery.isPending.value &&
|
||||
fabricLoaderVersionsQuery.isFetching.value
|
||||
"
|
||||
class="mb-0 mt-2 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.loadingFabricVersions) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="fabricLoaderVersionsQuery.isError.value"
|
||||
class="mb-0 mt-2 text-sm text-orange"
|
||||
>
|
||||
{{ formatMessage(messages.fabricVersionsError) }}
|
||||
</p>
|
||||
<p v-else-if="manualFabricSelectionUnavailable" class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.manualFabricVersionUnavailable) }}
|
||||
</p>
|
||||
<p v-else-if="noNonDowngradeFabricVersion" class="mb-0 mt-2 text-sm text-orange">
|
||||
{{ formatMessage(messages.noNonDowngradeFabricVersion) }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-else-if="!isFabric" class="mb-0 mt-3 text-secondary">
|
||||
{{ formatLoaderLabel(instance.loader) }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="gameVersionsQuery.isError.value"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.metadataErrorTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.metadataErrorBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else-if="versionTargets && !versionTargets.currentFound"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.currentVersionMissingTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.currentVersionMissingBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="flow.error.value"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.planningErrorTitle)"
|
||||
>
|
||||
{{ errorMessage(flow.error.value) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="flow.busy.value" class="flex items-center gap-2 text-secondary" role="status">
|
||||
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
|
||||
{{ formatMessage(messages.planningStatus, { count: snapshotItemCount }) }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Card,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
formatLoaderLabel,
|
||||
loaderVersionsForGameVersion,
|
||||
scopedLoaderMetadataQueryKey,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import { plan_instance_upgrade } from '@/helpers/instance-upgrade'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { Manifest } from '@/helpers/types'
|
||||
import { compareSemanticVersions } from '@/helpers/version-compatibility'
|
||||
|
||||
import {
|
||||
AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
automaticFabricLoaderTargetAvailable,
|
||||
fabricLoaderVersionForTarget,
|
||||
fabricUpgradeLoaderVersions,
|
||||
inferShaderRuntime,
|
||||
newerStableGameVersions,
|
||||
preserveFabricLoaderSelection,
|
||||
resolveUpgradePlanSelection,
|
||||
shouldReuseUpgradePlan,
|
||||
} from './analysis'
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
import { isCurrentUpgradeSelectPlanning } from './planning-navigation'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.select.title', defaultMessage: 'Upgrade instance' },
|
||||
description: {
|
||||
id: 'instance.upgrade.select.description',
|
||||
defaultMessage: 'Choose which Minecraft version this instance should be upgraded to.',
|
||||
},
|
||||
current: { id: 'instance.upgrade.select.current', defaultMessage: 'Current' },
|
||||
target: { id: 'instance.upgrade.select.target', defaultMessage: 'Target' },
|
||||
minecraft: { id: 'instance.upgrade.select.minecraft', defaultMessage: 'Minecraft version' },
|
||||
minecraftVersion: {
|
||||
id: 'instance.upgrade.select.minecraft-version',
|
||||
defaultMessage: 'Minecraft {version}',
|
||||
},
|
||||
targetVersionInput: {
|
||||
id: 'instance.upgrade.select.target-version-input',
|
||||
defaultMessage: 'Target Minecraft version',
|
||||
},
|
||||
loadingVersions: {
|
||||
id: 'instance.upgrade.select.loading-versions',
|
||||
defaultMessage: 'Loading Minecraft versions…',
|
||||
},
|
||||
fabricVersion: {
|
||||
id: 'instance.upgrade.select.fabric-version',
|
||||
defaultMessage: 'Fabric version',
|
||||
},
|
||||
automatic: { id: 'instance.upgrade.select.automatic', defaultMessage: 'Automatic' },
|
||||
loadingFabricVersions: {
|
||||
id: 'instance.upgrade.select.loading-fabric-versions',
|
||||
defaultMessage: 'Loading Fabric versions…',
|
||||
},
|
||||
fabricVersionsError: {
|
||||
id: 'instance.upgrade.select.fabric-versions-error',
|
||||
defaultMessage: 'Fabric versions could not be loaded. Automatic remains available.',
|
||||
},
|
||||
manualFabricVersionUnavailable: {
|
||||
id: 'instance.upgrade.select.manual-fabric-version-unavailable',
|
||||
defaultMessage: 'Manual Fabric version selection is unavailable.',
|
||||
},
|
||||
noNonDowngradeFabricVersion: {
|
||||
id: 'instance.upgrade.select.no-non-downgrade-fabric-version',
|
||||
defaultMessage: 'No Fabric version that avoids downgrading is available for this target.',
|
||||
},
|
||||
noNewerRelease: {
|
||||
id: 'instance.upgrade.select.no-newer-release',
|
||||
defaultMessage: 'This instance already uses the latest stable Minecraft version.',
|
||||
},
|
||||
metadataErrorTitle: {
|
||||
id: 'instance.upgrade.select.metadata-error-title',
|
||||
defaultMessage: 'Minecraft versions could not be loaded',
|
||||
},
|
||||
metadataErrorBody: {
|
||||
id: 'instance.upgrade.select.metadata-error-body',
|
||||
defaultMessage: 'Check your connection and try again.',
|
||||
},
|
||||
currentVersionMissingTitle: {
|
||||
id: 'instance.upgrade.select.current-version-missing-title',
|
||||
defaultMessage: 'Current version not found in metadata',
|
||||
},
|
||||
currentVersionMissingBody: {
|
||||
id: 'instance.upgrade.select.current-version-missing-body',
|
||||
defaultMessage: 'Stable releases are shown without guessing their numeric order.',
|
||||
},
|
||||
planningErrorTitle: {
|
||||
id: 'instance.upgrade.select.planning-error-title',
|
||||
defaultMessage: 'Compatibility analysis failed',
|
||||
},
|
||||
planningStatus: {
|
||||
id: 'instance.upgrade.select.planning-status',
|
||||
defaultMessage: 'Analyzing compatibility for {count} content items…',
|
||||
},
|
||||
checkCompatibility: {
|
||||
id: 'instance.upgrade.select.check-compatibility',
|
||||
defaultMessage: 'Check compatibility',
|
||||
},
|
||||
reviewCompatibility: {
|
||||
id: 'instance.upgrade.select.review-compatibility',
|
||||
defaultMessage: 'Review compatibility',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const instance = computed(() => flow.instance.value)
|
||||
const selectedGameVersion = ref<string | null>(flow.targetEnvironment.value?.gameVersion ?? null)
|
||||
const selectedFabricVersion = ref(
|
||||
flow.targetEnvironment.value?.modLoaderVersion ?? AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
)
|
||||
const isFabric = computed(() => instance.value.loader === 'fabric')
|
||||
|
||||
const gameVersionsQuery = useQuery({
|
||||
queryKey: ['instance-upgrade', 'game-versions'],
|
||||
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
|
||||
})
|
||||
const contentDataQuery = useQuery({
|
||||
queryKey: computed(() => ['instance-upgrade', 'content-data', flow.instanceId.value]),
|
||||
queryFn: () => loadInstanceContentData(flow.instanceId.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const fabricLoaderVersionsQuery = useQuery({
|
||||
queryKey: computed(() =>
|
||||
scopedLoaderMetadataQueryKey('instance-upgrade', 'fabric', selectedGameVersion.value ?? ''),
|
||||
),
|
||||
queryFn: ({ queryKey }) => get_loader_versions(queryKey[2], queryKey[3]) as Promise<Manifest>,
|
||||
enabled: computed(() => isFabric.value && selectedGameVersion.value !== null),
|
||||
})
|
||||
|
||||
const versionTargets = computed(() => {
|
||||
if (!gameVersionsQuery.data.value) return null
|
||||
return newerStableGameVersions(gameVersionsQuery.data.value, instance.value.game_version)
|
||||
})
|
||||
const targetVersions = computed(() => versionTargets.value?.versions ?? [])
|
||||
const currentFabricVersionComparable = computed(
|
||||
() =>
|
||||
Boolean(instance.value.loader_version) &&
|
||||
compareSemanticVersions(instance.value.loader_version!, instance.value.loader_version!) !==
|
||||
null,
|
||||
)
|
||||
const availableFabricLoaderVersions = computed(() =>
|
||||
fabricUpgradeLoaderVersions(
|
||||
instance.value.loader_version,
|
||||
loaderVersionsForGameVersion(
|
||||
fabricLoaderVersionsQuery.data.value,
|
||||
selectedGameVersion.value ?? '',
|
||||
).map((version) => version.id),
|
||||
),
|
||||
)
|
||||
const manualFabricSelectionUnavailable = computed(
|
||||
() => isFabric.value && !currentFabricVersionComparable.value,
|
||||
)
|
||||
const noNonDowngradeFabricVersion = computed(
|
||||
() =>
|
||||
isFabric.value &&
|
||||
fabricLoaderVersionsQuery.isSuccess.value &&
|
||||
currentFabricVersionComparable.value &&
|
||||
availableFabricLoaderVersions.value.length === 0,
|
||||
)
|
||||
const fabricLoaderOptions = computed(() => {
|
||||
const exactVersions = fabricLoaderVersionsQuery.isSuccess.value
|
||||
? availableFabricLoaderVersions.value
|
||||
: selectedFabricVersion.value !== AUTOMATIC_FABRIC_LOADER_VERSION
|
||||
? [selectedFabricVersion.value]
|
||||
: []
|
||||
return [AUTOMATIC_FABRIC_LOADER_VERSION, ...exactVersions]
|
||||
})
|
||||
const currentLoaderLabel = computed(() => {
|
||||
const loader = formatLoaderLabel(instance.value.loader)
|
||||
return instance.value.loader_version ? `${loader} ${instance.value.loader_version}` : loader
|
||||
})
|
||||
const snapshotItemCount = computed(() => contentDataQuery.data.value?.snapshot.items.length ?? 0)
|
||||
const canPlan = computed(
|
||||
() =>
|
||||
selectedGameVersion.value !== null &&
|
||||
targetVersions.value.includes(selectedGameVersion.value) &&
|
||||
!flow.busy.value &&
|
||||
!gameVersionsQuery.isError.value &&
|
||||
(!isFabric.value ||
|
||||
(selectedFabricVersion.value === AUTOMATIC_FABRIC_LOADER_VERSION &&
|
||||
automaticFabricLoaderTargetAvailable(
|
||||
fabricLoaderVersionsQuery.isSuccess.value,
|
||||
currentFabricVersionComparable.value,
|
||||
availableFabricLoaderVersions.value,
|
||||
)) ||
|
||||
availableFabricLoaderVersions.value.includes(selectedFabricVersion.value)),
|
||||
)
|
||||
|
||||
watch(
|
||||
versionTargets,
|
||||
(targets) => {
|
||||
if (!targets) return
|
||||
if (selectedGameVersion.value && targets.versions.includes(selectedGameVersion.value)) {
|
||||
return
|
||||
}
|
||||
selectedGameVersion.value = targets.versions[0] ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selectedGameVersion, () => (flow.error.value = null))
|
||||
watch(selectedFabricVersion, () => (flow.error.value = null))
|
||||
watch(
|
||||
[() => fabricLoaderVersionsQuery.isSuccess.value, availableFabricLoaderVersions],
|
||||
([loaded, versions]) => {
|
||||
if (!loaded) return
|
||||
selectedFabricVersion.value = preserveFabricLoaderSelection(
|
||||
selectedFabricVersion.value,
|
||||
versions,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function fabricLoaderOptionLabel(version: string) {
|
||||
return version === AUTOMATIC_FABRIC_LOADER_VERSION ? formatMessage(messages.automatic) : version
|
||||
}
|
||||
|
||||
const requestedTargetEnvironment = computed(() =>
|
||||
selectedGameVersion.value
|
||||
? {
|
||||
gameVersion: selectedGameVersion.value,
|
||||
modLoader: instance.value.loader,
|
||||
modLoaderVersion: isFabric.value
|
||||
? fabricLoaderVersionForTarget(selectedFabricVersion.value)
|
||||
: null,
|
||||
shaderRuntime: inferShaderRuntime(instance.value, contentDataQuery.data.value?.snapshot),
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const reusesPlan = computed(() =>
|
||||
shouldReuseUpgradePlan(flow.instanceId.value, flow.plan.value, requestedTargetEnvironment.value),
|
||||
)
|
||||
|
||||
function registerControls() {
|
||||
flow.registerStepControls({
|
||||
canNext: canPlan,
|
||||
busy: flow.busy,
|
||||
nextLabel: formatMessage(
|
||||
reusesPlan.value ? messages.reviewCompatibility : messages.checkCompatibility,
|
||||
),
|
||||
onNext: startPlanning,
|
||||
onBack: () => router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}`),
|
||||
})
|
||||
}
|
||||
onMounted(registerControls)
|
||||
watch([canPlan, reusesPlan, () => flow.busy.value], registerControls)
|
||||
let planningGeneration = 0
|
||||
let disposed = false
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
planningGeneration += 1
|
||||
flow.busy.value = false
|
||||
flow.registerStepControls(null)
|
||||
})
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'object' && error && 'message' in error) return String(error.message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function startPlanning() {
|
||||
if (!canPlan.value || !selectedGameVersion.value) return
|
||||
|
||||
const targetEnvironment = requestedTargetEnvironment.value!
|
||||
const instanceId = flow.instanceId.value
|
||||
const generation = ++planningGeneration
|
||||
flow.error.value = null
|
||||
flow.busy.value = true
|
||||
try {
|
||||
const planned = await resolveUpgradePlanSelection(
|
||||
instanceId,
|
||||
flow.plan.value,
|
||||
targetEnvironment,
|
||||
plan_instance_upgrade,
|
||||
)
|
||||
const routeInstanceId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (
|
||||
!isCurrentUpgradeSelectPlanning(
|
||||
disposed,
|
||||
generation,
|
||||
planningGeneration,
|
||||
route.name,
|
||||
routeInstanceId,
|
||||
instanceId,
|
||||
)
|
||||
)
|
||||
return
|
||||
if (!planned.reused) flow.setPlan(planned.plan)
|
||||
flow.setTargetEnvironment(planned.plan.targetEnvironment)
|
||||
await router.push(`/instance/${encodeURIComponent(instanceId)}/upgrade/compatibility`)
|
||||
} catch (error) {
|
||||
if (!disposed && generation === planningGeneration) flow.error.value = error
|
||||
} finally {
|
||||
if (!disposed && generation === planningGeneration) flow.busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<FloatingActionBar
|
||||
:shown="true"
|
||||
:aria-label="formatMessage(messages.aria)"
|
||||
hide-when-modal-open
|
||||
allow-overflow
|
||||
>
|
||||
<div
|
||||
class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3"
|
||||
>
|
||||
<div class="justify-self-start">
|
||||
<ButtonStyled v-if="!progress.complete && controls" type="outlined" size="small">
|
||||
<button :disabled="!controls" @click="controls?.onBack()">
|
||||
<ArrowLeftIcon aria-hidden="true" />
|
||||
<span class="bar-label">{{ formatMessage(messages.back) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="relative min-w-0 flex-1 select-none"
|
||||
tabindex="0"
|
||||
:aria-label="formatMessage(messages.steps)"
|
||||
@mouseenter="progressOpen = true"
|
||||
@mouseleave="progressOpen = false"
|
||||
@focus="progressOpen = true"
|
||||
@blur="progressOpen = false"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center gap-1.5 truncate text-center text-sm text-secondary"
|
||||
>
|
||||
<CheckCircleIcon
|
||||
v-if="progress.complete"
|
||||
class="size-4 shrink-0 text-green"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<template v-if="progress.complete">{{ formatMessage(messages.complete) }}</template>
|
||||
<template v-else>
|
||||
{{ progress.currentIndex + 1 }} / {{ progress.steps.length }} ·
|
||||
{{ formatMessage(stepLabels[progress.currentIndex]) }}
|
||||
</template>
|
||||
</span>
|
||||
<div
|
||||
v-if="progressOpen"
|
||||
class="absolute bottom-[calc(100%+0.75rem)] left-1/2 z-10 flex w-max max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col gap-2 rounded-md border border-solid border-surface-5 bg-surface-3 px-3 py-2 text-sm shadow-lg"
|
||||
style="background-color: var(--color-tooltip-bg)"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in progress.steps"
|
||||
:key="step"
|
||||
class="flex items-center gap-2 whitespace-nowrap"
|
||||
:class="stepClass(index)"
|
||||
>
|
||||
<CheckCircleIcon
|
||||
v-if="progress.complete || index < progress.currentIndex"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="size-3 shrink-0 rounded-full border-2 border-solid border-current"
|
||||
:class="{ 'bg-current': index === progress.currentIndex }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatMessage(stepLabels[index]) }}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="justify-self-end">
|
||||
<span v-tooltip="blockerTooltip" tabindex="0" :aria-label="blockerTooltip">
|
||||
<ButtonStyled v-if="!progress.complete && controls" color="brand" size="small">
|
||||
<button :disabled="!controls || !canNext || busy" @click="controls?.onNext()">
|
||||
<SpinnerIcon v-if="busy" class="animate-spin" aria-hidden="true" />
|
||||
<CircleArrowRightIcon v-else aria-hidden="true" />
|
||||
<span class="bar-label">{{
|
||||
controls?.nextLabel ?? formatMessage(messages.next)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftIcon, CheckCircleIcon, CircleArrowRightIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, FloatingActionBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
import { upgradeControlEnabled, upgradeProgressModel } from './flow-controls'
|
||||
|
||||
const messages = defineMessages({
|
||||
aria: { id: 'instance.upgrade.flow.aria', defaultMessage: 'Instance upgrade navigation' },
|
||||
back: { id: 'instance.upgrade.flow.back', defaultMessage: 'Previous' },
|
||||
next: { id: 'instance.upgrade.flow.next', defaultMessage: 'Next' },
|
||||
steps: { id: 'instance.upgrade.flow.steps', defaultMessage: 'Upgrade steps' },
|
||||
target: { id: 'instance.upgrade.flow.target', defaultMessage: 'Upgrade target' },
|
||||
issues: { id: 'instance.upgrade.flow.issues', defaultMessage: 'Resolve issues' },
|
||||
preferences: { id: 'instance.upgrade.flow.preferences', defaultMessage: 'Upgrade preferences' },
|
||||
confirm: { id: 'instance.upgrade.flow.confirm', defaultMessage: 'Confirm upgrade' },
|
||||
progress: { id: 'instance.upgrade.flow.progress', defaultMessage: 'Upgrading' },
|
||||
complete: { id: 'instance.upgrade.flow.complete', defaultMessage: 'Upgrade complete' },
|
||||
resolveBlockers: {
|
||||
id: 'instance.upgrade.compatibility.resolve-blockers-tooltip',
|
||||
defaultMessage: 'Please resolve all blocking items before continuing.',
|
||||
},
|
||||
chooseSharedMode: {
|
||||
id: 'instance.upgrade.confirm.choose-shared-mode-tooltip',
|
||||
defaultMessage: 'Choose how this shared instance should be upgraded.',
|
||||
},
|
||||
})
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
const progressOpen = ref(false)
|
||||
const stepLabels = [
|
||||
messages.target,
|
||||
messages.issues,
|
||||
messages.preferences,
|
||||
messages.confirm,
|
||||
messages.progress,
|
||||
]
|
||||
const progress = computed(() => upgradeProgressModel(route.path))
|
||||
const controls = computed(() => flow.controls.value)
|
||||
const canNext = computed(() => upgradeControlEnabled(flow.controls.value?.canNext))
|
||||
const busy = computed(() => upgradeControlEnabled(flow.controls.value?.busy))
|
||||
const showBlockerTooltip = computed(
|
||||
() =>
|
||||
route.path.endsWith('/upgrade/compatibility') &&
|
||||
(flow.plan.value?.blockingIssues.length ?? 0) > 0 &&
|
||||
!busy.value,
|
||||
)
|
||||
const blockerTooltip = computed(() => {
|
||||
if (
|
||||
route.path.endsWith('/upgrade/confirm') &&
|
||||
flow.instance.value &&
|
||||
flow.sharedUpgradeMode.value === null &&
|
||||
(flow.instance.value.link?.type === 'shared_instance' ||
|
||||
Boolean(flow.instance.value.symlink_target))
|
||||
) {
|
||||
return formatMessage(messages.chooseSharedMode)
|
||||
}
|
||||
return showBlockerTooltip.value ? formatMessage(messages.resolveBlockers) : undefined
|
||||
})
|
||||
|
||||
function stepClass(index: number) {
|
||||
if (progress.value.complete || index < progress.value.currentIndex) return 'text-green'
|
||||
if (index === progress.value.currentIndex) return 'font-semibold text-brand'
|
||||
return 'text-secondary'
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,824 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Card v-if="warnings.length" class="!m-0 !p-0">
|
||||
<Accordion
|
||||
class="block w-full"
|
||||
:open-by-default="warningsDefaultOpen"
|
||||
button-class="group flex !w-full cursor-pointer border-0 bg-transparent p-4 text-left hover:bg-surface-3 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-brand-shadow"
|
||||
content-class="border-0 border-t border-solid border-divider p-3"
|
||||
@on-open="warningsOpen = true"
|
||||
@on-close="warningsOpen = false"
|
||||
>
|
||||
<template #button="{ open }">
|
||||
<div data-warning-trigger-content class="flex w-full min-w-0 flex-col gap-2">
|
||||
<div class="flex w-full min-w-0 items-center gap-2">
|
||||
<TriangleAlertIcon class="size-5 shrink-0 text-orange" aria-hidden="true" />
|
||||
<strong class="min-w-0">{{ formatMessage(messages.warningsTitle) }}</strong>
|
||||
<Badge color="orange" :type="String(warnings.length)" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 shrink-0 text-secondary transition-transform duration-300 group-hover:text-primary"
|
||||
:class="{ 'rotate-180': open }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 pl-7 text-xs text-secondary">
|
||||
<span v-if="warningSummary.local">{{ summaryLabel('local') }}</span>
|
||||
<span v-if="warningSummary.kept">{{ summaryLabel('kept') }}</span>
|
||||
<span v-if="warningSummary.fallback">{{ summaryLabel('fallback') }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-7 text-xs text-secondary">{{ formatMessage(messages.reassurance) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="warningsOpen" class="flex flex-col gap-3">
|
||||
<StyledInput
|
||||
v-model="warningSearch"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.warningSearch)"
|
||||
:aria-label="formatMessage(messages.warningSearch)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
:aria-label="formatMessage(messages.warningFilters)"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-for="option in warningFilters"
|
||||
:key="option.value"
|
||||
size="small"
|
||||
:type="warningFilter === option.value ? 'standard' : 'outlined'"
|
||||
:color="warningFilter === option.value ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
:aria-pressed="warningFilter === option.value"
|
||||
@click="warningFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">{{ warningPaginationLabel }}</span>
|
||||
<div v-if="warningPage.items.length">
|
||||
<ul class="m-0 flex list-none flex-col gap-2 p-0">
|
||||
<li
|
||||
v-for="warning in warningPage.items"
|
||||
:key="warning.key"
|
||||
data-upgrade-warning-row
|
||||
class="rounded-md bg-surface-2 p-3"
|
||||
>
|
||||
<strong class="block text-sm text-contrast">{{ warningHeadline(warning) }}</strong>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">{{ warningDescription(warning) }}</p>
|
||||
<div class="mt-2 text-sm font-medium text-contrast">
|
||||
{{ warningIdentity(warning) }}
|
||||
</div>
|
||||
<div class="text-xs text-secondary">{{ warningContext(warning) }}</div>
|
||||
<details v-if="hasTechnicalDetails(warning)" class="mt-2 text-xs text-secondary">
|
||||
<summary class="cursor-pointer">
|
||||
{{ formatMessage(messages.technicalDetails) }}
|
||||
</summary>
|
||||
<dl class="mb-0 mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<template v-if="warning.relativePath">
|
||||
<dt>{{ formatMessage(messages.relativePath) }}</dt>
|
||||
<dd class="m-0 break-all">
|
||||
<code>{{ warning.relativePath }}</code>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="warning.code">
|
||||
<dt>{{ formatMessage(messages.warningCode) }}</dt>
|
||||
<dd class="m-0">
|
||||
<code>{{ warning.code }}</code>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="warning.provider || warning.projectId">
|
||||
<dt>{{ formatMessage(messages.providerIdentity) }}</dt>
|
||||
<dd class="m-0 break-all">
|
||||
{{ warning.provider }} · {{ warning.projectId }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="m-0 rounded-md bg-surface-2 p-4 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.noWarningMatches) }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-sm text-secondary">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="warningPage.page <= 1" @click="warningPageNumber -= 1">
|
||||
{{ formatMessage(messages.previous) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span>{{ warningPage.page }} / {{ warningPage.pageCount }}</span>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button
|
||||
:disabled="warningPage.page >= warningPage.pageCount"
|
||||
@click="warningPageNumber += 1"
|
||||
>
|
||||
{{ formatMessage(messages.next) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</Card>
|
||||
|
||||
<Card class="!m-0 !p-0">
|
||||
<Accordion
|
||||
button-class="flex w-full cursor-pointer items-center border-0 bg-transparent p-4 text-left hover:bg-surface-3"
|
||||
content-class="border-0 border-t border-solid border-divider p-4"
|
||||
@on-open="detailsOpen = true"
|
||||
@on-close="detailsOpen = false"
|
||||
>
|
||||
<template #title
|
||||
><strong>{{ formatMessage(messages.detailsTitle) }}</strong></template
|
||||
>
|
||||
<div v-if="detailsOpen" class="flex flex-col gap-3">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
:aria-label="formatMessage(messages.search)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
:aria-label="formatMessage(messages.filters)"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-for="option in filters"
|
||||
:key="option.value"
|
||||
size="small"
|
||||
:type="filter === option.value ? 'standard' : 'outlined'"
|
||||
:color="filter === option.value ? 'brand' : 'standard'"
|
||||
>
|
||||
<button :aria-pressed="filter === option.value" @click="filter = option.value">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleRows.length" class="divide-y divide-divider">
|
||||
<div
|
||||
v-for="item in visibleRows"
|
||||
:key="item.key"
|
||||
data-upgrade-detail-row
|
||||
class="flex items-center justify-between gap-3 py-2 text-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<RouterLink
|
||||
v-if="item.path"
|
||||
:to="item.path"
|
||||
class="block truncate font-medium text-contrast hover:text-brand hover:underline"
|
||||
>{{ item.title }}
|
||||
<ExternalIcon class="inline size-3" aria-hidden="true" /></RouterLink
|
||||
><span v-else class="block truncate font-medium text-contrast">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<div class="truncate text-xs text-secondary">{{ item.context }}</div>
|
||||
<div class="flex flex-wrap items-center gap-x-2 text-secondary">
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.currentReleaseId"
|
||||
:label="item.current"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.currentReleaseId"
|
||||
/><span v-else>{{ item.current }}</span>
|
||||
<span v-if="item.target" aria-hidden="true">→</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.targetReleaseId"
|
||||
:label="item.target"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.targetReleaseId"
|
||||
/><span v-else-if="item.target">{{ item.target }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge :color="item.badgeColor" :type="item.actionLabel" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="m-0 rounded-md bg-surface-2 p-4 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.noMatches) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-sm text-secondary">
|
||||
<span>{{ paginationLabel }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="pageData.page <= 1" @click="page -= 1">
|
||||
{{ formatMessage(messages.previous) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span>{{ pageData.page }} / {{ pageData.pageCount }}</span>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="pageData.page >= pageData.pageCount" @click="page += 1">
|
||||
{{ formatMessage(messages.next) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, ExternalIcon, SearchIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
defineMessages,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { get_content_snapshot } from '@/helpers/instance'
|
||||
import type { InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
import { shouldExpandUpgradeWarningsByDefault } from '@/helpers/post-upgrade-notice'
|
||||
import { upgradeProjectPath } from '@/helpers/upgrade-return-state'
|
||||
import {
|
||||
loadUpgradeProjectDisplayMetadata,
|
||||
loadUpgradeVersionDisplayMetadata,
|
||||
upgradeProjectDisplayCacheKey,
|
||||
upgradeVersionDisplayLabel,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
import {
|
||||
filterUpgradeDetailItems,
|
||||
paginateUpgradeDetailItems,
|
||||
type UpgradeDetailFilter,
|
||||
type UpgradeDetailItem,
|
||||
upgradeDetailItems,
|
||||
upgradeDetailProjectIdentities,
|
||||
upgradeDetailReleaseIdentities,
|
||||
} from './upgrade-result-presentation'
|
||||
import {
|
||||
filterUpgradeWarnings,
|
||||
paginateUpgradeWarnings,
|
||||
summarizeUpgradeWarnings,
|
||||
upgradeResultWarningRows,
|
||||
type UpgradeWarningCategory,
|
||||
upgradeWarningCategory,
|
||||
upgradeWarningContentKind,
|
||||
upgradeWarningDisplayName,
|
||||
type UpgradeWarningFilter,
|
||||
type UpgradeWarningRow,
|
||||
} from './upgrade-warning'
|
||||
import UpgradeVersionChangelogPopout from './UpgradeVersionChangelogPopout.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
warningsTitle: {
|
||||
id: 'instance.upgrade.result.warnings-title',
|
||||
defaultMessage: 'Compatibility warnings',
|
||||
},
|
||||
summaryLocal: {
|
||||
id: 'instance.upgrade.result.warning-summary-local',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# local item could not be identified} other {# local items could not be identified}}',
|
||||
},
|
||||
summaryKept: {
|
||||
id: 'instance.upgrade.result.warning-summary-kept',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# item kept its previous version} other {# items kept their previous version}}',
|
||||
},
|
||||
summaryFallback: {
|
||||
id: 'instance.upgrade.result.warning-summary-fallback',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# item used another compatibility fallback} other {# items used another compatibility fallback}}',
|
||||
},
|
||||
reassurance: {
|
||||
id: 'instance.upgrade.result.warning-reassurance',
|
||||
defaultMessage:
|
||||
'These warnings did not prevent the upgrade from completing. If the upgraded instance runs normally, no immediate action is required.',
|
||||
},
|
||||
warningSearch: {
|
||||
id: 'instance.upgrade.result.warning-search',
|
||||
defaultMessage: 'Search compatibility warnings',
|
||||
},
|
||||
warningFilters: {
|
||||
id: 'instance.upgrade.result.warning-filters',
|
||||
defaultMessage: 'Filter compatibility warnings',
|
||||
},
|
||||
warningFilterUnidentified: {
|
||||
id: 'instance.upgrade.result.warning-filter-unidentified',
|
||||
defaultMessage: 'Unidentified',
|
||||
},
|
||||
warningFilterFallback: {
|
||||
id: 'instance.upgrade.result.warning-filter-fallback',
|
||||
defaultMessage: 'Compatibility fallback',
|
||||
},
|
||||
noWarningMatches: {
|
||||
id: 'instance.upgrade.result.no-matching-warnings',
|
||||
defaultMessage: 'No matching compatibility warnings.',
|
||||
},
|
||||
unidentifiedHeadline: {
|
||||
id: 'instance.upgrade.result.warning-unidentified-headline',
|
||||
defaultMessage: 'This content was kept unchanged',
|
||||
},
|
||||
unidentifiedDescription: {
|
||||
id: 'instance.upgrade.result.warning-unidentified-description',
|
||||
defaultMessage:
|
||||
'The launcher could not confirm whether it supports Minecraft {targetVersion}. If you notice problems, try disabling it temporarily.',
|
||||
},
|
||||
unsupportedHeadline: {
|
||||
id: 'instance.upgrade.result.warning-unsupported-headline',
|
||||
defaultMessage: 'This content type was kept unchanged',
|
||||
},
|
||||
unsupportedDescription: {
|
||||
id: 'instance.upgrade.result.warning-unsupported-description',
|
||||
defaultMessage:
|
||||
'This content type cannot be upgraded automatically. Check for a manual update if problems occur.',
|
||||
},
|
||||
keptHeadline: {
|
||||
id: 'instance.upgrade.result.warning-kept-headline',
|
||||
defaultMessage: 'The previous version was kept',
|
||||
},
|
||||
keptDescription: {
|
||||
id: 'instance.upgrade.result.warning-kept-description',
|
||||
defaultMessage:
|
||||
'No verified compatible replacement was selected. Update it manually or disable it if the game has problems.',
|
||||
},
|
||||
prereleaseHeadline: {
|
||||
id: 'instance.upgrade.result.warning-prerelease-headline',
|
||||
defaultMessage: 'A prerelease version was used',
|
||||
},
|
||||
prereleaseDescription: {
|
||||
id: 'instance.upgrade.result.warning-prerelease-description',
|
||||
defaultMessage:
|
||||
'This item used an alpha, beta, or release-candidate build because no stable target build was available.',
|
||||
},
|
||||
shaderHeadline: {
|
||||
id: 'instance.upgrade.result.warning-shader-headline',
|
||||
defaultMessage: 'Shader compatibility could not be confirmed',
|
||||
},
|
||||
shaderDescription: {
|
||||
id: 'instance.upgrade.result.warning-shader-description',
|
||||
defaultMessage:
|
||||
'The shader was preserved, but compatibility with the target shader runtime is unknown. Disable it if rendering problems occur.',
|
||||
},
|
||||
dependencyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-dependency-headline',
|
||||
defaultMessage: 'A dependency needed a compatibility fallback',
|
||||
},
|
||||
dependencyDescription: {
|
||||
id: 'instance.upgrade.result.warning-dependency-description',
|
||||
defaultMessage:
|
||||
'The upgrade completed, but this dependency could not be verified normally. Review it if the game fails to start.',
|
||||
},
|
||||
conflictHeadline: {
|
||||
id: 'instance.upgrade.result.warning-conflict-headline',
|
||||
defaultMessage: 'Some dependency requirements conflicted',
|
||||
},
|
||||
conflictDescription: {
|
||||
id: 'instance.upgrade.result.warning-conflict-description',
|
||||
defaultMessage:
|
||||
'This content requested dependency versions that could not all be used together. Review its dependencies if the game fails to start.',
|
||||
},
|
||||
missingDependencyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-missing-dependency-headline',
|
||||
defaultMessage: 'A required dependency could not be found',
|
||||
},
|
||||
missingDependencyDescription: {
|
||||
id: 'instance.upgrade.result.warning-missing-dependency-description',
|
||||
defaultMessage:
|
||||
'The provider did not offer a required dependency for the target environment. Install a compatible dependency manually if needed.',
|
||||
},
|
||||
incompatibleDependencyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-incompatible-dependency-headline',
|
||||
defaultMessage: 'A dependency may be incompatible',
|
||||
},
|
||||
incompatibleDependencyDescription: {
|
||||
id: 'instance.upgrade.result.warning-incompatible-dependency-description',
|
||||
defaultMessage:
|
||||
'A dependency could not satisfy the selected versions. Review or disable the affected content if the game has problems.',
|
||||
},
|
||||
searchLimitHeadline: {
|
||||
id: 'instance.upgrade.result.warning-search-limit-headline',
|
||||
defaultMessage: 'Compatibility could not be fully verified',
|
||||
},
|
||||
searchLimitDescription: {
|
||||
id: 'instance.upgrade.result.warning-search-limit-description',
|
||||
defaultMessage:
|
||||
'The bounded compatibility search could not prove a complete result. Review this content if the game has problems.',
|
||||
},
|
||||
resolvedHeadline: {
|
||||
id: 'instance.upgrade.result.warning-resolved-headline',
|
||||
defaultMessage: 'A compatibility fallback was applied',
|
||||
},
|
||||
resolvedDescription: {
|
||||
id: 'instance.upgrade.result.warning-resolved-description',
|
||||
defaultMessage:
|
||||
'This warning occurred while planning, but the content was upgraded successfully. No immediate action is required.',
|
||||
},
|
||||
disabledHeadline: {
|
||||
id: 'instance.upgrade.result.warning-disabled-headline',
|
||||
defaultMessage: 'This content was disabled',
|
||||
},
|
||||
disabledDescription: {
|
||||
id: 'instance.upgrade.result.warning-disabled-description',
|
||||
defaultMessage:
|
||||
'The content was preserved on disk but disabled to avoid affecting the upgraded instance.',
|
||||
},
|
||||
legacyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-legacy-headline',
|
||||
defaultMessage: 'Compatibility needs attention',
|
||||
},
|
||||
technicalDetails: {
|
||||
id: 'instance.upgrade.result.technical-details',
|
||||
defaultMessage: 'Technical details',
|
||||
},
|
||||
relativePath: { id: 'instance.upgrade.result.relative-path', defaultMessage: 'Relative path' },
|
||||
warningCode: { id: 'instance.upgrade.result.warning-code', defaultMessage: 'Warning' },
|
||||
providerIdentity: {
|
||||
id: 'instance.upgrade.result.provider-identity',
|
||||
defaultMessage: 'Provider identity',
|
||||
},
|
||||
localContent: { id: 'instance.upgrade.result.local-content', defaultMessage: 'Local content' },
|
||||
content: { id: 'instance.upgrade.result.content-kind-content', defaultMessage: 'Content' },
|
||||
mod: { id: 'instance.upgrade.result.content-kind-mod', defaultMessage: 'Mod' },
|
||||
resourcepack: {
|
||||
id: 'instance.upgrade.result.content-kind-resourcepack',
|
||||
defaultMessage: 'Resource pack',
|
||||
},
|
||||
shaderpack: {
|
||||
id: 'instance.upgrade.result.content-kind-shaderpack',
|
||||
defaultMessage: 'Shader pack',
|
||||
},
|
||||
datapack: { id: 'instance.upgrade.result.content-kind-datapack', defaultMessage: 'Data pack' },
|
||||
detailsTitle: { id: 'instance.upgrade.result.details-title', defaultMessage: 'Upgrade details' },
|
||||
search: {
|
||||
id: 'instance.upgrade.result.search-details',
|
||||
defaultMessage: 'Search upgrade details',
|
||||
},
|
||||
filters: { id: 'instance.upgrade.result.filters', defaultMessage: 'Filter upgrade details' },
|
||||
all: { id: 'instance.upgrade.result.filter-all', defaultMessage: 'All' },
|
||||
updated: { id: 'instance.upgrade.result.filter-updated', defaultMessage: 'Updated' },
|
||||
kept: { id: 'instance.upgrade.result.filter-kept', defaultMessage: 'Kept' },
|
||||
disabled: { id: 'instance.upgrade.result.filter-disabled', defaultMessage: 'Disabled' },
|
||||
dependencies: {
|
||||
id: 'instance.upgrade.result.filter-dependencies',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
showing: {
|
||||
id: 'instance.upgrade.result.showing',
|
||||
defaultMessage: 'Showing {start}–{end} of {total}',
|
||||
},
|
||||
previous: { id: 'instance.upgrade.result.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'instance.upgrade.result.next', defaultMessage: 'Next' },
|
||||
noMatches: {
|
||||
id: 'instance.upgrade.result.no-matching-items',
|
||||
defaultMessage: 'No matching upgrade items.',
|
||||
},
|
||||
unknown: { id: 'instance.upgrade.result.unknown', defaultMessage: 'Unavailable' },
|
||||
dependency: { id: 'instance.upgrade.result.dependency', defaultMessage: 'Dependency' },
|
||||
upgrade: { id: 'instance.upgrade.result.action-upgrade', defaultMessage: 'Updated' },
|
||||
keep: { id: 'instance.upgrade.result.action-keep', defaultMessage: 'Kept' },
|
||||
disable: { id: 'instance.upgrade.result.action-disable', defaultMessage: 'Disabled' },
|
||||
dependencyAdded: {
|
||||
id: 'instance.upgrade.result.dependency-added',
|
||||
defaultMessage: 'Dependency added',
|
||||
},
|
||||
dependencyUpdated: {
|
||||
id: 'instance.upgrade.result.dependency-updated-status',
|
||||
defaultMessage: 'Dependency updated',
|
||||
},
|
||||
dependencyKept: {
|
||||
id: 'instance.upgrade.result.dependency-kept',
|
||||
defaultMessage: 'Dependency kept',
|
||||
},
|
||||
dependencyRemoved: {
|
||||
id: 'instance.upgrade.result.dependency-removed-status',
|
||||
defaultMessage: 'Dependency removed',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{ result: InstanceUpgradeResult; targetVersion: string | null }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const warnings = computed(() => upgradeResultWarningRows(props.result))
|
||||
const warningSummary = computed(() => summarizeUpgradeWarnings(warnings.value))
|
||||
const warningsDefaultOpen = computed(() =>
|
||||
shouldExpandUpgradeWarningsByDefault(warnings.value.length),
|
||||
)
|
||||
const warningsOpen = ref(warningsDefaultOpen.value)
|
||||
const warningSearch = ref('')
|
||||
const warningFilter = ref<UpgradeWarningFilter>('all')
|
||||
const warningPageNumber = ref(1)
|
||||
const warningFilters = computed(() => [
|
||||
{ value: 'all' as const, label: formatMessage(messages.all) },
|
||||
{ value: 'local' as const, label: formatMessage(messages.warningFilterUnidentified) },
|
||||
{ value: 'kept' as const, label: formatMessage(messages.kept) },
|
||||
{ value: 'fallback' as const, label: formatMessage(messages.warningFilterFallback) },
|
||||
])
|
||||
const filteredWarnings = computed(() =>
|
||||
filterUpgradeWarnings(
|
||||
warnings.value,
|
||||
warningFilter.value,
|
||||
warningSearch.value,
|
||||
warningSearchFields,
|
||||
),
|
||||
)
|
||||
const warningPage = computed(() =>
|
||||
paginateUpgradeWarnings(filteredWarnings.value, warningPageNumber.value),
|
||||
)
|
||||
const warningPaginationLabel = computed(() =>
|
||||
formatMessage(messages.showing, {
|
||||
start: warningPage.value.start,
|
||||
end: warningPage.value.end,
|
||||
total: warningPage.value.total,
|
||||
}),
|
||||
)
|
||||
watch([warningSearch, warningFilter], () => {
|
||||
warningPageNumber.value = 1
|
||||
})
|
||||
watch(
|
||||
() => warningPage.value.page,
|
||||
(value) => {
|
||||
warningPageNumber.value = value
|
||||
},
|
||||
)
|
||||
const detailsOpen = ref(false)
|
||||
|
||||
const snapshotsQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-content',
|
||||
props.result.sourceInstanceId,
|
||||
props.result.targetInstanceId,
|
||||
]),
|
||||
queryFn: () =>
|
||||
Promise.all(
|
||||
[...new Set([props.result.sourceInstanceId, props.result.targetInstanceId])].map((id) =>
|
||||
get_content_snapshot(id).catch(() => null),
|
||||
),
|
||||
),
|
||||
enabled: computed(() => warningsOpen.value || detailsOpen.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const snapshotItems = computed(
|
||||
() => snapshotsQuery.data.value?.flatMap((snapshot) => snapshot?.items ?? []) ?? [],
|
||||
)
|
||||
const allItems = computed(() => upgradeDetailItems(props.result.solution))
|
||||
const search = ref('')
|
||||
const filter = ref<UpgradeDetailFilter>('all')
|
||||
const page = ref(1)
|
||||
const filters = computed(() => [
|
||||
{ value: 'all' as const, label: formatMessage(messages.all) },
|
||||
{ value: 'updated' as const, label: formatMessage(messages.updated) },
|
||||
{ value: 'kept' as const, label: formatMessage(messages.kept) },
|
||||
{ value: 'disabled' as const, label: formatMessage(messages.disabled) },
|
||||
{ value: 'dependencies' as const, label: formatMessage(messages.dependencies) },
|
||||
])
|
||||
const filteredItems = computed(() =>
|
||||
filterUpgradeDetailItems(allItems.value, filter.value, search.value, searchFields),
|
||||
)
|
||||
const pageData = computed(() => paginateUpgradeDetailItems(filteredItems.value, page.value))
|
||||
watch([search, filter], () => {
|
||||
page.value = 1
|
||||
})
|
||||
watch(
|
||||
() => pageData.value.page,
|
||||
(value) => {
|
||||
page.value = value
|
||||
},
|
||||
)
|
||||
|
||||
const projectIdentities = computed(() =>
|
||||
detailsOpen.value ? upgradeDetailProjectIdentities(pageData.value.items) : [],
|
||||
)
|
||||
const releaseIdentities = computed(() =>
|
||||
detailsOpen.value ? upgradeDetailReleaseIdentities(pageData.value.items) : [],
|
||||
)
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-projects',
|
||||
...projectIdentities.value.map((item) => `${item.provider}:${item.projectId}`),
|
||||
]),
|
||||
queryFn: () => loadUpgradeProjectDisplayMetadata(projectIdentities.value),
|
||||
enabled: computed(() => detailsOpen.value && projectIdentities.value.length > 0),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-versions',
|
||||
...releaseIdentities.value.map(
|
||||
(item) => `${item.provider}:${item.projectId}:${item.releaseId}`,
|
||||
),
|
||||
]),
|
||||
queryFn: () => loadUpgradeVersionDisplayMetadata(releaseIdentities.value),
|
||||
enabled: computed(() => detailsOpen.value && releaseIdentities.value.length > 0),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const visibleRows = computed(() =>
|
||||
pageData.value.items.map((item) => {
|
||||
const snapshot = findSnapshot(item)
|
||||
const project =
|
||||
item.provider && item.projectId
|
||||
? projectsQuery.data.value?.get(
|
||||
upgradeProjectDisplayCacheKey(item.provider, item.projectId),
|
||||
)
|
||||
: null
|
||||
return {
|
||||
...item,
|
||||
title:
|
||||
project?.title ??
|
||||
snapshot?.content?.project.title ??
|
||||
snapshot?.content?.file_name ??
|
||||
filename(snapshot?.expectedRelativePath) ??
|
||||
item.projectId ??
|
||||
item.contentId ??
|
||||
formatMessage(messages.unknown),
|
||||
context:
|
||||
item.kind === 'dependency'
|
||||
? formatMessage(messages.dependency)
|
||||
: (snapshot?.expectedRelativePath ??
|
||||
item.provider ??
|
||||
formatMessage(messages.localContent)),
|
||||
path: upgradeProjectPath(item.provider, item.projectId),
|
||||
current: releaseLabel(item, item.currentReleaseId) ?? formatMessage(messages.unknown),
|
||||
target: releaseLabel(item, item.targetReleaseId),
|
||||
actionLabel: actionLabel(item),
|
||||
badgeColor:
|
||||
item.kind === 'selection' && item.action === 'disable'
|
||||
? ('gray' as const)
|
||||
: item.kind === 'selection' && item.action === 'keep'
|
||||
? ('blue' as const)
|
||||
: ('green' as const),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const paginationLabel = computed(() =>
|
||||
formatMessage(messages.showing, {
|
||||
start: pageData.value.start,
|
||||
end: pageData.value.end,
|
||||
total: pageData.value.total,
|
||||
}),
|
||||
)
|
||||
|
||||
function findSnapshot(item: UpgradeDetailItem) {
|
||||
return (
|
||||
snapshotItems.value.find((snapshot) => snapshot.entryId === item.contentId) ??
|
||||
snapshotItems.value.find(
|
||||
(snapshot) =>
|
||||
snapshot.provider === item.provider && snapshot.providerProjectId === item.projectId,
|
||||
)
|
||||
)
|
||||
}
|
||||
function searchFields(item: UpgradeDetailItem) {
|
||||
const snapshot = findSnapshot(item)
|
||||
return [
|
||||
snapshot?.content?.project.title,
|
||||
snapshot?.content?.file_name,
|
||||
snapshot?.content?.version?.version_number,
|
||||
snapshot?.expectedRelativePath,
|
||||
item.contentId,
|
||||
item.provider,
|
||||
item.projectId,
|
||||
item.currentReleaseId,
|
||||
item.targetReleaseId,
|
||||
]
|
||||
}
|
||||
function releaseLabel(item: UpgradeDetailItem, releaseId: string | null) {
|
||||
return releaseId
|
||||
? upgradeVersionDisplayLabel(versionsQuery.data.value, {
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
releaseId,
|
||||
})
|
||||
: null
|
||||
}
|
||||
function actionLabel(item: UpgradeDetailItem) {
|
||||
if (item.kind === 'selection')
|
||||
return formatMessage(messages[item.action as 'upgrade' | 'keep' | 'disable'])
|
||||
return formatMessage(
|
||||
item.action === 'add'
|
||||
? messages.dependencyAdded
|
||||
: item.action === 'upgrade'
|
||||
? messages.dependencyUpdated
|
||||
: item.action === 'remove'
|
||||
? messages.dependencyRemoved
|
||||
: messages.dependencyKept,
|
||||
)
|
||||
}
|
||||
function filename(path: string | null | undefined) {
|
||||
return path?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) ?? null
|
||||
}
|
||||
function warningIdentity(warning: UpgradeWarningRow) {
|
||||
const snapshot = snapshotItems.value.find(
|
||||
(item) =>
|
||||
item.entryId === warning.contentId || item.expectedRelativePath === warning.relativePath,
|
||||
)
|
||||
return (
|
||||
snapshot?.content?.project.title ??
|
||||
snapshot?.content?.file_name ??
|
||||
upgradeWarningDisplayName(warning) ??
|
||||
formatMessage(messages.unknown)
|
||||
)
|
||||
}
|
||||
function warningContext(warning: UpgradeWarningRow) {
|
||||
return `${formatMessage(messages[upgradeWarningContentKind(warning)])} · ${warning.provider ?? formatMessage(messages.localContent)}`
|
||||
}
|
||||
function warningSearchFields(warning: UpgradeWarningRow) {
|
||||
return [
|
||||
warningIdentity(warning),
|
||||
warning.relativePath,
|
||||
warning.code,
|
||||
warning.provider,
|
||||
warning.projectId,
|
||||
formatMessage(
|
||||
upgradeWarningCategory(warning) === 'local'
|
||||
? messages.warningFilterUnidentified
|
||||
: upgradeWarningCategory(warning) === 'kept'
|
||||
? messages.kept
|
||||
: messages.warningFilterFallback,
|
||||
),
|
||||
]
|
||||
}
|
||||
function summaryLabel(category: UpgradeWarningCategory) {
|
||||
return formatMessage(
|
||||
category === 'local'
|
||||
? messages.summaryLocal
|
||||
: category === 'kept'
|
||||
? messages.summaryKept
|
||||
: messages.summaryFallback,
|
||||
{ count: warningSummary.value[category] },
|
||||
)
|
||||
}
|
||||
function warningHeadline(warning: UpgradeWarningRow) {
|
||||
if (warning.legacyMessage) return formatMessage(messages.legacyHeadline)
|
||||
const action = warningAction(warning)
|
||||
if (action === 'disable') return formatMessage(messages.disabledHeadline)
|
||||
if (action === 'upgrade' && warning.code !== 'prerelease_only') {
|
||||
return formatMessage(messages.resolvedHeadline)
|
||||
}
|
||||
if (warning.code === 'prerelease_only' && action !== 'upgrade') {
|
||||
return formatMessage(messages.keptHeadline)
|
||||
}
|
||||
if (warning.code === 'unidentified') return formatMessage(messages.unidentifiedHeadline)
|
||||
if (warning.code === 'unsupported_content_type')
|
||||
return formatMessage(messages.unsupportedHeadline)
|
||||
if (warning.code === 'keep_incompatible' || warning.code === 'no_compatible_release')
|
||||
return formatMessage(messages.keptHeadline)
|
||||
if (warning.code === 'prerelease_only') return formatMessage(messages.prereleaseHeadline)
|
||||
if (warning.code?.includes('shader')) return formatMessage(messages.shaderHeadline)
|
||||
if (warning.code === 'dependency_conflict') return formatMessage(messages.conflictHeadline)
|
||||
if (warning.code === 'missing_required_dependency') {
|
||||
return formatMessage(messages.missingDependencyHeadline)
|
||||
}
|
||||
if (warning.code === 'incompatible_dependency') {
|
||||
return formatMessage(messages.incompatibleDependencyHeadline)
|
||||
}
|
||||
if (warning.code === 'search_limit_reached') return formatMessage(messages.searchLimitHeadline)
|
||||
return formatMessage(messages.dependencyHeadline)
|
||||
}
|
||||
function warningDescription(warning: UpgradeWarningRow) {
|
||||
if (warning.legacyMessage) return warning.legacyMessage
|
||||
const action = warningAction(warning)
|
||||
if (action === 'disable') return formatMessage(messages.disabledDescription)
|
||||
if (action === 'upgrade' && warning.code !== 'prerelease_only') {
|
||||
return formatMessage(messages.resolvedDescription)
|
||||
}
|
||||
if (warning.code === 'prerelease_only' && action !== 'upgrade') {
|
||||
return formatMessage(messages.keptDescription)
|
||||
}
|
||||
if (warning.code === 'unidentified')
|
||||
return formatMessage(messages.unidentifiedDescription, {
|
||||
targetVersion: props.targetVersion ?? formatMessage(messages.unknown),
|
||||
})
|
||||
if (warning.code === 'unsupported_content_type')
|
||||
return formatMessage(messages.unsupportedDescription)
|
||||
if (warning.code === 'keep_incompatible' || warning.code === 'no_compatible_release')
|
||||
return formatMessage(messages.keptDescription)
|
||||
if (warning.code === 'prerelease_only') return formatMessage(messages.prereleaseDescription)
|
||||
if (warning.code?.includes('shader')) return formatMessage(messages.shaderDescription)
|
||||
if (warning.code === 'dependency_conflict') return formatMessage(messages.conflictDescription)
|
||||
if (warning.code === 'missing_required_dependency') {
|
||||
return formatMessage(messages.missingDependencyDescription)
|
||||
}
|
||||
if (warning.code === 'incompatible_dependency') {
|
||||
return formatMessage(messages.incompatibleDependencyDescription)
|
||||
}
|
||||
if (warning.code === 'search_limit_reached') return formatMessage(messages.searchLimitDescription)
|
||||
return formatMessage(messages.dependencyDescription)
|
||||
}
|
||||
|
||||
function warningAction(warning: UpgradeWarningRow) {
|
||||
return props.result.solution.selections.find(
|
||||
(selection) => selection.contentId === warning.contentId,
|
||||
)?.action
|
||||
}
|
||||
function hasTechnicalDetails(warning: UpgradeWarningRow) {
|
||||
return Boolean(warning.relativePath || warning.code || warning.provider || warning.projectId)
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header class="flex items-start gap-3">
|
||||
<CheckCircleIcon class="mt-0.5 size-8 shrink-0 text-green" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-0 mt-1 text-secondary">
|
||||
{{
|
||||
formatMessage(
|
||||
mode === 'copy_and_upgrade' ? messages.copyDescription : messages.directDescription,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.environment) }}
|
||||
</h3>
|
||||
<div class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<span class="text-secondary">{{ formatMessage(messages.minecraft) }}</span>
|
||||
<strong
|
||||
>{{ sourceEnvironment?.gameVersion ?? formatMessage(messages.unknown) }}
|
||||
<span aria-hidden="true">→</span>
|
||||
{{ targetEnvironment?.gameVersion ?? formatMessage(messages.unknown) }}</strong
|
||||
>
|
||||
<span class="text-secondary">{{ formatMessage(messages.loader) }}</span>
|
||||
<strong
|
||||
>{{ loaderLabel(sourceEnvironment) }} <span aria-hidden="true">→</span>
|
||||
{{ loaderLabel(actualTargetEnvironment) }}</strong
|
||||
>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.metrics) }}
|
||||
</h3>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div v-for="metric in metrics" :key="metric.label">
|
||||
<div class="text-xl font-semibold text-contrast">{{ metric.value }}</div>
|
||||
<div class="text-xs text-secondary">{{ metric.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="brand"
|
||||
><button @click="router.push(`/instance/${encodeURIComponent(result.targetInstanceId)}`)">
|
||||
<ExternalIcon />{{ formatMessage(messages.openUpgraded) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled v-if="mode === 'copy_and_upgrade'" type="outlined"
|
||||
><button @click="router.push(`/instance/${encodeURIComponent(result.sourceInstanceId)}`)">
|
||||
<ExternalIcon />{{ formatMessage(messages.openOriginal) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled v-if="result.backupInstanceId" type="outlined"
|
||||
><button @click="router.push(`/instance/${encodeURIComponent(result.backupInstanceId!)}`)">
|
||||
<FolderOpenIcon />{{ formatMessage(messages.openBackup) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
|
||||
<Card v-if="result.backupInstanceId" class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.backupTitle) }}
|
||||
</h3>
|
||||
<p class="mb-3 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.backupDescription) }}
|
||||
</p>
|
||||
</Card>
|
||||
<Admonition
|
||||
v-else-if="mode === 'direct'"
|
||||
type="info"
|
||||
:header="formatMessage(messages.noBackupTitle)"
|
||||
>{{ formatMessage(messages.noBackupDescription) }}</Admonition
|
||||
>
|
||||
|
||||
<Admonition
|
||||
v-if="result.externalChanges.length"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.externalTitle)"
|
||||
>
|
||||
<p class="mb-2">{{ formatMessage(messages.externalDescription) }}</p>
|
||||
<ul class="m-0 list-disc pl-5">
|
||||
<li v-for="change in result.externalChanges" :key="`${change.kind}:${change.relativePath}`">
|
||||
<code>{{ change.relativePath }}</code> · {{ externalChangeLabel(change.kind) }}
|
||||
</li>
|
||||
</ul>
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="result.skippedDueToExternalConflict.length"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.skippedTitle)"
|
||||
>
|
||||
<p class="mb-2">{{ formatMessage(messages.skippedDescription) }}</p>
|
||||
<ul class="m-0 list-disc pl-5">
|
||||
<li v-for="path in result.skippedDueToExternalConflict" :key="path">
|
||||
<code>{{ path }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</Admonition>
|
||||
<UpgradeResultCollections
|
||||
:result="result"
|
||||
:target-version="targetEnvironment?.gameVersion ?? null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, ExternalIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
import type {
|
||||
InstanceUpgradeExternalChangeKind,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
|
||||
import { summarizeUpgradeResult, upgradeResultMode } from './result'
|
||||
import UpgradeResultCollections from './UpgradeResultCollections.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.result.title', defaultMessage: 'Upgrade complete' },
|
||||
directDescription: {
|
||||
id: 'instance.upgrade.result.direct-description',
|
||||
defaultMessage: 'This instance was upgraded successfully.',
|
||||
},
|
||||
copyDescription: {
|
||||
id: 'instance.upgrade.result.copy-description',
|
||||
defaultMessage:
|
||||
'An upgraded copy was created successfully. The original shared instance was left unchanged.',
|
||||
},
|
||||
environment: { id: 'instance.upgrade.result.environment', defaultMessage: 'Environment' },
|
||||
minecraft: { id: 'instance.upgrade.result.minecraft', defaultMessage: 'Minecraft' },
|
||||
loader: { id: 'instance.upgrade.result.loader', defaultMessage: 'Loader' },
|
||||
unknown: { id: 'instance.upgrade.result.unknown', defaultMessage: 'Unavailable' },
|
||||
automatic: { id: 'instance.upgrade.result.automatic', defaultMessage: 'Automatic' },
|
||||
metrics: { id: 'instance.upgrade.result.metrics', defaultMessage: 'Outcome' },
|
||||
updated: { id: 'instance.upgrade.result.updated', defaultMessage: 'Updated' },
|
||||
kept: { id: 'instance.upgrade.result.kept', defaultMessage: 'Kept' },
|
||||
disabled: { id: 'instance.upgrade.result.disabled', defaultMessage: 'Disabled' },
|
||||
added: { id: 'instance.upgrade.result.dependencies-added', defaultMessage: 'Dependencies added' },
|
||||
dependencyUpdated: {
|
||||
id: 'instance.upgrade.result.dependencies-updated',
|
||||
defaultMessage: 'Dependencies updated',
|
||||
},
|
||||
removed: {
|
||||
id: 'instance.upgrade.result.dependencies-removed',
|
||||
defaultMessage: 'Dependencies removed',
|
||||
},
|
||||
openUpgraded: {
|
||||
id: 'instance.upgrade.result.open-upgraded',
|
||||
defaultMessage: 'Open upgraded instance',
|
||||
},
|
||||
openOriginal: {
|
||||
id: 'instance.upgrade.result.open-original',
|
||||
defaultMessage: 'Open original instance',
|
||||
},
|
||||
backupTitle: { id: 'instance.upgrade.result.backup-title', defaultMessage: 'Backup created' },
|
||||
backupDescription: {
|
||||
id: 'instance.upgrade.result.backup-description',
|
||||
defaultMessage:
|
||||
'A complete pre-upgrade copy was created separately from automatic technical rollback.',
|
||||
},
|
||||
openBackup: { id: 'instance.upgrade.result.open-backup', defaultMessage: 'Open backup' },
|
||||
noBackupTitle: {
|
||||
id: 'instance.upgrade.result.no-backup-title',
|
||||
defaultMessage: 'No complete backup was created',
|
||||
},
|
||||
noBackupDescription: {
|
||||
id: 'instance.upgrade.result.no-backup-description',
|
||||
defaultMessage:
|
||||
'Automatic technical rollback protected this operation while it was running; it is not a permanent backup.',
|
||||
},
|
||||
externalTitle: {
|
||||
id: 'instance.upgrade.result.external-title',
|
||||
defaultMessage: 'Changes detected while upgrading',
|
||||
},
|
||||
externalDescription: {
|
||||
id: 'instance.upgrade.result.external-description',
|
||||
defaultMessage:
|
||||
'Files changed outside the launcher were detected, and user changes were given priority where applicable.',
|
||||
},
|
||||
skippedTitle: {
|
||||
id: 'instance.upgrade.result.skipped-title',
|
||||
defaultMessage: 'Some planned changes were skipped',
|
||||
},
|
||||
skippedDescription: {
|
||||
id: 'instance.upgrade.result.skipped-description',
|
||||
defaultMessage: 'These files changed while upgrading, so the user changes were preserved.',
|
||||
},
|
||||
warningsTitle: {
|
||||
id: 'instance.upgrade.result.warnings-title',
|
||||
defaultMessage: 'Compatibility warnings',
|
||||
},
|
||||
warningPrereleaseOnly: {
|
||||
id: 'instance.upgrade.warning.prerelease-only',
|
||||
defaultMessage: '{path} only has prerelease builds for the target environment.',
|
||||
},
|
||||
warningUnidentified: {
|
||||
id: 'instance.upgrade.warning.unidentified',
|
||||
defaultMessage: '{path} could not be identified and was preserved unchanged.',
|
||||
},
|
||||
warningDependencyConflict: {
|
||||
id: 'instance.upgrade.warning.dependency-conflict',
|
||||
defaultMessage: '{path} has conflicting dependency requirements.',
|
||||
},
|
||||
warningMissingDependency: {
|
||||
id: 'instance.upgrade.warning.missing-required-dependency',
|
||||
defaultMessage: '{path} requires a dependency that could not be resolved.',
|
||||
},
|
||||
warningIncompatibleDependency: {
|
||||
id: 'instance.upgrade.warning.incompatible-dependency',
|
||||
defaultMessage: '{path} has an incompatible dependency.',
|
||||
},
|
||||
warningUnsupportedType: {
|
||||
id: 'instance.upgrade.warning.unsupported-content-type',
|
||||
defaultMessage: '{path} uses a content type that cannot be upgraded automatically.',
|
||||
},
|
||||
warningNoRelease: {
|
||||
id: 'instance.upgrade.warning.no-compatible-release',
|
||||
defaultMessage: '{path} has no compatible release for the target environment.',
|
||||
},
|
||||
warningNoShaderRuntime: {
|
||||
id: 'instance.upgrade.warning.no-compatible-shader-runtime',
|
||||
defaultMessage: '{path} has no release compatible with the target shader runtime.',
|
||||
},
|
||||
warningShaderMissing: {
|
||||
id: 'instance.upgrade.warning.shader-runtime-missing',
|
||||
defaultMessage: '{path} was preserved because no target shader runtime is configured.',
|
||||
},
|
||||
warningShaderUnknown: {
|
||||
id: 'instance.upgrade.warning.shader-runtime-unknown',
|
||||
defaultMessage: '{path} was preserved because the target shader runtime is unknown.',
|
||||
},
|
||||
warningSearchLimit: {
|
||||
id: 'instance.upgrade.warning.search-limit-reached',
|
||||
defaultMessage: '{path} could not be resolved within the bounded compatibility search.',
|
||||
},
|
||||
warningKeepIncompatible: {
|
||||
id: 'instance.upgrade.warning.keep-incompatible',
|
||||
defaultMessage: '{path} was kept unchanged and may be incompatible with the upgraded instance.',
|
||||
},
|
||||
detailsTitle: { id: 'instance.upgrade.result.details-title', defaultMessage: 'Upgrade details' },
|
||||
add: { id: 'instance.upgrade.result.action-add', defaultMessage: 'Added' },
|
||||
upgrade: { id: 'instance.upgrade.result.action-upgrade', defaultMessage: 'Updated' },
|
||||
keep: { id: 'instance.upgrade.result.action-keep', defaultMessage: 'Kept' },
|
||||
disable: { id: 'instance.upgrade.result.action-disable', defaultMessage: 'Disabled' },
|
||||
remove: { id: 'instance.upgrade.result.action-remove', defaultMessage: 'Removed' },
|
||||
changeAdded: { id: 'instance.upgrade.result.change-added', defaultMessage: 'Added' },
|
||||
changeRemoved: { id: 'instance.upgrade.result.change-removed', defaultMessage: 'Removed' },
|
||||
changeModified: { id: 'instance.upgrade.result.change-modified', defaultMessage: 'Modified' },
|
||||
})
|
||||
|
||||
const props = defineProps<{ result: import('@/helpers/instance-upgrade').InstanceUpgradeResult }>()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const result = computed(() => props.result)
|
||||
const mode = computed(() => upgradeResultMode(result.value))
|
||||
const targetEnvironment = computed(() => result.value.targetEnvironment ?? null)
|
||||
const sourceEnvironment = computed(() => result.value.sourceEnvironment ?? null)
|
||||
const relatedInstancesQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-instances',
|
||||
result.value.sourceInstanceId,
|
||||
result.value.targetInstanceId,
|
||||
result.value.backupInstanceId,
|
||||
]),
|
||||
queryFn: () =>
|
||||
getInstances([
|
||||
result.value.sourceInstanceId,
|
||||
result.value.targetInstanceId,
|
||||
...(result.value.backupInstanceId ? [result.value.backupInstanceId] : []),
|
||||
]).catch(() => []),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const targetInstance = computed(
|
||||
() =>
|
||||
relatedInstancesQuery.data.value?.find(
|
||||
(instance) => instance.id === result.value.targetInstanceId,
|
||||
) ?? null,
|
||||
)
|
||||
const actualTargetEnvironment = computed<InstanceUpgradeTargetEnvironment | null>(() =>
|
||||
targetInstance.value
|
||||
? {
|
||||
gameVersion: targetInstance.value.game_version,
|
||||
modLoader: targetInstance.value.loader,
|
||||
modLoaderVersion:
|
||||
targetInstance.value.loader_version ?? targetEnvironment.value?.modLoaderVersion ?? null,
|
||||
shaderRuntime: targetEnvironment.value?.shaderRuntime ?? 'unknown',
|
||||
}
|
||||
: targetEnvironment.value,
|
||||
)
|
||||
const summary = computed(() => summarizeUpgradeResult(result.value.solution))
|
||||
const metrics = computed(() => [
|
||||
{ label: formatMessage(messages.updated), value: summary.value.updated },
|
||||
{ label: formatMessage(messages.kept), value: summary.value.kept },
|
||||
{ label: formatMessage(messages.disabled), value: summary.value.disabled },
|
||||
{ label: formatMessage(messages.added), value: summary.value.dependencyAdded },
|
||||
{ label: formatMessage(messages.dependencyUpdated), value: summary.value.dependencyUpdated },
|
||||
{ label: formatMessage(messages.removed), value: summary.value.dependencyRemoved },
|
||||
])
|
||||
|
||||
function loaderLabel(environment: InstanceUpgradeTargetEnvironment | null) {
|
||||
if (!environment) return formatMessage(messages.unknown)
|
||||
const label = formatLoaderLabel(environment.modLoader)
|
||||
return environment.modLoaderVersion
|
||||
? `${label} ${environment.modLoaderVersion}`
|
||||
: `${label} (${formatMessage(messages.automatic)})`
|
||||
}
|
||||
function externalChangeLabel(kind: InstanceUpgradeExternalChangeKind) {
|
||||
return formatMessage(
|
||||
messages[
|
||||
kind === 'added' ? 'changeAdded' : kind === 'removed' ? 'changeRemoved' : 'changeModified'
|
||||
],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
122
apps/app-frontend/src/pages/instance/upgrade/UpgradeShell.vue
Normal file
122
apps/app-frontend/src/pages/instance/upgrade/UpgradeShell.vue
Normal file
@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full" :class="wideCompatibilityLayout ? 'max-w-[96rem]' : 'max-w-5xl'">
|
||||
<RouterView v-if="instanceMatchesRoute" />
|
||||
</div>
|
||||
<UpgradeFlowFloatingBar v-if="instanceMatchesRoute" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, toRef, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { parkUpgradeFlow, restoreUpgradeFlow } from '@/helpers/upgrade-return-state'
|
||||
|
||||
import {
|
||||
attachUpgradeJobToFlow,
|
||||
isUpgradeRouteAvailable,
|
||||
isUpgradeRouteRecoveryPending,
|
||||
provideInstanceUpgradeFlow,
|
||||
type UpgradeRouteRequirement,
|
||||
} from './flow'
|
||||
import { isRecoverableUpgradeStatus, recoverInstanceUpgradeJob } from './install-job'
|
||||
import UpgradeFlowFloatingBar from './UpgradeFlowFloatingBar.vue'
|
||||
|
||||
const props = defineProps<{ instance: GameInstance }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const flow = provideInstanceUpgradeFlow(toRef(props, 'instance'))
|
||||
const routeInstanceId = computed(() =>
|
||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
|
||||
)
|
||||
const instanceMatchesRoute = computed(() => routeInstanceId.value === props.instance.id)
|
||||
const wideCompatibilityLayout = computed(() => route.path.endsWith('/upgrade/compatibility'))
|
||||
const restoredSnapshot = restoreUpgradeFlow(props.instance.id, route.fullPath, flow.hydrate)
|
||||
|
||||
async function recoverUpgradeJob() {
|
||||
const instanceId = props.instance.id
|
||||
const requirement = route.meta.upgradeRequirement as UpgradeRouteRequirement | undefined
|
||||
if (requirement === 'result') {
|
||||
flow.setJobRecoveryState('ready')
|
||||
return
|
||||
}
|
||||
flow.setJobRecoveryState('loading')
|
||||
try {
|
||||
const job = await recoverInstanceUpgradeJob(instanceId, {
|
||||
knownJobId: flow.activeJobId.value,
|
||||
continuation: requirement === 'job',
|
||||
})
|
||||
if (props.instance.id !== instanceId || !job) return
|
||||
const downloadsLocation = attachUpgradeJobToFlow(flow, job)
|
||||
if (isRecoverableUpgradeStatus(job.status) && requirement !== 'job') {
|
||||
await router.replace(downloadsLocation)
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
if (props.instance.id === instanceId) flow.setJobRecoveryState('ready')
|
||||
}
|
||||
}
|
||||
|
||||
void recoverUpgradeJob()
|
||||
|
||||
onMounted(async () => {
|
||||
if (restoredSnapshot?.scrollTop === undefined) return
|
||||
await nextTick()
|
||||
const viewport = document.querySelector('.app-viewport')
|
||||
if (viewport) viewport.scrollTop = restoredSnapshot.scrollTop
|
||||
})
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (to.path.startsWith('/project/')) {
|
||||
parkUpgradeFlow({
|
||||
instanceId: props.instance.id,
|
||||
returnFullPath: route.fullPath,
|
||||
targetEnvironment: flow.targetEnvironment.value,
|
||||
plan: flow.plan.value,
|
||||
createFullBackup: flow.createFullBackup.value,
|
||||
directFullBackupPreference: flow.directFullBackupPreference.value,
|
||||
sharedUpgradeMode: flow.sharedUpgradeMode.value,
|
||||
activeJobId: flow.activeJobId.value,
|
||||
result: flow.result.value,
|
||||
initialBlockingPlanId: flow.initialBlockingPlanId.value,
|
||||
initialBlockingIssues: flow.initialBlockingIssues.value,
|
||||
customizeActiveStrategy: flow.customizeActiveStrategy.value,
|
||||
scrollTop: document.querySelector('.app-viewport')?.scrollTop,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function safeEntryPath(instanceId: string) {
|
||||
return `/instance/${encodeURIComponent(instanceId)}/upgrade`
|
||||
}
|
||||
|
||||
function requirementFallback(instanceId: string, requirement: UpgradeRouteRequirement | undefined) {
|
||||
if ((requirement === 'unblocked-plan' || requirement === 'selection') && flow.plan.value) {
|
||||
return `${safeEntryPath(instanceId)}/compatibility`
|
||||
}
|
||||
return safeEntryPath(instanceId)
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => route.fullPath,
|
||||
() => props.instance.id,
|
||||
flow.activeJobId,
|
||||
flow.jobRecoveryState,
|
||||
flow.plan,
|
||||
flow.result,
|
||||
],
|
||||
async () => {
|
||||
if (!instanceMatchesRoute.value) return
|
||||
const requirement = route.meta.upgradeRequirement as UpgradeRouteRequirement | undefined
|
||||
if (requirement === 'result') return
|
||||
if (isUpgradeRouteRecoveryPending(requirement, flow)) return
|
||||
if (requirement === 'job' && route.name === 'InstanceUpgradeProgress') return
|
||||
if (!isUpgradeRouteAvailable(requirement, flow)) {
|
||||
await router.replace(requirementFallback(props.instance.id, requirement))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<section :data-instance-id="flow.instanceId.value" class="flex flex-col gap-2 py-2">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ title }}</h2>
|
||||
<p class="m-0 max-w-2xl text-secondary">{{ description }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
|
||||
defineProps<{ title: string; description: string }>()
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
</script>
|
||||
@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<span
|
||||
ref="trigger"
|
||||
class="relative inline-flex max-w-full"
|
||||
tabindex="0"
|
||||
@mouseenter="setOwnership('triggerHovered', true)"
|
||||
@mouseleave="setOwnership('triggerHovered', false)"
|
||||
@focus="setOwnership('triggerFocused', true)"
|
||||
@blur="setOwnership('triggerFocused', false)"
|
||||
>
|
||||
<span class="cursor-help underline decoration-dotted underline-offset-2">{{ label }}</span>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="popup"
|
||||
:style="popupStyle"
|
||||
class="fixed z-[200] flex max-h-[min(28rem,calc(100dvh-2rem))] w-96 max-w-[calc(100vw-2rem)] flex-col rounded-lg border border-solid border-surface-5 p-3 text-left shadow-xl"
|
||||
@mouseenter="setOwnership('popupHovered', true)"
|
||||
@mouseleave="setOwnership('popupHovered', false)"
|
||||
@focusin="setOwnership('popupFocused', true)"
|
||||
@focusout="setOwnership('popupFocused', false)"
|
||||
>
|
||||
<span v-if="loading" class="text-sm text-secondary">{{
|
||||
formatMessage(messages.loading)
|
||||
}}</span>
|
||||
<template v-else-if="metadata">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<strong class="block truncate text-sm text-contrast">{{ metadata.version }}</strong>
|
||||
<span v-if="metadata.channel" class="mt-1 block text-xs uppercase text-secondary">{{
|
||||
metadata.channel
|
||||
}}</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="metadata.changelog" type="transparent" size="small">
|
||||
<button :disabled="translationLoading" @click="toggleTranslation">
|
||||
<SpinnerIcon v-if="translationLoading" class="animate-spin" aria-hidden="true" />
|
||||
{{ formatMessage(showTranslation ? messages.showOriginal : messages.translate) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p v-if="translationError" class="mb-0 mt-2 text-sm text-red">{{ translationError }}</p>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="metadata.changelog"
|
||||
class="markdown-body mt-2 min-h-0 overflow-y-auto text-sm text-secondary"
|
||||
@click="openExternalLink"
|
||||
v-html="renderedChangelog"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<span v-else class="mt-2 block text-sm text-secondary">{{
|
||||
formatMessage(messages.empty)
|
||||
}}</span>
|
||||
</template>
|
||||
<span v-else class="text-sm text-secondary">{{ formatMessage(messages.unavailable) }}</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { renderHighlightedString } from '@modrinth/utils'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getTranslationErrorKind,
|
||||
getTranslationSettings,
|
||||
prepareDescription,
|
||||
renderTranslatedDescription,
|
||||
translateInBatches,
|
||||
validateTranslatedDescription,
|
||||
} from '@/helpers/translation'
|
||||
import {
|
||||
getUpgradeChangelogTranslation,
|
||||
setUpgradeChangelogTranslation,
|
||||
shouldUpgradeChangelogStayOpen,
|
||||
upgradeChangelogTranslationCacheKey,
|
||||
upgradeExternalChangelogUrl,
|
||||
} from '@/helpers/upgrade-changelog'
|
||||
import { loadUpgradeVersionMetadata } from '@/helpers/upgrade-version-metadata'
|
||||
import i18n from '@/i18n.config'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
provider: string | null
|
||||
projectId: string | null
|
||||
releaseId: string | null
|
||||
}>()
|
||||
const messages = defineMessages({
|
||||
loading: { id: 'instance.upgrade.changelog.loading', defaultMessage: 'Loading release details…' },
|
||||
empty: {
|
||||
id: 'instance.upgrade.changelog.empty',
|
||||
defaultMessage: 'No changelog was provided for this version.',
|
||||
},
|
||||
unavailable: {
|
||||
id: 'instance.upgrade.changelog.unavailable',
|
||||
defaultMessage: 'Release details unavailable.',
|
||||
},
|
||||
translate: { id: 'instance.upgrade.changelog.translate', defaultMessage: 'Translate' },
|
||||
showOriginal: { id: 'instance.upgrade.changelog.show-original', defaultMessage: 'Show original' },
|
||||
translationRateLimited: {
|
||||
id: 'instance.upgrade.changelog.translation.rate-limited',
|
||||
defaultMessage: 'Translation is temporarily rate limited.',
|
||||
},
|
||||
translationAuthentication: {
|
||||
id: 'instance.upgrade.changelog.translation.authentication',
|
||||
defaultMessage: 'Translation provider authentication failed.',
|
||||
},
|
||||
translationTooLong: {
|
||||
id: 'instance.upgrade.changelog.translation.too-long',
|
||||
defaultMessage: 'This changelog is too long to translate.',
|
||||
},
|
||||
translationNetwork: {
|
||||
id: 'instance.upgrade.changelog.translation.network',
|
||||
defaultMessage: 'Translation network request failed.',
|
||||
},
|
||||
translationFailed: {
|
||||
id: 'instance.upgrade.changelog.translation.failed',
|
||||
defaultMessage: 'Changelog translation failed.',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
const visible = ref(false)
|
||||
const trigger = ref<HTMLElement | null>(null)
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
const popupStyle = ref<Record<string, string>>({ backgroundColor: 'var(--color-tooltip-bg)' })
|
||||
const loading = ref(false)
|
||||
const metadata = ref<Awaited<ReturnType<typeof loadUpgradeVersionMetadata>> | null>(null)
|
||||
const translationLoading = ref(false)
|
||||
const translationError = ref<string | null>(null)
|
||||
const translatedChangelog = ref<string | null>(null)
|
||||
const showTranslation = ref(false)
|
||||
const ownership = ref({
|
||||
triggerHovered: false,
|
||||
triggerFocused: false,
|
||||
popupHovered: false,
|
||||
popupFocused: false,
|
||||
})
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let loaded = false
|
||||
|
||||
const renderedChangelog = computed(() => {
|
||||
if (showTranslation.value && translatedChangelog.value) return translatedChangelog.value
|
||||
return renderHighlightedString(metadata.value?.changelog ?? '')
|
||||
})
|
||||
|
||||
function cancelClose() {
|
||||
if (closeTimer) clearTimeout(closeTimer)
|
||||
}
|
||||
|
||||
function positionPopup() {
|
||||
if (!trigger.value || !popup.value) return
|
||||
const anchor = trigger.value.getBoundingClientRect()
|
||||
if (anchor.bottom < 0 || anchor.top > window.innerHeight) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
const popupRect = popup.value.getBoundingClientRect()
|
||||
const gap = 8
|
||||
const margin = 8
|
||||
const placeAbove =
|
||||
window.innerHeight - anchor.bottom < popupRect.height + gap &&
|
||||
anchor.top > popupRect.height + gap
|
||||
const desiredTop = placeAbove ? anchor.top - popupRect.height - gap : anchor.bottom + gap
|
||||
popupStyle.value = {
|
||||
backgroundColor: 'var(--color-tooltip-bg)',
|
||||
left: `${Math.max(margin, Math.min(anchor.left, window.innerWidth - popupRect.width - margin))}px`,
|
||||
top: `${Math.max(margin, Math.min(desiredTop, window.innerHeight - popupRect.height - margin))}px`,
|
||||
}
|
||||
}
|
||||
|
||||
async function open() {
|
||||
cancelClose()
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
if (loaded || !props.provider || !props.projectId || !props.releaseId) return
|
||||
loaded = true
|
||||
loading.value = true
|
||||
try {
|
||||
metadata.value = await loadUpgradeVersionMetadata(
|
||||
props.provider,
|
||||
props.projectId,
|
||||
props.releaseId,
|
||||
)
|
||||
} catch {
|
||||
metadata.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
}
|
||||
}
|
||||
|
||||
function setOwnership(key: keyof typeof ownership.value, active: boolean) {
|
||||
ownership.value = { ...ownership.value, [key]: active }
|
||||
if (shouldUpgradeChangelogStayOpen(ownership.value)) {
|
||||
void open()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function translationFailureMessage(error: unknown) {
|
||||
return formatMessage(
|
||||
{
|
||||
'rate-limited': messages.translationRateLimited,
|
||||
authentication: messages.translationAuthentication,
|
||||
'content-too-long': messages.translationTooLong,
|
||||
network: messages.translationNetwork,
|
||||
provider: messages.translationFailed,
|
||||
}[getTranslationErrorKind(error)],
|
||||
)
|
||||
}
|
||||
|
||||
async function toggleTranslation() {
|
||||
if (showTranslation.value) {
|
||||
showTranslation.value = false
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
return
|
||||
}
|
||||
if (!metadata.value?.changelog || !props.provider || !props.projectId || !props.releaseId) return
|
||||
translationLoading.value = true
|
||||
translationError.value = null
|
||||
try {
|
||||
const settings = await getTranslationSettings()
|
||||
const targetLanguage = settings.target_language || i18n.global.locale.value || 'en-US'
|
||||
const key = upgradeChangelogTranslationCacheKey(
|
||||
props.provider,
|
||||
props.projectId,
|
||||
props.releaseId,
|
||||
targetLanguage,
|
||||
)
|
||||
const cached = getUpgradeChangelogTranslation(key)
|
||||
if (cached) {
|
||||
translatedChangelog.value = cached
|
||||
} else {
|
||||
const prepared = prepareDescription(metadata.value.changelog)
|
||||
const accumulated: Record<string, string> = {}
|
||||
await translateInBatches(
|
||||
{
|
||||
source_language: 'auto',
|
||||
target_language: targetLanguage,
|
||||
segments: prepared.segments,
|
||||
context: { title: metadata.value.version, description: '' },
|
||||
},
|
||||
(response) => {
|
||||
for (const segment of response.segments) accumulated[segment.id] = segment.text
|
||||
},
|
||||
)
|
||||
validateTranslatedDescription(prepared, accumulated)
|
||||
const translated = renderTranslatedDescription(
|
||||
prepared,
|
||||
accumulated,
|
||||
'translation-only',
|
||||
settings.style,
|
||||
)
|
||||
setUpgradeChangelogTranslation(key, translated)
|
||||
translatedChangelog.value = translated
|
||||
}
|
||||
showTranslation.value = true
|
||||
} catch (error) {
|
||||
translationError.value = translationFailureMessage(error)
|
||||
} finally {
|
||||
translationLoading.value = false
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
}
|
||||
}
|
||||
|
||||
async function openExternalLink(event: MouseEvent) {
|
||||
const target = event.target instanceof Element ? event.target.closest('a') : null
|
||||
if (!target) return
|
||||
event.preventDefault()
|
||||
const url = upgradeExternalChangelogUrl(target.getAttribute('href') ?? '')
|
||||
if (!url) return
|
||||
await openUrl(url)
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (visible.value) positionPopup()
|
||||
}
|
||||
|
||||
function close() {
|
||||
cancelClose()
|
||||
closeTimer = setTimeout(() => {
|
||||
if (!shouldUpgradeChangelogStayOpen(ownership.value)) visible.value = false
|
||||
}, 160)
|
||||
}
|
||||
|
||||
function forceClose() {
|
||||
cancelClose()
|
||||
ownership.value = {
|
||||
triggerHovered: false,
|
||||
triggerFocused: false,
|
||||
popupHovered: false,
|
||||
popupFocused: false,
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (trigger.value?.contains(target) || popup.value?.contains(target)) return
|
||||
forceClose()
|
||||
}
|
||||
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') forceClose()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleViewportChange)
|
||||
window.addEventListener('scroll', handleViewportChange, true)
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.addEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (closeTimer) clearTimeout(closeTimer)
|
||||
window.removeEventListener('resize', handleViewportChange)
|
||||
window.removeEventListener('scroll', handleViewportChange, true)
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.markdown-body a) {
|
||||
color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
724
apps/app-frontend/src/pages/instance/upgrade/analysis.test.ts
Normal file
724
apps/app-frontend/src/pages/instance/upgrade/analysis.test.ts
Normal file
@ -0,0 +1,724 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type {
|
||||
InstanceUpgradePlan,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
actionableWarningContentIds,
|
||||
AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
automaticFabricLoaderTargetAvailable,
|
||||
availablePredefinedStrategies,
|
||||
captureInitialUpgradeBlockingIssues,
|
||||
commitUpgradePlanSelection,
|
||||
compatibilitySummary,
|
||||
confirmDependencyReleaseSlots,
|
||||
confirmSelectionReleaseSlots,
|
||||
confirmSolutionGroups,
|
||||
confirmTargetLoaderLabel,
|
||||
confirmUpgradeOptions,
|
||||
contentIdentityKeys,
|
||||
customConstraintsEqual,
|
||||
editableUpgradeRoots,
|
||||
fabricLoaderVersionForTarget,
|
||||
fabricUpgradeLoaderVersions,
|
||||
groupUpgradeIssues,
|
||||
inferShaderRuntime,
|
||||
isSharedUpgradeInstance,
|
||||
newerStableGameVersions,
|
||||
preserveFabricLoaderSelection,
|
||||
resolveConfirmDependencyReleases,
|
||||
resolveUpgradePlanSelection,
|
||||
sanitizeMinecraftDisplayTitle,
|
||||
setFixedConstraint,
|
||||
shouldReuseUpgradePlan,
|
||||
solutionSummary,
|
||||
upgradeContentDisplayMetadata,
|
||||
upgradeResolutionPresentation,
|
||||
upgradeTargetsEqual,
|
||||
} from './analysis.ts'
|
||||
|
||||
function issue(code: string, contentId: string | null, projectId: string | null, message = code) {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
contentId,
|
||||
provider: projectId ? 'modrinth' : null,
|
||||
projectId,
|
||||
conflictingProjectId: null,
|
||||
dependencyRequirements: [],
|
||||
}
|
||||
}
|
||||
|
||||
function planItem(contentId: string, projectId: string | null = contentId) {
|
||||
return {
|
||||
contentId,
|
||||
relativePath: `mods/${contentId}.jar`,
|
||||
projectType: 'mod',
|
||||
provider: projectId ? 'modrinth' : null,
|
||||
projectId,
|
||||
currentReleaseId: 'old',
|
||||
currentEnabled: true,
|
||||
autoDependency: false,
|
||||
status: 'already_compatible',
|
||||
resolution: {
|
||||
contentId,
|
||||
action: 'upgrade',
|
||||
allowPrerelease: false,
|
||||
confirmedPrereleaseDependencies: [],
|
||||
},
|
||||
candidateReleaseIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
test('newer stable versions follow metadata order without numeric parsing', () => {
|
||||
const result = newerStableGameVersions(
|
||||
[
|
||||
{ version: '26.1.2', version_type: 'release', date: '', major: false },
|
||||
{ version: '26.1-beta', version_type: 'snapshot', date: '', major: false },
|
||||
{ version: '26.1', version_type: 'release', date: '', major: true },
|
||||
{ version: '1.21.8', version_type: 'release', date: '', major: false },
|
||||
{ version: '1.21.7', version_type: 'release', date: '', major: false },
|
||||
],
|
||||
'1.21.8',
|
||||
)
|
||||
|
||||
assert.deepEqual(result, { currentFound: true, versions: ['26.1.2', '26.1'] })
|
||||
})
|
||||
|
||||
test('upgrade target equality uses semantic environment fields', () => {
|
||||
const target = {
|
||||
gameVersion: '26.2',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: '0.18.4',
|
||||
shaderRuntime: 'iris',
|
||||
} as const
|
||||
assert.equal(upgradeTargetsEqual(target, { ...target }), true)
|
||||
assert.equal(upgradeTargetsEqual(target, { ...target, gameVersion: '26.1' }), false)
|
||||
assert.equal(upgradeTargetsEqual(target, { ...target, modLoaderVersion: '1' }), false)
|
||||
})
|
||||
|
||||
test('matching instance and semantic target reuse existing plan without replacing state', () => {
|
||||
const target = {
|
||||
gameVersion: '26.2',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: null,
|
||||
shaderRuntime: 'iris',
|
||||
} as const
|
||||
const plan = {
|
||||
id: 'plan-one',
|
||||
instanceId: 'instance-one',
|
||||
targetEnvironment: target,
|
||||
items: [{ resolution: { action: 'keep' } }],
|
||||
customConstraints: [{ contentId: 'root', versionId: 'fixed' }],
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.equal(shouldReuseUpgradePlan('instance-one', plan, { ...target }), true)
|
||||
assert.equal(plan.id, 'plan-one')
|
||||
assert.equal(plan.items[0].resolution.action, 'keep')
|
||||
assert.equal(plan.customConstraints[0].versionId, 'fixed')
|
||||
assert.equal(
|
||||
shouldReuseUpgradePlan('instance-one', plan, { ...target, gameVersion: '26.1' }),
|
||||
false,
|
||||
)
|
||||
assert.equal(shouldReuseUpgradePlan('instance-two', plan, target), false)
|
||||
assert.equal(shouldReuseUpgradePlan('instance-one', null, target), false)
|
||||
})
|
||||
|
||||
test('plan selection skips planner for matching target and calls it once for a confirmed change', async () => {
|
||||
const target: InstanceUpgradeTargetEnvironment = {
|
||||
gameVersion: '26.2',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: '0.18.4',
|
||||
shaderRuntime: 'iris',
|
||||
}
|
||||
const existing = {
|
||||
id: 'plan-one',
|
||||
instanceId: 'instance-one',
|
||||
targetEnvironment: target,
|
||||
} as InstanceUpgradePlan
|
||||
let calls = 0
|
||||
const planner = async (_instanceId: string, nextTarget: InstanceUpgradeTargetEnvironment) => {
|
||||
calls += 1
|
||||
return {
|
||||
...existing,
|
||||
id: 'plan-two',
|
||||
targetEnvironment: nextTarget,
|
||||
} as InstanceUpgradePlan
|
||||
}
|
||||
|
||||
const reused = await resolveUpgradePlanSelection('instance-one', existing, { ...target }, planner)
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(reused.plan, existing)
|
||||
assert.equal(reused.reused, true)
|
||||
|
||||
const replanned = await resolveUpgradePlanSelection(
|
||||
'instance-one',
|
||||
existing,
|
||||
{ ...target, modLoaderVersion: '0.18.5' },
|
||||
planner,
|
||||
)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(replanned.plan.id, 'plan-two')
|
||||
assert.equal(replanned.reused, false)
|
||||
})
|
||||
|
||||
test('failed replan preserves existing plan and authoritative target', async () => {
|
||||
const oldTarget = {
|
||||
gameVersion: '26.1',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: '0.18.4',
|
||||
shaderRuntime: 'iris',
|
||||
} as const
|
||||
const attemptedTarget = { ...oldTarget, gameVersion: '26.2', modLoaderVersion: '0.18.5' }
|
||||
const oldPlan = {
|
||||
id: 'old-plan',
|
||||
instanceId: 'instance-one',
|
||||
targetEnvironment: oldTarget,
|
||||
} as InstanceUpgradePlan
|
||||
let authoritativePlan = oldPlan
|
||||
let authoritativeTarget = oldTarget
|
||||
|
||||
await assert.rejects(
|
||||
commitUpgradePlanSelection(
|
||||
'instance-one',
|
||||
oldPlan,
|
||||
attemptedTarget,
|
||||
async () => {
|
||||
throw new Error('planning failed')
|
||||
},
|
||||
(plan) => (authoritativePlan = plan),
|
||||
(target) => (authoritativeTarget = target as typeof oldTarget),
|
||||
),
|
||||
/planning failed/,
|
||||
)
|
||||
assert.equal(authoritativePlan, oldPlan)
|
||||
assert.equal(authoritativeTarget, oldTarget)
|
||||
})
|
||||
|
||||
test('Fabric loader choices exclude downgrades using numeric semantic comparison', () => {
|
||||
assert.deepEqual(
|
||||
fabricUpgradeLoaderVersions('0.18.4', ['0.18.6', '0.18.5', '0.18.4', '0.18.3']),
|
||||
['0.18.6', '0.18.5', '0.18.4'],
|
||||
)
|
||||
assert.deepEqual(fabricUpgradeLoaderVersions('0.18.9', ['0.18.10', '0.18.9']), [
|
||||
'0.18.10',
|
||||
'0.18.9',
|
||||
])
|
||||
assert.deepEqual(fabricUpgradeLoaderVersions('custom', ['0.19.0']), [])
|
||||
})
|
||||
|
||||
test('Fabric loader pending selection preserves valid exact values and maps target values', () => {
|
||||
assert.equal(preserveFabricLoaderSelection('0.18.5', ['0.18.5']), '0.18.5')
|
||||
assert.equal(preserveFabricLoaderSelection('0.18.5', ['0.18.6']), AUTOMATIC_FABRIC_LOADER_VERSION)
|
||||
assert.equal(
|
||||
preserveFabricLoaderSelection(AUTOMATIC_FABRIC_LOADER_VERSION, []),
|
||||
AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
)
|
||||
assert.equal(fabricLoaderVersionForTarget(AUTOMATIC_FABRIC_LOADER_VERSION), null)
|
||||
assert.equal(fabricLoaderVersionForTarget('0.18.5'), '0.18.5')
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(true, true, []), false)
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(true, true, ['0.18.5']), true)
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(false, true, []), true)
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(true, false, []), true)
|
||||
})
|
||||
|
||||
test('shared upgrade detection uses established link or external target metadata', () => {
|
||||
assert.equal(isSharedUpgradeInstance({ link: { type: 'shared_instance' } } as never), true)
|
||||
assert.equal(isSharedUpgradeInstance({ symlink_target: 'D:/Minecraft' } as never), true)
|
||||
assert.equal(isSharedUpgradeInstance({ link: null, symlink_target: null } as never), false)
|
||||
})
|
||||
|
||||
test('unknown current version exposes stable releases conservatively', () => {
|
||||
const result = newerStableGameVersions(
|
||||
[
|
||||
{ version: '26.1', version_type: 'release', date: '', major: true },
|
||||
{ version: '26.1-beta', version_type: 'snapshot', date: '', major: false },
|
||||
],
|
||||
'custom',
|
||||
)
|
||||
|
||||
assert.deepEqual(result, { currentFound: false, versions: ['26.1'] })
|
||||
})
|
||||
|
||||
test('compatibility summary uses selected solution and changed dependencies', () => {
|
||||
const plan = {
|
||||
blockingIssues: [{ code: 'dependency_conflict' }, { code: 'prerelease_only' }],
|
||||
selectedSolution: {
|
||||
selections: [
|
||||
{ action: 'upgrade', currentReleaseId: 'old', targetReleaseId: 'new' },
|
||||
{ action: 'keep', currentReleaseId: 'same', targetReleaseId: 'same' },
|
||||
{ action: 'disable', currentReleaseId: 'off', targetReleaseId: null },
|
||||
],
|
||||
dependencyChanges: [{ kind: 'add' }, { kind: 'keep' }, { kind: 'remove' }],
|
||||
},
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.deepEqual(compatibilitySummary(plan), {
|
||||
updates: 1,
|
||||
keptOrCompatible: 1,
|
||||
disabled: 1,
|
||||
dependencyChanges: 2,
|
||||
needsAttention: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('shader runtime inference uses exact loader component and provider identity', () => {
|
||||
const instance = {
|
||||
loader: 'fabric',
|
||||
loader_components: [],
|
||||
} as never
|
||||
const snapshot = {
|
||||
items: [
|
||||
{
|
||||
projectType: 'mod',
|
||||
provider: 'modrinth',
|
||||
providerProjectId: 'YL57xq9U',
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
} as never
|
||||
|
||||
assert.equal(inferShaderRuntime(instance, snapshot), 'iris')
|
||||
assert.equal(
|
||||
inferShaderRuntime({ ...instance, loader_components: [{ kind: 'optifine' }] }, undefined),
|
||||
'opti_fine',
|
||||
)
|
||||
assert.equal(inferShaderRuntime(instance, undefined), 'unknown')
|
||||
})
|
||||
|
||||
test('solution summary separates root and dependency changes', () => {
|
||||
const summary = solutionSummary({
|
||||
kind: 'newest',
|
||||
selections: [
|
||||
{
|
||||
contentId: 'a',
|
||||
provider: 'modrinth',
|
||||
projectId: 'a',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: '2',
|
||||
action: 'upgrade',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
contentId: 'b',
|
||||
provider: 'modrinth',
|
||||
projectId: 'b',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: '1',
|
||||
action: 'keep',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
contentId: 'c',
|
||||
provider: 'modrinth',
|
||||
projectId: 'c',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: null,
|
||||
action: 'disable',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
dependencyChanges: [
|
||||
{
|
||||
existingContentId: null,
|
||||
provider: 'modrinth',
|
||||
projectId: 'd',
|
||||
currentReleaseId: null,
|
||||
targetReleaseId: '1',
|
||||
kind: 'add',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
existingContentId: 'e',
|
||||
provider: 'modrinth',
|
||||
projectId: 'e',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: '2',
|
||||
kind: 'upgrade',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
existingContentId: 'f',
|
||||
provider: 'modrinth',
|
||||
projectId: 'f',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: null,
|
||||
kind: 'remove',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
})
|
||||
assert.deepEqual(summary, {
|
||||
upgraded: 1,
|
||||
kept: 1,
|
||||
disabled: 1,
|
||||
dependencyAdditions: 1,
|
||||
dependencyUpdates: 1,
|
||||
dependencyRemovals: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('confirm detail groups follow authoritative selection actions', () => {
|
||||
const solution = {
|
||||
kind: 'custom',
|
||||
selections: [
|
||||
{ contentId: 'update', action: 'upgrade', currentReleaseId: '1', targetReleaseId: '2' },
|
||||
{ contentId: 'same', action: 'upgrade', currentReleaseId: '1', targetReleaseId: '1' },
|
||||
{ contentId: 'keep', action: 'keep', currentReleaseId: '1', targetReleaseId: '1' },
|
||||
{ contentId: 'disable', action: 'disable', currentReleaseId: '1', targetReleaseId: null },
|
||||
],
|
||||
dependencyChanges: [{ kind: 'add' }, { kind: 'upgrade' }, { kind: 'keep' }, { kind: 'remove' }],
|
||||
warnings: [],
|
||||
} as never
|
||||
const groups = confirmSolutionGroups(solution)
|
||||
|
||||
assert.deepEqual(
|
||||
groups.updated.map((item) => item.contentId),
|
||||
['update'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.kept.map((item) => item.contentId),
|
||||
['same', 'keep'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.disabled.map((item) => item.contentId),
|
||||
['disable'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.dependencyChanges.map((item) => item.kind),
|
||||
['add', 'upgrade', 'remove'],
|
||||
)
|
||||
})
|
||||
|
||||
test('confirm upgrade options require shared mode and suppress redundant copy backup', () => {
|
||||
assert.deepEqual(confirmUpgradeOptions(false, null, true), {
|
||||
effectiveMode: 'direct',
|
||||
createFullBackup: true,
|
||||
canStart: true,
|
||||
})
|
||||
assert.deepEqual(confirmUpgradeOptions(true, null, true), {
|
||||
effectiveMode: null,
|
||||
createFullBackup: true,
|
||||
canStart: false,
|
||||
})
|
||||
assert.deepEqual(confirmUpgradeOptions(true, 'direct', false), {
|
||||
effectiveMode: 'direct',
|
||||
createFullBackup: false,
|
||||
canStart: true,
|
||||
})
|
||||
assert.deepEqual(confirmUpgradeOptions(true, 'copy_and_upgrade', true), {
|
||||
effectiveMode: 'copy_and_upgrade',
|
||||
createFullBackup: false,
|
||||
canStart: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('confirm release slots keep current and target changelogs independent', () => {
|
||||
assert.deepEqual(
|
||||
confirmSelectionReleaseSlots({
|
||||
action: 'upgrade',
|
||||
currentReleaseId: 'current',
|
||||
targetReleaseId: 'target',
|
||||
} as never),
|
||||
{ currentReleaseId: 'current', targetReleaseId: 'target' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
confirmSelectionReleaseSlots({
|
||||
action: 'keep',
|
||||
currentReleaseId: 'current',
|
||||
targetReleaseId: 'current',
|
||||
} as never),
|
||||
{ currentReleaseId: 'current', targetReleaseId: null },
|
||||
)
|
||||
assert.deepEqual(
|
||||
confirmDependencyReleaseSlots({
|
||||
currentReleaseId: 'current',
|
||||
targetReleaseId: 'target',
|
||||
} as never),
|
||||
{ currentReleaseId: 'current', targetReleaseId: 'target' },
|
||||
)
|
||||
})
|
||||
|
||||
test('dependency detail resolves deterministic current and target release slots', () => {
|
||||
const cases = [
|
||||
{
|
||||
change: { kind: 'upgrade', currentReleaseId: 'old', targetReleaseId: 'new' },
|
||||
expected: {
|
||||
currentReleaseId: 'old',
|
||||
targetReleaseId: 'new',
|
||||
current: 'old-label',
|
||||
target: 'new-label',
|
||||
},
|
||||
},
|
||||
{
|
||||
change: { kind: 'add', currentReleaseId: null, targetReleaseId: 'new' },
|
||||
expected: {
|
||||
currentReleaseId: null,
|
||||
targetReleaseId: 'new',
|
||||
current: null,
|
||||
target: 'new-label',
|
||||
},
|
||||
},
|
||||
{
|
||||
change: { kind: 'remove', currentReleaseId: 'old', targetReleaseId: null },
|
||||
expected: {
|
||||
currentReleaseId: 'old',
|
||||
targetReleaseId: null,
|
||||
current: 'old-label',
|
||||
target: null,
|
||||
},
|
||||
},
|
||||
]
|
||||
for (const { change, expected } of cases) {
|
||||
assert.deepEqual(
|
||||
resolveConfirmDependencyReleases(change as never, (releaseId) =>
|
||||
releaseId ? `${releaseId}-label` : null,
|
||||
),
|
||||
expected,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('confirm target loader label shows explicit version or honest automatic policy', () => {
|
||||
assert.equal(confirmTargetLoaderLabel('Fabric', 'fabric', '0.18.4', 'automatic'), 'Fabric 0.18.4')
|
||||
assert.equal(
|
||||
confirmTargetLoaderLabel('Fabric', 'fabric', null, 'automatic'),
|
||||
'Fabric (automatic)',
|
||||
)
|
||||
assert.equal(confirmTargetLoaderLabel('Vanilla', 'vanilla', null, 'automatic'), 'Vanilla')
|
||||
})
|
||||
|
||||
test('fixed constraints replace and remove by physical content without duplicates', () => {
|
||||
const first = {
|
||||
contentId: 'a',
|
||||
provider: 'modrinth' as const,
|
||||
projectId: 'project',
|
||||
versionId: 'one',
|
||||
}
|
||||
const replaced = setFixedConstraint([first], { ...first, versionId: 'two' }, 'a')
|
||||
assert.deepEqual(replaced, [{ ...first, versionId: 'two' }])
|
||||
assert.deepEqual(setFixedConstraint(replaced, null, 'a'), [])
|
||||
assert.equal(customConstraintsEqual(replaced, [{ ...first, versionId: 'two' }]), true)
|
||||
})
|
||||
|
||||
test('editable roots exclude automatic dependencies', () => {
|
||||
const root = {
|
||||
contentId: 'root',
|
||||
autoDependency: false,
|
||||
provider: 'modrinth',
|
||||
projectId: 'root',
|
||||
candidateReleaseIds: ['one'],
|
||||
}
|
||||
const dependency = { ...root, contentId: 'dependency', autoDependency: true }
|
||||
const plan = { items: [root, dependency], customConstraints: [] } as InstanceUpgradePlan
|
||||
assert.deepEqual(
|
||||
editableUpgradeRoots(plan).map((item) => item.contentId),
|
||||
['root'],
|
||||
)
|
||||
})
|
||||
|
||||
test('unavailable minimal solution is not selectable', () => {
|
||||
const newestSolution = { kind: 'newest', selections: [], dependencyChanges: [], warnings: [] }
|
||||
assert.deepEqual(
|
||||
availablePredefinedStrategies({
|
||||
newestSolution,
|
||||
minimalChangeSolution: null,
|
||||
} as InstanceUpgradePlan),
|
||||
['newest'],
|
||||
)
|
||||
})
|
||||
|
||||
test('issue grouping gives blocking precedence and includes every content once', () => {
|
||||
const plan = {
|
||||
items: [planItem('blocked'), planItem('warned'), planItem('clear')],
|
||||
blockingIssues: [issue('dependency_conflict', 'blocked', 'blocked')],
|
||||
warnings: [
|
||||
issue('keep_incompatible', 'blocked', 'blocked'),
|
||||
issue('keep_incompatible', 'warned', 'warned'),
|
||||
],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(plan)
|
||||
|
||||
assert.deepEqual(
|
||||
groups.blocking.map((group) => group.item.contentId),
|
||||
['blocked'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.warnings.map((group) => group.item.contentId),
|
||||
['warned'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.noIssues.map((group) => group.item.contentId),
|
||||
['clear'],
|
||||
)
|
||||
assert.equal(
|
||||
new Set(
|
||||
[...groups.blocking, ...groups.warnings, ...groups.noIssues].map(
|
||||
(group) => group.item.contentId,
|
||||
),
|
||||
).size,
|
||||
3,
|
||||
)
|
||||
})
|
||||
|
||||
test('initial blockers stay in blocking presentation without duplication after resolution', () => {
|
||||
const initialPlan = {
|
||||
items: [planItem('voxy'), planItem('clear')],
|
||||
blockingIssues: [issue('prerelease_only', 'voxy', 'voxy')],
|
||||
warnings: [],
|
||||
} as InstanceUpgradePlan
|
||||
const initial = captureInitialUpgradeBlockingIssues(initialPlan)
|
||||
const resolvedPlan = {
|
||||
...initialPlan,
|
||||
blockingIssues: [],
|
||||
warnings: [issue('keep_incompatible', 'voxy', 'voxy')],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(resolvedPlan, initial)
|
||||
|
||||
assert.deepEqual(
|
||||
groups.blocking.map((group) => group.item.contentId),
|
||||
['voxy'],
|
||||
)
|
||||
assert.equal(groups.blocking[0].currentlyBlocking, false)
|
||||
assert.equal(groups.blocking[0].warnings.length, 2)
|
||||
assert.equal(groups.warnings.length, 0)
|
||||
assert.deepEqual(
|
||||
groups.noIssues.map((group) => group.item.contentId),
|
||||
['clear'],
|
||||
)
|
||||
})
|
||||
|
||||
test('resolution presentation follows authoritative plan resolution rules', () => {
|
||||
const resolution = planItem('item').resolution
|
||||
assert.deepEqual(upgradeResolutionPresentation('two-option', resolution), {
|
||||
selectedAction: null,
|
||||
showUndo: false,
|
||||
})
|
||||
assert.deepEqual(upgradeResolutionPresentation('two-option', { ...resolution, action: 'keep' }), {
|
||||
selectedAction: 'keep',
|
||||
showUndo: false,
|
||||
})
|
||||
assert.deepEqual(
|
||||
upgradeResolutionPresentation('two-option', { ...resolution, action: 'disable' }),
|
||||
{ selectedAction: 'disable', showUndo: false },
|
||||
)
|
||||
assert.deepEqual(upgradeResolutionPresentation('single-prerelease', resolution), {
|
||||
selectedAction: null,
|
||||
showUndo: false,
|
||||
})
|
||||
assert.deepEqual(
|
||||
upgradeResolutionPresentation('single-prerelease', {
|
||||
...resolution,
|
||||
allowPrerelease: true,
|
||||
}),
|
||||
{ selectedAction: 'upgrade', showUndo: true },
|
||||
)
|
||||
})
|
||||
|
||||
test('root and content forms of one issue coalesce on exact project identity', () => {
|
||||
const plan = {
|
||||
items: [planItem('content', 'project')],
|
||||
blockingIssues: [
|
||||
issue('no_compatible_release', null, 'project', 'root form'),
|
||||
issue('no_compatible_release', 'content', 'project', 'content form'),
|
||||
],
|
||||
warnings: [],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(plan)
|
||||
|
||||
assert.equal(groups.blocking[0].blockingIssues.length, 1)
|
||||
assert.equal(groups.blocking[0].blockingIssues[0].message, 'content form')
|
||||
})
|
||||
|
||||
test('unmapped and ambiguous project issues remain global', () => {
|
||||
const duplicate = planItem('duplicate-b', 'duplicate')
|
||||
const plan = {
|
||||
items: [planItem('duplicate-a', 'duplicate'), duplicate],
|
||||
blockingIssues: [issue('dependency_conflict', null, 'missing')],
|
||||
warnings: [issue('keep_incompatible', null, 'duplicate')],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(plan)
|
||||
|
||||
assert.equal(groups.globalBlockingIssues.length, 1)
|
||||
assert.equal(groups.globalWarnings.length, 1)
|
||||
assert.equal(groups.noIssues.length, 2)
|
||||
})
|
||||
|
||||
test('actionable warning filtering excludes global and informational conflicts', () => {
|
||||
const plan = {
|
||||
items: [planItem('actionable'), planItem('conflict')],
|
||||
blockingIssues: [],
|
||||
warnings: [
|
||||
issue('keep_incompatible', 'actionable', 'actionable'),
|
||||
issue('dependency_conflict', 'conflict', 'conflict'),
|
||||
issue('keep_incompatible', null, null),
|
||||
],
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.deepEqual(actionableWarningContentIds(groupUpgradeIssues(plan)), ['actionable'])
|
||||
})
|
||||
|
||||
test('actionable warning count uses unique content rows', () => {
|
||||
const plan = {
|
||||
items: [planItem('one'), planItem('two')],
|
||||
blockingIssues: [],
|
||||
warnings: [
|
||||
issue('keep_incompatible', 'one', 'one'),
|
||||
issue('shader_runtime_unknown', 'one', 'one'),
|
||||
issue('unidentified', 'two', 'two'),
|
||||
],
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.equal(actionableWarningContentIds(groupUpgradeIssues(plan)).length, 2)
|
||||
})
|
||||
|
||||
test('content display metadata prefers normalized content then snapshot then plan fallback', () => {
|
||||
const item = planItem('entry', 'plan-project') as never
|
||||
const snapshot = {
|
||||
expectedRelativePath: 'resourcepacks/file.zip',
|
||||
content: {
|
||||
project: { title: 'Snapshot title', icon_url: 'snapshot.png' },
|
||||
version: { version_number: 'snapshot-version' },
|
||||
},
|
||||
} as never
|
||||
const content = {
|
||||
project: { title: 'Resolved title', icon_url: 'resolved.png' },
|
||||
version: { version_number: 'resolved-version' },
|
||||
} as never
|
||||
|
||||
assert.deepEqual(upgradeContentDisplayMetadata(item, content, snapshot), {
|
||||
title: 'Resolved title',
|
||||
iconUrl: 'resolved.png',
|
||||
currentVersion: 'resolved-version',
|
||||
})
|
||||
assert.deepEqual(upgradeContentDisplayMetadata(item, undefined, snapshot), {
|
||||
title: 'Snapshot title',
|
||||
iconUrl: 'snapshot.png',
|
||||
currentVersion: 'snapshot-version',
|
||||
})
|
||||
assert.equal(upgradeContentDisplayMetadata(item).title, 'entry.jar')
|
||||
})
|
||||
|
||||
test('local content identity joins by normalized path when entry ids are absent', () => {
|
||||
assert.deepEqual(contentIdentityKeys({ relativePath: 'resourcepacks\\pack.zip' }), [
|
||||
'resourcepacks/pack.zip',
|
||||
])
|
||||
assert.deepEqual(
|
||||
contentIdentityKeys({ instanceEntryId: 'entry', relativePath: 'resourcepacks/pack.zip' }),
|
||||
['entry', 'resourcepacks/pack.zip'],
|
||||
)
|
||||
})
|
||||
|
||||
test('Minecraft formatting codes are removed from display title only', () => {
|
||||
const item = planItem('identity')
|
||||
item.relativePath = 'resourcepacks/§9§lExample §rPack.zip'
|
||||
const originalPath = item.relativePath
|
||||
assert.equal(sanitizeMinecraftDisplayTitle('§9§lExample §rPack'), 'Example Pack')
|
||||
assert.equal(upgradeContentDisplayMetadata(item).title, 'Example Pack.zip')
|
||||
assert.equal(item.relativePath, originalPath)
|
||||
assert.equal(item.contentId, 'identity')
|
||||
})
|
||||
611
apps/app-frontend/src/pages/instance/upgrade/analysis.ts
Normal file
611
apps/app-frontend/src/pages/instance/upgrade/analysis.ts
Normal file
@ -0,0 +1,611 @@
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
|
||||
import type { InstanceContentSnapshot, InstanceContentSnapshotItem } from '@/helpers/instance'
|
||||
import type {
|
||||
InstanceUpgradeDependencyChange,
|
||||
InstanceUpgradeFixedConstraint,
|
||||
InstanceUpgradeIssue,
|
||||
InstanceUpgradePlan,
|
||||
InstanceUpgradePlanItem,
|
||||
InstanceUpgradeSolution,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
ShaderRuntime,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { compareSemanticVersions } from '../../../helpers/version-compatibility.ts'
|
||||
|
||||
export const AUTOMATIC_FABRIC_LOADER_VERSION = '__automatic__'
|
||||
|
||||
export interface UpgradeVersionTargets {
|
||||
currentFound: boolean
|
||||
versions: string[]
|
||||
}
|
||||
|
||||
export interface CompatibilitySummary {
|
||||
updates: number
|
||||
keptOrCompatible: number
|
||||
disabled: number
|
||||
dependencyChanges: number
|
||||
needsAttention: number
|
||||
}
|
||||
|
||||
export interface SolutionSummary {
|
||||
upgraded: number
|
||||
kept: number
|
||||
disabled: number
|
||||
dependencyAdditions: number
|
||||
dependencyUpdates: number
|
||||
dependencyRemovals: number
|
||||
}
|
||||
|
||||
export interface ConfirmSolutionGroups {
|
||||
updated: InstanceUpgradeSolution['selections']
|
||||
kept: InstanceUpgradeSolution['selections']
|
||||
disabled: InstanceUpgradeSolution['selections']
|
||||
dependencyChanges: InstanceUpgradeDependencyChange[]
|
||||
}
|
||||
|
||||
export interface ConfirmUpgradeOptions {
|
||||
effectiveMode: 'direct' | 'copy_and_upgrade' | null
|
||||
createFullBackup: boolean
|
||||
canStart: boolean
|
||||
}
|
||||
|
||||
export interface ConfirmReleaseSlots {
|
||||
currentReleaseId: string | null
|
||||
targetReleaseId: string | null
|
||||
}
|
||||
|
||||
export interface ConfirmResolvedReleaseSlots extends ConfirmReleaseSlots {
|
||||
current: string | null
|
||||
target: string | null
|
||||
}
|
||||
|
||||
export interface UpgradeContentIssueGroup {
|
||||
item: InstanceUpgradePlanItem
|
||||
blockingIssues: InstanceUpgradeIssue[]
|
||||
warnings: InstanceUpgradeIssue[]
|
||||
startedBlocking: boolean
|
||||
currentlyBlocking: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeIssueGroups {
|
||||
blocking: UpgradeContentIssueGroup[]
|
||||
warnings: UpgradeContentIssueGroup[]
|
||||
noIssues: UpgradeContentIssueGroup[]
|
||||
globalBlockingIssues: InstanceUpgradeIssue[]
|
||||
globalWarnings: InstanceUpgradeIssue[]
|
||||
}
|
||||
|
||||
export interface UpgradeContentDisplayMetadata {
|
||||
title: string
|
||||
iconUrl: string | null
|
||||
currentVersion: string | null
|
||||
}
|
||||
|
||||
export type InitialUpgradeBlockingIssues = Record<string, InstanceUpgradeIssue[]>
|
||||
|
||||
export interface UpgradeResolutionPresentation {
|
||||
selectedAction: 'upgrade' | 'keep' | 'disable' | null
|
||||
showUndo: boolean
|
||||
}
|
||||
|
||||
export function normalizeUpgradePath(path: string): string {
|
||||
return path.replaceAll('\\', '/').replace(/\/+/g, '/').replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
export function upgradeTargetsEqual(
|
||||
left: InstanceUpgradeTargetEnvironment,
|
||||
right: InstanceUpgradeTargetEnvironment,
|
||||
): boolean {
|
||||
return (
|
||||
left.gameVersion === right.gameVersion &&
|
||||
left.modLoader === right.modLoader &&
|
||||
left.modLoaderVersion === right.modLoaderVersion &&
|
||||
left.shaderRuntime === right.shaderRuntime
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldReuseUpgradePlan(
|
||||
instanceId: string,
|
||||
plan: InstanceUpgradePlan | null,
|
||||
target: InstanceUpgradeTargetEnvironment | null,
|
||||
): boolean {
|
||||
return (
|
||||
plan !== null &&
|
||||
target !== null &&
|
||||
plan.instanceId === instanceId &&
|
||||
upgradeTargetsEqual(plan.targetEnvironment, target)
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveUpgradePlanSelection(
|
||||
instanceId: string,
|
||||
existingPlan: InstanceUpgradePlan | null,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
planUpgrade: (
|
||||
instanceId: string,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
) => Promise<InstanceUpgradePlan>,
|
||||
): Promise<{ plan: InstanceUpgradePlan; reused: boolean }> {
|
||||
if (shouldReuseUpgradePlan(instanceId, existingPlan, target)) {
|
||||
return { plan: existingPlan!, reused: true }
|
||||
}
|
||||
return { plan: await planUpgrade(instanceId, target), reused: false }
|
||||
}
|
||||
|
||||
export async function commitUpgradePlanSelection(
|
||||
instanceId: string,
|
||||
existingPlan: InstanceUpgradePlan | null,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
planUpgrade: (
|
||||
instanceId: string,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
) => Promise<InstanceUpgradePlan>,
|
||||
commitPlan: (plan: InstanceUpgradePlan) => void,
|
||||
commitTarget: (target: InstanceUpgradeTargetEnvironment) => void,
|
||||
): Promise<{ plan: InstanceUpgradePlan; reused: boolean }> {
|
||||
const result = await resolveUpgradePlanSelection(instanceId, existingPlan, target, planUpgrade)
|
||||
if (!result.reused) commitPlan(result.plan)
|
||||
commitTarget(result.plan.targetEnvironment)
|
||||
return result
|
||||
}
|
||||
|
||||
export function fabricUpgradeLoaderVersions(
|
||||
currentVersion: string | null | undefined,
|
||||
availableVersions: readonly string[],
|
||||
): string[] {
|
||||
if (!currentVersion || compareSemanticVersions(currentVersion, currentVersion) === null) return []
|
||||
return availableVersions.filter((version) => {
|
||||
const comparison = compareSemanticVersions(version, currentVersion)
|
||||
return comparison !== null && comparison >= 0
|
||||
})
|
||||
}
|
||||
|
||||
export function preserveFabricLoaderSelection(
|
||||
selectedVersion: string,
|
||||
availableVersions: readonly string[],
|
||||
): string {
|
||||
return selectedVersion === AUTOMATIC_FABRIC_LOADER_VERSION ||
|
||||
availableVersions.includes(selectedVersion)
|
||||
? selectedVersion
|
||||
: AUTOMATIC_FABRIC_LOADER_VERSION
|
||||
}
|
||||
|
||||
export function fabricLoaderVersionForTarget(selectedVersion: string): string | null {
|
||||
return selectedVersion === AUTOMATIC_FABRIC_LOADER_VERSION ? null : selectedVersion
|
||||
}
|
||||
|
||||
export function automaticFabricLoaderTargetAvailable(
|
||||
metadataLoaded: boolean,
|
||||
currentVersionComparable: boolean,
|
||||
availableVersions: readonly string[],
|
||||
): boolean {
|
||||
return !metadataLoaded || !currentVersionComparable || availableVersions.length > 0
|
||||
}
|
||||
|
||||
export function isSharedUpgradeInstance(instance: GameInstance): boolean {
|
||||
return instance.link?.type === 'shared_instance' || Boolean(instance.symlink_target)
|
||||
}
|
||||
|
||||
export function contentIdentityKeys(item: {
|
||||
contentId?: string | null
|
||||
relativePath?: string | null
|
||||
instanceEntryId?: string | null
|
||||
instanceMemberId?: string | null
|
||||
instanceFileId?: string | null
|
||||
id?: string | null
|
||||
file_path?: string | null
|
||||
}): string[] {
|
||||
return [
|
||||
item.contentId,
|
||||
item.instanceEntryId,
|
||||
item.instanceMemberId,
|
||||
item.instanceFileId,
|
||||
item.id,
|
||||
item.relativePath ? normalizeUpgradePath(item.relativePath) : null,
|
||||
item.file_path ? normalizeUpgradePath(item.file_path) : null,
|
||||
].filter((value): value is string => Boolean(value))
|
||||
}
|
||||
|
||||
const ACTIONABLE_WARNING_CODES = new Set<InstanceUpgradeIssue['code']>([
|
||||
'unidentified',
|
||||
'unsupported_content_type',
|
||||
'prerelease_only',
|
||||
'no_compatible_release',
|
||||
'no_compatible_shader_runtime',
|
||||
'shader_runtime_missing',
|
||||
'shader_runtime_unknown',
|
||||
'keep_incompatible',
|
||||
])
|
||||
|
||||
function issueIdentity(issue: InstanceUpgradeIssue): string {
|
||||
const requirements = issue.dependencyRequirements
|
||||
.map((requirement) =>
|
||||
[
|
||||
requirement.rootContentId,
|
||||
requirement.parentProvider,
|
||||
requirement.parentProjectId,
|
||||
requirement.parentReleaseId,
|
||||
requirement.dependencyProvider,
|
||||
requirement.dependencyProjectId,
|
||||
requirement.requiredReleaseId ?? '',
|
||||
requirement.candidateReleaseId ?? '',
|
||||
].join(':'),
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
return [
|
||||
issue.code,
|
||||
issue.provider ?? '',
|
||||
issue.projectId ?? '',
|
||||
issue.conflictingProjectId ?? '',
|
||||
requirements,
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function issueContentId(
|
||||
issue: InstanceUpgradeIssue,
|
||||
itemsById: Map<string, InstanceUpgradePlanItem>,
|
||||
itemsByProject: Map<string, InstanceUpgradePlanItem | null>,
|
||||
): string | null {
|
||||
if (issue.contentId && itemsById.has(issue.contentId)) return issue.contentId
|
||||
if (!issue.projectId) return null
|
||||
const providerProject = `${issue.provider ?? ''}:${issue.projectId}`
|
||||
return itemsByProject.get(providerProject)?.contentId ?? null
|
||||
}
|
||||
|
||||
export function captureInitialUpgradeBlockingIssues(
|
||||
plan: InstanceUpgradePlan,
|
||||
): InitialUpgradeBlockingIssues {
|
||||
return Object.fromEntries(
|
||||
groupUpgradeIssues(plan).blocking.map((group) => [group.item.contentId, group.blockingIssues]),
|
||||
)
|
||||
}
|
||||
|
||||
export function groupUpgradeIssues(
|
||||
plan: InstanceUpgradePlan,
|
||||
initialBlockingIssues: InitialUpgradeBlockingIssues = {},
|
||||
): UpgradeIssueGroups {
|
||||
const itemsById = new Map(plan.items.map((item) => [item.contentId, item]))
|
||||
const itemsByProject = new Map<string, InstanceUpgradePlanItem | null>()
|
||||
for (const item of plan.items) {
|
||||
if (!item.projectId) continue
|
||||
const key = `${item.provider ?? ''}:${item.projectId}`
|
||||
itemsByProject.set(key, itemsByProject.has(key) ? null : item)
|
||||
}
|
||||
|
||||
const blockingByContent = new Map<string, Map<string, InstanceUpgradeIssue>>()
|
||||
const warningByContent = new Map<string, Map<string, InstanceUpgradeIssue>>()
|
||||
const globalBlockingIssues: InstanceUpgradeIssue[] = []
|
||||
const globalWarnings: InstanceUpgradeIssue[] = []
|
||||
|
||||
function collect(
|
||||
issue: InstanceUpgradeIssue,
|
||||
byContent: Map<string, Map<string, InstanceUpgradeIssue>>,
|
||||
global: InstanceUpgradeIssue[],
|
||||
) {
|
||||
const contentId = issueContentId(issue, itemsById, itemsByProject)
|
||||
if (!contentId) {
|
||||
global.push(issue)
|
||||
return
|
||||
}
|
||||
const issues = byContent.get(contentId) ?? new Map<string, InstanceUpgradeIssue>()
|
||||
const key = issueIdentity(issue)
|
||||
const existing = issues.get(key)
|
||||
if (!existing || (existing.contentId === null && issue.contentId !== null))
|
||||
issues.set(key, issue)
|
||||
byContent.set(contentId, issues)
|
||||
}
|
||||
|
||||
for (const issue of plan.blockingIssues) collect(issue, blockingByContent, globalBlockingIssues)
|
||||
for (const issue of plan.warnings) collect(issue, warningByContent, globalWarnings)
|
||||
|
||||
const blocking: UpgradeContentIssueGroup[] = []
|
||||
const warnings: UpgradeContentIssueGroup[] = []
|
||||
const noIssues: UpgradeContentIssueGroup[] = []
|
||||
for (const item of plan.items) {
|
||||
const itemBlocking = [...(blockingByContent.get(item.contentId)?.values() ?? [])]
|
||||
const blockingKeys = new Set(itemBlocking.map(issueIdentity))
|
||||
const itemWarnings = [...(warningByContent.get(item.contentId)?.values() ?? [])].filter(
|
||||
(issue) => !blockingKeys.has(issueIdentity(issue)),
|
||||
)
|
||||
const initialIssues = initialBlockingIssues[item.contentId] ?? []
|
||||
const startedBlocking = initialIssues.length > 0
|
||||
const currentlyBlocking = itemBlocking.length > 0
|
||||
const contextualWarnings = currentlyBlocking
|
||||
? itemWarnings
|
||||
: [
|
||||
...new Map(
|
||||
[...initialIssues, ...itemWarnings].map((issue) => [issueIdentity(issue), issue]),
|
||||
).values(),
|
||||
]
|
||||
const group = {
|
||||
item,
|
||||
blockingIssues: itemBlocking,
|
||||
warnings: contextualWarnings,
|
||||
startedBlocking,
|
||||
currentlyBlocking,
|
||||
}
|
||||
if (currentlyBlocking || startedBlocking) blocking.push(group)
|
||||
else if (itemWarnings.length) warnings.push(group)
|
||||
else noIssues.push(group)
|
||||
}
|
||||
|
||||
return { blocking, warnings, noIssues, globalBlockingIssues, globalWarnings }
|
||||
}
|
||||
|
||||
export function actionableWarningContentIds(groups: UpgradeIssueGroups): string[] {
|
||||
return groups.warnings
|
||||
.filter((group) => group.warnings.some((issue) => ACTIONABLE_WARNING_CODES.has(issue.code)))
|
||||
.map((group) => group.item.contentId)
|
||||
}
|
||||
|
||||
export function upgradeContentDisplayMetadata(
|
||||
item: InstanceUpgradePlanItem,
|
||||
contentItem?: ContentItem,
|
||||
snapshotItem?: InstanceContentSnapshotItem,
|
||||
): UpgradeContentDisplayMetadata {
|
||||
const fallbackPath = snapshotItem?.expectedRelativePath ?? item.relativePath
|
||||
const fallbackName = fallbackPath.split('/').pop() ?? fallbackPath
|
||||
return {
|
||||
title: sanitizeMinecraftDisplayTitle(
|
||||
contentItem?.project.title ?? snapshotItem?.content?.project.title ?? fallbackName,
|
||||
),
|
||||
iconUrl: contentItem?.project.icon_url ?? snapshotItem?.content?.project.icon_url ?? null,
|
||||
currentVersion:
|
||||
contentItem?.version?.version_number ??
|
||||
snapshotItem?.content?.version?.version_number ??
|
||||
item.currentReleaseId,
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeMinecraftDisplayTitle(title: string): string {
|
||||
return title.replace(/§[0-9a-fk-or]/gi, '')
|
||||
}
|
||||
|
||||
export function upgradeResolutionPresentation(
|
||||
kind: 'two-option' | 'single-prerelease',
|
||||
resolution: InstanceUpgradePlanItem['resolution'],
|
||||
): UpgradeResolutionPresentation {
|
||||
if (kind === 'single-prerelease') {
|
||||
return {
|
||||
selectedAction: resolution.allowPrerelease ? 'upgrade' : null,
|
||||
showUndo: resolution.allowPrerelease,
|
||||
}
|
||||
}
|
||||
return {
|
||||
selectedAction:
|
||||
resolution.action === 'keep' || resolution.action === 'disable' ? resolution.action : null,
|
||||
showUndo: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function availablePredefinedStrategies(plan: InstanceUpgradePlan) {
|
||||
return [
|
||||
...(plan.newestSolution ? (['newest'] as const) : []),
|
||||
...(plan.minimalChangeSolution ? (['minimal_change'] as const) : []),
|
||||
]
|
||||
}
|
||||
|
||||
const IRIS_MODRINTH_PROJECT_ID = 'YL57xq9U'
|
||||
|
||||
export function inferShaderRuntime(
|
||||
instance: GameInstance,
|
||||
snapshot: InstanceContentSnapshot | undefined,
|
||||
): ShaderRuntime {
|
||||
if (
|
||||
instance.loader === 'optifine' ||
|
||||
instance.loader_components.some((component) => component.kind === 'optifine')
|
||||
) {
|
||||
return 'opti_fine'
|
||||
}
|
||||
if (!snapshot) return 'unknown'
|
||||
|
||||
const hasIris = snapshot.items.some(
|
||||
(item) =>
|
||||
(item.provider === 'modrinth' && item.providerProjectId === IRIS_MODRINTH_PROJECT_ID) ||
|
||||
item.content?.provider_refs.some(
|
||||
(reference) =>
|
||||
reference.provider === 'modrinth' && reference.project_id === IRIS_MODRINTH_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
if (hasIris) return 'iris'
|
||||
|
||||
const hasUnresolvedModIdentity = snapshot.items.some(
|
||||
(item) =>
|
||||
item.projectType === 'mod' &&
|
||||
(item.provider !== 'modrinth' || item.providerProjectId === null),
|
||||
)
|
||||
return hasUnresolvedModIdentity ? 'unknown' : 'none'
|
||||
}
|
||||
|
||||
export function newerStableGameVersions(
|
||||
metadata: GameVersionTag[],
|
||||
currentVersion: string,
|
||||
): UpgradeVersionTargets {
|
||||
const currentIndex = metadata.findIndex((version) => version.version === currentVersion)
|
||||
const candidates = currentIndex === -1 ? metadata : metadata.slice(0, currentIndex)
|
||||
return {
|
||||
currentFound: currentIndex !== -1,
|
||||
versions: candidates
|
||||
.filter((version) => version.version_type === 'release' && version.version !== currentVersion)
|
||||
.map((version) => version.version),
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeSelections(solution: InstanceUpgradeSolution) {
|
||||
return solution.selections.reduce(
|
||||
(summary, selection) => {
|
||||
if (selection.action === 'disable') summary.disabled += 1
|
||||
else if (
|
||||
selection.action === 'upgrade' &&
|
||||
selection.targetReleaseId !== null &&
|
||||
selection.targetReleaseId !== selection.currentReleaseId
|
||||
) {
|
||||
summary.updates += 1
|
||||
} else summary.keptOrCompatible += 1
|
||||
return summary
|
||||
},
|
||||
{ updates: 0, keptOrCompatible: 0, disabled: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
export function solutionSummary(solution: InstanceUpgradeSolution): SolutionSummary {
|
||||
const selections = summarizeSelections(solution)
|
||||
return {
|
||||
upgraded: selections.updates,
|
||||
kept: selections.keptOrCompatible,
|
||||
disabled: selections.disabled,
|
||||
dependencyAdditions: solution.dependencyChanges.filter((change) => change.kind === 'add')
|
||||
.length,
|
||||
dependencyUpdates: solution.dependencyChanges.filter((change) => change.kind === 'upgrade')
|
||||
.length,
|
||||
dependencyRemovals: solution.dependencyChanges.filter((change) => change.kind === 'remove')
|
||||
.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmSolutionGroups(solution: InstanceUpgradeSolution): ConfirmSolutionGroups {
|
||||
return {
|
||||
updated: solution.selections.filter(
|
||||
(selection) =>
|
||||
selection.action === 'upgrade' &&
|
||||
selection.targetReleaseId !== null &&
|
||||
selection.targetReleaseId !== selection.currentReleaseId,
|
||||
),
|
||||
kept: solution.selections.filter(
|
||||
(selection) =>
|
||||
selection.action !== 'disable' &&
|
||||
(selection.action !== 'upgrade' ||
|
||||
selection.targetReleaseId === null ||
|
||||
selection.targetReleaseId === selection.currentReleaseId),
|
||||
),
|
||||
disabled: solution.selections.filter((selection) => selection.action === 'disable'),
|
||||
dependencyChanges: solution.dependencyChanges.filter((change) => change.kind !== 'keep'),
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmUpgradeOptions(
|
||||
sharedInstance: boolean,
|
||||
sharedMode: 'direct' | 'copy_and_upgrade' | null,
|
||||
directFullBackupPreference: boolean,
|
||||
): ConfirmUpgradeOptions {
|
||||
const effectiveMode = sharedInstance ? sharedMode : 'direct'
|
||||
return {
|
||||
effectiveMode,
|
||||
createFullBackup: effectiveMode === 'copy_and_upgrade' ? false : directFullBackupPreference,
|
||||
canStart: !sharedInstance || sharedMode !== null,
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmSelectionReleaseSlots(
|
||||
selection: InstanceUpgradeSolution['selections'][number],
|
||||
): ConfirmReleaseSlots {
|
||||
return {
|
||||
currentReleaseId: selection.currentReleaseId,
|
||||
targetReleaseId:
|
||||
selection.action === 'upgrade' && selection.targetReleaseId !== selection.currentReleaseId
|
||||
? selection.targetReleaseId
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmDependencyReleaseSlots(
|
||||
change: InstanceUpgradeDependencyChange,
|
||||
): ConfirmReleaseSlots {
|
||||
return {
|
||||
currentReleaseId: change.currentReleaseId,
|
||||
targetReleaseId:
|
||||
change.targetReleaseId !== change.currentReleaseId ? change.targetReleaseId : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveConfirmDependencyReleases(
|
||||
change: InstanceUpgradeDependencyChange,
|
||||
resolveLabel: (releaseId: string | null, slot: 'current' | 'target') => string | null,
|
||||
): ConfirmResolvedReleaseSlots {
|
||||
const releases = confirmDependencyReleaseSlots(change)
|
||||
return {
|
||||
...releases,
|
||||
current: resolveLabel(releases.currentReleaseId, 'current'),
|
||||
target: resolveLabel(releases.targetReleaseId, 'target'),
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmTargetLoaderLabel(
|
||||
loaderLabel: string,
|
||||
loader: InstanceUpgradeTargetEnvironment['modLoader'],
|
||||
version: string | null,
|
||||
automaticLabel: string,
|
||||
): string {
|
||||
if (version) return `${loaderLabel} ${version}`
|
||||
return loader === 'vanilla' ? loaderLabel : `${loaderLabel} (${automaticLabel})`
|
||||
}
|
||||
|
||||
function normalizedConstraints(constraints: InstanceUpgradeFixedConstraint[]) {
|
||||
return constraints
|
||||
.map((constraint) => ({
|
||||
contentId: constraint.contentId,
|
||||
provider: constraint.provider,
|
||||
projectId: constraint.projectId,
|
||||
versionId: constraint.versionId,
|
||||
}))
|
||||
.sort((left, right) => left.contentId.localeCompare(right.contentId))
|
||||
}
|
||||
|
||||
export function customConstraintsEqual(
|
||||
left: InstanceUpgradeFixedConstraint[],
|
||||
right: InstanceUpgradeFixedConstraint[],
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify(normalizedConstraints(left)) === JSON.stringify(normalizedConstraints(right))
|
||||
)
|
||||
}
|
||||
|
||||
export function setFixedConstraint(
|
||||
constraints: InstanceUpgradeFixedConstraint[],
|
||||
constraint: InstanceUpgradeFixedConstraint | null,
|
||||
contentId: string,
|
||||
): InstanceUpgradeFixedConstraint[] {
|
||||
const withoutContent = constraints.filter((current) => current.contentId !== contentId)
|
||||
return normalizedConstraints(constraint ? [...withoutContent, constraint] : withoutContent)
|
||||
}
|
||||
|
||||
export function editableUpgradeRoots(plan: InstanceUpgradePlan) {
|
||||
return plan.items.filter(
|
||||
(item) =>
|
||||
!item.autoDependency &&
|
||||
(item.provider === 'modrinth' || item.provider === 'curseforge') &&
|
||||
item.projectId !== null &&
|
||||
(item.candidateReleaseIds.length > 0 ||
|
||||
plan.customConstraints.some((constraint) => constraint.contentId === item.contentId)),
|
||||
)
|
||||
}
|
||||
|
||||
export function compatibilitySummary(plan: InstanceUpgradePlan): CompatibilitySummary {
|
||||
const content = plan.selectedSolution
|
||||
? summarizeSelections(plan.selectedSolution)
|
||||
: plan.items.reduce(
|
||||
(summary, item) => {
|
||||
if (item.resolution.action === 'disable') summary.disabled += 1
|
||||
else if (item.status === 'upgrade_available') summary.updates += 1
|
||||
else if (item.status === 'already_compatible' || item.resolution.action === 'keep') {
|
||||
summary.keptOrCompatible += 1
|
||||
}
|
||||
return summary
|
||||
},
|
||||
{ updates: 0, keptOrCompatible: 0, disabled: 0 },
|
||||
)
|
||||
const dependencyChanges = (
|
||||
plan.selectedSolution?.dependencyChanges ?? plan.dependencyChanges
|
||||
).filter((change) => change.kind !== 'keep').length
|
||||
|
||||
return {
|
||||
...content,
|
||||
dependencyChanges,
|
||||
needsAttention: plan.blockingIssues.length,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildUpgradeDisplayNames } from '../../../../../../packages/ui/src/utils/loaders.ts'
|
||||
|
||||
const input = {
|
||||
sourceName: '1.21.8-Fabric 0.18.4',
|
||||
sourceLoader: 'fabric',
|
||||
sourceGameVersion: '1.21.8',
|
||||
sourceLoaderVersion: '0.18.4',
|
||||
targetLoader: 'fabric',
|
||||
targetGameVersion: '1.21.9',
|
||||
targetLoaderVersion: '0.18.5',
|
||||
backupName: '1.21.8-Fabric 0.18.4(升级前备份)',
|
||||
customCopyName: '1.21.8-Fabric 0.18.4(升级副本)',
|
||||
}
|
||||
|
||||
test('default source name renames direct target and names copy for target environment', () => {
|
||||
assert.deepEqual(buildUpgradeDisplayNames(input), {
|
||||
backup: input.backupName,
|
||||
copy: '1.21.9-Fabric 0.18.5',
|
||||
upgradedTarget: '1.21.9-Fabric 0.18.5',
|
||||
shouldAutoRename: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('custom source name stays unchanged while copy receives localized suffix', () => {
|
||||
assert.deepEqual(buildUpgradeDisplayNames({ ...input, sourceName: 'My survival instance' }), {
|
||||
backup: input.backupName,
|
||||
copy: input.customCopyName,
|
||||
upgradedTarget: null,
|
||||
shouldAutoRename: false,
|
||||
})
|
||||
})
|
||||
63
apps/app-frontend/src/pages/instance/upgrade/entry.test.ts
Normal file
63
apps/app-frontend/src/pages/instance/upgrade/entry.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { isActiveUpgradeJobForInstance, isUnmanagedUpgradeEligible } from './entry.ts'
|
||||
|
||||
const instance = (link: GameInstance['link'] = null): GameInstance => ({
|
||||
id: 'instance',
|
||||
path: 'path',
|
||||
install_stage: 'installed',
|
||||
launcher_feature_version: '1',
|
||||
name: 'Instance',
|
||||
game_version: '1.21.8',
|
||||
loader: 'fabric',
|
||||
loader_components: [],
|
||||
groups: [],
|
||||
link,
|
||||
update_channel: 'release',
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
submitted_time_played: 0,
|
||||
recent_time_played: 0,
|
||||
hooks: {},
|
||||
})
|
||||
|
||||
test('eligibility allows local/shared and excludes managed packs', () => {
|
||||
assert.equal(isUnmanagedUpgradeEligible(instance()), true)
|
||||
assert.equal(
|
||||
isUnmanagedUpgradeEligible(instance({ type: 'shared_instance', shared_instance_id: 'shared' })),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
isUnmanagedUpgradeEligible(
|
||||
instance({ type: 'modrinth_modpack', project_id: 'p', version_id: 'v' }),
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(isUnmanagedUpgradeEligible({ ...instance(), install_stage: 'not_installed' }), false)
|
||||
})
|
||||
|
||||
test('active upgrade job ownership is exact', () => {
|
||||
const job = {
|
||||
kind: 'upgrade_unmanaged_instance',
|
||||
status: 'running',
|
||||
instance_id: 'instance',
|
||||
} as InstallJobSnapshot
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'instance'), true)
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'other'), false)
|
||||
assert.equal(isActiveUpgradeJobForInstance({ ...job, status: 'succeeded' }, 'instance'), false)
|
||||
})
|
||||
|
||||
test('active copy upgrade belongs to original source instance', () => {
|
||||
const job = {
|
||||
kind: 'upgrade_unmanaged_instance',
|
||||
status: 'running',
|
||||
instance_id: 'copy',
|
||||
source_instance_id: 'source',
|
||||
} as InstallJobSnapshot
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'source'), true)
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'copy'), false)
|
||||
})
|
||||
23
apps/app-frontend/src/pages/instance/upgrade/entry.ts
Normal file
23
apps/app-frontend/src/pages/instance/upgrade/entry.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export function isUnmanagedUpgradeEligible(instance: GameInstance): boolean {
|
||||
return (
|
||||
instance.install_stage === 'installed' &&
|
||||
Boolean(instance.game_version && instance.loader) &&
|
||||
(instance.link == null ||
|
||||
instance.link.type === 'shared_instance' ||
|
||||
Boolean(instance.symlink_target))
|
||||
)
|
||||
}
|
||||
|
||||
export function isActiveUpgradeJobForInstance(
|
||||
job: InstallJobSnapshot,
|
||||
instanceId: string,
|
||||
): boolean {
|
||||
return (
|
||||
job.kind === 'upgrade_unmanaged_instance' &&
|
||||
['queued', 'running', 'canceling', 'waiting_for_user'].includes(job.status) &&
|
||||
(job.source_instance_id ?? job.instance_id) === instanceId
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
bulkResolutionAction,
|
||||
filterBulkResolutionIds,
|
||||
initialCustomizeStrategy,
|
||||
UPGRADE_ACTIVE_STEPS,
|
||||
upgradeControlEnabled,
|
||||
upgradeProgressModel,
|
||||
} from './flow-controls.ts'
|
||||
|
||||
test('registered upgrade control reads live ref values without re-registration', () => {
|
||||
const canPlan = ref(false)
|
||||
const control = computed(() => canPlan.value)
|
||||
assert.equal(upgradeControlEnabled(control), false)
|
||||
canPlan.value = true
|
||||
assert.equal(upgradeControlEnabled(control), true)
|
||||
})
|
||||
|
||||
test('missing controls remain disabled', () => {
|
||||
assert.equal(upgradeControlEnabled(undefined), false)
|
||||
})
|
||||
|
||||
test('upgrade progress maps five active routes and terminal result', () => {
|
||||
assert.equal(UPGRADE_ACTIVE_STEPS.length, 5)
|
||||
for (const [index, route] of UPGRADE_ACTIVE_STEPS.entries()) {
|
||||
assert.deepEqual(upgradeProgressModel(`/instance/example/upgrade/${route}`), {
|
||||
currentIndex: index,
|
||||
complete: false,
|
||||
steps: UPGRADE_ACTIVE_STEPS,
|
||||
})
|
||||
}
|
||||
assert.equal(upgradeProgressModel('/instance/example/upgrade/result').complete, true)
|
||||
})
|
||||
|
||||
test('customize strategy prefers flow UI state over selected backend solution', () => {
|
||||
assert.equal(initialCustomizeStrategy('custom', 'newest', 'custom'), 'custom')
|
||||
assert.equal(initialCustomizeStrategy(null, 'minimal_change', 'custom'), 'minimal_change')
|
||||
assert.equal(initialCustomizeStrategy(null, null, 'custom'), 'custom')
|
||||
})
|
||||
|
||||
test('bulk resolution state and no-op filtering use authoritative actions', () => {
|
||||
assert.equal(bulkResolutionAction(['keep', 'keep']), 'keep')
|
||||
assert.equal(bulkResolutionAction(['disable', 'disable']), 'disable')
|
||||
assert.equal(bulkResolutionAction(['keep', 'disable']), null)
|
||||
assert.deepEqual(
|
||||
filterBulkResolutionIds(
|
||||
[
|
||||
{ contentId: 'a', action: 'keep' },
|
||||
{ contentId: 'b', action: 'disable' },
|
||||
],
|
||||
'keep',
|
||||
),
|
||||
['b'],
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,56 @@
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
export function upgradeControlEnabled(value: MaybeRef<boolean> | undefined): boolean {
|
||||
return toValue(value ?? false)
|
||||
}
|
||||
|
||||
export const UPGRADE_ACTIVE_STEPS = [
|
||||
'upgrade',
|
||||
'compatibility',
|
||||
'customize',
|
||||
'confirm',
|
||||
'progress',
|
||||
] as const
|
||||
|
||||
export interface UpgradeProgressModel {
|
||||
currentIndex: number
|
||||
complete: boolean
|
||||
steps: typeof UPGRADE_ACTIVE_STEPS
|
||||
}
|
||||
|
||||
export function upgradeProgressModel(path: string): UpgradeProgressModel {
|
||||
const routeStep = path.split('/').filter(Boolean).at(-1) ?? 'upgrade'
|
||||
const complete = routeStep === 'result'
|
||||
const index = UPGRADE_ACTIVE_STEPS.indexOf(routeStep as (typeof UPGRADE_ACTIVE_STEPS)[number])
|
||||
return {
|
||||
currentIndex: complete ? UPGRADE_ACTIVE_STEPS.length - 1 : Math.max(index, 0),
|
||||
complete,
|
||||
steps: UPGRADE_ACTIVE_STEPS,
|
||||
}
|
||||
}
|
||||
|
||||
export function initialCustomizeStrategy<T>(
|
||||
flowStrategy: T | null | undefined,
|
||||
selectedStrategy: T | null | undefined,
|
||||
defaultStrategy: T,
|
||||
): T {
|
||||
return flowStrategy ?? selectedStrategy ?? defaultStrategy
|
||||
}
|
||||
|
||||
export function bulkResolutionAction(
|
||||
actions: Array<'upgrade' | 'keep' | 'disable'>,
|
||||
): 'keep' | 'disable' | null {
|
||||
if (!actions.length) return null
|
||||
const unique = new Set(actions)
|
||||
return unique.size === 1 && (unique.has('keep') || unique.has('disable'))
|
||||
? ([...unique][0] as 'keep' | 'disable')
|
||||
: null
|
||||
}
|
||||
|
||||
export function filterBulkResolutionIds(
|
||||
items: Array<{ contentId: string; action: 'upgrade' | 'keep' | 'disable' }>,
|
||||
action: 'keep' | 'disable',
|
||||
): string[] {
|
||||
return items.filter((item) => item.action !== action).map((item) => item.contentId)
|
||||
}
|
||||
97
apps/app-frontend/src/pages/instance/upgrade/flow.test.ts
Normal file
97
apps/app-frontend/src/pages/instance/upgrade/flow.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { InstanceUpgradePlan, InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
attachUpgradeJobToFlow,
|
||||
type InstanceUpgradeFlow,
|
||||
isUpgradeRouteAvailable,
|
||||
isUpgradeRouteRecoveryPending,
|
||||
upgradeDownloadsLocation,
|
||||
upgradeProgressDestination,
|
||||
} from './flow.ts'
|
||||
|
||||
function selectionFlow(plan: InstanceUpgradePlan | null): InstanceUpgradeFlow {
|
||||
return { plan: ref(plan) } as InstanceUpgradeFlow
|
||||
}
|
||||
|
||||
test('selection route requires an unblocked plan with a selected solution', () => {
|
||||
const selectedSolution = { kind: 'newest', selections: [], dependencyChanges: [], warnings: [] }
|
||||
assert.equal(isUpgradeRouteAvailable('selection', selectionFlow(null)), false)
|
||||
assert.equal(
|
||||
isUpgradeRouteAvailable(
|
||||
'selection',
|
||||
selectionFlow({ blockingIssues: [], selectedSolution: null } as InstanceUpgradePlan),
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isUpgradeRouteAvailable(
|
||||
'selection',
|
||||
selectionFlow({
|
||||
blockingIssues: [{ code: 'dependency_conflict' }],
|
||||
selectedSolution,
|
||||
} as InstanceUpgradePlan),
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isUpgradeRouteAvailable(
|
||||
'selection',
|
||||
selectionFlow({ blockingIssues: [], selectedSolution } as InstanceUpgradePlan),
|
||||
),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('upgrade execution and Progress recovery target focused Downloads', () => {
|
||||
assert.deepEqual(upgradeDownloadsLocation('job/a'), {
|
||||
path: '/downloads',
|
||||
query: { job: 'job/a' },
|
||||
})
|
||||
assert.equal(upgradeProgressDestination('loading', null, 'instance/a'), null)
|
||||
assert.deepEqual(upgradeProgressDestination('ready', 'job/a', 'instance/a'), {
|
||||
path: '/downloads',
|
||||
query: { job: 'job/a' },
|
||||
})
|
||||
assert.deepEqual(upgradeProgressDestination('ready', null, 'instance/a'), {
|
||||
path: '/instance/instance%2Fa/upgrade',
|
||||
})
|
||||
})
|
||||
|
||||
test('accepted upgrade job sets ownership, preserves backend result, and returns Downloads target', () => {
|
||||
let jobId: string | null = null
|
||||
let result: unknown = null
|
||||
const location = attachUpgradeJobToFlow(
|
||||
{
|
||||
setJob: (value) => (jobId = value),
|
||||
setResult: (value) => (result = value),
|
||||
},
|
||||
{
|
||||
job_id: 'job-a',
|
||||
status: 'succeeded',
|
||||
upgrade_result: { planId: 'plan-a' } as InstanceUpgradeResult,
|
||||
} as InstallJobSnapshot,
|
||||
)
|
||||
assert.equal(jobId, 'job-a')
|
||||
assert.deepEqual(result, { planId: 'plan-a' })
|
||||
assert.deepEqual(location, { path: '/downloads', query: { job: 'job-a' } })
|
||||
})
|
||||
|
||||
test('job route waits only while persisted job recovery is loading', () => {
|
||||
const loading = {
|
||||
jobRecoveryState: ref('loading'),
|
||||
activeJobId: ref(null),
|
||||
} as InstanceUpgradeFlow
|
||||
assert.equal(isUpgradeRouteRecoveryPending('job', loading), true)
|
||||
assert.equal(isUpgradeRouteRecoveryPending('result', loading), true)
|
||||
loading.jobRecoveryState.value = 'ready'
|
||||
assert.equal(isUpgradeRouteRecoveryPending('job', loading), false)
|
||||
assert.equal(isUpgradeRouteAvailable('job', loading), false)
|
||||
loading.activeJobId.value = 'job-a'
|
||||
assert.equal(isUpgradeRouteAvailable('job', loading), true)
|
||||
})
|
||||
236
apps/app-frontend/src/pages/instance/upgrade/flow.ts
Normal file
236
apps/app-frontend/src/pages/instance/upgrade/flow.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import type { ComputedRef, InjectionKey, MaybeRef, Ref } from 'vue'
|
||||
import { computed, inject, provide, ref } from 'vue'
|
||||
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type {
|
||||
InstanceUpgradeIssue,
|
||||
InstanceUpgradePlan,
|
||||
InstanceUpgradeResult,
|
||||
InstanceUpgradeSolutionKind,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
SharedUpgradeMode,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export type UpgradeRouteRequirement = 'plan' | 'unblocked-plan' | 'selection' | 'job' | 'result'
|
||||
export type UpgradeJobRecoveryState = 'idle' | 'loading' | 'ready'
|
||||
|
||||
export function upgradeDownloadsLocation(jobId: string) {
|
||||
return { path: '/downloads', query: { job: jobId } } as const
|
||||
}
|
||||
|
||||
export function upgradeProgressDestination(
|
||||
recoveryState: UpgradeJobRecoveryState,
|
||||
jobId: string | null,
|
||||
instanceId: string,
|
||||
) {
|
||||
if (recoveryState !== 'ready') return null
|
||||
return jobId
|
||||
? upgradeDownloadsLocation(jobId)
|
||||
: { path: `/instance/${encodeURIComponent(instanceId)}/upgrade` }
|
||||
}
|
||||
|
||||
export function attachUpgradeJobToFlow(
|
||||
flow: Pick<InstanceUpgradeFlow, 'setJob' | 'setResult'>,
|
||||
job: InstallJobSnapshot,
|
||||
) {
|
||||
flow.setJob(job.job_id)
|
||||
if (job.status === 'succeeded' && job.upgrade_result) {
|
||||
flow.setResult(job.upgrade_result)
|
||||
}
|
||||
return upgradeDownloadsLocation(job.job_id)
|
||||
}
|
||||
|
||||
export interface InstanceUpgradeFlow {
|
||||
instance: Readonly<Ref<GameInstance>>
|
||||
instanceId: Readonly<Ref<string>>
|
||||
targetEnvironment: Ref<InstanceUpgradeTargetEnvironment | null>
|
||||
plan: Ref<InstanceUpgradePlan | null>
|
||||
selectedSolutionKind: ComputedRef<InstanceUpgradeSolutionKind | null>
|
||||
createFullBackup: Ref<boolean>
|
||||
directFullBackupPreference: Ref<boolean>
|
||||
sharedUpgradeMode: Ref<SharedUpgradeMode | null>
|
||||
activeJobId: Ref<string | null>
|
||||
jobRecoveryState: Ref<UpgradeJobRecoveryState>
|
||||
result: Ref<InstanceUpgradeResult | null>
|
||||
initialBlockingPlanId: Ref<string | null>
|
||||
initialBlockingIssues: Ref<Record<string, InstanceUpgradeIssue[]>>
|
||||
customizeActiveStrategy: Ref<InstanceUpgradeSolutionKind | null>
|
||||
busy: Ref<boolean>
|
||||
error: Ref<unknown | null>
|
||||
reset: () => void
|
||||
clearPlan: () => void
|
||||
setTargetEnvironment: (environment: InstanceUpgradeTargetEnvironment | null) => void
|
||||
setPlan: (plan: InstanceUpgradePlan | null) => void
|
||||
setJob: (jobId: string | null) => void
|
||||
setJobRecoveryState: (state: UpgradeJobRecoveryState) => void
|
||||
setResult: (result: InstanceUpgradeResult | null) => void
|
||||
hydrate: (snapshot: UpgradeFlowSnapshot) => void
|
||||
controls: Ref<UpgradeStepControls | null>
|
||||
registerStepControls: (controls: UpgradeStepControls | null) => void
|
||||
}
|
||||
|
||||
export interface UpgradeStepControls {
|
||||
canNext: MaybeRef<boolean>
|
||||
busy?: MaybeRef<boolean>
|
||||
nextLabel: string
|
||||
onNext: () => void | Promise<void>
|
||||
onBack: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface UpgradeFlowSnapshot {
|
||||
instanceId: string
|
||||
returnFullPath: string
|
||||
targetEnvironment: InstanceUpgradeTargetEnvironment | null
|
||||
plan: InstanceUpgradePlan | null
|
||||
createFullBackup: boolean
|
||||
directFullBackupPreference?: boolean
|
||||
sharedUpgradeMode: SharedUpgradeMode | null
|
||||
activeJobId: string | null
|
||||
result: InstanceUpgradeResult | null
|
||||
initialBlockingPlanId?: string | null
|
||||
initialBlockingIssues?: Record<string, InstanceUpgradeIssue[]>
|
||||
customizeActiveStrategy?: InstanceUpgradeSolutionKind | null
|
||||
scrollTop?: number
|
||||
}
|
||||
|
||||
export const INSTANCE_UPGRADE_FLOW_KEY: InjectionKey<InstanceUpgradeFlow> =
|
||||
Symbol('instance-upgrade-flow')
|
||||
|
||||
export function provideUpgradeFlow(flow: InstanceUpgradeFlow) {
|
||||
provide(INSTANCE_UPGRADE_FLOW_KEY, flow)
|
||||
}
|
||||
|
||||
export function provideInstanceUpgradeFlow(
|
||||
instance: Readonly<Ref<GameInstance>>,
|
||||
): InstanceUpgradeFlow {
|
||||
const instanceId = computed(() => instance.value.id)
|
||||
const targetEnvironment = ref<InstanceUpgradeTargetEnvironment | null>(null)
|
||||
const plan = ref<InstanceUpgradePlan | null>(null)
|
||||
const createFullBackup = ref(true)
|
||||
const directFullBackupPreference = ref(true)
|
||||
const sharedUpgradeMode = ref<SharedUpgradeMode | null>(null)
|
||||
const activeJobId = ref<string | null>(null)
|
||||
const jobRecoveryState = ref<UpgradeJobRecoveryState>('idle')
|
||||
const result = ref<InstanceUpgradeResult | null>(null)
|
||||
const initialBlockingPlanId = ref<string | null>(null)
|
||||
const initialBlockingIssues = ref<Record<string, InstanceUpgradeIssue[]>>({})
|
||||
const customizeActiveStrategy = ref<InstanceUpgradeSolutionKind | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref<unknown | null>(null)
|
||||
const selectedSolutionKind = computed(() => plan.value?.selectedSolution?.kind ?? null)
|
||||
const controls = ref<UpgradeStepControls | null>(null)
|
||||
|
||||
function clearPlan() {
|
||||
plan.value = null
|
||||
initialBlockingPlanId.value = null
|
||||
initialBlockingIssues.value = {}
|
||||
customizeActiveStrategy.value = null
|
||||
activeJobId.value = null
|
||||
result.value = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
targetEnvironment.value = null
|
||||
clearPlan()
|
||||
createFullBackup.value = true
|
||||
directFullBackupPreference.value = true
|
||||
sharedUpgradeMode.value = null
|
||||
busy.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function hydrate(snapshot: UpgradeFlowSnapshot) {
|
||||
if (snapshot.instanceId !== instance.value.id) return
|
||||
targetEnvironment.value = snapshot.targetEnvironment
|
||||
plan.value = snapshot.plan
|
||||
createFullBackup.value = snapshot.createFullBackup
|
||||
directFullBackupPreference.value = snapshot.directFullBackupPreference ?? true
|
||||
sharedUpgradeMode.value = snapshot.sharedUpgradeMode
|
||||
activeJobId.value = snapshot.activeJobId
|
||||
result.value = snapshot.result
|
||||
initialBlockingPlanId.value = snapshot.initialBlockingPlanId ?? null
|
||||
initialBlockingIssues.value = snapshot.initialBlockingIssues ?? {}
|
||||
customizeActiveStrategy.value = snapshot.customizeActiveStrategy ?? null
|
||||
}
|
||||
|
||||
const flow: InstanceUpgradeFlow = {
|
||||
instance,
|
||||
instanceId,
|
||||
targetEnvironment,
|
||||
plan,
|
||||
selectedSolutionKind,
|
||||
createFullBackup,
|
||||
directFullBackupPreference,
|
||||
sharedUpgradeMode,
|
||||
activeJobId,
|
||||
jobRecoveryState,
|
||||
result,
|
||||
initialBlockingPlanId,
|
||||
initialBlockingIssues,
|
||||
customizeActiveStrategy,
|
||||
busy,
|
||||
error,
|
||||
reset,
|
||||
clearPlan,
|
||||
setTargetEnvironment: (environment) => (targetEnvironment.value = environment),
|
||||
setPlan: (nextPlan) => {
|
||||
if (nextPlan?.id !== plan.value?.id) {
|
||||
initialBlockingPlanId.value = null
|
||||
initialBlockingIssues.value = {}
|
||||
customizeActiveStrategy.value = null
|
||||
sharedUpgradeMode.value = null
|
||||
createFullBackup.value = true
|
||||
directFullBackupPreference.value = true
|
||||
}
|
||||
plan.value = nextPlan
|
||||
},
|
||||
setJob: (jobId) => (activeJobId.value = jobId),
|
||||
setJobRecoveryState: (state) => (jobRecoveryState.value = state),
|
||||
setResult: (nextResult) => (result.value = nextResult),
|
||||
hydrate,
|
||||
controls,
|
||||
registerStepControls: (next) => (controls.value = next),
|
||||
}
|
||||
provideUpgradeFlow(flow)
|
||||
return flow
|
||||
}
|
||||
|
||||
export function isUpgradeRouteRecoveryPending(
|
||||
requirement: UpgradeRouteRequirement | undefined,
|
||||
flow: InstanceUpgradeFlow,
|
||||
): boolean {
|
||||
return (
|
||||
(requirement === 'job' || requirement === 'result') && flow.jobRecoveryState.value === 'loading'
|
||||
)
|
||||
}
|
||||
|
||||
export function useInstanceUpgradeFlow(): InstanceUpgradeFlow {
|
||||
const flow = inject(INSTANCE_UPGRADE_FLOW_KEY)
|
||||
if (!flow) throw new Error('Instance upgrade flow was not provided')
|
||||
return flow
|
||||
}
|
||||
|
||||
export function isUpgradeRouteAvailable(
|
||||
requirement: UpgradeRouteRequirement | undefined,
|
||||
flow: InstanceUpgradeFlow,
|
||||
): boolean {
|
||||
switch (requirement) {
|
||||
case 'plan':
|
||||
return flow.plan.value !== null
|
||||
case 'unblocked-plan':
|
||||
return flow.plan.value !== null && flow.plan.value.blockingIssues.length === 0
|
||||
case 'selection':
|
||||
return (
|
||||
flow.plan.value !== null &&
|
||||
flow.plan.value.blockingIssues.length === 0 &&
|
||||
flow.plan.value.selectedSolution !== null
|
||||
)
|
||||
case 'job':
|
||||
return flow.activeJobId.value !== null
|
||||
case 'result':
|
||||
return flow.result.value !== null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
122
apps/app-frontend/src/pages/instance/upgrade/install-job-core.ts
Normal file
122
apps/app-frontend/src/pages/instance/upgrade/install-job-core.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
import type { InstanceUpgradeDisplayNames, SharedUpgradeMode } from '@/helpers/instance-upgrade'
|
||||
|
||||
const RECOVERABLE_UPGRADE_STATUSES = new Set<InstallJobStatus>([
|
||||
'queued',
|
||||
'running',
|
||||
'canceling',
|
||||
'waiting_for_user',
|
||||
])
|
||||
|
||||
export type InstallJobInstanceIdResolver = (job: InstallJobSnapshot) => string | null
|
||||
|
||||
export interface UpgradeJobSelectionContext {
|
||||
knownJobId?: string | null
|
||||
continuation?: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionRequest {
|
||||
instanceId: string
|
||||
planId: string
|
||||
createFullBackup: boolean
|
||||
sharedUpgradeMode: SharedUpgradeMode
|
||||
displayNames: InstanceUpgradeDisplayNames
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionLock {
|
||||
value: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionResult {
|
||||
job: InstallJobSnapshot
|
||||
attached: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionDependencies {
|
||||
listJobs: (includeFinished: boolean) => Promise<InstallJobSnapshot[]>
|
||||
execute: (
|
||||
planId: string,
|
||||
createFullBackup: boolean,
|
||||
sharedUpgradeMode: SharedUpgradeMode,
|
||||
displayNames: InstanceUpgradeDisplayNames,
|
||||
) => Promise<InstallJobSnapshot>
|
||||
instanceIdOf: InstallJobInstanceIdResolver
|
||||
}
|
||||
|
||||
export function isRecoverableUpgradeStatus(status: InstallJobStatus): boolean {
|
||||
return RECOVERABLE_UPGRADE_STATUSES.has(status)
|
||||
}
|
||||
|
||||
export function isInstanceUpgradeJobWith(
|
||||
job: InstallJobSnapshot,
|
||||
instanceId: string,
|
||||
instanceIdOf: InstallJobInstanceIdResolver,
|
||||
): boolean {
|
||||
if (job.kind !== 'upgrade_unmanaged_instance') return false
|
||||
return (job.source_instance_id ?? instanceIdOf(job)) === instanceId
|
||||
}
|
||||
|
||||
function compareJobFreshness(a: InstallJobSnapshot, b: InstallJobSnapshot): number {
|
||||
return (
|
||||
b.modified.localeCompare(a.modified) ||
|
||||
b.created.localeCompare(a.created) ||
|
||||
b.job_id.localeCompare(a.job_id)
|
||||
)
|
||||
}
|
||||
|
||||
export function selectRecoverableUpgradeJobWith(
|
||||
jobs: InstallJobSnapshot[],
|
||||
instanceId: string,
|
||||
context: UpgradeJobSelectionContext,
|
||||
instanceIdOf: InstallJobInstanceIdResolver,
|
||||
): InstallJobSnapshot | null {
|
||||
const matching = jobs.filter((job) => isInstanceUpgradeJobWith(job, instanceId, instanceIdOf))
|
||||
if (context.knownJobId) {
|
||||
const known = matching.find((job) => job.job_id === context.knownJobId)
|
||||
if (known) return known
|
||||
}
|
||||
|
||||
const active = matching.filter((job) => isRecoverableUpgradeStatus(job.status))
|
||||
if (active.length) return [...active].sort(compareJobFreshness)[0]
|
||||
|
||||
if (!context.continuation) return null
|
||||
const completed = matching.filter(
|
||||
(job) =>
|
||||
job.status === 'succeeded' && job.upgrade_result !== null && job.upgrade_result !== undefined,
|
||||
)
|
||||
return completed.length ? [...completed].sort(compareJobFreshness)[0] : null
|
||||
}
|
||||
|
||||
export async function submitInstanceUpgradeWith(
|
||||
request: UpgradeSubmissionRequest,
|
||||
lock: UpgradeSubmissionLock,
|
||||
dependencies: UpgradeSubmissionDependencies,
|
||||
): Promise<UpgradeSubmissionResult | null> {
|
||||
if (lock.value) return null
|
||||
lock.value = true
|
||||
try {
|
||||
const jobs = await dependencies.listJobs(false)
|
||||
const active = selectRecoverableUpgradeJobWith(
|
||||
jobs,
|
||||
request.instanceId,
|
||||
{},
|
||||
dependencies.instanceIdOf,
|
||||
)
|
||||
if (active) return { job: active, attached: true }
|
||||
|
||||
const job = await dependencies.execute(
|
||||
request.planId,
|
||||
request.createFullBackup,
|
||||
request.sharedUpgradeMode,
|
||||
request.displayNames,
|
||||
)
|
||||
if (!isInstanceUpgradeJobWith(job, request.instanceId, dependencies.instanceIdOf)) {
|
||||
throw new Error(
|
||||
'Upgrade execution returned an Install Job for a different instance or job kind',
|
||||
)
|
||||
}
|
||||
return { job, attached: false }
|
||||
} finally {
|
||||
lock.value = false
|
||||
}
|
||||
}
|
||||
273
apps/app-frontend/src/pages/instance/upgrade/install-job.test.ts
Normal file
273
apps/app-frontend/src/pages/instance/upgrade/install-job.test.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
import type { InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
isInstanceUpgradeJobWith,
|
||||
isRecoverableUpgradeStatus,
|
||||
selectRecoverableUpgradeJobWith,
|
||||
submitInstanceUpgradeWith,
|
||||
} from './install-job-core.ts'
|
||||
|
||||
function job(
|
||||
jobId: string,
|
||||
status: InstallJobStatus,
|
||||
options: {
|
||||
instanceId?: string
|
||||
sourceInstanceId?: string | null
|
||||
kind?: InstallJobSnapshot['kind']
|
||||
modified?: string
|
||||
executionMode?: InstallJobSnapshot['execution_mode']
|
||||
result?: InstanceUpgradeResult | null
|
||||
} = {},
|
||||
): InstallJobSnapshot {
|
||||
return {
|
||||
job_id: jobId,
|
||||
instance_id: options.instanceId ?? 'instance-a',
|
||||
source_instance_id: options.sourceInstanceId,
|
||||
kind: options.kind ?? 'upgrade_unmanaged_instance',
|
||||
status,
|
||||
execution_mode: options.executionMode ?? 'normal',
|
||||
target: { type: 'existing_instance', instance_id: options.instanceId ?? 'instance-a' },
|
||||
modified: options.modified ?? '2026-08-22T10:00:00Z',
|
||||
created: '2026-08-22T09:00:00Z',
|
||||
upgrade_result: options.result,
|
||||
} as InstallJobSnapshot
|
||||
}
|
||||
|
||||
const result = { planId: 'plan-a' } as InstanceUpgradeResult
|
||||
const instanceIdOf = (candidate: InstallJobSnapshot) =>
|
||||
candidate.instance_id ?? candidate.target.instance_id ?? null
|
||||
|
||||
test('upgrade job ownership requires matching kind and instance identity', () => {
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(job('correct', 'running'), 'instance-a', instanceIdOf),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(
|
||||
job('wrong-kind', 'running', { kind: 'install_content' }),
|
||||
'instance-a',
|
||||
instanceIdOf,
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(
|
||||
job('wrong-instance', 'running', { instanceId: 'instance-b' }),
|
||||
'instance-a',
|
||||
instanceIdOf,
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('copy upgrade ownership follows source instance, not target instance', () => {
|
||||
const copy = job('copy', 'queued', {
|
||||
instanceId: 'copy-target',
|
||||
sourceInstanceId: 'instance-a',
|
||||
})
|
||||
assert.equal(isInstanceUpgradeJobWith(copy, 'instance-a', instanceIdOf), true)
|
||||
assert.equal(isInstanceUpgradeJobWith(copy, 'copy-target', instanceIdOf), false)
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(
|
||||
job('unrelated', 'queued', { sourceInstanceId: 'instance-c' }),
|
||||
'instance-a',
|
||||
instanceIdOf,
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('active recovery includes waiting and recovery validation and chooses freshest job', () => {
|
||||
assert.equal(isRecoverableUpgradeStatus('waiting_for_user'), true)
|
||||
const selected = selectRecoverableUpgradeJobWith(
|
||||
[
|
||||
job('older', 'running', { modified: '2026-08-22T10:00:00Z' }),
|
||||
job('newer', 'waiting_for_user', {
|
||||
modified: '2026-08-22T11:00:00Z',
|
||||
executionMode: 'recovery_validation',
|
||||
}),
|
||||
],
|
||||
'instance-a',
|
||||
{},
|
||||
instanceIdOf,
|
||||
)
|
||||
assert.equal(selected?.job_id, 'newer')
|
||||
})
|
||||
|
||||
test('ordinary entry ignores old success while continuation recovers backend result', () => {
|
||||
const succeeded = job('succeeded', 'succeeded', { result })
|
||||
assert.equal(selectRecoverableUpgradeJobWith([succeeded], 'instance-a', {}, instanceIdOf), null)
|
||||
assert.equal(
|
||||
selectRecoverableUpgradeJobWith([succeeded], 'instance-a', { continuation: true }, instanceIdOf)
|
||||
?.upgrade_result,
|
||||
result,
|
||||
)
|
||||
})
|
||||
|
||||
test('known terminal job preserves flow ownership', () => {
|
||||
const failed = job('known', 'failed')
|
||||
assert.equal(
|
||||
selectRecoverableUpgradeJobWith([failed], 'instance-a', { knownJobId: 'known' }, instanceIdOf)
|
||||
?.job_id,
|
||||
'known',
|
||||
)
|
||||
})
|
||||
|
||||
function submissionDependencies(calls: unknown[][], jobs: InstallJobSnapshot[] = []) {
|
||||
return {
|
||||
instanceIdOf,
|
||||
listJobs: async () => jobs,
|
||||
execute: async (
|
||||
planId: string,
|
||||
backup: boolean,
|
||||
mode: 'direct' | 'copy_and_upgrade',
|
||||
names: typeof displayNames,
|
||||
) => {
|
||||
calls.push([planId, backup, mode, names])
|
||||
return job(`job-${calls.length}`, 'queued')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const displayNames = {
|
||||
backup: 'Backup',
|
||||
copy: 'Copy',
|
||||
upgradedTarget: 'Target',
|
||||
shouldAutoRename: false,
|
||||
}
|
||||
|
||||
test('normal, shared direct, and copy submissions pass exact execution parameters', async () => {
|
||||
const calls: unknown[][] = []
|
||||
const dependencies = submissionDependencies(calls)
|
||||
for (const request of [
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'normal',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct' as const,
|
||||
displayNames,
|
||||
},
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'shared-direct',
|
||||
createFullBackup: false,
|
||||
sharedUpgradeMode: 'direct' as const,
|
||||
displayNames,
|
||||
},
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'copy',
|
||||
createFullBackup: false,
|
||||
sharedUpgradeMode: 'copy_and_upgrade' as const,
|
||||
displayNames,
|
||||
},
|
||||
]) {
|
||||
await submitInstanceUpgradeWith(request, { value: false }, dependencies)
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['normal', true, 'direct', displayNames],
|
||||
['shared-direct', false, 'direct', displayNames],
|
||||
['copy', false, 'copy_and_upgrade', displayNames],
|
||||
])
|
||||
})
|
||||
|
||||
test('synchronous lock prevents double submission', async () => {
|
||||
let releaseList: (() => void) | undefined
|
||||
let executeCalls = 0
|
||||
const lock = { value: false }
|
||||
const dependencies = {
|
||||
instanceIdOf,
|
||||
listJobs: () =>
|
||||
new Promise<InstallJobSnapshot[]>((resolve) => {
|
||||
releaseList = () => resolve([])
|
||||
}),
|
||||
execute: async () => {
|
||||
executeCalls += 1
|
||||
return job('started', 'queued')
|
||||
},
|
||||
}
|
||||
const request = {
|
||||
instanceId: 'instance-a',
|
||||
planId: 'plan-a',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct' as const,
|
||||
displayNames,
|
||||
}
|
||||
const first = submitInstanceUpgradeWith(request, lock, dependencies)
|
||||
const second = submitInstanceUpgradeWith(request, lock, dependencies)
|
||||
assert.equal(await second, null)
|
||||
releaseList?.()
|
||||
await first
|
||||
assert.equal(executeCalls, 1)
|
||||
})
|
||||
|
||||
test('active preflight attaches without a second execution', async () => {
|
||||
const calls: unknown[][] = []
|
||||
const submitted = await submitInstanceUpgradeWith(
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'plan-a',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct',
|
||||
displayNames,
|
||||
},
|
||||
{ value: false },
|
||||
submissionDependencies(calls, [job('existing', 'running')]),
|
||||
)
|
||||
assert.equal(submitted?.attached, true)
|
||||
assert.equal(submitted?.job.job_id, 'existing')
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('copy execution result attaches by source identity despite different target', async () => {
|
||||
const submitted = await submitInstanceUpgradeWith(
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'copy-plan',
|
||||
createFullBackup: false,
|
||||
sharedUpgradeMode: 'copy_and_upgrade',
|
||||
displayNames,
|
||||
},
|
||||
{ value: false },
|
||||
{
|
||||
instanceIdOf,
|
||||
listJobs: async () => [],
|
||||
execute: async () =>
|
||||
job('copy-job', 'queued', {
|
||||
instanceId: 'copy-target',
|
||||
sourceInstanceId: 'instance-a',
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert.equal(submitted?.job.job_id, 'copy-job')
|
||||
assert.equal(submitted?.attached, false)
|
||||
})
|
||||
|
||||
test('submission failure releases lock', async () => {
|
||||
const lock = { value: false }
|
||||
await assert.rejects(
|
||||
submitInstanceUpgradeWith(
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'plan-a',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct',
|
||||
displayNames,
|
||||
},
|
||||
lock,
|
||||
{
|
||||
instanceIdOf,
|
||||
listJobs: async () => [],
|
||||
execute: async () => {
|
||||
throw new Error('stale plan')
|
||||
},
|
||||
},
|
||||
),
|
||||
/stale plan/,
|
||||
)
|
||||
assert.equal(lock.value, false)
|
||||
})
|
||||
55
apps/app-frontend/src/pages/instance/upgrade/install-job.ts
Normal file
55
apps/app-frontend/src/pages/instance/upgrade/install-job.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
install_job_get,
|
||||
install_job_list,
|
||||
installJobInstanceId,
|
||||
type InstallJobSnapshot,
|
||||
} from '@/helpers/install'
|
||||
import { execute_instance_upgrade } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
isInstanceUpgradeJobWith,
|
||||
selectRecoverableUpgradeJobWith,
|
||||
submitInstanceUpgradeWith,
|
||||
type UpgradeJobSelectionContext,
|
||||
type UpgradeSubmissionLock,
|
||||
type UpgradeSubmissionRequest,
|
||||
type UpgradeSubmissionResult,
|
||||
} from './install-job-core'
|
||||
|
||||
export { isRecoverableUpgradeStatus } from './install-job-core'
|
||||
|
||||
export function isInstanceUpgradeJob(job: InstallJobSnapshot, instanceId: string): boolean {
|
||||
return isInstanceUpgradeJobWith(job, instanceId, installJobInstanceId)
|
||||
}
|
||||
|
||||
export function selectRecoverableUpgradeJob(
|
||||
jobs: InstallJobSnapshot[],
|
||||
instanceId: string,
|
||||
context: UpgradeJobSelectionContext = {},
|
||||
): InstallJobSnapshot | null {
|
||||
return selectRecoverableUpgradeJobWith(jobs, instanceId, context, installJobInstanceId)
|
||||
}
|
||||
|
||||
export async function recoverInstanceUpgradeJob(
|
||||
instanceId: string,
|
||||
context: UpgradeJobSelectionContext = {},
|
||||
): Promise<InstallJobSnapshot | null> {
|
||||
if (context.knownJobId) {
|
||||
const known = await install_job_get(context.knownJobId).catch(() => null)
|
||||
if (known && isInstanceUpgradeJob(known, instanceId)) return known
|
||||
}
|
||||
|
||||
const jobs = await install_job_list(true)
|
||||
return selectRecoverableUpgradeJob(jobs, instanceId, context)
|
||||
}
|
||||
|
||||
export function submitInstanceUpgrade(
|
||||
request: UpgradeSubmissionRequest,
|
||||
lock: UpgradeSubmissionLock,
|
||||
): Promise<UpgradeSubmissionResult | null> {
|
||||
return submitInstanceUpgradeWith(request, lock, {
|
||||
listJobs: install_job_list,
|
||||
execute: execute_instance_upgrade,
|
||||
instanceIdOf: installJobInstanceId,
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isCurrentUpgradeSelectPlanning } from './planning-navigation.ts'
|
||||
|
||||
test('planner continuation navigates only while same Select request remains current', () => {
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(false, 1, 1, 'InstanceUpgrade', 'a', 'a'), true)
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(false, 1, 1, 'InstanceContent', 'a', 'a'), false)
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(false, 1, 2, 'InstanceUpgrade', 'a', 'a'), false)
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(true, 1, 1, 'InstanceUpgrade', 'a', 'a'), false)
|
||||
})
|
||||
|
||||
test('pending planner completion does not navigate after route changes', async () => {
|
||||
let resolvePlanner!: () => void
|
||||
const planner = new Promise<void>((resolve) => {
|
||||
resolvePlanner = resolve
|
||||
})
|
||||
let routeName = 'InstanceUpgrade'
|
||||
let navigations = 0
|
||||
const continuation = planner.then(() => {
|
||||
if (isCurrentUpgradeSelectPlanning(false, 1, 1, routeName, 'a', 'a')) navigations += 1
|
||||
})
|
||||
|
||||
routeName = 'InstanceContent'
|
||||
resolvePlanner()
|
||||
await continuation
|
||||
|
||||
assert.equal(navigations, 0)
|
||||
})
|
||||
|
||||
test('pending planner completion navigates once while Select remains current', async () => {
|
||||
let resolvePlanner!: () => void
|
||||
const planner = new Promise<void>((resolve) => {
|
||||
resolvePlanner = resolve
|
||||
})
|
||||
let navigations = 0
|
||||
const continuation = planner.then(() => {
|
||||
if (isCurrentUpgradeSelectPlanning(false, 1, 1, 'InstanceUpgrade', 'a', 'a')) {
|
||||
navigations += 1
|
||||
}
|
||||
})
|
||||
|
||||
resolvePlanner()
|
||||
await continuation
|
||||
|
||||
assert.equal(navigations, 1)
|
||||
})
|
||||
@ -0,0 +1,15 @@
|
||||
export function isCurrentUpgradeSelectPlanning(
|
||||
disposed: boolean,
|
||||
generation: number,
|
||||
currentGeneration: number,
|
||||
routeName: unknown,
|
||||
routeInstanceId: unknown,
|
||||
instanceId: string,
|
||||
): boolean {
|
||||
return (
|
||||
!disposed &&
|
||||
generation === currentGeneration &&
|
||||
routeName === 'InstanceUpgrade' &&
|
||||
routeInstanceId === instanceId
|
||||
)
|
||||
}
|
||||
78
apps/app-frontend/src/pages/instance/upgrade/result.test.ts
Normal file
78
apps/app-frontend/src/pages/instance/upgrade/result.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
import type { InstanceUpgradeResult, InstanceUpgradeSolution } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
isSuccessfulUpgradeJob,
|
||||
summarizeUpgradeResult,
|
||||
upgradeResultLocation,
|
||||
upgradeResultMode,
|
||||
} from './result.ts'
|
||||
|
||||
function result(source = 'source', target = 'target'): InstanceUpgradeResult {
|
||||
return {
|
||||
planId: 'plan',
|
||||
sourceInstanceId: source,
|
||||
targetInstanceId: target,
|
||||
backupInstanceId: null,
|
||||
solution: { kind: 'custom', selections: [], dependencyChanges: [], warnings: [] },
|
||||
compatibilityWarnings: [],
|
||||
externalChanges: [],
|
||||
skippedDueToExternalConflict: [],
|
||||
}
|
||||
}
|
||||
|
||||
function job(
|
||||
status: InstallJobStatus,
|
||||
upgradeResult: InstanceUpgradeResult | null = result(),
|
||||
kind: InstallJobSnapshot['kind'] = 'upgrade_unmanaged_instance',
|
||||
): InstallJobSnapshot {
|
||||
return {
|
||||
job_id: 'job/a',
|
||||
instance_id: upgradeResult?.targetInstanceId ?? 'source',
|
||||
kind,
|
||||
status,
|
||||
upgrade_result: upgradeResult,
|
||||
} as InstallJobSnapshot
|
||||
}
|
||||
|
||||
test('successful upgrade result identifies copy and direct modes', () => {
|
||||
const copyJob = job('succeeded', result('source/a', 'target/b'))
|
||||
assert.equal(isSuccessfulUpgradeJob(copyJob), true)
|
||||
assert.equal(upgradeResultMode(copyJob.upgrade_result!), 'copy_and_upgrade')
|
||||
assert.equal(upgradeResultMode(result('same', 'same')), 'direct')
|
||||
})
|
||||
|
||||
test('successful result links to persisted standalone source-instance page', () => {
|
||||
assert.deepEqual(upgradeResultLocation(job('succeeded', result('source/a', 'target/b'))), {
|
||||
path: '/instance/source%2Fa/upgrade/result',
|
||||
query: { job: 'job/a' },
|
||||
})
|
||||
})
|
||||
|
||||
test('result summary follows executed selection actions and dependency kinds', () => {
|
||||
const solution = {
|
||||
selections: [
|
||||
...Array.from({ length: 3 }, () => ({ action: 'upgrade' })),
|
||||
...Array.from({ length: 2 }, () => ({ action: 'keep' })),
|
||||
{ action: 'disable' },
|
||||
],
|
||||
dependencyChanges: [
|
||||
...Array.from({ length: 2 }, () => ({ kind: 'add' })),
|
||||
...Array.from({ length: 3 }, () => ({ kind: 'upgrade' })),
|
||||
...Array.from({ length: 4 }, () => ({ kind: 'remove' })),
|
||||
...Array.from({ length: 5 }, () => ({ kind: 'keep' })),
|
||||
],
|
||||
} as InstanceUpgradeSolution
|
||||
|
||||
assert.deepEqual(summarizeUpgradeResult(solution), {
|
||||
updated: 3,
|
||||
kept: 2,
|
||||
disabled: 1,
|
||||
dependencyAdded: 2,
|
||||
dependencyUpdated: 3,
|
||||
dependencyRemoved: 4,
|
||||
})
|
||||
})
|
||||
47
apps/app-frontend/src/pages/instance/upgrade/result.ts
Normal file
47
apps/app-frontend/src/pages/instance/upgrade/result.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { InstanceUpgradeResult, InstanceUpgradeSolution } from '@/helpers/instance-upgrade'
|
||||
|
||||
export type UpgradeResultMode = 'direct' | 'copy_and_upgrade'
|
||||
|
||||
export interface UpgradeResultSummary {
|
||||
updated: number
|
||||
kept: number
|
||||
disabled: number
|
||||
dependencyAdded: number
|
||||
dependencyUpdated: number
|
||||
dependencyRemoved: number
|
||||
}
|
||||
|
||||
export function isSuccessfulUpgradeJob(job: InstallJobSnapshot): boolean {
|
||||
return (
|
||||
job.kind === 'upgrade_unmanaged_instance' &&
|
||||
job.status === 'succeeded' &&
|
||||
job.upgrade_result != null
|
||||
)
|
||||
}
|
||||
|
||||
export function upgradeResultMode(result: InstanceUpgradeResult): UpgradeResultMode {
|
||||
return result.sourceInstanceId === result.targetInstanceId ? 'direct' : 'copy_and_upgrade'
|
||||
}
|
||||
|
||||
export function summarizeUpgradeResult(solution: InstanceUpgradeSolution): UpgradeResultSummary {
|
||||
return {
|
||||
updated: solution.selections.filter((selection) => selection.action === 'upgrade').length,
|
||||
kept: solution.selections.filter((selection) => selection.action === 'keep').length,
|
||||
disabled: solution.selections.filter((selection) => selection.action === 'disable').length,
|
||||
dependencyAdded: solution.dependencyChanges.filter((change) => change.kind === 'add').length,
|
||||
dependencyUpdated: solution.dependencyChanges.filter((change) => change.kind === 'upgrade')
|
||||
.length,
|
||||
dependencyRemoved: solution.dependencyChanges.filter((change) => change.kind === 'remove')
|
||||
.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeResultLocation(job: InstallJobSnapshot) {
|
||||
if (!isSuccessfulUpgradeJob(job))
|
||||
return { path: '/downloads', query: { job: job.job_id } } as const
|
||||
return {
|
||||
path: `/instance/${encodeURIComponent(job.upgrade_result!.sourceInstanceId)}/upgrade/result`,
|
||||
query: { job: job.job_id },
|
||||
} as const
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstanceUpgradeSolution } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
filterUpgradeDetailItems,
|
||||
paginateUpgradeDetailItems,
|
||||
UPGRADE_RESULT_PAGE_SIZE,
|
||||
upgradeDetailItems,
|
||||
upgradeDetailProjectIdentities,
|
||||
upgradeDetailReleaseIdentities,
|
||||
} from './upgrade-result-presentation.ts'
|
||||
|
||||
function largeSolution(): InstanceUpgradeSolution {
|
||||
return {
|
||||
kind: 'custom',
|
||||
warnings: [],
|
||||
selections: Array.from({ length: 500 }, (_, index) => ({
|
||||
contentId: `example-${index}`,
|
||||
provider: 'modrinth',
|
||||
projectId: `project-${index}`,
|
||||
currentReleaseId: `old-${index}`,
|
||||
targetReleaseId: `new-${index}`,
|
||||
action: index % 3 === 0 ? 'keep' : index % 3 === 1 ? 'disable' : 'upgrade',
|
||||
enabled: index % 3 !== 1,
|
||||
})),
|
||||
dependencyChanges: [],
|
||||
}
|
||||
}
|
||||
|
||||
test('500-item result paginates to 25 real visible rows per page', () => {
|
||||
const all = upgradeDetailItems(largeSolution())
|
||||
const first = paginateUpgradeDetailItems(all, 1)
|
||||
const second = paginateUpgradeDetailItems(all, 2)
|
||||
assert.equal(UPGRADE_RESULT_PAGE_SIZE, 25)
|
||||
assert.equal(first.items.length, 25)
|
||||
assert.deepEqual(
|
||||
first.items.map((item) => item.contentId),
|
||||
Array.from({ length: 25 }, (_, index) => `example-${index}`),
|
||||
)
|
||||
assert.deepEqual(
|
||||
second.items.map((item) => item.contentId),
|
||||
Array.from({ length: 25 }, (_, index) => `example-${index + 25}`),
|
||||
)
|
||||
})
|
||||
|
||||
test('search and status filters happen before pagination', () => {
|
||||
const all = upgradeDetailItems(largeSolution())
|
||||
const match = filterUpgradeDetailItems(all, 'all', 'example-487')
|
||||
assert.deepEqual(
|
||||
match.map((item) => item.contentId),
|
||||
['example-487'],
|
||||
)
|
||||
const updated = filterUpgradeDetailItems(all, 'updated', '')
|
||||
assert.ok(updated.every((item) => item.action === 'upgrade'))
|
||||
assert.equal(
|
||||
paginateUpgradeDetailItems(updated, 99).page <=
|
||||
paginateUpgradeDetailItems(updated, 99).pageCount,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('metadata scope contains only identities from the current visible page', () => {
|
||||
const visible = paginateUpgradeDetailItems(upgradeDetailItems(largeSolution()), 1).items
|
||||
assert.equal(upgradeDetailProjectIdentities(visible).length, 25)
|
||||
assert.equal(upgradeDetailReleaseIdentities(visible).length, 50)
|
||||
})
|
||||
|
||||
test('component resets page on filter/search and lazily mounts paginated rows', () => {
|
||||
const source = readFileSync(new URL('./UpgradeResultCollections.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /watch\(\[search, filter\],[\s\S]*?page\.value = 1[\s\S]*?\)/)
|
||||
assert.match(source, /v-if="detailsOpen"/)
|
||||
assert.match(source, /v-for="item in visibleRows"/)
|
||||
assert.doesNotMatch(source, /v-for="item in allItems"/)
|
||||
assert.match(source, /upgradeDetailProjectIdentities\(pageData\.value\.items\)/)
|
||||
})
|
||||
@ -0,0 +1,139 @@
|
||||
import type {
|
||||
ContentProvider,
|
||||
InstanceUpgradeDependencyChangeKind,
|
||||
InstanceUpgradeSolution,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import type {
|
||||
UpgradeProjectIdentity,
|
||||
UpgradeReleaseIdentity,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
export const UPGRADE_RESULT_PAGE_SIZE = 25
|
||||
|
||||
export type UpgradeDetailFilter = 'all' | 'updated' | 'kept' | 'disabled' | 'dependencies'
|
||||
|
||||
export interface UpgradeDetailItem {
|
||||
key: string
|
||||
kind: 'selection' | 'dependency'
|
||||
contentId: string | null
|
||||
provider: ContentProvider | null
|
||||
projectId: string | null
|
||||
currentReleaseId: string | null
|
||||
targetReleaseId: string | null
|
||||
action: 'upgrade' | 'keep' | 'disable' | InstanceUpgradeDependencyChangeKind
|
||||
}
|
||||
|
||||
export interface UpgradeDetailPage {
|
||||
items: UpgradeDetailItem[]
|
||||
page: number
|
||||
pageCount: number
|
||||
start: number
|
||||
end: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function upgradeDetailItems(solution: InstanceUpgradeSolution): UpgradeDetailItem[] {
|
||||
return [
|
||||
...solution.selections.map((selection) => ({
|
||||
key: `selection:${selection.contentId}`,
|
||||
kind: 'selection' as const,
|
||||
contentId: selection.contentId,
|
||||
provider: selection.provider,
|
||||
projectId: selection.projectId,
|
||||
currentReleaseId: selection.currentReleaseId,
|
||||
targetReleaseId: selection.targetReleaseId,
|
||||
action: selection.action,
|
||||
})),
|
||||
...solution.dependencyChanges.map((change, index) => ({
|
||||
key: `dependency:${change.provider}:${change.projectId}:${change.existingContentId ?? index}`,
|
||||
kind: 'dependency' as const,
|
||||
contentId: change.existingContentId,
|
||||
provider: change.provider,
|
||||
projectId: change.projectId,
|
||||
currentReleaseId: change.currentReleaseId,
|
||||
targetReleaseId: change.targetReleaseId,
|
||||
action: change.kind,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export function filterUpgradeDetailItems(
|
||||
items: UpgradeDetailItem[],
|
||||
filter: UpgradeDetailFilter,
|
||||
query: string,
|
||||
searchFields: (item: UpgradeDetailItem) => Array<string | null | undefined> = defaultSearchFields,
|
||||
): UpgradeDetailItem[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
return items.filter((item) => {
|
||||
if (filter === 'dependencies' && item.kind !== 'dependency') return false
|
||||
if (filter === 'updated' && (item.kind !== 'selection' || item.action !== 'upgrade'))
|
||||
return false
|
||||
if (filter === 'kept' && (item.kind !== 'selection' || item.action !== 'keep')) return false
|
||||
if (filter === 'disabled' && (item.kind !== 'selection' || item.action !== 'disable'))
|
||||
return false
|
||||
if (!normalizedQuery) return true
|
||||
return searchFields(item).some((value) => value?.toLocaleLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
}
|
||||
|
||||
export function paginateUpgradeDetailItems(
|
||||
items: UpgradeDetailItem[],
|
||||
requestedPage: number,
|
||||
pageSize = UPGRADE_RESULT_PAGE_SIZE,
|
||||
): UpgradeDetailPage {
|
||||
const pageCount = Math.max(1, Math.ceil(items.length / pageSize))
|
||||
const page = Math.min(Math.max(1, requestedPage), pageCount)
|
||||
const startIndex = (page - 1) * pageSize
|
||||
return {
|
||||
items: items.slice(startIndex, startIndex + pageSize),
|
||||
page,
|
||||
pageCount,
|
||||
start: items.length ? startIndex + 1 : 0,
|
||||
end: Math.min(startIndex + pageSize, items.length),
|
||||
total: items.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeDetailProjectIdentities(
|
||||
items: UpgradeDetailItem[],
|
||||
): UpgradeProjectIdentity[] {
|
||||
const identities = new Map<string, UpgradeProjectIdentity>()
|
||||
for (const item of items) {
|
||||
if (item.provider !== 'modrinth' && item.provider !== 'curseforge') continue
|
||||
if (!item.projectId) continue
|
||||
identities.set(`${item.provider}:${item.projectId}`, {
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
})
|
||||
}
|
||||
return [...identities.values()]
|
||||
}
|
||||
|
||||
export function upgradeDetailReleaseIdentities(
|
||||
items: UpgradeDetailItem[],
|
||||
): UpgradeReleaseIdentity[] {
|
||||
const identities = new Map<string, UpgradeReleaseIdentity>()
|
||||
for (const item of items) {
|
||||
if (item.provider !== 'modrinth' && item.provider !== 'curseforge') continue
|
||||
if (!item.projectId) continue
|
||||
for (const releaseId of [item.currentReleaseId, item.targetReleaseId]) {
|
||||
if (!releaseId) continue
|
||||
identities.set(`${item.provider}:${item.projectId}:${releaseId}`, {
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
releaseId,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...identities.values()]
|
||||
}
|
||||
|
||||
function defaultSearchFields(item: UpgradeDetailItem) {
|
||||
return [
|
||||
item.contentId,
|
||||
item.provider,
|
||||
item.projectId,
|
||||
item.currentReleaseId,
|
||||
item.targetReleaseId,
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import {
|
||||
clearUpgradeFlow,
|
||||
consumeUpgradeFlow,
|
||||
parkUpgradeFlow,
|
||||
peekUpgradeFlow,
|
||||
restoreUpgradeFlow,
|
||||
upgradeProjectPath,
|
||||
} from '../../../helpers/upgrade-return-state.ts'
|
||||
|
||||
test('upgrade return snapshot is one-shot and instance-scoped', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: '/instance/instance-a/upgrade/compatibility',
|
||||
targetEnvironment: null,
|
||||
plan: null,
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
}
|
||||
parkUpgradeFlow(snapshot)
|
||||
assert.equal(consumeUpgradeFlow('instance-b', snapshot.returnFullPath), null)
|
||||
assert.deepEqual(consumeUpgradeFlow('instance-a', snapshot.returnFullPath), snapshot)
|
||||
assert.equal(consumeUpgradeFlow('instance-a', snapshot.returnFullPath), null)
|
||||
})
|
||||
|
||||
for (const route of ['compatibility', 'customize', 'confirm']) {
|
||||
test(`${route} return hydrates the parked plan before consuming it`, () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: `/instance/instance-a/upgrade/${route}`,
|
||||
targetEnvironment: { gameVersion: '26.1.2' },
|
||||
plan: { id: 'same-plan' },
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
} as never
|
||||
parkUpgradeFlow(snapshot)
|
||||
let hydratedPlanId: string | undefined
|
||||
const restored = restoreUpgradeFlow('instance-a', snapshot.returnFullPath, (value) => {
|
||||
hydratedPlanId = value.plan?.id
|
||||
})
|
||||
assert.equal(hydratedPlanId, 'same-plan')
|
||||
assert.equal(restored?.plan?.id, 'same-plan')
|
||||
assert.equal(peekUpgradeFlow('instance-a'), null)
|
||||
})
|
||||
}
|
||||
|
||||
test('confirm project return restores plan and confirm choices without replanning', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: '/instance/instance-a/upgrade/confirm',
|
||||
targetEnvironment: { gameVersion: '26.1.2' },
|
||||
plan: {
|
||||
id: 'same-plan',
|
||||
selectedSolution: { kind: 'custom' },
|
||||
customConstraints: [{ contentId: 'root', versionId: 'fixed' }],
|
||||
},
|
||||
createFullBackup: false,
|
||||
directFullBackupPreference: false,
|
||||
sharedUpgradeMode: 'direct',
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
} as never
|
||||
parkUpgradeFlow(snapshot)
|
||||
let restoredSnapshot: typeof snapshot | null = null
|
||||
restoreUpgradeFlow('instance-a', snapshot.returnFullPath, (value) => {
|
||||
restoredSnapshot = value as typeof snapshot
|
||||
})
|
||||
|
||||
assert.equal(restoredSnapshot?.plan.id, 'same-plan')
|
||||
assert.deepEqual(restoredSnapshot?.targetEnvironment, snapshot.targetEnvironment)
|
||||
assert.deepEqual(restoredSnapshot?.plan.selectedSolution, snapshot.plan.selectedSolution)
|
||||
assert.deepEqual(restoredSnapshot?.plan.customConstraints, snapshot.plan.customConstraints)
|
||||
assert.equal(restoredSnapshot?.createFullBackup, false)
|
||||
assert.equal(restoredSnapshot?.sharedUpgradeMode, 'direct')
|
||||
})
|
||||
|
||||
test('failed hydration leaves the parked snapshot available', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: '/instance/instance-a/upgrade/compatibility',
|
||||
targetEnvironment: null,
|
||||
plan: null,
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
}
|
||||
parkUpgradeFlow(snapshot)
|
||||
assert.throws(() =>
|
||||
restoreUpgradeFlow('instance-a', snapshot.returnFullPath, () => {
|
||||
throw new Error('hydrate failed')
|
||||
}),
|
||||
)
|
||||
assert.deepEqual(peekUpgradeFlow('instance-a'), snapshot)
|
||||
})
|
||||
|
||||
test('confirm project title routes match trusted provider routes only', () => {
|
||||
assert.equal(upgradeProjectPath('modrinth', 'P7dR8mSH'), '/project/P7dR8mSH')
|
||||
assert.equal(upgradeProjectPath('curseforge', '123'), '/project/curseforge/123')
|
||||
assert.equal(upgradeProjectPath('local', 'pack'), null)
|
||||
assert.equal(upgradeProjectPath(null, 'unidentified'), null)
|
||||
})
|
||||
|
||||
test('upgrade return snapshot detaches reactive flow DTOs', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = reactive({
|
||||
instanceId: 'reactive-instance',
|
||||
returnFullPath: '/instance/reactive-instance/upgrade/compatibility',
|
||||
targetEnvironment: null,
|
||||
plan: null,
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
})
|
||||
parkUpgradeFlow(snapshot)
|
||||
snapshot.createFullBackup = false
|
||||
assert.equal(peekUpgradeFlow('reactive-instance')?.createFullBackup, true)
|
||||
assert.equal(consumeUpgradeFlow('wrong-instance', snapshot.returnFullPath), null)
|
||||
})
|
||||
@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
import { shouldExpandUpgradeWarningsByDefault } from '../../../helpers/post-upgrade-notice.ts'
|
||||
import {
|
||||
filterUpgradeWarnings,
|
||||
paginateUpgradeWarnings,
|
||||
summarizeUpgradeWarnings,
|
||||
UPGRADE_WARNING_PAGE_SIZE,
|
||||
upgradeResultWarningRows,
|
||||
upgradeWarningDisplayName,
|
||||
upgradeWarningMessageId,
|
||||
} from './upgrade-warning.ts'
|
||||
|
||||
const base = {
|
||||
planId: 'plan',
|
||||
sourceInstanceId: 'source',
|
||||
targetInstanceId: 'target',
|
||||
backupInstanceId: null,
|
||||
solution: { kind: 'custom', selections: [], dependencyChanges: [], warnings: [] },
|
||||
externalChanges: [],
|
||||
skippedDueToExternalConflict: [],
|
||||
} as InstanceUpgradeResult
|
||||
|
||||
test('structured warning maps by stable code', () => {
|
||||
const rows = upgradeResultWarningRows({
|
||||
...base,
|
||||
compatibilityWarnings: [],
|
||||
compatibilityWarningDetails: [
|
||||
{
|
||||
code: 'keep_incompatible',
|
||||
relativePath: 'mods/a.jar',
|
||||
contentId: 'a',
|
||||
provider: 'modrinth',
|
||||
projectId: 'project',
|
||||
conflictingProjectId: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.equal(upgradeWarningMessageId(rows[0].code!), 'instance.upgrade.warning.keep-incompatible')
|
||||
const zhCn = JSON.parse(
|
||||
readFileSync(new URL('../../../locales/zh-CN/index.json', import.meta.url), 'utf8'),
|
||||
) as Record<string, { message: string }>
|
||||
const localized = zhCn[upgradeWarningMessageId(rows[0].code!)]?.message
|
||||
assert.equal(localized, '{path} 已原样保留,可能与升级后的实例不兼容。')
|
||||
assert.doesNotMatch(localized, /will be preserved/i)
|
||||
})
|
||||
|
||||
test('legacy persisted warning falls back to raw message', () => {
|
||||
const rows = upgradeResultWarningRows({
|
||||
...base,
|
||||
compatibilityWarnings: [
|
||||
{
|
||||
code: 'unidentified',
|
||||
message: 'Legacy backend text',
|
||||
contentId: null,
|
||||
provider: null,
|
||||
projectId: null,
|
||||
conflictingProjectId: null,
|
||||
dependencyRequirements: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.equal(rows[0].legacyMessage, 'Legacy backend text')
|
||||
})
|
||||
|
||||
test('300 structured warnings stay summarized with path as secondary data', () => {
|
||||
const rows = upgradeResultWarningRows({
|
||||
...base,
|
||||
compatibilityWarnings: [],
|
||||
compatibilityWarningDetails: Array.from({ length: 300 }, (_, index) => ({
|
||||
code:
|
||||
index < 200 ? 'unidentified' : index < 275 ? 'no_compatible_release' : 'prerelease_only',
|
||||
relativePath: `resourcepacks/example-${index}.zip`,
|
||||
contentId: `content-${index}`,
|
||||
provider: null,
|
||||
projectId: null,
|
||||
conflictingProjectId: null,
|
||||
})),
|
||||
})
|
||||
assert.deepEqual(summarizeUpgradeWarnings(rows), { local: 200, kept: 75, fallback: 25 })
|
||||
assert.equal(upgradeWarningDisplayName(rows[0]), 'example-0.zip')
|
||||
assert.equal(shouldExpandUpgradeWarningsByDefault(rows.length), false)
|
||||
const zhCn = JSON.parse(
|
||||
readFileSync(new URL('../../../locales/zh-CN/index.json', import.meta.url), 'utf8'),
|
||||
) as Record<string, { message: string }>
|
||||
assert.equal(
|
||||
zhCn['instance.upgrade.result.warning-unidentified-headline']?.message,
|
||||
'此内容在升级时被原样保留',
|
||||
)
|
||||
|
||||
const page1 = paginateUpgradeWarnings(rows, 1)
|
||||
const page2 = paginateUpgradeWarnings(rows, 2)
|
||||
const lastPage = paginateUpgradeWarnings(rows, 30)
|
||||
assert.equal(UPGRADE_WARNING_PAGE_SIZE, 10)
|
||||
assert.equal(page1.items.length, 10)
|
||||
assert.deepEqual(
|
||||
page2.items.map((row) => row.contentId),
|
||||
Array.from({ length: 10 }, (_, index) => `content-${index + 10}`),
|
||||
)
|
||||
assert.deepEqual(
|
||||
lastPage.items.map((row) => row.contentId),
|
||||
Array.from({ length: 10 }, (_, index) => `content-${index + 290}`),
|
||||
)
|
||||
const remainderPage = paginateUpgradeWarnings(rows.slice(0, 293), 30)
|
||||
assert.deepEqual(
|
||||
remainderPage.items.map((row) => row.contentId),
|
||||
['content-290', 'content-291', 'content-292'],
|
||||
)
|
||||
const searched = filterUpgradeWarnings(rows, 'all', 'example-287')
|
||||
assert.deepEqual(
|
||||
searched.map((row) => row.contentId),
|
||||
['content-287'],
|
||||
)
|
||||
assert.equal(paginateUpgradeWarnings(searched, 1).items.length, 1)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'all', '').length, 300)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'local', '').length, 200)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'kept', '').length, 75)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'fallback', '').length, 25)
|
||||
|
||||
const source = readFileSync(new URL('./UpgradeResultCollections.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /v-if="warningsOpen"/)
|
||||
assert.match(source, /v-for="warning in warningPage\.items"/)
|
||||
assert.doesNotMatch(source, /v-for="warning in warnings"/)
|
||||
assert.match(source, /technicalDetails/)
|
||||
assert.match(source, /warningHeadline\(warning\)/)
|
||||
assert.match(
|
||||
source,
|
||||
/watch\(\[warningSearch, warningFilter\],[\s\S]*?warningPageNumber\.value = 1/,
|
||||
)
|
||||
|
||||
const buttonSlotStart = source.indexOf('<template #button="{ open }">')
|
||||
const buttonSlotEnd = source.indexOf('</template>', buttonSlotStart)
|
||||
const summaryPosition = source.indexOf('warningSummary.local', buttonSlotStart)
|
||||
assert.ok(
|
||||
buttonSlotStart >= 0 && summaryPosition > buttonSlotStart && summaryPosition < buttonSlotEnd,
|
||||
)
|
||||
assert.match(source, /class="block w-full"/)
|
||||
assert.match(source, /button-class="[^"]*w-full[^"]*focus-visible:ring-4/)
|
||||
|
||||
const accordionSource = readFileSync(
|
||||
new URL('../../../../../../packages/ui/src/components/base/Accordion.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const accordionButtonSlot = accordionSource.lastIndexOf('<slot name="button"')
|
||||
const accordionButtonStart = accordionSource.lastIndexOf('<button', accordionButtonSlot)
|
||||
const accordionButtonEnd = accordionSource.indexOf('</button>', accordionButtonSlot)
|
||||
assert.ok(
|
||||
accordionButtonStart >= 0 &&
|
||||
accordionButtonSlot > accordionButtonStart &&
|
||||
accordionButtonSlot < accordionButtonEnd,
|
||||
)
|
||||
})
|
||||
123
apps/app-frontend/src/pages/instance/upgrade/upgrade-warning.ts
Normal file
123
apps/app-frontend/src/pages/instance/upgrade/upgrade-warning.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import type { InstanceUpgradeIssueCode, InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
export interface UpgradeWarningRow {
|
||||
key: string
|
||||
code: InstanceUpgradeIssueCode | null
|
||||
contentId: string | null
|
||||
relativePath: string | null
|
||||
provider: string | null
|
||||
projectId: string | null
|
||||
legacyMessage: string | null
|
||||
}
|
||||
|
||||
export type UpgradeWarningCategory = 'local' | 'kept' | 'fallback'
|
||||
export type UpgradeWarningFilter = 'all' | UpgradeWarningCategory
|
||||
|
||||
export const UPGRADE_WARNING_PAGE_SIZE = 10
|
||||
|
||||
export interface UpgradeWarningSummary {
|
||||
local: number
|
||||
kept: number
|
||||
fallback: number
|
||||
}
|
||||
|
||||
export interface UpgradeWarningPage {
|
||||
items: UpgradeWarningRow[]
|
||||
page: number
|
||||
pageCount: number
|
||||
start: number
|
||||
end: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function upgradeWarningMessageId(code: InstanceUpgradeIssueCode): string {
|
||||
return `instance.upgrade.warning.${code.replaceAll('_', '-')}`
|
||||
}
|
||||
|
||||
export function upgradeResultWarningRows(result: InstanceUpgradeResult): UpgradeWarningRow[] {
|
||||
if (result.compatibilityWarningDetails !== undefined) {
|
||||
return result.compatibilityWarningDetails.map((warning, index) => ({
|
||||
key: `${warning.code}:${warning.contentId ?? warning.relativePath ?? index}`,
|
||||
code: warning.code,
|
||||
contentId: warning.contentId,
|
||||
relativePath: warning.relativePath,
|
||||
provider: warning.provider,
|
||||
projectId: warning.projectId,
|
||||
legacyMessage: null,
|
||||
}))
|
||||
}
|
||||
return result.compatibilityWarnings.map((warning, index) => ({
|
||||
key: `${warning.code}:${warning.contentId ?? index}`,
|
||||
code: null,
|
||||
contentId: warning.contentId,
|
||||
relativePath: null,
|
||||
provider: warning.provider,
|
||||
projectId: warning.projectId,
|
||||
legacyMessage: warning.message || warning.code,
|
||||
}))
|
||||
}
|
||||
|
||||
export function upgradeWarningCategory(row: UpgradeWarningRow): UpgradeWarningCategory {
|
||||
if (row.code === 'unidentified' || row.code === 'unsupported_content_type') return 'local'
|
||||
if (row.code === 'keep_incompatible' || row.code === 'no_compatible_release') return 'kept'
|
||||
return 'fallback'
|
||||
}
|
||||
|
||||
export function summarizeUpgradeWarnings(rows: UpgradeWarningRow[]): UpgradeWarningSummary {
|
||||
const summary: UpgradeWarningSummary = { local: 0, kept: 0, fallback: 0 }
|
||||
for (const row of rows) summary[upgradeWarningCategory(row)] += 1
|
||||
return summary
|
||||
}
|
||||
|
||||
export function filterUpgradeWarnings(
|
||||
rows: UpgradeWarningRow[],
|
||||
filter: UpgradeWarningFilter,
|
||||
query: string,
|
||||
searchFields: (
|
||||
row: UpgradeWarningRow,
|
||||
) => Array<string | null | undefined> = defaultWarningSearchFields,
|
||||
): UpgradeWarningRow[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
return rows.filter((row) => {
|
||||
if (filter !== 'all' && upgradeWarningCategory(row) !== filter) return false
|
||||
if (!normalizedQuery) return true
|
||||
return searchFields(row).some((value) => value?.toLocaleLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
}
|
||||
|
||||
export function paginateUpgradeWarnings(
|
||||
rows: UpgradeWarningRow[],
|
||||
requestedPage: number,
|
||||
pageSize = UPGRADE_WARNING_PAGE_SIZE,
|
||||
): UpgradeWarningPage {
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize))
|
||||
const page = Math.min(Math.max(1, requestedPage), pageCount)
|
||||
const startIndex = (page - 1) * pageSize
|
||||
return {
|
||||
items: rows.slice(startIndex, startIndex + pageSize),
|
||||
page,
|
||||
pageCount,
|
||||
start: rows.length ? startIndex + 1 : 0,
|
||||
end: Math.min(startIndex + pageSize, rows.length),
|
||||
total: rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeWarningDisplayName(row: UpgradeWarningRow): string | null {
|
||||
const path = row.relativePath?.replaceAll('\\', '/')
|
||||
const filename = path?.split('/').filter(Boolean).at(-1)
|
||||
return filename ?? row.projectId ?? row.contentId
|
||||
}
|
||||
|
||||
export function upgradeWarningContentKind(row: UpgradeWarningRow): string {
|
||||
const path = row.relativePath?.replaceAll('\\', '/').toLocaleLowerCase()
|
||||
if (path?.startsWith('resourcepacks/')) return 'resourcepack'
|
||||
if (path?.startsWith('shaderpacks/')) return 'shaderpack'
|
||||
if (path?.startsWith('datapacks/')) return 'datapack'
|
||||
if (path?.startsWith('mods/')) return 'mod'
|
||||
return 'content'
|
||||
}
|
||||
|
||||
function defaultWarningSearchFields(row: UpgradeWarningRow) {
|
||||
return [row.contentId, row.relativePath, row.code, row.provider, row.projectId, row.legacyMessage]
|
||||
}
|
||||
17
apps/app-frontend/src/pages/library/Custom.vue
Normal file
17
apps/app-frontend/src/pages/library/Custom.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import GridDisplay from '@/components/GridDisplay.vue'
|
||||
|
||||
defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="instances && instances.length > 0"
|
||||
label="Instances"
|
||||
:instances="instances.filter((i) => !i.link)"
|
||||
/>
|
||||
</template>
|
||||
17
apps/app-frontend/src/pages/library/Downloaded.vue
Normal file
17
apps/app-frontend/src/pages/library/Downloaded.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import GridDisplay from '@/components/GridDisplay.vue'
|
||||
|
||||
defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="instances && instances.length > 0"
|
||||
label="Instances"
|
||||
:instances="instances.filter((i) => i.link)"
|
||||
/>
|
||||
</template>
|
||||
111
apps/app-frontend/src/pages/library/Index.vue
Normal file
111
apps/app-frontend/src/pages/library/Index.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { onUnmounted, shallowRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { NewInstanceImage } from '@/assets/icons'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
library: { id: 'app.library.title', defaultMessage: 'Library' },
|
||||
allInstances: { id: 'app.library.tabs.all-instances', defaultMessage: 'All instances' },
|
||||
modpacks: { id: 'app.library.tabs.modpacks', defaultMessage: 'Modpacks' },
|
||||
servers: { id: 'app.library.tabs.servers', defaultMessage: 'Servers' },
|
||||
custom: { id: 'app.library.tabs.custom', defaultMessage: 'Custom' },
|
||||
shared: { id: 'app.library.tabs.shared', defaultMessage: 'Shared with me' },
|
||||
saved: { id: 'app.library.tabs.saved', defaultMessage: 'Saved' },
|
||||
noInstances: { id: 'app.library.no-instances', defaultMessage: 'No instances found' },
|
||||
createInstance: {
|
||||
id: 'app.library.create-instance',
|
||||
defaultMessage: 'Create new instance',
|
||||
},
|
||||
})
|
||||
|
||||
breadcrumbs.setRootContext({ name: formatMessage(messages.library), link: route.path })
|
||||
|
||||
const instances = shallowRef(await list().catch(handleError))
|
||||
|
||||
const refreshInstances = async () => {
|
||||
instances.value = await list().catch(handleError)
|
||||
}
|
||||
|
||||
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, refreshInstances)
|
||||
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
const unlistenInstance = await instance_listener(async () => {
|
||||
await refreshInstances()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unlistenInstance()
|
||||
window.removeEventListener(DIRECT_LINKS_SYNCED_EVENT, refreshInstances)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-onboarding-id="library-content" class="p-6 flex flex-col gap-3">
|
||||
<h1 class="m-0 text-2xl hidden">{{ formatMessage(messages.library) }}</h1>
|
||||
<NavTabs
|
||||
:links="[
|
||||
{ label: formatMessage(messages.allInstances), href: `/library` },
|
||||
{ label: formatMessage(messages.modpacks), href: `/library/modpacks` },
|
||||
{ label: formatMessage(messages.servers), href: `/library/servers` },
|
||||
{ label: formatMessage(messages.custom), href: `/library/custom` },
|
||||
{ label: formatMessage(messages.shared), href: `/library/shared`, shown: false },
|
||||
{ label: formatMessage(messages.saved), href: `/library/saved`, shown: false },
|
||||
]"
|
||||
/>
|
||||
<template v-if="instances && instances.length > 0">
|
||||
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
|
||||
</template>
|
||||
<div v-else class="no-instance flex flex-col items-center justify-center h-full gap-3">
|
||||
<div class="icon">
|
||||
<NewInstanceImage />
|
||||
</div>
|
||||
<h3>{{ formatMessage(messages.noInstances) }}</h3>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
data-onboarding-id="create-instance"
|
||||
:disabled="offline"
|
||||
@click="router.push('/create')"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createInstance) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-instance {
|
||||
p,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
svg {
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
apps/app-frontend/src/pages/library/Modpacks.vue
Normal file
47
apps/app-frontend/src/pages/library/Modpacks.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import GridDisplay from '@/components/GridDisplay.vue'
|
||||
import { get_project_v3_many } from '@/helpers/cache.js'
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const serverProjectIds = ref(new Set())
|
||||
|
||||
const linkedInstances = computed(() => props.instances.filter((i) => i.link))
|
||||
|
||||
watchEffect(async () => {
|
||||
const projectIds = [
|
||||
...new Set(linkedInstances.value.map((i) => i.link?.project_id).filter(Boolean)),
|
||||
]
|
||||
if (projectIds.length === 0) {
|
||||
serverProjectIds.value = new Set()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await get_project_v3_many(projectIds, 'must_revalidate')
|
||||
serverProjectIds.value = new Set(
|
||||
projects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
|
||||
)
|
||||
} catch {
|
||||
serverProjectIds.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
const filteredInstances = computed(() =>
|
||||
linkedInstances.value.filter((i) => !serverProjectIds.value.has(i.link?.project_id)),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="filteredInstances && filteredInstances.length > 0"
|
||||
label="Instances"
|
||||
:instances="filteredInstances"
|
||||
/>
|
||||
</template>
|
||||
13
apps/app-frontend/src/pages/library/Overview.vue
Normal file
13
apps/app-frontend/src/pages/library/Overview.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import GridDisplay from '@/components/GridDisplay.vue'
|
||||
|
||||
defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
|
||||
</template>
|
||||
47
apps/app-frontend/src/pages/library/Servers.vue
Normal file
47
apps/app-frontend/src/pages/library/Servers.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import GridDisplay from '@/components/GridDisplay.vue'
|
||||
import { get_project_v3_many } from '@/helpers/cache.js'
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const serverProjectIds = ref(new Set())
|
||||
|
||||
const linkedInstances = computed(() => props.instances.filter((i) => i.link))
|
||||
|
||||
watchEffect(async () => {
|
||||
const projectIds = [
|
||||
...new Set(linkedInstances.value.map((i) => i.link?.project_id).filter(Boolean)),
|
||||
]
|
||||
if (projectIds.length === 0) {
|
||||
serverProjectIds.value = new Set()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await get_project_v3_many(projectIds, 'must_revalidate')
|
||||
serverProjectIds.value = new Set(
|
||||
projects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
|
||||
)
|
||||
} catch {
|
||||
serverProjectIds.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
const filteredInstances = computed(() =>
|
||||
linkedInstances.value.filter((i) => serverProjectIds.value.has(i.link?.project_id)),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="filteredInstances && filteredInstances.length > 0"
|
||||
label="Instances"
|
||||
:instances="filteredInstances"
|
||||
/>
|
||||
</template>
|
||||
8
apps/app-frontend/src/pages/library/index.js
Normal file
8
apps/app-frontend/src/pages/library/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
import Custom from './Custom.vue'
|
||||
import Downloaded from './Downloaded.vue'
|
||||
import Index from './Index.vue'
|
||||
import Modpacks from './Modpacks.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Servers from './Servers.vue'
|
||||
|
||||
export { Custom, Downloaded, Index, Modpacks, Overview, Servers }
|
||||
11
apps/app-frontend/src/pages/project/Changelog.vue
Normal file
11
apps/app-frontend/src/pages/project/Changelog.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Changelog',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
964
apps/app-frontend/src/pages/project/CurseForge.vue
Normal file
964
apps/app-frontend/src/pages/project/CurseForge.vue
Normal file
@ -0,0 +1,964 @@
|
||||
<template>
|
||||
<div v-if="loading" class="flex min-h-64 items-center justify-center gap-3 p-6 text-secondary">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div v-else-if="data">
|
||||
<UpgradeProjectReturnBar />
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="data"
|
||||
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
|
||||
:platform-action="(platform) => browseByProjectFilter('loader', platform)"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarLinks
|
||||
link-target="_blank"
|
||||
:project="data"
|
||||
:mcmod-url="mcmodUrl"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarTags
|
||||
:project="data"
|
||||
:tag-action="(tag) => browseByProjectFilter('category', tag)"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarCreators
|
||||
:members="members"
|
||||
:org-link="() => data.links.website_url"
|
||||
:user-link="(username) => authorLinks[username] ?? data.links.website_url"
|
||||
link-target="_blank"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarDetails
|
||||
:project="data"
|
||||
:has-versions="versions.length > 0"
|
||||
hide-license
|
||||
link-target="_blank"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<Teleport v-if="themeStore.featureFlags.project_background" to="#background-teleport-target">
|
||||
<ProjectBackgroundGradient :project="data" />
|
||||
</Teleport>
|
||||
<BrowseInstallHeader
|
||||
v-if="fromInstanceContent && cartInstallContext"
|
||||
:install-context="cartInstallContext"
|
||||
/>
|
||||
<ProjectHeader
|
||||
:project="data"
|
||||
:show-followers="false"
|
||||
:translated-title="translationActive ? translations.title : undefined"
|
||||
:translated-description="translationActive ? translations.description : undefined"
|
||||
:translation-mode="translationMode"
|
||||
:translation-style="translationStyle"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled size="large" type="transparent">
|
||||
<button :disabled="translationLoading" @click="toggleTranslation">
|
||||
<SpinnerIcon v-if="translationLoading" class="animate-spin" />
|
||||
<LanguagesIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
translationLoading
|
||||
? messages.translating
|
||||
: translationActive
|
||||
? messages.showOriginal
|
||||
: messages.translateProject,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="managedProjectType || isWorldMap" size="large" color="brand">
|
||||
<button :disabled="installing || cartProjectInstalling" @click="installSelected(null)">
|
||||
<SpinnerIcon v-if="installing" class="animate-spin" />
|
||||
<PlusIcon v-else-if="isWorldMap || cartProjectSelected" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
installing || cartProjectInstalling
|
||||
? commonMessages.installingLabel
|
||||
: cartProjectSelected
|
||||
? commonMessages.selectedLabel
|
||||
: isWorldMap
|
||||
? instanceId
|
||||
? commonMessages.installButton
|
||||
: messages.addToAnInstance
|
||||
: commonMessages.installButton,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="data.site_url || mcmodUrl || favoriteSupported"
|
||||
size="large"
|
||||
circular
|
||||
type="transparent"
|
||||
>
|
||||
<OverflowMenu
|
||||
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
|
||||
:options="[
|
||||
...(favoriteSupported
|
||||
? [
|
||||
{
|
||||
id: 'save',
|
||||
disabled: favoritePending,
|
||||
tooltip: formatMessage(
|
||||
favoriteSaved ? messages.removeFromFavorites : messages.addToFavorites,
|
||||
),
|
||||
action: () => toggleFavorite(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(data.site_url
|
||||
? [
|
||||
{
|
||||
id: 'open-in-browser',
|
||||
link: data.site_url,
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(mcmodUrl
|
||||
? [
|
||||
{
|
||||
id: 'open-in-mcmod',
|
||||
link: mcmodUrl,
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]"
|
||||
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #open-in-mcmod>
|
||||
<BookOpenIcon /> {{ formatMessage(messages.openInMcmod) }}
|
||||
</template>
|
||||
<template v-if="favoriteSupported" #save>
|
||||
<BookmarkFilledIcon v-if="favoriteSaved" class="text-brand" />
|
||||
<BookmarkIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
favoritePending
|
||||
? messages.favoritesLoading
|
||||
: favoriteSaved
|
||||
? messages.removeFromFavorites
|
||||
: messages.addToFavorites,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ProjectHeader>
|
||||
<SelectedProjectsFloatingBar
|
||||
v-if="cartInstallContext"
|
||||
:install-context="cartInstallContext"
|
||||
/>
|
||||
<BrowseInstanceSelector
|
||||
ref="browseInstanceSelector"
|
||||
:instances="contentSelection.instances.value"
|
||||
:selected-instance="contentSelection.targetInstance.value"
|
||||
:selected-count="contentSelection.selectedCount.value"
|
||||
:install-current="contentSelection.installSelected"
|
||||
:clear-current="contentSelection.clear"
|
||||
@select="contentSelection.setTarget"
|
||||
/>
|
||||
|
||||
<NavTabs
|
||||
:links="[
|
||||
{
|
||||
label: formatMessage(messages.description),
|
||||
href: projectDescriptionHref,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.versions),
|
||||
href: projectVersionsHref,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.gallery),
|
||||
href: projectGalleryHref,
|
||||
shown: data.gallery.length > 0,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<Gallery
|
||||
v-if="activeTab === 'gallery'"
|
||||
:project="data"
|
||||
:translation-active="translationActive"
|
||||
:translations="translations"
|
||||
:translation-mode="translationMode"
|
||||
:translation-style="translationStyle"
|
||||
/>
|
||||
<ProjectPageVersions
|
||||
v-else-if="activeTab === 'versions'"
|
||||
:loaders="allLoaders"
|
||||
:game-versions="allGameVersions"
|
||||
:versions="versions"
|
||||
:project="data"
|
||||
:show-environment-column="themeStore.featureFlags.show_version_environment_column"
|
||||
>
|
||||
<template #actions="{ version }">
|
||||
<ButtonStyled circular type="transparent" :color="isWorldMap ? 'brand' : 'green'">
|
||||
<button
|
||||
v-tooltip="
|
||||
formatMessage(isWorldMap ? messages.addToAnInstance : commonMessages.installButton)
|
||||
"
|
||||
:disabled="installing"
|
||||
@click.stop="installSelected(version.id)"
|
||||
>
|
||||
<PlusIcon v-if="isWorldMap" />
|
||||
<DownloadIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ProjectPageVersions>
|
||||
<Card v-else>
|
||||
<TranslatedProjectDescription
|
||||
v-if="data.body"
|
||||
:description="data.body"
|
||||
:active="translationActive"
|
||||
:translations="translations"
|
||||
:mode="translationMode"
|
||||
:style="translationStyle"
|
||||
format="html"
|
||||
/>
|
||||
<p v-else class="m-0">{{ data.description }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-6">
|
||||
<Card>
|
||||
<h2>{{ formatMessage(messages.unavailableTitle) }}</h2>
|
||||
<p class="mb-0">{{ formatMessage(messages.unavailableDescription) }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BookmarkFilledIcon,
|
||||
BookmarkIcon,
|
||||
BookOpenIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
LanguagesIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
BrowseInstallHeader,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectHeader,
|
||||
ProjectPageVersions,
|
||||
ProjectSidebarCompatibility,
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarDetails,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarTags,
|
||||
SelectedProjectsFloatingBar,
|
||||
usesTargetGameVersion,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
|
||||
import TranslatedProjectDescription from '@/components/ui/TranslatedProjectDescription.vue'
|
||||
import { useContentFavorites } from '@/composables/useContentFavorites'
|
||||
import { isFavoriteContentType } from '@/helpers/content-favorites'
|
||||
import { resolveMcmodUrl } from '@/helpers/content-search'
|
||||
import {
|
||||
type CurseForgeFile,
|
||||
type CurseForgeProject,
|
||||
getCurseForgeDescription,
|
||||
getCurseForgeFiles,
|
||||
getCurseForgeImageUrl,
|
||||
getCurseForgeProject,
|
||||
} from '@/helpers/curseforge'
|
||||
import { projectGalleryTranslationSegments } from '@/helpers/project-gallery'
|
||||
import { createProjectBrowseLocation, type ProjectBrowseFilter } from '@/helpers/project-links'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import {
|
||||
getTranslationErrorKind,
|
||||
getTranslationSettings,
|
||||
prepareDescription,
|
||||
translateInBatches as translateContent,
|
||||
type TranslationStyle,
|
||||
validateTranslatedDescription,
|
||||
} from '@/helpers/translation'
|
||||
import i18n from '@/i18n.config'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { injectContentSelection, makeContentSelectionKey } from '@/providers/content-selection'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/state.js'
|
||||
|
||||
import Gallery from './Gallery.vue'
|
||||
import UpgradeProjectReturnBar from './UpgradeProjectReturnBar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { installCurseForge, installCurseForgeWorld } = injectContentInstall()
|
||||
const contentSelection = injectContentSelection()
|
||||
const contentFavorites = useContentFavorites()
|
||||
|
||||
void contentFavorites.load().catch(handleError)
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'app.project.curseforge.loading',
|
||||
defaultMessage: 'Loading CurseForge project…',
|
||||
},
|
||||
openInMcmod: {
|
||||
id: 'app.project.open-in-mcmod',
|
||||
defaultMessage: 'Open in MC Mod',
|
||||
},
|
||||
description: {
|
||||
id: 'project.description.title',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
versions: {
|
||||
id: 'project.versions.title',
|
||||
defaultMessage: 'Versions',
|
||||
},
|
||||
gallery: {
|
||||
id: 'project.gallery.title',
|
||||
defaultMessage: 'Gallery',
|
||||
},
|
||||
translateProject: {
|
||||
id: 'app.project.translation.translate',
|
||||
defaultMessage: 'Translate',
|
||||
},
|
||||
showOriginal: {
|
||||
id: 'app.project.translation.show-original',
|
||||
defaultMessage: 'Show original',
|
||||
},
|
||||
translating: {
|
||||
id: 'app.project.translation.translating',
|
||||
defaultMessage: 'Translating…',
|
||||
},
|
||||
translationFailed: {
|
||||
id: 'app.project.translation.failed',
|
||||
defaultMessage: 'Translation failed. The original content was kept. Try again.',
|
||||
},
|
||||
translationFailedTitle: {
|
||||
id: 'app.project.translation.failed-title',
|
||||
defaultMessage: 'Translation failed',
|
||||
},
|
||||
translationRateLimited: {
|
||||
id: 'app.translation.error.rate-limited',
|
||||
defaultMessage: 'The translation service is temporarily rate limited. Please try again later.',
|
||||
},
|
||||
translationAuthenticationFailed: {
|
||||
id: 'app.translation.error.authentication',
|
||||
defaultMessage: 'The translation service could not authenticate. Please try again later.',
|
||||
},
|
||||
translationContentTooLong: {
|
||||
id: 'app.translation.error.content-too-long',
|
||||
defaultMessage: 'This content is too long for the selected translation service.',
|
||||
},
|
||||
translationNetworkFailed: {
|
||||
id: 'app.translation.error.network',
|
||||
defaultMessage: 'The translation service could not be reached. Check your network or proxy.',
|
||||
},
|
||||
unavailableTitle: {
|
||||
id: 'app.project.curseforge.unavailable-title',
|
||||
defaultMessage: 'Project unavailable',
|
||||
},
|
||||
unavailableDescription: {
|
||||
id: 'app.project.curseforge.unavailable-description',
|
||||
defaultMessage: 'The CurseForge project did not return any data.',
|
||||
},
|
||||
addToAnInstance: {
|
||||
id: 'app.browse.add-to-an-instance',
|
||||
defaultMessage: 'Add to an instance',
|
||||
},
|
||||
noCompatibleVersion: {
|
||||
id: 'app.project.install-button.no-compatible-version',
|
||||
defaultMessage: 'No compatible version was found for this instance.',
|
||||
},
|
||||
backToInstanceContent: {
|
||||
id: 'app.project.install-context.back-to-instance-content',
|
||||
defaultMessage: 'Back to instance content',
|
||||
},
|
||||
addToFavorites: {
|
||||
id: 'app.content-favorites.add',
|
||||
defaultMessage: 'Add to favorites',
|
||||
},
|
||||
removeFromFavorites: {
|
||||
id: 'app.content-favorites.remove',
|
||||
defaultMessage: 'Remove from favorites',
|
||||
},
|
||||
favoritesLoading: {
|
||||
id: 'app.content-favorites.loading',
|
||||
defaultMessage: 'Updating favorites…',
|
||||
},
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const installing = ref(false)
|
||||
const browseInstanceSelector = ref()
|
||||
const project = shallowRef<CurseForgeProject | null>(null)
|
||||
const mcmodUrl = ref<string | null>(null)
|
||||
const description = ref('')
|
||||
const files = shallowRef<CurseForgeFile[]>([])
|
||||
const allLoaders = ref([])
|
||||
const allGameVersions = ref([])
|
||||
const translationActive = ref(false)
|
||||
const translationLoading = ref(false)
|
||||
const translations = ref<Record<string, string>>({})
|
||||
const translationMode = ref<'bilingual' | 'translation-only'>('bilingual')
|
||||
const translationStyle = ref<TranslationStyle>('weakened')
|
||||
let projectRequestVersion = 0
|
||||
let translationRequestVersion = 0
|
||||
|
||||
const projectType = computed(() => {
|
||||
switch (project.value?.classId) {
|
||||
case 5:
|
||||
return 'plugin'
|
||||
case 6:
|
||||
return 'mod'
|
||||
case 12:
|
||||
return 'resourcepack'
|
||||
case 17:
|
||||
return 'world'
|
||||
case 6945:
|
||||
return 'datapack'
|
||||
case 4471:
|
||||
return 'modpack'
|
||||
case 6552:
|
||||
return 'shader'
|
||||
default:
|
||||
return 'mod'
|
||||
}
|
||||
})
|
||||
|
||||
const favoriteSupported = computed(() => isFavoriteContentType(projectType.value))
|
||||
const favoriteProjectId = computed(() => project.value?.id.toString() ?? '')
|
||||
const favoriteSaved = computed(() =>
|
||||
favoriteProjectId.value
|
||||
? contentFavorites.isFavorite('curseforge', favoriteProjectId.value)
|
||||
: false,
|
||||
)
|
||||
const favoritePending = computed(() =>
|
||||
favoriteProjectId.value
|
||||
? contentFavorites.isPending('curseforge', favoriteProjectId.value)
|
||||
: false,
|
||||
)
|
||||
|
||||
function toggleFavorite() {
|
||||
if (!favoriteSupported.value || !favoriteProjectId.value || favoritePending.value) return
|
||||
void contentFavorites
|
||||
.toggle({
|
||||
provider: 'curseforge',
|
||||
project_id: favoriteProjectId.value,
|
||||
content_type: projectType.value,
|
||||
})
|
||||
.catch(handleError)
|
||||
}
|
||||
|
||||
const managedProjectType = computed(() =>
|
||||
['mod', 'resourcepack', 'shader', 'datapack', 'modpack'].includes(projectType.value),
|
||||
)
|
||||
const isWorldMap = computed(() => projectType.value === 'world')
|
||||
const instanceId = computed(() => (typeof route.query.i === 'string' ? route.query.i : null))
|
||||
const fromBrowse = computed(
|
||||
() => typeof route.query.b === 'string' && route.query.b.startsWith('/browse/'),
|
||||
)
|
||||
const fromInstanceContent = computed(
|
||||
() => route.query.from === 'instance-content' && instanceId.value !== null,
|
||||
)
|
||||
const instanceContentTarget = computed(() => {
|
||||
if (!fromInstanceContent.value) return null
|
||||
const target = contentSelection.targetInstance.value
|
||||
return target?.id === instanceId.value ? target : null
|
||||
})
|
||||
const instanceContentBackUrl = computed(() =>
|
||||
instanceContentTarget.value ? `/instance/${encodeURIComponent(instanceId.value!)}` : null,
|
||||
)
|
||||
const cartEligible = computed(
|
||||
() =>
|
||||
(fromBrowse.value || instanceContentTarget.value !== null) &&
|
||||
['mod', 'resourcepack', 'shader', 'datapack', 'world'].includes(projectType.value),
|
||||
)
|
||||
const cartProjectKey = computed(() =>
|
||||
project.value ? makeContentSelectionKey('curseforge', project.value.id.toString()) : '',
|
||||
)
|
||||
const cartProjectSelected = computed(
|
||||
() => !!cartProjectKey.value && contentSelection.isSelected(cartProjectKey.value),
|
||||
)
|
||||
const cartProjectInstalling = computed(
|
||||
() => !!cartProjectKey.value && contentSelection.isInstalling(cartProjectKey.value),
|
||||
)
|
||||
const cartInstallContext = computed(() => {
|
||||
const target = contentSelection.targetInstance.value
|
||||
if (!cartEligible.value || !target) return null
|
||||
return {
|
||||
showInstallHeader: false,
|
||||
name: target.name,
|
||||
loader: target.loader,
|
||||
gameVersion: target.game_version,
|
||||
backUrl:
|
||||
instanceContentBackUrl.value ??
|
||||
(typeof route.query.b === 'string' ? route.query.b : `/browse/${projectType.value}`),
|
||||
backLabel: fromInstanceContent.value ? formatMessage(messages.backToInstanceContent) : '',
|
||||
heading: '',
|
||||
selectedProjects: contentSelection.selectedProjects.value,
|
||||
isInstallingSelected: ['validating', 'reviewing', 'queueing'].includes(
|
||||
contentSelection.state.value,
|
||||
),
|
||||
installProgress: contentSelection.progress.value,
|
||||
clearSelected: contentSelection.clear,
|
||||
installSelected: contentSelection.installSelected,
|
||||
}
|
||||
})
|
||||
|
||||
const platformNames = [
|
||||
'forge',
|
||||
'fabric',
|
||||
'quilt',
|
||||
'neoforge',
|
||||
'liteloader',
|
||||
'rift',
|
||||
'iris',
|
||||
'optifine',
|
||||
]
|
||||
const loaderTypes: Record<number, string> = { 1: 'forge', 4: 'fabric', 5: 'quilt', 6: 'neoforge' }
|
||||
|
||||
function getFilePlatforms(file: CurseForgeFile) {
|
||||
const platforms = file.gameVersions
|
||||
.map((version) => version.toLowerCase().replaceAll(' ', ''))
|
||||
.filter((version) => platformNames.includes(version))
|
||||
|
||||
if (platforms.length === 0 && projectType.value === 'resourcepack') {
|
||||
return ['minecraft']
|
||||
}
|
||||
|
||||
return platforms
|
||||
}
|
||||
|
||||
const projectLoaders = computed(() => {
|
||||
const loaders = new Set<string>()
|
||||
for (const file of files.value) {
|
||||
for (const platform of getFilePlatforms(file)) loaders.add(platform)
|
||||
}
|
||||
for (const index of project.value?.latestFilesIndexes ?? []) {
|
||||
if (index.modLoader && loaderTypes[index.modLoader]) loaders.add(loaderTypes[index.modLoader])
|
||||
}
|
||||
return [...loaders]
|
||||
})
|
||||
|
||||
const minecraftVersions = computed(() => {
|
||||
const versions = new Set<string>()
|
||||
for (const file of files.value) {
|
||||
for (const version of file.gameVersions) {
|
||||
if (/^\d+\.\d+/.test(version)) versions.add(version)
|
||||
}
|
||||
}
|
||||
for (const index of project.value?.latestFilesIndexes ?? []) {
|
||||
if (/^\d+\.\d+/.test(index.gameVersion)) versions.add(index.gameVersion)
|
||||
}
|
||||
return [...versions]
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
if (!project.value) return null
|
||||
const value = project.value
|
||||
return {
|
||||
id: value.id.toString(),
|
||||
slug: value.slug,
|
||||
title: value.name,
|
||||
description: value.summary,
|
||||
body: description.value,
|
||||
project_type: projectType.value,
|
||||
downloads: value.downloadCount,
|
||||
followers: 0,
|
||||
icon_url: getCurseForgeImageUrl(value.logo?.thumbnailUrl),
|
||||
color: null,
|
||||
status: 'approved',
|
||||
categories: value.categories.map((category) => category.slug),
|
||||
additional_categories: [],
|
||||
versions: files.value.map((file) => file.id.toString()),
|
||||
game_versions: minecraftVersions.value,
|
||||
loaders: projectLoaders.value,
|
||||
client_side: 'unknown',
|
||||
server_side: 'unknown',
|
||||
published: value.dateCreated,
|
||||
approved: value.dateReleased || value.dateCreated,
|
||||
updated: value.dateModified,
|
||||
queued: null,
|
||||
license: { id: 'LicenseRef-Unknown', name: 'Unknown', url: null },
|
||||
issues_url: value.links.issuesUrl ?? '',
|
||||
source_url: value.links.sourceUrl ?? '',
|
||||
wiki_url: value.links.wikiUrl ?? '',
|
||||
discord_url: '',
|
||||
site_url: value.links.websiteUrl ?? '',
|
||||
donation_urls: [],
|
||||
links: {
|
||||
website_url: value.links.websiteUrl ?? '',
|
||||
},
|
||||
gallery: value.screenshots.map((screenshot) => ({
|
||||
title: screenshot.title,
|
||||
description: '',
|
||||
created: value.dateModified,
|
||||
url: getCurseForgeImageUrl(screenshot.thumbnailUrl, 960),
|
||||
raw_url: getCurseForgeImageUrl(screenshot.url, 1920),
|
||||
featured: false,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
function browseByProjectFilter(filter: ProjectBrowseFilter, value: string) {
|
||||
if (!data.value?.project_type) return
|
||||
void router.push(createProjectBrowseLocation(data.value.project_type, filter, value))
|
||||
}
|
||||
|
||||
const members = computed(() =>
|
||||
(project.value?.authors ?? []).map((author, index) => ({
|
||||
id: author.id.toString(),
|
||||
role: index === 0 ? 'Owner' : 'Author',
|
||||
is_owner: index === 0,
|
||||
accepted: true,
|
||||
user: {
|
||||
id: author.id.toString(),
|
||||
username: author.name,
|
||||
avatar_url: '',
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
const authorLinks = computed(() =>
|
||||
Object.fromEntries((project.value?.authors ?? []).map((author) => [author.name, author.url])),
|
||||
)
|
||||
|
||||
const versions = computed(() =>
|
||||
files.value.map((file) => {
|
||||
const loaders = getFilePlatforms(file)
|
||||
const gameVersions = file.gameVersions.filter((version) => /^\d+\.\d+/.test(version))
|
||||
return {
|
||||
id: file.id.toString(),
|
||||
project_id: project.value?.id.toString() ?? '',
|
||||
name: file.displayName,
|
||||
version_number: file.displayName,
|
||||
version_type: file.releaseType === 1 ? 'release' : file.releaseType === 2 ? 'beta' : 'alpha',
|
||||
date_published: file.fileDate,
|
||||
downloads: file.downloadCount,
|
||||
game_versions: gameVersions,
|
||||
loaders: loaders.length ? loaders : projectLoaders.value,
|
||||
files: [
|
||||
{
|
||||
filename: file.fileName,
|
||||
size: file.fileLength,
|
||||
url: file.downloadUrl ?? '',
|
||||
primary: true,
|
||||
hashes: {},
|
||||
},
|
||||
],
|
||||
featured: false,
|
||||
status: 'listed',
|
||||
changelog: '',
|
||||
dependencies: [],
|
||||
displayUrlEnding: file.id.toString(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const activeTab = computed(() => {
|
||||
if (route.path.endsWith('/versions')) return 'versions'
|
||||
if (route.path.endsWith('/gallery')) return 'gallery'
|
||||
return 'description'
|
||||
})
|
||||
|
||||
function buildProjectHref(path: string) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(route.query)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) if (item) params.append(key, String(item))
|
||||
} else if (value) {
|
||||
params.append(key, String(value))
|
||||
}
|
||||
}
|
||||
const query = params.toString()
|
||||
return query ? `${path}?${query}` : path
|
||||
}
|
||||
|
||||
const projectDescriptionHref = computed(() =>
|
||||
buildProjectHref(`/project/curseforge/${route.params.id}`),
|
||||
)
|
||||
const projectVersionsHref = computed(() =>
|
||||
buildProjectHref(`/project/curseforge/${route.params.id}/versions`),
|
||||
)
|
||||
const projectGalleryHref = computed(() =>
|
||||
buildProjectHref(`/project/curseforge/${route.params.id}/gallery`),
|
||||
)
|
||||
|
||||
async function loadProject(projectId: number) {
|
||||
const requestVersion = ++projectRequestVersion
|
||||
translationRequestVersion++
|
||||
translationActive.value = false
|
||||
translationLoading.value = false
|
||||
translations.value = {}
|
||||
loading.value = true
|
||||
project.value = null
|
||||
mcmodUrl.value = null
|
||||
description.value = ''
|
||||
files.value = []
|
||||
allLoaders.value = []
|
||||
allGameVersions.value = []
|
||||
|
||||
try {
|
||||
const supplementaryData = Promise.allSettled([
|
||||
getCurseForgeDescription(projectId),
|
||||
getCurseForgeFiles(projectId, { index: 0, pageSize: 50 }),
|
||||
get_loaders(),
|
||||
get_game_versions(),
|
||||
])
|
||||
const projectData = await getCurseForgeProject(projectId)
|
||||
if (requestVersion !== projectRequestVersion) return
|
||||
project.value = projectData
|
||||
breadcrumbs.setName('Project', projectData.name)
|
||||
breadcrumbs.setNameIcon(
|
||||
'Project',
|
||||
projectData.logo?.thumbnailUrl ?? projectData.logo?.url ?? null,
|
||||
)
|
||||
loading.value = false
|
||||
void resolveMcmodUrl(projectData.slug, 'curseforge').then((url) => {
|
||||
if (requestVersion === projectRequestVersion) mcmodUrl.value = url
|
||||
})
|
||||
|
||||
const [projectDescription, projectFiles, loaders, gameVersions] = await supplementaryData
|
||||
if (requestVersion !== projectRequestVersion) return
|
||||
if (projectDescription.status === 'fulfilled') {
|
||||
description.value = projectDescription.value
|
||||
} else {
|
||||
handleError(projectDescription.reason)
|
||||
}
|
||||
if (projectFiles.status === 'fulfilled') {
|
||||
files.value = projectFiles.value.files
|
||||
} else {
|
||||
handleError(projectFiles.reason)
|
||||
}
|
||||
if (loaders.status === 'fulfilled') allLoaders.value = loaders.value
|
||||
if (gameVersions.status === 'fulfilled') allGameVersions.value = gameVersions.value
|
||||
void maybeAutoTranslate()
|
||||
} catch (error) {
|
||||
if (requestVersion === projectRequestVersion) handleError(error)
|
||||
} finally {
|
||||
if (requestVersion === projectRequestVersion) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => Number(route.params.id),
|
||||
(projectId) => {
|
||||
if (Number.isFinite(projectId)) void loadProject(projectId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[fromBrowse, fromInstanceContent, instanceId],
|
||||
async ([fromBrowseEnabled, fromInstanceContentEnabled, preferredInstanceId]) => {
|
||||
if (fromBrowseEnabled || fromInstanceContentEnabled) {
|
||||
await contentSelection.refreshInstances(preferredInstanceId)
|
||||
if (fromInstanceContentEnabled && preferredInstanceId) {
|
||||
const preferredInstance = contentSelection.instances.value.find(
|
||||
(instance) => instance.id === preferredInstanceId,
|
||||
)
|
||||
if (preferredInstance) contentSelection.setTarget(preferredInstance)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function installSelected(fileId: string | null) {
|
||||
if (!project.value) return
|
||||
if (cartEligible.value && !contentSelection.targetInstance.value) {
|
||||
await contentSelection.refreshInstances()
|
||||
browseInstanceSelector.value?.show()
|
||||
return
|
||||
}
|
||||
if (cartEligible.value && contentSelection.targetInstance.value) {
|
||||
if (cartProjectSelected.value) {
|
||||
contentSelection.remove(cartProjectKey.value)
|
||||
return
|
||||
}
|
||||
const target = contentSelection.targetInstance.value
|
||||
const expectedLoader = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 }[target.loader]
|
||||
let resolvedFileId: string | number | null = fileId
|
||||
if (!resolvedFileId && !usesTargetGameVersion(projectType.value)) {
|
||||
resolvedFileId = files.value.find((file) => file.isAvailable)?.id ?? null
|
||||
} else if (!resolvedFileId) {
|
||||
resolvedFileId =
|
||||
project.value.latestFilesIndexes.find(
|
||||
(index) =>
|
||||
index.gameVersion === target.game_version &&
|
||||
(projectType.value !== 'mod' || !expectedLoader || index.modLoader === expectedLoader),
|
||||
)?.fileId ??
|
||||
files.value.find(
|
||||
(file) =>
|
||||
file.gameVersions.includes(target.game_version) &&
|
||||
(projectType.value !== 'mod' || getFilePlatforms(file).includes(target.loader)),
|
||||
)?.id ??
|
||||
null
|
||||
}
|
||||
if (!resolvedFileId) {
|
||||
handleError(new Error(formatMessage(messages.noCompatibleVersion)))
|
||||
return
|
||||
}
|
||||
await contentSelection.add({
|
||||
key: cartProjectKey.value,
|
||||
provider: 'curseforge',
|
||||
projectId: project.value.id.toString(),
|
||||
providerProjectId: project.value.id.toString(),
|
||||
versionId: resolvedFileId.toString(),
|
||||
contentType: projectType.value as 'mod' | 'resourcepack' | 'datapack' | 'shader' | 'world',
|
||||
title: project.value.name,
|
||||
iconUrl: getCurseForgeImageUrl(project.value.logo?.thumbnailUrl),
|
||||
slug: project.value.slug,
|
||||
preferences: {
|
||||
gameVersions: usesTargetGameVersion(projectType.value) ? [target.game_version] : [],
|
||||
loaders: projectType.value === 'mod' ? [target.loader] : [],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (isWorldMap.value) {
|
||||
installing.value = true
|
||||
await installCurseForgeWorld(project.value.id, fileId, instanceId.value, 'ProjectPage', () => {
|
||||
installing.value = false
|
||||
}).catch((error) => {
|
||||
installing.value = false
|
||||
handleError(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
installing.value = true
|
||||
await installCurseForge(
|
||||
project.value.id.toString(),
|
||||
fileId,
|
||||
instanceId.value,
|
||||
'ProjectPage',
|
||||
() => {
|
||||
installing.value = false
|
||||
},
|
||||
(instanceId) => {
|
||||
router.push(`/instance/${instanceId}`)
|
||||
},
|
||||
).catch((error) => {
|
||||
installing.value = false
|
||||
handleError(error)
|
||||
})
|
||||
}
|
||||
|
||||
function translationFailureMessage(error: unknown) {
|
||||
return formatMessage(
|
||||
{
|
||||
'rate-limited': messages.translationRateLimited,
|
||||
authentication: messages.translationAuthenticationFailed,
|
||||
'content-too-long': messages.translationContentTooLong,
|
||||
network: messages.translationNetworkFailed,
|
||||
provider: messages.translationFailed,
|
||||
}[getTranslationErrorKind(error)],
|
||||
)
|
||||
}
|
||||
|
||||
async function translateProject() {
|
||||
if (!data.value || translationLoading.value) return
|
||||
const requestVersion = ++translationRequestVersion
|
||||
const previousTranslationActive = translationActive.value
|
||||
const previousTranslations = translations.value
|
||||
translationLoading.value = true
|
||||
|
||||
try {
|
||||
const settings = await getTranslationSettings()
|
||||
translationMode.value = settings.mode
|
||||
translationStyle.value = settings.style
|
||||
const prepared = prepareDescription(data.value.body ?? '', 'html')
|
||||
const targetLanguage = settings.target_language || i18n.global.locale.value || 'en-US'
|
||||
const baseRequest = {
|
||||
source_language: 'auto',
|
||||
target_language: targetLanguage,
|
||||
context: {
|
||||
title: data.value.title,
|
||||
description: data.value.description,
|
||||
},
|
||||
}
|
||||
const allSegments = [
|
||||
{ id: 'title', text: data.value.title, format: 'plain' },
|
||||
{ id: 'description', text: data.value.description, format: 'plain' },
|
||||
...projectGalleryTranslationSegments(data.value.gallery),
|
||||
...prepared.segments,
|
||||
]
|
||||
|
||||
translationActive.value = true
|
||||
const accumulated = { ...translations.value }
|
||||
await translateContent({ ...baseRequest, segments: allSegments }, (response) => {
|
||||
if (requestVersion !== translationRequestVersion) return
|
||||
for (const segment of response.segments) accumulated[segment.id] = segment.text
|
||||
translations.value = { ...accumulated }
|
||||
})
|
||||
if (requestVersion !== translationRequestVersion) return
|
||||
|
||||
validateTranslatedDescription(prepared, accumulated)
|
||||
} catch (error) {
|
||||
if (requestVersion === translationRequestVersion) {
|
||||
translationActive.value = previousTranslationActive
|
||||
translations.value = previousTranslations
|
||||
addNotification({
|
||||
title: formatMessage(messages.translationFailedTitle),
|
||||
text: translationFailureMessage(error),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (requestVersion === translationRequestVersion) translationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeAutoTranslate() {
|
||||
try {
|
||||
const settings = await getTranslationSettings()
|
||||
if (settings.auto_translate) await translateProject()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTranslation() {
|
||||
if (translationActive.value) {
|
||||
translationRequestVersion++
|
||||
translationActive.value = false
|
||||
translationLoading.value = false
|
||||
return
|
||||
}
|
||||
void translateProject()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-sidebar-section {
|
||||
@apply p-4 flex flex-col gap-2 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid;
|
||||
}
|
||||
</style>
|
||||
46
apps/app-frontend/src/pages/project/Description.vue
Normal file
46
apps/app-frontend/src/pages/project/Description.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<Card>
|
||||
<TranslatedProjectDescription
|
||||
:description="project.body"
|
||||
:active="translationActive"
|
||||
:translations="translations"
|
||||
:mode="translationMode"
|
||||
:style="translationStyle"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Card } from '@modrinth/ui'
|
||||
|
||||
import TranslatedProjectDescription from '@/components/ui/TranslatedProjectDescription.vue'
|
||||
|
||||
defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
translationActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
translations: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
translationMode: {
|
||||
type: String,
|
||||
default: 'bilingual',
|
||||
},
|
||||
translationStyle: {
|
||||
type: String,
|
||||
default: 'weakened',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Description',
|
||||
}
|
||||
</script>
|
||||
372
apps/app-frontend/src/pages/project/Gallery.vue
Normal file
372
apps/app-frontend/src/pages/project/Gallery.vue
Normal file
@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarIcon,
|
||||
ContractIcon,
|
||||
ExpandIcon,
|
||||
ExternalIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
commonProjectSettingsMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
type ProjectGalleryCaptionField,
|
||||
projectGalleryTranslationSegmentId,
|
||||
visibleProjectGallery,
|
||||
} from '@/helpers/project-gallery'
|
||||
import type { TranslationMode, TranslationStyle } from '@/helpers/translation'
|
||||
|
||||
interface GalleryImage {
|
||||
url: string
|
||||
raw_url?: string
|
||||
title?: string
|
||||
description?: string
|
||||
created: string
|
||||
}
|
||||
|
||||
interface GalleryEntry {
|
||||
image: GalleryImage
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: {
|
||||
id: string
|
||||
gallery?: GalleryImage[]
|
||||
}
|
||||
translationActive?: boolean
|
||||
translations?: Record<string, string>
|
||||
translationMode?: TranslationMode
|
||||
translationStyle?: TranslationStyle
|
||||
}>(),
|
||||
{
|
||||
translationActive: false,
|
||||
translations: () => ({}),
|
||||
translationMode: 'bilingual',
|
||||
translationStyle: 'weakened',
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatDate = useFormatDateTime({
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
const screenshotMessages = defineMessages({
|
||||
zoomIn: {
|
||||
id: 'app.instance.screenshots.zoom-in',
|
||||
defaultMessage: 'View at full size',
|
||||
},
|
||||
zoomOut: {
|
||||
id: 'app.instance.screenshots.zoom-out',
|
||||
defaultMessage: 'Fit to window',
|
||||
},
|
||||
})
|
||||
|
||||
const filteredGallery = computed<GalleryEntry[]>(() => visibleProjectGallery(props.project.gallery))
|
||||
const selectedGalleryItem = ref<GalleryEntry | null>(null)
|
||||
const zoomedIn = ref(false)
|
||||
const viewerModal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const viewerTitle = computed(() => {
|
||||
if (!selectedGalleryItem.value) return formatMessage(commonProjectSettingsMessages.gallery)
|
||||
return (
|
||||
galleryText(selectedGalleryItem.value, 'title') ||
|
||||
formatMessage(commonProjectSettingsMessages.gallery)
|
||||
)
|
||||
})
|
||||
|
||||
const viewerImageUrl = computed(() => {
|
||||
if (!selectedGalleryItem.value) return ''
|
||||
return zoomedIn.value
|
||||
? (selectedGalleryItem.value.image.raw_url ?? selectedGalleryItem.value.image.url)
|
||||
: selectedGalleryItem.value.image.url
|
||||
})
|
||||
|
||||
const translationClass = computed(() => [
|
||||
'gallery-translation',
|
||||
`gallery-translation--${props.translationStyle}`,
|
||||
])
|
||||
|
||||
function translationFor(
|
||||
entry: GalleryEntry,
|
||||
field: ProjectGalleryCaptionField,
|
||||
): string | undefined {
|
||||
if (!props.translationActive) return undefined
|
||||
return (
|
||||
props.translations[projectGalleryTranslationSegmentId(entry.index, field)]?.trim() || undefined
|
||||
)
|
||||
}
|
||||
|
||||
function galleryText(entry: GalleryEntry, field: ProjectGalleryCaptionField): string {
|
||||
const original = entry.image[field] ?? ''
|
||||
const translated = translationFor(entry, field)
|
||||
return props.translationMode === 'translation-only' && translated ? translated : original
|
||||
}
|
||||
|
||||
function showBilingualTranslation(entry: GalleryEntry, field: ProjectGalleryCaptionField): boolean {
|
||||
return props.translationMode === 'bilingual' && !!translationFor(entry, field)
|
||||
}
|
||||
|
||||
function imageAlt(entry: GalleryEntry): string {
|
||||
return galleryText(entry, 'title') || formatMessage(commonProjectSettingsMessages.gallery)
|
||||
}
|
||||
|
||||
function viewImage(entry: GalleryEntry) {
|
||||
selectedGalleryItem.value = entry
|
||||
zoomedIn.value = false
|
||||
viewerModal.value?.show()
|
||||
|
||||
trackEvent('GalleryImageExpand', {
|
||||
project_id: props.project.id,
|
||||
url: entry.image.url,
|
||||
})
|
||||
}
|
||||
|
||||
function changeImage(offset: number) {
|
||||
if (!selectedGalleryItem.value || filteredGallery.value.length < 2) return
|
||||
const currentIndex = filteredGallery.value.findIndex(
|
||||
(entry) => entry.index === selectedGalleryItem.value?.index,
|
||||
)
|
||||
const nextIndex =
|
||||
(currentIndex + offset + filteredGallery.value.length) % filteredGallery.value.length
|
||||
selectedGalleryItem.value = filteredGallery.value[nextIndex]
|
||||
zoomedIn.value = false
|
||||
|
||||
trackEvent(offset > 0 ? 'GalleryImageNext' : 'GalleryImagePrevious', {
|
||||
project_id: props.project.id,
|
||||
url: selectedGalleryItem.value.image.url,
|
||||
})
|
||||
}
|
||||
|
||||
function handleViewerHide() {
|
||||
selectedGalleryItem.value = null
|
||||
zoomedIn.value = false
|
||||
}
|
||||
|
||||
function keyListener(event: KeyboardEvent) {
|
||||
if (!selectedGalleryItem.value) return
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
changeImage(-1)
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
changeImage(1)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', keyListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', keyListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-4">
|
||||
<article
|
||||
v-for="entry in filteredGallery"
|
||||
:key="entry.image.url"
|
||||
class="group overflow-hidden rounded-2xl border border-solid border-surface-5 bg-surface-2 transition-colors hover:border-brand"
|
||||
>
|
||||
<button
|
||||
class="relative block aspect-video w-full cursor-zoom-in overflow-hidden border-0 bg-surface-1 p-0"
|
||||
:aria-label="formatMessage(commonMessages.viewLabel)"
|
||||
@click="viewImage(entry)"
|
||||
>
|
||||
<img
|
||||
:src="entry.image.url"
|
||||
:alt="imageAlt(entry)"
|
||||
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 p-3">
|
||||
<div
|
||||
v-if="galleryText(entry, 'title') || galleryText(entry, 'description')"
|
||||
class="min-w-0"
|
||||
>
|
||||
<h3
|
||||
v-if="galleryText(entry, 'title')"
|
||||
class="m-0 break-words font-semibold text-contrast"
|
||||
>
|
||||
{{ galleryText(entry, 'title') }}
|
||||
</h3>
|
||||
<p v-if="showBilingualTranslation(entry, 'title')" :class="translationClass">
|
||||
{{ translationFor(entry, 'title') }}
|
||||
</p>
|
||||
<p v-if="galleryText(entry, 'description')" class="mb-0 mt-1 break-words text-secondary">
|
||||
{{ galleryText(entry, 'description') }}
|
||||
</p>
|
||||
<p v-if="showBilingualTranslation(entry, 'description')" :class="translationClass">
|
||||
{{ translationFor(entry, 'description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-auto flex items-center gap-2 text-sm text-secondary">
|
||||
<CalendarIcon class="size-4 shrink-0" aria-hidden="true" />
|
||||
{{ formatDate(new Date(entry.image.created)) }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<NewModal
|
||||
ref="viewerModal"
|
||||
:max-width="'92rem'"
|
||||
:width="'calc(100vw - 4rem)'"
|
||||
:no-padding="true"
|
||||
:header="viewerTitle"
|
||||
:on-hide="handleViewerHide"
|
||||
>
|
||||
<div
|
||||
v-if="selectedGalleryItem"
|
||||
class="relative flex w-full min-h-64 max-h-[calc(100vh-13rem)] items-center justify-center overflow-hidden bg-surface-1"
|
||||
>
|
||||
<div
|
||||
class="min-w-0 flex max-h-[calc(100vh-13rem)] w-full items-center justify-center overflow-auto p-4"
|
||||
>
|
||||
<img
|
||||
:src="viewerImageUrl"
|
||||
:alt="imageAlt(selectedGalleryItem)"
|
||||
:class="
|
||||
zoomedIn
|
||||
? 'max-w-none cursor-zoom-out'
|
||||
: 'max-h-[calc(100vh-15rem)] max-w-full cursor-zoom-in'
|
||||
"
|
||||
@click="zoomedIn = !zoomedIn"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled v-if="filteredGallery.length > 1" circular>
|
||||
<button
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2"
|
||||
:aria-label="formatMessage(commonMessages.backButton)"
|
||||
@click="changeImage(-1)"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="filteredGallery.length > 1" circular>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
:aria-label="formatMessage(commonMessages.nextButton)"
|
||||
@click="changeImage(1)"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div v-if="selectedGalleryItem" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="min-w-0 text-sm text-secondary">
|
||||
<p v-if="galleryText(selectedGalleryItem, 'description')" class="m-0 break-words">
|
||||
{{ galleryText(selectedGalleryItem, 'description') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showBilingualTranslation(selectedGalleryItem, 'description')"
|
||||
:class="translationClass"
|
||||
>
|
||||
{{ translationFor(selectedGalleryItem, 'description') }}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<CalendarIcon class="size-4 shrink-0" aria-hidden="true" />
|
||||
{{ formatDate(new Date(selectedGalleryItem.image.created)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="zoomedIn = !zoomedIn">
|
||||
<ContractIcon v-if="zoomedIn" />
|
||||
<ExpandIcon v-else />
|
||||
{{ formatMessage(zoomedIn ? screenshotMessages.zoomOut : screenshotMessages.zoomIn) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:href="selectedGalleryItem.image.raw_url ?? selectedGalleryItem.image.url"
|
||||
>
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gallery-translation {
|
||||
margin: 0.25rem 0 0;
|
||||
animation: translation-float-in 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.gallery-translation--weakened {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.gallery-translation--blur {
|
||||
filter: blur(4px);
|
||||
opacity: 0.75;
|
||||
transition:
|
||||
filter 0.1s ease-in-out,
|
||||
opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.gallery-translation--blur:hover {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gallery-translation--blockquote {
|
||||
padding: 4px 0 4px 8px;
|
||||
border-left: 4px solid var(--color-brand);
|
||||
}
|
||||
|
||||
.gallery-translation--dashed-line {
|
||||
text-decoration: underline dashed var(--color-brand);
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
|
||||
.gallery-translation--border {
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gallery-translation--text-color {
|
||||
color: oklch(0.693 0.17 162.48);
|
||||
}
|
||||
|
||||
.gallery-translation--background {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
|
||||
@keyframes translation-float-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1554
apps/app-frontend/src/pages/project/Index.vue
Normal file
1554
apps/app-frontend/src/pages/project/Index.vue
Normal file
File diff suppressed because it is too large
Load Diff
332
apps/app-frontend/src/pages/project/McArchive.vue
Normal file
332
apps/app-frontend/src/pages/project/McArchive.vue
Normal file
@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, ExternalIcon, FileArchiveIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Card,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import {
|
||||
import_mcarchive_content,
|
||||
install_mcarchive_content,
|
||||
type McArchiveContentInstallRequest,
|
||||
} from '@/helpers/instance'
|
||||
import {
|
||||
getMcArchiveModBySlug,
|
||||
type McArchiveFile,
|
||||
type McArchiveModVersion,
|
||||
} from '@/helpers/mcarchive'
|
||||
import { injectContentSelection } from '@/providers/content-selection'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const contentSelection = injectContentSelection()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const instanceSelector = ref<InstanceType<typeof BrowseInstanceSelector>>()
|
||||
const project = ref<Awaited<ReturnType<typeof getMcArchiveModBySlug>> | null>(null)
|
||||
const loading = ref(false)
|
||||
const busyFileId = ref<string | null>(null)
|
||||
const manualDownload = ref<{
|
||||
request: McArchiveContentInstallRequest
|
||||
fileName: string
|
||||
pageUrl: string | null
|
||||
expectedSha256: string | null
|
||||
} | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'app.project.mcarchive.loading',
|
||||
defaultMessage: 'Loading MCArchive project…',
|
||||
},
|
||||
versions: {
|
||||
id: 'app.project.mcarchive.versions',
|
||||
defaultMessage: 'Versions',
|
||||
},
|
||||
chooseInstance: {
|
||||
id: 'app.browse.choose-instance',
|
||||
defaultMessage: 'Choose instance',
|
||||
},
|
||||
install: {
|
||||
id: 'app.project.mcarchive.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
installed: {
|
||||
id: 'app.project.mcarchive.installed',
|
||||
defaultMessage: 'Installed {fileName}',
|
||||
},
|
||||
manualTitle: {
|
||||
id: 'app.project.mcarchive.manual.title',
|
||||
defaultMessage: 'Manual download required',
|
||||
},
|
||||
manualDescription: {
|
||||
id: 'app.project.mcarchive.manual.description',
|
||||
defaultMessage:
|
||||
'{fileName} has no verifiable direct download. Download it from the source page, then import the downloaded file.',
|
||||
},
|
||||
openSource: {
|
||||
id: 'app.project.mcarchive.manual.open-source',
|
||||
defaultMessage: 'Open source page',
|
||||
},
|
||||
importFile: {
|
||||
id: 'app.project.mcarchive.manual.import-file',
|
||||
defaultMessage: 'Import downloaded file',
|
||||
},
|
||||
pickFile: {
|
||||
id: 'app.project.mcarchive.manual.pick-file',
|
||||
defaultMessage: 'Choose downloaded archive',
|
||||
},
|
||||
archiveFilter: {
|
||||
id: 'app.project.mcarchive.manual.archive-filter',
|
||||
defaultMessage: 'Minecraft archives',
|
||||
},
|
||||
source: {
|
||||
id: 'app.project.mcarchive.source',
|
||||
defaultMessage: 'MCArchive source',
|
||||
},
|
||||
noFiles: {
|
||||
id: 'app.project.mcarchive.no-files',
|
||||
defaultMessage: 'No files are available for this release.',
|
||||
},
|
||||
sha256: {
|
||||
id: 'app.project.mcarchive.sha256',
|
||||
defaultMessage: 'SHA-256',
|
||||
},
|
||||
})
|
||||
|
||||
const selectedInstance = computed(() => contentSelection.targetInstance.value)
|
||||
|
||||
function createRequest(version: McArchiveModVersion, file: McArchiveFile) {
|
||||
if (!project.value) throw new Error('MCArchive project is unavailable')
|
||||
return {
|
||||
projectId: project.value.uuid,
|
||||
projectSlug: project.value.slug,
|
||||
versionId: version.uuid,
|
||||
fileId: file.uuid,
|
||||
projectType: 'mod',
|
||||
} satisfies McArchiveContentInstallRequest
|
||||
}
|
||||
|
||||
async function install(version: McArchiveModVersion, file: McArchiveFile) {
|
||||
if (!selectedInstance.value) {
|
||||
await contentSelection.refreshInstances(
|
||||
typeof route.query.i === 'string' ? route.query.i : undefined,
|
||||
)
|
||||
instanceSelector.value?.show()
|
||||
return
|
||||
}
|
||||
busyFileId.value = file.uuid
|
||||
try {
|
||||
const request = createRequest(version, file)
|
||||
const result = await install_mcarchive_content(selectedInstance.value.id, request)
|
||||
if (result.state === 'manual_download') {
|
||||
manualDownload.value = {
|
||||
request,
|
||||
fileName: result.fileName,
|
||||
pageUrl: result.pageUrl,
|
||||
expectedSha256: result.expectedSha256,
|
||||
}
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.installed, { fileName: file.name }),
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busyFileId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function importDownloadedFile() {
|
||||
if (!manualDownload.value || !selectedInstance.value) return
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
title: formatMessage(messages.pickFile),
|
||||
filters: [{ name: formatMessage(messages.archiveFilter), extensions: ['jar', 'zip'] }],
|
||||
})
|
||||
if (!path || Array.isArray(path)) return
|
||||
busyFileId.value = manualDownload.value.request.fileId
|
||||
try {
|
||||
const result = await import_mcarchive_content(
|
||||
selectedInstance.value.id,
|
||||
manualDownload.value.request,
|
||||
path,
|
||||
)
|
||||
if (result.state === 'manual_download') {
|
||||
manualDownload.value = {
|
||||
request: manualDownload.value.request,
|
||||
fileName: result.fileName,
|
||||
pageUrl: result.pageUrl,
|
||||
expectedSha256: result.expectedSha256,
|
||||
}
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.installed, { fileName: manualDownload.value.fileName }),
|
||||
})
|
||||
manualDownload.value = null
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busyFileId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function selectInstance(instance: (typeof contentSelection.instances.value)[number]) {
|
||||
contentSelection.setTarget(instance)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
async (slug) => {
|
||||
if (typeof slug !== 'string' || !slug) return
|
||||
loading.value = true
|
||||
project.value = null
|
||||
manualDownload.value = null
|
||||
try {
|
||||
project.value = await getMcArchiveModBySlug(slug)
|
||||
breadcrumbs.setName('Project', project.value.name)
|
||||
breadcrumbs.setNameIcon('Project', project.value.icon_url ?? project.value.iconUrl ?? null)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
void contentSelection
|
||||
.refreshInstances(typeof route.query.i === 'string' ? route.query.i : undefined)
|
||||
.then(() => {
|
||||
if (typeof route.query.i !== 'string' || contentSelection.targetInstance.value) return
|
||||
const requested = contentSelection.instances.value.find(
|
||||
(instance) => instance.id === route.query.i,
|
||||
)
|
||||
if (requested) contentSelection.setTarget(requested)
|
||||
})
|
||||
.catch(handleError)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="flex min-h-64 items-center justify-center gap-3 p-6 text-secondary">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div v-else-if="project" class="mx-auto flex w-full max-w-5xl flex-col gap-5 p-6">
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">{{ project.name }}</h1>
|
||||
<p v-if="project.summary" class="mb-0 mt-2 text-secondary">{{ project.summary }}</p>
|
||||
</div>
|
||||
<ButtonStyled size="standard" type="standard">
|
||||
<button class="flex min-w-0 items-center gap-2" @click="instanceSelector?.show()">
|
||||
<InstanceIcon
|
||||
v-if="selectedInstance"
|
||||
class="shrink-0"
|
||||
size="1.25rem"
|
||||
:icon-path="selectedInstance.icon_path"
|
||||
:instance-id="selectedInstance.id"
|
||||
:loader="selectedInstance.loader"
|
||||
/>
|
||||
<span class="max-w-48 truncate font-medium">
|
||||
{{ selectedInstance?.name ?? formatMessage(messages.chooseInstance) }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p v-if="project.description" class="m-0 whitespace-pre-wrap text-sm text-secondary">
|
||||
{{ project.description }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Card v-if="manualDownload" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.manualTitle) }}
|
||||
</h2>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.manualDescription, { fileName: manualDownload.fileName }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled v-if="manualDownload.pageUrl" type="outlined">
|
||||
<button @click="openUrl(manualDownload!.pageUrl!)">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.openSource) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="busyFileId !== null" @click="importDownloadedFile">
|
||||
<FileArchiveIcon />
|
||||
{{ formatMessage(messages.importFile) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.versions) }}
|
||||
</h2>
|
||||
<Card v-for="version in project.modVersions" :key="version.uuid" class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-semibold text-contrast">{{ version.name }}</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ version.gameVersions.map((gameVersion) => gameVersion.name).join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="version.files.length"
|
||||
class="divide-y divide-surface-4 border-y border-surface-4"
|
||||
>
|
||||
<div
|
||||
v-for="file in version.files"
|
||||
:key="file.uuid"
|
||||
class="flex min-w-0 items-center gap-3 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-contrast">{{ file.name }}</div>
|
||||
<div v-if="file.sha256" class="truncate text-xs text-secondary">
|
||||
{{ formatMessage(messages.sha256) }}: {{ file.sha256 }}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled size="standard">
|
||||
<button :disabled="busyFileId !== null" @click="install(version, file)">
|
||||
<SpinnerIcon v-if="busyFileId === file.uuid" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{ formatMessage(messages.install) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="m-0 text-sm text-secondary">{{ formatMessage(messages.noFiles) }}</p>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
<div v-else class="p-6">
|
||||
<Card class="text-secondary">{{ formatMessage(messages.loading) }}</Card>
|
||||
</div>
|
||||
<BrowseInstanceSelector
|
||||
ref="instanceSelector"
|
||||
:instances="contentSelection.instances.value"
|
||||
:selected-instance="selectedInstance"
|
||||
:selected-count="0"
|
||||
:install-current="async () => true"
|
||||
:clear-current="() => {}"
|
||||
@select="selectInstance"
|
||||
/>
|
||||
</template>
|
||||
257
apps/app-frontend/src/pages/project/PlanetMinecraft.vue
Normal file
257
apps/app-frontend/src/pages/project/PlanetMinecraft.vue
Normal file
@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, ExternalIcon, FileArchiveIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Card,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import {
|
||||
import_planet_minecraft_content,
|
||||
install_planet_minecraft_content,
|
||||
type PlanetMinecraftContentInstallRequest,
|
||||
} from '@/helpers/instance'
|
||||
import { getPlanetMinecraftProject, type PlanetMinecraftVersion } from '@/helpers/planet-minecraft'
|
||||
import { injectContentSelection } from '@/providers/content-selection'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const contentSelection = injectContentSelection()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const instanceSelector = ref<InstanceType<typeof BrowseInstanceSelector>>()
|
||||
const project = ref<Awaited<ReturnType<typeof getPlanetMinecraftProject>> | null>(null)
|
||||
const loading = ref(false)
|
||||
const busyVersionId = ref<string | null>(null)
|
||||
const manualDownload = ref<{
|
||||
request: PlanetMinecraftContentInstallRequest
|
||||
pageUrl: string
|
||||
fileName: string | null
|
||||
} | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'app.project.planet-minecraft.loading',
|
||||
defaultMessage: 'Loading Planet Minecraft project…',
|
||||
},
|
||||
chooseInstance: { id: 'app.browse.choose-instance', defaultMessage: 'Choose instance' },
|
||||
versions: { id: 'app.project.planet-minecraft.versions', defaultMessage: 'Downloads' },
|
||||
install: { id: 'app.project.planet-minecraft.install', defaultMessage: 'Install' },
|
||||
manualTitle: {
|
||||
id: 'app.project.planet-minecraft.manual.title',
|
||||
defaultMessage: 'Manual download required',
|
||||
},
|
||||
manualDescription: {
|
||||
id: 'app.project.planet-minecraft.manual.description',
|
||||
defaultMessage: '{fileName} must be downloaded from Planet Minecraft, then imported here.',
|
||||
},
|
||||
unknownFile: {
|
||||
id: 'app.project.planet-minecraft.unknown-file',
|
||||
defaultMessage: 'Downloaded file',
|
||||
},
|
||||
openSource: {
|
||||
id: 'app.project.planet-minecraft.manual.open-source',
|
||||
defaultMessage: 'Open source page',
|
||||
},
|
||||
importFile: {
|
||||
id: 'app.project.planet-minecraft.manual.import-file',
|
||||
defaultMessage: 'Import downloaded file',
|
||||
},
|
||||
pickFile: {
|
||||
id: 'app.project.planet-minecraft.manual.pick-file',
|
||||
defaultMessage: 'Choose downloaded archive',
|
||||
},
|
||||
archiveFilter: {
|
||||
id: 'app.project.planet-minecraft.manual.archive-filter',
|
||||
defaultMessage: 'Minecraft archives',
|
||||
},
|
||||
installed: {
|
||||
id: 'app.project.planet-minecraft.installed',
|
||||
defaultMessage: 'Installed {fileName}',
|
||||
},
|
||||
})
|
||||
|
||||
const selectedInstance = computed(() => contentSelection.targetInstance.value)
|
||||
|
||||
function createRequest(version: PlanetMinecraftVersion): PlanetMinecraftContentInstallRequest {
|
||||
if (!project.value) throw new Error('Planet Minecraft project is unavailable')
|
||||
return { projectId: project.value.id, versionId: version.id, projectType: 'mod' }
|
||||
}
|
||||
|
||||
async function install(version: PlanetMinecraftVersion) {
|
||||
if (!selectedInstance.value) {
|
||||
await contentSelection.refreshInstances(
|
||||
typeof route.query.i === 'string' ? route.query.i : undefined,
|
||||
)
|
||||
instanceSelector.value?.show()
|
||||
return
|
||||
}
|
||||
busyVersionId.value = version.id
|
||||
try {
|
||||
const request = createRequest(version)
|
||||
const result = await install_planet_minecraft_content(selectedInstance.value.id, request)
|
||||
if (result.state === 'manual_download') {
|
||||
manualDownload.value = { request, pageUrl: result.pageUrl, fileName: result.fileName }
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.installed, {
|
||||
fileName: version.download.fileName ?? version.name,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busyVersionId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function importDownloadedFile() {
|
||||
if (!manualDownload.value || !selectedInstance.value) return
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
title: formatMessage(messages.pickFile),
|
||||
filters: [{ name: formatMessage(messages.archiveFilter), extensions: ['jar', 'zip'] }],
|
||||
})
|
||||
if (!path || Array.isArray(path)) return
|
||||
busyVersionId.value = manualDownload.value.request.versionId
|
||||
try {
|
||||
const result = await import_planet_minecraft_content(
|
||||
selectedInstance.value.id,
|
||||
manualDownload.value.request,
|
||||
path,
|
||||
)
|
||||
const fileName =
|
||||
manualDownload.value.fileName ?? (result.state === 'installed' ? result.relativePath : '')
|
||||
addNotification({ type: 'success', title: formatMessage(messages.installed, { fileName }) })
|
||||
manualDownload.value = null
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busyVersionId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (id) => {
|
||||
if (typeof id !== 'string' || !id) return
|
||||
loading.value = true
|
||||
try {
|
||||
project.value = await getPlanetMinecraftProject(id)
|
||||
breadcrumbs.setName('Project', project.value.title)
|
||||
breadcrumbs.setNameIcon('Project', project.value.icon_url ?? project.value.iconUrl ?? null)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
void contentSelection
|
||||
.refreshInstances(typeof route.query.i === 'string' ? route.query.i : undefined)
|
||||
.then(() => {
|
||||
if (typeof route.query.i !== 'string' || contentSelection.targetInstance.value) return
|
||||
const requested = contentSelection.instances.value.find(
|
||||
(instance) => instance.id === route.query.i,
|
||||
)
|
||||
if (requested) contentSelection.setTarget(requested)
|
||||
})
|
||||
.catch(handleError)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="flex min-h-64 items-center justify-center gap-3 p-6 text-secondary">
|
||||
<SpinnerIcon class="animate-spin" /> {{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div v-else-if="project" class="mx-auto flex w-full max-w-5xl flex-col gap-5 p-6">
|
||||
<section class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">{{ project.title }}</h1>
|
||||
<p v-if="project.summary" class="mb-0 mt-2 text-secondary">{{ project.summary }}</p>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
><button class="flex items-center gap-2" @click="instanceSelector?.show()">
|
||||
<InstanceIcon
|
||||
v-if="selectedInstance"
|
||||
size="1.25rem"
|
||||
:icon-path="selectedInstance.icon_path"
|
||||
:instance-id="selectedInstance.id"
|
||||
:loader="selectedInstance.loader"
|
||||
/>
|
||||
{{ selectedInstance?.name ?? formatMessage(messages.chooseInstance) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</section>
|
||||
<Card v-if="manualDownload" class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.manualTitle) }}
|
||||
</h2>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(messages.manualDescription, {
|
||||
fileName: manualDownload.fileName ?? formatMessage(messages.unknownFile),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="outlined"
|
||||
><button @click="openUrl(manualDownload!.pageUrl)">
|
||||
<ExternalIcon /> {{ formatMessage(messages.openSource) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled
|
||||
><button :disabled="busyVersionId !== null" @click="importDownloadedFile">
|
||||
<FileArchiveIcon /> {{ formatMessage(messages.importFile) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
</Card>
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.versions) }}
|
||||
</h2>
|
||||
<Card
|
||||
v-for="version in project.versions"
|
||||
:key="version.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-contrast">{{ version.name }}</div>
|
||||
<div class="text-sm text-secondary">{{ version.gameVersions.join(', ') }}</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
><button :disabled="busyVersionId !== null" @click="install(version)">
|
||||
<SpinnerIcon v-if="busyVersionId === version.id" class="animate-spin" /><DownloadIcon
|
||||
v-else
|
||||
/>
|
||||
{{ formatMessage(messages.install) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
<BrowseInstanceSelector
|
||||
ref="instanceSelector"
|
||||
:instances="contentSelection.instances.value"
|
||||
:selected-instance="selectedInstance"
|
||||
:selected-count="0"
|
||||
:install-current="async () => true"
|
||||
:clear-current="() => {}"
|
||||
@select="contentSelection.setTarget"
|
||||
/>
|
||||
</template>
|
||||
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<FloatingActionBar
|
||||
v-if="snapshot"
|
||||
:shown="true"
|
||||
aria-label="Return to instance upgrade"
|
||||
hide-when-modal-open
|
||||
>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button @click="returnToUpgrade">
|
||||
<ArrowLeftIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.returnAction) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, FloatingActionBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { peekUpgradeFlow } from '@/helpers/upgrade-return-state'
|
||||
|
||||
const messages = defineMessages({
|
||||
returnAction: { id: 'instance.upgrade.return', defaultMessage: 'Return to instance upgrade' },
|
||||
})
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const snapshot = computed(() => peekUpgradeFlow())
|
||||
async function returnToUpgrade() {
|
||||
if (snapshot.value) await router.push(snapshot.value.returnFullPath)
|
||||
}
|
||||
</script>
|
||||
219
apps/app-frontend/src/pages/project/Version.vue
Normal file
219
apps/app-frontend/src/pages/project/Version.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<router-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
|
||||
>
|
||||
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
|
||||
</router-link>
|
||||
<VersionPage
|
||||
v-if="version"
|
||||
:version="version"
|
||||
:enrichment="enrichment"
|
||||
:enrichment-loading="enrichmentLoading"
|
||||
:members="members"
|
||||
:dependency-link-creator="createDependencyLink"
|
||||
>
|
||||
<template #headerActions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="installing || (installed && installedVersion === version.id)"
|
||||
@click="() => version && install(version.id)"
|
||||
>
|
||||
<DownloadIcon v-if="!installed" />
|
||||
<SwapIcon v-else-if="installedVersion !== version.id" />
|
||||
<CheckIcon v-else />
|
||||
{{
|
||||
installing
|
||||
? formatMessage(messages.installing)
|
||||
: installed && installedVersion === version.id
|
||||
? formatMessage(commonMessages.installedLabel)
|
||||
: installed
|
||||
? formatMessage(commonMessages.switchToVersionButton)
|
||||
: formatMessage(commonMessages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined" circular>
|
||||
<OverflowMenu
|
||||
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
|
||||
:options="[
|
||||
{
|
||||
id: 'open-in-browser',
|
||||
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
|
||||
external: true,
|
||||
},
|
||||
]"
|
||||
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #supplementaryResourceActions="{ file }">
|
||||
<ButtonStyled>
|
||||
<a :href="file.url" :download="file.filename" target="_blank">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.downloadInBrowser) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</VersionPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ReportIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
type DependencyContext,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
VersionPage,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons'
|
||||
import { get_project_many, get_version_many } from '@/helpers/cache.js'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
allVersions: {
|
||||
id: 'app.project.version.all-versions',
|
||||
defaultMessage: 'All versions',
|
||||
},
|
||||
installing: {
|
||||
id: 'app.project.version.installing',
|
||||
defaultMessage: 'Installing',
|
||||
},
|
||||
downloadInBrowser: {
|
||||
id: 'app.project.version.download-in-browser',
|
||||
defaultMessage: 'Download in browser',
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v2.Project
|
||||
versions: Labrinth.Versions.v3.Version[]
|
||||
members: Labrinth.Projects.v3.TeamMember[]
|
||||
install: (version: string | null) => void
|
||||
installed: boolean
|
||||
installing: boolean
|
||||
installedVersion: string
|
||||
}>()
|
||||
|
||||
const version = ref(props.versions.find((version) => version.id === route.params.version))
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
|
||||
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
|
||||
const enrichmentLoading = ref(false)
|
||||
|
||||
function buildProjectHref(path: string): string {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, val] of Object.entries(route.query)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) {
|
||||
if (v != null) params.append(key, v)
|
||||
}
|
||||
} else if (val) {
|
||||
params.append(key, String(val))
|
||||
}
|
||||
}
|
||||
const qs = params.toString()
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
function createDependencyLink(context: DependencyContext): string | undefined {
|
||||
if (context.version) {
|
||||
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
|
||||
}
|
||||
if (context.project) {
|
||||
return buildProjectHref(`/project/${context.project.id}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function refreshEnrichment() {
|
||||
if (!version.value) return
|
||||
|
||||
const projectIds = new Set<string>()
|
||||
const versionIds = new Set<string>()
|
||||
for (const dependency of version.value.dependencies ?? []) {
|
||||
if (dependency.project_id) {
|
||||
projectIds.add(dependency.project_id)
|
||||
}
|
||||
if (dependency.version_id) {
|
||||
versionIds.add(dependency.version_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (projectIds.size === 0 && versionIds.size === 0) {
|
||||
enrichment.value = { projects: [], versions: [] }
|
||||
return
|
||||
}
|
||||
|
||||
enrichmentLoading.value = true
|
||||
try {
|
||||
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
|
||||
for (const dependencyVersion of versionResults ?? []) {
|
||||
if (dependencyVersion.project_id) {
|
||||
projectIds.add(dependencyVersion.project_id)
|
||||
}
|
||||
}
|
||||
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
|
||||
enrichment.value = {
|
||||
projects: projectResults ?? [],
|
||||
versions: versionResults ?? [],
|
||||
}
|
||||
} finally {
|
||||
enrichmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.versions,
|
||||
async () => {
|
||||
if (route.params.version) {
|
||||
version.value = props.versions.find((v) => v.id === route.params.version)
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
await refreshEnrichment()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await refreshEnrichment()
|
||||
</script>
|
||||
221
apps/app-frontend/src/pages/project/Versions.vue
Normal file
221
apps/app-frontend/src/pages/project/Versions.vue
Normal file
@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div>
|
||||
<ProjectPageVersions
|
||||
:loaders="loaders"
|
||||
:game-versions="gameVersions"
|
||||
:versions="versions"
|
||||
:project="project"
|
||||
:show-environment-column="themeStore.featureFlags.show_version_environment_column"
|
||||
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
|
||||
>
|
||||
<template #actions="{ version }">
|
||||
<ButtonStyled
|
||||
circular
|
||||
type="transparent"
|
||||
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!installed
|
||||
? formatMessage(commonMessages.installButton)
|
||||
: version.id !== installedVersion
|
||||
? formatMessage(commonMessages.switchToVersionButton)
|
||||
: formatMessage(messages.alreadyInstalled)
|
||||
"
|
||||
:disabled="installing || (installed && version.id === installedVersion)"
|
||||
@click.stop="() => install(version.id)"
|
||||
>
|
||||
<DownloadIcon v-if="!installed" />
|
||||
<SwapIcon v-else-if="installed && version.id !== installedVersion" />
|
||||
<CheckIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<!-- 开服功能暂有问题,隐藏该按钮
|
||||
<ButtonStyled v-if="serverCapable && startServer" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.startServer)"
|
||||
@click.stop="() => startServer(version)"
|
||||
>
|
||||
<ServerIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
-->
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
v-if="false"
|
||||
:options="[
|
||||
{
|
||||
id: 'install-elsewhere',
|
||||
action: () => {},
|
||||
shown: false && !!instance,
|
||||
color: 'primary',
|
||||
hoverFilled: true,
|
||||
},
|
||||
{
|
||||
id: 'open-in-browser',
|
||||
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
|
||||
},
|
||||
]"
|
||||
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #install-elsewhere>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.addToAnotherInstance) }}
|
||||
</template>
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
<a
|
||||
v-else
|
||||
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
|
||||
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ProjectPageVersions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ServerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
ProjectPageVersions,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
|
||||
const messages = defineMessages({
|
||||
alreadyInstalled: {
|
||||
id: 'app.project.versions.already-installed',
|
||||
defaultMessage: 'Already installed',
|
||||
},
|
||||
addToAnotherInstance: {
|
||||
id: 'app.project.versions.add-to-another-instance',
|
||||
defaultMessage: 'Add to another instance',
|
||||
},
|
||||
startServer: {
|
||||
id: 'app.project.versions.start-server',
|
||||
defaultMessage: 'Create server',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
install: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
installing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
instance: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
installedVersion: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
startServer: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const serverCapable = computed(
|
||||
() => props.project?.project_type === 'modpack' && props.project?.server_side !== 'unsupported',
|
||||
)
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
|
||||
function buildProjectHref(path) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, val] of Object.entries(route.query)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) params.append(key, v)
|
||||
} else if (val) {
|
||||
params.append(key, String(val))
|
||||
}
|
||||
}
|
||||
const qs = params.toString()
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
const [loaders, gameVersions] = await Promise.all([
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.table-row {
|
||||
grid-template-columns: min-content 1fr 1fr 1.5fr;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100% !important;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.version-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
text-wrap: wrap;
|
||||
|
||||
.version-badge {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
:deep(.checkbox) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
8
apps/app-frontend/src/pages/project/index.js
Normal file
8
apps/app-frontend/src/pages/project/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
import CurseForge from './CurseForge.vue'
|
||||
import Description from './Description.vue'
|
||||
import Gallery from './Gallery.vue'
|
||||
import Index from './Index.vue'
|
||||
import Version from './Version.vue'
|
||||
import Versions from './Versions.vue'
|
||||
|
||||
export { CurseForge, Description, Gallery, Index, Version, Versions }
|
||||
Reference in New Issue
Block a user