feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

@@ -0,0 +1,11 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'Changelog',
}
</script>
<style scoped></style>
@@ -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>
@@ -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>
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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 }