feat:移除了弹窗,服务器添加sls
This commit is contained in:
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 }
|
||||
Reference in new issue
Block a user