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:
@@ -3,6 +3,7 @@
|
||||
ref="outerRef"
|
||||
data-tauri-drag-region
|
||||
class="min-w-0 overflow-hidden pl-3"
|
||||
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
|
||||
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@@ -128,6 +129,16 @@ watch(breadcrumbs, () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-fade-mask {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px,
|
||||
black calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.breadcrumbs-scroll {
|
||||
animation: breadcrumb-scroll 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
@@ -12,18 +13,23 @@ type Instance = {
|
||||
name: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
instance: Instance
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: Instance
|
||||
backTab?: string
|
||||
}>(),
|
||||
{ backTab: undefined },
|
||||
)
|
||||
|
||||
const instanceLink = computed(() => {
|
||||
const base = `/instance/${encodeURIComponent(props.instance.path)}`
|
||||
return props.backTab ? `${base}/${props.backTab}` : base
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
|
||||
<router-link
|
||||
:to="`/instance/${encodeURIComponent(instance.path)}`"
|
||||
tabindex="-1"
|
||||
class="flex flex-col gap-4 text-primary"
|
||||
>
|
||||
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
@@ -43,9 +49,7 @@ defineProps<{
|
||||
</span>
|
||||
</router-link>
|
||||
<ButtonStyled>
|
||||
<router-link :to="`/instance/${encodeURIComponent(instance.path)}`">
|
||||
<LeftArrowIcon /> Back to instance
|
||||
</router-link>
|
||||
<router-link :to="instanceLink"> <LeftArrowIcon /> Back to instance </router-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
ref="scrollContainer"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown === undefined ? true : link.shown"
|
||||
:key="index"
|
||||
ref="tabLinkElements"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
:class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" />
|
||||
<span class="text-nowrap">{{ link.label }}</span>
|
||||
</RouterLink>
|
||||
<div
|
||||
:class="[
|
||||
'pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1',
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||
{ 'navtabs-transition': transitionsEnabled },
|
||||
]"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
right: sliderRightPx,
|
||||
bottom: sliderBottomPx,
|
||||
opacity: sliderReady && activeIndex !== -1 ? 1 : 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string | RouteLocationRaw
|
||||
shown?: boolean
|
||||
icon?: unknown
|
||||
subpages?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
links: Tab[]
|
||||
query?: string
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const sliderLeft = ref(4)
|
||||
const sliderTop = ref(4)
|
||||
const sliderRight = ref(4)
|
||||
const sliderBottom = ref(4)
|
||||
const activeIndex = ref(-1)
|
||||
const subpageSelected = ref(false)
|
||||
const sliderReady = ref(false)
|
||||
const transitionsEnabled = ref(false)
|
||||
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
|
||||
|
||||
const filteredLinks = computed(() =>
|
||||
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
|
||||
)
|
||||
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
|
||||
const sliderTopPx = computed(() => `${sliderTop.value}px`)
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`)
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
|
||||
|
||||
const leftDelay = computed(() => sliderDelays.value.left)
|
||||
const rightDelay = computed(() => sliderDelays.value.right)
|
||||
const topDelay = computed(() => sliderDelays.value.top)
|
||||
const bottomDelay = computed(() => sliderDelays.value.bottom)
|
||||
|
||||
function pickLink() {
|
||||
let index = -1
|
||||
subpageSelected.value = false
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
|
||||
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
|
||||
index = i
|
||||
break
|
||||
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
|
||||
index = i
|
||||
subpageSelected.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
activeIndex.value = index
|
||||
|
||||
if (activeIndex.value !== -1) {
|
||||
startAnimation()
|
||||
} else {
|
||||
sliderLeft.value = 0
|
||||
sliderRight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function getTabElement(index: number): HTMLElement | null {
|
||||
if (index === -1) return null
|
||||
const container = scrollContainer.value
|
||||
if (!container) return null
|
||||
const tabs = container.querySelectorAll('.button-animation')
|
||||
return (tabs[index] as HTMLElement) ?? null
|
||||
}
|
||||
|
||||
function startAnimation() {
|
||||
const el = getTabElement(activeIndex.value)
|
||||
if (!el?.offsetParent) return
|
||||
|
||||
const parent = el.offsetParent as HTMLElement
|
||||
const newValues = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
||||
|
||||
if (isInitialPosition) {
|
||||
sliderLeft.value = newValues.left
|
||||
sliderRight.value = newValues.right
|
||||
sliderTop.value = newValues.top
|
||||
sliderBottom.value = newValues.bottom
|
||||
sliderReady.value = true
|
||||
requestAnimationFrame(() => {
|
||||
transitionsEnabled.value = true
|
||||
})
|
||||
} else {
|
||||
const STAGGER_DELAY = '200ms'
|
||||
sliderDelays.value = {
|
||||
left: newValues.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
|
||||
right: newValues.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
|
||||
top: newValues.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
|
||||
bottom: newValues.top < sliderTop.value ? STAGGER_DELAY : '0ms',
|
||||
}
|
||||
sliderLeft.value = newValues.left
|
||||
sliderRight.value = newValues.right
|
||||
sliderTop.value = newValues.top
|
||||
sliderBottom.value = newValues.bottom
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', pickLink)
|
||||
await nextTick()
|
||||
pickLink()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', pickLink)
|
||||
})
|
||||
|
||||
watch(
|
||||
filteredLinks,
|
||||
async () => {
|
||||
await nextTick()
|
||||
pickLink()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(route, async () => {
|
||||
await nextTick()
|
||||
pickLink()
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
/* Delay on opacity is to hide any jankiness as the page loads */
|
||||
transition:
|
||||
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
|
||||
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
|
||||
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
|
||||
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
</style>
|
||||
@@ -30,19 +30,19 @@ const deleteConfirmModal = ref()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
|
||||
const title = ref(instance.name)
|
||||
const icon: Ref<string | undefined> = ref(instance.icon_path)
|
||||
const groups = ref(instance.groups)
|
||||
const title = ref(instance.value.name)
|
||||
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
|
||||
const groups = ref([...instance.value.groups])
|
||||
|
||||
const newCategoryInput = ref('')
|
||||
|
||||
const installing = computed(() => instance.install_stage !== 'installed')
|
||||
const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateProfile() {
|
||||
await duplicate(instance.path).catch(handleError)
|
||||
await duplicate(instance.value.path).catch(handleError)
|
||||
trackEvent('InstanceDuplicate', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const availableGroups = computed(() => [
|
||||
|
||||
async function resetIcon() {
|
||||
icon.value = undefined
|
||||
await edit_icon(instance.path, null).catch(handleError)
|
||||
await edit_icon(instance.value.path, null).catch(handleError)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ async function setIcon() {
|
||||
if (!value) return
|
||||
|
||||
icon.value = value
|
||||
await edit_icon(instance.path, icon.value).catch(handleError)
|
||||
await edit_icon(instance.value.path, icon.value).catch(handleError)
|
||||
|
||||
trackEvent('InstanceSetIcon')
|
||||
}
|
||||
@@ -102,7 +102,7 @@ watch(
|
||||
[title, groups, groups],
|
||||
async () => {
|
||||
if (removing.value) return
|
||||
await edit(instance.path, editProfileObject.value).catch(handleError)
|
||||
await edit(instance.value.path, editProfileObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -110,11 +110,11 @@ watch(
|
||||
const removing = ref(false)
|
||||
async function removeProfile() {
|
||||
removing.value = true
|
||||
const path = instance.path
|
||||
const path = instance.value.path
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
|
||||
await router.push({ path: '/' })
|
||||
|
||||
@@ -22,9 +22,11 @@ const { instance } = injectInstanceSettings()
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideHooks = ref(
|
||||
!!instance.hooks.pre_launch || !!instance.hooks.wrapper || !!instance.hooks.post_exit,
|
||||
!!instance.value.hooks.pre_launch ||
|
||||
!!instance.value.hooks.wrapper ||
|
||||
!!instance.value.hooks.post_exit,
|
||||
)
|
||||
const hooks = ref(instance.hooks ?? globalSettings.hooks)
|
||||
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile: {
|
||||
@@ -40,7 +42,7 @@ const editProfileObject = computed(() => {
|
||||
watch(
|
||||
[overrideHooks, hooks],
|
||||
async () => {
|
||||
await edit(instance.path, editProfileObject.value)
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
@@ -73,9 +73,9 @@ const [
|
||||
])
|
||||
|
||||
const { data: modpackInfo } = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.path]),
|
||||
queryFn: () => get_linked_modpack_info(instance.path, 'must_revalidate'),
|
||||
enabled: computed(() => !!instance.linked_data?.project_id && !offline),
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
|
||||
enabled: computed(() => !!instance.value.linked_data?.project_id && !offline),
|
||||
})
|
||||
|
||||
const repairing = ref(false)
|
||||
@@ -101,13 +101,13 @@ function getManifest(loader: string) {
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
const allProfiles = await list()
|
||||
const prefix = `${instance.name} - Backup #`
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allProfiles
|
||||
.filter((p) => p.name.startsWith(prefix))
|
||||
.map((p) => parseInt(p.name.slice(prefix.length), 10))
|
||||
.filter((n) => !isNaN(n))
|
||||
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
|
||||
const newPath = await duplicate(instance.path)
|
||||
const newPath = await duplicate(instance.value.path)
|
||||
await edit(newPath, { name: `${prefix}${nextNum}` })
|
||||
},
|
||||
})
|
||||
@@ -118,27 +118,30 @@ provideInstallationSettings({
|
||||
const rows = [
|
||||
{
|
||||
label: formatMessage(commonMessages.platformLabel),
|
||||
value: formatLoaderLabel(instance.loader),
|
||||
value: formatLoaderLabel(instance.value.loader),
|
||||
},
|
||||
{
|
||||
label: formatMessage(commonMessages.gameVersionLabel),
|
||||
value: instance.game_version,
|
||||
value: instance.value.game_version,
|
||||
},
|
||||
]
|
||||
if (instance.loader !== 'vanilla' && instance.loader_version) {
|
||||
if (instance.value.loader !== 'vanilla' && instance.value.loader_version) {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersion, {
|
||||
loader: formatLoaderLabel(instance.loader),
|
||||
loader: formatLoaderLabel(instance.value.loader),
|
||||
}),
|
||||
value: instance.loader_version,
|
||||
value: instance.value.loader_version,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => !!instance.linked_data?.locked),
|
||||
isLinked: computed(() => !!instance.value.linked_data?.locked),
|
||||
isBusy: computed(
|
||||
() =>
|
||||
instance.install_stage !== 'installed' || repairing.value || reinstalling.value || !!offline,
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
!!offline,
|
||||
),
|
||||
modpack: computed(() => {
|
||||
if (!modpackInfo.value) return null
|
||||
@@ -149,9 +152,9 @@ provideInstallationSettings({
|
||||
versionNumber: modpackInfo.value.version?.version_number,
|
||||
}
|
||||
}),
|
||||
currentPlatform: computed(() => instance.loader),
|
||||
currentGameVersion: computed(() => instance.game_version),
|
||||
currentLoaderVersion: computed(() => instance.loader_version ?? ''),
|
||||
currentPlatform: computed(() => instance.value.loader),
|
||||
currentGameVersion: computed(() => instance.value.game_version),
|
||||
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
|
||||
availablePlatforms: loaders?.value?.map((x) => x.name) ?? [],
|
||||
|
||||
resolveGameVersions(loader, showSnapshots) {
|
||||
@@ -194,50 +197,50 @@ provideInstallationSettings({
|
||||
if (platform !== 'vanilla' && loaderVersionId) {
|
||||
editProfile.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.path, editProfile).catch(handleError)
|
||||
await edit(instance.value.path, editProfile).catch(handleError)
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
await install(instance.path, false).catch(handleError)
|
||||
await install(instance.value.path, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
},
|
||||
|
||||
async repair() {
|
||||
repairing.value = true
|
||||
await install(instance.path, true).catch(handleError)
|
||||
await install(instance.value.path, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
reinstalling.value = true
|
||||
await update_repair_modrinth(instance.path).catch(handleError)
|
||||
await update_repair_modrinth(instance.value.path).catch(handleError)
|
||||
reinstalling.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
await edit(instance.path, {
|
||||
await edit(instance.value.path, {
|
||||
linked_data: null as unknown as undefined,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.path],
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
onUnlinked()
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
const versions = await get_project_versions(instance.linked_data!.project_id!).catch(
|
||||
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
|
||||
handleError,
|
||||
)
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
@@ -250,20 +253,20 @@ provideInstallationSettings({
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
await update_managed_modrinth_version(instance.path, version.id)
|
||||
await update_managed_modrinth_version(instance.value.path, version.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.path],
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: true,
|
||||
currentVersionId:
|
||||
modpackInfo.value?.update_version_id ?? instance.linked_data?.version_id ?? '',
|
||||
modpackInfo.value?.update_version_id ?? instance.value.linked_data?.version_id ?? '',
|
||||
projectIconUrl: modpackInfo.value?.project?.icon_url,
|
||||
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
|
||||
currentGameVersion: instance.game_version,
|
||||
currentLoader: instance.loader,
|
||||
currentGameVersion: instance.value.game_version,
|
||||
currentLoader: instance.value.loader,
|
||||
})),
|
||||
|
||||
isServer: false,
|
||||
|
||||
@@ -25,20 +25,24 @@ const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.java_path)
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.path).catch(handleError))
|
||||
const javaInstall = ref({ path: optimalJava.path ?? instance.java_path })
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
|
||||
const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
|
||||
|
||||
const overrideJavaArgs = ref((instance.extra_launch_args?.length ?? 0) > 0)
|
||||
const javaArgs = ref((instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '))
|
||||
|
||||
const overrideEnvVars = ref((instance.custom_env_vars?.length ?? 0) > 0)
|
||||
const envVars = ref(
|
||||
(instance.custom_env_vars ?? globalSettings.custom_env_vars).map((x) => x.join('=')).join(' '),
|
||||
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
|
||||
const javaArgs = ref(
|
||||
(instance.value.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
|
||||
)
|
||||
|
||||
const overrideMemorySettings = ref(!!instance.memory)
|
||||
const memory = ref(instance.memory ?? globalSettings.memory)
|
||||
const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
|
||||
const envVars = ref(
|
||||
(instance.value.custom_env_vars ?? globalSettings.custom_env_vars)
|
||||
.map((x) => x.join('='))
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
const overrideMemorySettings = ref(!!instance.value.memory)
|
||||
const memory = ref(instance.value.memory ?? globalSettings.memory)
|
||||
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
|
||||
maxMemory: number
|
||||
snapPoints: number[]
|
||||
@@ -76,7 +80,7 @@ watch(
|
||||
memory,
|
||||
],
|
||||
async () => {
|
||||
await edit(instance.path, editProfileObject.value)
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
@@ -22,12 +22,14 @@ const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideWindowSettings = ref(!!instance.game_resolution || !!instance.force_fullscreen)
|
||||
const overrideWindowSettings = ref(
|
||||
!!instance.value.game_resolution || !!instance.value.force_fullscreen,
|
||||
)
|
||||
const resolution: Ref<[number, number]> = ref(
|
||||
instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
|
||||
instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
|
||||
)
|
||||
const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
@@ -46,7 +48,7 @@ const editProfileObject = computed(() => {
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting],
|
||||
async () => {
|
||||
await edit(instance.path, editProfileObject.value)
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
@@ -44,8 +44,10 @@ const emit = defineEmits<{
|
||||
const isMinecraftServer = ref(false)
|
||||
const handleUnlinked = () => emit('unlinked')
|
||||
|
||||
const instanceRef = computed(() => props.instance)
|
||||
|
||||
provideInstanceSettings({
|
||||
instance: props.instance,
|
||||
instance: instanceRef,
|
||||
offline: props.offline,
|
||||
isMinecraftServer,
|
||||
onUnlinked: handleUnlinked,
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody, { instanceName }) }}
|
||||
</Admonition>
|
||||
<p class="m-0 text-secondary">
|
||||
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
|
||||
<template #bold="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="handleGoToInstance">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.goToInstance) }}
|
||||
<button class="!border !border-surface-4" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleGoToInstance">
|
||||
{{ formatMessage(messages.instance) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="handleCreateAnyway">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createAnyway) }}
|
||||
{{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -24,8 +34,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -35,21 +52,18 @@ const messages = defineMessages({
|
||||
id: 'app.instance.modpack-already-installed.header',
|
||||
defaultMessage: 'Modpack already installed',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'app.instance.modpack-already-installed.admonition-header',
|
||||
defaultMessage: 'Duplicate modpack',
|
||||
body: {
|
||||
id: 'app.instance.modpack-already-installed.body',
|
||||
defaultMessage:
|
||||
'This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'app.instance.modpack-already-installed.admonition-body',
|
||||
defaultMessage: 'This modpack is already installed in the "{instanceName}" instance.',
|
||||
instance: {
|
||||
id: 'app.instance.modpack-already-installed.instance',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
goToInstance: {
|
||||
id: 'app.instance.modpack-already-installed.go-to-instance',
|
||||
defaultMessage: 'Go to instance',
|
||||
},
|
||||
createAnyway: {
|
||||
id: 'app.instance.modpack-already-installed.create-anyway',
|
||||
defaultMessage: 'Create anyway',
|
||||
create: {
|
||||
id: 'app.instance.modpack-already-installed.create',
|
||||
defaultMessage: 'Create',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -68,6 +82,10 @@ function show(name: string, path: string) {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleGoToInstance() {
|
||||
modal.value?.hide()
|
||||
emit('go-to-instance', instancePath.value)
|
||||
|
||||
@@ -189,6 +189,22 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.linked_server',
|
||||
defaultMessage: 'Managed by server project',
|
||||
},
|
||||
incompatibleVersion: {
|
||||
id: 'app.world.world-item.incompatible-version',
|
||||
defaultMessage: 'Incompatible version {version}',
|
||||
},
|
||||
playersOnline: {
|
||||
id: 'app.world.world-item.players-online',
|
||||
defaultMessage: '{count} online',
|
||||
},
|
||||
offline: {
|
||||
id: 'app.world.world-item.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
notPlayedYet: {
|
||||
id: 'app.world.world-item.not-played-yet',
|
||||
defaultMessage: 'Not played yet',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
@@ -243,13 +259,17 @@ const messages = defineMessages({
|
||||
>
|
||||
<template v-if="refreshing">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
|
||||
Loading...
|
||||
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||
</template>
|
||||
<template v-else-if="serverStatus">
|
||||
<template v-if="serverIncompatible">
|
||||
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
|
||||
<span class="text-orange">
|
||||
Incompatible version {{ serverStatus.version?.name }}
|
||||
{{
|
||||
formatMessage(messages.incompatibleVersion, {
|
||||
version: serverStatus.version?.name,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -265,8 +285,11 @@ const messages = defineMessages({
|
||||
/>
|
||||
<Tooltip :disabled="!hasPlayersTooltip">
|
||||
<span :class="{ 'cursor-help': hasPlayersTooltip }">
|
||||
{{ formatNumber(serverStatus.players?.online) }}
|
||||
online
|
||||
{{
|
||||
formatMessage(messages.playersOnline, {
|
||||
count: formatNumber(serverStatus.players?.online ?? 0),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template #popper>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -280,7 +303,7 @@ const messages = defineMessages({
|
||||
</template>
|
||||
<template v-else>
|
||||
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
|
||||
Offline
|
||||
{{ formatMessage(messages.offline) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,7 +322,7 @@ const messages = defineMessages({
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else> Not played yet </template>
|
||||
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
|
||||
</div>
|
||||
<template v-if="instancePath">
|
||||
•
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
|
||||
@@ -26,10 +25,10 @@ const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const modal = ref()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const name = ref()
|
||||
const address = ref()
|
||||
const name = ref('')
|
||||
const address = ref('')
|
||||
const resourcePack = ref<ServerPackStatus>('enabled')
|
||||
|
||||
async function addServer(play: boolean) {
|
||||
@@ -60,11 +59,11 @@ function show() {
|
||||
name.value = ''
|
||||
address.value = ''
|
||||
resourcePack.value = 'enabled'
|
||||
modal.value.show()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -85,37 +84,33 @@ const messages = defineMessages({
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal">
|
||||
<template #title>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<InstanceModalTitlePrefix :instance="instance" />
|
||||
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" width="500px" max-width="500px">
|
||||
<ServerModalBody
|
||||
v-model:name="name"
|
||||
v-model:address="address"
|
||||
v-model:resource-pack="resourcePack"
|
||||
/>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!address" @click="addServer(true)">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(messages.addAndPlay) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="!address" @click="addServer(false)">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="!address" @click="addServer(false)">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!address" @click="addServer(true)">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(messages.addAndPlay) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
commonMessages,
|
||||
defineMessage,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
|
||||
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
@@ -32,7 +32,7 @@ const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const modal = ref()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const name = ref<string>('')
|
||||
const address = ref<string>('')
|
||||
@@ -81,11 +81,11 @@ function show(server: ServerWorld) {
|
||||
index.value = server.index
|
||||
displayStatus.value = server.display_status
|
||||
hideFromHome.value = server.display_status === 'hidden'
|
||||
modal.value.show()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
@@ -96,29 +96,28 @@ const titleMessage = defineMessage({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal">
|
||||
<template #title>
|
||||
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
|
||||
</template>
|
||||
<NewModal ref="modal" :header="formatMessage(titleMessage)" width="500px" max-width="500px">
|
||||
<ServerModalBody
|
||||
v-model:name="name"
|
||||
v-model:address="address"
|
||||
v-model:resource-pack="resourcePack"
|
||||
/>
|
||||
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!address" @click="saveServer">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!address" @click="saveServer">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@@ -49,34 +49,40 @@ const messages = defineMessages({
|
||||
id: 'instance.server-modal.placeholder-name',
|
||||
defaultMessage: 'Minecraft Server',
|
||||
},
|
||||
placeholderAddress: {
|
||||
id: 'app.world.server-modal.placeholder-address',
|
||||
defaultMessage: 'example.modrinth.gg',
|
||||
},
|
||||
selectAnOption: {
|
||||
id: 'app.world.server-modal.select-an-option',
|
||||
defaultMessage: 'Select an option',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ resourcePackOptions })
|
||||
</script>
|
||||
<template>
|
||||
<div class="w-[450px]">
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
v-model="name"
|
||||
:placeholder="formatMessage(messages.placeholderName)"
|
||||
autocomplete="off"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
||||
{{ formatMessage(messages.address) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
v-model="address"
|
||||
placeholder="example.modrinth.gg"
|
||||
autocomplete="off"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
||||
{{ formatMessage(messages.resourcePack) }}
|
||||
</h2>
|
||||
<div>
|
||||
<div class="space-y-4 w-full">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
||||
<StyledInput
|
||||
v-model="name"
|
||||
:placeholder="formatMessage(messages.placeholderName)"
|
||||
autocomplete="off"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.address) }}</span>
|
||||
<StyledInput
|
||||
v-model="address"
|
||||
:placeholder="formatMessage(messages.placeholderAddress)"
|
||||
autocomplete="off"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.resourcePack) }}</span>
|
||||
<Combobox
|
||||
v-model="resourcePack"
|
||||
:options="
|
||||
@@ -89,9 +95,9 @@ defineExpose({ resourcePackOptions })
|
||||
:display-value="
|
||||
resourcePack
|
||||
? formatMessage(resourcePackOptionMessages[resourcePack])
|
||||
: 'Select an option'
|
||||
: formatMessage(messages.selectAnOption)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,6 +30,8 @@ export type ServerWorld = BaseWorld & {
|
||||
index: number
|
||||
address: string
|
||||
pack_status: ServerPackStatus
|
||||
project_id?: string
|
||||
content_kind?: string
|
||||
}
|
||||
|
||||
export type World = SingleplayerWorld | ServerWorld
|
||||
@@ -140,12 +142,16 @@ export async function add_server_to_profile(
|
||||
name: string,
|
||||
address: string,
|
||||
packStatus: ServerPackStatus,
|
||||
projectId?: string,
|
||||
contentKind?: string,
|
||||
): Promise<number> {
|
||||
return await invoke('plugin:worlds|add_server_to_profile', {
|
||||
path,
|
||||
name,
|
||||
address,
|
||||
packStatus,
|
||||
projectId,
|
||||
contentKind,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,39 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Cannot reach authentication servers"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"message": "Add server to instance"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Add servers to your instance"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Add to instance"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "Add to {instanceName}"
|
||||
},
|
||||
"app.browse.added": {
|
||||
"message": "Added"
|
||||
},
|
||||
"app.browse.already-added": {
|
||||
"message": "Already added"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Discover content"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Discover servers"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Hide added servers"
|
||||
},
|
||||
"app.browse.hide-installed-content": {
|
||||
"message": "Hide installed content"
|
||||
},
|
||||
"app.browse.install-content-to-instance": {
|
||||
"message": "Install content to instance"
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Enter modpack description..."
|
||||
},
|
||||
@@ -41,33 +74,21 @@
|
||||
"app.instance.confirm-delete.header": {
|
||||
"message": "Delete instance"
|
||||
},
|
||||
"app.instance.modpack-already-installed.admonition-body": {
|
||||
"message": "This modpack is already installed in the \"{instanceName}\" instance."
|
||||
"app.instance.modpack-already-installed.body": {
|
||||
"message": "This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?"
|
||||
},
|
||||
"app.instance.modpack-already-installed.admonition-header": {
|
||||
"message": "Duplicate modpack"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create-anyway": {
|
||||
"message": "Create anyway"
|
||||
},
|
||||
"app.instance.modpack-already-installed.go-to-instance": {
|
||||
"message": "Go to instance"
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "Create"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "Modpack already installed"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "Instance"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "project"
|
||||
},
|
||||
"app.instance.mods.copy-link": {
|
||||
"message": "Copy link"
|
||||
},
|
||||
"app.instance.mods.installing": {
|
||||
"message": "Installing..."
|
||||
},
|
||||
"app.instance.mods.modpack-fallback": {
|
||||
"message": "Modpack"
|
||||
},
|
||||
"app.instance.mods.project-was-added": {
|
||||
"message": "\"{name}\" was added"
|
||||
},
|
||||
@@ -80,17 +101,53 @@
|
||||
"app.instance.mods.share-title": {
|
||||
"message": "Sharing modpack content"
|
||||
},
|
||||
"app.instance.mods.show-file": {
|
||||
"message": "Show file"
|
||||
},
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "Successfully uploaded"
|
||||
},
|
||||
"app.instance.mods.unknown-version": {
|
||||
"message": "Unknown"
|
||||
"app.instance.worlds.add-server": {
|
||||
"message": "Add server"
|
||||
},
|
||||
"app.instance.mods.updating": {
|
||||
"message": "Updating..."
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Browse servers"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "'{name}' will be **permanently deleted**, and there will be no way to recover it."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Are you sure you want to permanently delete this world?"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Modded"
|
||||
},
|
||||
"app.instance.worlds.filter-offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.instance.worlds.filter-online": {
|
||||
"message": "Online"
|
||||
},
|
||||
"app.instance.worlds.filter-vanilla": {
|
||||
"message": "Vanilla"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-description": {
|
||||
"message": "Add a server or browse to get started"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "No servers or worlds added"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "'{name}' will be removed from your list, including in-game, and there will be no way to recover it."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "'{name}' ({address}) will be removed from your list, including in-game, and there will be no way to recover it."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Are you sure you want to remove {name}?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Search {count} worlds..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "this server"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Content required"
|
||||
@@ -197,6 +254,24 @@
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Reload to install update"
|
||||
},
|
||||
"app.world.server-modal.placeholder-address": {
|
||||
"message": "example.modrinth.gg"
|
||||
},
|
||||
"app.world.server-modal.select-an-option": {
|
||||
"message": "Select an option"
|
||||
},
|
||||
"app.world.world-item.incompatible-version": {
|
||||
"message": "Incompatible version {version}"
|
||||
},
|
||||
"app.world.world-item.not-played-yet": {
|
||||
"message": "Not played yet"
|
||||
},
|
||||
"app.world.world-item.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.world.world-item.players-online": {
|
||||
"message": "{count} online"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Add a friend"
|
||||
},
|
||||
@@ -296,6 +371,12 @@
|
||||
"instance.edit-world.title": {
|
||||
"message": "Edit world"
|
||||
},
|
||||
"instance.files.adding-files": {
|
||||
"message": "Adding files ({completed}/{total})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Save as..."
|
||||
},
|
||||
"instance.server-modal.address": {
|
||||
"message": "Address"
|
||||
},
|
||||
@@ -467,9 +548,6 @@
|
||||
"instance.worlds.dont_show_on_home": {
|
||||
"message": "Don't show on Home"
|
||||
},
|
||||
"instance.worlds.filter.available": {
|
||||
"message": "Available"
|
||||
},
|
||||
"instance.worlds.game_already_open": {
|
||||
"message": "Instance is already open"
|
||||
},
|
||||
@@ -494,12 +572,6 @@
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Play instance"
|
||||
},
|
||||
"instance.worlds.type.server": {
|
||||
"message": "Server"
|
||||
},
|
||||
"instance.worlds.type.singleplayer": {
|
||||
"message": "Singleplayer"
|
||||
},
|
||||
"instance.worlds.view_instance": {
|
||||
"message": "View instance"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -133,6 +133,8 @@ export function createContentInstall(opts: {
|
||||
title: string
|
||||
icon_url?: string | null
|
||||
project_type?: string
|
||||
organization?: string | null
|
||||
team?: string
|
||||
},
|
||||
version?: Labrinth.Versions.v2.Version,
|
||||
) {
|
||||
@@ -164,6 +166,60 @@ export function createContentInstall(opts: {
|
||||
if (items.some((i) => i.file_name === placeholder.file_name)) return
|
||||
next.set(instancePath, [...items, placeholder])
|
||||
installingItems.value = next
|
||||
|
||||
if (project.organization) {
|
||||
get_organization(project.organization)
|
||||
.then((org: { id: string; slug: string; name: string; icon_url?: string }) => {
|
||||
updateInstallingItem(instancePath, placeholder.file_name, {
|
||||
owner: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
avatar_url: org.icon_url,
|
||||
type: 'organization',
|
||||
},
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
} else if (project.team) {
|
||||
get_team(project.team)
|
||||
.then(
|
||||
(
|
||||
members: {
|
||||
user: { id: string; username: string; avatar_url?: string }
|
||||
is_owner: boolean
|
||||
}[],
|
||||
) => {
|
||||
const owner = members.find((m) => m.is_owner)
|
||||
if (owner) {
|
||||
updateInstallingItem(instancePath, placeholder.file_name, {
|
||||
owner: {
|
||||
id: owner.user.id,
|
||||
name: owner.user.username,
|
||||
avatar_url: owner.user.avatar_url,
|
||||
type: 'user',
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function updateInstallingItem(
|
||||
instancePath: string,
|
||||
fileName: string,
|
||||
updates: Partial<ContentItem>,
|
||||
) {
|
||||
const next = new Map(installingItems.value)
|
||||
const items = next.get(instancePath)
|
||||
if (!items) return
|
||||
const index = items.findIndex((i) => i.file_name === fileName)
|
||||
if (index === -1) return
|
||||
const updated = [...items]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
next.set(instancePath, updated)
|
||||
installingItems.value = next
|
||||
}
|
||||
|
||||
function removeInstallingItems(instancePath: string, projectIds: string[]) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export interface InstanceSettingsContext {
|
||||
instance: GameInstance
|
||||
instance: ComputedRef<GameInstance>
|
||||
offline?: boolean
|
||||
isMinecraftServer: Ref<boolean>
|
||||
onUnlinked: () => void
|
||||
|
||||
@@ -41,7 +41,8 @@ export default new createRouter({
|
||||
name: 'Discover content',
|
||||
component: Pages.Browse,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Discover content' }],
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?BrowseTitle' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -178,6 +179,15 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'Files',
|
||||
component: Instance.Files,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Files' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'Logs',
|
||||
|
||||
Reference in New Issue
Block a user