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

@@ -16,6 +16,7 @@
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-os": "^2.2.0",
@@ -50,7 +51,8 @@
"tsconfig": "workspace:*",
"typescript": "^5.5.4",
"vite": "^5.4.6",
"vue-tsc": "^2.1.6"
"vue-tsc": "^2.1.6",
"@taijased/vue-render-tracker": "^1.0.7"
},
"packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json"

View File

@@ -16,6 +16,7 @@ import {
RestoreIcon,
RightArrowIcon,
SettingsIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
@@ -166,11 +167,17 @@ async function setupApp() {
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
'criticalAnnouncements',
true,
).then((res) => {
if (res && res.header && res.body) {
criticalErrorMessage.value = res
}
})
)
.then((res) => {
if (res && res.header && res.body) {
criticalErrorMessage.value = res
}
})
.catch(() => {
console.log(
`No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
)
})
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
if (res && res.articles) {
@@ -359,7 +366,7 @@ function handleAuxClick(e) {
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout relative">
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
@@ -372,6 +379,9 @@ function handleAuxClick(e) {
<NavButton v-tooltip.right="'Home'" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack"

Binary file not shown.

View File

@@ -2,8 +2,44 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://cdn.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('https://cdn.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url('https://cdn.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 600;
src: url('https://cdn.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
}
.font-minecraft {
font-family: 'bundled-minecraft-font-mrapp', monospace;
}
:root {
font-family: var(--font-standard);
font-family: var(--font-standard, sans-serif), sans-serif;
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);

View File

@@ -10,7 +10,6 @@ import {
StopCircleIcon,
ExternalIcon,
EyeIcon,
ChevronRightIcon,
} from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import Instance from '@/components/ui/Instance.vue'
@@ -26,6 +25,7 @@ import { trackEvent } from '@/helpers/analytics'
import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js'
import { openUrl } from '@tauri-apps/plugin-opener'
import { HeadingLink } from '@modrinth/ui'
const router = useRouter()
@@ -44,7 +44,9 @@ const props = defineProps({
})
const actualInstances = computed(() =>
props.instances.filter((x) => x && x.instances && x.instances[0]),
props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
),
)
const modsRow = ref(null)
@@ -181,6 +183,10 @@ const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
@@ -204,16 +210,21 @@ const calculateCardsPerRow = () => {
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
resizeObserver.value.observe(rowContainer.value)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
resizeObserver.value.unobserve(rowContainer.value)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
})
</script>
@@ -227,17 +238,10 @@ onUnmounted(() => {
@proceed="deleteProfile"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
<router-link
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"
:class="{ 'mt-1': rowIndex > 0 }"
:to="row.route"
>
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
{{ row.label }}
<ChevronRightIcon
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
/>
</router-link>
</HeadingLink>
<section
v-if="row.instance"
ref="modsRow"

View File

@@ -19,6 +19,7 @@
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
class="text-primary"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))

View File

@@ -70,7 +70,7 @@ const onHide = () => {
v-for="version in filteredVersions"
:key="version.id"
class="table-row with-columns selectable"
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<Button

View File

@@ -7,7 +7,7 @@
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
}"
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
>
<slot />
</RouterLink>

View File

@@ -14,7 +14,10 @@
<div v-if="selectedProcess" class="status">
<span class="circle running" />
<div ref="profileButton" class="running-text">
<router-link :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`">
<router-link
class="text-primary"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
>
{{ selectedProcess.profile.name }}
</router-link>
<div

View File

@@ -41,6 +41,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])
@@ -80,6 +84,7 @@ function proceed() {
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
:danger="danger"
:markdown="markdown"
@proceed="proceed"
/>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: GameInstance
}>()
</script>
<template>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="instance.path"
/>
{{ instance.name }} <ChevronRightIcon />
</span>
</template>

View File

@@ -7,7 +7,7 @@ import { get, set } from '@/helpers/settings'
const themeStore = useTheming()
const settings = ref(await get())
const options = ref(['project_background', 'page_path'])
const options = ref(['project_background', 'page_path', 'worlds_tab'])
function getStoreValue(key: string) {
return themeStore.featureFlags[key] ?? false
@@ -30,7 +30,7 @@ watch(
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option }}
{{ option.replaceAll('_', ' ') }}
</h2>
</div>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import {
EyeIcon,
FolderOpenIcon,
MoreVerticalIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import { get_project } from '@/helpers/cache'
import { capitalizeString } from '@modrinth/utils'
import { kill, run } from '@/helpers/profile'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process'
import { handleError } from '@/store/notifications'
import { process_listener } from '@/helpers/events'
const { formatMessage } = useVIntl()
const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'stop'): void
}>()
const props = defineProps<{
instance: GameInstance
}>()
const loadingModpack = ref(!!props.instance.linked_data)
const modpack = ref()
if (props.instance.linked_data) {
nextTick().then(async () => {
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
loadingModpack.value = false
})
}
const instanceIcon = computed(() => props.instance.icon_path)
const loader = computed(() => {
if (props.instance.loader === 'vanilla') {
return 'Minecraft'
} else if (props.instance.loader === 'neoforge') {
return 'NeoForge'
} else {
return capitalizeString(props.instance.loader)
}
})
const loading = ref(false)
const playing = ref(false)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
})
emit('play')
loading.value = false
}
const stop = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
emit('stop')
loading.value = false
}
const unlistenProcesses = await process_listener(async () => {
await checkProcess()
})
const checkProcess = async () => {
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0
}
onMounted(() => {
checkProcess()
})
onUnmounted(() => {
unlistenProcesses()
})
</script>
<template>
<SmartClickable>
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.path)}`"
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
:tint-by="instance.path"
size="48px"
/>
<div class="flex flex-col col-span-2 justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ instance.name }}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="
instance.last_played
? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A')
: null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
>
<template v-if="instance.last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: dayjs(instance.last_played).fromNow(),
})
}}
</template>
<template v-else> Not played yet </template>
</div>
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary"
:to="`/project/${modpack.id}`"
>
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
<span class="truncate">{{ modpack.title }}</span>
</router-link>
({{ loader }} {{ instance.game_version }})
</span>
<span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary">
<SpinnerIcon class="animate-spin shrink-0" />
<span class="truncate">Loading modpack...</span>
</span>
<span v-else class="flex items-center gap-1 truncate text-secondary">
{{ loader }}
{{ instance.game_version }}
</span>
</div>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing && !loading" color="red">
<button @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="playing ? 'Instance is already open' : null"
:disabled="playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'open-instance',
shown: !!instance.path,
action: () => router.push(encodeURI(`/instance/${instance.path}`)),
},
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</SmartClickable>
</template>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import {
type ServerWorld,
type ServerData,
type WorldWithProfile,
get_recent_worlds,
getWorldIdentifier,
get_profile_protocol_version,
refreshServerData,
start_join_server,
start_join_singleplayer_world,
} from '@/helpers/worlds.ts'
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import { watch, onMounted, onUnmounted, ref } from 'vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { useTheming } from '@/store/theme'
import { kill } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
recentInstances: GameInstance[]
}>()
const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, number | null>>({})
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
type BaseJumpBackInItem = {
last_played: Dayjs
instance: GameInstance
}
type InstanceJumpBackInItem = BaseJumpBackInItem & {
type: 'instance'
}
type WorldJumpBackInItem = BaseJumpBackInItem & {
type: 'world'
world: WorldWithProfile
}
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
watch(props.recentInstances, async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
})
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
async function populateJumpBackIn() {
console.info('Repopulating jump back in...')
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN)
const worldItems: WorldJumpBackInItem[] = []
worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (!instance || !world.last_played) {
return
}
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played),
world: world,
instance: instance,
})
})
const servers: {
instancePath: string
address: string
}[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({
instancePath: item.instance.path,
address: (item.world as ServerWorld).address,
}))
// fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all(
[...uniqueServerInstances].map((path) => {
get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => {
console.error(`Failed to get profile protocol for: ${path} `)
})
}),
)
// initialize server data
servers.forEach(({ address }) => {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
})
// fetch each server's data
await Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
)
const instanceItems: InstanceJumpBackInItem[] = []
props.recentInstances.forEach((instance) => {
if (worldItems.some((item) => item.instance.path === instance.path) || !instance.last_played) {
return
}
instanceItems.push({
type: 'instance',
last_played: dayjs(instance.last_played),
instance: instance,
})
})
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
jumpBackInItems.value = items.filter(
(item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO),
)
}
async function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithProfile) {
console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') {
await start_join_server(world.profile, world.address).catch(handleError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
}
}
async function stopInstance(path: string) {
await kill(path).catch(handleError)
trackEvent('InstanceStop', {
source: 'RecentWorldsList',
})
}
const currentProfile = ref<string>()
const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => {
await checkProcesses()
})
const unlistenProfiles = await profile_listener(async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
})
const runningInstances = ref<string[]>([])
type ProcessMetadata = {
uuid: string
profile_path: string
start_time: string
}
const checkProcesses = async () => {
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
const runningPaths = runningProcesses.map((x) => x.profile_path)
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
currentProfile.value = undefined
currentWorld.value = undefined
}
runningInstances.value = runningPaths
}
onMounted(() => {
checkProcesses()
})
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
})
</script>
<template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink
v-if="(theme.featureFlags as Record<string, boolean>)['worlds_tab']"
to="/worlds"
class="mt-1"
>
Jump back in
</HeadingLink>
<span
v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
>
Jump back in
</span>
<div class="flex flex-col w-full gap-2">
<template
v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.path)"
:playing-world="
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
"
:refreshing="
item.world.type === 'server'
? serverData[item.world.address].refreshing && !serverData[item.world.address].status
: undefined
"
supports-quick-play
:server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined
"
:rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
"
:current-protocol="protocolVersions[item.instance.game_version]"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
:instance-path="item.instance.path"
:instance-name="item.instance.name"
:instance-icon="item.instance.icon_path"
@refresh="
() =>
item.world.type === 'server'
? refreshServer(item.world.address, item.instance.path)
: {}
"
@play="
() => {
currentProfile = item.instance.path
currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world)
}
"
@stop="() => stopInstance(item.instance.path)"
/>
<InstanceItem v-else :instance="item.instance" />
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,470 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts'
import { getWorldIdentifier, showWorldInFolder } from '@/helpers/worlds.ts'
import { formatNumber } from '@modrinth/utils'
import {
IssuesIcon,
EyeIcon,
ClipboardCopyIcon,
EditIcon,
FolderOpenIcon,
MoreVerticalIcon,
NoSignalIcon,
PlayIcon,
SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
TrashIcon,
UpdatedIcon,
UserIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Component } from 'vue'
import { computed } from 'vue'
import { copyToClipboard } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import { Tooltip } from 'floating-vue'
const { formatMessage } = useVIntl()
const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'stop' | 'refresh' | 'edit' | 'delete'): void
}>()
const props = withDefaults(
defineProps<{
world: World
playingInstance?: boolean
playingWorld?: boolean
startingInstance?: boolean
supportsQuickPlay?: boolean
currentProtocol?: number | null
highlighted?: boolean
// Server only
refreshing?: boolean
serverStatus?: ServerStatus
renderedMotd?: string
// Singleplayer only
gameMode?: {
icon: Component
message: MessageDescriptor
}
// Instance
instancePath?: string
instanceName?: string
instanceIcon?: string
}>(),
{
playingInstance: false,
playingWorld: false,
startingInstance: false,
supportsQuickPlay: false,
refreshing: false,
serverStatus: undefined,
renderedMotd: undefined,
gameMode: undefined,
instancePath: undefined,
instanceName: undefined,
instanceIcon: undefined,
},
)
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
const hasPlayersTooltip = computed(
() => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0,
)
const serverIncompatible = computed(
() =>
!!props.serverStatus &&
!!props.serverStatus.version?.protocol &&
!!props.currentProtocol &&
props.serverStatus.version.protocol !== props.currentProtocol,
)
function getPingLevel(ping: number) {
if (ping < 150) {
return 5
} else if (ping < 300) {
return 4
} else if (ping < 600) {
return 3
} else if (ping < 1000) {
return 2
} else {
return 1
}
}
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({
hardcore: {
id: 'instance.worlds.hardcore',
defaultMessage: 'Hardcore mode',
},
cantConnect: {
id: 'instance.worlds.cant_connect',
defaultMessage: "Can't connect to server",
},
aMinecraftServer: {
id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server',
},
noQuickPlay: {
id: 'instance.worlds.no_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
},
gameAlreadyOpen: {
id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is already open',
},
copyAddress: {
id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address',
},
viewInstance: {
id: 'instance.worlds.view_instance',
defaultMessage: 'View instance',
},
playAnyway: {
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
worldInUse: {
id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use',
},
})
</script>
<template>
<SmartClickable>
<template v-if="instancePath" #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl"
:class="{
'world-item-highlighted': highlighted,
}"
>
<Avatar
:src="
world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon
"
size="48px"
/>
<div class="flex flex-col justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ world.name }}
</div>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
>
<UserIcon
aria-hidden="true"
class="h-4 w-4 text-secondary shrink-0"
stroke-width="3px"
/>
{{ formatMessage(commonMessages.singleplayerLabel) }}
</div>
<div
v-else-if="world.type === 'server'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
Loading...
</template>
<template v-else-if="serverStatus">
<template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange">
Incompatible version {{ serverStatus.version?.name }}
</span>
</template>
<template v-else>
<SignalIcon
v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0"
:class="{
'smart-clickable:allow-pointer-events': serverStatus,
}"
/>
<Tooltip :disabled="!hasPlayersTooltip">
<span :class="{ 'cursor-help': hasPlayersTooltip }">
{{ formatNumber(serverStatus.players?.online, false) }} online
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.name">
{{ player.name }}
</span>
</div>
</template>
</Tooltip>
</template>
</template>
<template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" /> Offline
</template>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="
world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': world.last_played }"
>
<template v-if="world.last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: dayjs(world.last_played).fromNow(),
})
}}
</template>
<template v-else> Not played yet </template>
</div>
<template v-if="instancePath">
<router-link
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instancePath}`"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
size="16px"
:tint-by="instancePath"
class="shrink-0"
/>
<span class="truncate">{{ instanceName }}</span>
</router-link>
</template>
</div>
</div>
<div
class="font-semibold flex items-center gap-1 justify-center text-center"
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
>
<template v-if="world.type === 'server'">
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin" />
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<div
v-else-if="renderedMotd"
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
v-html="renderedMotd"
/>
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
{{ formatMessage(messages.cantConnect) }}
</div>
<div v-else class="font-normal font-minecraft text-secondary leading-5">
{{ formatMessage(messages.aMinecraftServer) }}
</div>
</template>
<template v-else-if="world.type === 'singleplayer' && gameMode">
<template v-if="world.hardcore">
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(messages.hardcore) }}
</template>
<template v-else>
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(gameMode.message) }}
</template>
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<template v-if="world.type === 'singleplayer' || serverStatus">
<ButtonStyled
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
color="red"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
serverIncompatible
? 'Server is incompatible'
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else>
<button class="invisible">
<PlayIcon aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
action: () => emit('play'),
},
{
id: 'open-instance',
shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}/worlds`)),
},
{
id: 'refresh',
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
{
id: 'open-folder',
shown: world.type === 'singleplayer',
action: () =>
world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
},
{
divider: true,
shown: !instancePath,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" /> {{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" /> {{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server'
? commonMessages.removeButton
: commonMessages.deleteLabel,
)
}}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</SmartClickable>
</template>
<style scoped lang="scss">
.world-item-highlighted {
position: relative;
animation: fade-highlight 4s ease-out;
filter: brightness(1);
&::before {
@apply rounded-xl inset-0 absolute;
animation: fade-opacity 4s ease-out;
content: '';
box-shadow: 0 0 8px 2px var(--color-brand);
border: 1.5px solid var(--color-brand);
opacity: 0;
}
}
@keyframes fade-highlight {
0% {
filter: brightness(1.25);
}
75% {
filter: brightness(1.25);
}
100% {
filter: brightness(1);
}
}
@keyframes fade-opacity {
0% {
opacity: 0.5;
}
75% {
opacity: 0.5;
}
100% {
opacity: 0;
}
}
.light-mode .motd-renderer {
filter: brightness(0.75);
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [server: ServerWorld, play: boolean]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const name = ref()
const address = ref()
const resourcePack = ref<ServerPackStatus>('enabled')
async function addServer(play: boolean) {
const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value
const index =
(await add_server_to_profile(
props.instance.path,
serverName,
address.value,
resourcePackStatus,
).catch(handleError)) ?? 0
emit(
'submit',
{
name: serverName,
type: 'server',
index,
address: address.value,
pack_status: resourcePackStatus,
},
play,
)
hide()
}
function show() {
name.value = ''
address.value = ''
resourcePack.value = 'enabled'
modal.value.show()
}
function hide() {
modal.value.hide()
}
const messages = defineMessages({
title: {
id: 'instance.add-server.title',
defaultMessage: 'Add a server',
},
addServer: {
id: 'instance.add-server.add-server',
defaultMessage: 'Add server',
},
addAndPlay: {
id: 'instance.add-server.add-and-play',
defaultMessage: 'Add and play',
},
})
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceModalTitlePrefix :instance="instance" />
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
</span>
</template>
<ServerModalBody
v-model:name="name"
v-model:address="address"
v-model:resource-pack="resourcePack"
/>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!address" @click="addServer(false)">
<PlusIcon />
{{ formatMessage(messages.addServer) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import { edit_server_in_profile, type ServerWorld } from '@/helpers/worlds.ts'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [server: ServerWorld]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const name = ref()
const address = ref()
const resourcePack = ref('enabled')
const index = ref()
async function saveServer() {
const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value
await edit_server_in_profile(
props.instance.path,
index.value,
serverName,
address.value,
resourcePackStatus,
).catch(handleError)
emit('submit', {
name: serverName,
type: 'server',
index: index.value,
address: address.value,
pack_status: resourcePackStatus,
})
hide()
}
function show(server: ServerWorld) {
name.value = server.name
address.value = server.address
resourcePack.value = server.pack_status
index.value = server.index
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show })
const titleMessage = defineMessage({
id: 'instance.edit-server.title',
defaultMessage: 'Edit server',
})
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
</template>
<ServerModalBody
v-model:name="name"
v-model:address="address"
v-model:resource-pack="resourcePack"
/>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import type { SingleplayerWorld } from '@/helpers/worlds.ts'
import { rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [path: string, name: string, removeIcon: boolean]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const icon = ref()
const name = ref()
const path = ref()
const removeIcon = ref(false)
async function saveWorld() {
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
if (removeIcon.value) {
await reset_world_icon(props.instance.path, path.value).catch(handleError)
}
emit('submit', path.value, name.value, removeIcon.value)
hide()
}
function show(world: SingleplayerWorld) {
name.value = world.name
path.value = world.path
icon.value = world.icon
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show })
const messages = defineMessages({
title: {
id: 'instance.edit-world.title',
defaultMessage: 'Edit world',
},
name: {
id: 'instance.edit-world.name',
defaultMessage: 'Name',
},
placeholderName: {
id: 'instance.edit-world.placeholder-name',
defaultMessage: 'Minecraft World',
},
resetIcon: {
id: 'instance.edit-world.reset-icon',
defaultMessage: 'Reset icon',
},
})
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<Avatar :src="removeIcon || !icon ? undefined : icon" size="24px" />
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(messages.title) }}</span>
</template>
<div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
/>
</div>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button @click="saveWorld">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon />
{{ formatMessage(messages.resetIcon) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { TeleportDropdownMenu } from '@modrinth/ui'
import type { ServerPackStatus } from '@/helpers/worlds.ts'
import { type MessageDescriptor, defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const name = defineModel<string>('name')
const address = defineModel<string>('address')
const resourcePack = defineModel<ServerPackStatus>('resourcePack')
const resourcePackOptions: ServerPackStatus[] = ['enabled', 'prompt', 'disabled']
const resourcePackOptionMessages: Record<ServerPackStatus, MessageDescriptor> = defineMessages({
enabled: {
id: 'instance.add-server.resource-pack.enabled',
defaultMessage: 'Enabled',
},
prompt: {
id: 'instance.add-server.resource-pack.prompt',
defaultMessage: 'Prompt',
},
disabled: {
id: 'instance.add-server.resource-pack.disabled',
defaultMessage: 'Disabled',
},
})
const messages = defineMessages({
name: {
id: 'instance.server-modal.name',
defaultMessage: 'Name',
},
address: {
id: 'instance.server-modal.address',
defaultMessage: 'Address',
},
resourcePack: {
id: 'instance.server-modal.resource-pack',
defaultMessage: 'Resource pack',
},
placeholderName: {
id: 'instance.server-modal.placeholder-name',
defaultMessage: 'Minecraft Server',
},
})
defineExpose({ resourcePackOptions })
</script>
<template>
<div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.address) }}
</h2>
<input
v-model="address"
type="text"
placeholder="example.modrinth.gg"
class="w-full"
autocomplete="off"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.resourcePack) }}
</h2>
<div>
<TeleportDropdownMenu
v-model="resourcePack"
:options="resourcePackOptions"
name="Server resource pack"
:display-name="
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
"
/>
</div>
</div>
</template>

View File

@@ -62,7 +62,7 @@ export async function process_listener(callback) {
ProfilePayload {
uuid: unique identification of the process in the state (currently identified by path, but that will change)
name: name of the profile
profile_path: relative path to profile (used for path identification)
profile_path: relative path toprofile_listener profile (used for path identification)
path: path to profile (used for opening the profile in the OS file explorer)
event: event type ("Created", "Added", "Edited", "Removed")
}

View File

@@ -37,6 +37,13 @@ export async function restartApp() {
return await invoke('restart_app')
}
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':
@@ -49,3 +56,7 @@ export const releaseColor = (releaseType) => {
return ''
}
}
export async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}

View File

@@ -0,0 +1,303 @@
import { invoke } from '@tauri-apps/api/core'
import { get_full_path } from '@/helpers/profile'
import { openPath } from '@/helpers/utils'
import { autoToHTML } from '@geometrically/minecraft-motd-parser'
import dayjs from 'dayjs'
import type { GameVersion } from '@modrinth/ui'
type BaseWorld = {
name: string
last_played?: string
icon?: string
}
export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer'
path: string
game_mode: SingleplayerGameMode
hardcore: boolean
locked: boolean
}
export type ServerWorld = BaseWorld & {
type: 'server'
index: number
address: string
pack_status: ServerPackStatus
}
export type World = SingleplayerWorld | ServerWorld
export type WorldWithProfile = {
profile: string
} & World
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
export type ServerStatus = {
// https://minecraft.wiki/w/Text_component_format
description?: string | Chat
players?: {
max: number
online: number
sample: { name: string; id: string }[]
}
version?: {
name: string
protocol: number
}
favicon?: string
enforces_secure_chat: boolean
ping?: number
}
export interface Chat {
text: string
bold: boolean
italic: boolean
underlined: boolean
strikethrough: boolean
obfuscated: boolean
color?: string
extra: Chat[]
}
export type ServerData = {
refreshing: boolean
status?: ServerStatus
rawMotd?: string | Chat
renderedMotd?: string
}
export async function get_recent_worlds(limit: number): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit })
}
export async function get_profile_worlds(path: string): Promise<World[]> {
return await invoke('plugin:worlds|get_profile_worlds', { path })
}
export async function get_singleplayer_world(
instance: string,
world: string,
): Promise<SingleplayerWorld> {
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
}
export async function rename_world(
instance: string,
world: string,
newName: string,
): Promise<void> {
return await invoke('plugin:worlds|rename_world', { instance, world, newName })
}
export async function reset_world_icon(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|reset_world_icon', { instance, world })
}
export async function backup_world(instance: string, world: string): Promise<number> {
return await invoke('plugin:worlds|backup_world', { instance, world })
}
export async function delete_world(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|delete_world', { instance, world })
}
export async function add_server_to_profile(
path: string,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<number> {
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
}
export async function edit_server_in_profile(
path: string,
index: number,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<void> {
return await invoke('plugin:worlds|edit_server_in_profile', {
path,
index,
name,
address,
packStatus,
})
}
export async function remove_server_from_profile(path: string, index: number): Promise<void> {
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
}
export async function get_profile_protocol_version(path: string): Promise<number | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
}
export async function get_server_status(
address: string,
protocolVersion: number | null = null,
): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
}
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
}
export async function start_join_server(path: string, address: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_server', { path, address })
}
export async function showWorldInFolder(instancePath: string, worldPath: string) {
const fullPath = await get_full_path(instancePath)
return await openPath(fullPath + '/saves/' + worldPath)
}
export function getWorldIdentifier(world: World) {
return world.type === 'singleplayer' ? world.path : world.address
}
export function sortWorlds(worlds: World[]) {
worlds.sort((a, b) => {
if (!a.last_played) {
return 1
}
if (!b.last_played) {
return -1
}
return dayjs(b.last_played).diff(dayjs(a.last_played))
})
}
export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
return world.type === 'singleplayer'
}
export function isServerWorld(world: World): world is ServerWorld {
return world.type === 'server'
}
export async function refreshServerData(
serverData: ServerData,
protocolVersion: number | null,
address: string,
): Promise<void> {
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.catch((err) => {
console.error(`Refreshing addr: ${address}`, err)
})
.finally(() => {
serverData.refreshing = false
})
}
export async function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: number | null,
) {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
if (!serverData[server.address]) {
serverData[server.address] = {
refreshing: true,
}
} else {
serverData[server.address].refreshing = true
}
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address),
),
)
}
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
if (index !== -1) {
worlds[index] = await get_singleplayer_world(instancePath, worldPath)
sortWorlds(worlds)
} else {
console.error(`Error refreshing world, could not find world at path ${worldPath}.`)
}
}
export async function handleDefaultProfileUpdateEvent(
worlds: World[],
instancePath: string,
e: ProfileEvent,
) {
if (e.event === 'world_updated') {
await refreshWorld(worlds, instancePath, e.world)
}
if (e.event === 'server_joined') {
const world = worlds.find(
(w) =>
w.type === 'server' &&
(w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
)
if (world) {
world.last_played = e.timestamp
sortWorlds(worlds)
} else {
console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
}
}
}
export async function refreshWorlds(instancePath: string): Promise<World[]> {
const worlds = await get_profile_worlds(instancePath).catch((err) => {
console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
})
if (worlds) {
sortWorlds(worlds)
}
return worlds ?? []
}
const FIRST_QUICK_PLAY_VERSION = '23w14a'
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return false
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}
export type ProfileEvent = { profile_path_id: string } & (
| {
event: 'servers_updated'
}
| {
event: 'world_updated'
world: string
}
| {
event: 'server_joined'
host: string
port: number
timestamp: string
}
)

View File

@@ -20,12 +20,57 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
"instance.add-server.add-server": {
"message": "Add server"
},
"instance.add-server.resource-pack.disabled": {
"message": "Disabled"
},
"instance.add-server.resource-pack.enabled": {
"message": "Enabled"
},
"instance.add-server.resource-pack.prompt": {
"message": "Prompt"
},
"instance.add-server.title": {
"message": "Add a server"
},
"instance.edit-server.title": {
"message": "Edit server"
},
"instance.edit-world.name": {
"message": "Name"
},
"instance.edit-world.placeholder-name": {
"message": "Minecraft World"
},
"instance.edit-world.reset-icon": {
"message": "Reset icon"
},
"instance.edit-world.title": {
"message": "Edit world"
},
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
"instance.server-modal.address": {
"message": "Address"
},
"instance.server-modal.name": {
"message": "Name"
},
"instance.server-modal.placeholder-name": {
"message": "Minecraft Server"
},
"instance.server-modal.resource-pack": {
"message": "Resource pack"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -308,6 +353,42 @@
"instance.settings.title": {
"message": "Settings"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
"instance.worlds.cant_connect": {
"message": "Can't connect to server"
},
"instance.worlds.copy_address": {
"message": "Copy address"
},
"instance.worlds.filter.available": {
"message": "Available"
},
"instance.worlds.game_already_open": {
"message": "Instance is already open"
},
"instance.worlds.hardcore": {
"message": "Hardcore mode"
},
"instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+"
},
"instance.worlds.play_anyway": {
"message": "Play anyway"
},
"instance.worlds.type.server": {
"message": "Server"
},
"instance.worlds.type.singleplayer": {
"message": "Singleplayer"
},
"instance.worlds.view_instance": {
"message": "View instance"
},
"instance.worlds.world_in_use": {
"message": "World is in use"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},

View File

@@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
const VIntlPlugin = createPlugin({
controllerOpts: {
@@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({
injectInto: [],
})
const vueScan = new VueScanPlugin({
enabled: false, // Enable or disable the tracker
showOverlay: true, // Show overlay to visualize renders
log: false, // Log render events to the console
playSound: false, // Play sound on each render
})
const pinia = createPinia()
let app = createApp(App)
@@ -35,6 +43,7 @@ Sentry.init({
tracesSampleRate: 0.1,
})
app.use(vueScan)
app.use(router)
app.use(pinia)
app.use(FloatingVue, {

View File

@@ -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 Modrinth 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',

View File

@@ -0,0 +1,4 @@
<script setup lang="ts"></script>
<template>
<div class="p-6 flex flex-col gap-2">Worlds</div>
</template>

View File

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

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 }

View File

@@ -192,6 +192,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([

View File

@@ -18,6 +18,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Home' }],
},
},
{
path: '/worlds',
name: 'Worlds',
component: Pages.Worlds,
meta: {
breadcrumb: [{ name: 'Worlds' }],
},
},
{
path: '/browse/:projectType',
name: 'Discover content',
@@ -106,13 +114,31 @@ export default new createRouter({
component: Instance.Index,
props: true,
children: [
// {
// path: '',
// name: 'Overview',
// component: Instance.Overview,
// meta: {
// useRootContext: true,
// breadcrumb: [{ name: '?Instance' }],
// },
// },
{
path: 'worlds',
name: 'InstanceWorlds',
component: Instance.Worlds,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: '',
name: 'Mods',
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance' }],
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
@@ -121,7 +147,7 @@ export default new createRouter({
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance' }],
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{

View File

@@ -3,9 +3,8 @@
windows_subsystem = "windows"
)]
use std::time::Duration;
use theseus::prelude::*;
use tokio::signal::ctrl_c;
use theseus::worlds::get_recent_worlds;
// A simple Rust implementation of the authentication run
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
@@ -41,21 +40,16 @@ async fn main() -> theseus::Result<()> {
// Initialize state
State::init().await?;
loop {
if State::get().await?.friends_socket.is_connected().await {
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
let worlds = get_recent_worlds(4).await?;
for world in worlds {
println!(
"World: {:?}/{:?} played at {:?}: {:#?}",
world.profile,
world.world.name,
world.world.last_played,
world.world.details
);
}
tracing::info!("Starting host");
let socket = State::get().await?.friends_socket.open_port(25565).await?;
tracing::info!("Running host on socket {}", socket.socket_id());
ctrl_c().await?;
tracing::info!("Stopping host");
socket.shutdown().await?;
Ok(())
}

View File

@@ -30,8 +30,10 @@ tokio = { version = "1", features = ["full"] }
thiserror = "1.0"
daedalus = { path = "../../packages/daedalus" }
chrono = "0.4.26"
either = "1.15"
url = "2.2"
urlencoding = "2.1"
uuid = { version = "1.1", features = ["serde", "v4"] }
os_info = "3.7.0"

View File

@@ -240,6 +240,29 @@ fn main() {
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"worlds",
InlinedPlugin::new()
.commands(&[
"get_recent_worlds",
"get_profile_worlds",
"get_singleplayer_world",
"rename_world",
"reset_world_icon",
"backup_world",
"delete_world",
"add_server_to_profile",
"edit_server_in_profile",
"remove_server_from_profile",
"get_profile_protocol_version",
"get_server_status",
"start_join_singleplayer_world",
"start_join_server",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
),
)
.expect("Failed to run tauri-build");

View File

@@ -35,6 +35,7 @@
"tags:default",
"utils:default",
"ads:default",
"friends:default"
"friends:default",
"worlds:default"
]
}

View File

@@ -19,6 +19,7 @@ pub mod utils;
pub mod ads;
pub mod cache;
pub mod friends;
pub mod worlds;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;

View File

@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use theseus::prelude::*;
use theseus::profile::QuickPlayType;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("profile")
@@ -250,7 +251,7 @@ pub async fn profile_get_pack_export_candidates(
// invoke('plugin:profile|profile_run', path)
#[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path).await?;
let process = profile::run(path, &QuickPlayType::None).await?;
Ok(process)
}
@@ -264,7 +265,9 @@ pub async fn profile_run_credentials(
path: &str,
credentials: Credentials,
) -> Result<ProcessMetadata> {
let process = profile::run_credentials(path, &credentials).await?;
let process =
profile::run_credentials(path, &credentials, &QuickPlayType::None)
.await?;
Ok(process)
}
@@ -347,6 +350,9 @@ pub async fn profile_edit(path: &str, edit_profile: EditProfile) -> Result<()> {
prof.name = name;
}
if let Some(game_version) = edit_profile.game_version.clone() {
if game_version != prof.game_version {
prof.protocol_version = None;
}
prof.game_version = game_version;
}
if let Some(loader) = edit_profile.loader {

View File

@@ -4,9 +4,11 @@ use theseus::{
prelude::{CommandPayload, DirectoryInfo},
};
use crate::api::Result;
use crate::api::{Result, TheseusSerializableError};
use dashmap::DashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use theseus::prelude::canonicalize;
use url::Url;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("utils")
@@ -140,3 +142,28 @@ pub async fn handle_command(command: String) -> Result<()> {
tracing::info!("handle command: {command}");
Ok(theseus::handler::parse_and_emit_command(&command).await?)
}
// Remove when (and if) https://github.com/tauri-apps/tauri/issues/12022 is implemented
pub(crate) fn tauri_convert_file_src(path: &Path) -> Result<Url> {
#[cfg(any(windows, target_os = "android"))]
const BASE: &str = "http://asset.localhost/";
#[cfg(not(any(windows, target_os = "android")))]
const BASE: &str = "asset://localhost/";
macro_rules! theseus_try {
($test:expr) => {
match $test {
Ok(val) => val,
Err(e) => {
return Err(TheseusSerializableError::Theseus(e.into()))
}
}
};
}
let path = theseus_try!(canonicalize(path));
let path = path.to_string_lossy();
let encoded = urlencoding::encode(&path);
Ok(theseus_try!(Url::parse(&format!("{BASE}{encoded}"))))
}

195
apps/app/src/api/worlds.rs Normal file
View File

@@ -0,0 +1,195 @@
use crate::api::Result;
use either::Either;
use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata;
use theseus::profile::{get_full_path, QuickPlayType};
use theseus::worlds::{
ServerPackStatus, ServerStatus, World, WorldWithProfile,
};
use theseus::{profile, worlds};
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("worlds")
.invoke_handler(tauri::generate_handler![
get_recent_worlds,
get_profile_worlds,
get_singleplayer_world,
rename_world,
reset_world_icon,
backup_world,
delete_world,
add_server_to_profile,
edit_server_in_profile,
remove_server_from_profile,
get_profile_protocol_version,
get_server_status,
start_join_singleplayer_world,
start_join_server,
])
.build()
}
#[tauri::command]
pub async fn get_recent_worlds<R: Runtime>(
app_handle: AppHandle<R>,
limit: usize,
) -> Result<Vec<WorldWithProfile>> {
let mut result = worlds::get_recent_worlds(limit).await?;
for world in result.iter_mut() {
adapt_world_icon(&app_handle, &mut world.world);
}
Ok(result)
}
#[tauri::command]
pub async fn get_profile_worlds<R: Runtime>(
app_handle: AppHandle<R>,
path: &str,
) -> Result<Vec<World>> {
let mut result = worlds::get_profile_worlds(path).await?;
for world in result.iter_mut() {
adapt_world_icon(&app_handle, world);
}
Ok(result)
}
#[tauri::command]
pub async fn get_singleplayer_world<R: Runtime>(
app_handle: AppHandle<R>,
instance: &str,
world: &str,
) -> Result<World> {
let instance = get_full_path(instance).await?;
let mut world = worlds::get_singleplayer_world(&instance, world).await?;
adapt_world_icon(&app_handle, &mut world);
Ok(world)
}
fn adapt_world_icon<R: Runtime>(app_handle: &AppHandle<R>, world: &mut World) {
if let Some(Either::Left(icon_path)) = &world.icon {
let icon_path = icon_path.clone();
if let Ok(new_url) = super::utils::tauri_convert_file_src(&icon_path) {
world.icon = Some(Either::Right(new_url));
if let Err(e) =
app_handle.asset_protocol_scope().allow_file(&icon_path)
{
tracing::warn!(
"Failed to allow file access for icon {}: {}",
icon_path.display(),
e
);
}
} else {
tracing::warn!(
"Encountered invalid icon path for world {}: {}",
world.name,
icon_path.display()
);
world.icon = None;
}
}
}
#[tauri::command]
pub async fn rename_world(
instance: &str,
world: &str,
new_name: &str,
) -> Result<()> {
let instance = get_full_path(instance).await?;
worlds::rename_world(&instance, world, new_name).await?;
Ok(())
}
#[tauri::command]
pub async fn reset_world_icon(instance: &str, world: &str) -> Result<()> {
let instance = get_full_path(instance).await?;
worlds::reset_world_icon(&instance, world).await?;
Ok(())
}
#[tauri::command]
pub async fn backup_world(instance: &str, world: &str) -> Result<u64> {
let instance = get_full_path(instance).await?;
Ok(worlds::backup_world(&instance, world).await?)
}
#[tauri::command]
pub async fn delete_world(instance: &str, world: &str) -> Result<()> {
let instance = get_full_path(instance).await?;
worlds::delete_world(&instance, world).await?;
Ok(())
}
#[tauri::command]
pub async fn add_server_to_profile(
path: &str,
name: String,
address: String,
pack_status: ServerPackStatus,
) -> Result<usize> {
let path = get_full_path(path).await?;
Ok(
worlds::add_server_to_profile(&path, name, address, pack_status)
.await?,
)
}
#[tauri::command]
pub async fn edit_server_in_profile(
path: &str,
index: usize,
name: String,
address: String,
pack_status: ServerPackStatus,
) -> Result<()> {
let path = get_full_path(path).await?;
worlds::edit_server_in_profile(&path, index, name, address, pack_status)
.await?;
Ok(())
}
#[tauri::command]
pub async fn remove_server_from_profile(
path: &str,
index: usize,
) -> Result<()> {
let path = get_full_path(path).await?;
worlds::remove_server_from_profile(&path, index).await?;
Ok(())
}
#[tauri::command]
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
Ok(worlds::get_profile_protocol_version(path).await?)
}
#[tauri::command]
pub async fn get_server_status(
address: &str,
protocol_version: Option<i32>,
) -> Result<ServerStatus> {
Ok(worlds::get_server_status(address, protocol_version).await?)
}
#[tauri::command]
pub async fn start_join_singleplayer_world(
path: &str,
world: String,
) -> Result<ProcessMetadata> {
let process =
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
Ok(process)
}
#[tauri::command]
pub async fn start_join_server(
path: &str,
address: &str,
) -> Result<ProcessMetadata> {
let process =
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
Ok(process)
}

View File

@@ -268,6 +268,7 @@ fn main() {
.plugin(api::cache::init())
.plugin(api::ads::init())
.plugin(api::friends::init())
.plugin(api::worlds::init())
.invoke_handler(tauri::generate_handler![
initialize_state,
is_dev,

View File

@@ -76,7 +76,7 @@
],
"security": {
"assetProtocol": {
"scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*"],
"scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*", "$APPDATA/profiles/*/saves/*/icon.png", "$APPCONFIG/profiles/*/saves/*/icon.png", "$CONFIG/profiles/*/saves/*/icon.png"],
"enable": true
},
"capabilities": ["ads", "core", "plugins"],

View File

@@ -930,7 +930,7 @@ button {
color: var(--color-text);
padding: 0.5rem 0 0.5rem 1rem;
font-weight: var(--font-weight-medium);
min-height: 40px;
min-height: 36px;
box-sizing: border-box;
width: fit-content;
align-items: center;

View File

@@ -451,7 +451,7 @@ textarea {
var(--shadow-inset-sm),
0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 40px;
min-height: 36px;
&:focus,
&:focus-visible {

View File

@@ -1,660 +0,0 @@
<template>
<div class="flex items-center justify-center">
<div class="w-full overflow-hidden">
<div class="mb-4">
<div
v-for="(line, lineIndex) in motd"
:key="lineIndex"
class="relative mb-2 rounded bg-button-bg p-2"
>
<div
class="font-minecraft text-white"
:contenteditable="true"
spellcheck="false"
@input="handleInput($event, lineIndex)"
@keydown.enter.prevent
@paste.prevent="handlePaste($event, lineIndex)"
@mouseup="handleSelection(lineIndex)"
v-html="renderLine(line)"
></div>
<div class="text-sm text-gray-400">
{{ motd[lineIndex].reduce((sum, segment) => sum + segment.text.length, 0) }}/45
characters
</div>
</div>
</div>
</div>
<transition name="fade">
<div
v-if="showPopup"
:style="{ top: `${popupY}px`, left: `${popupX}px` }"
class="fixed z-10 flex flex-col items-end gap-2 transition-all duration-300 ease-in-out"
>
<div class="rounded-xl border bg-table-alternateRow p-2 shadow-lg">
<div class="flex space-x-2">
<Button
v-for="style in styles"
:key="style.name"
icon-only
transparent
@click="applyStyle({ [style.name]: !currentStyle[style.name] })"
>
<component :is="style.icon" class="h-4 w-4" />
</Button>
<div class="relative overflow-y-scroll">
<Button icon-only transparent :class="colorPicker ?? 'hidden'" @click="pickColor">
<PaintBrushIcon />
</Button>
</div>
</div>
</div>
<div
v-if="colorPicker"
icon-only
class="w-fit overflow-y-auto rounded-xl p-2 [&&]:bg-table-alternateRow"
>
<div :class="colorPicker ? `grid grid-flow-col grid-rows-4 gap-2` : '[&&]:hidden'">
<button
v-for="format in sortedFormatCodes()"
:key="format.code"
class="rounded-full p-3"
:style="{ backgroundColor: format.color }"
:title="format.description"
@click="applyStyle({ color: format.color })"
></button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import {
ItalicIcon,
BoldIcon,
StrikethroughIcon,
UnderlineIcon,
PaintBrushIcon,
ChevronLeftIcon,
} from "@modrinth/assets";
import { Button } from "@modrinth/ui";
const props = defineProps({
server: {
type: Object,
required: true,
},
});
const formatCodes = [
{ code: "§f", color: "white", description: "White" },
{ code: "§7", color: "#AAAAAA", description: "Gray" },
{ code: "§8", color: "#555555", description: "Dark Gray" },
{ code: "§0", color: "#000000", description: "Black" },
{ code: "§9", color: "#5555FF", description: "Blue" },
{ code: "§1", color: "#0000AA", description: "Dark Blue" },
{ code: "§b", color: "#55FFFF", description: "Aqua" },
{ code: "§3", color: "#00AAAA", description: "Dark Aqua" },
{ code: "§a", color: "#55FF55", description: "Green" },
{ code: "§2", color: "#00AA00", description: "Dark Green" },
{ code: "§e", color: "#FFFF55", description: "Yellow" },
{ code: "§6", color: "#FFAA00", description: "Gold" },
{ code: "§c", color: "#FF5555", description: "Red" },
{ code: "§4", color: "#AA0000", description: "Dark Red" },
{ code: "§d", color: "#FF55FF", description: "Light Purple" },
{ code: "§5", color: "#AA00AA", description: "Dark Purple" },
];
const sortedFormatCodes = () => {
const colors = formatCodes;
if (colors[0].description === "White") {
colors.reverse();
}
return colors;
};
const minecraftEmojis = [
{ char: "☺", name: "SMILING FACE" },
{ char: "☹", name: "FROWNING FACE" },
{ char: "☠", name: "SKULL AND CROSSBONES" },
{ char: "❣", name: "HEART EXCLAMATION" },
{ char: "❤", name: "RED HEART" },
{ char: "✌", name: "VICTORY HAND" },
{ char: "☝", name: "INDEX POINTING UP" },
{ char: "✍", name: "WRITING HAND" },
{ char: "♨", name: "HOT SPRINGS" },
{ char: "✈", name: "AIRPLANE" },
{ char: "⌛", name: "HOURGLASS DONE" },
{ char: "⌚", name: "WATCH" },
{ char: "☀", name: "SUN" },
{ char: "☁", name: "CLOUD" },
{ char: "☂", name: "UMBRELLA" },
{ char: "❄", name: "SNOWFLAKE" },
{ char: "☃", name: "SNOWMAN" },
{ char: "☄", name: "COMET" },
{ char: "♠", name: "SPADE SUIT" },
{ char: "♥", name: "HEART SUIT" },
{ char: "♦", name: "DIAMOND SUIT" },
{ char: "♣", name: "CLUB SUIT" },
{ char: "♟", name: "CHESS PAWN" },
{ char: "☎", name: "TELEPHONE" },
{ char: "⌨", name: "KEYBOARD" },
{ char: "✉", name: "ENVELOPE" },
{ char: "✏", name: "PENCIL" },
{ char: "✒", name: "BLACK PEN" },
{ char: "✂", name: "SCISSORS" },
{ char: "☢", name: "RADIOACTIVE" },
{ char: "☣", name: "BIOHAZARD" },
{ char: "⬆", name: "UP ARROW" },
{ char: "⬇", name: "DOWN ARROW" },
{ char: "➡", name: "RIGHT ARROW" },
{ char: "⬅", name: "LEFT ARROW" },
{ char: "↗", name: "UP-RIGHT ARROW" },
{ char: "↘", name: "DOWN-RIGHT ARROW" },
{ char: "↙", name: "DOWN-LEFT ARROW" },
{ char: "↖", name: "UP-LEFT ARROW" },
{ char: "↕", name: "UP-DOWN ARROW" },
{ char: "↔", name: "LEFT-RIGHT ARROW" },
{ char: "↩", name: "RIGHT ARROW CURVING LEFT" },
{ char: "↪", name: "LEFT ARROW CURVING RIGHT" },
{ char: "✡", name: "STAR OF DAVID" },
{ char: "☸", name: "WHEEL OF DHARMA" },
{ char: "☯", name: "YIN YANG" },
{ char: "✝", name: "LATIN CROSS" },
{ char: "☦", name: "ORTHODOX CROSS" },
{ char: "☪", name: "STAR AND CRESCENT" },
{ char: "☮", name: "PEACE SYMBOL" },
{ char: "♈", name: "ARIES" },
{ char: "♉", name: "TAURUS" },
{ char: "♊", name: "GEMINI" },
{ char: "♋", name: "CANCER" },
{ char: "♌", name: "LEO" },
{ char: "♍", name: "VIRGO" },
{ char: "♎", name: "LIBRA" },
{ char: "♏", name: "SCORPIO" },
{ char: "♐", name: "SAGITTARIUS" },
{ char: "♑", name: "CAPRICORN" },
{ char: "♒", name: "AQUARIUS" },
{ char: "♓", name: "PISCES" },
{ char: "▶", name: "PLAY BUTTON" },
{ char: "◀", name: "REVERSE BUTTON" },
{ char: "♀", name: "FEMALE SIGN" },
{ char: "♂", name: "MALE SIGN" },
{ char: "✖", name: "MULTIPLY" },
{ char: "‼", name: "DOUBLE EXCLAMATION MARK" },
{ char: "〰", name: "WAVY DASH" },
{ char: "☑", name: "CHECK BOX WITH CHECK" },
{ char: "✔", name: "CHECK MARK" },
{ char: "✳", name: "EIGHT-SPOKED ASTERISK" },
{ char: "✴", name: "EIGHT-POINTED STAR" },
{ char: "❇", name: "SPARKLE" },
{ char: "©", name: "COPYRIGHT" },
{ char: "®", name: "REGISTERED" },
{ char: "™", name: "TRADE MARK" },
{ char: "Ⓜ", name: "CIRCLED M" },
{ char: "㊗", name: 'JAPANESE "CONGRATULATIONS" BUTTON' },
{ char: "㊙", name: 'JAPANESE "SECRET" BUTTON' },
{ char: "▪", name: "BLACK SMALL SQUARE" },
{ char: "▫", name: "WHITE SMALL SQUARE" },
{ char: "☷", name: "TRIGRAM FOR EARTH" },
{ char: "☵", name: "TRIGRAM FOR WATER" },
{ char: "☶", name: "TRIGRAM FOR MOUNTAIN" },
{ char: "☋", name: "DESCENDING NODE" },
{ char: "☌", name: "CONJUNCTION" },
{ char: "♜", name: "BLACK CHESS ROOK" },
{ char: "♕", name: "WHITE CHESS QUEEN" },
{ char: "♡", name: "WHITE HEART SUIT" },
{ char: "♬", name: "BEAMED SIXTEENTH NOTES" },
{ char: "☚", name: "BLACK LEFT POINTING INDEX" },
{ char: "♮", name: "MUSIC NATURAL SIGN" },
{ char: "♝", name: "BLACK CHESS BISHOP" },
{ char: "♯", name: "SHARP" },
{ char: "☴", name: "TRIGRAM FOR WIND" },
{ char: "♭", name: "FLAT" },
{ char: "☓", name: "SALTIRE" },
{ char: "☛", name: "BLACK RIGHT POINTING INDEX" },
{ char: "☭", name: "HAMMER AND SICKLE" },
{ char: "♢", name: "WHITE DIAMOND SUIT" },
{ char: "✐", name: "UPPER RIGHT PENCIL" },
{ char: "♖", name: "WHITE CHESS ROOK" },
{ char: "☈", name: "THUNDERSTORM" },
{ char: "☒", name: "BALLOT BOX WITH X" },
{ char: "★", name: "BLACK STAR" },
{ char: "♚", name: "BLACK CHESS KING" },
{ char: "♛", name: "BLACK CHESS QUEEN" },
{ char: "✎", name: "LOWER RIGHT PENCIL" },
{ char: "♪", name: "EIGHTH NOTE" },
{ char: "☰", name: "TRIGRAM FOR HEAVEN" },
{ char: "☽", name: "FIRST QUARTER MOON" },
{ char: "☡", name: "CAUTION SIGN" },
{ char: "☼", name: "WHITE SUN WITH RAYS" },
{ char: "♅", name: "URANUS" },
{ char: "☐", name: "BALLOT BOX" },
{ char: "☟", name: "WHITE DOWN POINTING INDEX" },
{ char: "❦", name: "FLORAL HEART" },
{ char: "☊", name: "ASCENDING NODE" },
{ char: "☍", name: "OPPOSITION" },
{ char: "☬", name: "ADI SHAKTI" },
{ char: "♧", name: "WHITE CLUB SUIT" },
{ char: "☫", name: "FARSI SYMBOL" },
{ char: "☱", name: "TRIGRAM FOR LAKE" },
{ char: "☾", name: "LAST QUARTER MOON" },
{ char: "☤", name: "CADUCEUS" },
{ char: "❧", name: "ROTATED FLORAL HEART BULLET" },
{ char: "♄", name: "SATURN" },
{ char: "♁", name: "EARTH" },
{ char: "♔", name: "WHITE CHESS KING" },
{ char: "❥", name: "ROTATED HEAVY BLACK HEART BULLET" },
{ char: "☥", name: "ANKH" },
{ char: "☻", name: "BLACK SMILING FACE" },
{ char: "♤", name: "WHITE SPADE SUIT" },
{ char: "♞", name: "BLACK CHESS KNIGHT" },
{ char: "♆", name: "NEPTUNE" },
{ char: "#", name: "HASH SIGN" },
{ char: "♃", name: "JUPITER" },
{ char: "♩", name: "QUARTER NOTE" },
{ char: "☇", name: "LIGHTNING" },
{ char: "☞", name: "WHITE RIGHT POINTING INDEX" },
{ char: "♫", name: "BEAMED EIGHTH NOTES" },
{ char: "☏", name: "WHITE TELEPHONE" },
{ char: "♘", name: "WHITE CHESS KNIGHT" },
{ char: "☧", name: "CHI RHO" },
{ char: "☉", name: "SUN" },
{ char: "♇", name: "PLUTO" },
{ char: "☩", name: "CROSS OF JERUSALEM" },
{ char: "♙", name: "WHITE CHESS PAWN" },
{ char: "☜", name: "WHITE LEFT POINTING INDEX" },
{ char: "☲", name: "TRIGRAM FOR FIRE" },
{ char: "☨", name: "CROSS OF LORRAINE" },
{ char: "♗", name: "WHITE CHESS BISHOP" },
{ char: "☳", name: "TRIGRAM FOR THUNDER" },
{ char: "⚔", name: "CROSSED SWORDS" },
{ char: "⚀", name: "DICE ONE" },
];
const rawMotd = ref(props.server.general?.motd ?? "");
const motd = computed(() => {
const lines = rawMotd.value.split("\n");
return lines.map((line) => {
const segments = [];
let currentSegment = { text: "", color: "White" };
let i = 0;
while (i < line.length) {
if (line[i] === "§") {
if (currentSegment.text) {
segments.push({ ...currentSegment });
currentSegment = { text: "", color: "White" };
}
const formatCode = line.substr(i, 2);
const format = formatCodes.find((f) => f.code === formatCode);
console.log(format);
console.log(formatCode);
if (format) {
currentSegment.color = format.color;
i += 2;
continue;
} else if (formatCode === "§l") {
currentSegment.bold = true;
i += 2;
continue;
} else if (formatCode === "§o") {
currentSegment.italic = true;
i += 2;
continue;
} else if (formatCode === "§n") {
currentSegment.underline = true;
i += 2;
continue;
} else if (formatCode === "§m") {
currentSegment.strikethrough = true;
i += 2;
continue;
}
}
currentSegment.text += line[i];
i++;
}
if (currentSegment.text) {
segments.push(currentSegment);
}
return segments;
});
});
const styles = [
{
name: "bold",
icon: BoldIcon,
},
{
name: "italic",
icon: ItalicIcon,
},
{
name: "underline",
icon: UnderlineIcon,
},
{
name: "strikethrough",
icon: StrikethroughIcon,
},
];
const showPopup = ref(false);
const popupX = ref(0);
const popupY = ref(0);
const currentLineIndex = ref(0);
const selectionStart = ref(0);
const selectionEnd = ref(0);
const colorPicker = ref(false);
const pickColor = () => {
colorPicker.value = !colorPicker.value;
};
const totalCharacters = computed(() => {
return motd.value.reduce((sum, line) => {
return Math.max(
sum,
line.reduce((lineSum, segment) => lineSum + segment.text.length, 0),
);
}, 0);
});
const minecraftFormat = computed(() => {
return motd.value
.map((line) => {
return line
.map((segment) => {
let format = getColorCode(segment.color);
if (segment.bold) format += "§l";
if (segment.italic) format += "§o";
if (segment.underline) format += "§n";
if (segment.strikethrough) format += "§m";
return format + segment.text;
})
.join("");
})
.join("\n");
});
const currentStyle = computed(() => {
const line = motd.value[currentLineIndex.value];
if (!line) return {};
let start = 0;
for (const segment of line) {
if (start + segment.text.length > selectionStart.value) {
return {
color: segment.color || "White",
bold: segment.bold || false,
italic: segment.italic || false,
underline: segment.underline || false,
strikethrough: segment.strikethrough || false,
};
}
start += segment.text.length;
}
return {};
});
function getColorCode(color) {
const format = formatCodes.find((f) => f.description === color);
return format ? format.code : "§f";
}
function renderLine(line) {
return line
.map((segment) => {
let style = `color: ${segment.color};`;
if (segment.bold) style += "font-weight: 900;";
if (segment.italic) style += "font-style: italic;";
if (segment.underline) style += "text-decoration: underline;";
if (segment.strikethrough) style += "text-decoration: line-through;";
return `<span style="${style}">${segment.text}</span>`;
})
.join("");
}
function handleSelection(lineIndex) {
const selection = window.getSelection();
if (selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
popupX.value = rect.left;
popupY.value = rect.bottom;
showPopup.value = true;
currentLineIndex.value = lineIndex;
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
const rangeClone = range.cloneRange();
rangeClone.selectNodeContents(lineElement);
rangeClone.setEnd(range.startContainer, range.startOffset);
selectionStart.value = rangeClone.toString().length;
selectionEnd.value = selectionStart.value + range.toString().length;
} else {
showPopup.value = false;
colorPicker.value = false;
}
}
function applyStyle(newStyle) {
const line = motd.value[currentLineIndex.value];
const newLine = [];
let currentPos = 0;
for (const segment of line) {
if (currentPos + segment.text.length <= selectionStart.value) {
newLine.push(segment);
} else if (currentPos >= selectionEnd.value) {
newLine.push(segment);
} else {
const beforeSelection = segment.text.slice(0, Math.max(0, selectionStart.value - currentPos));
const inSelection = segment.text.slice(
Math.max(0, selectionStart.value - currentPos),
Math.min(segment.text.length, selectionEnd.value - currentPos),
);
const afterSelection = segment.text.slice(
Math.min(segment.text.length, selectionEnd.value - currentPos),
);
console.log(beforeSelection);
console.log(inSelection);
console.log(afterSelection);
if (beforeSelection) newLine.push({ ...segment, text: beforeSelection });
if (inSelection) {
const mergedStyle = { ...segment, ...newStyle };
for (const key in newStyle) {
if (newStyle[key] === false) {
delete mergedStyle[key];
}
}
newLine.push({ ...mergedStyle, text: inSelection });
}
if (afterSelection) newLine.push({ ...segment, text: afterSelection });
}
currentPos += segment.text.length;
}
motd.value[currentLineIndex.value] = newLine;
showPopup.value = false;
colorPicker.value = false;
// Rerender the line to reflect the changes
nextTick(() => {
const lineElement = document.querySelectorAll("[contenteditable]")[currentLineIndex.value];
lineElement.innerHTML = renderLine(newLine);
});
}
function insertEmoji() {
const emoji = "☺";
if (totalCharacters.value + emoji.length <= 90) {
applyStyle({ text: emoji });
}
}
function handleInput(event, lineIndex) {
const newText = event.target.textContent;
const oldText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
const diff = newText.length - oldText.length;
if (newText.length <= 45) {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const cursorOffset = getCursorOffset(event.target, range);
const newLine = [];
let currentPos = 0;
for (const segment of motd.value[lineIndex]) {
const segmentEnd = currentPos + segment.text.length;
const newSegmentText = newText.slice(currentPos, Math.min(segmentEnd, newText.length));
if (newSegmentText) {
newLine.push({ ...segment, text: newSegmentText });
}
currentPos = segmentEnd;
if (currentPos >= newText.length) break;
}
if (currentPos < newText.length) {
newLine.push({ text: newText.slice(currentPos), color: "White" });
}
motd.value[lineIndex] = newLine;
nextTick(() => {
const lineElement = event.target;
lineElement.innerHTML = renderLine(newLine);
const newRange = document.createRange();
const sel = window.getSelection();
const { node, offset } = getCursorNodeAndOffset(lineElement, cursorOffset);
if (node) {
newRange.setStart(node, offset);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
});
} else {
event.target.innerHTML = renderLine(motd.value[lineIndex]);
}
}
// Helper function to get cursor offset considering styled spans
function getCursorOffset(element, range) {
let offset = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
if (node === range.startContainer) {
return offset + range.startOffset;
}
offset += node.length;
}
return offset;
}
// Helper function to find the node and offset for cursor placement
function getCursorNodeAndOffset(element, targetOffset) {
let currentOffset = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
if (currentOffset + node.length >= targetOffset) {
return { node, offset: targetOffset - currentOffset };
}
currentOffset += node.length;
}
// If we've gone past the end, return the last possible position
const lastTextNode = element.lastChild?.lastChild;
return { node: lastTextNode, offset: lastTextNode?.length || 0 };
}
function handlePaste(event, lineIndex) {
event.preventDefault();
const pastedText = (event.clipboardData || window.clipboardData).getData("text");
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const startOffset = range.startOffset;
const currentText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
const newText = currentText.slice(0, startOffset) + pastedText + currentText.slice(startOffset);
if (newText.length <= 45) {
// Preserve existing styles by matching new text with old segments
const newLine = [];
let currentPos = 0;
for (const segment of motd.value[lineIndex]) {
if (currentPos < startOffset) {
const segmentEnd = Math.min(currentPos + segment.text.length, startOffset);
newLine.push({ ...segment, text: newText.slice(currentPos, segmentEnd) });
currentPos = segmentEnd;
} else if (currentPos >= startOffset + pastedText.length) {
newLine.push({ ...segment, text: newText.slice(currentPos) });
break;
}
}
// Insert pasted text as a new segment
if (currentPos < startOffset + pastedText.length) {
newLine.push({
text: newText.slice(currentPos, startOffset + pastedText.length),
color: "White",
});
}
motd.value[lineIndex] = newLine;
nextTick(() => {
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
lineElement.innerHTML = renderLine(newLine);
const newRange = document.createRange();
const sel = window.getSelection();
newRange.setStart(lineElement.childNodes[0], startOffset + pastedText.length);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
});
}
}
</script>
<style scoped>
.minecraft-font {
font-family: "Minecraft", monospace;
font-size: 16px;
line-height: 1.5;
}
[contenteditable] {
outline: none;
}
</style>
<style scoped>
@font-face {
font-family: "Monocraft";
src: url("/Monocraft.ttf") format("truetype");
}
.font-minecraft {
font-family: "Monocraft", monospace;
}
.mcbg {
background: url("@/assets/images/servers/minecraft-background-dark.png") repeat center center;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease-in-out;
}
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
opacity: 0;
}
</style>

View File

@@ -1,14 +0,0 @@
<template>
<a
href="https://pyro.host"
target="_blank"
class="mx-auto mt-8 flex select-none flex-row items-center gap-2 hover:underline"
>
<PyroIcon class="size-4 text-secondary" />
<span class="text-sm text-secondary">Powered by Pyro</span>
</a>
</template>
<script setup lang="ts">
import { PyroIcon } from "@modrinth/assets";
</script>

View File

@@ -91,8 +91,7 @@ pub async fn ws_init(
let friend_statuses = if !friends.is_empty() {
let db = db.clone();
let redis = redis.clone();
let statuses = tokio_stream::iter(friends.iter())
tokio_stream::iter(friends.iter())
.map(|x| {
let db = db.clone();
let redis = redis.clone();
@@ -112,9 +111,10 @@ pub async fn ws_init(
})
.buffer_unordered(16)
.collect::<Vec<_>>()
.await;
statuses.into_iter().flatten().collect()
.await
.into_iter()
.flatten()
.collect()
} else {
Vec::new()
};