Direct World Joining (#3457)

* Begin work on worlds backend

* Finish implementing get_profile_worlds and get_server_status (except pinning)

* Create TS types and manually copy unparsed chat components

* Clippy fix

* Update types.d.ts

* Initial worlds UI work

* Fix api::get_profile_worlds to take in a relative path

* sanitize & security update

* Fix sanitizePotentialFileUrl

* Fix sanitizePotentialFileUrl (for real)

* Fix empty motd causing error

* Finally actually fix world icons

* Fix world icon not being visible on non-Windows

* Use the correct generics to take in AppHandle

* Implement start_join_singleplayer_world and start_join_server for modern versions

* Don't error if server has no cached icon

* Migrate to own server pinging

* Ignore missing server hidden field and missing saves dir

* Update world list frontend

* More frontend work

* Server status player sample can be absent

* Fix refresh state

* Add get_profile_protocol_version

* Add protocol_version column to database

* SQL INTEGER is i64 in sqlx

* sqlx prepare

* Cache protocol version in database

* Continue worlds UI work

* Fix motds being bold

* Remove legacy pinging and add a 30-second timeout

* Remove pinned for now and match world (and server) parsing closer to spec

* Move type ServerStatus to worlds.ts

* Implement add_server_to_profile

* Fix pack_status being ignored when joining from launcher

* Make World path field be relative

* Implement rename_world and reset_world_icon

* Clippy fix

* Fix rename_world

* UI enhancements

* Implement backup_world, which returns the backup size in bytes

* Clippy fix

* Return index when adding servers to profile

* Fix backup

* Implement delete_world

* Implement edit_server_in_profile and remove_server_from_profile

* Clippy fix

* Log server joins

* Add edit and delete support

* Fix ts errors

* Fix minecraft font

* Switch font out for non-monospaced.

* Fix font proper

* Some more world cleanup, handle play state, check quickplay compatibility

* Clear the cached protocol version when a profile's game version is changed

* Fix tint colors in navbar

* Fix server protocol version pinging

* UI fixes

* Fix protocol version handler

* Fix MOTD parsing

* Add worlds_updated profile event

* fix pkg

* Functional home screen with worlds

* lint

* Fix incorrect folder creation

* Make items clickable

* Add locked field to SingleplayerWorld indicating whether the world is locked by the game

* Implement locking frontend

* Fix locking condition

* Split worlds_updated profile event into servers_updated and world_updated

* Fix compile error

* Use port from resolve SRV record

* Fix serialization of ProfilePayload and ProfilePayloadType

* Individual singleplayer world refreshing

* Log when worlds are perceived to be updated

* Push logging + total refresh lock

* Unlisten fixes

* Highlight current world when clicked

* Launcher logs refactor (#3444)

* Switch live log to use STDOUT

* fix clippy, legacy logs support

* Fix lint

* Handle non-XML log messages in XML logging, and don't escape log messages into XML

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>

* Update incompatibility text

* Home page fixes, and unlock after close

* Remove logging

* Add join log database migration

* Switch server join timing to being in the database instead of in a separate log file

* Create optimized get_recent_worlds function that takes in a limit

* Update dependencies and fix Cargo.lock

* temp disable overflow menus

* revert home page changes

* Enable overflow menus again

* Remove list

* Revert

* Push dev tools

* Remove default filter

* Disable debug renderer

* Fix random app errors

* Refactor

* Fix missing computed import

* Fix light mode issues

* Fix TS errors

* Lint

* Fix bad link in change modpack version modal

* fix lint

* fix intl

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
Prospector
2025-04-26 18:09:58 -07:00
committed by GitHub
parent 25016053ca
commit ff4c7f47b2
106 changed files with 5852 additions and 1346 deletions

View File

@@ -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`,
},
])

View File

@@ -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
},
},
})

View File

@@ -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="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)"
/>
<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,
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 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)"
/>
<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,6 +273,7 @@ import {
ContentListPanel,
OverflowMenu,
Pagination,
RadialHeader,
Toggle,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
@@ -323,10 +325,22 @@ const props = defineProps({
return false
},
},
playing: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
installed: {
type: Boolean,
default() {
return false
},
},
})
type ProjectListEntryAuthor = {
@@ -458,7 +472,7 @@ const initProjects = async (cacheBehaviour?) => {
})
}
projects.value = newProjects
projects.value = newProjects ?? []
const newSelectionMap = new Map()
for (const project of projects.value) {

View 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>

View File

@@ -0,0 +1,447 @@
<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" />
<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) {
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) {
worlds.value[index] = server
sortWorlds(worlds.value)
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[] = []
if (worlds.value.some((x) => x.type === 'singleplayer')) {
options.push({
id: 'singleplayer',
message: messages.singleplayer,
})
}
if (worlds.value.some((x) => x.type === 'server')) {
options.push({
id: 'server',
message: messages.server,
})
// add available filter if there's any offline ("unavailable") servers
if (
worlds.value.some(
(x) =>
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>

View File

@@ -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 }