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

@@ -245,7 +245,7 @@ const colorVariables = computed(() => {
}
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95 hover:brightness-125 focus-visible:brightness-125 hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
@apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
&:hover svg:first-child,
&:focus-visible svg:first-child {

View File

@@ -0,0 +1,52 @@
<template>
<div v-if="options.length > 1" class="flex flex-wrap gap-1 items-center">
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button
v-for="filter in options"
:key="`filter-${filter.id}`"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleFilter(filter.id)"
>
{{ formatMessage(filter.message) }}
</button>
</div>
</template>
<script setup lang="ts">
import { FilterIcon } from '@modrinth/assets'
import { watch } from 'vue'
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
export type FilterBarOption = {
id: string
message: MessageDescriptor
}
const selectedFilters = defineModel<string[]>({ required: true })
const props = defineProps<{
options: FilterBarOption[]
}>()
watch(
() => props.options,
() => {
for (let i = 0; i < selectedFilters.value.length; i++) {
const option = selectedFilters.value[i]
if (!props.options.some((x) => x.id === option)) {
selectedFilters.value.splice(i, 1)
}
}
},
)
function toggleFilter(option: string) {
if (selectedFilters.value.includes(option)) {
selectedFilters.value.splice(selectedFilters.value.indexOf(option), 1)
} else {
selectedFilters.value.push(option)
}
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<AutoLink
:to="to"
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group w-fit"
>
<slot />
<ChevronRightIcon
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
/>
</AutoLink>
</template>
<script setup lang="ts">
import AutoLink from './AutoLink.vue'
import { ChevronRightIcon } from '@modrinth/assets'
defineProps<{
to: unknown
}>()
</script>

View File

@@ -1,8 +1,10 @@
<template>
<div :style="colorClasses" class="radial-header relative pb-1" v-bind="$attrs">
<slot />
<div>
<div :style="colorClasses" class="radial-header relative" v-bind="$attrs">
<slot />
</div>
<div class="radial-header-divider" />
</div>
<div class="radial-header-divider" />
</template>
<script setup lang="ts">
import { computed } from 'vue'

View File

@@ -0,0 +1,61 @@
<template>
<div class="smart-clickable" :class="{ 'smart-clickable--has-clickable': !!$slots.clickable }">
<slot name="clickable" />
<div v-bind="$attrs" class="smart-clickable__contents pointer-events-none">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
</script>
<style scoped lang="scss">
.smart-clickable {
display: grid;
> * {
grid-area: 1 / 1;
}
.smart-clickable__contents {
// Utility classes for contents
:deep(.smart-clickable\:allow-pointer-events) {
pointer-events: all;
}
}
}
// Only apply effects when a clickable is present
.smart-clickable.smart-clickable--has-clickable {
// Setup base styles for contents
.smart-clickable__contents {
transition: scale 0.125s ease-out;
// Why? I don't know. It forces the SVGs to render differently, which fixes some shift on hover otherwise.
//filter: brightness(1.00001);
}
// When clickable is being hovered or focus-visible, give contents an effect
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
filter: var(--hover-filter-weak);
// Utility classes for contents
:deep(.smart-clickable\:underline-on-hover) {
text-decoration: underline;
}
// Utility classes for contents
:deep(.smart-clickable\:highlight-on-hover) {
filter: brightness(var(--hover-brightness, 1.25));
}
}
// When clickable is being clicked, give contents an effect
&:has(> *:first-child:active) .smart-clickable__contents {
scale: 0.97;
}
}
</style>

View File

@@ -17,6 +17,9 @@ export { default as DropArea } from './base/DropArea.vue'
export { default as DropdownSelect } from './base/DropdownSelect.vue'
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
export { default as FileInput } from './base/FileInput.vue'
export { default as FilterBar } from './base/FilterBar.vue'
export type { FilterBarOption } from './base/FilterBar.vue'
export { default as HeadingLink } from './base/HeadingLink.vue'
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
export { default as ManySelect } from './base/ManySelect.vue'
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
@@ -34,6 +37,7 @@ export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as ServerNotice } from './base/ServerNotice.vue'
export { default as SimpleBadge } from './base/SimpleBadge.vue'
export { default as Slider } from './base/Slider.vue'
export { default as SmartClickable } from './base/SmartClickable.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as TagItem } from './base/TagItem.vue'
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'

View File

@@ -6,11 +6,16 @@
</slot>
</template>
<div class="flex flex-col gap-4">
<div
v-if="description"
class="markdown-body max-w-[35rem]"
v-html="renderString(description)"
/>
<template v-if="description">
<div
v-if="markdown"
class="markdown-body max-w-[35rem]"
v-html="renderString(description)"
/>
<p v-else class="max-w-[35rem] m-0">
{{ description }}
</p>
</template>
<slot />
<label v-if="hasToType" for="confirmation">
<span>
@@ -46,7 +51,7 @@
<script setup>
import { renderString } from '@modrinth/utils'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { TrashIcon, XIcon } from '@modrinth/assets'
import NewModal from './NewModal.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
@@ -92,6 +97,10 @@ const props = defineProps({
return () => {}
},
},
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])

