refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
ExternalIcon,
|
||||
GlobeIcon,
|
||||
@@ -14,10 +15,12 @@ import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
injectNotificationManager,
|
||||
LoadingIndicator,
|
||||
NavTabs,
|
||||
Pagination,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
@@ -33,12 +36,11 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type Instance from '@/components/ui/Instance.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import SearchCard from '@/components/ui/SearchCard.vue'
|
||||
import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
@@ -51,7 +53,7 @@ import {
|
||||
} from '@/helpers/profile.js'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { getServerLatency } from '@/helpers/worlds'
|
||||
import { add_server_to_profile, get_profile_worlds, getServerLatency } from '@/helpers/worlds'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { getServerAddress } from '@/store/install.js'
|
||||
@@ -108,19 +110,27 @@ const installedProjectIds: Ref<string[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(false)
|
||||
const newlyInstalled = ref<string[]>([])
|
||||
const isServerInstance = ref(false)
|
||||
const isFromWorlds = computed(() => route.query.from === 'worlds')
|
||||
|
||||
if (isFromWorlds.value && route.params.projectType !== 'server') {
|
||||
router.replace({
|
||||
path: '/browse/server',
|
||||
query: route.query,
|
||||
})
|
||||
}
|
||||
|
||||
const allInstalledIds = computed(
|
||||
() => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]),
|
||||
)
|
||||
|
||||
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
|
||||
const PERSISTENT_QUERY_PARAMS = ['i', 'ai', 'from']
|
||||
|
||||
await initInstanceContext()
|
||||
|
||||
async function initInstanceContext() {
|
||||
debugLog('initInstanceContext', { queryI: route.query.i, queryAi: route.query.ai })
|
||||
if (route.query.i) {
|
||||
instance.value = await getInstance(route.query.i).catch(handleError)
|
||||
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
|
||||
debugLog('instance loaded', {
|
||||
name: instance.value?.name,
|
||||
loader: instance.value?.loader,
|
||||
@@ -130,12 +140,24 @@ async function initInstanceContext() {
|
||||
// Load installed project IDs in background — the page and initial search render immediately.
|
||||
// When this resolves, instanceFilters recomputes and triggers a search refresh
|
||||
// that applies the "hide installed" negative filters and marks installed badges.
|
||||
getInstalledProjectIds(route.query.i)
|
||||
.then((ids) => {
|
||||
debugLog('installedProjectIds loaded', { count: ids?.length })
|
||||
installedProjectIds.value = ids
|
||||
})
|
||||
.catch(handleError)
|
||||
if (route.query.from === 'worlds') {
|
||||
get_profile_worlds(route.query.i as string)
|
||||
.then((worlds) => {
|
||||
const serverProjectIds = worlds
|
||||
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
|
||||
.map((w) => (w as { project_id: string }).project_id)
|
||||
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
|
||||
installedProjectIds.value = serverProjectIds
|
||||
})
|
||||
.catch(handleError)
|
||||
} else {
|
||||
getInstalledProjectIds(route.query.i as string)
|
||||
.then((ids) => {
|
||||
debugLog('installedProjectIds loaded', { count: ids?.length })
|
||||
installedProjectIds.value = ids
|
||||
})
|
||||
.catch(handleError)
|
||||
}
|
||||
|
||||
if (instance.value?.linked_data?.project_id) {
|
||||
debugLog('checking linked project for server status', instance.value.linked_data.project_id)
|
||||
@@ -246,6 +268,10 @@ const activeGameVersion = computed(() => {
|
||||
})
|
||||
|
||||
const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
||||
const filteredServerHits = computed(() => {
|
||||
if (!instanceHideInstalled.value || allInstalledIds.value.size === 0) return serverHits.value
|
||||
return serverHits.value.filter((hit) => !allInstalledIds.value.has(hit.project_id))
|
||||
})
|
||||
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
||||
const runningServerProjects = ref<Record<string, string>>({})
|
||||
|
||||
@@ -281,11 +307,28 @@ async function handlePlayServerProject(projectId: string) {
|
||||
checkServerRunningStates(serverHits.value)
|
||||
}
|
||||
|
||||
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
|
||||
const address = getServerAddress(project.minecraft_java_server)
|
||||
if (!address) return
|
||||
showAddServerToInstanceModal(project.name, address)
|
||||
|
||||
if (instance.value) {
|
||||
try {
|
||||
await add_server_to_profile(
|
||||
instance.value.path,
|
||||
project.name,
|
||||
address,
|
||||
'prompt',
|
||||
project.project_id,
|
||||
project.minecraft_java_server?.content?.kind,
|
||||
)
|
||||
newlyInstalled.value.push(project.project_id)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
} else {
|
||||
showAddServerToInstanceModal(project.name, address)
|
||||
}
|
||||
}
|
||||
|
||||
const unlistenProcesses = await process_listener(
|
||||
@@ -317,6 +360,16 @@ const {
|
||||
createServerPageParams,
|
||||
} = useServerSearch({ tags, query, maxResults, currentPage })
|
||||
|
||||
if (instance.value?.game_version) {
|
||||
const gv = instance.value.game_version
|
||||
const alreadyHasGv = serverCurrentFilters.value.some(
|
||||
(f) => f.type === 'server_game_version' && f.option === gv,
|
||||
)
|
||||
if (!alreadyHasGv) {
|
||||
serverCurrentFilters.value.push({ type: 'server_game_version', option: gv })
|
||||
}
|
||||
}
|
||||
|
||||
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
debugLog('pingServerHits', { hitCount: hits.length })
|
||||
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address)
|
||||
@@ -346,8 +399,107 @@ window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
addServerToInstance: {
|
||||
id: 'app.browse.add-server-to-instance',
|
||||
defaultMessage: 'Add server to instance',
|
||||
},
|
||||
addServersToInstance: {
|
||||
id: 'app.browse.add-servers-to-instance',
|
||||
defaultMessage: 'Add servers to your instance',
|
||||
},
|
||||
addToInstance: {
|
||||
id: 'app.browse.add-to-instance',
|
||||
defaultMessage: 'Add to instance',
|
||||
},
|
||||
addToInstanceName: {
|
||||
id: 'app.browse.add-to-instance-name',
|
||||
defaultMessage: 'Add to {instanceName}',
|
||||
},
|
||||
added: {
|
||||
id: 'app.browse.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
alreadyAdded: {
|
||||
id: 'app.browse.already-added',
|
||||
defaultMessage: 'Already added',
|
||||
},
|
||||
discoverContent: {
|
||||
id: 'app.browse.discover-content',
|
||||
defaultMessage: 'Discover content',
|
||||
},
|
||||
discoverServers: {
|
||||
id: 'app.browse.discover-servers',
|
||||
defaultMessage: 'Discover servers',
|
||||
},
|
||||
environmentProvidedByServer: {
|
||||
id: 'search.filter.locked.server-environment.title',
|
||||
defaultMessage: 'Only client-side mods can be added to the server instance',
|
||||
},
|
||||
gameVersionProvidedByInstance: {
|
||||
id: 'search.filter.locked.instance-game-version.title',
|
||||
defaultMessage: 'Game version is provided by the instance',
|
||||
},
|
||||
gameVersionProvidedByServer: {
|
||||
id: 'search.filter.locked.server-game-version.title',
|
||||
defaultMessage: 'Game version is provided by the server',
|
||||
},
|
||||
hideAddedServers: {
|
||||
id: 'app.browse.hide-added-servers',
|
||||
defaultMessage: 'Hide added servers',
|
||||
},
|
||||
hideInstalledContent: {
|
||||
id: 'app.browse.hide-installed-content',
|
||||
defaultMessage: 'Hide installed content',
|
||||
},
|
||||
installContentToInstance: {
|
||||
id: 'app.browse.install-content-to-instance',
|
||||
defaultMessage: 'Install content to instance',
|
||||
},
|
||||
modLoaderProvidedByInstance: {
|
||||
id: 'search.filter.locked.instance-loader.title',
|
||||
defaultMessage: 'Loader is provided by the instance',
|
||||
},
|
||||
modLoaderProvidedByServer: {
|
||||
id: 'search.filter.locked.server-loader.title',
|
||||
defaultMessage: 'Loader is provided by the server',
|
||||
},
|
||||
providedByInstance: {
|
||||
id: 'search.filter.locked.instance',
|
||||
defaultMessage: 'Provided by the instance',
|
||||
},
|
||||
providedByServer: {
|
||||
id: 'search.filter.locked.server',
|
||||
defaultMessage: 'Provided by the server',
|
||||
},
|
||||
syncFilterButton: {
|
||||
id: 'search.filter.locked.instance.sync',
|
||||
defaultMessage: 'Sync with instance',
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: route.query })
|
||||
const browseTitle = computed(() =>
|
||||
formatMessage(isFromWorlds.value ? messages.discoverServers : messages.discoverContent),
|
||||
)
|
||||
breadcrumbs.setName('BrowseTitle', browseTitle.value)
|
||||
if (instance.value) {
|
||||
const instanceLink = `/instance/${encodeURIComponent(instance.value.path)}`
|
||||
breadcrumbs.setContext({
|
||||
name: instance.value.name,
|
||||
link: isFromWorlds.value ? `${instanceLink}/worlds` : instanceLink,
|
||||
})
|
||||
} else {
|
||||
breadcrumbs.setContext(null)
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
breadcrumbs.setContext({
|
||||
name: browseTitle.value,
|
||||
link: `/browse/${projectType.value}`,
|
||||
query: route.query,
|
||||
})
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -484,11 +636,6 @@ async function refreshSearch() {
|
||||
...(isServer ? createServerPageParams() : createPageParams()),
|
||||
}
|
||||
|
||||
breadcrumbs.setContext({
|
||||
name: 'Discover content',
|
||||
link: `/browse/${projectType.value}`,
|
||||
query: params,
|
||||
})
|
||||
debugLog('updating URL', params)
|
||||
router.replace({ path: route.path, query: params })
|
||||
|
||||
@@ -532,6 +679,18 @@ watch(
|
||||
debugLog('projectType route param changed', { from: projectType.value, to: newType })
|
||||
projectType.value = newType
|
||||
|
||||
// If instance context was removed (e.g. sidebar browse navigation), reset state
|
||||
if (!route.query.i && instance.value) {
|
||||
debugLog('instance context removed, resetting')
|
||||
instance.value = null
|
||||
installedProjectIds.value = null
|
||||
instanceHideInstalled.value = false
|
||||
newlyInstalled.value = []
|
||||
isServerInstance.value = false
|
||||
breadcrumbs.setName('BrowseTitle', formatMessage(messages.discoverContent))
|
||||
breadcrumbs.setContext(null)
|
||||
}
|
||||
|
||||
currentSortType.value = { display: 'Relevance', name: 'relevance' }
|
||||
query.value = ''
|
||||
},
|
||||
@@ -569,64 +728,25 @@ const selectableProjectTypes = computed(() => {
|
||||
if (route.query.ai) {
|
||||
params.ai = route.query.ai
|
||||
}
|
||||
|
||||
const links = [
|
||||
{ label: 'Modpacks', href: `/browse/modpack`, shown: modpacks },
|
||||
{ label: 'Mods', href: `/browse/mod`, shown: mods },
|
||||
{ label: 'Resource Packs', href: `/browse/resourcepack` },
|
||||
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks },
|
||||
{ label: 'Shaders', href: `/browse/shader` },
|
||||
{ label: 'Servers', href: `/browse/server`, shown: !instance.value },
|
||||
]
|
||||
|
||||
if (params) {
|
||||
return links.map((link) => {
|
||||
return {
|
||||
...link,
|
||||
href: {
|
||||
path: link.href,
|
||||
query: params,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (route.query.from) {
|
||||
params.from = route.query.from
|
||||
}
|
||||
|
||||
return links
|
||||
})
|
||||
const queryString = new URLSearchParams(params as Record<string, string>).toString()
|
||||
const suffix = queryString ? `?${queryString}` : ''
|
||||
|
||||
const messages = defineMessages({
|
||||
gameVersionProvidedByInstance: {
|
||||
id: 'search.filter.locked.instance-game-version.title',
|
||||
defaultMessage: 'Game version is provided by the instance',
|
||||
},
|
||||
gameVersionProvidedByServer: {
|
||||
id: 'search.filter.locked.server-game-version.title',
|
||||
defaultMessage: 'Game version is provided by the server',
|
||||
},
|
||||
modLoaderProvidedByInstance: {
|
||||
id: 'search.filter.locked.instance-loader.title',
|
||||
defaultMessage: 'Loader is provided by the instance',
|
||||
},
|
||||
modLoaderProvidedByServer: {
|
||||
id: 'search.filter.locked.server-loader.title',
|
||||
defaultMessage: 'Loader is provided by the server',
|
||||
},
|
||||
environmentProvidedByServer: {
|
||||
id: 'search.filter.locked.server-environment.title',
|
||||
defaultMessage: 'Only client-side mods can be added to the server instance',
|
||||
},
|
||||
providedByInstance: {
|
||||
id: 'search.filter.locked.instance',
|
||||
defaultMessage: 'Provided by the instance',
|
||||
},
|
||||
providedByServer: {
|
||||
id: 'search.filter.locked.server',
|
||||
defaultMessage: 'Provided by the server',
|
||||
},
|
||||
syncFilterButton: {
|
||||
id: 'search.filter.locked.instance.sync',
|
||||
defaultMessage: 'Sync with instance',
|
||||
},
|
||||
if (isFromWorlds.value) {
|
||||
return [{ label: 'Servers', href: `/browse/server${suffix}` }]
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'Modpacks', href: `/browse/modpack${suffix}`, shown: modpacks },
|
||||
{ label: 'Mods', href: `/browse/mod${suffix}`, shown: mods },
|
||||
{ label: 'Resource Packs', href: `/browse/resourcepack${suffix}` },
|
||||
{ label: 'Data Packs', href: `/browse/datapack${suffix}`, shown: dataPacks },
|
||||
{ label: 'Shaders', href: `/browse/shader${suffix}` },
|
||||
{ label: 'Servers', href: `/browse/server${suffix}`, shown: !instance.value },
|
||||
]
|
||||
})
|
||||
|
||||
const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
@@ -697,7 +817,9 @@ previousFilterState.value = JSON.stringify({
|
||||
>
|
||||
<Checkbox
|
||||
v-model="instanceHideInstalled"
|
||||
label="Hide installed content"
|
||||
:label="
|
||||
formatMessage(isFromWorlds ? messages.hideAddedServers : messages.hideInstalledContent)
|
||||
"
|
||||
class="filter-checkbox"
|
||||
@update:model-value="onSearchChangeToTop()"
|
||||
@click.prevent.stop
|
||||
@@ -776,9 +898,15 @@ previousFilterState.value = JSON.stringify({
|
||||
</Teleport>
|
||||
<div ref="searchWrapper" class="flex flex-col gap-3 p-6">
|
||||
<template v-if="instance">
|
||||
<InstanceIndicator :instance="instance" />
|
||||
<h1 class="m-0 mb-1 text-xl">Install content to instance</h1>
|
||||
<Admonition v-if="isServerInstance" type="warning" class="mb-1">
|
||||
<InstanceIndicator :instance="instance" :back-tab="isFromWorlds ? 'worlds' : undefined" />
|
||||
<h1 class="m-0 mb-1 text-xl">
|
||||
{{
|
||||
formatMessage(
|
||||
isFromWorlds ? messages.addServersToInstance : messages.installContentToInstance,
|
||||
)
|
||||
}}
|
||||
</h1>
|
||||
<Admonition v-if="isServerInstance && !isFromWorlds" type="warning" class="mb-1">
|
||||
Adding content can break compatibility when joining the server. Any added content will also
|
||||
be lost when you update the server instance content.
|
||||
</Admonition>
|
||||
@@ -850,7 +978,7 @@ previousFilterState.value = JSON.stringify({
|
||||
<section
|
||||
v-else-if="
|
||||
projectType === 'server'
|
||||
? serverHits.length === 0
|
||||
? filteredServerHits.length === 0
|
||||
: results && results.hits && results.hits.length === 0
|
||||
"
|
||||
class="offline"
|
||||
@@ -861,7 +989,7 @@ previousFilterState.value = JSON.stringify({
|
||||
<ProjectCardList v-else :layout="'list'">
|
||||
<template v-if="projectType === 'server'">
|
||||
<ProjectCard
|
||||
v-for="project in serverHits"
|
||||
v-for="project in filteredServerHits"
|
||||
:key="`server-card-${project.project_id}`"
|
||||
:title="project.name"
|
||||
:icon-url="project.icon_url || undefined"
|
||||
@@ -887,37 +1015,71 @@ previousFilterState.value = JSON.stringify({
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="'Add server to instance'"
|
||||
@click.stop="() => handleAddServerToInstance(project)"
|
||||
<template v-if="isFromWorlds && instance">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
:disabled="allInstalledIds.has(project.project_id)"
|
||||
@click.stop="() => handleAddServerToInstance(project)"
|
||||
>
|
||||
<CheckIcon v-if="allInstalledIds.has(project.project_id)" />
|
||||
<PlusIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
allInstalledIds.has(project.project_id)
|
||||
? messages.added
|
||||
: messages.addToInstance,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="
|
||||
allInstalledIds.has(project.project_id)
|
||||
? formatMessage(messages.alreadyAdded)
|
||||
: instance
|
||||
? formatMessage(messages.addToInstanceName, {
|
||||
instanceName: instance.name,
|
||||
})
|
||||
: formatMessage(messages.addServerToInstance)
|
||||
"
|
||||
:disabled="allInstalledIds.has(project.project_id)"
|
||||
@click.stop="() => handleAddServerToInstance(project)"
|
||||
>
|
||||
<CheckIcon v-if="allInstalledIds.has(project.project_id)" />
|
||||
<PlusIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="runningServerProjects[project.project_id]"
|
||||
color="red"
|
||||
type="outlined"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="runningServerProjects[project.project_id]"
|
||||
color="red"
|
||||
type="outlined"
|
||||
>
|
||||
<button @click="() => handleStopServerProject(project.project_id)">
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" type="outlined">
|
||||
<button
|
||||
:disabled="(installingServerProjects as string[]).includes(project.project_id)"
|
||||
@click="() => handlePlayServerProject(project.project_id)"
|
||||
>
|
||||
<PlayIcon />
|
||||
{{
|
||||
(installingServerProjects as string[]).includes(project.project_id)
|
||||
? 'Installing...'
|
||||
: 'Play'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<button @click="() => handleStopServerProject(project.project_id)">
|
||||
<StopCircleIcon />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" type="outlined">
|
||||
<button
|
||||
:disabled="
|
||||
(installingServerProjects as string[]).includes(project.project_id)
|
||||
"
|
||||
@click="() => handlePlayServerProject(project.project_id)"
|
||||
>
|
||||
<PlayIcon />
|
||||
{{
|
||||
formatMessage(
|
||||
(installingServerProjects as string[]).includes(project.project_id)
|
||||
? commonMessages.installingLabel
|
||||
: commonMessages.playButton,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
|
||||
345
apps/app-frontend/src/pages/instance/Files.vue
Normal file
345
apps/app-frontend/src/pages/instance/Files.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditingFile, FileItem, UploadState } from '@modrinth/ui'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FilePageLayout,
|
||||
injectNotificationManager,
|
||||
provideFileManager,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
mkdir,
|
||||
readDir,
|
||||
readFile as readFileBytes,
|
||||
readTextFile,
|
||||
remove,
|
||||
rename,
|
||||
stat,
|
||||
writeFile as writeFileBytes,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/plugin-fs'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { profile_listener } from '@/helpers/events'
|
||||
import { get_full_path } from '@/helpers/profile'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: unknown
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
isServerInstance: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const debug = useDebugLogger('Files')
|
||||
|
||||
const messages = defineMessages({
|
||||
saveAs: {
|
||||
id: 'instance.files.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
addingFiles: {
|
||||
id: 'instance.files.adding-files',
|
||||
defaultMessage: 'Adding files ({completed}/{total})',
|
||||
},
|
||||
})
|
||||
|
||||
const instanceRoot = ref('')
|
||||
const items = ref<FileItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<Error | null>(null)
|
||||
const currentPath = ref('')
|
||||
const editingFile = ref<EditingFile | null>(null)
|
||||
|
||||
debug('setup: start, instance.path =', props.instance.path)
|
||||
|
||||
instanceRoot.value = await get_full_path(props.instance.path)
|
||||
debug('setup: instanceRoot =', instanceRoot.value)
|
||||
await refresh()
|
||||
debug('setup: refresh complete, items =', items.value.length, 'error =', error.value)
|
||||
|
||||
function resolvePath(relativePath: string): string {
|
||||
return relativePath ? `${instanceRoot.value}/${relativePath}` : instanceRoot.value
|
||||
}
|
||||
|
||||
async function listDirectory(dirPath: string): Promise<FileItem[]> {
|
||||
const absPath = resolvePath(dirPath)
|
||||
debug('listDirectory: dirPath =', dirPath, 'absPath =', absPath)
|
||||
const entries = await readDir(absPath)
|
||||
debug('listDirectory: got', entries.length, 'entries')
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryAbsPath = `${absPath}/${entry.name}`
|
||||
let metadata
|
||||
try {
|
||||
metadata = await stat(entryAbsPath)
|
||||
} catch {
|
||||
debug('listDirectory: stat failed for', entry.name, '- skipping')
|
||||
return null
|
||||
}
|
||||
const item: FileItem = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory ? 'directory' : 'file',
|
||||
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : 0,
|
||||
created: metadata.birthtime ? Math.floor(metadata.birthtime.getTime() / 1000) : 0,
|
||||
}
|
||||
if (!entry.isDirectory) {
|
||||
item.size = metadata.size
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
try {
|
||||
const children = await readDir(entryAbsPath)
|
||||
item.count = children.length
|
||||
} catch {
|
||||
item.count = 0
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
)
|
||||
return results.filter((item): item is FileItem => item !== null)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
debug('refresh: called, currentPath =', currentPath.value, 'instanceRoot =', instanceRoot.value)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await listDirectory(currentPath.value)
|
||||
debug('refresh: success, items =', items.value.length)
|
||||
} catch (e) {
|
||||
debug('refresh: error =', e)
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
debug('navigateTo:', path)
|
||||
currentPath.value = path.startsWith('/') ? path.slice(1) : path
|
||||
refresh()
|
||||
}
|
||||
|
||||
function startEditing(file: EditingFile) {
|
||||
editingFile.value = file
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingFile.value = null
|
||||
}
|
||||
|
||||
async function handleCreateItem(name: string, type: 'file' | 'directory') {
|
||||
const targetPath = currentPath.value ? `${currentPath.value}/${name}` : name
|
||||
const absPath = resolvePath(targetPath)
|
||||
try {
|
||||
if (type === 'directory') {
|
||||
await mkdir(absPath)
|
||||
} else {
|
||||
await writeTextFile(absPath, '')
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.createFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameItem(path: string, newName: string) {
|
||||
const oldAbs = resolvePath(path)
|
||||
const parentDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''
|
||||
const newPath = parentDir ? `${parentDir}/${newName}` : newName
|
||||
const newAbs = resolvePath(newPath)
|
||||
try {
|
||||
await rename(oldAbs, newAbs)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.renameFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveItem(source: string, destination: string) {
|
||||
try {
|
||||
await rename(resolvePath(source), resolvePath(destination))
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.moveFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(path: string, recursive: boolean) {
|
||||
try {
|
||||
await remove(resolvePath(path), { recursive })
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.deleteFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReadFile(path: string): Promise<string> {
|
||||
return await readTextFile(resolvePath(path))
|
||||
}
|
||||
|
||||
async function handleReadFileAsBlob(path: string): Promise<Blob> {
|
||||
const bytes = await readFileBytes(resolvePath(path))
|
||||
return new Blob([bytes])
|
||||
}
|
||||
|
||||
async function handleWriteFile(path: string, content: string) {
|
||||
await writeTextFile(resolvePath(path), content)
|
||||
}
|
||||
|
||||
async function handleDownloadFile(path: string, _fileName: string) {
|
||||
await invoke('plugin:files|file_save_as', {
|
||||
instancePath: props.instance.path,
|
||||
filePath: path,
|
||||
})
|
||||
}
|
||||
|
||||
const uploadState = ref<UploadState>({
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
|
||||
async function handleUploadFiles(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
|
||||
uploadState.value = {
|
||||
isUploading: true,
|
||||
currentFileName: '',
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
try {
|
||||
for (const file of files) {
|
||||
uploadState.value.currentFileName = file.name
|
||||
const buffer = await file.arrayBuffer()
|
||||
const targetPath = resolvePath(
|
||||
currentPath.value ? `${currentPath.value}/${file.name}` : file.name,
|
||||
)
|
||||
await writeFileBytes(targetPath, new Uint8Array(buffer))
|
||||
uploadState.value.completedFiles++
|
||||
uploadState.value.uploadedBytes += file.size
|
||||
uploadState.value.currentFileProgress = 1
|
||||
}
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.uploadFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
uploadState.value.isUploading = false
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
|
||||
try {
|
||||
return await invoke('plugin:files|file_extract_zip', {
|
||||
instancePath: props.instance.path,
|
||||
filePath: path,
|
||||
overrideConflicts: override,
|
||||
dryRun: dry,
|
||||
})
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.extractFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
debug('setup: registering profile_listener')
|
||||
const unlistenProfiles = await profile_listener(
|
||||
async (event: { event: string; profile_path_id: string }) => {
|
||||
debug('profile_listener: event =', event.event, 'path =', event.profile_path_id)
|
||||
if (event.profile_path_id === props.instance.path && event.event === 'synced') {
|
||||
debug('profile_listener: synced event matched, calling refresh')
|
||||
await refresh()
|
||||
}
|
||||
},
|
||||
)
|
||||
debug('setup: profile_listener registered')
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfiles()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance.path,
|
||||
async () => {
|
||||
debug('watch instance.path: changed to', props.instance.path)
|
||||
instanceRoot.value = await get_full_path(props.instance.path)
|
||||
currentPath.value = ''
|
||||
await refresh()
|
||||
},
|
||||
)
|
||||
|
||||
provideFileManager({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
currentPath,
|
||||
navigateTo,
|
||||
editingFile,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
createItem: handleCreateItem,
|
||||
renameItem: handleRenameItem,
|
||||
moveItem: handleMoveItem,
|
||||
deleteItem: handleDeleteItem,
|
||||
readFile: handleReadFile,
|
||||
readFileAsBlob: handleReadFileAsBlob,
|
||||
writeFile: handleWriteFile,
|
||||
downloadFile: handleDownloadFile,
|
||||
uploadFiles: handleUploadFiles,
|
||||
uploadState,
|
||||
extractFile: handleExtractFile,
|
||||
refresh,
|
||||
basePath: instanceRoot,
|
||||
openInFolder: (path: string) => highlightInFolder(path),
|
||||
downloadButtonLabel: formatMessage(messages.saveAs),
|
||||
uploadingLabel: (completed: number, total: number) =>
|
||||
formatMessage(messages.addingFiles, { completed, total }),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FilePageLayout :show-refresh-button="true" />
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
|
||||
<ExportModal ref="exportModal" :instance="instance" />
|
||||
<InstanceSettingsModal
|
||||
:key="instance.path"
|
||||
ref="settingsModal"
|
||||
:instance="instance"
|
||||
:offline="offline"
|
||||
@@ -36,27 +37,6 @@
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
|
||||
<div v-if="linkedProjectV3" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div
|
||||
v-if="linkedProjectV3"
|
||||
class="flex gap-1.5 items-center font-medium text-primary"
|
||||
>
|
||||
Linked to
|
||||
<Avatar
|
||||
:src="linkedProjectV3.icon_url"
|
||||
:alt="linkedProjectV3.name"
|
||||
:tint-by="instance.path"
|
||||
size="24px"
|
||||
/>
|
||||
<router-link
|
||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||
class="hover:underline text-primary truncate"
|
||||
>
|
||||
{{ linkedProjectV3.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -285,6 +265,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckCircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
@@ -302,6 +283,7 @@ import {
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UpdatedIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
@@ -312,6 +294,7 @@ import {
|
||||
ContentPageHeader,
|
||||
injectNotificationManager,
|
||||
LoadingIndicator,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
ServerOnlinePlayers,
|
||||
ServerPing,
|
||||
@@ -329,7 +312,6 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
import { process_listener, profile_listener } from '@/helpers/events'
|
||||
@@ -451,14 +433,22 @@ const tabs = computed(() => [
|
||||
{
|
||||
label: 'Content',
|
||||
href: `${basePath.value}`,
|
||||
icon: BoxesIcon,
|
||||
},
|
||||
{
|
||||
label: 'Files',
|
||||
href: `${basePath.value}/files`,
|
||||
icon: FolderOpenIcon,
|
||||
},
|
||||
{
|
||||
label: 'Worlds',
|
||||
href: `${basePath.value}/worlds`,
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
href: `${basePath.value}/logs`,
|
||||
icon: TerminalSquareIcon,
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
|
||||
:enable-toggle="!props.isServerInstance"
|
||||
:get-overflow-options="getOverflowOptions"
|
||||
:switch-version="handleSwitchVersion"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="handleModpackContentBulkToggle"
|
||||
@bulk:disable="handleModpackContentBulkToggle"
|
||||
@@ -47,7 +48,7 @@
|
||||
"
|
||||
:project-name="
|
||||
updatingModpack
|
||||
? (linkedModpackProject?.title ?? formatMessage(messages.modpackFallback))
|
||||
? (linkedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
|
||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
@@ -65,7 +66,9 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
ConfirmModpackUpdateModal,
|
||||
ContentCardLayout as ContentPageLayout,
|
||||
type ContentItem,
|
||||
type ContentModpackCardCategory,
|
||||
type ContentModpackCardProject,
|
||||
@@ -82,7 +85,6 @@ import {
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ContentCardLayout as ContentPageLayout } from '@modrinth/ui'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
@@ -125,10 +127,6 @@ const messages = defineMessages({
|
||||
id: 'app.instance.mods.share-text',
|
||||
defaultMessage: "Check out the projects I'm using in my modpack!",
|
||||
},
|
||||
modpackFallback: {
|
||||
id: 'app.instance.mods.modpack-fallback',
|
||||
defaultMessage: 'Modpack',
|
||||
},
|
||||
successfullyUploaded: {
|
||||
id: 'app.instance.mods.successfully-uploaded',
|
||||
defaultMessage: 'Successfully uploaded',
|
||||
@@ -141,30 +139,10 @@ const messages = defineMessages({
|
||||
id: 'app.instance.mods.projects-were-added',
|
||||
defaultMessage: '{count} projects were added',
|
||||
},
|
||||
updating: {
|
||||
id: 'app.instance.mods.updating',
|
||||
defaultMessage: 'Updating...',
|
||||
},
|
||||
installing: {
|
||||
id: 'app.instance.mods.installing',
|
||||
defaultMessage: 'Installing...',
|
||||
},
|
||||
contentTypeProject: {
|
||||
id: 'app.instance.mods.content-type-project',
|
||||
defaultMessage: 'project',
|
||||
},
|
||||
unknownVersion: {
|
||||
id: 'app.instance.mods.unknown-version',
|
||||
defaultMessage: 'Unknown',
|
||||
},
|
||||
showFile: {
|
||||
id: 'app.instance.mods.show-file',
|
||||
defaultMessage: 'Show file',
|
||||
},
|
||||
copyLink: {
|
||||
id: 'app.instance.mods.copy-link',
|
||||
defaultMessage: 'Copy link',
|
||||
},
|
||||
})
|
||||
|
||||
let savedModalState: ModpackContentModalState | null = null
|
||||
@@ -283,7 +261,7 @@ async function handleUploadFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function _toggleDisableMod(mod: ContentItem) {
|
||||
async function toggleDisableMod(mod: ContentItem) {
|
||||
try {
|
||||
mod.file_path = await toggle_disable_project(props.instance.path, mod.file_path!)
|
||||
mod.enabled = !mod.enabled
|
||||
@@ -301,7 +279,7 @@ async function _toggleDisableMod(mod: ContentItem) {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDisableMod = useDebounceFn(_toggleDisableMod, 20)
|
||||
const toggleDisableDebounced = useDebounceFn(toggleDisableMod, 20)
|
||||
|
||||
async function removeMod(mod: ContentItem) {
|
||||
await remove_project(props.instance.path, mod.file_path!).catch(handleError)
|
||||
@@ -354,6 +332,12 @@ async function updateProject(mod: ContentItem) {
|
||||
}
|
||||
|
||||
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
|
||||
isBulkOperating.value = true
|
||||
mod.installing = true
|
||||
if (mod.version) {
|
||||
mod.version.id = version.id
|
||||
mod.version.version_number = version.version_number
|
||||
}
|
||||
try {
|
||||
await remove_project(props.instance.path, mod.file_path!)
|
||||
const newPath = await add_project_from_version(props.instance.path, version.id)
|
||||
@@ -364,20 +348,12 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
|
||||
}
|
||||
|
||||
mod.file_path = newPath
|
||||
if (mod.version) {
|
||||
mod.version.id = version.id
|
||||
mod.version.version_number = version.version_number
|
||||
}
|
||||
|
||||
trackEvent('InstanceProjectSwitchVersion', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
mod.installing = false
|
||||
isBulkOperating.value = false
|
||||
await initProjects()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,11 +444,11 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
}
|
||||
|
||||
async function handleModpackContentToggle(item: ContentItem) {
|
||||
await toggleDisableMod(item)
|
||||
await toggleDisableDebounced(item)
|
||||
}
|
||||
|
||||
async function handleModpackContentBulkToggle(items: ContentItem[]) {
|
||||
await Promise.all(items.map((item) => _toggleDisableMod(item)))
|
||||
await Promise.all(items.map((item) => toggleDisableMod(item)))
|
||||
}
|
||||
|
||||
async function handleModpackContent() {
|
||||
@@ -539,7 +515,7 @@ async function fetchAndSpliceVersion(
|
||||
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
if (version.changelog != null) return
|
||||
loadingChangelog.value = true
|
||||
await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError)
|
||||
await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError as (err: unknown) => void)
|
||||
loadingChangelog.value = false
|
||||
}
|
||||
|
||||
@@ -660,14 +636,14 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
|
||||
const options: OverflowMenuOption[] = []
|
||||
|
||||
options.push({
|
||||
id: formatMessage(messages.showFile),
|
||||
id: formatMessage(commonMessages.showFileButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => highlightModInProfile(props.instance.path, item.file_path),
|
||||
})
|
||||
|
||||
if (item.project?.slug) {
|
||||
options.push({
|
||||
id: formatMessage(messages.copyLink),
|
||||
id: formatMessage(commonMessages.copyLinkButton),
|
||||
icon: ClipboardCopyIcon,
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
@@ -718,18 +694,13 @@ async function initProjects(cacheBehaviour?: CacheBehaviour) {
|
||||
|
||||
if (allCategories && modpackInfo.project.categories) {
|
||||
const seen = new Set<string>()
|
||||
linkedModpackCategories.value = allCategories
|
||||
.filter((cat: { name: string }) => {
|
||||
if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) {
|
||||
seen.add(cat.name)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
.map((cat: { name: string }) => ({
|
||||
...cat,
|
||||
name: cat.name.charAt(0).toUpperCase() + cat.name.slice(1),
|
||||
}))
|
||||
linkedModpackCategories.value = allCategories.filter((cat: { name: string }) => {
|
||||
if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) {
|
||||
seen.add(cat.name)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
linkedModpackCategories.value = []
|
||||
}
|
||||
@@ -799,8 +770,8 @@ provideContentManager({
|
||||
hasUpdate: linkedModpackHasUpdate.value,
|
||||
disabled: isModpackUpdating.value,
|
||||
disabledText: isModpackUpdating.value
|
||||
? formatMessage(messages.updating)
|
||||
: formatMessage(messages.installing),
|
||||
? formatMessage(commonMessages.updatingLabel)
|
||||
: formatMessage(commonMessages.installingLabel),
|
||||
}
|
||||
: null,
|
||||
),
|
||||
@@ -808,13 +779,18 @@ provideContentManager({
|
||||
isBusy: isInstanceBusy,
|
||||
isBulkOperating,
|
||||
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
|
||||
toggleEnabled: toggleDisableMod,
|
||||
bulkEnableItems: (items) =>
|
||||
Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}),
|
||||
bulkDisableItems: (items) =>
|
||||
Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}),
|
||||
toggleEnabled: toggleDisableDebounced,
|
||||
bulkEnableItems: (items: ContentItem[]) =>
|
||||
Promise.all(items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item))).then(
|
||||
() => {},
|
||||
),
|
||||
bulkDisableItems: (items: ContentItem[]) =>
|
||||
Promise.all(items.filter((item) => item.enabled).map((item) => toggleDisableMod(item))).then(
|
||||
() => {},
|
||||
),
|
||||
deleteItem: removeMod,
|
||||
bulkDeleteItems: (items) => Promise.all(items.map((item) => removeMod(item))).then(() => {}),
|
||||
bulkDeleteItems: (items: ContentItem[]) =>
|
||||
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
|
||||
refresh: () => initProjects('must_revalidate'),
|
||||
browse: handleBrowseContent,
|
||||
uploadFiles: handleUploadFiles,
|
||||
@@ -830,7 +806,7 @@ provideContentManager({
|
||||
showContentHint,
|
||||
dismissContentHint,
|
||||
shareItems: handleShareItems,
|
||||
mapToTableItem: (item) => ({
|
||||
mapToTableItem: (item: ContentItem) => ({
|
||||
id: item.id,
|
||||
project: item.project ?? {
|
||||
id: item.file_name,
|
||||
@@ -843,7 +819,7 @@ provideContentManager({
|
||||
: undefined,
|
||||
version: item.version ?? {
|
||||
id: item.file_name,
|
||||
version_number: formatMessage(messages.unknownVersion),
|
||||
version_number: formatMessage(commonMessages.unknownLabel),
|
||||
file_name: item.file_name,
|
||||
},
|
||||
versionLink:
|
||||
@@ -860,6 +836,7 @@ provideContentManager({
|
||||
}
|
||||
: undefined,
|
||||
enabled: item.enabled,
|
||||
installing: item.installing,
|
||||
}),
|
||||
filterPersistKey: props.instance.path,
|
||||
})
|
||||
|
||||
@@ -15,48 +15,88 @@
|
||||
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
|
||||
<ConfirmModalWrapper
|
||||
ref="removeServerModal"
|
||||
:title="`Are you sure you want to remove ${serverToRemove?.name ?? 'this server'}?`"
|
||||
:description="`'${serverToRemove?.name}'${serverToRemove?.address === serverToRemove?.name ? ' ' : ` (${serverToRemove?.address})`} will be removed from your list, including in-game, and there will be no way to recover it.`"
|
||||
:title="
|
||||
formatMessage(messages.removeServerTitle, {
|
||||
name: serverToRemove?.name ?? formatMessage(messages.thisServer),
|
||||
})
|
||||
"
|
||||
:description="
|
||||
serverToRemove?.address === serverToRemove?.name
|
||||
? formatMessage(messages.removeServerDescription, { name: serverToRemove?.name })
|
||||
: formatMessage(messages.removeServerDescriptionWithAddress, {
|
||||
name: serverToRemove?.name,
|
||||
address: serverToRemove?.address,
|
||||
})
|
||||
"
|
||||
:markdown="false"
|
||||
@proceed="proceedRemoveServer"
|
||||
/>
|
||||
<ConfirmModalWrapper
|
||||
ref="deleteWorldModal"
|
||||
:title="`Are you sure you want to permanently delete this world?`"
|
||||
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
|
||||
:title="formatMessage(messages.deleteWorldTitle)"
|
||||
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
|
||||
@proceed="proceedDeleteWorld"
|
||||
/>
|
||||
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
placeholder="Search worlds..."
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
clearable
|
||||
wrapper-class="flex-grow"
|
||||
:placeholder="
|
||||
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
|
||||
"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<template v-if="refreshingAll">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Refreshing...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</template>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<FilterIcon class="size-5 text-secondary" />
|
||||
<button
|
||||
:class="filterPillClass(selectedFilters.length === 0)"
|
||||
@click="selectedFilters = []"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="addServerModal?.show()">
|
||||
<PlusIcon />
|
||||
Add a server
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
:class="filterPillClass(selectedFilters.includes(option.id))"
|
||||
@click="toggleFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<WorldItem
|
||||
v-for="world in filteredWorlds"
|
||||
@@ -92,51 +132,49 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
|
||||
<RadialHeader class="">
|
||||
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||
<img src="@/assets/sad-modrinth-bot.webp" alt="" aria-hidden="true" class="h-24" />
|
||||
<span class="text-contrast font-bold text-xl"> You don't have any worlds yet. </span>
|
||||
</div>
|
||||
</RadialHeader>
|
||||
<div class="flex gap-2 mt-4 mx-auto">
|
||||
<ButtonStyled>
|
||||
<button @click="addServerModal?.show()">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add a server
|
||||
<EmptyState
|
||||
v-else
|
||||
type="empty-inbox"
|
||||
:heading="formatMessage(messages.noWorldsHeading)"
|
||||
:description="formatMessage(messages.noWorldsDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<template v-if="refreshingAll">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||
Refreshing...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
Refresh
|
||||
</template>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SearchIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { CompassIcon, FilterIcon, PlusIcon, RefreshCwIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FilterBar,
|
||||
type FilterBarOption,
|
||||
EmptyState,
|
||||
GAME_MODES,
|
||||
type GameVersion,
|
||||
injectNotificationManager,
|
||||
RadialHeader,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
@@ -176,11 +214,80 @@ import {
|
||||
type World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
|
||||
|
||||
const messages = defineMessages({
|
||||
removeServerTitle: {
|
||||
id: 'app.instance.worlds.remove-server-title',
|
||||
defaultMessage: 'Are you sure you want to remove {name}?',
|
||||
},
|
||||
removeServerDescription: {
|
||||
id: 'app.instance.worlds.remove-server-description',
|
||||
defaultMessage:
|
||||
"'{name}' will be removed from your list, including in-game, and there will be no way to recover it.",
|
||||
},
|
||||
removeServerDescriptionWithAddress: {
|
||||
id: 'app.instance.worlds.remove-server-description-with-address',
|
||||
defaultMessage:
|
||||
"'{name}' ({address}) will be removed from your list, including in-game, and there will be no way to recover it.",
|
||||
},
|
||||
deleteWorldTitle: {
|
||||
id: 'app.instance.worlds.delete-world-title',
|
||||
defaultMessage: 'Are you sure you want to permanently delete this world?',
|
||||
},
|
||||
deleteWorldDescription: {
|
||||
id: 'app.instance.worlds.delete-world-description',
|
||||
defaultMessage:
|
||||
"'{name}' will be **permanently deleted**, and there will be no way to recover it.",
|
||||
},
|
||||
searchWorldsPlaceholder: {
|
||||
id: 'app.instance.worlds.search-worlds-placeholder',
|
||||
defaultMessage: 'Search {count} worlds...',
|
||||
},
|
||||
addServer: {
|
||||
id: 'app.instance.worlds.add-server',
|
||||
defaultMessage: 'Add server',
|
||||
},
|
||||
browseServers: {
|
||||
id: 'app.instance.worlds.browse-servers',
|
||||
defaultMessage: 'Browse servers',
|
||||
},
|
||||
noWorldsHeading: {
|
||||
id: 'app.instance.worlds.no-worlds-heading',
|
||||
defaultMessage: 'No servers or worlds added',
|
||||
},
|
||||
noWorldsDescription: {
|
||||
id: 'app.instance.worlds.no-worlds-description',
|
||||
defaultMessage: 'Add a server or browse to get started',
|
||||
},
|
||||
thisServer: {
|
||||
id: 'app.instance.worlds.this-server',
|
||||
defaultMessage: 'this server',
|
||||
},
|
||||
vanillaFilter: {
|
||||
id: 'app.instance.worlds.filter-vanilla',
|
||||
defaultMessage: 'Vanilla',
|
||||
},
|
||||
moddedFilter: {
|
||||
id: 'app.instance.worlds.filter-modded',
|
||||
defaultMessage: 'Modded',
|
||||
},
|
||||
onlineFilter: {
|
||||
id: 'app.instance.worlds.filter-online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offlineFilter: {
|
||||
id: 'app.instance.worlds.filter-offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { playServerProject } = injectServerInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||
@@ -211,9 +318,32 @@ function play(world: World) {
|
||||
emit('play', world)
|
||||
}
|
||||
|
||||
const filters = ref<string[]>([])
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const searchFilter = ref('')
|
||||
|
||||
function filterPillClass(isActive: boolean) {
|
||||
return [
|
||||
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
|
||||
isActive
|
||||
? 'border-green bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
|
||||
]
|
||||
}
|
||||
|
||||
function toggleFilter(id: string) {
|
||||
const idx = selectedFilters.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
selectedFilters.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedFilters.value.push(id)
|
||||
if (id === 'singleplayer') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'online' && f !== 'offline')
|
||||
} else if (id === 'online' || id === 'offline') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'singleplayer')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
const hadNoWorlds = ref(true)
|
||||
const startingInstance = ref(false)
|
||||
@@ -371,6 +501,12 @@ async function editServer(server: ServerWorld) {
|
||||
async function removeServer(server: ServerWorld) {
|
||||
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
|
||||
let serverIdx = 0
|
||||
for (const w of worlds.value) {
|
||||
if (w.type === 'server') {
|
||||
w.index = serverIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function editWorld(path: string, name: string, removeIcon: boolean) {
|
||||
@@ -393,7 +529,7 @@ async function deleteWorld(world: SingleplayerWorld) {
|
||||
}
|
||||
|
||||
function handleJoinError(err: Error) {
|
||||
handleError(err)
|
||||
handleSevereError(err, { profilePath: instance.value.path })
|
||||
startingInstance.value = false
|
||||
worldPlaying.value = undefined
|
||||
}
|
||||
@@ -498,58 +634,83 @@ const dedupedWorlds = computed(() => {
|
||||
})
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options: FilterBarOption[] = []
|
||||
|
||||
const options: { id: string; label: string }[] = []
|
||||
const hasSingleplayer = dedupedWorlds.value.some((x) => x.type === 'singleplayer')
|
||||
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
|
||||
|
||||
if (dedupedWorlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
|
||||
options.push({
|
||||
id: 'singleplayer',
|
||||
message: messages.singleplayer,
|
||||
})
|
||||
options.push({
|
||||
id: 'server',
|
||||
message: messages.server,
|
||||
})
|
||||
const hasStatusFilter =
|
||||
selectedFilters.value.includes('online') || selectedFilters.value.includes('offline')
|
||||
|
||||
if (hasSingleplayer && hasServer && !hasStatusFilter) {
|
||||
options.push({ id: 'singleplayer', label: formatMessage(commonMessages.singleplayerLabel) })
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
|
||||
if (
|
||||
dedupedWorlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'server' &&
|
||||
!serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing,
|
||||
) &&
|
||||
dedupedWorlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'singleplayer' ||
|
||||
(x.type === 'server' &&
|
||||
serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing),
|
||||
)
|
||||
) {
|
||||
options.push({
|
||||
id: 'available',
|
||||
message: messages.available,
|
||||
})
|
||||
const servers = dedupedWorlds.value.filter((x) => x.type === 'server')
|
||||
const hasVanilla = servers.some((x) => x.content_kind !== 'modpack')
|
||||
const hasModded = servers.some((x) => x.content_kind === 'modpack')
|
||||
if (hasVanilla && hasModded) {
|
||||
options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
|
||||
options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
|
||||
}
|
||||
if (!selectedFilters.value.includes('singleplayer')) {
|
||||
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
|
||||
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
|
||||
if (hasOnline && hasOffline) {
|
||||
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
|
||||
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
watch(filterOptions, (options) => {
|
||||
const validIds = new Set(options.map((opt) => opt.id))
|
||||
const cleaned = selectedFilters.value.filter((f) => validIds.has(f))
|
||||
if (cleaned.length !== selectedFilters.value.length) {
|
||||
selectedFilters.value = cleaned
|
||||
}
|
||||
})
|
||||
|
||||
const filteredWorlds = computed(() =>
|
||||
dedupedWorlds.value.filter((x) => {
|
||||
const availableFilter = filters.value.includes('available')
|
||||
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
|
||||
if (searchFilter.value && !x.name.toLowerCase().includes(searchFilter.value.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(!typeFilter || filters.value.includes(x.type)) &&
|
||||
(!availableFilter || x.type !== 'server' || serverData.value[x.address]?.status) &&
|
||||
(!searchFilter.value || x.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
|
||||
)
|
||||
if (selectedFilters.value.length === 0) return true
|
||||
|
||||
const hasSingleplayerFilter = selectedFilters.value.includes('singleplayer')
|
||||
const typeFilters = selectedFilters.value.filter((f) => f === 'vanilla' || f === 'modded')
|
||||
const statusFilters = selectedFilters.value.filter((f) => f === 'online' || f === 'offline')
|
||||
|
||||
if (x.type === 'singleplayer') {
|
||||
return hasSingleplayerFilter || (typeFilters.length === 0 && statusFilters.length === 0)
|
||||
}
|
||||
|
||||
if (hasSingleplayerFilter && typeFilters.length === 0 && statusFilters.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let passesType = true
|
||||
if (typeFilters.length > 0) {
|
||||
const isModded = x.content_kind === 'modpack'
|
||||
passesType =
|
||||
(typeFilters.includes('modded') && isModded) ||
|
||||
(typeFilters.includes('vanilla') && !isModded)
|
||||
}
|
||||
|
||||
let passesStatus = true
|
||||
if (statusFilters.length > 0) {
|
||||
const isOnline = !!serverData.value[x.address]?.status
|
||||
passesStatus =
|
||||
(statusFilters.includes('online') && isOnline) ||
|
||||
(statusFilters.includes('offline') && !isOnline)
|
||||
}
|
||||
|
||||
return passesType && passesStatus
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -588,19 +749,4 @@ async function proceedDeleteWorld() {
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
singleplayer: {
|
||||
id: 'instance.worlds.type.singleplayer',
|
||||
defaultMessage: 'Singleplayer',
|
||||
},
|
||||
server: {
|
||||
id: 'instance.worlds.type.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
available: {
|
||||
id: 'instance.worlds.filter.available',
|
||||
defaultMessage: 'Available',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Files from './Files.vue'
|
||||
import Index from './Index.vue'
|
||||
import Logs from './Logs.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Index, Logs, Mods, Overview, Worlds }
|
||||
export { Files, Index, Logs, Mods, Overview, Worlds }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@modrinth/assets'
|
||||
import { Button, injectNotificationManager } from '@modrinth/ui'
|
||||
import { Button, injectNotificationManager, NavTabs } from '@modrinth/ui'
|
||||
import { inject, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { NewInstanceImage } from '@/assets/icons'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { profile_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/profile.js'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
|
||||
|
||||
@@ -170,10 +170,7 @@
|
||||
},
|
||||
{
|
||||
label: 'Versions',
|
||||
href: {
|
||||
path: `/project/${$route.params.id}/versions`,
|
||||
query: instanceFilters,
|
||||
},
|
||||
href: versionsHref,
|
||||
subpages: ['version'],
|
||||
shown: projectV3?.minecraft_server == null,
|
||||
},
|
||||
@@ -224,6 +221,7 @@ import {
|
||||
import {
|
||||
ButtonStyled,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectHeader,
|
||||
@@ -242,7 +240,6 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import {
|
||||
get_organization,
|
||||
get_project,
|
||||
@@ -318,6 +315,21 @@ const instanceFilters = computed(() => {
|
||||
return { l: loaders, g: instance.value.game_version }
|
||||
})
|
||||
|
||||
const versionsHref = computed(() => {
|
||||
const base = `/project/${route.params.id}/versions`
|
||||
const filters = instanceFilters.value
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
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 ? `${base}?${qs}` : base
|
||||
})
|
||||
|
||||
const [allLoaders, allGameVersions] = await Promise.all([
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
|
||||
Reference in New Issue
Block a user