diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
new file mode 100644
index 000000000..90bbef67c
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+ {{
+ formatMessage(commonMessages.playedLabel, {
+ time: dayjs(instance.last_played).fromNow(),
+ })
+ }}
+
+ Not played yet
+
+ •
+
+
+
+ {{ modpack.title }}
+
+ ({{ loader }} {{ instance.game_version }})
+
+
+
+ Loading modpack...
+
+
+ {{ loader }}
+ {{ instance.game_version }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View instance
+
+
+
+ {{ formatMessage(commonMessages.openFolderButton) }}
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
new file mode 100644
index 000000000..9ad347fca
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
@@ -0,0 +1,275 @@
+
+
+
+
+
+ Jump back in
+
+
+ Jump back in
+
+
+
+
+ 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)"
+ />
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue
new file mode 100644
index 000000000..cb39b7e2d
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue
@@ -0,0 +1,470 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ world.name }}
+
+
+
+ {{ formatMessage(commonMessages.singleplayerLabel) }}
+
+
+
+
+ Loading...
+
+
+
+
+
+ Incompatible version {{ serverStatus.version?.name }}
+
+
+
+
+
+
+ {{ formatNumber(serverStatus.players?.online, false) }} online
+
+
+
+
+ {{ player.name }}
+
+
+
+
+
+
+
+ Offline
+
+
+
+
+
+
+ {{
+ formatMessage(commonMessages.playedLabel, {
+ time: dayjs(world.last_played).fromNow(),
+ })
+ }}
+
+ Not played yet
+
+
+ •
+
+
+ {{ instanceName }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.loadingLabel) }}
+
+
+
+ {{ formatMessage(messages.cantConnect) }}
+
+
+ {{ formatMessage(messages.aMinecraftServer) }}
+
+
+
+
+
+ {{ formatMessage(messages.hardcore) }}
+
+
+
+ {{ formatMessage(gameMode.message) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.playAnyway) }}
+
+
+
+ {{ formatMessage(messages.viewInstance) }}
+
+
+ {{ formatMessage(commonMessages.editButton) }}
+
+
+
+ {{ formatMessage(commonMessages.openFolderButton) }}
+
+
+ {{ formatMessage(messages.copyAddress) }}
+
+
+ {{ formatMessage(commonMessages.refreshButton) }}
+
+
+
+ {{
+ formatMessage(
+ world.type === 'server'
+ ? commonMessages.removeButton
+ : commonMessages.deleteLabel,
+ )
+ }}
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue
new file mode 100644
index 000000000..00fab96ec
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue
new file mode 100644
index 000000000..b93ca749c
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue
new file mode 100644
index 000000000..2fc39c317
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue
new file mode 100644
index 000000000..64c82b27d
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue
@@ -0,0 +1,86 @@
+
+
+
+
diff --git a/apps/app-frontend/src/helpers/events.js b/apps/app-frontend/src/helpers/events.js
index 0ed288365..81a849b9e 100644
--- a/apps/app-frontend/src/helpers/events.js
+++ b/apps/app-frontend/src/helpers/events.js
@@ -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")
}
diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js
index 569420101..89ebd52ba 100644
--- a/apps/app-frontend/src/helpers/utils.js
+++ b/apps/app-frontend/src/helpers/utils.js
@@ -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)
+}
diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts
new file mode 100644
index 000000000..13615cdf2
--- /dev/null
+++ b/apps/app-frontend/src/helpers/worlds.ts
@@ -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
{
+ return await invoke('plugin:worlds|get_recent_worlds', { limit })
+}
+
+export async function get_profile_worlds(path: string): Promise {
+ return await invoke('plugin:worlds|get_profile_worlds', { path })
+}
+
+export async function get_singleplayer_world(
+ instance: string,
+ world: string,
+): Promise {
+ return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
+}
+
+export async function rename_world(
+ instance: string,
+ world: string,
+ newName: string,
+): Promise {
+ return await invoke('plugin:worlds|rename_world', { instance, world, newName })
+}
+
+export async function reset_world_icon(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|reset_world_icon', { instance, world })
+}
+
+export async function backup_world(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|backup_world', { instance, world })
+}
+
+export async function delete_world(instance: string, world: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
+}
+
+export async function get_profile_protocol_version(path: string): Promise {
+ return await invoke('plugin:worlds|get_profile_protocol_version', { path })
+}
+
+export async function get_server_status(
+ address: string,
+ protocolVersion: number | null = null,
+): Promise {
+ return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
+}
+
+export async function start_join_singleplayer_world(path: string, world: string): Promise {
+ return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
+}
+
+export async function start_join_server(path: string, address: string): Promise {
+ 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 {
+ 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,
+ 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 {
+ 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
+ }
+)
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 515e4e71a..4290c58b8 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -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"
},
diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js
index ba6d3f49b..a37a7018f 100644
--- a/apps/app-frontend/src/main.js
+++ b/apps/app-frontend/src/main.js
@@ -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, {
diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue
index 9d9064eb5..e5c1e0689 100644
--- a/apps/app-frontend/src/pages/Index.vue
+++ b/apps/app-frontend/src/pages/Index.vue
@@ -1,4 +1,4 @@
-
+
+ Worlds
+
diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js
index 6c4866b46..82b0b3ec2 100644
--- a/apps/app-frontend/src/pages/index.js
+++ b/apps/app-frontend/src/pages/index.js
@@ -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 }
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index d2f3d52e0..65bfbf68f 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -1,152 +1,156 @@
- handleRightClick(event, instance.path)"
- >
-
-
-
-
-
-
-
- {{ instance.name }}
-
-
-
-
-
- {{ instance.loader }} {{ instance.game_version }}
-
-
-
-
- {{ timePlayedHumanized }}
-
- Never played
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Share instance
- Create a server
- Open folder
- Export modpack
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Play
- Stop
- Add content
- Edit
- Copy path
- Open folder
- Copy link
- Open in Modrinth
- Copy names
- Copy slugs
- Copy links
- Toggle selected
- Disable selected
- Enable selected
- Show/Hide unselected
- Update {{ selected.length > 0 ? 'selected' : 'all' }}
+ handleRightClick(event, instance.path)"
>
-
Select Updatable
-
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+ {{ instance.loader }} {{ instance.game_version }}
+
+
+
+
+ {{ timePlayedHumanized }}
+
+ Never played
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Share instance
+ Create a server
+ Open folder
+ Export modpack
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stopInstance('InstanceSubpage')"
+ >
+
+
+
+
+
+
+
+
+ Play
+ Stop
+ Add content
+ Edit
+ Copy path
+ Open folder
+ Copy link
+ Open in Modrinth
+ Copy names
+ Copy slugs
+ Copy links
+ Toggle selected
+ Disable selected
+ Enable selected
+ Show/Hide unselected
+ Update {{ selected.length > 0 ? 'selected' : 'all' }}
+ Select Updatable
+
+
diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue
new file mode 100644
index 000000000..22eed8dbb
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/Worlds.vue
@@ -0,0 +1,447 @@
+