Files
AstralRinth/apps/app-frontend/src/pages/instance/Index.vue
Prospector ff4c7f47b2 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>
2025-04-26 18:09:58 -07:00

637 lines
16 KiB
Vue

<template>
<div>
<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 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 {
Avatar,
ButtonStyled,
ContentPageHeader,
LoadingIndicator,
OverflowMenu,
} from '@modrinth/ui'
import {
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GameIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
StopCircleIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { computed, onUnmounted, ref, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const instance = ref()
const modrinthVersions = ref([])
const playing = ref(false)
const loading = ref(false)
async function fetchInstance() {
instance.value = await get(route.params.id).catch(handleError)
if (!offline.value && instance.value.linked_data && instance.value.linked_data.project_id) {
get_project(instance.value.linked_data.project_id, 'must_revalidate')
.catch(handleError)
.then((project) => {
if (project && project.versions) {
get_version_many(project.versions, 'must_revalidate')
.catch(handleError)
.then((versions) => {
modrinthVersions.value = versions.sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
})
}
})
}
await updatePlayState()
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
playing.value = runningProcesses.length > 0
}
await fetchInstance()
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/instance')) {
await fetchInstance()
}
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
},
])
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
})
const loadingBar = useLoading()
const options = ref(null)
const startInstance = async (context) => {
loading.value = true
try {
await run(route.params.id)
playing.value = true
} catch (err) {
handleSevereError(err, { profilePath: route.params.id })
}
loading.value = false
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
}
const stopInstance = async (context) => {
playing.value = false
await kill(route.params.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
}
const repairInstance = async () => {
await finish_install(instance.value)
}
const handleRightClick = (event) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
{ name: 'edit' },
{ name: 'open_folder' },
{ name: 'copy_path' },
]
options.value.showMenu(
event,
instance.value,
playing.value
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...baseOptions,
],
)
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
await startInstance('InstancePageContextMenu')
break
case 'stop':
await stopInstance('InstancePageContextMenu')
break
case 'add_content':
await router.push({
path: `/browse/${instance.value.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: route.params.id },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(route.params.id)}/options`,
})
break
case 'open_folder':
await showProfileInFolder(instance.value.path)
break
case 'copy_path': {
const fullPath = await get_full_path(instance.value.path)
await navigator.clipboard.writeText(fullPath)
break
}
}
}
const unlistenProfiles = await profile_listener(async (event) => {
if (event.profile_path_id === route.params.id) {
if (event.event === 'removed') {
await router.push({
path: '/',
})
return
}
instance.value = await get(route.params.id).catch(handleError)
}
})
const unlistenProcesses = await process_listener((e) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
playing.value = false
}
})
const icon = computed(() =>
instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const settingsModal = ref()
const timePlayed = computed(() => {
return instance.value.recent_time_played + instance.value.submitted_time_played
})
const timePlayedHumanized = computed(() => {
const duration = dayjs.duration(timePlayed.value, 'seconds')
const hours = Math.floor(duration.asHours())
if (hours >= 1) {
return hours + ' hour' + (hours > 1 ? 's' : '')
}
const minutes = Math.floor(duration.asMinutes())
if (minutes >= 1) {
return minutes + ' minute' + (minutes > 1 ? 's' : '')
}
const seconds = Math.floor(duration.asSeconds())
return seconds + ' second' + (seconds > 1 ? 's' : '')
})
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
})
</script>
<style scoped lang="scss">
.instance-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
Button {
width: 100%;
}
.button-group {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.side-cards {
position: fixed;
width: 300px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 3.25rem);
max-height: calc(100vh - 3.25rem);
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
background: transparent;
}
.card {
min-height: unset;
margin-bottom: 0;
}
}
.instance-nav {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 1rem;
gap: 0.5rem;
background: var(--color-raised-bg);
height: 100%;
}
.name {
font-size: 1.25rem;
color: var(--color-contrast);
overflow: hidden;
text-overflow: ellipsis;
}
.metadata {
text-transform: capitalize;
}
.instance-container {
display: flex;
flex-direction: row;
overflow: auto;
gap: 1rem;
min-height: 100%;
padding: 1rem;
}
.instance-info {
display: flex;
flex-direction: column;
width: 100%;
}
.badge {
display: flex;
align-items: center;
font-weight: bold;
width: fit-content;
color: var(--color-orange);
}
.pages-list {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
.btn {
font-size: 100%;
font-weight: 400;
background: inherit;
transition: all ease-in-out 0.1s;
width: 100%;
color: var(--color-primary);
box-shadow: none;
&.router-link-exact-active {
box-shadow: var(--shadow-inset-lg);
background: var(--color-button-bg);
color: var(--color-contrast);
}
&:hover {
background-color: var(--color-button-bg);
color: var(--color-contrast);
box-shadow: var(--shadow-inset-lg);
text-decoration: none;
}
svg {
width: 1.3rem;
height: 1.3rem;
}
}
}
.instance-nav {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: left;
padding: 1rem;
gap: 0.5rem;
height: min-content;
width: 100%;
}
.instance-button {
width: fit-content;
}
.actions {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 0.5rem;
}
.content {
margin: 0 1rem 0.5rem 20rem;
width: calc(100% - 20rem);
display: flex;
flex-direction: column;
overflow: auto;
}
.stats {
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: var(--gap-md);
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--gap-xs);
--stat-strong-size: 1.25rem;
strong {
font-size: var(--stat-strong-size);
}
p {
margin: 0;
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
.date {
margin-top: auto;
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--gap-md);
margin-top: var(--gap-xs);
}
@media screen and (max-width: 600px) {
margin-top: 0;
.stat-label {
display: none;
}
}
}
</style>