View File

@@ -20,6 +20,21 @@
"button.edit": {
"defaultMessage": "Edit"
},
"button.open-folder": {
"defaultMessage": "Open folder"
},
"button.play": {
"defaultMessage": "Play"
},
"button.refresh": {
"defaultMessage": "Refresh"
},
"button.remove": {
"defaultMessage": "Remove"
},
"button.remove-image": {
"defaultMessage": "Remove image"
},
"button.report": {
"defaultMessage": "Report"
},
@@ -35,6 +50,9 @@
"button.sign-out": {
"defaultMessage": "Sign out"
},
"button.stop": {
"defaultMessage": "Stop"
},
"button.upload-image": {
"defaultMessage": "Upload image"
},
@@ -65,6 +83,21 @@
"input.view.list": {
"defaultMessage": "Rows view"
},
"instance.worlds.game_mode.adventure": {
"defaultMessage": "Adventure mode"
},
"instance.worlds.game_mode.creative": {
"defaultMessage": "Creative mode"
},
"instance.worlds.game_mode.spectator": {
"defaultMessage": "Spectator mode"
},
"instance.worlds.game_mode.survival": {
"defaultMessage": "Survival mode"
},
"instance.worlds.game_mode.unknown": {
"defaultMessage": "Unknown game mode"
},
"label.changes-saved": {
"defaultMessage": "Changes saved"
},
@@ -89,6 +122,9 @@
"label.followed-projects": {
"defaultMessage": "Followed projects"
},
"label.loading": {
"defaultMessage": "Loading..."
},
"label.moderation": {
"defaultMessage": "Moderation"
},
@@ -98,6 +134,9 @@
"label.password": {
"defaultMessage": "Password"
},
"label.played": {
"defaultMessage": "Played {time}"
},
"label.public": {
"defaultMessage": "Public"
},
@@ -107,12 +146,18 @@
"label.scopes": {
"defaultMessage": "Scopes"
},
"label.server": {
"defaultMessage": "Server"
},
"label.servers": {
"defaultMessage": "Servers"
},
"label.settings": {
"defaultMessage": "Settings"
},
"label.singleplayer": {
"defaultMessage": "Singleplayer"
},
"label.title": {
"defaultMessage": "Title"
},

View File

@@ -85,6 +85,10 @@ export const commonMessages = defineMessages({
id: 'input.view.list',
defaultMessage: 'Rows view',
},
loadingLabel: {
id: 'label.loading',
defaultMessage: 'Loading...',
},
moderationLabel: {
id: 'label.moderation',
defaultMessage: 'Moderation',
@@ -93,6 +97,14 @@ export const commonMessages = defineMessages({
id: 'label.notifications',
defaultMessage: 'Notifications',
},
playButton: {
id: 'button.play',
defaultMessage: 'Play',
},
playedLabel: {
id: 'label.played',
defaultMessage: 'Played {time}',
},
privateLabel: {
id: 'collection.label.private',
defaultMessage: 'Private',
@@ -101,14 +113,26 @@ export const commonMessages = defineMessages({
id: 'label.public',
defaultMessage: 'Public',
},
refreshButton: {
id: 'button.refresh',
defaultMessage: 'Refresh',
},
rejectedLabel: {
id: 'label.rejected',
defaultMessage: 'Rejected',
},
removeButton: {
id: 'button.remove',
defaultMessage: 'Remove',
},
reportButton: {
id: 'button.report',
defaultMessage: 'Report',
},
openFolderButton: {
id: 'button.open-folder',
defaultMessage: 'Open folder',
},
passwordLabel: {
id: 'label.password',
defaultMessage: 'Password',
@@ -125,6 +149,10 @@ export const commonMessages = defineMessages({
id: 'label.scopes',
defaultMessage: 'Scopes',
},
serverLabel: {
id: 'label.server',
defaultMessage: 'Server',
},
serversLabel: {
id: 'label.servers',
defaultMessage: 'Servers',
@@ -141,6 +169,14 @@ export const commonMessages = defineMessages({
id: 'button.sign-out',
defaultMessage: 'Sign out',
},
singleplayerLabel: {
id: 'label.singleplayer',
defaultMessage: 'Singleplayer',
},
stopButton: {
id: 'button.stop',
defaultMessage: 'Stop',
},
titleLabel: {
id: 'label.title',
defaultMessage: 'Title',

View File

@@ -0,0 +1,40 @@
import { BlocksIcon, CompassIcon, EyeIcon, PickaxeIcon, UnknownIcon } from '@modrinth/assets'
import { defineMessage } from '@vintl/vintl'
export const GAME_MODES = {
survival: {
icon: PickaxeIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.survival',
defaultMessage: 'Survival mode',
}),
},
creative: {
icon: BlocksIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.creative',
defaultMessage: 'Creative mode',
}),
},
adventure: {
icon: CompassIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.adventure',
defaultMessage: 'Adventure mode',
}),
},
spectator: {
icon: EyeIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.spectator',
defaultMessage: 'Spectator mode',
}),
},
unknown: {
icon: UnknownIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.unknown',
defaultMessage: 'Unknown game mode',
}),
},
}