You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '74cf3f076eff43755bb4bef62f1c1bb3fc0e6c2a' into feature-clean
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import RowDisplay from '@/components/RowDisplay.vue'
|
||||
@@ -8,19 +8,32 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
|
||||
const featuredModpacks = ref({})
|
||||
const featuredMods = ref({})
|
||||
const filter = ref('')
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
|
||||
|
||||
const recentInstances = ref([])
|
||||
const instances = ref<GameInstance[]>([])
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
const featuredModpacks = ref<SearchResult[]>([])
|
||||
const featuredMods = ref<SearchResult[]>([])
|
||||
const installedModpacksFilter = ref('')
|
||||
|
||||
const recentInstances = computed(() =>
|
||||
instances.value
|
||||
.filter((x) => x.last_played)
|
||||
.slice()
|
||||
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
|
||||
)
|
||||
|
||||
const hasFeaturedProjects = computed(
|
||||
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
|
||||
)
|
||||
|
||||
const offline = ref<boolean>(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
offline.value = true
|
||||
})
|
||||
@@ -28,34 +41,21 @@ window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const getInstances = async () => {
|
||||
const profiles = await list().catch(handleError)
|
||||
|
||||
recentInstances.value = profiles
|
||||
.filter((x) => x.last_played)
|
||||
.sort((a, b) => {
|
||||
const dateA = dayjs(a.last_played)
|
||||
const dateB = dayjs(b.last_played)
|
||||
|
||||
if (dateA.isSame(dateB)) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
async function fetchInstances() {
|
||||
instances.value = await list().catch(handleError)
|
||||
|
||||
const filters = []
|
||||
for (const instance of profiles) {
|
||||
for (const instance of instances.value) {
|
||||
if (instance.linked_data && instance.linked_data.project_id) {
|
||||
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
|
||||
}
|
||||
}
|
||||
filter.value = filters.join(' AND ')
|
||||
installedModpacksFilter.value = filters.join(' AND ')
|
||||
}
|
||||
|
||||
const getFeaturedModpacks = async () => {
|
||||
async function fetchFeaturedModpacks() {
|
||||
const response = await get_search_results(
|
||||
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
|
||||
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
|
||||
)
|
||||
|
||||
if (response) {
|
||||
@@ -64,7 +64,8 @@ const getFeaturedModpacks = async () => {
|
||||
featuredModpacks.value = []
|
||||
}
|
||||
}
|
||||
const getFeaturedMods = async () => {
|
||||
|
||||
async function fetchFeaturedMods() {
|
||||
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
|
||||
|
||||
if (response) {
|
||||
@@ -74,27 +75,21 @@ const getFeaturedMods = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
async function refreshFeaturedProjects() {
|
||||
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
|
||||
}
|
||||
|
||||
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
|
||||
await fetchInstances()
|
||||
await refreshFeaturedProjects()
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e) => {
|
||||
await getInstances()
|
||||
await fetchInstances()
|
||||
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
})
|
||||
|
||||
// computed sums of recentInstances, featuredModpacks, featuredMods, treating them as arrays if they are not
|
||||
const total = computed(() => {
|
||||
return (
|
||||
(recentInstances.value?.length ?? 0) +
|
||||
(featuredModpacks.value?.length ?? 0) +
|
||||
(featuredMods.value?.length ?? 0)
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
})
|
||||
@@ -104,17 +99,10 @@ onUnmounted(() => {
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl">Welcome to AstralRinth App!</h1>
|
||||
<RecentWorldsList :recent-instances="recentInstances" />
|
||||
<RowDisplay
|
||||
v-if="total > 0"
|
||||
v-if="hasFeaturedProjects"
|
||||
:instances="[
|
||||
{
|
||||
label: 'Recently played',
|
||||
route: '/library',
|
||||
instances: recentInstances,
|
||||
instance: true,
|
||||
downloaded: true,
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
label: 'Discover a modpack',
|
||||
route: '/browse/modpack',
|
||||
|
||||
4
apps/app-frontend/src/pages/Worlds.vue
Normal file
4
apps/app-frontend/src/pages/Worlds.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">Worlds</div>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
import Index from './Index.vue'
|
||||
import Browse from './Browse.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Index, Browse }
|
||||
export { Index, Browse, Worlds }
|
||||
|
||||
@@ -1,152 +1,156 @@
|
||||
<template>
|
||||
<div
|
||||
class="p-6 pr-2 pb-4"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||
>
|
||||
<ExportModal ref="exportModal" :instance="instance" />
|
||||
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ instance.name }}
|
||||
</template>
|
||||
<template #summary> </template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
||||
>
|
||||
<GameIcon class="h-6 w-6 text-secondary" />
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<TimerIcon class="h-6 w-6 text-secondary" />
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="instance.install_stage.includes('installing')"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Installing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="instance.install_stage !== 'installed'"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="repairInstance()">
|
||||
<DownloadIcon />
|
||||
Repair
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||
<button @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="playing === false && loading === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="startInstance('InstancePage')">
|
||||
<PlayIcon />
|
||||
Play
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="loading === true && playing === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Loading...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'open-folder',
|
||||
action: () => showProfileInFolder(instance.path),
|
||||
},
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
action: () => $refs.exportModal.show(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
||||
<template #host-a-server> <ServerIcon /> Create a server </template>
|
||||
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</div>
|
||||
<div class="px-6">
|
||||
<NavTabs :links="tabs" />
|
||||
</div>
|
||||
<div class="p-6 pt-4">
|
||||
<RouterView v-slot="{ Component }" :key="instance.path">
|
||||
<template v-if="Component">
|
||||
<Suspense
|
||||
:key="instance.path"
|
||||
@pending="loadingBar.startLoading()"
|
||||
@resolve="loadingBar.stopLoading()"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:instance="instance"
|
||||
:options="options"
|
||||
:offline="offline"
|
||||
:playing="playing"
|
||||
:versions="modrinthVersions"
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
></component>
|
||||
<template #fallback>
|
||||
<LoadingIndicator />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy links</template>
|
||||
<template #toggle><EditIcon />Toggle selected</template>
|
||||
<template #disable><XIcon />Disable selected</template>
|
||||
<template #enable><CheckCircleIcon />Enable selected</template>
|
||||
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
||||
<template #update_all
|
||||
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
||||
<div>
|
||||
<div
|
||||
class="p-6 pr-2 pb-4"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||
>
|
||||
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
||||
</ContextMenu>
|
||||
<ExportModal ref="exportModal" :instance="instance" />
|
||||
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ instance.name }}
|
||||
</template>
|
||||
<template #summary> </template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
||||
>
|
||||
<GameIcon class="h-6 w-6 text-secondary" />
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<TimerIcon class="h-6 w-6 text-secondary" />
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="instance.install_stage.includes('installing')"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Installing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="instance.install_stage !== 'installed'"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="repairInstance()">
|
||||
<DownloadIcon />
|
||||
Repair
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||
<button @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="playing === false && loading === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="startInstance('InstancePage')">
|
||||
<PlayIcon />
|
||||
Play
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="loading === true && playing === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Loading...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'open-folder',
|
||||
action: () => showProfileInFolder(instance.path),
|
||||
},
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
action: () => $refs.exportModal.show(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
||||
<template #host-a-server> <ServerIcon /> Create a server </template>
|
||||
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</div>
|
||||
<div class="px-6">
|
||||
<NavTabs :links="tabs" />
|
||||
</div>
|
||||
<div v-if="!!instance" class="p-6 pt-4">
|
||||
<RouterView v-slot="{ Component }" :key="instance.path">
|
||||
<template v-if="Component">
|
||||
<Suspense
|
||||
:key="instance.path"
|
||||
@pending="loadingBar.startLoading()"
|
||||
@resolve="loadingBar.stopLoading()"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:instance="instance"
|
||||
:options="options"
|
||||
:offline="offline"
|
||||
:playing="playing"
|
||||
:versions="modrinthVersions"
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
@play="updatePlayState"
|
||||
@stop="() => stopInstance('InstanceSubpage')"
|
||||
></component>
|
||||
<template #fallback>
|
||||
<LoadingIndicator />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy links</template>
|
||||
<template #toggle><EditIcon />Toggle selected</template>
|
||||
<template #disable><XIcon />Disable selected</template>
|
||||
<template #enable><CheckCircleIcon />Enable selected</template>
|
||||
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
||||
<template #update_all
|
||||
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
||||
>
|
||||
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -238,6 +242,10 @@ async function fetchInstance() {
|
||||
})
|
||||
}
|
||||
|
||||
await updatePlayState()
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
@@ -253,14 +261,20 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: 'Content',
|
||||
href: `/instance/${encodeURIComponent(route.params.id)}`,
|
||||
href: `${basePath.value}`,
|
||||
},
|
||||
{
|
||||
label: 'Worlds',
|
||||
href: `${basePath.value}/worlds`,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
href: `/instance/${encodeURIComponent(route.params.id)}/logs`,
|
||||
href: `${basePath.value}/logs`,
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -117,15 +117,37 @@ const route = useRoute()
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,251 +1,252 @@
|
||||
<template>
|
||||
<template v-if="projects?.length > 0">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="iconified-input flex-grow">
|
||||
<SearchIcon />
|
||||
<input
|
||||
v-model="searchFilter"
|
||||
type="text"
|
||||
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
|
||||
class="text-input search-input"
|
||||
autocomplete="off"
|
||||
<div>
|
||||
<template v-if="projects?.length > 0">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="iconified-input flex-grow">
|
||||
<SearchIcon />
|
||||
<input
|
||||
v-model="searchFilter"
|
||||
type="text"
|
||||
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
|
||||
class="text-input search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (searchFilter = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in filterOptions"
|
||||
:key="`content-filter-${filter.id}`"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleArray(selectedFilters, filter.id)"
|
||||
>
|
||||
{{ filter.formattedName }}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (searchFilter = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in filterOptions"
|
||||
:key="filter"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleArray(selectedFilters, filter.id)"
|
||||
>
|
||||
{{ filter.formattedName }}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ContentListPanel
|
||||
v-model="selectedFiles"
|
||||
:locked="isPackLocked"
|
||||
:items="
|
||||
search.map((x) => {
|
||||
const item: ContentItem<any> = {
|
||||
path: x.path,
|
||||
disabled: x.disabled,
|
||||
filename: x.file_name,
|
||||
icon: x.icon,
|
||||
title: x.name,
|
||||
data: x,
|
||||
}
|
||||
|
||||
if (x.version) {
|
||||
item.version = x.version
|
||||
item.versionId = x.version
|
||||
}
|
||||
|
||||
if (x.id) {
|
||||
item.project = {
|
||||
id: x.id,
|
||||
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
|
||||
linkProps: {},
|
||||
<ContentListPanel
|
||||
v-model="selectedFiles"
|
||||
:locked="isPackLocked"
|
||||
:items="
|
||||
search.map((x) => {
|
||||
const item: ContentItem<any> = {
|
||||
path: x.path,
|
||||
disabled: x.disabled,
|
||||
filename: x.file_name,
|
||||
icon: x.icon ?? undefined,
|
||||
title: x.name,
|
||||
data: x,
|
||||
}
|
||||
}
|
||||
|
||||
if (x.author) {
|
||||
item.creator = {
|
||||
name: x.author.name,
|
||||
type: x.author.type,
|
||||
id: x.author.slug,
|
||||
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
|
||||
linkProps: { target: '_blank' },
|
||||
if (x.version) {
|
||||
item.version = x.version
|
||||
item.versionId = x.version
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
"
|
||||
:sort-column="sortColumn"
|
||||
:sort-ascending="ascending"
|
||||
:update-sort="sortProjects"
|
||||
:current-page="currentPage"
|
||||
>
|
||||
<template v-if="selectedProjects.length > 0" #headers>
|
||||
<div class="flex gap-2">
|
||||
if (x.id) {
|
||||
item.project = {
|
||||
id: x.id,
|
||||
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
|
||||
linkProps: {},
|
||||
}
|
||||
}
|
||||
|
||||
if (x.author) {
|
||||
item.creator = {
|
||||
name: x.author.name,
|
||||
type: x.author.type,
|
||||
id: x.author.slug,
|
||||
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
|
||||
linkProps: { target: '_blank' },
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
"
|
||||
:sort-column="sortColumn"
|
||||
:sort-ascending="ascending"
|
||||
:update-sort="sortProjects"
|
||||
:current-page="currentPage"
|
||||
>
|
||||
<template v-if="selectedProjects.length > 0" #headers>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
>
|
||||
<button @click="updateSelected()"><DownloadIcon /> Update</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'share-names',
|
||||
action: () => shareNames(),
|
||||
},
|
||||
{
|
||||
id: 'share-file-names',
|
||||
action: () => shareFileNames(),
|
||||
},
|
||||
{
|
||||
id: 'share-urls',
|
||||
action: () => shareUrls(),
|
||||
},
|
||||
{
|
||||
id: 'share-markdown',
|
||||
action: () => shareMarkdown(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ShareIcon /> Share <DropdownIcon />
|
||||
<template #share-names> <TextInputIcon /> Project names </template>
|
||||
<template #share-file-names> <FileIcon /> File names </template>
|
||||
<template #share-urls> <LinkIcon /> Project links </template>
|
||||
<template #share-markdown> <CodeIcon /> Markdown links </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
|
||||
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
|
||||
<button @click="disableAll()"><SlashIcon /> Disable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
|
||||
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
|
||||
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
@click="updateAll"
|
||||
>
|
||||
<button class="w-max"><DownloadIcon /> Update all</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="canUpdatePack"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
>
|
||||
<button @click="updateSelected()"><DownloadIcon /> Update</button>
|
||||
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()">
|
||||
<DownloadIcon /> Update pack
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && (item.data as any).outdated"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="`Update`"
|
||||
:disabled="(item.data as ProjectListEntry).updating"
|
||||
@click="updateProject(item.data)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="w-[36px]"></div>
|
||||
<Toggle
|
||||
class="!mx-2"
|
||||
:model-value="!item.data.disabled"
|
||||
@update:model-value="toggleDisableMod(item.data)"
|
||||
/>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button v-tooltip="'Remove'" @click="removeMod(item)">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'share-names',
|
||||
action: () => shareNames(),
|
||||
id: 'show-file',
|
||||
action: () => highlightModInProfile(instance.path, item.path),
|
||||
},
|
||||
{
|
||||
id: 'share-file-names',
|
||||
action: () => shareFileNames(),
|
||||
},
|
||||
{
|
||||
id: 'share-urls',
|
||||
action: () => shareUrls(),
|
||||
},
|
||||
{
|
||||
id: 'share-markdown',
|
||||
action: () => shareMarkdown(),
|
||||
id: 'copy-link',
|
||||
shown: item.data !== undefined && item.data.slug !== undefined,
|
||||
action: () => copyModLink(item),
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
>
|
||||
<ShareIcon /> Share <DropdownIcon />
|
||||
<template #share-names> <TextInputIcon /> Project names </template>
|
||||
<template #share-file-names> <FileIcon /> File names </template>
|
||||
<template #share-urls> <LinkIcon /> Project links </template>
|
||||
<template #share-markdown> <CodeIcon /> Markdown links </template>
|
||||
<MoreVerticalIcon />
|
||||
<template #show-file> <ExternalIcon /> Show file </template>
|
||||
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
|
||||
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
|
||||
<button @click="disableAll()"><SlashIcon /> Disable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
|
||||
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
@click="updateAll"
|
||||
>
|
||||
<button class="w-max"><DownloadIcon /> Update all</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="canUpdatePack"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
>
|
||||
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
|
||||
<DownloadIcon /> Update pack
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && (item.data as any).outdated"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="`Update`"
|
||||
:disabled="(item.data as any).updating"
|
||||
@click="updateProject(item.data)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="w-[36px]"></div>
|
||||
<Toggle
|
||||
class="!mx-2"
|
||||
:model-value="!item.data.disabled"
|
||||
@update:model-value="toggleDisableMod(item.data)"
|
||||
</template>
|
||||
</ContentListPanel>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button v-tooltip="'Remove'" @click="removeMod(item)">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'show-file',
|
||||
action: () => highlightModInProfile(instance.path, item.path),
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
shown: item.data !== undefined && item.data.slug !== undefined,
|
||||
action: () => copyModLink(item),
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
</div>
|
||||
</template>
|
||||
<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" class="h-24" />
|
||||
<span class="text-contrast font-bold text-xl"
|
||||
>You haven't added any content to this instance yet.</span
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #show-file> <ExternalIcon /> Show file </template>
|
||||
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ContentListPanel>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="w-full flex flex-col items-center justify-center mt-6 max-w-[48rem] mx-auto">
|
||||
<div class="top-box w-full">
|
||||
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
|
||||
<span class="text-contrast font-bold text-xl"
|
||||
>You haven't added any content to this instance yet.</span
|
||||
>
|
||||
</div>
|
||||
</RadialHeader>
|
||||
<div class="flex mt-4 mx-auto">
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-box-divider"></div>
|
||||
<div class="flex items-center gap-6 py-4">
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
<ShareModalWrapper
|
||||
ref="shareModal"
|
||||
share-title="Sharing modpack content"
|
||||
share-text="Check out the projects I'm using in my modpack!"
|
||||
:open-in-new-tab="false"
|
||||
/>
|
||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||
<ModpackVersionModal
|
||||
v-if="instance.linked_data"
|
||||
ref="modpackVersionModal"
|
||||
:instance="instance"
|
||||
:versions="props.versions"
|
||||
/>
|
||||
</div>
|
||||
<ShareModalWrapper
|
||||
ref="shareModal"
|
||||
share-title="Sharing modpack content"
|
||||
share-text="Check out the projects I'm using in my modpack!"
|
||||
:open-in-new-tab="false"
|
||||
/>
|
||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||
<ModpackVersionModal
|
||||
v-if="instance.linked_data"
|
||||
ref="modpackVersionModal"
|
||||
:instance="instance"
|
||||
:versions="props.versions"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -272,8 +273,10 @@ import {
|
||||
ContentListPanel,
|
||||
OverflowMenu,
|
||||
Pagination,
|
||||
RadialHeader,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -303,31 +306,18 @@ import { profile_listener } from '@/helpers/events.js'
|
||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import dayjs from 'dayjs'
|
||||
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
}>()
|
||||
|
||||
type ProjectListEntryAuthor = {
|
||||
name: string
|
||||
@@ -342,13 +332,15 @@ type ProjectListEntry = {
|
||||
author: ProjectListEntryAuthor | null
|
||||
version: string | null
|
||||
file_name: string
|
||||
icon: string | null
|
||||
icon: string | undefined
|
||||
disabled: boolean
|
||||
updateVersion?: string
|
||||
outdated: boolean
|
||||
updated: dayjs.Dayjs
|
||||
project_type: string
|
||||
id?: string
|
||||
updating?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const isPackLocked = computed(() => {
|
||||
@@ -361,17 +353,20 @@ const canUpdatePack = computed(() => {
|
||||
const exportModal = ref(null)
|
||||
|
||||
const projects = ref<ProjectListEntry[]>([])
|
||||
const selectedFiles = ref([])
|
||||
const selectedFiles = ref<string[]>([])
|
||||
const selectedProjects = computed(() =>
|
||||
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
|
||||
)
|
||||
|
||||
const selectionMap = ref(new Map())
|
||||
|
||||
const initProjects = async (cacheBehaviour?) => {
|
||||
const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
const newProjects: ProjectListEntry[] = []
|
||||
|
||||
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
|
||||
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record<
|
||||
string,
|
||||
ContentFile
|
||||
>
|
||||
const fetchProjects = []
|
||||
const fetchVersions = []
|
||||
|
||||
@@ -383,21 +378,21 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
}
|
||||
|
||||
const [modrinthProjects, modrinthVersions] = await Promise.all([
|
||||
await get_project_many(fetchProjects).catch(handleError),
|
||||
await get_version_many(fetchVersions).catch(handleError),
|
||||
(await get_project_many(fetchProjects).catch(handleError)) as Project[],
|
||||
(await get_version_many(fetchVersions).catch(handleError)) as Version[],
|
||||
])
|
||||
|
||||
const [modrinthTeams, modrinthOrganizations] = await Promise.all([
|
||||
await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
|
||||
await get_organization_many(
|
||||
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][],
|
||||
(await get_organization_many(
|
||||
modrinthProjects.map((x) => x.organization).filter((x) => !!x),
|
||||
).catch(handleError),
|
||||
).catch(handleError)) as Organization[],
|
||||
])
|
||||
|
||||
for (const [path, file] of Object.entries(profileProjects)) {
|
||||
if (file.metadata) {
|
||||
const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
|
||||
const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
|
||||
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id)
|
||||
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id)
|
||||
|
||||
if (project && version) {
|
||||
const org = project.organization
|
||||
@@ -406,7 +401,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
|
||||
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
|
||||
|
||||
let author: ProjectListEntryAuthor | null
|
||||
let author: ProjectListEntryAuthor | null = null
|
||||
if (org) {
|
||||
author = {
|
||||
name: org.name,
|
||||
@@ -415,13 +410,13 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
}
|
||||
} else if (team) {
|
||||
const teamMember = team.find((x) => x.is_owner)
|
||||
author = {
|
||||
name: teamMember.user.username,
|
||||
slug: teamMember.user.username,
|
||||
type: 'user',
|
||||
if (teamMember) {
|
||||
author = {
|
||||
name: teamMember.user.username,
|
||||
slug: teamMember.user.username,
|
||||
type: 'user',
|
||||
}
|
||||
}
|
||||
} else {
|
||||
author = null
|
||||
}
|
||||
|
||||
newProjects.push({
|
||||
@@ -450,7 +445,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
author: null,
|
||||
version: null,
|
||||
file_name: file.file_name,
|
||||
icon: null,
|
||||
icon: undefined,
|
||||
disabled: file.file_name.endsWith('.disabled'),
|
||||
outdated: false,
|
||||
updated: dayjs(0),
|
||||
@@ -458,7 +453,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
})
|
||||
}
|
||||
|
||||
projects.value = newProjects
|
||||
projects.value = newProjects ?? []
|
||||
|
||||
const newSelectionMap = new Map()
|
||||
for (const project of projects.value) {
|
||||
@@ -474,7 +469,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
}
|
||||
await initProjects()
|
||||
|
||||
const modpackVersionModal = ref(null)
|
||||
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>()
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
|
||||
const vintl = useVIntl()
|
||||
@@ -499,7 +494,7 @@ const messages = defineMessages({
|
||||
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
||||
const options: FilterOption[] = []
|
||||
|
||||
const frequency = projects.value.reduce((map, item) => {
|
||||
const frequency = projects.value.reduce((map: Record<string, number>, item) => {
|
||||
map[item.project_type] = (map[item.project_type] || 0) + 1
|
||||
return map
|
||||
}, {})
|
||||
@@ -530,7 +525,7 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const selectedFilters = ref([])
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const filteredProjects = computed(() => {
|
||||
const updatesFilter = selectedFilters.value.includes('updates')
|
||||
const disabledFilter = selectedFilters.value.includes('disabled')
|
||||
@@ -557,7 +552,7 @@ watch(filterOptions, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function toggleArray(array, value) {
|
||||
function toggleArray<T>(array: T[], value: T) {
|
||||
if (array.includes(value)) {
|
||||
array.splice(array.indexOf(value), 1)
|
||||
} else {
|
||||
@@ -567,7 +562,7 @@ function toggleArray(array, value) {
|
||||
|
||||
const searchFilter = ref('')
|
||||
const selectAll = ref(false)
|
||||
const shareModal = ref(null)
|
||||
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
|
||||
const ascending = ref(true)
|
||||
const sortColumn = ref('Name')
|
||||
const currentPage = ref(1)
|
||||
@@ -608,7 +603,7 @@ const search = computed(() => {
|
||||
|
||||
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
|
||||
|
||||
const sortProjects = (filter) => {
|
||||
const sortProjects = (filter: string) => {
|
||||
if (sortColumn.value === filter) {
|
||||
ascending.value = !ascending.value
|
||||
} else {
|
||||
@@ -626,7 +621,7 @@ const updateAll = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const paths = await update_all(props.instance.path).catch(handleError)
|
||||
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string>
|
||||
|
||||
for (const [oldVal, newVal] of Object.entries(paths)) {
|
||||
const index = projects.value.findIndex((x) => x.path === oldVal)
|
||||
@@ -635,7 +630,7 @@ const updateAll = async () => {
|
||||
|
||||
if (projects.value[index].updateVersion) {
|
||||
projects.value[index].version = projects.value[index].updateVersion.version_number
|
||||
projects.value[index].updateVersion = null
|
||||
projects.value[index].updateVersion = undefined
|
||||
}
|
||||
}
|
||||
for (const project of setProjects) {
|
||||
@@ -650,15 +645,15 @@ const updateAll = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateProject = async (mod) => {
|
||||
const updateProject = async (mod: ProjectListEntry) => {
|
||||
mod.updating = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
|
||||
mod.updating = false
|
||||
|
||||
mod.outdated = false
|
||||
mod.version = mod.updateVersion.version_number
|
||||
mod.updateVersion = null
|
||||
mod.version = mod.updateVersion?.version_number
|
||||
mod.updateVersion = undefined
|
||||
|
||||
trackEvent('InstanceProjectUpdate', {
|
||||
loader: props.instance.loader,
|
||||
@@ -669,15 +664,15 @@ const updateProject = async (mod) => {
|
||||
})
|
||||
}
|
||||
|
||||
const locks = {}
|
||||
const locks: Record<string, string | null> = {}
|
||||
|
||||
const toggleDisableMod = async (mod) => {
|
||||
const toggleDisableMod = async (mod: ProjectListEntry) => {
|
||||
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
|
||||
const lock = locks[mod.file_name]
|
||||
|
||||
while (lock) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout((_) => resolve(), 100)
|
||||
setTimeout((value: unknown) => resolve(value), 100)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -702,20 +697,20 @@ const toggleDisableMod = async (mod) => {
|
||||
locks[mod.file_name] = null
|
||||
}
|
||||
|
||||
const removeMod = async (mod) => {
|
||||
const removeMod = async (mod: ContentItem<ProjectListEntry>) => {
|
||||
await remove_project(props.instance.path, mod.path).catch(handleError)
|
||||
projects.value = projects.value.filter((x) => mod.path !== x.path)
|
||||
|
||||
trackEvent('InstanceProjectRemove', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
id: mod.id,
|
||||
name: mod.name,
|
||||
project_type: mod.project_type,
|
||||
id: mod.data.id,
|
||||
name: mod.data.name,
|
||||
project_type: mod.data.project_type,
|
||||
})
|
||||
}
|
||||
|
||||
const copyModLink = async (mod) => {
|
||||
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => {
|
||||
await navigator.clipboard.writeText(
|
||||
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
|
||||
)
|
||||
@@ -730,15 +725,15 @@ const deleteSelected = async () => {
|
||||
}
|
||||
|
||||
const shareNames = async () => {
|
||||
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
|
||||
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n'))
|
||||
}
|
||||
|
||||
const shareFileNames = async () => {
|
||||
await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
|
||||
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n'))
|
||||
}
|
||||
|
||||
const shareUrls = async () => {
|
||||
await shareModal.value.show(
|
||||
await shareModal.value?.show(
|
||||
functionValues.value
|
||||
.filter((x) => x.slug)
|
||||
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
|
||||
@@ -747,7 +742,7 @@ const shareUrls = async () => {
|
||||
}
|
||||
|
||||
const shareMarkdown = async () => {
|
||||
await shareModal.value.show(
|
||||
await shareModal.value?.show(
|
||||
functionValues.value
|
||||
.map((x) => {
|
||||
if (x.slug) {
|
||||
@@ -812,15 +807,17 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
await initProjects()
|
||||
})
|
||||
|
||||
const unlistenProfiles = await profile_listener(async (event) => {
|
||||
if (
|
||||
event.profile_path_id === props.instance.path &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage !== 'pack_installing'
|
||||
) {
|
||||
await initProjects()
|
||||
}
|
||||
})
|
||||
const unlistenProfiles = await profile_listener(
|
||||
async (event: { event: string; profile_path_id: string }) => {
|
||||
if (
|
||||
event.profile_path_id === props.instance.path &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage !== 'pack_installing'
|
||||
) {
|
||||
await initProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
|
||||
15
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
15
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>{{ instance.name }} overview</template>
|
||||
<script setup lang="ts">
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { Version } from '@modrinth/utils'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
}>()
|
||||
</script>
|
||||
463
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
463
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<AddServerModal
|
||||
ref="addServerModal"
|
||||
:instance="instance"
|
||||
@submit="
|
||||
(server, start) => {
|
||||
addServer(server)
|
||||
if (start) {
|
||||
joinWorld(server)
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
|
||||
<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.`"
|
||||
: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.`"
|
||||
@proceed="proceedDeleteWorld"
|
||||
/>
|
||||
<div v-if="worlds.length > 0" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<div class="iconified-input flex-grow">
|
||||
<SearchIcon />
|
||||
<input
|
||||
v-model="searchFilter"
|
||||
type="text"
|
||||
:placeholder="`Search worlds...`"
|
||||
class="text-input search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button v-if="searchFilter" class="r-btn" @click="() => (searchFilter = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<template v-if="refreshingAll">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Refreshing...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="addServerModal?.show()">
|
||||
<PlusIcon />
|
||||
Add a server
|
||||
</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"
|
||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||
:world="world"
|
||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||
:supports-quick-play="supportsQuickPlay"
|
||||
:current-protocol="protocolVersion"
|
||||
:playing-instance="playing"
|
||||
:playing-world="worldsMatch(world, worldPlaying)"
|
||||
:starting-instance="startingInstance"
|
||||
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
|
||||
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
|
||||
:rendered-motd="
|
||||
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
|
||||
"
|
||||
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
||||
@play="() => joinWorld(world)"
|
||||
@stop="() => emit('stop')"
|
||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||
@edit="
|
||||
() =>
|
||||
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
|
||||
"
|
||||
@delete="() => promptToRemoveWorld(world)"
|
||||
/>
|
||||
</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
|
||||
</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>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
RadialHeader,
|
||||
FilterBar,
|
||||
type FilterBarOption,
|
||||
type GameVersion,
|
||||
GAME_MODES,
|
||||
} from '@modrinth/ui'
|
||||
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type SingleplayerWorld,
|
||||
type World,
|
||||
type ServerWorld,
|
||||
type ServerData,
|
||||
type ProfileEvent,
|
||||
get_profile_protocol_version,
|
||||
remove_server_from_profile,
|
||||
delete_world,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
getWorldIdentifier,
|
||||
refreshServerData,
|
||||
refreshWorld,
|
||||
sortWorlds,
|
||||
refreshServers,
|
||||
hasQuickPlaySupport,
|
||||
refreshWorlds,
|
||||
handleDefaultProfileUpdateEvent,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { Version } from '@modrinth/utils'
|
||||
import { profile_listener } from '@/helpers/events'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import { defineMessages } from '@vintl/vintl'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
|
||||
const removeServerModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
|
||||
const deleteWorldModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
|
||||
|
||||
const serverToRemove = ref<ServerWorld>()
|
||||
const worldToDelete = ref<SingleplayerWorld>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'play', world: World): void
|
||||
(event: 'stop'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu> | null
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
}>()
|
||||
|
||||
const instance = computed(() => props.instance)
|
||||
const playing = computed(() => props.playing)
|
||||
|
||||
function play(world: World) {
|
||||
emit('play', world)
|
||||
}
|
||||
|
||||
const filters = ref<string[]>([])
|
||||
const searchFilter = ref('')
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
const hadNoWorlds = ref(true)
|
||||
const startingInstance = ref(false)
|
||||
const worldPlaying = ref<World>()
|
||||
|
||||
const worlds = ref<World[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
|
||||
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||
if (e.profile_path_id !== instance.value.path) return
|
||||
|
||||
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
|
||||
|
||||
if (e.event === 'servers_updated') {
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
|
||||
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
|
||||
})
|
||||
|
||||
await refreshAllWorlds()
|
||||
|
||||
async function refreshServer(address: string) {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
}
|
||||
await refreshServerData(serverData.value[address], protocolVersion.value, address)
|
||||
}
|
||||
|
||||
async function refreshAllWorlds() {
|
||||
if (refreshingAll.value) {
|
||||
console.log(`Already refreshing, cancelling refresh.`)
|
||||
return
|
||||
}
|
||||
|
||||
refreshingAll.value = true
|
||||
|
||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||
() => (refreshingAll.value = false),
|
||||
)
|
||||
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
|
||||
const hasNoWorlds = worlds.value.length === 0
|
||||
|
||||
if (hadNoWorlds.value && hasNoWorlds) {
|
||||
setTimeout(() => {
|
||||
refreshingAll.value = false
|
||||
}, 1000)
|
||||
} else {
|
||||
refreshingAll.value = false
|
||||
}
|
||||
|
||||
hadNoWorlds.value = hasNoWorlds
|
||||
}
|
||||
|
||||
async function addServer(server: ServerWorld) {
|
||||
worlds.value.push(server)
|
||||
sortWorlds(worlds.value)
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
|
||||
async function editServer(server: ServerWorld) {
|
||||
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
|
||||
if (index !== -1) {
|
||||
const oldServer = worlds.value[index] as ServerWorld
|
||||
worlds.value[index] = server
|
||||
sortWorlds(worlds.value)
|
||||
if (oldServer.address !== server.address) {
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
} else {
|
||||
handleError(`Error refreshing server, refreshing all worlds`)
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function editWorld(path: string, name: string, removeIcon: boolean) {
|
||||
const world = worlds.value.find((world) => world.type === 'singleplayer' && world.path === path)
|
||||
if (world) {
|
||||
world.name = name
|
||||
if (removeIcon) {
|
||||
world.icon = undefined
|
||||
}
|
||||
sortWorlds(worlds.value)
|
||||
} else {
|
||||
handleError(`Error finding world in list, refreshing all worlds`)
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorld(world: SingleplayerWorld) {
|
||||
await delete_world(instance.value.path, world.path).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
|
||||
}
|
||||
|
||||
function handleJoinError(err: unknown) {
|
||||
handleError(err)
|
||||
startingInstance.value = false
|
||||
worldPlaying.value = undefined
|
||||
}
|
||||
|
||||
async function joinWorld(world: World) {
|
||||
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||
startingInstance.value = true
|
||||
worldPlaying.value = world
|
||||
if (world.type === 'server') {
|
||||
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
|
||||
}
|
||||
play(world)
|
||||
startingInstance.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => playing.value,
|
||||
(playing) => {
|
||||
if (!playing) {
|
||||
worldPlaying.value = undefined
|
||||
|
||||
setTimeout(async () => {
|
||||
for (const world of worlds.value) {
|
||||
if (world.type === 'singleplayer' && world.locked) {
|
||||
await refreshWorld(worlds.value, instance.value.path, world.path)
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function worldsMatch(world: World, other: World | undefined) {
|
||||
if (world.type === 'server' && other?.type === 'server') {
|
||||
return world.address === other.address
|
||||
} else if (world.type === 'singleplayer' && other?.type === 'singleplayer') {
|
||||
return world.path === other.path
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||
const supportsQuickPlay = computed(() =>
|
||||
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options: FilterBarOption[] = []
|
||||
|
||||
const hasServer = worlds.value.some((x) => x.type === 'server')
|
||||
|
||||
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
|
||||
options.push({
|
||||
id: 'singleplayer',
|
||||
message: messages.singleplayer,
|
||||
})
|
||||
options.push({
|
||||
id: 'server',
|
||||
message: messages.server,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
|
||||
if (
|
||||
worlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'server' &&
|
||||
!serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing,
|
||||
) &&
|
||||
worlds.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const filteredWorlds = computed(() =>
|
||||
worlds.value.filter((x) => {
|
||||
const availableFilter = filters.value.includes('available')
|
||||
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
|
||||
|
||||
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()))
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const highlightedWorld = ref(route.query.highlight)
|
||||
|
||||
function promptToRemoveWorld(world: World): boolean {
|
||||
if (world.type === 'server') {
|
||||
serverToRemove.value = world
|
||||
removeServerModal.value?.show()
|
||||
return !!removeServerModal.value
|
||||
} else {
|
||||
worldToDelete.value = world
|
||||
deleteWorldModal.value?.show()
|
||||
return !!deleteWorldModal.value
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedRemoveServer() {
|
||||
if (!serverToRemove.value) {
|
||||
handleError(`Error removing server, no server marked for removal.`)
|
||||
return
|
||||
}
|
||||
await removeServer(serverToRemove.value)
|
||||
serverToRemove.value = undefined
|
||||
}
|
||||
|
||||
async function proceedDeleteWorld() {
|
||||
if (!worldToDelete.value) {
|
||||
handleError(`Error deleting world, no world marked for removal.`)
|
||||
return
|
||||
}
|
||||
await deleteWorld(worldToDelete.value)
|
||||
worldToDelete.value = undefined
|
||||
}
|
||||
|
||||
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,5 +1,7 @@
|
||||
import Index from './Index.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Logs from './Logs.vue'
|
||||
|
||||
export { Index, Mods, Logs }
|
||||
export { Index, Overview, Worlds, Mods, Logs }
|
||||
|
||||
@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
@@ -170,6 +170,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -192,6 +193,11 @@ const [allLoaders, allGameVersions] = await Promise.all([
|
||||
async function fetchProjectData() {
|
||||
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
|
||||
|
||||
if (!project) {
|
||||
handleError('Error loading project')
|
||||
return
|
||||
}
|
||||
|
||||
data.value = project
|
||||
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
|
||||
await Promise.all([
|
||||
@@ -242,6 +248,9 @@ async function install(version) {
|
||||
installedVersion.value = version
|
||||
}
|
||||
},
|
||||
(profile) => {
|
||||
router.push(`/instance/${profile}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user