refactor: migrate to common eslint+prettier configs (#4168)

* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
This commit is contained in:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

View File

@@ -3,8 +3,7 @@ root = true
[*] [*]
charset = utf-8 charset = utf-8
indent_style = space indent_style = "tab"
indent_size = 4
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
@@ -15,5 +14,5 @@ indent_size = 2
max_line_length = off max_line_length = off
trim_trailing_whitespace = false trim_trailing_whitespace = false
[*.{json,yml,yaml,ts,vue,scss,css,html,js,cjs,mjs,gltf,prettierrc}] [*.{json,yml,yaml}]
indent_size = 2 indent_size = 2

21
.vscode/settings.json vendored
View File

@@ -1,10 +1,15 @@
{ {
"prettier.endOfLine": "lf", "prettier.endOfLine": "lf",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true, "editor.detectIndentation": false,
"editor.codeActionsOnSave": { "editor.insertSpaces": false,
"source.fixAll.eslint": "explicit", "files.eol": "\n",
"source.organizeImports": "always", "files.trimTrailingWhitespace": true,
} "files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
} }

View File

@@ -1,2 +1,3 @@
**/dist **/dist
*.gltf *.gltf
src/locales/

View File

@@ -1,22 +1,2 @@
import { createConfigForNuxt } from '@nuxt/eslint-config/flat' import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
import { fixupPluginRules } from '@eslint/compat' export default config
import turboPlugin from 'eslint-plugin-turbo'
export default createConfigForNuxt().append([
{
name: 'turbo',
plugins: {
turbo: fixupPluginRules(turboPlugin),
},
rules: {
'turbo/no-undeclared-env-vars': 'error',
},
},
{
name: 'modrinth',
rules: {
'vue/html-self-closing': 'off',
'vue/multi-word-component-names': 'off',
},
},
])

View File

@@ -1,17 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en" class="dark-mode"> <html lang="en" class="dark-mode">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Modrinth App</title> <title>Modrinth App</title>
<link rel="stylesheet" href="/src/assets/stylesheets/global.scss" /> <link rel="stylesheet" href="/src/assets/stylesheets/global.scss" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script src="https://tally.so/widgets/embed.js" async></script> <script src="https://tally.so/widgets/embed.js" async></script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,64 +1,63 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-local", "version": "1.0.0-local",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4", "@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0", "@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-http": "^2.5.0", "@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2", "@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0", "@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"ofetch": "^1.3.4", "ofetch": "^1.3.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"posthog-js": "^1.158.2", "posthog-js": "^1.158.2",
"three": "^0.172.0", "three": "^0.172.0",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-multiselect": "3.0.0", "vue-multiselect": "3.0.0",
"vue-router": "4.3.0", "vue-router": "4.3.0",
"vue-virtual-scroller": "v2.0.0-beta.8" "vue-virtual-scroller": "v2.0.0-beta.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.1.1", "@modrinth/tooling-config": "workspace:*",
"@formatjs/cli": "^6.2.12", "@eslint/compat": "^1.1.1",
"@nuxt/eslint-config": "^0.5.6", "@formatjs/cli": "^6.2.12",
"@taijased/vue-render-tracker": "^1.0.7", "@nuxt/eslint-config": "^0.5.6",
"@vitejs/plugin-vue": "^5.0.4", "@taijased/vue-render-tracker": "^1.0.7",
"autoprefixer": "^10.4.19", "@vitejs/plugin-vue": "^5.0.4",
"eslint": "^9.9.1", "autoprefixer": "^10.4.19",
"eslint-config-custom": "workspace:*", "eslint": "^9.9.1",
"eslint-plugin-turbo": "^2.5.4", "eslint-plugin-turbo": "^2.5.4",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.74.1", "sass": "^1.74.1",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"tsconfig": "workspace:*", "typescript": "^5.5.4",
"typescript": "^5.5.4", "vite": "^5.4.6",
"vite": "^5.4.6", "vue-tsc": "^2.1.6"
"vue-tsc": "^2.1.6" },
}, "packageManager": "pnpm@9.4.0",
"packageManager": "pnpm@9.4.0", "web-types": "../../web-types.json"
"web-types": "../../web-types.json"
} }

View File

@@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,18 @@
export { default as ATLauncherIcon } from './atlauncher.svg'
export { default as BuyMeACoffeeIcon } from './bmac.svg' export { default as BuyMeACoffeeIcon } from './bmac.svg'
export { default as DiscordIcon } from './discord.svg' export { default as DiscordIcon } from './discord.svg'
export { default as GDLauncherIcon } from './gdlauncher.png'
export { default as GithubIcon } from './github.svg'
export { default as GitLabIcon } from './gitlab.svg'
export { default as GoogleIcon } from './google.svg'
export { default as KoFiIcon } from './kofi.svg' export { default as KoFiIcon } from './kofi.svg'
export { default as MastodonIcon } from './mastodon.svg'
export { default as MicrosoftIcon } from './microsoft.svg'
export { default as MultiMCIcon } from './multimc.webp'
export { default as OpenCollectiveIcon } from './opencollective.svg'
export { default as PatreonIcon } from './patreon.svg' export { default as PatreonIcon } from './patreon.svg'
export { default as PaypalIcon } from './paypal.svg' export { default as PaypalIcon } from './paypal.svg'
export { default as OpenCollectiveIcon } from './opencollective.svg'
export { default as TwitterIcon } from './twitter.svg'
export { default as GithubIcon } from './github.svg'
export { default as MastodonIcon } from './mastodon.svg'
export { default as RedditIcon } from './reddit.svg'
export { default as GoogleIcon } from './google.svg'
export { default as MicrosoftIcon } from './microsoft.svg'
export { default as SteamIcon } from './steam.svg'
export { default as GitLabIcon } from './gitlab.svg'
export { default as ATLauncherIcon } from './atlauncher.svg'
export { default as GDLauncherIcon } from './gdlauncher.png'
export { default as MultiMCIcon } from './multimc.webp'
export { default as PrismIcon } from './prism.svg' export { default as PrismIcon } from './prism.svg'
export { default as RedditIcon } from './reddit.svg'
export { default as SteamIcon } from './steam.svg'
export { default as TwitterIcon } from './twitter.svg'

View File

@@ -1,9 +1,9 @@
export { default as SwapIcon } from './arrow-left-right.svg'
export { default as ToggleIcon } from './toggle.svg'
export { default as PackageIcon } from './package.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg' export { default as AddProjectImage } from './add-project.svg'
export { default as NewInstanceImage } from './new-instance.svg' export { default as SwapIcon } from './arrow-left-right.svg'
export { default as MenuIcon } from './menu.svg' export { default as MenuIcon } from './menu.svg'
export { default as ChatIcon } from './messages-square.svg' export { default as ChatIcon } from './messages-square.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as NewInstanceImage } from './new-instance.svg'
export { default as PackageIcon } from './package.svg'
export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as ToggleIcon } from './toggle.svg'

View File

@@ -3,158 +3,158 @@
@tailwind utilities; @tailwind utilities;
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
} }
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
} }
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
} }
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
} }
.font-minecraft { .font-minecraft {
font-family: 'bundled-minecraft-font-mrapp', monospace; font-family: 'bundled-minecraft-font-mrapp', monospace;
} }
:root { :root {
font-family: var(--font-standard, sans-serif), sans-serif; font-family: var(--font-standard, sans-serif), sans-serif;
color-scheme: dark; color-scheme: dark;
--view-width: calc(100% - 5rem); --view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem); --expanded-view-width: calc(100% - 13rem);
} }
body { body {
position: fixed; position: fixed;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
.card-divider { .card-divider {
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
border: none; border: none;
color: var(--color-button-bg); color: var(--color-button-bg);
height: 1px; height: 1px;
margin: var(--gap-sm) 0; margin: var(--gap-sm) 0;
} }
.no-wrap { .no-wrap {
white-space: nowrap; white-space: nowrap;
} }
.no-select { .no-select {
-webkit-user-select: none; -webkit-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
a { a {
color: var(--color-link); color: var(--color-link);
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: none; text-decoration: none;
} }
} }
input { input {
border: none !important; border: none !important;
} }
.badge { .badge {
display: flex; display: flex;
border-radius: var(--radius-md); border-radius: var(--radius-md);
white-space: nowrap; white-space: nowrap;
align-items: center; align-items: center;
background-color: var(--color-bg); background-color: var(--color-bg);
padding-block: var(--gap-sm); padding-block: var(--gap-sm);
padding-inline: var(--gap-lg); padding-inline: var(--gap-lg);
width: min-content; width: min-content;
svg { svg {
width: 1.1rem; width: 1.1rem;
height: 1.1rem; height: 1.1rem;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
&.featured { &.featured {
background-color: var(--color-brand-highlight); background-color: var(--color-brand-highlight);
color: var(--color-contrast); color: var(--color-contrast);
} }
} }
* { * {
scrollbar-width: auto; scrollbar-width: auto;
scrollbar-color: var(--color-scrollbar) var(--color-bg); scrollbar-color: var(--color-scrollbar) var(--color-bg);
} }
/* Chrome, Edge, and Safari */ /* Chrome, Edge, and Safari */
*::-webkit-scrollbar { *::-webkit-scrollbar {
width: 16px; width: 16px;
border: 3px solid transparent; border: 3px solid transparent;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
*::-webkit-scrollbar:hover { *::-webkit-scrollbar:hover {
opacity: 1; opacity: 1;
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar); background-color: var(--color-scrollbar);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 5px solid transparent; border: 5px solid transparent;
background-clip: content-box; background-clip: content-box;
} }
.highlighted { .highlighted {
box-shadow: 0 0 1rem var(--color-brand) !important; box-shadow: 0 0 1rem var(--color-brand) !important;
} }
.gecko { .gecko {
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: none !important; box-shadow: none !important;
} }
img { img {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
} }
.card-shadow { .card-shadow {
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
@import '@modrinth/assets/omorphia.scss'; @import '@modrinth/assets/omorphia.scss';

View File

@@ -1,3 +1,3 @@
img { img {
pointer-events: none !important; pointer-events: none !important;
} }

View File

@@ -1,37 +1,38 @@
<script setup> <script setup>
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { duplicate, remove } from '@/helpers/profile.js'
import { import {
ClipboardCopyIcon, ClipboardCopyIcon,
EyeIcon, EyeIcon,
FolderOpenIcon, FolderOpenIcon,
PlayIcon, PlayIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
StopCircleIcon, StopCircleIcon,
TrashIcon, TrashIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui' import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils' import { formatCategoryHeader } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { duplicate, remove } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instances: { instances: {
type: Array, type: Array,
default() { default() {
return [] return []
}, },
}, },
label: { label: {
type: String, type: String,
default: '', default: '',
}, },
}) })
const instanceOptions = ref(null) const instanceOptions = ref(null)
const instanceComponents = ref(null) const instanceComponents = ref(null)
@@ -40,84 +41,84 @@ const currentDeleteInstance = ref(null)
const confirmModal = ref(null) const confirmModal = ref(null)
async function deleteProfile() { async function deleteProfile() {
if (currentDeleteInstance.value) { if (currentDeleteInstance.value) {
instanceComponents.value = instanceComponents.value.filter( instanceComponents.value = instanceComponents.value.filter(
(x) => x.instance.path !== currentDeleteInstance.value, (x) => x.instance.path !== currentDeleteInstance.value,
) )
await remove(currentDeleteInstance.value).catch(handleError) await remove(currentDeleteInstance.value).catch(handleError)
} }
} }
async function duplicateProfile(p) { async function duplicateProfile(p) {
await duplicate(p).catch(handleError) await duplicate(p).catch(handleError)
} }
const handleRightClick = (event, profilePathId) => { const handleRightClick = (event, profilePathId) => {
const item = instanceComponents.value.find((x) => x.instance.path === profilePathId) const item = instanceComponents.value.find((x) => x.instance.path === profilePathId)
const baseOptions = [ const baseOptions = [
{ name: 'add_content' }, { name: 'add_content' },
{ type: 'divider' }, { type: 'divider' },
{ name: 'edit' }, { name: 'edit' },
{ name: 'duplicate' }, { name: 'duplicate' },
{ name: 'open' }, { name: 'open' },
{ name: 'copy' }, { name: 'copy' },
{ type: 'divider' }, { type: 'divider' },
{ {
name: 'delete', name: 'delete',
color: 'danger', color: 'danger',
}, },
] ]
instanceOptions.value.showMenu( instanceOptions.value.showMenu(
event, event,
item, item,
item.playing item.playing
? [ ? [
{ {
name: 'stop', name: 'stop',
color: 'danger', color: 'danger',
}, },
...baseOptions, ...baseOptions,
] ]
: [ : [
{ {
name: 'play', name: 'play',
color: 'primary', color: 'primary',
}, },
...baseOptions, ...baseOptions,
], ],
) )
} }
const handleOptionsClick = async (args) => { const handleOptionsClick = async (args) => {
switch (args.option) { switch (args.option) {
case 'play': case 'play':
args.item.play(null, 'InstanceGridContextMenu') args.item.play(null, 'InstanceGridContextMenu')
break break
case 'stop': case 'stop':
args.item.stop(null, 'InstanceGridContextMenu') args.item.stop(null, 'InstanceGridContextMenu')
break break
case 'add_content': case 'add_content':
await args.item.addContent() await args.item.addContent()
break break
case 'edit': case 'edit':
await args.item.seeInstance() await args.item.seeInstance()
break break
case 'duplicate': case 'duplicate':
if (args.item.instance.install_stage == 'installed') if (args.item.instance.install_stage == 'installed')
await duplicateProfile(args.item.instance.path) await duplicateProfile(args.item.instance.path)
break break
case 'open': case 'open':
await args.item.openFolder() await args.item.openFolder()
break break
case 'copy': case 'copy':
await navigator.clipboard.writeText(args.item.instance.path) await navigator.clipboard.writeText(args.item.instance.path)
break break
case 'delete': case 'delete':
currentDeleteInstance.value = args.item.instance.path currentDeleteInstance.value = args.item.instance.path
confirmModal.value.show() confirmModal.value.show()
break break
} }
} }
const search = ref('') const search = ref('')
@@ -125,261 +126,261 @@ const group = ref('Group')
const sortBy = ref('Name') const sortBy = ref('Name')
const filteredResults = computed(() => { const filteredResults = computed(() => {
const instances = props.instances.filter((instance) => { const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase()) return instance.name.toLowerCase().includes(search.value.toLowerCase())
}) })
if (sortBy.value === 'Name') { if (sortBy.value === 'Name') {
instances.sort((a, b) => { instances.sort((a, b) => {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
} }
if (sortBy.value === 'Game version') { if (sortBy.value === 'Game version') {
instances.sort((a, b) => { instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true }) return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
}) })
} }
if (sortBy.value === 'Last played') { if (sortBy.value === 'Last played') {
instances.sort((a, b) => { instances.sort((a, b) => {
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)) return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
}) })
} }
if (sortBy.value === 'Date created') { if (sortBy.value === 'Date created') {
instances.sort((a, b) => { instances.sort((a, b) => {
return dayjs(b.date_created).diff(dayjs(a.date_created)) return dayjs(b.date_created).diff(dayjs(a.date_created))
}) })
} }
if (sortBy.value === 'Date modified') { if (sortBy.value === 'Date modified') {
instances.sort((a, b) => { instances.sort((a, b) => {
return dayjs(b.date_modified).diff(dayjs(a.date_modified)) return dayjs(b.date_modified).diff(dayjs(a.date_modified))
}) })
} }
const instanceMap = new Map() const instanceMap = new Map()
if (group.value === 'Loader') { if (group.value === 'Loader') {
instances.forEach((instance) => { instances.forEach((instance) => {
const loader = formatCategoryHeader(instance.loader) const loader = formatCategoryHeader(instance.loader)
if (!instanceMap.has(loader)) { if (!instanceMap.has(loader)) {
instanceMap.set(loader, []) instanceMap.set(loader, [])
} }
instanceMap.get(loader).push(instance) instanceMap.get(loader).push(instance)
}) })
} else if (group.value === 'Game version') { } else if (group.value === 'Game version') {
instances.forEach((instance) => { instances.forEach((instance) => {
if (!instanceMap.has(instance.game_version)) { if (!instanceMap.has(instance.game_version)) {
instanceMap.set(instance.game_version, []) instanceMap.set(instance.game_version, [])
} }
instanceMap.get(instance.game_version).push(instance) instanceMap.get(instance.game_version).push(instance)
}) })
} else if (group.value === 'Group') { } else if (group.value === 'Group') {
instances.forEach((instance) => { instances.forEach((instance) => {
if (instance.groups.length === 0) { if (instance.groups.length === 0) {
instance.groups.push('None') instance.groups.push('None')
} }
for (const category of instance.groups) { for (const category of instance.groups) {
if (!instanceMap.has(category)) { if (!instanceMap.has(category)) {
instanceMap.set(category, []) instanceMap.set(category, [])
} }
instanceMap.get(category).push(instance) instanceMap.get(category).push(instance)
} }
}) })
} else { } else {
return instanceMap.set('None', instances) return instanceMap.set('None', instances)
} }
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance // For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A // ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy.value === 'Name') { if (sortBy.value === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => { const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first // None should always be first
if (a[0] === 'None' && b[0] !== 'None') { if (a[0] === 'None' && b[0] !== 'None') {
return -1 return -1
} }
if (a[0] !== 'None' && b[0] === 'None') { if (a[0] !== 'None' && b[0] === 'None') {
return 1 return 1
} }
return a[0].localeCompare(b[0]) return a[0].localeCompare(b[0])
}) })
instanceMap.clear() instanceMap.clear()
sortedEntries.forEach((entry) => { sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1]) instanceMap.set(entry[0], entry[1])
}) })
} }
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8 // default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20 // localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group.value === 'Game version') { if (group.value === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => { const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true }) return a[0].localeCompare(b[0], undefined, { numeric: true })
}) })
instanceMap.clear() instanceMap.clear()
sortedEntries.forEach((entry) => { sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1]) instanceMap.set(entry[0], entry[1])
}) })
} }
return instanceMap return instanceMap
}) })
</script> </script>
<template> <template>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="iconified-input flex-1"> <div class="iconified-input flex-1">
<SearchIcon /> <SearchIcon />
<input v-model="search" type="text" placeholder="Search" /> <input v-model="search" type="text" placeholder="Search" />
<Button class="r-btn" @click="() => (search = '')"> <Button class="r-btn" @click="() => (search = '')">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
<DropdownSelect <DropdownSelect
v-slot="{ selected }" v-slot="{ selected }"
v-model="sortBy" v-model="sortBy"
name="Sort Dropdown" name="Sort Dropdown"
class="max-w-[16rem]" class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']" :options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..." placeholder="Select..."
> >
<span class="font-semibold text-primary">Sort by: </span> <span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span> <span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect> </DropdownSelect>
<DropdownSelect <DropdownSelect
v-slot="{ selected }" v-slot="{ selected }"
v-model="group" v-model="group"
class="max-w-[16rem]" class="max-w-[16rem]"
name="Group Dropdown" name="Group Dropdown"
:options="['Group', 'Loader', 'Game version', 'None']" :options="['Group', 'Loader', 'Game version', 'None']"
placeholder="Select..." placeholder="Select..."
> >
<span class="font-semibold text-primary">Group by: </span> <span class="font-semibold text-primary">Group by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span> <span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect> </DropdownSelect>
</div> </div>
<div <div
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({ v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key, key,
value, value,
}))" }))"
:key="instanceSection.key" :key="instanceSection.key"
class="row" class="row"
> >
<div v-if="instanceSection.key !== 'None'" class="divider"> <div v-if="instanceSection.key !== 'None'" class="divider">
<p>{{ instanceSection.key }}</p> <p>{{ instanceSection.key }}</p>
<hr aria-hidden="true" /> <hr aria-hidden="true" />
</div> </div>
<section class="instances"> <section class="instances">
<Instance <Instance
v-for="instance in instanceSection.value" v-for="instance in instanceSection.value"
ref="instanceComponents" ref="instanceComponents"
:key="instance.path + instance.install_stage" :key="instance.path + instance.install_stage"
:instance="instance" :instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)" @contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/> />
</section> </section>
</div> </div>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="confirmModal" ref="confirmModal"
title="Are you sure you want to delete this instance?" title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it." description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false" :has-to-type="false"
proceed-label="Delete" proceed-label="Delete"
@proceed="deleteProfile" @proceed="deleteProfile"
/> />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick"> <ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template> <template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template> <template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template> <template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template> <template #edit> <EyeIcon /> View instance </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template> <template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #delete> <TrashIcon /> Delete </template> <template #delete> <TrashIcon /> Delete </template>
<template #open> <FolderOpenIcon /> Open folder </template> <template #open> <FolderOpenIcon /> Open folder </template>
<template #copy> <ClipboardCopyIcon /> Copy path </template> <template #copy> <ClipboardCopyIcon /> Copy path </template>
</ContextMenu> </ContextMenu>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.row { .row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
.divider { .divider {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
p { p {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
white-space: nowrap; white-space: nowrap;
color: var(--color-contrast); color: var(--color-contrast);
} }
hr { hr {
background-color: var(--color-gray); background-color: var(--color-gray);
height: 1px; height: 1px;
width: 100%; width: 100%;
border: none; border: none;
} }
} }
} }
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
align-items: inherit; align-items: inherit;
margin: 1rem 1rem 0 !important; margin: 1rem 1rem 0 !important;
padding: 1rem; padding: 1rem;
width: calc(100% - 2rem); width: calc(100% - 2rem);
.iconified-input { .iconified-input {
flex-grow: 1; flex-grow: 1;
input { input {
min-width: 100%; min-width: 100%;
} }
} }
.sort-dropdown { .sort-dropdown {
width: 10rem; width: 10rem;
} }
.filter-dropdown { .filter-dropdown {
width: 15rem; width: 15rem;
} }
.group-dropdown { .group-dropdown {
width: 10rem; width: 10rem;
} }
.labeled_button { .labeled_button {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
white-space: nowrap; white-space: nowrap;
} }
} }
.instances { .instances {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
width: 100%; width: 100%;
gap: 0.75rem; gap: 0.75rem;
margin-right: auto; margin-right: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
overflow-y: auto; overflow-y: auto;
} }
</style> </style>

View File

@@ -1,29 +1,30 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js' import { useLoading } from '@/store/state.js'
const props = defineProps({ const props = defineProps({
throttle: { throttle: {
type: Number, type: Number,
default: 0, default: 0,
}, },
duration: { duration: {
type: Number, type: Number,
default: 1000, default: 1000,
}, },
height: { height: {
type: Number, type: Number,
default: 2, default: 2,
}, },
color: { color: {
type: String, type: String,
default: 'var(--loading-bar-gradient)', default: 'var(--loading-bar-gradient)',
}, },
}) })
const indicator = useLoadingIndicator({ const indicator = useLoadingIndicator({
duration: props.duration, duration: props.duration,
throttle: props.throttle, throttle: props.throttle,
}) })
onBeforeUnmount(() => indicator.clear) onBeforeUnmount(() => indicator.clear)
@@ -31,111 +32,111 @@ onBeforeUnmount(() => indicator.clear)
const loading = useLoading() const loading = useLoading()
watch(loading, (newValue) => { watch(loading, (newValue) => {
if (newValue.barEnabled) { if (newValue.barEnabled) {
if (newValue.loading) { if (newValue.loading) {
indicator.start() indicator.start()
} else { } else {
indicator.finish() indicator.finish()
} }
} }
}) })
function useLoadingIndicator(opts) { function useLoadingIndicator(opts) {
const progress = ref(0) const progress = ref(0)
const isLoading = ref(false) const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration) const step = computed(() => 10000 / opts.duration)
let _timer = null let _timer = null
let _throttle = null let _throttle = null
function start() { function start() {
clear() clear()
progress.value = 0 progress.value = 0
if (opts.throttle) { if (opts.throttle) {
_throttle = setTimeout(() => { _throttle = setTimeout(() => {
isLoading.value = true isLoading.value = true
_startTimer() _startTimer()
}, opts.throttle) }, opts.throttle)
} else { } else {
isLoading.value = true isLoading.value = true
_startTimer() _startTimer()
} }
} }
function finish() { function finish() {
progress.value = 100 progress.value = 100
_hide() _hide()
} }
function clear() { function clear() {
clearInterval(_timer) clearInterval(_timer)
clearTimeout(_throttle) clearTimeout(_throttle)
_timer = null _timer = null
_throttle = null _throttle = null
} }
function _increase(num) { function _increase(num) {
progress.value = Math.min(100, progress.value + num) progress.value = Math.min(100, progress.value + num)
} }
function _hide() { function _hide() {
clear() clear()
setTimeout(() => { setTimeout(() => {
isLoading.value = false isLoading.value = false
setTimeout(() => { setTimeout(() => {
progress.value = 0 progress.value = 0
}, 400) }, 400)
}, 500) }, 500)
} }
function _startTimer() { function _startTimer() {
_timer = setInterval(() => { _timer = setInterval(() => {
_increase(step.value) _increase(step.value)
}, 100) }, 100)
} }
return { progress, isLoading, start, finish, clear } return { progress, isLoading, start, finish, clear }
} }
</script> </script>
<template> <template>
<div <div
class="loading-indicator-bar" class="loading-indicator-bar"
:style="{ :style="{
'--_width': `${indicator.progress.value}%`, '--_width': `${indicator.progress.value}%`,
'--_height': `${indicator.isLoading.value ? props.height : 0}px`, '--_height': `${indicator.isLoading.value ? props.height : 0}px`,
'--_opacity': `${indicator.isLoading.value ? 1 : 0}`, '--_opacity': `${indicator.isLoading.value ? 1 : 0}`,
top: `0`, top: `0`,
right: `0`, right: `0`,
left: `${props.offsetWidth}`, left: `${props.offsetWidth}`,
pointerEvents: 'none', pointerEvents: 'none',
width: `var(--_width)`, width: `var(--_width)`,
height: `var(--_height)`, height: `var(--_height)`,
borderRadius: `var(--_height)`, borderRadius: `var(--_height)`,
// opacity: `var(--_opacity)`, // opacity: `var(--_opacity)`,
background: `${props.color}`, background: `${props.color}`,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`, backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s ease-in-out, height 0.1s ease-out', transition: 'width 0.1s ease-in-out, height 0.1s ease-out',
zIndex: 6, zIndex: 6,
}" }"
> >
<slot /> <slot />
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.loading-indicator-bar::before { .loading-indicator-bar::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
width: var(--_width); width: var(--_width);
bottom: 0; bottom: 0;
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%); background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
opacity: calc(var(--_opacity) * 0.1); opacity: calc(var(--_opacity) * 0.1);
z-index: 5; z-index: 5;
transition: transition:
width 0.1s ease-in-out, width 0.1s ease-in-out,
opacity 0.1s ease-out; opacity 0.1s ease-out;
} }
</style> </style>

View File

@@ -1,4 +1,21 @@
<script setup> <script setup>
import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue' import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
@@ -9,45 +26,29 @@ import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js' import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js' import { install as installVersion } from '@/store/install.js'
import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
instances: { instances: {
type: Array, type: Array,
default() { default() {
return [] return []
}, },
}, },
label: { label: {
type: String, type: String,
default: '', default: '',
}, },
canPaginate: Boolean, canPaginate: Boolean,
}) })
const actualInstances = computed(() => const actualInstances = computed(() =>
props.instances.filter( props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show, (x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
), ),
) )
const modsRow = ref(null) const modsRow = ref(null)
@@ -59,124 +60,124 @@ const deleteConfirmModal = ref(null)
const currentDeleteInstance = ref(null) const currentDeleteInstance = ref(null)
async function deleteProfile() { async function deleteProfile() {
if (currentDeleteInstance.value) { if (currentDeleteInstance.value) {
await remove(currentDeleteInstance.value).catch(handleError) await remove(currentDeleteInstance.value).catch(handleError)
} }
} }
async function duplicateProfile(p) { async function duplicateProfile(p) {
await duplicate(p).catch(handleError) await duplicate(p).catch(handleError)
} }
const handleInstanceRightClick = async (event, passedInstance) => { const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [ const baseOptions = [
{ name: 'add_content' }, { name: 'add_content' },
{ type: 'divider' }, { type: 'divider' },
{ name: 'edit' }, { name: 'edit' },
{ name: 'duplicate' }, { name: 'duplicate' },
{ name: 'open_folder' }, { name: 'open_folder' },
{ name: 'copy_path' }, { name: 'copy_path' },
{ type: 'divider' }, { type: 'divider' },
{ {
name: 'delete', name: 'delete',
color: 'danger', color: 'danger',
}, },
] ]
const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError) const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError)
const options = const options =
runningProcesses.length > 0 runningProcesses.length > 0
? [ ? [
{ {
name: 'stop', name: 'stop',
color: 'danger', color: 'danger',
}, },
...baseOptions, ...baseOptions,
] ]
: [ : [
{ {
name: 'play', name: 'play',
color: 'primary', color: 'primary',
}, },
...baseOptions, ...baseOptions,
] ]
instanceOptions.value.showMenu(event, passedInstance, options) instanceOptions.value.showMenu(event, passedInstance, options)
} }
const handleProjectClick = (event, passedInstance) => { const handleProjectClick = (event, passedInstance) => {
instanceOptions.value.showMenu(event, passedInstance, [ instanceOptions.value.showMenu(event, passedInstance, [
{ {
name: 'install', name: 'install',
color: 'primary', color: 'primary',
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
name: 'open_link', name: 'open_link',
}, },
{ {
name: 'copy_link', name: 'copy_link',
}, },
]) ])
} }
const handleOptionsClick = async (args) => { const handleOptionsClick = async (args) => {
switch (args.option) { switch (args.option) {
case 'play': case 'play':
await run(args.item.path).catch((err) => await run(args.item.path).catch((err) =>
handleSevereError(err, { profilePath: args.item.path }), handleSevereError(err, { profilePath: args.item.path }),
) )
trackEvent('InstanceStart', { trackEvent('InstanceStart', {
loader: args.item.loader, loader: args.item.loader,
game_version: args.item.game_version, game_version: args.item.game_version,
}) })
break break
case 'stop': case 'stop':
await kill(args.item.path).catch(handleError) await kill(args.item.path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
loader: args.item.loader, loader: args.item.loader,
game_version: args.item.game_version, game_version: args.item.game_version,
}) })
break break
case 'add_content': case 'add_content':
await router.push({ await router.push({
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`, path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: args.item.path }, query: { i: args.item.path },
}) })
break break
case 'edit': case 'edit':
await router.push({ await router.push({
path: `/instance/${encodeURIComponent(args.item.path)}/`, path: `/instance/${encodeURIComponent(args.item.path)}/`,
}) })
break break
case 'duplicate': case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path) if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path)
break break
case 'delete': case 'delete':
currentDeleteInstance.value = args.item.path currentDeleteInstance.value = args.item.path
deleteConfirmModal.value.show() deleteConfirmModal.value.show()
break break
case 'open_folder': case 'open_folder':
await showProfileInFolder(args.item.path) await showProfileInFolder(args.item.path)
break break
case 'copy_path': case 'copy_path':
await navigator.clipboard.writeText(args.item.path) await navigator.clipboard.writeText(args.item.path)
break break
case 'install': { case 'install': {
await installVersion(args.item.project_id, null, null, 'ProjectCardContextMenu') await installVersion(args.item.project_id, null, null, 'ProjectCardContextMenu')
break break
} }
case 'open_link': case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`) openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break break
case 'copy_link': case 'copy_link':
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`, `https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
) )
break break
} }
} }
const maxInstancesPerCompactRow = ref(1) const maxInstancesPerCompactRow = ref(1)
@@ -184,184 +185,184 @@ const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1) const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => { const calculateCardsPerRow = () => {
if (rows.value.length === 0) { if (rows.value.length === 0) {
return return
} }
// Calculate how many cards fit in one row // Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem // Convert container width from pixels to rem
const containerWidthInRem = const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize) containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75) maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75) maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75) maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) { if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2 maxInstancesPerRow.value *= 2
} }
if (maxInstancesPerCompactRow.value < 5) { if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2 maxInstancesPerCompactRow.value *= 2
} }
if (maxProjectsPerRow.value < 3) { if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2 maxProjectsPerRow.value *= 2
} }
} }
const rowContainer = ref(null) const rowContainer = ref(null)
const resizeObserver = ref(null) const resizeObserver = ref(null)
onMounted(() => { onMounted(() => {
calculateCardsPerRow() calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow) resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) { if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value) resizeObserver.value.observe(rowContainer.value)
} }
window.addEventListener('resize', calculateCardsPerRow) window.addEventListener('resize', calculateCardsPerRow)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow) window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) { if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value) resizeObserver.value.unobserve(rowContainer.value)
} }
}) })
</script> </script>
<template> <template>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="deleteConfirmModal" ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?" title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it." description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false" :has-to-type="false"
proceed-label="Delete" proceed-label="Delete"
@proceed="deleteProfile" @proceed="deleteProfile"
/> />
<div ref="rowContainer" class="flex flex-col gap-4"> <div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row"> <div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route"> <HeadingLink class="mt-1" :to="row.route">
{{ row.label }} {{ row.label }}
</HeadingLink> </HeadingLink>
<section <section
v-if="row.instance" v-if="row.instance"
ref="modsRow" ref="modsRow"
class="instances" class="instances"
:class="{ compact: row.compact }" :class="{ compact: row.compact }"
> >
<Instance <Instance
v-for="(instance, instanceIndex) in row.instances.slice( v-for="(instance, instanceIndex) in row.instances.slice(
0, 0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow, row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)" )"
:key="row.label + instance.path" :key="row.label + instance.path"
:instance="instance" :instance="instance"
:compact="row.compact" :compact="row.compact"
:first="instanceIndex === 0" :first="instanceIndex === 0"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)" @contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/> />
</section> </section>
<section v-else ref="modsRow" class="projects"> <section v-else ref="modsRow" class="projects">
<ProjectCard <ProjectCard
v-for="project in row.instances.slice(0, maxProjectsPerRow)" v-for="project in row.instances.slice(0, maxProjectsPerRow)"
:key="project?.project_id" :key="project?.project_id"
ref="instanceComponents" ref="instanceComponents"
class="item" class="item"
:project="project" :project="project"
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)" @contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
/> />
</section> </section>
</div> </div>
</div> </div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick"> <ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template> <template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template> <template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template> <template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template> <template #edit> <EyeIcon /> View instance </template>
<template #delete> <TrashIcon /> Delete </template> <template #delete> <TrashIcon /> Delete </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template> <template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template> <template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template> <template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #install> <DownloadIcon /> Install </template> <template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template> <template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template> <template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu> </ContextMenu>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
gap: 1rem; gap: 1rem;
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0; width: 0;
background: transparent; background: transparent;
} }
} }
.row { .row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
&:nth-child(even) { &:nth-child(even) {
background: var(--color-bg); background: var(--color-bg);
} }
.header { .header {
width: 100%; width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
gap: var(--gap-xs); gap: var(--gap-xs);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
a { a {
margin: 0; margin: 0;
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: bolder; font-weight: bolder;
white-space: nowrap; white-space: nowrap;
color: var(--color-base); color: var(--color-base);
} }
svg { svg {
height: 1.25rem; height: 1.25rem;
width: 1.25rem; width: 1.25rem;
color: var(--color-base); color: var(--color-base);
} }
} }
.instances { .instances {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-gap: 0.75rem; grid-gap: 0.75rem;
width: 100%; width: 100%;
&.compact { &.compact {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
} }
.projects { .projects {
display: grid; display: grid;
width: 100%; width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 0.75rem; grid-gap: 0.75rem;
.item { .item {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
} }
} }
} }
</style> </style>

View File

@@ -1,102 +1,103 @@
<template> <template>
<div <div
v-if="mode !== 'isolated'" v-if="mode !== 'isolated'"
ref="button" ref="button"
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2" class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
:class="{ expanded: mode === 'expanded' }" :class="{ expanded: mode === 'expanded' }"
@click="toggleMenu" @click="toggleMenu"
> >
<Avatar <Avatar
size="36px" size="36px"
:src=" :src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png' selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
" "
/> />
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span> <span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span> <span class="text-secondary text-xs">Minecraft account</span>
</div> </div>
<DropdownIcon class="w-5 h-5 shrink-0" /> <DropdownIcon class="w-5 h-5 shrink-0" />
</div> </div>
<transition name="fade"> <transition name="fade">
<Card <Card
v-if="showCard || mode === 'isolated'" v-if="showCard || mode === 'isolated'"
ref="card" ref="card"
class="account-card" class="account-card"
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }" :class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
> >
<div v-if="selectedAccount" class="selected account"> <div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="avatarUrl" /> <Avatar size="xs" :src="avatarUrl" />
<div> <div>
<h4>{{ selectedAccount.profile.name }}</h4> <h4>{{ selectedAccount.profile.name }}</h4>
<p>Selected</p> <p>Selected</p>
</div> </div>
<Button <Button
v-tooltip="'Log out'" v-tooltip="'Log out'"
icon-only icon-only
color="raised" color="raised"
@click="logout(selectedAccount.profile.id)" @click="logout(selectedAccount.profile.id)"
> >
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
<div v-else class="logged-out account"> <div v-else class="logged-out account">
<h4>Not signed in</h4> <h4>Not signed in</h4>
<Button <Button
v-tooltip="'Log in'" v-tooltip="'Log in'"
:disabled="loginDisabled" :disabled="loginDisabled"
icon-only icon-only
color="primary" color="primary"
@click="login()" @click="login()"
> >
<LogInIcon v-if="!loginDisabled" /> <LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" /> <SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
</div> </div>
<div v-if="displayAccounts.length > 0" class="account-group"> <div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row"> <div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<Button class="option account" @click="setAccount(account)"> <Button class="option account" @click="setAccount(account)">
<Avatar :src="getAccountAvatarUrl(account)" class="icon" /> <Avatar :src="getAccountAvatarUrl(account)" class="icon" />
<p>{{ account.profile.name }}</p> <p>{{ account.profile.name }}</p>
</Button> </Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)"> <Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
</div> </div>
<Button v-if="accounts.length > 0" @click="login()"> <Button v-if="accounts.length > 0" @click="login()">
<PlusIcon /> <PlusIcon />
Add account Add account
</Button> </Button>
</Card> </Card>
</transition> </transition>
</template> </template>
<script setup> <script setup>
import { DropdownIcon, LogInIcon, PlusIcon, SpinnerIcon, TrashIcon } from '@modrinth/assets'
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { import {
get_default_user, get_default_user,
login as login_flow, login as login_flow,
remove_user, remove_user,
set_default_user, set_default_user,
users, users,
} from '@/helpers/auth' } from '@/helpers/auth'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts' import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import { get_available_skins } from '@/helpers/skins' import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { DropdownIcon, LogInIcon, PlusIcon, SpinnerIcon, TrashIcon } from '@modrinth/assets'
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
defineProps({ defineProps({
mode: { mode: {
type: String, type: String,
required: true, required: true,
default: 'normal', default: 'normal',
}, },
}) })
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
@@ -108,370 +109,370 @@ const equippedSkin = ref(null)
const headUrlCache = ref(new Map()) const headUrlCache = ref(new Map())
async function refreshValues() { async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError) defaultUser.value = await get_default_user().catch(handleError)
accounts.value = await users().catch(handleError) accounts.value = await users().catch(handleError)
try { try {
const skins = await get_available_skins() const skins = await get_available_skins()
equippedSkin.value = skins.find((skin) => skin.is_equipped) equippedSkin.value = skins.find((skin) => skin.is_equipped)
if (equippedSkin.value) { if (equippedSkin.value) {
try { try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value) const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl) headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
} catch (error) { } catch (error) {
console.warn('Failed to get head render for equipped skin:', error) console.warn('Failed to get head render for equipped skin:', error)
} }
} }
} catch { } catch {
equippedSkin.value = null equippedSkin.value = null
} }
} }
function setLoginDisabled(value) { function setLoginDisabled(value) {
loginDisabled.value = value loginDisabled.value = value
} }
defineExpose({ defineExpose({
refreshValues, refreshValues,
setLoginDisabled, setLoginDisabled,
loginDisabled, loginDisabled,
}) })
await refreshValues() await refreshValues()
const displayAccounts = computed(() => const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.profile.id), accounts.value.filter((account) => defaultUser.value !== account.profile.id),
) )
const avatarUrl = computed(() => { const avatarUrl = computed(() => {
if (equippedSkin.value?.texture_key) { if (equippedSkin.value?.texture_key) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key) const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) { if (cachedUrl) {
return cachedUrl return cachedUrl
} }
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128` return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
} }
if (selectedAccount.value?.profile?.id) { if (selectedAccount.value?.profile?.id) {
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128` return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
} }
return 'https://launcher-files.modrinth.com/assets/steve_head.png' return 'https://launcher-files.modrinth.com/assets/steve_head.png'
}) })
function getAccountAvatarUrl(account) { function getAccountAvatarUrl(account) {
if ( if (
account.profile.id === selectedAccount.value?.profile?.id && account.profile.id === selectedAccount.value?.profile?.id &&
equippedSkin.value?.texture_key equippedSkin.value?.texture_key
) { ) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key) const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) { if (cachedUrl) {
return cachedUrl return cachedUrl
} }
} }
return `https://mc-heads.net/avatar/${account.profile.id}/128` return `https://mc-heads.net/avatar/${account.profile.id}/128`
} }
const selectedAccount = computed(() => const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value), accounts.value.find((account) => account.profile.id === defaultUser.value),
) )
async function setAccount(account) { async function setAccount(account) {
defaultUser.value = account.profile.id defaultUser.value = account.profile.id
await set_default_user(account.profile.id).catch(handleError) await set_default_user(account.profile.id).catch(handleError)
emit('change') emit('change')
} }
async function login() { async function login() {
loginDisabled.value = true loginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError) const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) { if (loggedIn) {
await setAccount(loggedIn) await setAccount(loggedIn)
await refreshValues() await refreshValues()
} }
trackEvent('AccountLogIn') trackEvent('AccountLogIn')
loginDisabled.value = false loginDisabled.value = false
} }
const logout = async (id) => { const logout = async (id) => {
await remove_user(id).catch(handleError) await remove_user(id).catch(handleError)
await refreshValues() await refreshValues()
if (!selectedAccount.value && accounts.value.length > 0) { if (!selectedAccount.value && accounts.value.length > 0) {
await setAccount(accounts.value[0]) await setAccount(accounts.value[0])
await refreshValues() await refreshValues()
} else { } else {
emit('change') emit('change')
} }
trackEvent('AccountLogOut') trackEvent('AccountLogOut')
} }
const showCard = ref(false) const showCard = ref(false)
const card = ref(null) const card = ref(null)
const button = ref(null) const button = ref(null)
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY) const elements = document.elementsFromPoint(event.clientX, event.clientY)
if ( if (
card.value && card.value &&
card.value.$el !== event.target && card.value.$el !== event.target &&
!elements.includes(card.value.$el) && !elements.includes(card.value.$el) &&
!button.value.contains(event.target) !button.value.contains(event.target)
) { ) {
toggleMenu(false) toggleMenu(false)
} }
} }
function toggleMenu(override = true) { function toggleMenu(override = true) {
if (showCard.value || !override) { if (showCard.value || !override) {
showCard.value = false showCard.value = false
} else { } else {
showCard.value = true showCard.value = true
} }
} }
const unlisten = await process_listener(async (e) => { const unlisten = await process_listener(async (e) => {
if (e.event === 'launched') { if (e.event === 'launched') {
await refreshValues() await refreshValues()
} }
}) })
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleClickOutside) window.addEventListener('click', handleClickOutside)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside) window.removeEventListener('click', handleClickOutside)
}) })
onUnmounted(() => { onUnmounted(() => {
unlisten() unlisten()
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.selected { .selected {
background: var(--color-brand-highlight); background: var(--color-brand-highlight);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
color: var(--color-contrast); color: var(--color-contrast);
gap: 1rem; gap: 1rem;
} }
.logged-out { .logged-out {
background: var(--color-bg); background: var(--color-bg);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
gap: 1rem; gap: 1rem;
} }
.account { .account {
width: max-content; width: max-content;
display: flex; display: flex;
align-items: center; align-items: center;
text-align: left; text-align: left;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
h4, h4,
p { p {
margin: 0; margin: 0;
} }
} }
.account-card { .account-card {
position: fixed; position: fixed;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: 0.5rem; margin-top: 0.5rem;
right: 2rem; right: 2rem;
z-index: 11; z-index: 11;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
width: max-content; width: max-content;
user-select: none; user-select: none;
-ms-user-select: none; -ms-user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
max-height: 98vh; max-height: 98vh;
overflow-y: auto; overflow-y: auto;
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
border-top-right-radius: 1rem; border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem; border-bottom-right-radius: 1rem;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
border-top-right-radius: 1rem; border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem; border-bottom-right-radius: 1rem;
} }
&.hidden { &.hidden {
display: none; display: none;
} }
&.expanded { &.expanded {
left: 13.5rem; left: 13.5rem;
} }
&.isolated { &.isolated {
position: relative; position: relative;
left: 0; left: 0;
top: 0; top: 0;
} }
} }
.accounts-title { .accounts-title {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: bolder; font-weight: bolder;
} }
.account-group { .account-group {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.option { .option {
width: calc(100% - 2.25rem); width: calc(100% - 2.25rem);
background: var(--color-raised-bg); background: var(--color-raised-bg);
color: var(--color-base); color: var(--color-base);
box-shadow: none; box-shadow: none;
img { img {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
} }
.icon { .icon {
--size: 1.5rem !important; --size: 1.5rem !important;
} }
.account-row { .account-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 0.5rem; gap: 0.5rem;
vertical-align: center; vertical-align: center;
justify-content: space-between; justify-content: space-between;
padding-right: 1rem; padding-right: 1rem;
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: transition:
opacity 0.25s ease, opacity 0.25s ease,
translate 0.25s ease, translate 0.25s ease,
scale 0.25s ease; scale 0.25s ease;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
translate: 0 -2rem; translate: 0 -2rem;
scale: 0.9; scale: 0.9;
} }
.avatar-button { .avatar-button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--color-base); color: var(--color-base);
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
border-radius: var(--radius-md); border-radius: var(--radius-md);
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: left;
&.expanded { &.expanded {
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
padding: 1rem; padding: 1rem;
} }
} }
.avatar-text { .avatar-text {
margin: auto 0 auto 0.25rem; margin: auto 0 auto 0.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.text { .text {
width: 6rem; width: 6rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.accounts-text { .accounts-text {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
margin: 0; margin: 0;
} }
.qr-code { .qr-code {
background-color: white !important; background-color: white !important;
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--gap-lg); gap: var(--gap-lg);
align-items: center; align-items: center;
padding: var(--gap-xl); padding: var(--gap-xl);
.modal-text { .modal-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-sm); gap: var(--gap-sm);
width: 100%; width: 100%;
h2, h2,
p { p {
margin: 0; margin: 0;
} }
.code-text { .code-text {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--gap-xs); gap: var(--gap-xs);
align-items: center; align-items: center;
.code { .code {
background-color: var(--color-bg); background-color: var(--color-bg);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: solid 1px var(--color-button-bg); border: solid 1px var(--color-button-bg);
font-family: var(--mono-font); font-family: var(--mono-font);
letter-spacing: var(--gap-md); letter-spacing: var(--gap-md);
color: var(--color-contrast); color: var(--color-contrast);
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md); padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
} }
.btn { .btn {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
} }
} }
} }
} }
.button-row { .button-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
.modal { .modal {
position: absolute; position: absolute;
} }
.code { .code {
color: var(--color-brand); color: var(--color-brand);
padding: 0.05rem 0.1rem; padding: 0.05rem 0.1rem;
// row not column // row not column
display: flex; display: flex;
.card { .card {
background: var(--color-base); background: var(--color-base);
color: var(--color-contrast); color: var(--color-contrast);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
} }
</style> </style>

View File

@@ -1,61 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { add_project_from_path } from '@/helpers/profile.js'
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets' import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui' import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { add_project_from_path } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instance: { instance: {
type: Object, type: Object,
required: true, required: true,
}, },
}) })
const router = useRouter() const router = useRouter()
const handleAddContentFromFile = async () => { const handleAddContentFromFile = async () => {
const newProject = await open({ multiple: true }) const newProject = await open({ multiple: true })
if (!newProject) return if (!newProject) return
for (const project of newProject) { for (const project of newProject) {
await add_project_from_path(props.instance.path, project.path ?? project).catch(handleError) await add_project_from_path(props.instance.path, project.path ?? project).catch(handleError)
} }
} }
const handleSearchContent = async () => { const handleSearchContent = async () => {
await router.push({ await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`, path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.path }, query: { i: props.instance.path },
}) })
} }
</script> </script>
<template> <template>
<div class="joined-buttons"> <div class="joined-buttons">
<ButtonStyled> <ButtonStyled>
<button @click="handleSearchContent"> <button @click="handleSearchContent">
<PlusIcon /> <PlusIcon />
Install content Install content
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<OverflowMenu <OverflowMenu
:options="[ :options="[
{ {
id: 'from_file', id: 'from_file',
action: handleAddContentFromFile, action: handleAddContentFromFile,
}, },
]" ]"
> >
<DropdownIcon /> <DropdownIcon />
<template #from_file> <template #from_file>
<FolderOpenIcon /> <FolderOpenIcon />
<span class="no-wrap"> Add from file </span> <span class="no-wrap"> Add from file </span>
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>

View File

@@ -1,63 +1,64 @@
<template> <template>
<div data-tauri-drag-region class="flex items-center gap-1 pl-3"> <div data-tauri-drag-region class="flex items-center gap-1 pl-3">
<Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()"> <Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon /> <ChevronLeftIcon />
</Button> </Button>
<Button <Button
v-if="false" v-if="false"
class="breadcrumbs__forward transparent" class="breadcrumbs__forward transparent"
icon-only icon-only
@click="$router.forward()" @click="$router.forward()"
> >
<ChevronRightIcon /> <ChevronRightIcon />
</Button> </Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }} {{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name"> <template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link <router-link
v-if="breadcrumb.link" v-if="breadcrumb.link"
:to="{ :to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)), path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query, query: breadcrumb.query,
}" }"
class="text-primary" class="text-primary"
>{{ >{{
breadcrumb.name.charAt(0) === '?' breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1)) ? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name : breadcrumb.name
}} }}
</router-link> </router-link>
<span <span
v-else v-else
data-tauri-drag-region data-tauri-drag-region
class="text-contrast font-semibold cursor-default select-none" class="text-contrast font-semibold cursor-default select-none"
>{{ >{{
breadcrumb.name.charAt(0) === '?' breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1)) ? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name : breadcrumb.name
}}</span }}</span
> >
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" /> <ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ChevronRightIcon, ChevronLeftIcon } from '@modrinth/assets' import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui' import { Button } from '@modrinth/ui'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRoute } from 'vue-router'
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const route = useRoute() const route = useRoute()
const breadcrumbData = useBreadcrumbs() const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
const additionalContext = const additionalContext =
route.meta.useContext === true route.meta.useContext === true
? breadcrumbData.context ? breadcrumbData.context
: route.meta.useRootContext === true : route.meta.useRootContext === true
? breadcrumbData.rootContext ? breadcrumbData.rootContext
: null : null
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
}) })
</script> </script>

View File

@@ -1,26 +1,26 @@
<template> <template>
<transition name="fade"> <transition name="fade">
<div <div
v-show="shown" v-show="shown"
ref="contextMenu" ref="contextMenu"
class="context-menu" class="context-menu"
:style="{ :style="{
left: left, left: left,
top: top, top: top,
}" }"
> >
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)"> <div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
<hr v-if="option.type === 'divider'" class="divider" /> <hr v-if="option.type === 'divider'" class="divider" />
<div <div
v-else-if="!(isLinkedData(item) && option.name === `add_content`)" v-else-if="!(isLinkedData(item) && option.name === `add_content`)"
class="item clickable" class="item clickable"
:class="[option.color ?? 'base']" :class="[option.color ?? 'base']"
> >
<slot :name="option.name" /> <slot :name="option.name" />
</div> </div>
</div> </div>
</div> </div>
</transition> </transition>
</template> </template>
<script setup> <script setup>
@@ -36,141 +36,141 @@ const top = ref('0px')
const shown = ref(false) const shown = ref(false)
defineExpose({ defineExpose({
showMenu: (event, passedItem, passedOptions) => { showMenu: (event, passedItem, passedOptions) => {
item.value = passedItem item.value = passedItem
options.value = passedOptions options.value = passedOptions
const menuWidth = contextMenu.value.clientWidth const menuWidth = contextMenu.value.clientWidth
const menuHeight = contextMenu.value.clientHeight const menuHeight = contextMenu.value.clientHeight
if (menuWidth + event.pageX >= window.innerWidth) { if (menuWidth + event.pageX >= window.innerWidth) {
left.value = event.pageX - menuWidth + 2 + 'px' left.value = event.pageX - menuWidth + 2 + 'px'
} else { } else {
left.value = event.pageX - 2 + 'px' left.value = event.pageX - 2 + 'px'
} }
if (menuHeight + event.pageY >= window.innerHeight) { if (menuHeight + event.pageY >= window.innerHeight) {
top.value = event.pageY - menuHeight + 2 + 'px' top.value = event.pageY - menuHeight + 2 + 'px'
} else { } else {
top.value = event.pageY - 2 + 'px' top.value = event.pageY - 2 + 'px'
} }
shown.value = true shown.value = true
}, },
}) })
const isLinkedData = (item) => { const isLinkedData = (item) => {
if (item.instance != undefined && item.instance.linked_data) { if (item.instance != undefined && item.instance.linked_data) {
return true return true
} else if (item != undefined && item.linked_data) { } else if (item != undefined && item.linked_data) {
return true return true
} }
return false return false
} }
const hideContextMenu = () => { const hideContextMenu = () => {
shown.value = false shown.value = false
emit('menu-closed') emit('menu-closed')
} }
const optionClicked = (option) => { const optionClicked = (option) => {
emit('option-clicked', { emit('option-clicked', {
item: item.value, item: item.value,
option: option, option: option,
}) })
hideContextMenu() hideContextMenu()
} }
const onEscKeyRelease = (event) => { const onEscKeyRelease = (event) => {
if (event.keyCode === 27) { if (event.keyCode === 27) {
hideContextMenu() hideContextMenu()
} }
} }
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY) const elements = document.elementsFromPoint(event.clientX, event.clientY)
if ( if (
contextMenu.value && contextMenu.value &&
contextMenu.value.$el !== event.target && contextMenu.value.$el !== event.target &&
!elements.includes(contextMenu.value.$el) !elements.includes(contextMenu.value.$el)
) { ) {
hideContextMenu() hideContextMenu()
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleClickOutside) window.addEventListener('click', handleClickOutside)
document.body.addEventListener('keyup', onEscKeyRelease) document.body.addEventListener('keyup', onEscKeyRelease)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside) window.removeEventListener('click', handleClickOutside)
document.removeEventListener('keyup', onEscKeyRelease) document.removeEventListener('keyup', onEscKeyRelease)
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.context-menu { .context-menu {
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-floating); box-shadow: var(--shadow-floating);
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
margin: 0; margin: 0;
position: fixed; position: fixed;
z-index: 1000000; z-index: 1000000;
overflow: hidden; overflow: hidden;
padding: var(--gap-sm); padding: var(--gap-sm);
.item { .item {
align-items: center; align-items: center;
color: var(--color-base); color: var(--color-base);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
gap: var(--gap-sm); gap: var(--gap-sm);
padding: var(--gap-sm); padding: var(--gap-sm);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
&:hover, &:hover,
&:active { &:active {
&.base { &.base {
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
color: var(--color-contrast); color: var(--color-contrast);
} }
&.primary { &.primary {
background-color: var(--color-brand); background-color: var(--color-brand);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
font-weight: bold; font-weight: bold;
} }
&.danger { &.danger {
background-color: var(--color-red); background-color: var(--color-red);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
font-weight: bold; font-weight: bold;
} }
&.contrast { &.contrast {
background-color: var(--color-orange); background-color: var(--color-orange);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
font-weight: bold; font-weight: bold;
} }
} }
} }
.divider { .divider {
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
margin: var(--gap-sm); margin: var(--gap-sm);
pointer-events: none; pointer-events: none;
} }
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@@ -1,4 +1,16 @@
<script setup> <script setup>
import {
CheckIcon,
CopyIcon,
DropdownIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons' import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
@@ -6,17 +18,6 @@ import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install } from '@/helpers/profile.js' import { install } from '@/helpers/profile.js'
import { cancel_directory_change } from '@/helpers/settings.ts' import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import {
CheckIcon,
CopyIcon,
DropdownIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -31,111 +32,111 @@ const supportLink = ref('https://support.modrinth.com')
const metadata = ref({}) const metadata = ref({})
defineExpose({ defineExpose({
async show(errorVal, context, canClose = true, source = null) { async show(errorVal, context, canClose = true, source = null) {
closable.value = canClose closable.value = canClose
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) { if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
title.value = 'Unable to sign in to Minecraft' title.value = 'Unable to sign in to Minecraft'
errorType.value = 'minecraft_auth' errorType.value = 'minecraft_auth'
supportLink.value = supportLink.value =
'https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues' 'https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues'
if ( if (
errorVal.message.includes('existing connection was forcibly closed') || errorVal.message.includes('existing connection was forcibly closed') ||
errorVal.message.includes('error sending request for url') errorVal.message.includes('error sending request for url')
) { ) {
metadata.value.network = true metadata.value.network = true
} }
if (errorVal.message.includes('because the target machine actively refused it')) { if (errorVal.message.includes('because the target machine actively refused it')) {
metadata.value.hostsFile = true metadata.value.hostsFile = true
} }
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) { } else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
title.value = 'Sign in to Minecraft' title.value = 'Sign in to Minecraft'
errorType.value = 'minecraft_sign_in' errorType.value = 'minecraft_sign_in'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) { } else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
title.value = 'Could not change app directory' title.value = 'Could not change app directory'
errorType.value = 'directory_move' errorType.value = 'directory_move'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
if (errorVal.message.includes('directory is not writeable')) { if (errorVal.message.includes('directory is not writeable')) {
metadata.value.readOnly = true metadata.value.readOnly = true
} }
if (errorVal.message.includes('Not enough space')) { if (errorVal.message.includes('Not enough space')) {
metadata.value.notEnoughSpace = true metadata.value.notEnoughSpace = true
} }
} else if (errorVal.message && errorVal.message.includes('No loader version selected for')) { } else if (errorVal.message && errorVal.message.includes('No loader version selected for')) {
title.value = 'No loader selected' title.value = 'No loader selected'
errorType.value = 'no_loader_version' errorType.value = 'no_loader_version'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
metadata.value.profilePath = context.profilePath metadata.value.profilePath = context.profilePath
} else if (source === 'state_init') { } else if (source === 'state_init') {
title.value = 'Error initializing Modrinth App' title.value = 'Error initializing Modrinth App'
errorType.value = 'state_init' errorType.value = 'state_init'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
} else { } else {
title.value = 'An error occurred' title.value = 'An error occurred'
errorType.value = 'unknown' errorType.value = 'unknown'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
metadata.value = {} metadata.value = {}
} }
error.value = errorVal error.value = errorVal
errorModal.value.show() errorModal.value.show()
}, },
}) })
const loadingMinecraft = ref(false) const loadingMinecraft = ref(false)
async function loginMinecraft() { async function loginMinecraft() {
try { try {
loadingMinecraft.value = true loadingMinecraft.value = true
const loggedIn = await login_flow() const loggedIn = await login_flow()
if (loggedIn) { if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError) await set_default_user(loggedIn.profile.id).catch(handleError)
} }
await trackEvent('AccountLogIn', { source: 'ErrorModal' }) await trackEvent('AccountLogIn', { source: 'ErrorModal' })
loadingMinecraft.value = false loadingMinecraft.value = false
errorModal.value.hide() errorModal.value.hide()
} catch (err) { } catch (err) {
loadingMinecraft.value = false loadingMinecraft.value = false
handleSevereError(err) handleSevereError(err)
} }
} }
async function cancelDirectoryChange() { async function cancelDirectoryChange() {
try { try {
await cancel_directory_change() await cancel_directory_change()
window.location.reload() window.location.reload()
} catch (err) { } catch (err) {
handleError(err) handleError(err)
} }
} }
function retryDirectoryChange() { function retryDirectoryChange() {
window.location.reload() window.location.reload()
} }
const loadingRepair = ref(false) const loadingRepair = ref(false)
async function repairInstance() { async function repairInstance() {
loadingRepair.value = true loadingRepair.value = true
try { try {
await install(metadata.value.profilePath, false) await install(metadata.value.profilePath, false)
errorModal.value.hide() errorModal.value.hide()
} catch (err) { } catch (err) {
handleSevereError(err) handleSevereError(err)
} }
loadingRepair.value = false loadingRepair.value = false
} }
const hasDebugInfo = computed( const hasDebugInfo = computed(
() => () =>
errorType.value === 'directory_move' || errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' || errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' || errorType.value === 'state_init' ||
errorType.value === 'no_loader_version', errorType.value === 'no_loader_version',
) )
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.') const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
@@ -143,236 +144,236 @@ const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error
const copied = ref(false) const copied = ref(false)
async function copyToClipboard(text) { async function copyToClipboard(text) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
copied.value = true copied.value = true
setTimeout(() => { setTimeout(() => {
copied.value = false copied.value = false
}, 3000) }, 3000)
} }
</script> </script>
<template> <template>
<ModalWrapper ref="errorModal" :header="title" :closable="closable"> <ModalWrapper ref="errorModal" :header="title" :closable="closable">
<div class="modal-body"> <div class="modal-body">
<div class="markdown-body"> <div class="markdown-body">
<template v-if="errorType === 'minecraft_auth'"> <template v-if="errorType === 'minecraft_auth'">
<template v-if="metadata.network"> <template v-if="metadata.network">
<h3>Network issues</h3> <h3>Network issues</h3>
<p> <p>
It looks like there were issues with the Modrinth App connecting to Microsoft's It looks like there were issues with the Modrinth App connecting to Microsoft's
servers. This is often the result of a poor connection, so we recommend trying again servers. This is often the result of a poor connection, so we recommend trying again
to see if it works. If issues continue to persist, follow the steps in to see if it works. If issues continue to persist, follow the steps in
<a <a
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_e71a5f805f" href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_e71a5f805f"
> >
our support article our support article
</a> </a>
to troubleshoot. to troubleshoot.
</p> </p>
</template> </template>
<template v-else-if="metadata.hostsFile"> <template v-else-if="metadata.hostsFile">
<h3>Network issues</h3> <h3>Network issues</h3>
<p> <p>
The Modrinth App tried to connect to Microsoft / Xbox / Minecraft services, but the The Modrinth App tried to connect to Microsoft / Xbox / Minecraft services, but the
remote server rejected the connection. This may indicate that these services are remote server rejected the connection. This may indicate that these services are
blocked by the hosts file. Please visit blocked by the hosts file. Please visit
<a <a
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_d694a29256" href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_d694a29256"
> >
our support article our support article
</a> </a>
for steps on how to fix the issue. for steps on how to fix the issue.
</p> </p>
</template> </template>
<template v-else> <template v-else>
<h3>Try another Microsoft account</h3> <h3>Try another Microsoft account</h3>
<p> <p>
Double check you've signed in with the right account. You may own Minecraft on a Double check you've signed in with the right account. You may own Minecraft on a
different Microsoft account. different Microsoft account.
</p> </p>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft"> <button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Try another account <LogInIcon /> Try another account
</button> </button>
</div> </div>
<h3>Using PC Game Pass, coming from Bedrock, or just bought the game?</h3> <h3>Using PC Game Pass, coming from Bedrock, or just bought the game?</h3>
<p> <p>
Try signing in with the Try signing in with the
<a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>
first. Once you're done, come back here and sign in! first. Once you're done, come back here and sign in!
</p> </p>
</template> </template>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft"> <button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Try signing in again <LogInIcon /> Try signing in again
</button> </button>
</div> </div>
</template> </template>
<template v-if="errorType === 'directory_move'"> <template v-if="errorType === 'directory_move'">
<template v-if="metadata.readOnly"> <template v-if="metadata.readOnly">
<h3>Change directory permissions</h3> <h3>Change directory permissions</h3>
<p> <p>
It looks like the Modrinth App is unable to write to the directory you selected. It looks like the Modrinth App is unable to write to the directory you selected.
Please adjust the permissions of the directory and try again or cancel the directory Please adjust the permissions of the directory and try again or cancel the directory
change. change.
</p> </p>
</template> </template>
<template v-else-if="metadata.notEnoughSpace"> <template v-else-if="metadata.notEnoughSpace">
<h3>Not enough space</h3> <h3>Not enough space</h3>
<p> <p>
It looks like there is not enough space on the disk containing the directory you It looks like there is not enough space on the disk containing the directory you
selected. Please free up some space and try again or cancel the directory change. selected. Please free up some space and try again or cancel the directory change.
</p> </p>
</template> </template>
<template v-else> <template v-else>
<p> <p>
The Modrinth App is unable to migrate to the new directory you selected. Please The Modrinth App is unable to migrate to the new directory you selected. Please
contact support for help or cancel the directory change. contact support for help or cancel the directory change.
</p> </p>
</template> </template>
<div class="cta-button"> <div class="cta-button">
<button class="btn" @click="retryDirectoryChange"> <button class="btn" @click="retryDirectoryChange">
<UpdatedIcon /> Retry directory change <UpdatedIcon /> Retry directory change
</button> </button>
<button class="btn btn-danger" @click="cancelDirectoryChange"> <button class="btn btn-danger" @click="cancelDirectoryChange">
<XIcon /> Cancel directory change <XIcon /> Cancel directory change
</button> </button>
</div> </div>
</template> </template>
<div v-else-if="errorType === 'minecraft_sign_in'"> <div v-else-if="errorType === 'minecraft_sign_in'">
<p> <p>
To play this instance, you must sign in through Microsoft below. If you don't have a To play this instance, you must sign in through Microsoft below. If you don't have a
Minecraft account, you can purchase the game on the Minecraft account, you can purchase the game on the
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc" <a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
>Minecraft website</a >Minecraft website</a
>. >.
</p> </p>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft"> <button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Sign in to Minecraft <LogInIcon /> Sign in to Minecraft
</button> </button>
</div> </div>
</div> </div>
<template v-else-if="errorType === 'state_init'"> <template v-else-if="errorType === 'state_init'">
<p> <p>
Modrinth App failed to load correctly. This may be because of a corrupted file, or Modrinth App failed to load correctly. This may be because of a corrupted file, or
because the app is missing crucial files. because the app is missing crucial files.
</p> </p>
<p>You may be able to fix it through one of the following ways:</p> <p>You may be able to fix it through one of the following ways:</p>
<ul> <ul>
<li>Ensuring you are connected to the internet, then try restarting the app.</li> <li>Ensuring you are connected to the internet, then try restarting the app.</li>
<li>Redownloading the app.</li> <li>Redownloading the app.</li>
</ul> </ul>
</template> </template>
<template v-else-if="errorType === 'no_loader_version'"> <template v-else-if="errorType === 'no_loader_version'">
<p>The Modrinth App failed to find the loader version for this instance.</p> <p>The Modrinth App failed to find the loader version for this instance.</p>
<p>To resolve this, you need to repair the instance. Click the button below to do so.</p> <p>To resolve this, you need to repair the instance. Click the button below to do so.</p>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance"> <button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance">
<HammerIcon /> Repair instance <HammerIcon /> Repair instance
</button> </button>
</div> </div>
</template> </template>
<template v-else> <template v-else>
{{ debugInfo }} {{ debugInfo }}
</template> </template>
<template v-if="hasDebugInfo"> <template v-if="hasDebugInfo">
<hr /> <hr />
<p> <p>
If nothing is working and you need help, visit If nothing is working and you need help, visit
<a :href="supportLink">our support page</a> <a :href="supportLink">our support page</a>
and start a chat using the widget in the bottom right and we will be more than happy to and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent: assist! Make sure to provide the following debug information to the agent:
</p> </p>
</template> </template>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ButtonStyled> <ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a> <a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="closable"> <ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button> <button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="hasDebugInfo"> <ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)"> <button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template> <template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template> <template v-else> <CopyIcon /> Copy debug info </template>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<template v-if="hasDebugInfo"> <template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip"> <div class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button <button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer" class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed" @click="errorCollapsed = !errorCollapsed"
> >
<span class="text-contrast font-extrabold m-0">Debug information:</span> <span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon <DropdownIcon
class="h-5 w-5 text-secondary transition-transform" class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }" :class="{ 'rotate-180': !errorCollapsed }"
/> />
</button> </button>
<Collapsible :collapsed="errorCollapsed"> <Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre> <pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
</Collapsible> </Collapsible>
</div> </div>
</template> </template>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style> <style>
.light-mode { .light-mode {
--color-orange-bg: rgba(255, 163, 71, 0.2); --color-orange-bg: rgba(255, 163, 71, 0.2);
} }
.dark-mode, .dark-mode,
.oled-mode { .oled-mode {
--color-orange-bg: rgba(224, 131, 37, 0.2); --color-orange-bg: rgba(224, 131, 37, 0.2);
} }
</style> </style>
<style scoped lang="scss"> <style scoped lang="scss">
.cta-button { .cta-button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.5rem; padding: 0.5rem;
gap: 0.5rem; gap: 0.5rem;
} }
.warning-banner { .warning-banner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: var(--gap-lg); padding: var(--gap-lg);
background-color: var(--color-orange-bg); background-color: var(--color-orange-bg);
border: 2px solid var(--color-orange); border: 2px solid var(--color-orange);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.warning-banner__title { .warning-banner__title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-weight: 700; font-weight: 700;
svg { svg {
color: var(--color-orange); color: var(--color-orange);
height: 1.5rem; height: 1.5rem;
width: 1.5rem; width: 1.5rem;
} }
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-md); gap: var(--gap-md);
} }
.markdown-body { .markdown-body {
overflow: auto; overflow: auto;
} }
</style> </style>

View File

@@ -1,26 +1,27 @@
<script setup> <script setup>
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { PlusIcon, XIcon } from '@modrinth/assets' import { PlusIcon, XIcon } from '@modrinth/assets'
import { Button, Checkbox, injectNotificationManager } from '@modrinth/ui' import { Button, Checkbox, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue' import { ref } from 'vue'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instance: { instance: {
type: Object, type: Object,
required: true, required: true,
}, },
}) })
defineExpose({ defineExpose({
show: () => { show: () => {
exportModal.value.show() exportModal.value.show()
initFiles() initFiles()
}, },
}) })
const exportModal = ref(null) const exportModal = ref(null)
@@ -32,273 +33,273 @@ const folders = ref([])
const showingFiles = ref(false) const showingFiles = ref(false)
const initFiles = async () => { const initFiles = async () => {
const newFolders = new Map() const newFolders = new Map()
const sep = '/' const sep = '/'
files.value = [] files.value = []
await get_pack_export_candidates(props.instance.path).then((filePaths) => await get_pack_export_candidates(props.instance.path).then((filePaths) =>
filePaths filePaths
.map((folder) => ({ .map((folder) => ({
path: folder, path: folder,
name: folder.split(sep).pop(), name: folder.split(sep).pop(),
selected: selected:
folder.startsWith('mods') || folder.startsWith('mods') ||
folder.startsWith('datapacks') || folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') || folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') || folder.startsWith('shaderpacks') ||
folder.startsWith('config'), folder.startsWith('config'),
disabled: disabled:
folder === 'profile.json' || folder === 'profile.json' ||
folder.startsWith('modrinth_logs') || folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric'), folder.startsWith('.fabric'),
})) }))
.filter((pathData) => !pathData.path.includes('.DS_Store')) .filter((pathData) => !pathData.path.includes('.DS_Store'))
.forEach((pathData) => { .forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep) const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') { if (parent !== '') {
if (newFolders.has(parent)) { if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData) newFolders.get(parent).push(pathData)
} else { } else {
newFolders.set(parent, [pathData]) newFolders.set(parent, [pathData])
} }
} else { } else {
files.value.push(pathData) files.value.push(pathData)
} }
}), }),
) )
folders.value = [...newFolders.entries()].map(([name, value]) => [ folders.value = [...newFolders.entries()].map(([name, value]) => [
{ {
name, name,
showingMore: false, showingMore: false,
}, },
value, value,
]) ])
} }
await initFiles() await initFiles()
const exportPack = async () => { const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path) const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => { folders.value.forEach((args) => {
args[1].forEach((child) => { args[1].forEach((child) => {
if (child.selected) { if (child.selected) {
filesToExport.push(child.path) filesToExport.push(child.path)
} }
}) })
}) })
const outputPath = await open({ const outputPath = await open({
directory: true, directory: true,
multiple: false, multiple: false,
}) })
if (outputPath) { if (outputPath) {
export_profile_mrpack( export_profile_mrpack(
props.instance.path, props.instance.path,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`, outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
filesToExport, filesToExport,
versionInput.value, versionInput.value,
exportDescription.value, exportDescription.value,
nameInput.value, nameInput.value,
).catch((err) => handleError(err)) ).catch((err) => handleError(err))
exportModal.value.hide() exportModal.value.hide()
} }
} }
</script> </script>
<template> <template>
<ModalWrapper ref="exportModal" header="Export modpack"> <ModalWrapper ref="exportModal" header="Export modpack">
<div class="modal-body"> <div class="modal-body">
<div class="labeled_input"> <div class="labeled_input">
<p>Modpack Name</p> <p>Modpack Name</p>
<div class="iconified-input"> <div class="iconified-input">
<PackageIcon /> <PackageIcon />
<input v-model="nameInput" type="text" placeholder="Modpack name" class="input" /> <input v-model="nameInput" type="text" placeholder="Modpack name" class="input" />
<Button class="r-btn" @click="nameInput = ''"> <Button class="r-btn" @click="nameInput = ''">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
</div> </div>
<div class="labeled_input"> <div class="labeled_input">
<p>Version number</p> <p>Version number</p>
<div class="iconified-input"> <div class="iconified-input">
<VersionIcon /> <VersionIcon />
<input v-model="versionInput" type="text" placeholder="1.0.0" class="input" /> <input v-model="versionInput" type="text" placeholder="1.0.0" class="input" />
<Button class="r-btn" @click="versionInput = ''"> <Button class="r-btn" @click="versionInput = ''">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<div class="labeled_input"> <div class="labeled_input">
<p>Description</p> <p>Description</p>
<div class="textarea-wrapper"> <div class="textarea-wrapper">
<textarea v-model="exportDescription" placeholder="Enter modpack description..." /> <textarea v-model="exportDescription" placeholder="Enter modpack description..." />
</div> </div>
</div> </div>
</div> </div>
<div class="table"> <div class="table">
<div class="table-head"> <div class="table-head">
<div class="table-cell row-wise"> <div class="table-cell row-wise">
Select files and folders to include in pack Select files and folders to include in pack
<Button <Button
class="sleek-primary collapsed-button" class="sleek-primary collapsed-button"
icon-only icon-only
@click="() => (showingFiles = !showingFiles)" @click="() => (showingFiles = !showingFiles)"
> >
<PlusIcon v-if="!showingFiles" /> <PlusIcon v-if="!showingFiles" />
<XIcon v-else /> <XIcon v-else />
</Button> </Button>
</div> </div>
</div> </div>
<div v-if="showingFiles" class="table-content"> <div v-if="showingFiles" class="table-content">
<div v-for="[path, children] in folders" :key="path.name" class="table-row"> <div v-for="[path, children] in folders" :key="path.name" class="table-row">
<div class="table-cell file-entry"> <div class="table-cell file-entry">
<div class="file-primary"> <div class="file-primary">
<Checkbox <Checkbox
:model-value="children.every((child) => child.selected)" :model-value="children.every((child) => child.selected)"
:label="path.name" :label="path.name"
class="select-checkbox" class="select-checkbox"
:disabled="children.every((x) => x.disabled)" :disabled="children.every((x) => x.disabled)"
@update:model-value=" @update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue)) (newValue) => children.forEach((child) => (child.selected = newValue))
" "
/> />
<Checkbox <Checkbox
v-model="path.showingMore" v-model="path.showingMore"
class="select-checkbox dropdown" class="select-checkbox dropdown"
collapsing-toggle-style collapsing-toggle-style
/> />
</div> </div>
<div v-if="path.showingMore" class="file-secondary"> <div v-if="path.showingMore" class="file-secondary">
<div v-for="child in children" :key="child.path" class="file-secondary-row"> <div v-for="child in children" :key="child.path" class="file-secondary-row">
<Checkbox <Checkbox
v-model="child.selected" v-model="child.selected"
:label="child.name" :label="child.name"
class="select-checkbox" class="select-checkbox"
:disabled="child.disabled" :disabled="child.disabled"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-for="file in files" :key="file.path" class="table-row"> <div v-for="file in files" :key="file.path" class="table-row">
<div class="table-cell file-entry"> <div class="table-cell file-entry">
<div class="file-primary"> <div class="file-primary">
<Checkbox <Checkbox
v-model="file.selected" v-model="file.selected"
:label="file.name" :label="file.name"
:disabled="file.disabled" :disabled="file.disabled"
class="select-checkbox" class="select-checkbox"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="button-row push-right"> <div class="button-row push-right">
<Button @click="exportModal.hide"> <Button @click="exportModal.hide">
<XIcon /> <XIcon />
Cancel Cancel
</Button> </Button>
<Button color="primary" @click="exportPack"> <Button color="primary" @click="exportPack">
<PackageIcon /> <PackageIcon />
Export Export
</Button> </Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-md); gap: var(--gap-md);
} }
.labeled_input { .labeled_input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-sm); gap: var(--gap-sm);
p { p {
margin: 0; margin: 0;
} }
} }
.select-checkbox { .select-checkbox {
gap: var(--gap-sm); gap: var(--gap-sm);
button.checkbox { button.checkbox {
border: none; border: none;
} }
&.dropdown { &.dropdown {
margin-left: auto; margin-left: auto;
} }
} }
.table-content { .table-content {
max-height: 18rem; max-height: 18rem;
overflow-y: auto; overflow-y: auto;
} }
.table { .table {
border: 1px solid var(--color-bg); border: 1px solid var(--color-bg);
} }
.file-entry { .file-entry {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.file-primary { .file-primary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.file-secondary { .file-secondary {
margin-left: var(--gap-xl); margin-left: var(--gap-xl);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-sm); gap: var(--gap-sm);
height: 100%; height: 100%;
vertical-align: center; vertical-align: center;
} }
.file-secondary-row { .file-secondary-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.button-row { .button-row {
display: flex; display: flex;
gap: var(--gap-sm); gap: var(--gap-sm);
align-items: center; align-items: center;
} }
.row-wise { .row-wise {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
} }
.textarea-wrapper { .textarea-wrapper {
// margin-top: 1rem; // margin-top: 1rem;
height: 12rem; height: 12rem;
textarea { textarea {
max-height: 12rem; max-height: 12rem;
} }
.preview { .preview {
overflow-y: auto; overflow-y: auto;
} }
} }
</style> </style>

View File

@@ -1,17 +1,11 @@
<script setup> <script setup>
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { import {
DownloadIcon, DownloadIcon,
GameIcon, GameIcon,
PlayIcon, PlayIcon,
SpinnerIcon, SpinnerIcon,
StopCircleIcon, StopCircleIcon,
TimerIcon, TimerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui' import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
@@ -19,33 +13,40 @@ import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps({ const props = defineProps({
instance: { instance: {
type: Object, type: Object,
default() { default() {
return {} return {}
}, },
}, },
compact: { compact: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
first: { first: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}) })
const playing = ref(false) const playing = ref(false)
const loading = ref(false) const loading = ref(false)
const modLoading = computed( const modLoading = computed(
() => () =>
loading.value || loading.value ||
currentEvent.value === 'installing' || currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value), (currentEvent.value === 'launched' && !playing.value),
) )
const installing = computed(() => props.instance.install_stage.includes('installing')) const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed') const installed = computed(() => props.instance.install_stage === 'installed')
@@ -53,78 +54,78 @@ const installed = computed(() => props.instance.install_stage === 'installed')
const router = useRouter() const router = useRouter()
const seeInstance = async () => { const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.path)}`) await router.push(`/instance/${encodeURIComponent(props.instance.path)}`)
} }
const checkProcess = async () => { const checkProcess = async () => {
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError) const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0 playing.value = runningProcesses.length > 0
} }
const play = async (e, context) => { const play = async (e, context) => {
e?.stopPropagation() e?.stopPropagation()
loading.value = true loading.value = true
await run(props.instance.path) await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path })) .catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => { .finally(() => {
trackEvent('InstancePlay', { trackEvent('InstancePlay', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
source: context, source: context,
}) })
}) })
loading.value = false loading.value = false
} }
const stop = async (e, context) => { const stop = async (e, context) => {
e?.stopPropagation() e?.stopPropagation()
playing.value = false playing.value = false
await kill(props.instance.path).catch(handleError) await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
source: context, source: context,
}) })
} }
const repair = async (e) => { const repair = async (e) => {
e?.stopPropagation() e?.stopPropagation()
await finish_install(props.instance) await finish_install(props.instance)
} }
const openFolder = async () => { const openFolder = async () => {
await showProfileInFolder(props.instance.path) await showProfileInFolder(props.instance.path)
} }
const addContent = async () => { const addContent = async () => {
await router.push({ await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`, path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.path }, query: { i: props.instance.path },
}) })
} }
defineExpose({ defineExpose({
play, play,
stop, stop,
seeInstance, seeInstance,
openFolder, openFolder,
addContent, addContent,
instance: props.instance, instance: props.instance,
}) })
const currentEvent = ref(null) const currentEvent = ref(null)
const unlisten = await process_listener((e) => { const unlisten = await process_listener((e) => {
if (e.profile_path_id === props.instance.path) { if (e.profile_path_id === props.instance.path) {
currentEvent.value = e.event currentEvent.value = e.event
if (e.event === 'finished') { if (e.event === 'finished') {
playing.value = false playing.value = false
} }
} }
}) })
onMounted(() => checkProcess()) onMounted(() => checkProcess())
@@ -132,118 +133,118 @@ onUnmounted(() => unlisten())
</script> </script>
<template> <template>
<template v-if="compact"> <template v-if="compact">
<div <div
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all" class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
@click="seeInstance" @click="seeInstance"
@mouseenter="checkProcess" @mouseenter="checkProcess"
> >
<Avatar <Avatar
size="48px" size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path" :tint-by="instance.path"
alt="Mod card" alt="Mod card"
/> />
<div class="h-full flex items-center font-bold text-contrast leading-normal"> <div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span> <span class="line-clamp-2">{{ instance.name }}</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess"> <ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')"> <button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon /> <StopCircleIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular> <ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="'Instance is loading...'" disabled> <button v-tooltip="'Instance is loading...'" disabled>
<SpinnerIcon class="animate-spin" /> <SpinnerIcon class="animate-spin" />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular> <ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<button <button
v-tooltip="'Play'" v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')" @click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<!-- Translate for optical centering --> <!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" /> <PlayIcon class="translate-x-[1px]" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon /> <TimerIcon />
<span class="text-sm"> <span class="text-sm">
<template v-if="instance.last_played"> <template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }} Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template> </template>
<template v-else> Never played </template> <template v-else> Never played </template>
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<div v-else> <div v-else>
<div <div
class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group" class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group"
@click="seeInstance" @click="seeInstance"
@mouseenter="checkProcess" @mouseenter="checkProcess"
> >
<div class="relative flex items-center justify-center"> <div class="relative flex items-center justify-center">
<Avatar <Avatar
size="48px" size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path" :tint-by="instance.path"
alt="Mod card" alt="Mod card"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`" :class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/> />
<div class="absolute inset-0 flex items-center justify-center"> <div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" size="large" color="red" circular> <ButtonStyled v-if="playing" size="large" color="red" circular>
<button <button
v-tooltip="'Stop'" v-tooltip="'Stop'"
:class="{ 'scale-100 opacity-100': playing }" :class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow" class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')" @click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<StopCircleIcon /> <StopCircleIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<SpinnerIcon <SpinnerIcon
v-else-if="modLoading || installing" v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'" v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8" class="animate-spin w-8 h-8"
tabindex="-1" tabindex="-1"
/> />
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular> <ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button <button
v-tooltip="'Repair'" v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow" class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)" @click="(e) => repair(e)"
> >
<DownloadIcon /> <DownloadIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular> <ButtonStyled v-else size="large" color="brand" circular>
<button <button
v-tooltip="'Play'" v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow" class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')" @click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<PlayIcon class="translate-x-[2px]" /> <PlayIcon class="translate-x-[2px]" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1"> <p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
{{ instance.name }} {{ instance.name }}
</p> </p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" /> <GameIcon class="shrink-0" />
<span class="text-sm capitalize"> <span class="text-sm capitalize">
{{ instance.loader }} {{ instance.game_version }} {{ instance.loader }} {{ instance.game_version }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'
import { formatCategory } from '@modrinth/utils'
import { GameIcon, LeftArrowIcon } from '@modrinth/assets' import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui' import { Avatar, ButtonStyled } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
type Instance = { type Instance = {
game_version: string game_version: string
loader: string loader: string
path: string path: string
install_stage: string install_stage: string
icon_path?: string icon_path?: string
name: string name: string
} }
defineProps<{ defineProps<{
instance: Instance instance: Instance
}>() }>()
</script> </script>
<template> <template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4"> <div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link <router-link
:to="`/instance/${encodeURIComponent(instance.path)}`" :to="`/instance/${encodeURIComponent(instance.path)}`"
tabindex="-1" tabindex="-1"
class="flex flex-col gap-4 text-primary" class="flex flex-col gap-4 text-primary"
> >
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name" :alt="instance.name"
size="48px" size="48px"
/> />
<span class="flex flex-col gap-2"> <span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast"> <span class="font-extrabold bold text-contrast">
{{ instance.name }} {{ instance.name }}
</span> </span>
<span class="text-secondary flex items-center gap-2 font-semibold"> <span class="text-secondary flex items-center gap-2 font-semibold">
<GameIcon class="h-5 w-5 text-secondary" /> <GameIcon class="h-5 w-5 text-secondary" />
{{ formatCategory(instance.loader) }} {{ instance.game_version }} {{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span> </span>
</span> </span>
</span> </span>
</router-link> </router-link>
<ButtonStyled> <ButtonStyled>
<router-link :to="`/instance/${encodeURIComponent(instance.path)}`"> <router-link :to="`/instance/${encodeURIComponent(instance.path)}`">
<LeftArrowIcon /> Back to instance <LeftArrowIcon /> Back to instance
</router-link> </router-link>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,47 +1,48 @@
<template> <template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false"> <ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<div class="auto-detect-modal"> <div class="auto-detect-modal">
<div class="table"> <div class="table">
<div class="table-row table-head"> <div class="table-row table-head">
<div class="table-cell table-text">Version</div> <div class="table-cell table-text">Version</div>
<div class="table-cell table-text">Path</div> <div class="table-cell table-text">Path</div>
<div class="table-cell table-text">Actions</div> <div class="table-cell table-text">Actions</div>
</div> </div>
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row"> <div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
<div class="table-cell table-text"> <div class="table-cell table-text">
<span>{{ javaInstall.version }}</span> <span>{{ javaInstall.version }}</span>
</div> </div>
<div v-tooltip="javaInstall.path" class="table-cell table-text"> <div v-tooltip="javaInstall.path" class="table-cell table-text">
<span>{{ javaInstall.path }}</span> <span>{{ javaInstall.path }}</span>
</div> </div>
<div class="table-cell table-text manage"> <div class="table-cell table-text manage">
<Button v-if="currentSelected.path === javaInstall.path" disabled <Button v-if="currentSelected.path === javaInstall.path" disabled
><CheckIcon /> Selected</Button ><CheckIcon /> Selected</Button
> >
<Button v-else @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</Button> <Button v-else @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</Button>
</div> </div>
</div> </div>
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row"> <div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
<div class="table-cell table-text">No java installations found!</div> <div class="table-cell table-text">No java installations found!</div>
</div> </div>
</div> </div>
<div class="input-group push-right"> <div class="input-group push-right">
<Button @click="$refs.detectJavaModal.hide()"> <Button @click="$refs.detectJavaModal.hide()">
<XIcon /> <XIcon />
Cancel Cancel
</Button> </Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { find_filtered_jres } from '@/helpers/jre.js'
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets' import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { find_filtered_jres } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const chosenInstallOptions = ref([]) const chosenInstallOptions = ref([])
@@ -49,48 +50,48 @@ const detectJavaModal = ref(null)
const currentSelected = ref({}) const currentSelected = ref({})
defineExpose({ defineExpose({
show: async (version, currentSelectedJava) => { show: async (version, currentSelectedJava) => {
chosenInstallOptions.value = await find_filtered_jres(version).catch(handleError) chosenInstallOptions.value = await find_filtered_jres(version).catch(handleError)
currentSelected.value = currentSelectedJava currentSelected.value = currentSelectedJava
if (!currentSelected.value) { if (!currentSelected.value) {
currentSelected.value = { path: '', version: '' } currentSelected.value = { path: '', version: '' }
} }
detectJavaModal.value.show() detectJavaModal.value.show()
}, },
}) })
const emit = defineEmits(['submit']) const emit = defineEmits(['submit'])
function setJavaInstall(javaInstall) { function setJavaInstall(javaInstall) {
emit('submit', javaInstall) emit('submit', javaInstall)
detectJavaModal.value.hide() detectJavaModal.value.hide()
trackEvent('JavaAutoDetect', { trackEvent('JavaAutoDetect', {
path: javaInstall.path, path: javaInstall.path,
version: javaInstall.version, version: javaInstall.version,
}) })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.auto-detect-modal { .auto-detect-modal {
.table { .table {
.table-row { .table-row {
grid-template-columns: 1fr 4fr min-content; grid-template-columns: 1fr 4fr min-content;
} }
span { span {
display: inherit; display: inherit;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
padding: 0.5rem; padding: 0.5rem;
} }
} }
.manage { .manage {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
</style> </style>

View File

@@ -1,101 +1,102 @@
<template> <template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" /> <JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div class="toggle-setting" :class="{ compact }"> <div class="toggle-setting" :class="{ compact }">
<input <input
autocomplete="off" autocomplete="off"
:disabled="props.disabled" :disabled="props.disabled"
:value="props.modelValue ? props.modelValue.path : ''" :value="props.modelValue ? props.modelValue.path : ''"
type="text" type="text"
class="installation-input" class="installation-input"
:placeholder="placeholder ?? '/path/to/java'" :placeholder="placeholder ?? '/path/to/java'"
@input=" @input="
(val) => { (val) => {
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
path: val.target.value, path: val.target.value,
}) })
} }
" "
/> />
<span class="installation-buttons"> <span class="installation-buttons">
<Button <Button
v-if="props.version" v-if="props.version"
:disabled="props.disabled || installingJava" :disabled="props.disabled || installingJava"
@click="reinstallJava" @click="reinstallJava"
> >
<DownloadIcon /> <DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }} {{ installingJava ? 'Installing...' : 'Install recommended' }}
</Button> </Button>
<Button :disabled="props.disabled" @click="autoDetect"> <Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon /> <SearchIcon />
Detect Detect
</Button> </Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()"> <Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon /> <FolderSearchIcon />
Browse Browse
</Button> </Button>
<Button v-if="testingJava" disabled> Testing... </Button> <Button v-if="testingJava" disabled> Testing... </Button>
<Button v-else-if="testingJavaSuccess === true"> <Button v-else-if="testingJavaSuccess === true">
<CheckIcon class="test-success" /> <CheckIcon class="test-success" />
Success Success
</Button> </Button>
<Button v-else-if="testingJavaSuccess === false"> <Button v-else-if="testingJavaSuccess === false">
<XIcon class="test-fail" /> <XIcon class="test-fail" />
Failed Failed
</Button> </Button>
<Button v-else :disabled="props.disabled" @click="testJava"> <Button v-else :disabled="props.disabled" @click="testJava">
<PlayIcon /> <PlayIcon />
Test Test
</Button> </Button>
</span> </span>
</div> </div>
</template> </template>
<script setup> <script setup>
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { import {
CheckIcon, CheckIcon,
DownloadIcon, DownloadIcon,
FolderSearchIcon, FolderSearchIcon,
PlayIcon, PlayIcon,
SearchIcon, SearchIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue' import { ref } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
version: { version: {
type: Number, type: Number,
required: false, required: false,
default: null, default: null,
}, },
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ default: () => ({
path: '', path: '',
version: '', version: '',
}), }),
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
placeholder: { placeholder: {
type: String, type: String,
required: false, required: false,
default: null, default: null,
}, },
compact: { compact: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -106,115 +107,115 @@ const testingJavaSuccess = ref(null)
const installingJava = ref(false) const installingJava = ref(false)
async function testJava() { async function testJava() {
testingJava.value = true testingJava.value = true
testingJavaSuccess.value = await test_jre( testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '', props.modelValue ? props.modelValue.path : '',
props.version, props.version,
) )
testingJava.value = false testingJava.value = false
trackEvent('JavaTest', { trackEvent('JavaTest', {
path: props.modelValue ? props.modelValue.path : '', path: props.modelValue ? props.modelValue.path : '',
success: testingJavaSuccess.value, success: testingJavaSuccess.value,
}) })
setTimeout(() => { setTimeout(() => {
testingJavaSuccess.value = null testingJavaSuccess.value = null
}, 2000) }, 2000)
} }
async function handleJavaFileInput() { async function handleJavaFileInput() {
const filePath = await open() const filePath = await open()
if (filePath) { if (filePath) {
let result = await get_jre(filePath.path ?? filePath).catch(handleError) let result = await get_jre(filePath.path ?? filePath).catch(handleError)
if (!result) { if (!result) {
result = { result = {
path: filePath.path ?? filePath, path: filePath.path ?? filePath,
version: props.version.toString(), version: props.version.toString(),
architecture: 'x86', architecture: 'x86',
} }
} }
trackEvent('JavaManualSelect', { trackEvent('JavaManualSelect', {
version: props.version, version: props.version,
}) })
emit('update:modelValue', result) emit('update:modelValue', result)
} }
} }
const detectJavaModal = ref(null) const detectJavaModal = ref(null)
async function autoDetect() { async function autoDetect() {
if (!props.compact) { if (!props.compact) {
detectJavaModal.value.show(props.version, props.modelValue) detectJavaModal.value.show(props.version, props.modelValue)
} else { } else {
const versions = await find_filtered_jres(props.version).catch(handleError) const versions = await find_filtered_jres(props.version).catch(handleError)
if (versions.length > 0) { if (versions.length > 0) {
emit('update:modelValue', versions[0]) emit('update:modelValue', versions[0])
} }
} }
} }
async function reinstallJava() { async function reinstallJava() {
installingJava.value = true installingJava.value = true
const path = await auto_install_java(props.version).catch(handleError) const path = await auto_install_java(props.version).catch(handleError)
let result = await get_jre(path) let result = await get_jre(path)
if (!result) { if (!result) {
result = { result = {
path: path, path: path,
version: props.version.toString(), version: props.version.toString(),
architecture: 'x86', architecture: 'x86',
} }
} }
trackEvent('JavaReInstall', { trackEvent('JavaReInstall', {
path: path, path: path,
version: props.version, version: props.version,
}) })
emit('update:modelValue', result) emit('update:modelValue', result)
installingJava.value = false installingJava.value = false
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.installation-input { .installation-input {
width: 100% !important; width: 100% !important;
flex-grow: 1; flex-grow: 1;
} }
.toggle-setting { .toggle-setting {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
&.compact { &.compact {
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
.installation-buttons { .installation-buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin: 0; margin: 0;
.btn { .btn {
width: max-content; width: max-content;
} }
} }
.test-success { .test-success {
color: var(--color-green); color: var(--color-green);
} }
.test-fail { .test-fail {
color: var(--color-red); color: var(--color-red);
} }
</style> </style>

View File

@@ -1,33 +1,34 @@
<script setup> <script setup>
import { CheckIcon } from '@modrinth/assets' import { CheckIcon } from '@modrinth/assets'
import { Button, Badge } from '@modrinth/ui' import { Badge, Button } from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { update_managed_modrinth_version } from '@/helpers/profile'
import { releaseColor } from '@/helpers/utils'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { update_managed_modrinth_version } from '@/helpers/profile'
import { releaseColor } from '@/helpers/utils'
const props = defineProps({ const props = defineProps({
versions: { versions: {
type: Array, type: Array,
required: true, required: true,
}, },
instance: { instance: {
type: Object, type: Object,
default: null, default: null,
}, },
}) })
defineExpose({ defineExpose({
show: () => { show: () => {
modpackVersionModal.value.show() modpackVersionModal.value.show()
}, },
}) })
const emit = defineEmits(['finish-install']) const emit = defineEmits(['finish-install'])
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
return props.versions return props.versions
}) })
const modpackVersionModal = ref(null) const modpackVersionModal = ref(null)
@@ -36,160 +37,160 @@ const installing = computed(() => props.instance.install_stage !== 'installed')
const inProgress = ref(false) const inProgress = ref(false)
const switchVersion = async (versionId) => { const switchVersion = async (versionId) => {
modpackVersionModal.value.hide() modpackVersionModal.value.hide()
inProgress.value = true inProgress.value = true
await update_managed_modrinth_version(props.instance.path, versionId) await update_managed_modrinth_version(props.instance.path, versionId)
inProgress.value = false inProgress.value = false
emit('finish-install') emit('finish-install')
} }
const onHide = () => { const onHide = () => {
if (!inProgress.value) { if (!inProgress.value) {
emit('finish-install') emit('finish-install')
} }
} }
</script> </script>
<template> <template>
<ModalWrapper <ModalWrapper
ref="modpackVersionModal" ref="modpackVersionModal"
class="modpack-version-modal" class="modpack-version-modal"
header="Change modpack version" header="Change modpack version"
:on-hide="onHide" :on-hide="onHide"
> >
<div class="modal-body"> <div class="modal-body">
<div v-if="instance.linked_data" class="mod-card"> <div v-if="instance.linked_data" class="mod-card">
<div class="table"> <div class="table">
<div class="table-row with-columns table-head"> <div class="table-row with-columns table-head">
<div class="table-cell table-text download-cell" /> <div class="table-cell table-text download-cell" />
<div class="name-cell table-cell table-text">Name</div> <div class="name-cell table-cell table-text">Name</div>
<div class="table-cell table-text">Supports</div> <div class="table-cell table-text">Supports</div>
</div> </div>
<div class="scrollable"> <div class="scrollable">
<div <div
v-for="version in filteredVersions" v-for="version in filteredVersions"
:key="version.id" :key="version.id"
class="table-row with-columns selectable" class="table-row with-columns selectable"
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)" @click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
> >
<div class="table-cell table-text"> <div class="table-cell table-text">
<Button <Button
:color="version.id === installedVersion ? '' : 'primary'" :color="version.id === installedVersion ? '' : 'primary'"
icon-only icon-only
:disabled="inProgress || installing || version.id === installedVersion" :disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)" @click.stop="() => switchVersion(version.id)"
> >
<SwapIcon v-if="version.id !== installedVersion" /> <SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else /> <CheckIcon v-else />
</Button> </Button>
</div> </div>
<div class="name-cell table-cell table-text"> <div class="name-cell table-cell table-text">
<div class="version-link"> <div class="version-link">
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }} {{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }}
<div class="version-badge"> <div class="version-badge">
<div class="channel-indicator"> <div class="channel-indicator">
<Badge <Badge
:color="releaseColor(version.version_type)" :color="releaseColor(version.version_type)"
:type=" :type="
version.version_type.charAt(0).toUpperCase() + version.version_type.charAt(0).toUpperCase() +
version.version_type.slice(1) version.version_type.slice(1)
" "
/> />
</div> </div>
<div> <div>
{{ version.version_number }} {{ version.version_number }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="table-cell table-text stacked-text"> <div class="table-cell table-text stacked-text">
<span> <span>
{{ {{
version.loaders version.loaders
.map((str) => str.charAt(0).toUpperCase() + str.slice(1)) .map((str) => str.charAt(0).toUpperCase() + str.slice(1))
.join(', ') .join(', ')
}} }}
</span> </span>
<span> <span>
{{ version.game_versions.join(', ') }} {{ version.game_versions.join(', ') }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.filter-header { .filter-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.with-columns { .with-columns {
grid-template-columns: min-content 1fr 1fr; grid-template-columns: min-content 1fr 1fr;
} }
.scrollable { .scrollable {
overflow-y: auto; overflow-y: auto;
max-height: 25rem; max-height: 25rem;
} }
.card-row { .card-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
} }
.mod-card { .mod-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
overflow: hidden; overflow: hidden;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.version-link { .version-link {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
.version-badge { .version-badge {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.channel-indicator { .channel-indicator {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
} }
} }
.stacked-text { .stacked-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
align-items: flex-start; align-items: flex-start;
} }
.download-cell { .download-cell {
width: 4rem; width: 4rem;
padding: 1rem; padding: 1rem;
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-md); gap: var(--gap-md);
} }
.table { .table {
border: 1px solid var(--color-bg); border: 1px solid var(--color-bg);
} }
</style> </style>

View File

@@ -1,24 +1,24 @@
<template> <template>
<RouterLink <RouterLink
v-if="typeof to === 'string'" v-if="typeof to === 'string'"
:to="to" :to="to"
v-bind="$attrs" v-bind="$attrs"
:class="{ :class="{
'router-link-active': isPrimary && isPrimary(route), 'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route), 'subpage-active': isSubpage && isSubpage(route),
}" }"
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" 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 /> <slot />
</RouterLink> </RouterLink>
<button <button
v-else v-else
v-bind="$attrs" v-bind="$attrs"
class="button-animation border-none text-primary cursor-pointer 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="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
@click="to" @click="to"
> >
<slot /> <slot />
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -30,30 +30,30 @@ const route = useRoute()
type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean
defineProps<{ defineProps<{
to: (() => void) | string to: (() => void) | string
isPrimary?: RouteFunction isPrimary?: RouteFunction
isSubpage?: RouteFunction isSubpage?: RouteFunction
highlightOverride?: boolean highlightOverride?: boolean
}>() }>()
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.router-link-active, .router-link-active,
.subpage-active { .subpage-active {
svg { svg {
filter: drop-shadow(0 0 0.5rem black); filter: drop-shadow(0 0 0.5rem black);
} }
} }
.router-link-active { .router-link-active {
@apply text-[--color-button-text-selected] bg-[--color-button-bg-selected]; @apply text-[--color-button-text-selected] bg-[--color-button-bg-selected];
} }
.subpage-active { .subpage-active {
@apply text-contrast bg-button-bg; @apply text-contrast bg-button-bg;
} }
</style> </style>

View File

@@ -1,50 +1,50 @@
<template> <template>
<nav <nav
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold" class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
> >
<RouterLink <RouterLink
v-for="(link, index) in filteredLinks" v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown" v-show="link.shown === undefined ? true : link.shown"
:key="index" :key="index"
ref="tabLinkElements" ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href" :to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
:class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`" :class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`"
> >
<component :is="link.icon" v-if="link.icon" class="size-5" /> <component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span> <span class="text-nowrap">{{ link.label }}</span>
</RouterLink> </RouterLink>
<div <div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`" :class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
:style="{ :style="{
left: sliderLeftPx, left: sliderLeftPx,
top: sliderTopPx, top: sliderTopPx,
right: sliderRightPx, right: sliderRightPx,
bottom: sliderBottomPx, bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1, opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}" }"
aria-hidden="true" aria-hidden="true"
></div> ></div>
</nav> </nav>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
import { useRoute, RouterLink } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
interface Tab { interface Tab {
label: string label: string
href: string | RouteLocationRaw href: string | RouteLocationRaw
shown?: boolean shown?: boolean
icon?: unknown icon?: unknown
subpages?: string[] subpages?: string[]
} }
const props = defineProps<{ const props = defineProps<{
links: Tab[] links: Tab[]
query?: string query?: string
}>() }>()
const sliderLeft = ref(4) const sliderLeft = ref(4)
@@ -56,7 +56,7 @@ const oldIndex = ref(-1)
const subpageSelected = ref(false) const subpageSelected = ref(false)
const filteredLinks = computed(() => const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)), props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
) )
const sliderLeftPx = computed(() => `${sliderLeft.value}px`) const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`) const sliderTopPx = computed(() => `${sliderTop.value}px`)
@@ -64,97 +64,97 @@ const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`) const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
function pickLink() { function pickLink() {
let index = -1 let index = -1
subpageSelected.value = false subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) { for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i] const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) { if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
index = i index = i
break break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) { } else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
index = i index = i
subpageSelected.value = true subpageSelected.value = true
break break
} }
} }
activeIndex.value = index activeIndex.value = index
if (activeIndex.value !== -1) { if (activeIndex.value !== -1) {
startAnimation() startAnimation()
} else { } else {
oldIndex.value = -1 oldIndex.value = -1
sliderLeft.value = 0 sliderLeft.value = 0
sliderRight.value = 0 sliderRight.value = 0
} }
} }
const tabLinkElements = ref() const tabLinkElements = ref()
function startAnimation() { function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return if (!el || !el.offsetParent) return
const newValues = { const newValues = {
left: el.offsetLeft, left: el.offsetLeft,
top: el.offsetTop, top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth, right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight, bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
} }
if (sliderLeft.value === 4 && sliderRight.value === 4) { if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left sliderLeft.value = newValues.left
sliderRight.value = newValues.right sliderRight.value = newValues.right
sliderTop.value = newValues.top sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom sliderBottom.value = newValues.bottom
} else { } else {
const delay = 200 const delay = 200
if (newValues.left < sliderLeft.value) { if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left sliderLeft.value = newValues.left
setTimeout(() => { setTimeout(() => {
sliderRight.value = newValues.right sliderRight.value = newValues.right
}, delay) }, delay)
} else { } else {
sliderRight.value = newValues.right sliderRight.value = newValues.right
setTimeout(() => { setTimeout(() => {
sliderLeft.value = newValues.left sliderLeft.value = newValues.left
}, delay) }, delay)
} }
if (newValues.top < sliderTop.value) { if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top sliderTop.value = newValues.top
setTimeout(() => { setTimeout(() => {
sliderBottom.value = newValues.bottom sliderBottom.value = newValues.bottom
}, delay) }, delay)
} else { } else {
sliderBottom.value = newValues.bottom sliderBottom.value = newValues.bottom
setTimeout(() => { setTimeout(() => {
sliderTop.value = newValues.top sliderTop.value = newValues.top
}, delay) }, delay)
} }
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('resize', pickLink) window.addEventListener('resize', pickLink)
pickLink() pickLink()
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', pickLink) window.removeEventListener('resize', pickLink)
}) })
watch(route, () => { watch(route, () => {
pickLink() pickLink()
}) })
</script> </script>
<style scoped> <style scoped>
.navtabs-transition { .navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */ /* Delay on opacity is to hide any jankiness as the page loads */
transition: transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s, all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms; opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
} }
</style> </style>

View File

@@ -1,33 +1,33 @@
<template> <template>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div> <div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ defineProps({
progress: { progress: {
type: Number, type: Number,
required: true, required: true,
validator(value) { validator(value) {
return value >= 0 && value <= 100 return value >= 0 && value <= 100
}, },
}, },
}) })
</script> </script>
<style scoped> <style scoped>
.progress-bar { .progress-bar {
width: 100%; width: 100%;
height: 0.5rem; height: 0.5rem;
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
} }
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background-color: var(--color-brand); background-color: var(--color-brand);
transition: width 0.3s; transition: width 0.3s;
} }
</style> </style>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { Avatar, TagItem } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets' import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { formatNumber, formatCategory } from '@modrinth/utils' import { Avatar, TagItem } from '@modrinth/ui'
import { computed } from 'vue' import { formatCategory, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@@ -12,107 +12,107 @@ dayjs.extend(relativeTime)
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
project: { project: {
type: Object, type: Object,
default() { default() {
return {} return {}
}, },
}, },
}) })
const featuredCategory = computed(() => { const featuredCategory = computed(() => {
if (props.project.display_categories.includes('optimization')) { if (props.project.display_categories.includes('optimization')) {
return 'optimization' return 'optimization'
} }
return props.project.display_categories[0] ?? props.project.categories[0] return props.project.display_categories[0] ?? props.project.categories[0]
}) })
const toColor = computed(() => { const toColor = computed(() => {
let color = props.project.color let color = props.project.color
color >>>= 0 color >>>= 0
const b = color & 0xff const b = color & 0xff
const g = (color >>> 8) & 0xff const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff const r = (color >>> 16) & 0xff
return 'rgba(' + [r, g, b, 1].join(',') + ')' return 'rgba(' + [r, g, b, 1].join(',') + ')'
}) })
const toTransparent = computed(() => { const toTransparent = computed(() => {
let color = props.project.color let color = props.project.color
color >>>= 0 color >>>= 0
const b = color & 0xff const b = color & 0xff
const g = (color >>> 8) & 0xff const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff const r = (color >>> 16) & 0xff
return ( return (
'linear-gradient(rgba(' + 'linear-gradient(rgba(' +
[r, g, b, 0.03].join(',') + [r, g, b, 0.03].join(',') +
'), 65%, rgba(' + '), 65%, rgba(' +
[r, g, b, 0.3].join(',') + [r, g, b, 0.3].join(',') +
'))' '))'
) )
}) })
</script> </script>
<template> <template>
<div <div
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all" class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
@click="router.push(`/project/${project.slug}`)" @click="router.push(`/project/${project.slug}`)"
> >
<div <div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat" class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{ :style="{
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor, 'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${ 'background-image': `url(${
project.featured_gallery ?? project.featured_gallery ??
project.gallery[0] ?? project.gallery[0] ??
'https://launcher-files.modrinth.com/assets/maze-bg.png' 'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`, })`,
}" }"
> >
<div <div
class="badges-wrapper" class="badges-wrapper"
:class="{ :class="{
'no-image': !project.featured_gallery && !project.gallery[0], 'no-image': !project.featured_gallery && !project.gallery[0],
}" }"
:style="{ :style="{
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null, background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
}" }"
></div> ></div>
</div> </div>
<div class="flex flex-col justify-center gap-2 px-4 py-3"> <div class="flex flex-col justify-center gap-2 px-4 py-3">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<Avatar size="48px" :src="project.icon_url" /> <Avatar size="48px" :src="project.icon_url" />
<div class="h-full flex items-center font-bold text-contrast leading-normal"> <div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ project.title }}</span> <span class="line-clamp-2">{{ project.title }}</span>
</div> </div>
</div> </div>
<p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]"> <p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]">
{{ project.description }} {{ project.description }}
</p> </p>
<div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto"> <div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto">
<div <div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border" class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
> >
<DownloadIcon /> <DownloadIcon />
{{ formatNumber(project.downloads) }} {{ formatNumber(project.downloads) }}
</div> </div>
<div <div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border" class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
> >
<HeartIcon /> <HeartIcon />
{{ formatNumber(project.follows) }} {{ formatNumber(project.follows) }}
</div> </div>
<div class="flex items-center gap-1 pr-2"> <div class="flex items-center gap-1 pr-2">
<TagIcon /> <TagIcon />
<TagItem> <TagItem>
{{ formatCategory(featuredCategory) }} {{ formatCategory(featuredCategory) }}
</TagItem> </TagItem>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { onMounted, ref } from 'vue'
import { init_ads_window } from '@/helpers/ads.js' import { init_ads_window } from '@/helpers/ads.js'
const adsWrapper = ref(null) const adsWrapper = ref(null)
@@ -7,58 +8,58 @@ const adsWrapper = ref(null)
let devicePixelRatioWatcher = null let devicePixelRatioWatcher = null
function initDevicePixelRatioWatcher() { function initDevicePixelRatioWatcher() {
if (devicePixelRatioWatcher) { if (devicePixelRatioWatcher) {
devicePixelRatioWatcher.removeEventListener('change', updateAdPosition) devicePixelRatioWatcher.removeEventListener('change', updateAdPosition)
} }
devicePixelRatioWatcher = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`) devicePixelRatioWatcher = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
devicePixelRatioWatcher.addEventListener('change', updateAdPosition) devicePixelRatioWatcher.addEventListener('change', updateAdPosition)
} }
onMounted(() => { onMounted(() => {
updateAdPosition() updateAdPosition()
window.addEventListener('resize', updateAdPosition) window.addEventListener('resize', updateAdPosition)
initDevicePixelRatioWatcher() initDevicePixelRatioWatcher()
}) })
function updateAdPosition() { function updateAdPosition() {
if (adsWrapper.value) { if (adsWrapper.value) {
init_ads_window() init_ads_window()
initDevicePixelRatioWatcher() initDevicePixelRatioWatcher()
} }
} }
</script> </script>
<template> <template>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg"> <div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<a <a
href="https://modrinth.gg?from=app-placeholder" href="https://modrinth.gg?from=app-placeholder"
target="_blank" target="_blank"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]" class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
> >
<img <img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp" src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
alt="Host your next server with Modrinth Servers" alt="Host your next server with Modrinth Servers"
class="hidden light-image rounded-[inherit]" class="hidden light-image rounded-[inherit]"
/> />
<img <img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp" src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
alt="Host your next server with Modrinth Servers" alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]" class="dark-image rounded-[inherit]"
/> />
</a> </a>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.light, .light,
.light-mode { .light-mode {
.dark-image { .dark-image {
display: none; display: none;
} }
.light-image { .light-image {
display: block; display: block;
} }
} }
</style> </style>

View File

@@ -1,74 +1,75 @@
<script setup> <script setup>
import NavButton from '@/components/ui/NavButton.vue'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile'
import { SpinnerIcon } from '@modrinth/assets' import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui' import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const recentInstances = ref([]) const recentInstances = ref([])
const getInstances = async () => { const getInstances = async () => {
const profiles = await list().catch(handleError) const profiles = await list().catch(handleError)
recentInstances.value = profiles recentInstances.value = profiles
.sort((a, b) => { .sort((a, b) => {
const dateACreated = dayjs(a.created) const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0) const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateBCreated = dayjs(b.created) const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0) const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
if (dateA.isSame(dateB)) { if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
} }
return dateB - dateA return dateB - dateA
}) })
.slice(0, 3) .slice(0, 3)
} }
await getInstances() await getInstances()
const unlistenProfile = await profile_listener(async (event) => { const unlistenProfile = await profile_listener(async (event) => {
if (event.event !== 'synced') { if (event.event !== 'synced') {
await getInstances() await getInstances()
} }
}) })
onUnmounted(() => { onUnmounted(() => {
unlistenProfile() unlistenProfile()
}) })
</script> </script>
<template> <template>
<NavButton <NavButton
v-for="instance in recentInstances" v-for="instance in recentInstances"
:key="instance.id" :key="instance.id"
v-tooltip.right="instance.name" v-tooltip.right="instance.name"
:to="`/instance/${encodeURIComponent(instance.path)}`" :to="`/instance/${encodeURIComponent(instance.path)}`"
class="relative" class="relative"
> >
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px" size="28px"
:tint-by="instance.path" :tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`" :class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/> />
<div <div
v-if="instance.install_stage !== 'installed'" v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10" class="absolute inset-0 flex items-center justify-center z-10"
> >
<SpinnerIcon class="animate-spin w-4 h-4" /> <SpinnerIcon class="animate-spin w-4 h-4" />
</div> </div>
</NavButton> </NavButton>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-button-bg"></div> <div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,115 +1,117 @@
<template> <template>
<div class="action-groups"> <div class="action-groups">
<ButtonStyled v-if="currentLoadingBars.length > 0" color="brand" type="transparent" circular> <ButtonStyled v-if="currentLoadingBars.length > 0" color="brand" type="transparent" circular>
<button ref="infoButton" @click="toggleCard()"> <button ref="infoButton" @click="toggleCard()">
<DownloadIcon /> <DownloadIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<div v-if="offline" class="status"> <div v-if="offline" class="status">
<UnplugIcon /> <UnplugIcon />
<div class="running-text"> <div class="running-text">
<span> Offline </span> <span> Offline </span>
</div> </div>
</div> </div>
<div v-if="selectedProcess" class="status"> <div v-if="selectedProcess" class="status">
<span class="circle running" /> <span class="circle running" />
<div ref="profileButton" class="running-text"> <div ref="profileButton" class="running-text">
<router-link <router-link
class="text-primary" class="text-primary"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`" :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
> >
{{ selectedProcess.profile.name }} {{ selectedProcess.profile.name }}
</router-link> </router-link>
<div <div
v-if="currentProcesses.length > 1" v-if="currentProcesses.length > 1"
class="arrow button-base" class="arrow button-base"
:class="{ rotate: showProfiles }" :class="{ rotate: showProfiles }"
@click="toggleProfiles()" @click="toggleProfiles()"
> >
<DropdownIcon /> <DropdownIcon />
</div> </div>
</div> </div>
<Button <Button
v-tooltip="'Stop instance'" v-tooltip="'Stop instance'"
icon-only icon-only
class="icon-button stop" class="icon-button stop"
@click="stop(selectedProcess)" @click="stop(selectedProcess)"
> >
<StopCircleIcon /> <StopCircleIcon />
</Button> </Button>
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()"> <Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()">
<TerminalSquareIcon /> <TerminalSquareIcon />
</Button> </Button>
</div> </div>
<div v-else class="status"> <div v-else class="status">
<span class="circle stopped" /> <span class="circle stopped" />
<span class="running-text"> No instances running </span> <span class="running-text"> No instances running </span>
</div> </div>
</div> </div>
<transition name="download"> <transition name="download">
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card"> <Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
<div v-for="loadingBar in currentLoadingBars" :key="loadingBar.id" class="info-text"> <div v-for="loadingBar in currentLoadingBars" :key="loadingBar.id" class="info-text">
<h3 class="info-title"> <h3 class="info-title">
{{ loadingBar.title }} {{ loadingBar.title }}
</h3> </h3>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" /> <ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row"> <div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}% {{ loadingBar.message }} {{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
</div> {{ loadingBar.message }}
</div> </div>
</Card> </div>
</transition> </Card>
<transition name="download"> </transition>
<Card <transition name="download">
v-if="showProfiles === true && currentProcesses.length > 0" <Card
ref="profiles" v-if="showProfiles === true && currentProcesses.length > 0"
class="profile-card" ref="profiles"
> class="profile-card"
<Button >
v-for="process in currentProcesses" <Button
:key="process.uuid" v-for="process in currentProcesses"
class="profile-button" :key="process.uuid"
@click="selectProcess(process)" class="profile-button"
> @click="selectProcess(process)"
<div class="text"><span class="circle running" /> {{ process.profile.name }}</div> >
<Button <div class="text"><span class="circle running" /> {{ process.profile.name }}</div>
v-tooltip="'Stop instance'" <Button
icon-only v-tooltip="'Stop instance'"
class="icon-button stop" icon-only
@click.stop="stop(process)" class="icon-button stop"
> @click.stop="stop(process)"
<StopCircleIcon /> >
</Button> <StopCircleIcon />
<Button </Button>
v-tooltip="'View logs'" <Button
icon-only v-tooltip="'View logs'"
class="icon-button" icon-only
@click.stop="goToTerminal(process.profile.path)" class="icon-button"
> @click.stop="goToTerminal(process.profile.path)"
<TerminalSquareIcon /> >
</Button> <TerminalSquareIcon />
</Button> </Button>
</Card> </Button>
</transition> </Card>
</transition>
</template> </template>
<script setup> <script setup>
import {
DownloadIcon,
DropdownIcon,
StopCircleIcon,
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ui/ProgressBar.vue' import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events' import { loading_listener, process_listener } from '@/helpers/events'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process' import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { get_many } from '@/helpers/profile.js' import { get_many } from '@/helpers/profile.js'
import { progress_bars_list } from '@/helpers/state.js' import { progress_bars_list } from '@/helpers/state.js'
import {
DownloadIcon,
DropdownIcon,
StopCircleIcon,
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -126,347 +128,347 @@ const currentProcesses = ref([])
const selectedProcess = ref() const selectedProcess = ref()
const refresh = async () => { const refresh = async () => {
const processes = await getRunningProcesses().catch(handleError) const processes = await getRunningProcesses().catch(handleError)
const profiles = await get_many(processes.map((x) => x.profile_path)).catch(handleError) const profiles = await get_many(processes.map((x) => x.profile_path)).catch(handleError)
currentProcesses.value = processes.map((x) => ({ currentProcesses.value = processes.map((x) => ({
profile: profiles.find((prof) => x.profile_path === prof.path), profile: profiles.find((prof) => x.profile_path === prof.path),
...x, ...x,
})) }))
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) { if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0] selectedProcess.value = currentProcesses.value[0]
} }
} }
await refresh() await refresh()
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
offline.value = true offline.value = true
}) })
window.addEventListener('online', () => { window.addEventListener('online', () => {
offline.value = false offline.value = false
}) })
const unlistenProcess = await process_listener(async () => { const unlistenProcess = await process_listener(async () => {
await refresh() await refresh()
}) })
const stop = async (process) => { const stop = async (process) => {
try { try {
await killProcess(process.uuid).catch(handleError) await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
loader: process.profile.loader, loader: process.profile.loader,
game_version: process.profile.game_version, game_version: process.profile.game_version,
source: 'AppBar', source: 'AppBar',
}) })
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
await refresh() await refresh()
} }
const goToTerminal = (path) => { const goToTerminal = (path) => {
router.push(`/instance/${encodeURIComponent(path ?? selectedProcess.value.profile.path)}/logs`) router.push(`/instance/${encodeURIComponent(path ?? selectedProcess.value.profile.path)}/logs`)
} }
const currentLoadingBars = ref([]) const currentLoadingBars = ref([])
const refreshInfo = async () => { const refreshInfo = async () => {
const currentLoadingBarCount = currentLoadingBars.value.length const currentLoadingBarCount = currentLoadingBars.value.length
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError)).map( currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError)).map(
(x) => { (x) => {
if (x.bar_type.type === 'java_download') { if (x.bar_type.type === 'java_download') {
x.title = 'Downloading Java ' + x.bar_type.version x.title = 'Downloading Java ' + x.bar_type.version
} }
if (x.bar_type.profile_path) { if (x.bar_type.profile_path) {
x.title = x.bar_type.profile_path x.title = x.bar_type.profile_path
} }
if (x.bar_type.pack_name) { if (x.bar_type.pack_name) {
x.title = x.bar_type.pack_name x.title = x.bar_type.pack_name
} }
return x return x
}, },
) )
currentLoadingBars.value.sort((a, b) => { currentLoadingBars.value.sort((a, b) => {
if (a.loading_bar_uuid < b.loading_bar_uuid) { if (a.loading_bar_uuid < b.loading_bar_uuid) {
return -1 return -1
} }
if (a.loading_bar_uuid > b.loading_bar_uuid) { if (a.loading_bar_uuid > b.loading_bar_uuid) {
return 1 return 1
} }
return 0 return 0
}) })
if (currentLoadingBars.value.length === 0) { if (currentLoadingBars.value.length === 0) {
showCard.value = false showCard.value = false
} else if (currentLoadingBarCount < currentLoadingBars.value.length) { } else if (currentLoadingBarCount < currentLoadingBars.value.length) {
showCard.value = true showCard.value = true
} }
} }
await refreshInfo() await refreshInfo()
const unlistenLoading = await loading_listener(async () => { const unlistenLoading = await loading_listener(async () => {
await refreshInfo() await refreshInfo()
}) })
const selectProcess = (process) => { const selectProcess = (process) => {
selectedProcess.value = process selectedProcess.value = process
showProfiles.value = false showProfiles.value = false
} }
const handleClickOutsideCard = (event) => { const handleClickOutsideCard = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY) const elements = document.elementsFromPoint(event.clientX, event.clientY)
if ( if (
card.value && card.value &&
card.value.$el !== event.target && card.value.$el !== event.target &&
!elements.includes(card.value.$el) && !elements.includes(card.value.$el) &&
infoButton.value && infoButton.value &&
!infoButton.value.contains(event.target) !infoButton.value.contains(event.target)
) { ) {
showCard.value = false showCard.value = false
} }
} }
const handleClickOutsideProfile = (event) => { const handleClickOutsideProfile = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY) const elements = document.elementsFromPoint(event.clientX, event.clientY)
if ( if (
profiles.value && profiles.value &&
profiles.value.$el !== event.target && profiles.value.$el !== event.target &&
!elements.includes(profiles.value.$el) && !elements.includes(profiles.value.$el) &&
!profileButton.value.contains(event.target) !profileButton.value.contains(event.target)
) { ) {
showProfiles.value = false showProfiles.value = false
} }
} }
const toggleCard = async () => { const toggleCard = async () => {
showCard.value = !showCard.value showCard.value = !showCard.value
showProfiles.value = false showProfiles.value = false
await refreshInfo() await refreshInfo()
} }
const toggleProfiles = async () => { const toggleProfiles = async () => {
if (currentProcesses.value.length === 1) return if (currentProcesses.value.length === 1) return
showProfiles.value = !showProfiles.value showProfiles.value = !showProfiles.value
showCard.value = false showCard.value = false
} }
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleClickOutsideCard) window.addEventListener('click', handleClickOutsideCard)
window.addEventListener('click', handleClickOutsideProfile) window.addEventListener('click', handleClickOutsideProfile)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutsideCard) window.removeEventListener('click', handleClickOutsideCard)
window.removeEventListener('click', handleClickOutsideProfile) window.removeEventListener('click', handleClickOutsideProfile)
unlistenProcess() unlistenProcess()
unlistenLoading() unlistenLoading()
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.action-groups { .action-groups {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: var(--gap-md); gap: var(--gap-md);
} }
.arrow { .arrow {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
display: flex; display: flex;
align-items: center; align-items: center;
&.rotate { &.rotate {
transform: rotate(180deg); transform: rotate(180deg);
} }
} }
.status { .status {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
padding: var(--gap-sm) var(--gap-lg); padding: var(--gap-sm) var(--gap-lg);
} }
.running-text { .running-text {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--gap-xs); gap: var(--gap-xs);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
-webkit-user-select: none; /* Safari */ -webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */ -ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; user-select: none;
&.clickable:hover { &.clickable:hover {
cursor: pointer; cursor: pointer;
} }
} }
.circle { .circle {
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
margin-right: 0.25rem; margin-right: 0.25rem;
&.running { &.running {
background-color: var(--color-brand); background-color: var(--color-brand);
} }
&.stopped { &.stopped {
background-color: var(--color-base); background-color: var(--color-base);
} }
} }
.icon-button { .icon-button {
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
box-shadow: none; box-shadow: none;
width: 1.25rem !important; width: 1.25rem !important;
height: 1.25rem !important; height: 1.25rem !important;
svg { svg {
min-width: 1.25rem; min-width: 1.25rem;
} }
&.stop { &.stop {
color: var(--color-red); color: var(--color-red);
} }
} }
.info-card { .info-card {
position: absolute; position: absolute;
top: 3.5rem; top: 3.5rem;
right: 0.5rem; right: 0.5rem;
z-index: 9; z-index: 9;
width: 20rem; width: 20rem;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised); box-shadow: var(--shadow-raised);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
overflow: auto; overflow: auto;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
&.hidden { &.hidden {
transform: translateY(-100%); transform: translateY(-100%);
} }
} }
.loading-option { .loading-option {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
:hover { :hover {
background-color: var(--color-raised-bg-hover); background-color: var(--color-raised-bg-hover);
} }
} }
.loading-text { .loading-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0; margin: 0;
padding: 0; padding: 0;
.row { .row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
} }
.loading-icon { .loading-icon {
width: 2.25rem; width: 2.25rem;
height: 2.25rem; height: 2.25rem;
display: block; display: block;
:deep(svg) { :deep(svg) {
left: 1rem; left: 1rem;
width: 2.25rem; width: 2.25rem;
height: 2.25rem; height: 2.25rem;
} }
} }
.download-enter-active, .download-enter-active,
.download-leave-active { .download-leave-active {
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.download-enter-from, .download-enter-from,
.download-leave-to { .download-leave-to {
opacity: 0; opacity: 0;
} }
.progress-bar { .progress-bar {
width: 100%; width: 100%;
} }
.info-text { .info-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.info-title { .info-title {
margin: 0; margin: 0;
} }
.profile-button { .profile-button {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
width: 100%; width: 100%;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: none; box-shadow: none;
.text { .text {
margin-right: auto; margin-right: auto;
} }
} }
.profile-card { .profile-card {
position: absolute; position: absolute;
top: 3.5rem; top: 3.5rem;
right: 0.5rem; right: 0.5rem;
z-index: 9; z-index: 9;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised); box-shadow: var(--shadow-raised);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: auto;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
padding: var(--gap-md); padding: var(--gap-md);
&.hidden { &.hidden {
transform: translateY(-100%); transform: translateY(-100%);
} }
} }
.link { .link {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
} }
</style> </style>

View File

@@ -1,159 +1,160 @@
<template> <template>
<div <div
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all" class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
@click=" @click="
() => { () => {
emit('open') emit('open')
$router.push({ $router.push({
path: `/project/${project.project_id ?? project.id}`, path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined }, query: { i: props.instance ? props.instance.path : undefined },
}) })
} }
" "
> >
<div class="icon w-[96px] h-[96px] relative"> <div class="icon w-[96px] h-[96px] relative">
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" /> <Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
</div> </div>
<div class="flex flex-col gap-2 overflow-hidden"> <div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis"> <div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none"> <span class="text-lg font-extrabold text-contrast m-0 leading-none">
{{ project.title }} {{ project.title }}
</span> </span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span> <span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div> </div>
<div class="m-0 line-clamp-2"> <div class="m-0 line-clamp-2">
{{ project.description }} {{ project.description }}
</div> </div>
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap"> <div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" /> <TagsIcon class="h-4 w-4 shrink-0" />
<div <div
v-if="project.project_type === 'mod' || project.project_type === 'modpack'" v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full" class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
> >
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'"> <template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
Client or server Client or server
</template> </template>
<template <template
v-else-if=" v-else-if="
(project.client_side === 'optional' || project.client_side === 'required') && (project.client_side === 'optional' || project.client_side === 'required') &&
(project.server_side === 'optional' || project.server_side === 'unsupported') (project.server_side === 'optional' || project.server_side === 'unsupported')
" "
> >
Client Client
</template> </template>
<template <template
v-else-if=" v-else-if="
(project.server_side === 'optional' || project.server_side === 'required') && (project.server_side === 'optional' || project.server_side === 'required') &&
(project.client_side === 'optional' || project.client_side === 'unsupported') (project.client_side === 'optional' || project.client_side === 'unsupported')
" "
> >
Server Server
</template> </template>
<template <template
v-else-if=" v-else-if="
project.client_side === 'unsupported' && project.server_side === 'unsupported' project.client_side === 'unsupported' && project.server_side === 'unsupported'
" "
> >
Unsupported Unsupported
</template> </template>
<template <template
v-else-if="project.client_side === 'required' && project.server_side === 'required'" v-else-if="project.client_side === 'required' && project.server_side === 'required'"
> >
Client and server Client and server
</template> </template>
</div> </div>
<div <div
v-for="tag in categories" v-for="tag in categories"
:key="tag" :key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full" class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
> >
{{ formatCategory(tag.name) }} {{ formatCategory(tag.name) }}
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto"> <div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" /> <DownloadIcon class="shrink-0" />
<span> <span>
{{ formatNumber(project.downloads) }} {{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span> <span class="text-secondary">downloads</span>
</span> </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<HeartIcon class="shrink-0" /> <HeartIcon class="shrink-0" />
<span> <span>
{{ formatNumber(project.follows ?? project.followers) }} {{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span> <span class="text-secondary">followers</span>
</span> </span>
</div> </div>
<div class="mt-auto relative"> <div class="mt-auto relative">
<div class="absolute bottom-0 right-0 w-fit"> <div class="absolute bottom-0 right-0 w-fit">
<ButtonStyled color="brand" type="outlined"> <ButtonStyled color="brand" type="outlined">
<button <button
:disabled="installed || installing" :disabled="installed || installing"
class="shrink-0 no-wrap" class="shrink-0 no-wrap"
@click.stop="install()" @click.stop="install()"
> >
<template v-if="!installed"> <template v-if="!installed">
<DownloadIcon v-if="modpack || instance" /> <DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else /> <PlusIcon v-else />
</template> </template>
<CheckIcon v-else /> <CheckIcon v-else />
{{ {{
installing installing
? 'Installing' ? 'Installing'
: installed : installed
? 'Installed' ? 'Installed'
: modpack || instance : modpack || instance
? 'Install' ? 'Install'
: 'Add to an instance' : 'Add to an instance'
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { TagsIcon, DownloadIcon, HeartIcon, PlusIcon, CheckIcon } from '@modrinth/assets' import { CheckIcon, DownloadIcon, HeartIcon, PlusIcon, TagsIcon } from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui' import { Avatar, ButtonStyled } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils' import { formatCategory, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue' import { computed, ref } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
backgroundImage: { backgroundImage: {
type: String, type: String,
default: null, default: null,
}, },
project: { project: {
type: Object, type: Object,
required: true, required: true,
}, },
categories: { categories: {
type: Array, type: Array,
required: true, required: true,
}, },
instance: { instance: {
type: Object, type: Object,
default: null, default: null,
}, },
featured: { featured: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
installed: { installed: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}) })
const emit = defineEmits(['open', 'install']) const emit = defineEmits(['open', 'install'])
@@ -161,20 +162,20 @@ const emit = defineEmits(['open', 'install'])
const installing = ref(false) const installing = ref(false)
async function install() { async function install() {
installing.value = true installing.value = true
await installVersion( await installVersion(
props.project.project_id ?? props.project.id, props.project.project_id ?? props.project.id,
null, null,
props.instance ? props.instance.path : null, props.instance ? props.instance.path : null,
'SearchCard', 'SearchCard',
() => { () => {
installing.value = false installing.value = false
emit('install', props.project.project_id ?? props.project.id) emit('install', props.project.project_id ?? props.project.id)
}, },
(profile) => { (profile) => {
router.push(`/instance/${profile}`) router.push(`/instance/${profile}`)
}, },
) )
} }
const modpack = computed(() => props.project.project_type === 'modpack') const modpack = computed(() => props.project.project_type === 'modpack')

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,12 @@
<script setup> <script setup>
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue' import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js' import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js' import { get_categories } from '@/helpers/tags.js'
import { install as installVersion } from '@/store/install.js' import { install as installVersion } from '@/store/install.js'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -16,86 +17,86 @@ const categories = ref(null)
const installing = ref(false) const installing = ref(false)
defineExpose({ defineExpose({
async show(event) { async show(event) {
if (event.event === 'InstallVersion') { if (event.event === 'InstallVersion') {
version.value = await get_version(event.id, 'must_revalidate').catch(handleError) version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project(version.value.project_id, 'must_revalidate').catch( project.value = await get_project(version.value.project_id, 'must_revalidate').catch(
handleError, handleError,
) )
} else { } else {
project.value = await get_project(event.id, 'must_revalidate').catch(handleError) project.value = await get_project(event.id, 'must_revalidate').catch(handleError)
version.value = await get_version( version.value = await get_version(
project.value.versions[project.value.versions.length - 1], project.value.versions[project.value.versions.length - 1],
'must_revalidate', 'must_revalidate',
).catch(handleError) ).catch(handleError)
} }
categories.value = (await get_categories().catch(handleError)).filter( categories.value = (await get_categories().catch(handleError)).filter(
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod', (cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
) )
confirmModal.value.show() confirmModal.value.show()
}, },
}) })
async function install() { async function install() {
confirmModal.value.hide() confirmModal.value.hide()
await installVersion(project.value.id, version.value.id, null, 'URLConfirmModal') await installVersion(project.value.id, version.value.id, null, 'URLConfirmModal')
} }
</script> </script>
<template> <template>
<ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`"> <ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`">
<div class="modal-body"> <div class="modal-body">
<SearchCard <SearchCard
:project="project" :project="project"
class="project-card" class="project-card"
:categories="categories" :categories="categories"
@open="confirmModal.hide()" @open="confirmModal.hide()"
/> />
<div class="button-row"> <div class="button-row">
<div class="markdown-body"> <div class="markdown-body">
<p> <p>
Installing <code>{{ version.id }}</code> from Modrinth Installing <code>{{ version.id }}</code> from Modrinth
</p> </p>
</div> </div>
<div class="button-group"> <div class="button-group">
<Button :loading="installing" color="primary" @click="install">Install</Button> <Button :loading="installing" color="primary" @click="install">Install</Button>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--gap-md); gap: var(--gap-md);
} }
.button-row { .button-row {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--gap-md); gap: var(--gap-md);
} }
.button-group { .button-group {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.project-card { .project-card {
background-color: var(--color-bg); background-color: var(--color-bg);
width: 100%; width: 100%;
:deep(.badge) { :deep(.badge) {
border: 1px solid var(--color-raised-bg); border: 1px solid var(--color-raised-bg);
background-color: var(--color-accent-contrast); background-color: var(--color-accent-contrast);
} }
} }
</style> </style>

View File

@@ -1,34 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events'
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
import { import {
MailIcon, MailIcon,
MoreVerticalIcon, MoreVerticalIcon,
SettingsIcon, SettingsIcon,
TrashIcon, TrashIcon,
UserPlusIcon, UserPlusIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
injectNotificationManager, injectNotificationManager,
OverflowMenu, OverflowMenu,
useRelativeTime, useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events'
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps<{ const props = defineProps<{
credentials: unknown | null credentials: unknown | null
signIn: () => void signIn: () => void
}>() }>()
const userCredentials = computed(() => props.credentials) const userCredentials = computed(() => props.credentials)
@@ -40,328 +41,328 @@ const friendInvitesModal = ref()
const username = ref('') const username = ref('')
const addFriendModal = ref() const addFriendModal = ref()
async function addFriendFromModal() { async function addFriendFromModal() {
addFriendModal.value.hide() addFriendModal.value.hide()
await add_friend(username.value).catch(handleError) await add_friend(username.value).catch(handleError)
username.value = '' username.value = ''
await loadFriends() await loadFriends()
} }
const friendOptions = ref() const friendOptions = ref()
async function handleFriendOptions(args) { async function handleFriendOptions(args) {
switch (args.option) { switch (args.option) {
case 'remove-friend': case 'remove-friend':
await removeFriend(args.item) await removeFriend(args.item)
break break
} }
} }
async function addFriend(friend: Friend) { async function addFriend(friend: Friend) {
await add_friend( await add_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError) ).catch(handleError)
await loadFriends() await loadFriends()
} }
async function removeFriend(friend: Friend) { async function removeFriend(friend: Friend) {
await remove_friend( await remove_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError) ).catch(handleError)
await loadFriends() await loadFriends()
} }
type Friend = { type Friend = {
id: string id: string
friend_id: string | null friend_id: string | null
status: string | null status: string | null
last_updated: Dayjs | null last_updated: Dayjs | null
created: Dayjs created: Dayjs
username: string username: string
accepted: boolean accepted: boolean
online: boolean online: boolean
avatar: string avatar: string
} }
const userFriends = ref<Friend[]>([]) const userFriends = ref<Friend[]>([])
const acceptedFriends = computed(() => const acceptedFriends = computed(() =>
userFriends.value userFriends.value
.filter((x) => x.accepted) .filter((x) => x.accepted)
.toSorted((a, b) => { .toSorted((a, b) => {
if (a.last_updated === null && b.last_updated === null) { if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting return 0 // Both are null, equal in sorting
} }
if (a.last_updated === null) { if (a.last_updated === null) {
return 1 // `a` is null, move it after `b` return 1 // `a` is null, move it after `b`
} }
if (b.last_updated === null) { if (b.last_updated === null) {
return -1 // `b` is null, move it after `a` return -1 // `b` is null, move it after `a`
} }
// Both are non-null, sort by date // Both are non-null, sort by date
return b.last_updated.diff(a.last_updated) return b.last_updated.diff(a.last_updated)
}), }),
) )
const pendingFriends = computed(() => const pendingFriends = computed(() =>
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)), userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
) )
const loading = ref(true) const loading = ref(true)
async function loadFriends(timeout = false) { async function loadFriends(timeout = false) {
loading.value = timeout loading.value = timeout
try { try {
const friendsList = await friends() const friendsList = await friends()
if (friendsList.length === 0) { if (friendsList.length === 0) {
userFriends.value = [] userFriends.value = []
} else { } else {
const friendStatuses = await friend_statuses() const friendStatuses = await friend_statuses()
const users = await get_user_many( const users = await get_user_many(
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)), friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
) )
userFriends.value = friendsList.map((friend) => { userFriends.value = friendsList.map((friend) => {
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id) const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find( const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id, (x) => x.user_id === friend.id || x.user_id === friend.friend_id,
) )
return { return {
id: friend.id, id: friend.id,
friend_id: friend.friend_id, friend_id: friend.friend_id,
status: status?.profile_name, status: status?.profile_name,
last_updated: status && status.last_update ? dayjs(status.last_update) : null, last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created), created: dayjs(friend.created),
avatar: user?.avatar_url, avatar: user?.avatar_url,
username: user?.username, username: user?.username,
online: !!status, online: !!status,
accepted: friend.accepted, accepted: friend.accepted,
} }
}) })
} }
loading.value = false loading.value = false
} catch (e) { } catch (e) {
console.error('Error loading friends', e) console.error('Error loading friends', e)
if (timeout) { if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000) setTimeout(() => loadFriends(), 15 * 1000)
} }
} }
} }
watch( watch(
userCredentials, userCredentials,
() => { () => {
if (userCredentials.value === undefined) { if (userCredentials.value === undefined) {
userFriends.value = [] userFriends.value = []
} else if (userCredentials.value === null) { } else if (userCredentials.value === null) {
userFriends.value = [] userFriends.value = []
loading.value = false loading.value = false
} else { } else {
loadFriends(true) loadFriends(true)
} }
}, },
{ immediate: true }, { immediate: true },
) )
const unlisten = await friend_listener(() => loadFriends()) const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => { onUnmounted(() => {
unlisten() unlisten()
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="manageFriendsModal" header="Manage friends"> <ModalWrapper ref="manageFriendsModal" header="Manage friends">
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p> <p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
<div v-else class="flex flex-col gap-4 min-w-[20rem]"> <div v-else class="flex flex-col gap-4 min-w-[20rem]">
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" /> <input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
<div <div
v-for="friend in acceptedFriends.filter( v-for="friend in acceptedFriends.filter(
(x) => !search || x.username.toLowerCase().includes(search), (x) => !search || x.username.toLowerCase().includes(search),
)" )"
:key="friend.username" :key="friend.username"
class="flex gap-2 items-center" class="flex gap-2 items-center"
> >
<div class="relative"> <div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> <Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span <span
v-if="friend.online" v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full" class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/> />
</div> </div>
<div>{{ friend.username }}</div> <div>{{ friend.username }}</div>
<div class="ml-auto"> <div class="ml-auto">
<ButtonStyled> <ButtonStyled>
<button @click="removeFriend(friend)"> <button @click="removeFriend(friend)">
<XIcon /> <XIcon />
Remove Remove
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="friendInvitesModal" header="View friend requests"> <ModalWrapper ref="friendInvitesModal" header="View friend requests">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p> <p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4"> <div v-else class="flex flex-col gap-4">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2"> <div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> <Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div> <div>
<p class="m-0"> <p class="m-0">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request <span class="font-bold">{{ friend.username }}</span> sent you a friend request
</template> </template>
<template v-else> <template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template> </template>
</p> </p>
<p class="m-0 text-sm text-secondary"> <p class="m-0 text-sm text-secondary">
{{ formatRelativeTime(friend.created.toISOString()) }} {{ formatRelativeTime(friend.created.toISOString()) }}
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials.user_id">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button @click="addFriend(friend)"> <button @click="addFriend(friend)">
<UserPlusIcon /> <UserPlusIcon />
Accept Accept
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="removeFriend(friend)"> <button @click="removeFriend(friend)">
<XIcon /> <XIcon />
Ignore Ignore
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
<template v-else> <template v-else>
<ButtonStyled> <ButtonStyled>
<button @click="removeFriend(friend)"> <button @click="removeFriend(friend)">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="addFriendModal" header="Add a friend"> <ModalWrapper ref="addFriendModal" header="Add a friend">
<div class="mb-4"> <div class="mb-4">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p> <p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
<input v-model="username" class="mt-2 w-full" type="text" placeholder="Enter username..." /> <input v-model="username" class="mt-2 w-full" type="text" placeholder="Enter username..." />
</div> </div>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal"> <button :disabled="username.length === 0" @click="addFriendFromModal">
<UserPlusIcon /> <UserPlusIcon />
Add friend Add friend
</button> </button>
</ButtonStyled> </ButtonStyled>
</ModalWrapper> </ModalWrapper>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3> <h3 class="text-lg m-0">Friends</h3>
<ButtonStyled v-if="userCredentials" type="transparent" circular> <ButtonStyled v-if="userCredentials" type="transparent" circular>
<OverflowMenu <OverflowMenu
:options="[ :options="[
{ {
id: 'add-friend', id: 'add-friend',
action: () => addFriendModal.show(), action: () => addFriendModal.show(),
}, },
{ {
id: 'manage-friends', id: 'manage-friends',
action: () => manageFriendsModal.show(), action: () => manageFriendsModal.show(),
shown: acceptedFriends.length > 0, shown: acceptedFriends.length > 0,
}, },
{ {
id: 'view-requests', id: 'view-requests',
action: () => friendInvitesModal.show(), action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0, shown: pendingFriends.length > 0,
}, },
]" ]"
aria-label="More options" aria-label="More options"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #add-friend> <template #add-friend>
<UserPlusIcon aria-hidden="true" /> <UserPlusIcon aria-hidden="true" />
Add friend Add friend
</template> </template>
<template #manage-friends> <template #manage-friends>
<SettingsIcon aria-hidden="true" /> <SettingsIcon aria-hidden="true" />
Manage friends Manage friends
<div <div
v-if="acceptedFriends.length > 0" v-if="acceptedFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center" class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
> >
{{ acceptedFriends.length }} {{ acceptedFriends.length }}
</div> </div>
</template> </template>
<template #view-requests> <template #view-requests>
<MailIcon aria-hidden="true" /> <MailIcon aria-hidden="true" />
View friend requests View friend requests
<div <div
v-if="pendingFriends.length > 0" v-if="pendingFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center" class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
> >
{{ pendingFriends.length }} {{ pendingFriends.length }}
</div> </div>
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="flex flex-col gap-2 mt-2"> <div class="flex flex-col gap-2 mt-2">
<template v-if="loading"> <template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse"> <div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div> <div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div> <div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
<div class="h-2.5 bg-button-bg rounded-full w-3/4"></div> <div class="h-2.5 bg-button-bg rounded-full w-3/4"></div>
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="acceptedFriends.length === 0"> <template v-else-if="acceptedFriends.length === 0">
<div class="text-sm"> <div class="text-sm">
<div v-if="!userCredentials"> <div v-if="!userCredentials">
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends! <span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
</div> </div>
<div v-else> <div v-else>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span> <span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
to share what you're playing! to share what you're playing!
</div> </div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions"> <ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #remove-friend> <TrashIcon /> Remove friend </template> <template #remove-friend> <TrashIcon /> Remove friend </template>
</ContextMenu> </ContextMenu>
<div <div
v-for="friend in acceptedFriends.slice(0, 5)" v-for="friend in acceptedFriends.slice(0, 5)"
:key="friend.username" :key="friend.username"
class="flex gap-2 items-center" class="flex gap-2 items-center"
:class="{ grayscale: !friend.online }" :class="{ grayscale: !friend.online }"
@contextmenu.prevent.stop=" @contextmenu.prevent.stop="
(event) => (event) =>
friendOptions.showMenu(event, friend, [ friendOptions.showMenu(event, friend, [
{ {
name: 'remove-friend', name: 'remove-friend',
color: 'danger', color: 'danger',
}, },
]) ])
" "
> >
<div class="relative"> <div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> <Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span <span
v-if="friend.online" v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full" class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/> />
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }"> <span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
{{ friend.username }} {{ friend.username }}
</span> </span>
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span> <span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
</template> </template>

View File

@@ -1,70 +1,71 @@
<template> <template>
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall"> <ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
<div class="modal-body"> <div class="modal-body">
<p> <p>
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
you're trying to install it on. Are you sure you want to continue? Dependencies will not be you're trying to install it on. Are you sure you want to continue? Dependencies will not be
installed. installed.
</p> </p>
<table> <table>
<thead> <thead>
<tr class="header"> <tr class="header">
<th>{{ instance?.name }}</th> <th>{{ instance?.name }}</th>
<th>{{ project.title }}</th> <th>{{ project.title }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr class="content"> <tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td> <td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td> <td>
<multiselect <multiselect
v-if="versions?.length > 1" v-if="versions?.length > 1"
v-model="selectedVersion" v-model="selectedVersion"
:options="versions" :options="versions"
:searchable="true" :searchable="true"
placeholder="Select version" placeholder="Select version"
open-direction="top" open-direction="top"
:show-labels="false" :show-labels="false"
:custom-label=" :custom-label="
(version) => (version) =>
`${version?.name} (${version?.loaders `${version?.name} (${version?.loaders
.map((name) => formatCategory(name)) .map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})` .join(', ')} - ${version?.game_versions.join(', ')})`
" "
:max-height="150" :max-height="150"
/> />
<span v-else> <span v-else>
<span> <span>
{{ selectedVersion?.name }} ({{ {{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ') selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}} }}
- {{ selectedVersion?.game_versions.join(', ') }}) - {{ selectedVersion?.game_versions.join(', ') }})
</span> </span>
</span> </span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="button-group"> <div class="button-group">
<Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button> <Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()"> <Button color="primary" :disabled="installing" @click="install()">
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }} <DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
</Button> </Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { DownloadIcon, XIcon } from '@modrinth/assets' import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils' import { formatCategory } from '@modrinth/utils'
import { ref } from 'vue' import { ref } from 'vue'
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const instance = ref(null) const instance = ref(null)
@@ -77,91 +78,91 @@ const installing = ref(false)
const onInstall = ref(() => {}) const onInstall = ref(() => {})
defineExpose({ defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => { show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal instance.value = instanceVal
versions.value = projectVersions versions.value = projectVersions
selectedVersion.value = selected ?? projectVersions[0] selectedVersion.value = selected ?? projectVersions[0]
project.value = projectVal project.value = projectVal
onInstall.value = callback onInstall.value = callback
installing.value = false installing.value = false
incompatibleModal.value.show() incompatibleModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' }) trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
}, },
}) })
const install = async () => { const install = async () => {
installing.value = true installing.value = true
await installMod(instance.value.path, selectedVersion.value.id).catch(handleError) await installMod(instance.value.path, selectedVersion.value.id).catch(handleError)
installing.value = false installing.value = false
onInstall.value(selectedVersion.value.id) onInstall.value(selectedVersion.value.id)
incompatibleModal.value.hide() incompatibleModal.value.hide()
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: instance.value.loader, loader: instance.value.loader,
game_version: instance.value.game_version, game_version: instance.value.game_version,
id: project.value, id: project.value,
version_id: selectedVersion.value.id, version_id: selectedVersion.value.id,
project_type: project.value.project_type, project_type: project.value.project_type,
title: project.value.title, title: project.value.title,
source: 'ProjectIncompatibilityWarningModal', source: 'ProjectIncompatibilityWarningModal',
}) })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.data { .data {
text-transform: capitalize; text-transform: capitalize;
} }
table { table {
width: 100%; width: 100%;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border-collapse: collapse; border-collapse: collapse;
box-shadow: 0 0 0 1px var(--color-button-bg); box-shadow: 0 0 0 1px var(--color-button-bg);
} }
th { th {
text-align: left; text-align: left;
padding: 1rem; padding: 1rem;
background-color: var(--color-bg); background-color: var(--color-bg);
overflow: hidden; overflow: hidden;
border-bottom: 1px solid var(--color-button-bg); border-bottom: 1px solid var(--color-button-bg);
} }
th:first-child { th:first-child {
border-top-left-radius: var(--radius-lg); border-top-left-radius: var(--radius-lg);
border-right: 1px solid var(--color-button-bg); border-right: 1px solid var(--color-button-bg);
} }
th:last-child { th:last-child {
border-top-right-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg);
} }
td { td {
padding: 1rem; padding: 1rem;
} }
td:first-child { td:first-child {
border-right: 1px solid var(--color-button-bg); border-right: 1px solid var(--color-button-bg);
} }
.button-group { .button-group {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 1rem; gap: 1rem;
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
:deep(.animated-dropdown .options) { :deep(.animated-dropdown .options) {
max-height: 13.375rem; max-height: 13.375rem;
} }
} }
</style> </style>

View File

@@ -1,11 +1,12 @@
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { DownloadIcon, XIcon } from '@modrinth/assets' import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const versionId = ref() const versionId = ref()
@@ -17,60 +18,60 @@ const onInstall = ref(() => {})
const onCreateInstance = ref(() => {}) const onCreateInstance = ref(() => {})
defineExpose({ defineExpose({
show: (projectVal, versionIdVal, callback, createInstanceCallback) => { show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
project.value = projectVal project.value = projectVal
versionId.value = versionIdVal versionId.value = versionIdVal
installing.value = false installing.value = false
confirmModal.value.show() confirmModal.value.show()
onInstall.value = callback onInstall.value = callback
onCreateInstance.value = createInstanceCallback onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart') trackEvent('PackInstallStart')
}, },
}) })
async function install() { async function install() {
installing.value = true installing.value = true
confirmModal.value.hide() confirmModal.value.hide()
await pack_install( await pack_install(
project.value.id, project.value.id,
versionId.value, versionId.value,
project.value.title, project.value.title,
project.value.icon_url, project.value.icon_url,
onCreateInstance.value, onCreateInstance.value,
).catch(handleError) ).catch(handleError)
trackEvent('PackInstall', { trackEvent('PackInstall', {
id: project.value.id, id: project.value.id,
version_id: versionId.value, version_id: versionId.value,
title: project.value.title, title: project.value.title,
source: 'ConfirmModal', source: 'ConfirmModal',
}) })
onInstall.value(versionId.value) onInstall.value(versionId.value)
installing.value = false installing.value = false
} }
</script> </script>
<template> <template>
<ModalWrapper ref="confirmModal" header="Are you sure?" :on-hide="onInstall"> <ModalWrapper ref="confirmModal" header="Are you sure?" :on-hide="onInstall">
<div class="modal-body"> <div class="modal-body">
<p>You already have this modpack installed. Are you sure you want to install it again?</p> <p>You already have this modpack installed. Are you sure you want to install it again?</p>
<div class="input-group push-right"> <div class="input-group push-right">
<Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button> <Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()" <Button color="primary" :disabled="installing" @click="install()"
><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button ><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button
> >
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
</style> </style>

View File

@@ -1,21 +1,11 @@
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { import {
check_installed, CheckIcon,
create, DownloadIcon,
get, PlusIcon,
add_project_from_version as installMod, RightArrowIcon,
list, UploadIcon,
} from '@/helpers/profile' XIcon,
import { installVersionDependencies } from '@/store/install.js'
import {
CheckIcon,
DownloadIcon,
PlusIcon,
RightArrowIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui' import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
@@ -23,6 +13,17 @@ import { open } from '@tauri-apps/plugin-dialog'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import {
add_project_from_version as installMod,
check_installed,
create,
get,
list,
} from '@/helpers/profile'
import { installVersionDependencies } from '@/store/install.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const router = useRouter() const router = useRouter()
@@ -43,361 +44,361 @@ const creatingInstance = ref(false)
const profiles = ref([]) const profiles = ref([])
const shownProfiles = computed(() => const shownProfiles = computed(() =>
profiles.value profiles.value
.filter((profile) => { .filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase()) return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}) })
.filter((profile) => { .filter((profile) => {
const loaders = versions.value.flatMap((v) => v.loaders) const loaders = versions.value.flatMap((v) => v.loaders)
return ( return (
versions.value.flatMap((v) => v.game_versions).includes(profile.game_version) && versions.value.flatMap((v) => v.game_versions).includes(profile.game_version) &&
(project.value.project_type === 'mod' (project.value.project_type === 'mod'
? loaders.includes(profile.loader) || loaders.includes('minecraft') ? loaders.includes(profile.loader) || loaders.includes('minecraft')
: true) : true)
) )
}), }),
) )
const onInstall = ref(() => {}) const onInstall = ref(() => {})
defineExpose({ defineExpose({
show: async (projectVal, versionsVal, callback) => { show: async (projectVal, versionsVal, callback) => {
project.value = projectVal project.value = projectVal
versions.value = versionsVal versions.value = versionsVal
searchFilter.value = '' searchFilter.value = ''
showCreation.value = false showCreation.value = false
name.value = null name.value = null
icon.value = null icon.value = null
display_icon.value = null display_icon.value = null
gameVersion.value = null gameVersion.value = null
loader.value = null loader.value = null
onInstall.value = callback onInstall.value = callback
const profilesVal = await list().catch(handleError) const profilesVal = await list().catch(handleError)
for (const profile of profilesVal) { for (const profile of profilesVal) {
profile.installing = false profile.installing = false
profile.installedMod = await check_installed(profile.path, project.value.id).catch( profile.installedMod = await check_installed(profile.path, project.value.id).catch(
handleError, handleError,
) )
} }
profiles.value = profilesVal profiles.value = profilesVal
installModal.value.show() installModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' }) trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
}, },
}) })
async function install(instance) { async function install(instance) {
instance.installing = true instance.installing = true
const version = versions.value.find((v) => { const version = versions.value.find((v) => {
return ( return (
v.game_versions.includes(instance.game_version) && v.game_versions.includes(instance.game_version) &&
(project.value.project_type === 'mod' (project.value.project_type === 'mod'
? v.loaders.includes(instance.loader) || v.loaders.includes('minecraft') ? v.loaders.includes(instance.loader) || v.loaders.includes('minecraft')
: true) : true)
) )
}) })
if (!version) { if (!version) {
instance.installing = false instance.installing = false
handleError('No compatible version found') handleError('No compatible version found')
return return
} }
await installMod(instance.path, version.id).catch(handleError) await installMod(instance.path, version.id).catch(handleError)
await installVersionDependencies(instance, version) await installVersionDependencies(instance, version)
instance.installedMod = true instance.installedMod = true
instance.installing = false instance.installing = false
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: instance.loader, loader: instance.loader,
game_version: instance.game_version, game_version: instance.game_version,
id: project.value.id, id: project.value.id,
version_id: version.id, version_id: version.id,
project_type: project.value.project_type, project_type: project.value.project_type,
title: project.value.title, title: project.value.title,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
onInstall.value(version.id) onInstall.value(version.id)
} }
const toggleCreation = () => { const toggleCreation = () => {
showCreation.value = !showCreation.value showCreation.value = !showCreation.value
name.value = null name.value = null
icon.value = null icon.value = null
display_icon.value = null display_icon.value = null
gameVersion.value = null gameVersion.value = null
loader.value = null loader.value = null
if (showCreation.value) { if (showCreation.value) {
trackEvent('InstanceCreateStart', { source: 'ProjectInstallModal' }) trackEvent('InstanceCreateStart', { source: 'ProjectInstallModal' })
} }
} }
const upload_icon = async () => { const upload_icon = async () => {
const res = await open({ const res = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: 'Image', name: 'Image',
extensions: ['png', 'jpeg'], extensions: ['png', 'jpeg'],
}, },
], ],
}) })
icon.value = res.path ?? res icon.value = res.path ?? res
if (!icon.value) return if (!icon.value) return
display_icon.value = convertFileSrc(icon.value) display_icon.value = convertFileSrc(icon.value)
} }
const reset_icon = () => { const reset_icon = () => {
icon.value = null icon.value = null
display_icon.value = null display_icon.value = null
} }
const createInstance = async () => { const createInstance = async () => {
creatingInstance.value = true creatingInstance.value = true
const loader = const loader =
versions.value[0].loaders[0] !== 'forge' && versions.value[0].loaders[0] !== 'forge' &&
versions.value[0].loaders[0] !== 'fabric' && versions.value[0].loaders[0] !== 'fabric' &&
versions.value[0].loaders[0] !== 'quilt' versions.value[0].loaders[0] !== 'quilt'
? 'vanilla' ? 'vanilla'
: versions.value[0].loaders[0] : versions.value[0].loaders[0]
const id = await create( const id = await create(
name.value, name.value,
versions.value[0].game_versions[0], versions.value[0].game_versions[0],
loader, loader,
'latest', 'latest',
icon.value, icon.value,
).catch(handleError) ).catch(handleError)
await installMod(id, versions.value[0].id).catch(handleError) await installMod(id, versions.value[0].id).catch(handleError)
await router.push(`/instance/${encodeURIComponent(id)}/`) await router.push(`/instance/${encodeURIComponent(id)}/`)
const instance = await get(id, true) const instance = await get(id, true)
await installVersionDependencies(instance, versions.value[0]) await installVersionDependencies(instance, versions.value[0])
trackEvent('InstanceCreate', { trackEvent('InstanceCreate', {
profile_name: name.value, profile_name: name.value,
game_version: versions.value[0].game_versions[0], game_version: versions.value[0].game_versions[0],
loader: loader, loader: loader,
loader_version: 'latest', loader_version: 'latest',
has_icon: !!icon.value, has_icon: !!icon.value,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: loader, loader: loader,
game_version: versions.value[0].game_versions[0], game_version: versions.value[0].game_versions[0],
id: project.value, id: project.value,
version_id: versions.value[0].id, version_id: versions.value[0].id,
project_type: project.value.project_type, project_type: project.value.project_type,
title: project.value.title, title: project.value.title,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
onInstall.value(versions.value[0].id) onInstall.value(versions.value[0].id)
if (installModal.value) installModal.value.hide() if (installModal.value) installModal.value.hide()
creatingInstance.value = false creatingInstance.value = false
} }
</script> </script>
<template> <template>
<ModalWrapper ref="installModal" header="Install project to instance" :on-hide="onInstall"> <ModalWrapper ref="installModal" header="Install project to instance" :on-hide="onInstall">
<div class="modal-body"> <div class="modal-body">
<input <input
v-model="searchFilter" v-model="searchFilter"
autocomplete="off" autocomplete="off"
type="text" type="text"
class="search" class="search"
placeholder="Search for an instance" placeholder="Search for an instance"
/> />
<div class="profiles" :class="{ 'hide-creation': !showCreation }"> <div class="profiles" :class="{ 'hide-creation': !showCreation }">
<div v-for="profile in shownProfiles" :key="profile.name" class="option"> <div v-for="profile in shownProfiles" :key="profile.name" class="option">
<router-link <router-link
class="btn btn-transparent profile-button" class="btn btn-transparent profile-button"
:to="`/instance/${encodeURIComponent(profile.path)}`" :to="`/instance/${encodeURIComponent(profile.path)}`"
@click="installModal.hide()" @click="installModal.hide()"
> >
<Avatar <Avatar
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null" :src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="profile-image" class="profile-image"
/> />
{{ profile.name }} {{ profile.name }}
</router-link> </router-link>
<div <div
v-tooltip=" v-tooltip="
profile.linked_data?.locked && !profile.installedMod profile.linked_data?.locked && !profile.installedMod
? 'Unpair or unlock an instance to add mods.' ? 'Unpair or unlock an instance to add mods.'
: '' : ''
" "
> >
<Button <Button
:disabled="profile.installedMod || profile.installing" :disabled="profile.installedMod || profile.installing"
@click="install(profile)" @click="install(profile)"
> >
<DownloadIcon v-if="!profile.installedMod && !profile.installing" /> <DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<CheckIcon v-else-if="profile.installedMod" /> <CheckIcon v-else-if="profile.installedMod" />
{{ {{
profile.installing profile.installing
? 'Installing...' ? 'Installing...'
: profile.installedMod : profile.installedMod
? 'Installed' ? 'Installed'
: 'Install' : 'Install'
}} }}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<Card v-if="showCreation" class="creation-card"> <Card v-if="showCreation" class="creation-card">
<div class="creation-container"> <div class="creation-container">
<div class="creation-icon"> <div class="creation-icon">
<Avatar size="md" class="icon" :src="display_icon" /> <Avatar size="md" class="icon" :src="display_icon" />
<div class="creation-icon__description"> <div class="creation-icon__description">
<Button @click="upload_icon()"> <Button @click="upload_icon()">
<UploadIcon /> <UploadIcon />
<span class="no-wrap"> Select icon </span> <span class="no-wrap"> Select icon </span>
</Button> </Button>
<Button :disabled="!display_icon" @click="reset_icon()"> <Button :disabled="!display_icon" @click="reset_icon()">
<XIcon /> <XIcon />
<span class="no-wrap"> Remove icon </span> <span class="no-wrap"> Remove icon </span>
</Button> </Button>
</div> </div>
</div> </div>
<div class="creation-settings"> <div class="creation-settings">
<input <input
v-model="name" v-model="name"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Name" placeholder="Name"
class="creation-input" class="creation-input"
/> />
<Button :disabled="creatingInstance === true || !name" @click="createInstance()"> <Button :disabled="creatingInstance === true || !name" @click="createInstance()">
<RightArrowIcon /> <RightArrowIcon />
{{ creatingInstance ? 'Creating...' : 'Create' }} {{ creatingInstance ? 'Creating...' : 'Create' }}
</Button> </Button>
</div> </div>
</div> </div>
</Card> </Card>
<div class="input-group push-right"> <div class="input-group push-right">
<Button :color="showCreation ? '' : 'primary'" @click="toggleCreation()"> <Button :color="showCreation ? '' : 'primary'" @click="toggleCreation()">
<PlusIcon /> <PlusIcon />
{{ showCreation ? 'Hide New Instance' : 'Create new instance' }} {{ showCreation ? 'Hide New Instance' : 'Create new instance' }}
</Button> </Button>
<Button @click="installModal.hide()">Cancel</Button> <Button @click="installModal.hide()">Cancel</Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.creation-card { .creation-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
margin: 0; margin: 0;
background-color: var(--color-bg); background-color: var(--color-bg);
} }
.creation-container { .creation-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
} }
.creation-icon { .creation-icon {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
flex-grow: 1; flex-grow: 1;
.creation-icon__description { .creation-icon__description {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
} }
.creation-input { .creation-input {
width: 100%; width: 100%;
} }
.no-wrap { .no-wrap {
white-space: nowrap; white-space: nowrap;
} }
.creation-dropdown { .creation-dropdown {
width: min-content !important; width: min-content !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.creation-settings { .creation-settings {
width: 100%; width: 100%;
margin-left: 0.5rem; margin-left: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
justify-content: center; justify-content: center;
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
min-width: 350px; min-width: 350px;
} }
.profiles { .profiles {
max-height: 12rem; max-height: 12rem;
overflow-y: auto; overflow-y: auto;
&.hide-creation { &.hide-creation {
max-height: 21rem; max-height: 21rem;
} }
} }
.option { .option {
width: calc(100%); width: calc(100%);
background: var(--color-raised-bg); background: var(--color-raised-bg);
color: var(--color-base); color: var(--color-base);
box-shadow: none; box-shadow: none;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
img { img {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.name { .name {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
.profile-button { .profile-button {
align-content: start; align-content: start;
padding: 0.5rem; padding: 0.5rem;
text-align: left; text-align: left;
} }
} }
.profile-image { .profile-image {
--size: 2rem !important; --size: 2rem !important;
} }
</style> </style>

View File

@@ -1,20 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets' import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
Checkbox, Checkbox,
injectNotificationManager, injectNotificationManager,
OverflowMenu, OverflowMenu,
} from '@modrinth/ui' } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, type Ref, watch } from 'vue' import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types' import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -34,299 +36,299 @@ const newCategoryInput = ref('')
const installing = computed(() => props.instance.install_stage !== 'installed') const installing = computed(() => props.instance.install_stage !== 'installed')
async function duplicateProfile() { async function duplicateProfile() {
await duplicate(props.instance.path).catch(handleError) await duplicate(props.instance.path).catch(handleError)
trackEvent('InstanceDuplicate', { trackEvent('InstanceDuplicate', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
}) })
} }
const allInstances = ref((await list()) as GameInstance[]) const allInstances = ref((await list()) as GameInstance[])
const availableGroups = computed(() => [ const availableGroups = computed(() => [
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]), ...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
]) ])
async function resetIcon() { async function resetIcon() {
icon.value = undefined icon.value = undefined
await edit_icon(props.instance.path, null).catch(handleError) await edit_icon(props.instance.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon') trackEvent('InstanceRemoveIcon')
} }
async function setIcon() { async function setIcon() {
const value = await open({ const value = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: 'Image', name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'], extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
}, },
], ],
}) })
if (!value) return if (!value) return
icon.value = value icon.value = value
await edit_icon(props.instance.path, icon.value).catch(handleError) await edit_icon(props.instance.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon') trackEvent('InstanceSetIcon')
} }
const editProfileObject = computed(() => ({ const editProfileObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance', name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0), groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
})) }))
const toggleGroup = (group: string) => { const toggleGroup = (group: string) => {
if (groups.value.includes(group)) { if (groups.value.includes(group)) {
groups.value = groups.value.filter((x) => x !== group) groups.value = groups.value.filter((x) => x !== group)
} else { } else {
groups.value.push(group) groups.value.push(group)
} }
} }
const addCategory = () => { const addCategory = () => {
const text = newCategoryInput.value.trim() const text = newCategoryInput.value.trim()
if (text.length > 0) { if (text.length > 0) {
groups.value.push(text.substring(0, 32)) groups.value.push(text.substring(0, 32))
newCategoryInput.value = '' newCategoryInput.value = ''
} }
} }
watch( watch(
[title, groups, groups], [title, groups, groups],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const removing = ref(false) const removing = ref(false)
async function removeProfile() { async function removeProfile() {
removing.value = true removing.value = true
await remove(props.instance.path).catch(handleError) await remove(props.instance.path).catch(handleError)
removing.value = false removing.value = false
trackEvent('InstanceRemove', { trackEvent('InstanceRemove', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
}) })
await router.push({ path: '/' }) await router.push({ path: '/' })
} }
const messages = defineMessages({ const messages = defineMessages({
name: { name: {
id: 'instance.settings.tabs.general.name', id: 'instance.settings.tabs.general.name',
defaultMessage: 'Name', defaultMessage: 'Name',
}, },
libraryGroups: { libraryGroups: {
id: 'instance.settings.tabs.general.library-groups', id: 'instance.settings.tabs.general.library-groups',
defaultMessage: 'Library groups', defaultMessage: 'Library groups',
}, },
libraryGroupsDescription: { libraryGroupsDescription: {
id: 'instance.settings.tabs.general.library-groups.description', id: 'instance.settings.tabs.general.library-groups.description',
defaultMessage: defaultMessage:
'Library groups allow you to organize your instances into different sections in your library.', 'Library groups allow you to organize your instances into different sections in your library.',
}, },
libraryGroupsEnterName: { libraryGroupsEnterName: {
id: 'instance.settings.tabs.general.library-groups.enter-name', id: 'instance.settings.tabs.general.library-groups.enter-name',
defaultMessage: 'Enter group name', defaultMessage: 'Enter group name',
}, },
libraryGroupsCreate: { libraryGroupsCreate: {
id: 'instance.settings.tabs.general.library-groups.create', id: 'instance.settings.tabs.general.library-groups.create',
defaultMessage: 'Create new group', defaultMessage: 'Create new group',
}, },
editIcon: { editIcon: {
id: 'instance.settings.tabs.general.edit-icon', id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon', defaultMessage: 'Edit icon',
}, },
selectIcon: { selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select', id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon', defaultMessage: 'Select icon',
}, },
replaceIcon: { replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace', id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon', defaultMessage: 'Replace icon',
}, },
removeIcon: { removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove', id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon', defaultMessage: 'Remove icon',
}, },
duplicateInstance: { duplicateInstance: {
id: 'instance.settings.tabs.general.duplicate-instance', id: 'instance.settings.tabs.general.duplicate-instance',
defaultMessage: 'Duplicate instance', defaultMessage: 'Duplicate instance',
}, },
duplicateInstanceDescription: { duplicateInstanceDescription: {
id: 'instance.settings.tabs.general.duplicate-instance.description', id: 'instance.settings.tabs.general.duplicate-instance.description',
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.', defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
}, },
duplicateButtonTooltipInstalling: { duplicateButtonTooltipInstalling: {
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing', id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
defaultMessage: 'Cannot duplicate while installing.', defaultMessage: 'Cannot duplicate while installing.',
}, },
duplicateButton: { duplicateButton: {
id: 'instance.settings.tabs.general.duplicate-button', id: 'instance.settings.tabs.general.duplicate-button',
defaultMessage: 'Duplicate', defaultMessage: 'Duplicate',
}, },
deleteInstance: { deleteInstance: {
id: 'instance.settings.tabs.general.delete', id: 'instance.settings.tabs.general.delete',
defaultMessage: 'Delete instance', defaultMessage: 'Delete instance',
}, },
deleteInstanceDescription: { deleteInstanceDescription: {
id: 'instance.settings.tabs.general.delete.description', id: 'instance.settings.tabs.general.delete.description',
defaultMessage: defaultMessage:
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.', 'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
}, },
deleteInstanceButton: { deleteInstanceButton: {
id: 'instance.settings.tabs.general.delete.button', id: 'instance.settings.tabs.general.delete.button',
defaultMessage: 'Delete instance', defaultMessage: 'Delete instance',
}, },
deletingInstanceButton: { deletingInstanceButton: {
id: 'instance.settings.tabs.general.deleting.button', id: 'instance.settings.tabs.general.deleting.button',
defaultMessage: 'Deleting...', defaultMessage: 'Deleting...',
}, },
}) })
</script> </script>
<template> <template>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="deleteConfirmModal" ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?" title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it." description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it."
:has-to-type="false" :has-to-type="false"
proceed-label="Delete" proceed-label="Delete"
:show-ad-on-close="false" :show-ad-on-close="false"
@proceed="removeProfile" @proceed="removeProfile"
/> />
<div class="block"> <div class="block">
<div class="float-end ml-4 relative group"> <div class="float-end ml-4 relative group">
<OverflowMenu <OverflowMenu
v-tooltip="formatMessage(messages.editIcon)" v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform" class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[ :options="[
{ {
id: 'select', id: 'select',
action: () => setIcon(), action: () => setIcon(),
}, },
{ {
id: 'remove', id: 'remove',
color: 'danger', color: 'danger',
action: () => resetIcon(), action: () => resetIcon(),
shown: !!icon, shown: !!icon,
}, },
]" ]"
> >
<Avatar <Avatar
:src="icon ? convertFileSrc(icon) : icon" :src="icon ? convertFileSrc(icon) : icon"
size="108px" size="108px"
class="!border-4 group-hover:brightness-75" class="!border-4 group-hover:brightness-75"
:tint-by="props.instance.path" :tint-by="props.instance.path"
no-shadow no-shadow
/> />
<div class="absolute top-0 right-0 m-2"> <div class="absolute top-0 right-0 m-2">
<div <div
class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow" class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow"
> >
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" /> <EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div> </div>
</div> </div>
<template #select> <template #select>
<UploadIcon /> <UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }} {{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template> </template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template> <template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu> </OverflowMenu>
</div> </div>
<label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block"> <label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.name) }} {{ formatMessage(messages.name) }}
</label> </label>
<div class="flex"> <div class="flex">
<input <input
id="instance-name" id="instance-name"
v-model="title" v-model="title"
autocomplete="off" autocomplete="off"
maxlength="80" maxlength="80"
class="flex-grow" class="flex-grow"
type="text" type="text"
/> />
</div> </div>
<template v-if="instance.install_stage == 'installed'"> <template v-if="instance.install_stage == 'installed'">
<div> <div>
<h2 <h2
id="duplicate-instance-label" id="duplicate-instance-label"
class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"
> >
{{ formatMessage(messages.duplicateInstance) }} {{ formatMessage(messages.duplicateInstance) }}
</h2> </h2>
<p class="m-0 mb-2"> <p class="m-0 mb-2">
{{ formatMessage(messages.duplicateInstanceDescription) }} {{ formatMessage(messages.duplicateInstanceDescription) }}
</p> </p>
</div> </div>
<ButtonStyled> <ButtonStyled>
<button <button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null" v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label" aria-labelledby="duplicate-instance-label"
:disabled="installing" :disabled="installing"
@click="duplicateProfile" @click="duplicateProfile"
> >
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }} <CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
<h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.libraryGroups) }} {{ formatMessage(messages.libraryGroups) }}
</h2> </h2>
<p class="m-0 mb-2"> <p class="m-0 mb-2">
{{ formatMessage(messages.libraryGroupsDescription) }} {{ formatMessage(messages.libraryGroupsDescription) }}
</p> </p>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<Checkbox <Checkbox
v-for="group in availableGroups" v-for="group in availableGroups"
:key="group" :key="group"
:model-value="groups.includes(group)" :model-value="groups.includes(group)"
:label="group" :label="group"
@click="toggleGroup(group)" @click="toggleGroup(group)"
/> />
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<input <input
v-model="newCategoryInput" v-model="newCategoryInput"
type="text" type="text"
:placeholder="formatMessage(messages.libraryGroupsEnterName)" :placeholder="formatMessage(messages.libraryGroupsEnterName)"
@submit="() => addCategory" @submit="() => addCategory"
/> />
<ButtonStyled> <ButtonStyled>
<button class="w-fit" @click="() => addCategory()"> <button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }} <PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
<h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.deleteInstance) }} {{ formatMessage(messages.deleteInstance) }}
</h2> </h2>
<p class="m-0 mb-2"> <p class="m-0 mb-2">
{{ formatMessage(messages.deleteInstanceDescription) }} {{ formatMessage(messages.deleteInstanceDescription) }}
</p> </p>
<ButtonStyled color="red"> <ButtonStyled color="red">
<button <button
aria-labelledby="delete-instance-label" aria-labelledby="delete-instance-label"
:disabled="removing" :disabled="removing"
@click="deleteConfirmModal.show()" @click="deleteConfirmModal.show()"
> >
<SpinnerIcon v-if="removing" class="animate-spin" /> <SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else /> <TrashIcon v-else />
{{ {{
removing removing
? formatMessage(messages.deletingInstanceButton) ? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton) : formatMessage(messages.deleteInstanceButton)
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.hovering-icon-shadow { .hovering-icon-shadow {
box-shadow: var(--shadow-inset-sm), var(--shadow-raised); box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
} }
</style> </style>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { Checkbox, injectNotificationManager } from '@modrinth/ui' import { Checkbox, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types' import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -14,139 +16,139 @@ const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref( const overrideHooks = ref(
!!props.instance.hooks.pre_launch || !!props.instance.hooks.pre_launch ||
!!props.instance.hooks.wrapper || !!props.instance.hooks.wrapper ||
!!props.instance.hooks.post_exit, !!props.instance.hooks.post_exit,
) )
const hooks = ref(props.instance.hooks ?? globalSettings.hooks) const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
hooks?: Hooks hooks?: Hooks
} = {} } = {}
// When hooks are not overridden per-instance, we want to clear them // When hooks are not overridden per-instance, we want to clear them
editProfile.hooks = overrideHooks.value ? hooks.value : {} editProfile.hooks = overrideHooks.value ? hooks.value : {}
return editProfile return editProfile
}) })
watch( watch(
[overrideHooks, hooks], [overrideHooks, hooks],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const messages = defineMessages({ const messages = defineMessages({
hooks: { hooks: {
id: 'instance.settings.tabs.hooks.title', id: 'instance.settings.tabs.hooks.title',
defaultMessage: 'Game launch hooks', defaultMessage: 'Game launch hooks',
}, },
hooksDescription: { hooksDescription: {
id: 'instance.settings.tabs.hooks.description', id: 'instance.settings.tabs.hooks.description',
defaultMessage: defaultMessage:
'Hooks allow advanced users to run certain system commands before and after launching the game.', 'Hooks allow advanced users to run certain system commands before and after launching the game.',
}, },
customHooks: { customHooks: {
id: 'instance.settings.tabs.hooks.custom-hooks', id: 'instance.settings.tabs.hooks.custom-hooks',
defaultMessage: 'Custom launch hooks', defaultMessage: 'Custom launch hooks',
}, },
preLaunch: { preLaunch: {
id: 'instance.settings.tabs.hooks.pre-launch', id: 'instance.settings.tabs.hooks.pre-launch',
defaultMessage: 'Pre-launch', defaultMessage: 'Pre-launch',
}, },
preLaunchDescription: { preLaunchDescription: {
id: 'instance.settings.tabs.hooks.pre-launch.description', id: 'instance.settings.tabs.hooks.pre-launch.description',
defaultMessage: 'Ran before the instance is launched.', defaultMessage: 'Ran before the instance is launched.',
}, },
preLaunchEnter: { preLaunchEnter: {
id: 'instance.settings.tabs.hooks.pre-launch.enter', id: 'instance.settings.tabs.hooks.pre-launch.enter',
defaultMessage: 'Enter pre-launch command...', defaultMessage: 'Enter pre-launch command...',
}, },
wrapper: { wrapper: {
id: 'instance.settings.tabs.hooks.wrapper', id: 'instance.settings.tabs.hooks.wrapper',
defaultMessage: 'Wrapper', defaultMessage: 'Wrapper',
}, },
wrapperDescription: { wrapperDescription: {
id: 'instance.settings.tabs.hooks.wrapper.description', id: 'instance.settings.tabs.hooks.wrapper.description',
defaultMessage: 'Wrapper command for launching Minecraft.', defaultMessage: 'Wrapper command for launching Minecraft.',
}, },
wrapperEnter: { wrapperEnter: {
id: 'instance.settings.tabs.hooks.wrapper.enter', id: 'instance.settings.tabs.hooks.wrapper.enter',
defaultMessage: 'Enter wrapper command...', defaultMessage: 'Enter wrapper command...',
}, },
postExit: { postExit: {
id: 'instance.settings.tabs.hooks.post-exit', id: 'instance.settings.tabs.hooks.post-exit',
defaultMessage: 'Post-exit', defaultMessage: 'Post-exit',
}, },
postExitDescription: { postExitDescription: {
id: 'instance.settings.tabs.hooks.post-exit.description', id: 'instance.settings.tabs.hooks.post-exit.description',
defaultMessage: 'Ran after the game closes.', defaultMessage: 'Ran after the game closes.',
}, },
postExitEnter: { postExitEnter: {
id: 'instance.settings.tabs.hooks.post-exit.enter', id: 'instance.settings.tabs.hooks.post-exit.enter',
defaultMessage: 'Enter post-exit command...', defaultMessage: 'Enter post-exit command...',
}, },
}) })
</script> </script>
<template> <template>
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.hooks) }} {{ formatMessage(messages.hooks) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.hooksDescription) }} {{ formatMessage(messages.hooksDescription) }}
</p> </p>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" /> <Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" />
<h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast"> <h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.preLaunch) }} {{ formatMessage(messages.preLaunch) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }} {{ formatMessage(messages.preLaunchDescription) }}
</p> </p>
<input <input
id="pre-launch" id="pre-launch"
v-model="hooks.pre_launch" v-model="hooks.pre_launch"
autocomplete="off" autocomplete="off"
:disabled="!overrideHooks" :disabled="!overrideHooks"
type="text" type="text"
:placeholder="formatMessage(messages.preLaunchEnter)" :placeholder="formatMessage(messages.preLaunchEnter)"
class="w-full mt-2" class="w-full mt-2"
/> />
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast"> <h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.wrapper) }} {{ formatMessage(messages.wrapper) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.wrapperDescription) }} {{ formatMessage(messages.wrapperDescription) }}
</p> </p>
<input <input
id="wrapper" id="wrapper"
v-model="hooks.wrapper" v-model="hooks.wrapper"
autocomplete="off" autocomplete="off"
:disabled="!overrideHooks" :disabled="!overrideHooks"
type="text" type="text"
:placeholder="formatMessage(messages.wrapperEnter)" :placeholder="formatMessage(messages.wrapperEnter)"
class="w-full mt-2" class="w-full mt-2"
/> />
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast"> <h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.postExit) }} {{ formatMessage(messages.postExit) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.postExitDescription) }} {{ formatMessage(messages.postExitDescription) }}
</p> </p>
<input <input
id="post-exit" id="post-exit"
v-model="hooks.post_exit" v-model="hooks.post_exit"
autocomplete="off" autocomplete="off"
:disabled="!overrideHooks" :disabled="!overrideHooks"
type="text" type="text"
:placeholder="formatMessage(messages.postExitEnter)" :placeholder="formatMessage(messages.postExitEnter)"
class="w-full mt-2" class="w-full mt-2"
/> />
</div> </div>
</template> </template>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import JavaSelector from '@/components/ui/JavaSelector.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets' import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import { Checkbox, injectNotificationManager, Slider } from '@modrinth/ui' import { Checkbox, injectNotificationManager, Slider } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, readonly, ref, watch } from 'vue' import { computed, readonly, ref, watch } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, InstanceSettingsTabProps, MemorySettings } from '../../../helpers/types' import type { AppSettings, InstanceSettingsTabProps, MemorySettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -22,14 +24,14 @@ const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined) const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined)
const javaArgs = ref( const javaArgs = ref(
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '), (props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
) )
const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined) const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined)
const envVars = ref( const envVars = ref(
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars) (props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('=')) .map((x) => x.join('='))
.join(' '), .join(' '),
) )
const overrideMemorySettings = ref(!!props.instance.memory) const overrideMemorySettings = ref(!!props.instance.memory)
@@ -37,154 +39,154 @@ const memory = ref(props.instance.memory ?? globalSettings.memory)
const { maxMemory, snapPoints } = await useMemorySlider() const { maxMemory, snapPoints } = await useMemorySlider()
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
java_path?: string java_path?: string
extra_launch_args?: string[] extra_launch_args?: string[]
custom_env_vars?: string[][] custom_env_vars?: string[][]
memory?: MemorySettings memory?: MemorySettings
} = {} } = {}
if (overrideJavaInstall.value) { if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') { if (javaInstall.value.path !== '') {
editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe') editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe')
} }
} }
if (overrideJavaArgs.value) { if (overrideJavaArgs.value) {
editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean) editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean)
} }
if (overrideEnvVars.value) { if (overrideEnvVars.value) {
editProfile.custom_env_vars = envVars.value editProfile.custom_env_vars = envVars.value
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
.map((x) => x.split('=').filter(Boolean)) .map((x) => x.split('=').filter(Boolean))
} }
if (overrideMemorySettings.value) { if (overrideMemorySettings.value) {
editProfile.memory = memory.value editProfile.memory = memory.value
} }
return editProfile return editProfile
}) })
watch( watch(
[ [
overrideJavaInstall, overrideJavaInstall,
javaInstall, javaInstall,
overrideJavaArgs, overrideJavaArgs,
javaArgs, javaArgs,
overrideEnvVars, overrideEnvVars,
envVars, envVars,
overrideMemorySettings, overrideMemorySettings,
memory, memory,
], ],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const messages = defineMessages({ const messages = defineMessages({
javaInstallation: { javaInstallation: {
id: 'instance.settings.tabs.java.java-installation', id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation', defaultMessage: 'Java installation',
}, },
javaArguments: { javaArguments: {
id: 'instance.settings.tabs.java.java-arguments', id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments', defaultMessage: 'Java arguments',
}, },
javaEnvironmentVariables: { javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables', id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables', defaultMessage: 'Environment variables',
}, },
javaMemory: { javaMemory: {
id: 'instance.settings.tabs.java.java-memory', id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated', defaultMessage: 'Memory allocated',
}, },
hooks: { hooks: {
id: 'instance.settings.tabs.java.hooks', id: 'instance.settings.tabs.java.hooks',
defaultMessage: 'Hooks', defaultMessage: 'Hooks',
}, },
}) })
</script> </script>
<template> <template>
<div> <div>
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }} {{ formatMessage(messages.javaInstallation) }}
</h2> </h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" /> <Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" />
<template v-if="!overrideJavaInstall"> <template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold"> <div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall"> <template v-if="javaInstall">
<CheckCircleIcon class="text-brand-green h-4 w-4" /> <CheckCircleIcon class="text-brand-green h-4 w-4" />
<span>Using default Java {{ optimalJava.major_version }} installation:</span> <span>Using default Java {{ optimalJava.major_version }} installation:</span>
</template> </template>
<template v-else-if="optimalJava"> <template v-else-if="optimalJava">
<XCircleIcon class="text-brand-red h-5 w-5" /> <XCircleIcon class="text-brand-red h-5 w-5" />
<span <span
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set >Could not find a default Java {{ optimalJava.major_version }} installation. Please set
one below:</span one below:</span
> >
</template> </template>
<template v-else> <template v-else>
<XCircleIcon class="text-brand-red h-5 w-5" /> <XCircleIcon class="text-brand-red h-5 w-5" />
<span <span
>Could not automatically determine a Java installation to use. Please set one >Could not automatically determine a Java installation to use. Please set one
below:</span below:</span
> >
</template> </template>
</div> </div>
<div <div
v-if="javaInstall && !overrideJavaInstall" v-if="javaInstall && !overrideJavaInstall"
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono" class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
> >
{{ javaInstall.path }} {{ javaInstall.path }}
</div> </div>
</template> </template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" /> <JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }} {{ formatMessage(messages.javaMemory) }}
</h2> </h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" /> <Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" />
<Slider <Slider
id="max-memory" id="max-memory"
v-model="memory.maximum" v-model="memory.maximum"
:disabled="!overrideMemorySettings" :disabled="!overrideMemorySettings"
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
:snap-points="snapPoints" :snap-points="snapPoints"
:snap-range="512" :snap-range="512"
unit="MB" unit="MB"
/> />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }} {{ formatMessage(messages.javaArguments) }}
</h2> </h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" /> <Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<input <input
id="java-args" id="java-args"
v-model="javaArgs" v-model="javaArgs"
autocomplete="off" autocomplete="off"
:disabled="!overrideJavaArgs" :disabled="!overrideJavaArgs"
type="text" type="text"
class="w-full" class="w-full"
placeholder="Enter java arguments..." placeholder="Enter java arguments..."
/> />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }} {{ formatMessage(messages.javaEnvironmentVariables) }}
</h2> </h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" /> <Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" />
<input <input
id="env-vars" id="env-vars"
v-model="envVars" v-model="envVars"
autocomplete="off" autocomplete="off"
:disabled="!overrideEnvVars" :disabled="!overrideEnvVars"
type="text" type="text"
class="w-full" class="w-full"
placeholder="Enter environmental variables..." placeholder="Enter environmental variables..."
/> />
</div> </div>
</template> </template>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { Checkbox, injectNotificationManager, Toggle } from '@modrinth/ui' import { Checkbox, injectNotificationManager, Toggle } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, type Ref, watch } from 'vue' import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -14,151 +16,151 @@ const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref( const overrideWindowSettings = ref(
!!props.instance.game_resolution || !!props.instance.force_fullscreen, !!props.instance.game_resolution || !!props.instance.force_fullscreen,
) )
const resolution: Ref<[number, number]> = ref( const resolution: Ref<[number, number]> = ref(
props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]), props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
) )
const fullscreenSetting: Ref<boolean> = ref( const fullscreenSetting: Ref<boolean> = ref(
props.instance.force_fullscreen ?? globalSettings.force_fullscreen, props.instance.force_fullscreen ?? globalSettings.force_fullscreen,
) )
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
force_fullscreen?: boolean force_fullscreen?: boolean
game_resolution?: [number, number] game_resolution?: [number, number]
} = {} } = {}
if (overrideWindowSettings.value) { if (overrideWindowSettings.value) {
editProfile.force_fullscreen = fullscreenSetting.value editProfile.force_fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) { if (!fullscreenSetting.value) {
editProfile.game_resolution = resolution.value editProfile.game_resolution = resolution.value
} }
} }
return editProfile return editProfile
}) })
watch( watch(
[overrideWindowSettings, resolution, fullscreenSetting], [overrideWindowSettings, resolution, fullscreenSetting],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const messages = defineMessages({ const messages = defineMessages({
customWindowSettings: { customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings', id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Custom window settings', defaultMessage: 'Custom window settings',
}, },
fullscreen: { fullscreen: {
id: 'instance.settings.tabs.window.fullscreen', id: 'instance.settings.tabs.window.fullscreen',
defaultMessage: 'Fullscreen', defaultMessage: 'Fullscreen',
}, },
fullscreenDescription: { fullscreenDescription: {
id: 'instance.settings.tabs.window.fullscreen.description', id: 'instance.settings.tabs.window.fullscreen.description',
defaultMessage: 'Make the game start in full screen when launched (using options.txt).', defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
}, },
width: { width: {
id: 'instance.settings.tabs.window.width', id: 'instance.settings.tabs.window.width',
defaultMessage: 'Width', defaultMessage: 'Width',
}, },
widthDescription: { widthDescription: {
id: 'instance.settings.tabs.window.width.description', id: 'instance.settings.tabs.window.width.description',
defaultMessage: 'The width of the game window when launched.', defaultMessage: 'The width of the game window when launched.',
}, },
enterWidth: { enterWidth: {
id: 'instance.settings.tabs.window.width.enter', id: 'instance.settings.tabs.window.width.enter',
defaultMessage: 'Enter width...', defaultMessage: 'Enter width...',
}, },
height: { height: {
id: 'instance.settings.tabs.window.height', id: 'instance.settings.tabs.window.height',
defaultMessage: 'Height', defaultMessage: 'Height',
}, },
heightDescription: { heightDescription: {
id: 'instance.settings.tabs.window.height.description', id: 'instance.settings.tabs.window.height.description',
defaultMessage: 'The height of the game window when launched.', defaultMessage: 'The height of the game window when launched.',
}, },
enterHeight: { enterHeight: {
id: 'instance.settings.tabs.window.height.enter', id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...', defaultMessage: 'Enter height...',
}, },
}) })
</script> </script>
<template> <template>
<div> <div>
<Checkbox <Checkbox
v-model="overrideWindowSettings" v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)" :label="formatMessage(messages.customWindowSettings)"
@update:model-value=" @update:model-value="
(value) => { (value) => {
if (!value) { if (!value) {
resolution = globalSettings.game_resolution resolution = globalSettings.game_resolution
fullscreenSetting = globalSettings.force_fullscreen fullscreenSetting = globalSettings.force_fullscreen
} }
} }
" "
/> />
<div class="mt-2 flex items-center gap-4 justify-between"> <div class="mt-2 flex items-center gap-4 justify-between">
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.fullscreen) }} {{ formatMessage(messages.fullscreen) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.fullscreenDescription) }} {{ formatMessage(messages.fullscreenDescription) }}
</p> </p>
</div> </div>
<Toggle <Toggle
id="fullscreen" id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen" :model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:disabled="!overrideWindowSettings" :disabled="!overrideWindowSettings"
@update:model-value=" @update:model-value="
(e) => { (e) => {
fullscreenSetting = e fullscreenSetting = e
} }
" "
/> />
</div> </div>
<div class="mt-4 flex items-center gap-4 justify-between"> <div class="mt-4 flex items-center gap-4 justify-between">
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.width) }} {{ formatMessage(messages.width) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.widthDescription) }} {{ formatMessage(messages.widthDescription) }}
</p> </p>
</div> </div>
<input <input
id="width" id="width"
v-model="resolution[0]" v-model="resolution[0]"
autocomplete="off" autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting" :disabled="!overrideWindowSettings || fullscreenSetting"
type="number" type="number"
:placeholder="formatMessage(messages.enterWidth)" :placeholder="formatMessage(messages.enterWidth)"
/> />
</div> </div>
<div class="mt-4 flex items-center gap-4 justify-between"> <div class="mt-4 flex items-center gap-4 justify-between">
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.height) }} {{ formatMessage(messages.height) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.heightDescription) }} {{ formatMessage(messages.heightDescription) }}
</p> </p>
</div> </div>
<input <input
id="height" id="height"
v-model="resolution[1]" v-model="resolution[1]"
autocomplete="off" autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting" :disabled="!overrideWindowSettings || fullscreenSetting"
type="number" type="number"
:placeholder="formatMessage(messages.enterHeight)" :placeholder="formatMessage(messages.enterHeight)"
/> />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,28 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ReportIcon, CoffeeIcon,
ModrinthIcon, GameIcon,
ShieldIcon, GaugeIcon,
SettingsIcon, ModrinthIcon,
GaugeIcon, PaintbrushIcon,
PaintbrushIcon, ReportIcon,
GameIcon, SettingsIcon,
CoffeeIcon, ShieldIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { TabbedModal } from '@modrinth/ui' import { TabbedModal } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useVIntl, defineMessage } from '@vintl/vintl'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/plugin-os' import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { useTheming } from '@/store/state' import { defineMessage, useVIntl } from '@vintl/vintl'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue' import { computed, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
const themeStore = useTheming() const themeStore = useTheming()
@@ -31,66 +32,66 @@ const { formatMessage } = useVIntl()
const devModeCounter = ref(0) const devModeCounter = ref(0)
const developerModeEnabled = defineMessage({ const developerModeEnabled = defineMessage({
id: 'app.settings.developer-mode-enabled', id: 'app.settings.developer-mode-enabled',
defaultMessage: 'Developer mode enabled.', defaultMessage: 'Developer mode enabled.',
}) })
const tabs = [ const tabs = [
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.appearance', id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance', defaultMessage: 'Appearance',
}), }),
icon: PaintbrushIcon, icon: PaintbrushIcon,
content: AppearanceSettings, content: AppearanceSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.privacy', id: 'app.settings.tabs.privacy',
defaultMessage: 'Privacy', defaultMessage: 'Privacy',
}), }),
icon: ShieldIcon, icon: ShieldIcon,
content: PrivacySettings, content: PrivacySettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.java-installations', id: 'app.settings.tabs.java-installations',
defaultMessage: 'Java installations', defaultMessage: 'Java installations',
}), }),
icon: CoffeeIcon, icon: CoffeeIcon,
content: JavaSettings, content: JavaSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.default-instance-options', id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options', defaultMessage: 'Default instance options',
}), }),
icon: GameIcon, icon: GameIcon,
content: DefaultInstanceSettings, content: DefaultInstanceSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.resource-management', id: 'app.settings.tabs.resource-management',
defaultMessage: 'Resource management', defaultMessage: 'Resource management',
}), }),
icon: GaugeIcon, icon: GaugeIcon,
content: ResourceManagementSettings, content: ResourceManagementSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.feature-flags', id: 'app.settings.tabs.feature-flags',
defaultMessage: 'Feature flags', defaultMessage: 'Feature flags',
}), }),
icon: ReportIcon, icon: ReportIcon,
content: FeatureFlagSettings, content: FeatureFlagSettings,
developerOnly: true, developerOnly: true,
}, },
] ]
const modal = ref() const modal = ref()
function show() { function show() {
modal.value.show() modal.value.show()
} }
const isOpen = computed(() => modal.value?.isOpen) const isOpen = computed(() => modal.value?.isOpen)
@@ -103,59 +104,62 @@ const osVersion = getOsVersion()
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
await set(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
function devModeCount() { function devModeCount() {
devModeCounter.value++ devModeCounter.value++
if (devModeCounter.value > 5) { if (devModeCounter.value > 5) {
themeStore.devMode = !themeStore.devMode themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0 devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) { if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
modal.value.setTab(0) modal.value.setTab(0)
} }
} }
} }
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
<template #title> <template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast"> <span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings <SettingsIcon /> Settings
</span> </span>
</template> </template>
<TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)"> <TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<template #footer> <template #footer>
<div class="mt-auto text-secondary text-sm"> <div class="mt-auto text-secondary text-sm">
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2"> <p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }} {{ formatMessage(developerModeEnabled) }}
</p> </p>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation" class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }" :class="{
@click="devModeCount" 'text-brand': themeStore.devMode,
> 'text-secondary': !themeStore.devMode,
<ModrinthIcon class="w-6 h-6" /> }"
</button> @click="devModeCount"
<div> >
<p class="m-0">Modrinth App {{ version }}</p> <ModrinthIcon class="w-6 h-6" />
<p class="m-0"> </button>
<span v-if="osPlatform === 'macos'">MacOS</span> <div>
<span v-else class="capitalize">{{ osPlatform }}</span> <p class="m-0">Modrinth App {{ version }}</p>
{{ osVersion }} <p class="m-0">
</p> <span v-if="osPlatform === 'macos'">MacOS</span>
</div> <span v-else class="capitalize">{{ osPlatform }}</span>
</div> {{ osVersion }}
</div> </p>
</template> </div>
</TabbedModal> </div>
</ModalWrapper> </div>
</template>
</TabbedModal>
</ModalWrapper>
</template> </template>

View File

@@ -1,42 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets' import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({ defineProps({
onFlowCancel: { onFlowCancel: {
type: Function, type: Function,
default() { default() {
return async () => {} return async () => {}
}, },
}, },
}) })
const modal = ref() const modal = ref()
function show() { function show() {
modal.value.show() modal.value.show()
} }
function hide() { function hide() {
modal.value.hide() modal.value.hide()
} }
defineExpose({ show, hide }) defineExpose({ show, hide })
</script> </script>
<template> <template>
<ModalWrapper ref="modal" @hide="onFlowCancel"> <ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title> <template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast"> <span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in <LogInIcon /> Sign in
</span> </span>
</template> </template>
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" /> <SpinnerIcon class="w-12 h-12 animate-spin" />
</div> </div>
<p class="text-sm text-secondary"> <p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue. Please sign in at the browser window that just opened to continue.
</p> </p>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,90 +1,91 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { ConfirmModal } from '@modrinth/ui' import { ConfirmModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
const props = defineProps({ const props = defineProps({
confirmationText: { confirmationText: {
type: String, type: String,
default: '', default: '',
}, },
hasToType: { hasToType: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
title: { title: {
type: String, type: String,
default: 'No title defined', default: 'No title defined',
required: true, required: true,
}, },
description: { description: {
type: String, type: String,
default: 'No description defined', default: 'No description defined',
required: true, required: true,
}, },
proceedIcon: { proceedIcon: {
type: Object, type: Object,
default: undefined, default: undefined,
}, },
proceedLabel: { proceedLabel: {
type: String, type: String,
default: 'Proceed', default: 'Proceed',
}, },
danger: { danger: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showAdOnClose: { showAdOnClose: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
markdown: { markdown: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}) })
const emit = defineEmits(['proceed']) const emit = defineEmits(['proceed'])
const modal = ref(null) const modal = ref(null)
defineExpose({ defineExpose({
show: () => { show: () => {
hide_ads_window() hide_ads_window()
modal.value.show() modal.value.show()
}, },
hide: () => { hide: () => {
onModalHide() onModalHide()
modal.value.hide() modal.value.hide()
}, },
}) })
function onModalHide() { function onModalHide() {
if (props.showAdOnClose) { if (props.showAdOnClose) {
show_ads_window() show_ads_window()
} }
} }
function proceed() { function proceed() {
emit('proceed') emit('proceed')
} }
</script> </script>
<template> <template>
<ConfirmModal <ConfirmModal
ref="modal" ref="modal"
:confirmation-text="confirmationText" :confirmation-text="confirmationText"
:has-to-type="hasToType" :has-to-type="hasToType"
:title="title" :title="title"
:description="description" :description="description"
:proceed-icon="proceedIcon" :proceed-icon="proceedIcon"
:proceed-label="proceedLabel" :proceed-label="proceedLabel"
:on-hide="onModalHide" :on-hide="onModalHide"
:noblur="!themeStore.advancedRendering" :noblur="!themeStore.advancedRendering"
:danger="danger" :danger="danger"
:markdown="markdown" :markdown="markdown"
@proceed="proceed" @proceed="proceed"
/> />
</template> </template>

View File

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

View File

@@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ChevronRightIcon, ChevronRightIcon,
CoffeeIcon, CodeIcon,
InfoIcon, CoffeeIcon,
WrenchIcon, InfoIcon,
MonitorIcon, MonitorIcon,
CodeIcon, WrenchIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, TabbedModal, type TabbedModalTab } from '@modrinth/ui' import { Avatar, TabbedModal, type TabbedModalTab } from '@modrinth/ui'
import { ref } from 'vue'
import { defineMessage, useVIntl } from '@vintl/vintl'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { ref } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue' import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue' import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { InstanceSettingsTabProps } from '../../../helpers/types' import type { InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -24,75 +26,75 @@ const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [ const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.general', id: 'instance.settings.tabs.general',
defaultMessage: 'General', defaultMessage: 'General',
}), }),
icon: InfoIcon, icon: InfoIcon,
content: GeneralSettings, content: GeneralSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.installation', id: 'instance.settings.tabs.installation',
defaultMessage: 'Installation', defaultMessage: 'Installation',
}), }),
icon: WrenchIcon, icon: WrenchIcon,
content: InstallationSettings, content: InstallationSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.window', id: 'instance.settings.tabs.window',
defaultMessage: 'Window', defaultMessage: 'Window',
}), }),
icon: MonitorIcon, icon: MonitorIcon,
content: WindowSettings, content: WindowSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.java', id: 'instance.settings.tabs.java',
defaultMessage: 'Java and memory', defaultMessage: 'Java and memory',
}), }),
icon: CoffeeIcon, icon: CoffeeIcon,
content: JavaSettings, content: JavaSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.hooks', id: 'instance.settings.tabs.hooks',
defaultMessage: 'Launch hooks', defaultMessage: 'Launch hooks',
}), }),
icon: CodeIcon, icon: CodeIcon,
content: HooksSettings, content: HooksSettings,
}, },
] ]
const modal = ref() const modal = ref()
function show() { function show() {
modal.value.show() modal.value.show()
} }
defineExpose({ show }) defineExpose({ show })
const titleMessage = defineMessage({ const titleMessage = defineMessage({
id: 'instance.settings.title', id: 'instance.settings.title',
defaultMessage: 'Settings', defaultMessage: 'Settings',
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
<template #title> <template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary"> <span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px" size="24px"
:tint-by="props.instance.path" :tint-by="props.instance.path"
/> />
{{ instance.name }} <ChevronRightIcon /> {{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span> <span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span>
</span> </span>
</template> </template>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" /> <TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,57 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef } from 'vue'
import { NewModal as Modal } from '@modrinth/ui' import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { useTemplateRef } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
const props = defineProps({ const props = defineProps({
header: { header: {
type: String, type: String,
default: null, default: null,
}, },
closable: { closable: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
onHide: { onHide: {
type: Function, type: Function,
default() { default() {
return () => {} return () => {}
}, },
}, },
showAdOnClose: { showAdOnClose: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}) })
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
defineExpose({ defineExpose({
show: (e: MouseEvent) => { show: (e: MouseEvent) => {
hide_ads_window() hide_ads_window()
modal.value?.show(e) modal.value?.show(e)
}, },
hide: () => { hide: () => {
onModalHide() onModalHide()
modal.value?.hide() modal.value?.hide()
}, },
}) })
function onModalHide() { function onModalHide() {
if (props.showAdOnClose) { if (props.showAdOnClose) {
show_ads_window() show_ads_window()
} }
props.onHide?.() props.onHide?.()
} }
</script> </script>
<template> <template>
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide"> <Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
<template #title> <template #title>
<slot name="title" /> <slot name="title" />
</template> </template>
<slot /> <slot />
</Modal> </Modal>
</template> </template>

View File

@@ -1,61 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { ShareModal } from '@modrinth/ui' import { ShareModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
defineProps({ defineProps({
header: { header: {
type: String, type: String,
default: 'Share', default: 'Share',
}, },
shareTitle: { shareTitle: {
type: String, type: String,
default: 'Modrinth', default: 'Modrinth',
}, },
shareText: { shareText: {
type: String, type: String,
default: null, default: null,
}, },
link: { link: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
openInNewTab: { openInNewTab: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}) })
const modal = ref(null) const modal = ref(null)
defineExpose({ defineExpose({
show: (passedContent) => { show: (passedContent) => {
hide_ads_window() hide_ads_window()
modal.value.show(passedContent) modal.value.show(passedContent)
}, },
hide: () => { hide: () => {
onModalHide() onModalHide()
modal.value.hide() modal.value.hide()
}, },
}) })
function onModalHide() { function onModalHide() {
show_ads_window() show_ads_window()
} }
</script> </script>
<template> <template>
<ShareModal <ShareModal
ref="modal" ref="modal"
:header="header" :header="header"
:share-title="shareTitle" :share-title="shareTitle"
:share-text="shareText" :share-text="shareText"
:link="link" :link="link"
:open-in-new-tab="openInNewTab" :open-in-new-tab="openInNewTab"
:on-hide="onModalHide" :on-hide="onModalHide"
:noblur="!themeStore.advancedRendering" :noblur="!themeStore.advancedRendering"
/> />
</template> </template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui' import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils' import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme } from '@/store/theme.ts' import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
@@ -12,119 +13,119 @@ const os = ref(await getOS())
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
await set(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2>
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p> <p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector <ThemeSelector
:update-color-theme=" :update-color-theme="
(theme: ColorTheme) => { (theme: ColorTheme) => {
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
settings.theme = theme settings.theme = theme
} }
" "
:current-theme="settings.theme" :current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()" :theme-options="themeStore.getThemeOptions()"
system-theme-color="system" system-theme-color="system"
/> />
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2>
<p class="m-0 mt-1"> <p class="m-0 mt-1">
Enables advanced rendering such as blur effects that may cause performance issues without Enables advanced rendering such as blur effects that may cause performance issues without
hardware-accelerated rendering. hardware-accelerated rendering.
</p> </p>
</div> </div>
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.advancedRendering" :model-value="themeStore.advancedRendering"
@update:model-value=" @update:model-value="
(e) => { (e) => {
themeStore.advancedRendering = e themeStore.advancedRendering = e
settings.advanced_rendering = themeStore.advancedRendering settings.advanced_rendering = themeStore.advancedRendering
} }
" "
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p> <p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div> </div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" /> <Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div> </div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4"> <div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p> <p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div> </div>
<Toggle id="native-decorations" v-model="settings.native_decorations" /> <Toggle id="native-decorations" v-model="settings.native_decorations" />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p> <p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div> </div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" /> <Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p> <p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div> </div>
<TeleportDropdownMenu <TeleportDropdownMenu
id="opening-page" id="opening-page"
v-model="settings.default_page" v-model="settings.default_page"
name="Opening page dropdown" name="Opening page dropdown"
class="w-40" class="w-40"
:options="['Home', 'Library']" :options="['Home', 'Library']"
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p> <p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div> </div>
<Toggle <Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')" :model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value=" @update:model-value="
() => { () => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home') const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue settings.feature_flags['worlds_in_home'] = newValue
} }
" "
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p> <p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
</div> </div>
<Toggle <Toggle
id="toggle-sidebar" id="toggle-sidebar"
:model-value="settings.toggle_sidebar" :model-value="settings.toggle_sidebar"
@update:model-value=" @update:model-value="
(e) => { (e) => {
settings.toggle_sidebar = e settings.toggle_sidebar = e
themeStore.toggleSidebar = settings.toggle_sidebar themeStore.toggleSidebar = settings.toggle_sidebar
} }
" "
/> />
</div> </div>
</template> </template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { Slider, Toggle } from '@modrinth/ui' import { Slider, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider' import useMemorySlider from '@/composables/useMemorySlider'
import { get, set } from '@/helpers/settings.ts'
const fetchSettings = await get() const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ') fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
@@ -13,161 +14,161 @@ const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = await useMemorySlider() const { maxMemory, snapPoints } = await useMemorySlider()
watch( watch(
settings, settings,
async () => { async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value)) const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean) setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars setSettings.custom_env_vars = setSettings.envVars
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
.map((x) => x.split('=').filter(Boolean)) .map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) { if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null setSettings.hooks.pre_launch = null
} }
if (!setSettings.hooks.wrapper) { if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null setSettings.hooks.wrapper = null
} }
if (!setSettings.hooks.post_exit) { if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null setSettings.hooks.post_exit = null
} }
if (!setSettings.custom_dir) { if (!setSettings.custom_dir) {
setSettings.custom_dir = null setSettings.custom_dir = null
} }
await set(setSettings) await set(setSettings)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Overwrites the options.txt file to start in full screen when launched. Overwrites the options.txt file to start in full screen when launched.
</p> </p>
</div> </div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" /> <Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The width of the game window when launched. The width of the game window when launched.
</p> </p>
</div> </div>
<input <input
id="width" id="width"
v-model="settings.game_resolution[0]" v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen" :disabled="settings.force_fullscreen"
autocomplete="off" autocomplete="off"
type="number" type="number"
placeholder="Enter width..." placeholder="Enter width..."
/> />
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The height of the game window when launched. The height of the game window when launched.
</p> </p>
</div> </div>
<input <input
id="height" id="height"
v-model="settings.game_resolution[1]" v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen" :disabled="settings.force_fullscreen"
autocomplete="off" autocomplete="off"
type="number" type="number"
class="input" class="input"
placeholder="Enter height..." placeholder="Enter height..."
/> />
</div> </div>
<hr class="mt-4 bg-button-border border-none h-[1px]" /> <hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2> <h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p> <p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<Slider <Slider
id="max-memory" id="max-memory"
v-model="settings.memory.maximum" v-model="settings.memory.maximum"
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
:snap-points="snapPoints" :snap-points="snapPoints"
:snap-range="512" :snap-range="512"
unit="MB" unit="MB"
/> />
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2> <h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2>
<input <input
id="java-args" id="java-args"
v-model="settings.launchArgs" v-model="settings.launchArgs"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter java arguments..." placeholder="Enter java arguments..."
class="w-full" class="w-full"
/> />
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2> <h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2>
<input <input
id="env-vars" id="env-vars"
v-model="settings.envVars" v-model="settings.envVars"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter environmental variables..." placeholder="Enter environmental variables..."
class="w-full" class="w-full"
/> />
<hr class="mt-4 bg-button-border border-none h-[1px]" /> <hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2> <h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p>
<input <input
id="pre-launch" id="pre-launch"
v-model="settings.hooks.pre_launch" v-model="settings.hooks.pre_launch"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter pre-launch command..." placeholder="Enter pre-launch command..."
class="w-full" class="w-full"
/> />
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Wrapper command for launching Minecraft. Wrapper command for launching Minecraft.
</p> </p>
<input <input
id="wrapper" id="wrapper"
v-model="settings.hooks.wrapper" v-model="settings.hooks.wrapper"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter wrapper command..." placeholder="Enter wrapper command..."
class="w-full" class="w-full"
/> />
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p>
<input <input
id="post-exit" id="post-exit"
v-model="settings.hooks.post_exit" v-model="settings.hooks.post_exit"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter post-exit command..." placeholder="Enter post-exit command..."
class="w-full" class="w-full"
/> />
</div> </div>
</template> </template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts' import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
@@ -11,30 +12,30 @@ const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS)) const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
function setFeatureFlag(key: string, value: boolean) { function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value settings.value.feature_flags[key] = value
} }
watch( watch(
settings, settings,
async () => { async () => {
await setSettings(settings.value) await setSettings(settings.value)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between"> <div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize"> <h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }} {{ option.replaceAll('_', ' ') }}
</h2> </h2>
</div> </div>
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)" :model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))" @update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/> />
</div> </div>
</template> </template>

View File

@@ -1,34 +1,35 @@
<script setup> <script setup>
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { injectNotificationManager } from '@modrinth/ui' import { injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const javaVersions = ref(await get_java_versions().catch(handleError)) const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) { async function updateJavaVersion(version) {
if (version?.path === '') { if (version?.path === '') {
version.path = undefined version.path = undefined
} }
if (version?.path) { if (version?.path) {
version.path = version.path.replace('java.exe', 'javaw.exe') version.path = version.path.replace('java.exe', 'javaw.exe')
} }
await set_java_version(version).catch(handleError) await set_java_version(version).catch(handleError)
} }
</script> </script>
<template> <template>
<div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`"> <div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }"> <h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location Java {{ javaVersion }} location
</h2> </h2>
<JavaSelector <JavaSelector
:id="'java-selector-' + javaVersion" :id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]" v-model="javaVersions[javaVersion]"
:version="javaVersion" :version="javaVersion"
@update:model-value="updateJavaVersion" @update:model-value="updateJavaVersion"
/> />
</div> </div>
</template> </template>

View File

@@ -1,62 +1,63 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics' import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
import { get, set } from '@/helpers/settings.ts'
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
if (settings.value.telemetry) { if (settings.value.telemetry) {
optInAnalytics() optInAnalytics()
} else { } else {
optOutAnalytics() optOutAnalytics()
} }
await set(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2>
<p class="m-0 text-sm"> <p class="m-0 text-sm">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests. option, you opt out and ads will no longer be shown based on your interests.
</p> </p>
</div> </div>
<Toggle id="personalized-ads" v-model="settings.personalized_ads" /> <Toggle id="personalized-ads" v-model="settings.personalized_ads" />
</div> </div>
<div class="mt-4 flex items-center justify-between gap-4"> <div class="mt-4 flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2>
<p class="m-0 text-sm"> <p class="m-0 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no customize your experience. By disabling this option, you opt out and your data will no
longer be collected. longer be collected.
</p> </p>
</div> </div>
<Toggle id="opt-out-analytics" v-model="settings.telemetry" /> <Toggle id="opt-out-analytics" v-model="settings.telemetry" />
</div> </div>
<div class="mt-4 flex items-center justify-between gap-4"> <div class="mt-4 flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2>
<p class="m-0 text-sm"> <p class="m-0 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile. longer show up as a game or app you are using on your Discord profile.
</p> </p>
<p class="m-0 mt-2 text-sm"> <p class="m-0 mt-2 text-sm">
Note: This will not prevent any instance-specific Discord Rich Presence integrations, such Note: This will not prevent any instance-specific Discord Rich Presence integrations, such
as those added by mods. (app restart required to take effect) as those added by mods. (app restart required to take effect)
</p> </p>
</div> </div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" /> <Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
</div> </div>
</template> </template>

View File

@@ -1,117 +1,118 @@
<script setup> <script setup>
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { Button, injectNotificationManager, Slider } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { purge_cache_types } from '@/helpers/cache.js' import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.ts'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { Button, Slider, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value)) const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) { if (!setSettings.custom_dir) {
setSettings.custom_dir = null setSettings.custom_dir = null
} }
await set(setSettings) await set(setSettings)
}, },
{ deep: true }, { deep: true },
) )
async function purgeCache() { async function purgeCache() {
await purge_cache_types([ await purge_cache_types([
'project', 'project',
'version', 'version',
'user', 'user',
'team', 'team',
'organization', 'organization',
'loader_manifest', 'loader_manifest',
'minecraft_manifest', 'minecraft_manifest',
'categories', 'categories',
'report_types', 'report_types',
'loaders', 'loaders',
'game_versions', 'game_versions',
'donation_platforms', 'donation_platforms',
'file_update', 'file_update',
'search_results', 'search_results',
]).catch(handleError) ]).catch(handleError)
} }
async function findLauncherDir() { async function findLauncherDir() {
const newDir = await open({ const newDir = await open({
multiple: false, multiple: false,
directory: true, directory: true,
title: 'Select a new app directory', title: 'Select a new app directory',
}) })
if (newDir) { if (newDir) {
settings.value.custom_dir = newDir settings.value.custom_dir = newDir
} }
} }
</script> </script>
<template> <template>
<h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher. restarting the launcher.
</p> </p>
<div class="m-1 my-2"> <div class="m-1 my-2">
<div class="iconified-input w-full"> <div class="iconified-input w-full">
<BoxIcon /> <BoxIcon />
<input id="appDir" v-model="settings.custom_dir" type="text" class="input" /> <input id="appDir" v-model="settings.custom_dir" type="text" class="input" />
<Button class="r-btn" @click="findLauncherDir"> <Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon /> <FolderSearchIcon />
</Button> </Button>
</div> </div>
</div> </div>
<div> <div>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="purgeCacheConfirmModal" ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?" title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily." description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false" :has-to-type="false"
proceed-label="Purge cache" proceed-label="Purge cache"
:show-ad-on-close="false" :show-ad-on-close="false"
@proceed="purgeCache" @proceed="purgeCache"
/> />
<h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily. app to reload data. This may slow down the app temporarily.
</p> </p>
</div> </div>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()"> <button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon /> <TrashIcon />
Purge cache Purge cache
</button> </button>
<h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2> <h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect) value if you have a poor internet connection. (app restart required to take effect)
</p> </p>
<Slider <Slider
id="max-downloads" id="max-downloads"
v-model="settings.max_concurrent_downloads" v-model="settings.max_concurrent_downloads"
:min="1" :min="1"
:max="10" :max="10"
:step="1" :step="1"
/> />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2> <h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect) value if you are frequently getting I/O errors. (app restart required to take effect)
</p> </p>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" /> <Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</template> </template>

View File

@@ -1,136 +1,137 @@
<template> <template>
<UploadSkinModal ref="uploadModal" /> <UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState"> <ModalWrapper ref="modal" @on-hide="resetState">
<template #title> <template #title>
<span class="text-lg font-extrabold text-contrast"> <span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }} {{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
</span> </span>
</template> </template>
<div class="flex flex-col md:flex-row gap-6"> <div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative"> <div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0"> <div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer <SkinPreviewRenderer
:variant="variant" :variant="variant"
:texture-src="previewSkin || ''" :texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture" :cape-src="selectedCapeTexture"
:scale="1.4" :scale="1.4"
:fov="50" :fov="50"
:initial-rotation="Math.PI / 8" :initial-rotation="Math.PI / 8"
class="h-full w-full" class="h-full w-full"
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]"> <div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section> <section>
<h2 class="text-base font-semibold mb-2">Texture</h2> <h2 class="text-base font-semibold mb-2">Texture</h2>
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button> <Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
</section> </section>
<section> <section>
<h2 class="text-base font-semibold mb-2">Arm style</h2> <h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']"> <RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }"> <template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }} {{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template> </template>
</RadioButtons> </RadioButtons>
</section> </section>
<section> <section>
<h2 class="text-base font-semibold mb-2">Cape</h2> <h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2"> <div class="flex gap-2">
<CapeButton <CapeButton
v-if="defaultCape" v-if="defaultCape"
:id="defaultCape.id" :id="defaultCape.id"
:texture="defaultCape.texture" :texture="defaultCape.texture"
:name="undefined" :name="undefined"
:selected="!selectedCape" :selected="!selectedCape"
faded faded
@select="selectCape(undefined)" @select="selectCape(undefined)"
> >
<span>Use default cape</span> <span>Use default cape</span>
</CapeButton> </CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)"> <CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span> <span>Use default cape</span>
</CapeLikeTextButton> </CapeLikeTextButton>
<CapeButton <CapeButton
v-for="cape in visibleCapeList" v-for="cape in visibleCapeList"
:id="cape.id" :id="cape.id"
:key="cape.id" :key="cape.id"
:texture="cape.texture" :texture="cape.texture"
:name="cape.name || 'Cape'" :name="cape.name || 'Cape'"
:selected="selectedCape?.id === cape.id" :selected="selectedCape?.id === cape.id"
@select="selectCape(cape)" @select="selectCape(cape)"
/> />
<CapeLikeTextButton <CapeLikeTextButton
v-if="(capes?.length ?? 0) > 2" v-if="(capes?.length ?? 0) > 2"
tooltip="View more capes" tooltip="View more capes"
@mouseup="openSelectCapeModal" @mouseup="openSelectCapeModal"
> >
<template #icon><ChevronRightIcon /></template> <template #icon><ChevronRightIcon /></template>
<span>More</span> <span>More</span>
</CapeLikeTextButton> </CapeLikeTextButton>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
<div class="flex gap-2 mt-12"> <div class="flex gap-2 mt-12">
<ButtonStyled color="brand" :disabled="disableSave || isSaving"> <ButtonStyled color="brand" :disabled="disableSave || isSaving">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save"> <button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" /> <SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" /> <CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else /> <SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }} {{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button> <Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
</div> </div>
</ModalWrapper> </ModalWrapper>
<SelectCapeModal <SelectCapeModal
ref="selectCapeModal" ref="selectCapeModal"
:capes="capes || []" :capes="capes || []"
@select="handleCapeSelected" @select="handleCapeSelected"
@cancel="handleCapeCancel" @cancel="handleCapeCancel"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {
CheckIcon,
ChevronRightIcon,
SaveIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
CapeButton,
CapeLikeTextButton,
injectNotificationManager,
RadioButtons,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue' import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue' import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { import {
add_and_equip_custom_skin, add_and_equip_custom_skin,
determineModelType, type Cape,
get_normalized_skin_texture, determineModelType,
remove_custom_skin, get_normalized_skin_texture,
unequip_skin, remove_custom_skin,
type Cape, type Skin,
type Skin, type SkinModel,
type SkinModel, unequip_skin,
} from '@/helpers/skins.ts' } from '@/helpers/skins.ts'
import {
CheckIcon,
ChevronRightIcon,
SaveIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
CapeButton,
CapeLikeTextButton,
injectNotificationManager,
RadioButtons,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -152,264 +153,264 @@ const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const visibleCapeList = ref<Cape[]>([]) const visibleCapeList = ref<Cape[]>([])
const sortedCapes = computed(() => { const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => { return [...(props.capes || [])].sort((a, b) => {
const nameA = (a.name || '').toLowerCase() const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase() const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB) return nameA.localeCompare(nameB)
}) })
}) })
function initVisibleCapeList() { function initVisibleCapeList() {
if (!props.capes || props.capes.length === 0) { if (!props.capes || props.capes.length === 0) {
visibleCapeList.value = [] visibleCapeList.value = []
return return
} }
if (visibleCapeList.value.length === 0) { if (visibleCapeList.value.length === 0) {
if (selectedCape.value) { if (selectedCape.value) {
const otherCape = getSortedCapeExcluding(selectedCape.value.id) const otherCape = getSortedCapeExcluding(selectedCape.value.id)
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value] visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
} else { } else {
visibleCapeList.value = getSortedCapes(2) visibleCapeList.value = getSortedCapes(2)
} }
} }
} }
function getSortedCapes(count: number): Cape[] { function getSortedCapes(count: number): Cape[] {
if (!sortedCapes.value || sortedCapes.value.length === 0) return [] if (!sortedCapes.value || sortedCapes.value.length === 0) return []
return sortedCapes.value.slice(0, count) return sortedCapes.value.slice(0, count)
} }
function getSortedCapeExcluding(excludeId: string): Cape | undefined { function getSortedCapeExcluding(excludeId: string): Cape | undefined {
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
return sortedCapes.value.find((cape) => cape.id !== excludeId) return sortedCapes.value.find((cape) => cape.id !== excludeId)
} }
async function loadPreviewSkin() { async function loadPreviewSkin() {
if (uploadedTextureUrl.value) { if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value previewSkin.value = uploadedTextureUrl.value
} else if (currentSkin.value) { } else if (currentSkin.value) {
try { try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value) previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
} catch (error) { } catch (error) {
console.error('Failed to load skin texture:', error) console.error('Failed to load skin texture:', error)
previewSkin.value = '/src/assets/skins/steve.png' previewSkin.value = '/src/assets/skins/steve.png'
} }
} else { } else {
previewSkin.value = '/src/assets/skins/steve.png' previewSkin.value = '/src/assets/skins/steve.png'
} }
} }
const hasEdits = computed(() => { const hasEdits = computed(() => {
if (mode.value !== 'edit') return true if (mode.value !== 'edit') return true
if (uploadedTextureUrl.value) return true if (uploadedTextureUrl.value) return true
if (!currentSkin.value) return false if (!currentSkin.value) return false
if (variant.value !== currentSkin.value.variant) return true if (variant.value !== currentSkin.value.variant) return true
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
return false return false
}) })
const disableSave = computed( const disableSave = computed(
() => () =>
(mode.value === 'new' && !uploadedTextureUrl.value) || (mode.value === 'new' && !uploadedTextureUrl.value) ||
(mode.value === 'edit' && !hasEdits.value), (mode.value === 'edit' && !hasEdits.value),
) )
const saveTooltip = computed(() => { const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...' if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!' if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!' if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
return undefined return undefined
}) })
function resetState() { function resetState() {
mode.value = 'new' mode.value = 'new'
currentSkin.value = null currentSkin.value = null
uploadedTextureUrl.value = null uploadedTextureUrl.value = null
previewSkin.value = '' previewSkin.value = ''
variant.value = 'CLASSIC' variant.value = 'CLASSIC'
selectedCape.value = undefined selectedCape.value = undefined
visibleCapeList.value = [] visibleCapeList.value = []
shouldRestoreModal.value = false shouldRestoreModal.value = false
isSaving.value = false isSaving.value = false
} }
async function show(e: MouseEvent, skin?: Skin) { async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new' mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null currentSkin.value = skin ?? null
if (skin) { if (skin) {
variant.value = skin.variant variant.value = skin.variant
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id) selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
} else { } else {
variant.value = 'CLASSIC' variant.value = 'CLASSIC'
selectedCape.value = undefined selectedCape.value = undefined
} }
visibleCapeList.value = [] visibleCapeList.value = []
initVisibleCapeList() initVisibleCapeList()
await loadPreviewSkin() await loadPreviewSkin()
modal.value?.show(e) modal.value?.show(e)
} }
async function showNew(e: MouseEvent, skinTextureUrl: string) { async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new' mode.value = 'new'
currentSkin.value = null currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl) variant.value = await determineModelType(skinTextureUrl)
selectedCape.value = undefined selectedCape.value = undefined
visibleCapeList.value = [] visibleCapeList.value = []
initVisibleCapeList() initVisibleCapeList()
await loadPreviewSkin() await loadPreviewSkin()
modal.value?.show(e) modal.value?.show(e)
} }
async function restoreWithNewTexture(skinTextureUrl: string) { async function restoreWithNewTexture(skinTextureUrl: string) {
uploadedTextureUrl.value = skinTextureUrl uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin() await loadPreviewSkin()
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
modal.value?.show() modal.value?.show()
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 0) }, 0)
} }
} }
function hide() { function hide() {
modal.value?.hide() modal.value?.hide()
setTimeout(() => resetState(), 250) setTimeout(() => resetState(), 250)
} }
function selectCape(cape: Cape | undefined) { function selectCape(cape: Cape | undefined) {
if (cape && selectedCape.value?.id !== cape.id) { if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id) const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
if (!isInVisibleList && visibleCapeList.value.length > 0) { if (!isInVisibleList && visibleCapeList.value.length > 0) {
visibleCapeList.value.splice(0, 1, cape) visibleCapeList.value.splice(0, 1, cape)
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) { if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
const otherCape = getSortedCapeExcluding(cape.id) const otherCape = getSortedCapeExcluding(cape.id)
if (otherCape) { if (otherCape) {
visibleCapeList.value.splice(1, 1, otherCape) visibleCapeList.value.splice(1, 1, otherCape)
} }
} }
} }
} }
selectedCape.value = cape selectedCape.value = cape
} }
function handleCapeSelected(cape: Cape | undefined) { function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape) selectCape(cape)
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
modal.value?.show() modal.value?.show()
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 0) }, 0)
} }
} }
function handleCapeCancel() { function handleCapeCancel() {
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
modal.value?.show() modal.value?.show()
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 0) }, 0)
} }
} }
function openSelectCapeModal(e: MouseEvent) { function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return if (!selectCapeModal.value) return
shouldRestoreModal.value = true shouldRestoreModal.value = true
modal.value?.hide() modal.value?.hide()
setTimeout(() => { setTimeout(() => {
selectCapeModal.value?.show( selectCapeModal.value?.show(
e, e,
currentSkin.value?.texture_key, currentSkin.value?.texture_key,
selectedCape.value, selectedCape.value,
previewSkin.value, previewSkin.value,
variant.value, variant.value,
) )
}, 0) }, 0)
} }
function openUploadSkinModal(e: MouseEvent) { function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true shouldRestoreModal.value = true
modal.value?.hide() modal.value?.hide()
emit('open-upload-modal', e) emit('open-upload-modal', e)
} }
function restoreModal() { function restoreModal() {
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
const fakeEvent = new MouseEvent('click') const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent) modal.value?.show(fakeEvent)
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 500) }, 500)
} }
} }
async function save() { async function save() {
isSaving.value = true isSaving.value = true
try { try {
let textureUrl: string let textureUrl: string
if (uploadedTextureUrl.value) { if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value textureUrl = uploadedTextureUrl.value
} else { } else {
textureUrl = currentSkin.value!.texture textureUrl = currentSkin.value!.texture
} }
await unequip_skin() await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer()) const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') { if (mode.value === 'new') {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value) await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved') emit('saved')
} else { } else {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value) await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!) await remove_custom_skin(currentSkin.value!)
emit('saved') emit('saved')
} }
hide() hide()
} catch (err) { } catch (err) {
handleError(err) handleError(err)
} finally { } finally {
isSaving.value = false isSaving.value = false
} }
} }
watch([uploadedTextureUrl, currentSkin], async () => { watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin() await loadPreviewSkin()
}) })
watch( watch(
() => props.capes, () => props.capes,
() => { () => {
initVisibleCapeList() initVisibleCapeList()
}, },
{ immediate: true }, { immediate: true },
) )
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'saved'): void (event: 'saved'): void
(event: 'deleted', skin: Skin): void (event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void (event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>() }>()
defineExpose({ defineExpose({
show, show,
showNew, showNew,
restoreWithNewTexture, restoreWithNewTexture,
hide, hide,
shouldRestoreModal, shouldRestoreModal,
restoreModal, restoreModal,
}) })
</script> </script>

View File

@@ -1,33 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef, ref, computed } from 'vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
import {
ButtonStyled,
ScrollablePanel,
CapeButton,
CapeLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { CheckIcon, XIcon } from '@modrinth/assets' import { CheckIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
ScrollablePanel,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void (e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void (e: 'cancel'): void
}>() }>()
const props = defineProps<{ const props = defineProps<{
capes: Cape[] capes: Cape[]
}>() }>()
const sortedCapes = computed(() => { const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => { return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase() const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase() const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB) return nameA.localeCompare(nameB)
}) })
}) })
const currentSkinId = ref<string | undefined>() const currentSkinId = ref<string | undefined>()
@@ -37,104 +38,104 @@ const currentCapeTexture = computed<string | undefined>(() => currentCape.value?
const currentCape = ref<Cape | undefined>() const currentCape = ref<Cape | undefined>()
function show( function show(
e: MouseEvent, e: MouseEvent,
skinId?: string, skinId?: string,
selected?: Cape, selected?: Cape,
skinTexture?: string, skinTexture?: string,
variant?: SkinModel, variant?: SkinModel,
) { ) {
currentSkinId.value = skinId currentSkinId.value = skinId
currentSkinTexture.value = skinTexture currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC' currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected currentCape.value = selected
modal.value?.show(e) modal.value?.show(e)
} }
function select() { function select() {
emit('select', currentCape.value) emit('select', currentCape.value)
hide() hide()
} }
function hide() { function hide() {
modal.value?.hide() modal.value?.hide()
emit('cancel') emit('cancel')
} }
function updateSelectedCape(cape: Cape | undefined) { function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape currentCape.value = cape
} }
function onModalHide() { function onModalHide() {
emit('cancel') emit('cancel')
} }
defineExpose({ defineExpose({
show, show,
hide, hide,
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="modal" @on-hide="onModalHide"> <ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title> <template #title>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span> <span class="text-lg font-extrabold text-heading">Change cape</span>
</div> </div>
</template> </template>
<div class="flex flex-col md:flex-row gap-6"> <div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative"> <div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0"> <div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer <SkinPreviewRenderer
v-if="currentSkinTexture" v-if="currentSkinTexture"
:cape-src="currentCapeTexture" :cape-src="currentCapeTexture"
:texture-src="currentSkinTexture" :texture-src="currentSkinTexture"
:variant="currentSkinVariant" :variant="currentSkinVariant"
:scale="1.4" :scale="1.4"
:fov="50" :fov="50"
:initial-rotation="Math.PI + Math.PI / 8" :initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full" class="h-full w-full"
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-4 w-full my-auto"> <div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full"> <ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full"> <div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton <CapeLikeTextButton
tooltip="No Cape" tooltip="No Cape"
:highlighted="!currentCape" :highlighted="!currentCape"
@click="updateSelectedCape(undefined)" @click="updateSelectedCape(undefined)"
> >
<template #icon> <template #icon>
<XIcon /> <XIcon />
</template> </template>
<span>None</span> <span>None</span>
</CapeLikeTextButton> </CapeLikeTextButton>
<CapeButton <CapeButton
v-for="cape in sortedCapes" v-for="cape in sortedCapes"
:id="cape.id" :id="cape.id"
:key="cape.id" :key="cape.id"
:name="cape.name" :name="cape.name"
:texture="cape.texture" :texture="cape.texture"
:selected="currentCape?.id === cape.id" :selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)" @select="updateSelectedCape(cape)"
/> />
</div> </div>
</ScrollablePanel> </ScrollablePanel>
</div> </div>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button @click="select"> <button @click="select">
<CheckIcon /> <CheckIcon />
Select Select
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="hide"> <button @click="hide">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,39 +1,40 @@
<template> <template>
<ModalWrapper ref="modal" @on-hide="hide(true)"> <ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title> <template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span> <span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template> </template>
<div class="relative"> <div class="relative">
<div <div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative" class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput" @click="triggerFileInput"
> >
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2"> <p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file <UploadIcon /> Select skin texture file
</p> </p>
<p class="mx-auto mt-0 text-secondary text-sm text-center"> <p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse Drag and drop or click here to browse
</p> </p>
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
accept="image/png" accept="image/png"
class="hidden" class="hidden"
@change="handleInputFileChange" @change="handleInputFileChange"
/> />
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
import { UploadIcon } from '@modrinth/assets' import { UploadIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui' import { injectNotificationManager } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview'
import { onBeforeUnmount, ref, watch } from 'vue' import { onBeforeUnmount, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const modal = ref() const modal = ref()
@@ -42,98 +43,98 @@ const unlisten = ref<() => void>()
const modalVisible = ref(false) const modalVisible = ref(false)
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'uploaded', data: ArrayBuffer): void (e: 'uploaded', data: ArrayBuffer): void
(e: 'canceled'): void (e: 'canceled'): void
}>() }>()
function show(e?: MouseEvent) { function show(e?: MouseEvent) {
modal.value?.show(e) modal.value?.show(e)
modalVisible.value = true modalVisible.value = true
setupDragDropListener() setupDragDropListener()
} }
function hide(emitCanceled = false) { function hide(emitCanceled = false) {
modal.value?.hide() modal.value?.hide()
modalVisible.value = false modalVisible.value = false
cleanupDragDropListener() cleanupDragDropListener()
resetState() resetState()
if (emitCanceled) { if (emitCanceled) {
emit('canceled') emit('canceled')
} }
} }
function resetState() { function resetState() {
if (fileInput.value) fileInput.value.value = '' if (fileInput.value) fileInput.value.value = ''
} }
function triggerFileInput() { function triggerFileInput() {
fileInput.value?.click() fileInput.value?.click()
} }
async function handleInputFileChange(e: Event) { async function handleInputFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) { if (!files || files.length === 0) {
return return
} }
const file = files[0] const file = files[0]
const buffer = await file.arrayBuffer() const buffer = await file.arrayBuffer()
await processData(buffer) await processData(buffer)
} }
async function setupDragDropListener() { async function setupDragDropListener() {
try { try {
if (modalVisible.value) { if (modalVisible.value) {
await cleanupDragDropListener() await cleanupDragDropListener()
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => { unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') { if (event.payload.type !== 'drop') {
return return
} }
if (!event.payload.paths || event.payload.paths.length === 0) { if (!event.payload.paths || event.payload.paths.length === 0) {
return return
} }
const filePath = event.payload.paths[0] const filePath = event.payload.paths[0]
try { try {
const data = await get_dragged_skin_data(filePath) const data = await get_dragged_skin_data(filePath)
await processData(data.buffer) await processData(data.buffer)
} catch (error) { } catch (error) {
addNotification({ addNotification({
title: 'Error processing file', title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.', text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error', type: 'error',
}) })
} }
}) })
} }
} catch (error) { } catch (error) {
console.error('Failed to set up drag and drop listener:', error) console.error('Failed to set up drag and drop listener:', error)
} }
} }
async function cleanupDragDropListener() { async function cleanupDragDropListener() {
if (unlisten.value) { if (unlisten.value) {
unlisten.value() unlisten.value()
unlisten.value = undefined unlisten.value = undefined
} }
} }
async function processData(buffer: ArrayBuffer) { async function processData(buffer: ArrayBuffer) {
emit('uploaded', buffer) emit('uploaded', buffer)
hide() hide()
} }
watch(modalVisible, (isVisible) => { watch(modalVisible, (isVisible) => {
if (isVisible) { if (isVisible) {
setupDragDropListener() setupDragDropListener()
} else { } else {
cleanupDragDropListener() cleanupDragDropListener()
} }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
cleanupDragDropListener() cleanupDragDropListener()
}) })
defineExpose({ show, hide }) defineExpose({ show, hide })

View File

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

View File

@@ -1,4 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue' import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
@@ -7,28 +12,24 @@ import { get_all } from '@/helpers/process'
import { kill, run } from '@/helpers/profile' import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
type ProtocolVersion, get_profile_protocol_version,
type ServerData, get_recent_worlds,
type ServerWorld, getWorldIdentifier,
type WorldWithProfile, type ProtocolVersion,
getWorldIdentifier, refreshServerData,
get_profile_protocol_version, type ServerData,
get_recent_worlds, type ServerWorld,
refreshServerData, start_join_server,
start_join_server, start_join_singleplayer_world,
start_join_singleplayer_world, type WorldWithProfile,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.ts'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const props = defineProps<{ const props = defineProps<{
recentInstances: GameInstance[] recentInstances: GameInstance[]
}>() }>()
const theme = useTheming() const theme = useTheming()
@@ -42,17 +43,17 @@ const MAX_JUMP_BACK_IN = 6
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day') const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
type BaseJumpBackInItem = { type BaseJumpBackInItem = {
last_played: Dayjs last_played: Dayjs
instance: GameInstance instance: GameInstance
} }
type InstanceJumpBackInItem = BaseJumpBackInItem & { type InstanceJumpBackInItem = BaseJumpBackInItem & {
type: 'instance' type: 'instance'
} }
type WorldJumpBackInItem = BaseJumpBackInItem & { type WorldJumpBackInItem = BaseJumpBackInItem & {
type: 'world' type: 'world'
world: WorldWithProfile world: WorldWithProfile
} }
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
@@ -60,244 +61,244 @@ type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home')) const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
watch([() => props.recentInstances, () => showWorlds.value], async () => { watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
}) })
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
async function populateJumpBackIn() { async function populateJumpBackIn() {
console.info('Repopulating jump back in...') console.info('Repopulating jump back in...')
const worldItems: WorldJumpBackInItem[] = [] const worldItems: WorldJumpBackInItem[] = []
if (showWorlds.value) { if (showWorlds.value) {
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite']) const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
worlds.forEach((world) => { worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile) const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (!instance || !world.last_played) { if (!instance || !world.last_played) {
return return
} }
worldItems.push({ worldItems.push({
type: 'world', type: 'world',
last_played: dayjs(world.last_played ?? 0), last_played: dayjs(world.last_played ?? 0),
world: world, world: world,
instance: instance, instance: instance,
}) })
}) })
const servers: { const servers: {
instancePath: string instancePath: string
address: string address: string
}[] = worldItems }[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance) .filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({ .map((item) => ({
instancePath: item.instance.path, instancePath: item.instance.path,
address: (item.world as ServerWorld).address, address: (item.world as ServerWorld).address,
})) }))
// fetch protocol versions for all unique MC versions with server worlds // fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath)) const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all( await Promise.all(
[...uniqueServerInstances].map((path) => [...uniqueServerInstances].map((path) =>
get_profile_protocol_version(path) get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer)) .then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => { .catch(() => {
console.error(`Failed to get profile protocol for: ${path} `) console.error(`Failed to get profile protocol for: ${path} `)
}), }),
), ),
) )
// initialize server data // initialize server data
servers.forEach(({ address }) => { servers.forEach(({ address }) => {
if (!serverData.value[address]) { if (!serverData.value[address]) {
serverData.value[address] = { serverData.value[address] = {
refreshing: true, refreshing: true,
} }
} }
}) })
servers.forEach(({ instancePath, address }) => servers.forEach(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address), refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
) )
} }
const instanceItems: InstanceJumpBackInItem[] = [] const instanceItems: InstanceJumpBackInItem[] = []
for (const instance of props.recentInstances) { for (const instance of props.recentInstances) {
const worldItem = worldItems.find((item) => item.instance.path === instance.path) const worldItem = worldItems.find((item) => item.instance.path === instance.path)
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) { if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
continue continue
} }
instanceItems.push({ instanceItems.push({
type: 'instance', type: 'instance',
last_played: dayjs(instance.last_played ?? 0), last_played: dayjs(instance.last_played ?? 0),
instance: instance, instance: instance,
}) })
} }
const items: JumpBackInItem[] = [...worldItems, ...instanceItems] const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))) items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
jumpBackInItems.value = items jumpBackInItems.value = items
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO)) .filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
.slice(0, MAX_JUMP_BACK_IN) .slice(0, MAX_JUMP_BACK_IN)
} }
function refreshServer(address: string, instancePath: string) { function refreshServer(address: string, instancePath: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address) refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
} }
async function joinWorld(world: WorldWithProfile) { async function joinWorld(world: WorldWithProfile) {
console.log(`Joining world ${getWorldIdentifier(world)}`) console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') { if (world.type === 'server') {
await start_join_server(world.profile, world.address).catch(handleError) await start_join_server(world.profile, world.address).catch(handleError)
} else if (world.type === 'singleplayer') { } else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(world.profile, world.path).catch(handleError) await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
} }
} }
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
await run(instance.path) await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path })) .catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => { .finally(() => {
trackEvent('InstancePlay', { trackEvent('InstancePlay', {
loader: instance.loader, loader: instance.loader,
game_version: instance.game_version, game_version: instance.game_version,
source: 'WorldItem', source: 'WorldItem',
}) })
}) })
} }
async function stopInstance(path: string) { async function stopInstance(path: string) {
await kill(path).catch(handleError) await kill(path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
source: 'RecentWorldsList', source: 'RecentWorldsList',
}) })
} }
const currentProfile = ref<string>() const currentProfile = ref<string>()
const currentWorld = ref<string>() const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => { const unlistenProcesses = await process_listener(async () => {
await checkProcesses() await checkProcesses()
}) })
const unlistenProfiles = await profile_listener(async () => { const unlistenProfiles = await profile_listener(async () => {
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
}) })
const runningInstances = ref<string[]>([]) const runningInstances = ref<string[]>([])
type ProcessMetadata = { type ProcessMetadata = {
uuid: string uuid: string
profile_path: string profile_path: string
start_time: string start_time: string
} }
const checkProcesses = async () => { const checkProcesses = async () => {
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError) const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
const runningPaths = runningProcesses.map((x) => x.profile_path) const runningPaths = runningProcesses.map((x) => x.profile_path)
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x)) const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) { if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
currentProfile.value = undefined currentProfile.value = undefined
currentWorld.value = undefined currentWorld.value = undefined
} }
runningInstances.value = runningPaths runningInstances.value = runningPaths
} }
onMounted(() => { onMounted(() => {
checkProcesses() checkProcesses()
}) })
onUnmounted(() => { onUnmounted(() => {
unlistenProcesses() unlistenProcesses()
unlistenProfiles() unlistenProfiles()
}) })
</script> </script>
<template> <template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2"> <div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1"> <HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in Jump back in
</HeadingLink> </HeadingLink>
<span <span
v-else v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold" class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
> >
Jump back in Jump back in
</span> </span>
<div class="grid-when-huge flex flex-col w-full gap-2"> <div class="grid-when-huge flex flex-col w-full gap-2">
<template <template
v-for="item in jumpBackInItems" v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`" :key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
> >
<WorldItem <WorldItem
v-if="item.type === 'world'" v-if="item.type === 'world'"
:world="item.world" :world="item.world"
:playing-instance="runningInstances.includes(item.instance.path)" :playing-instance="runningInstances.includes(item.instance.path)"
:playing-world=" :playing-world="
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world) currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
" "
:refreshing=" :refreshing="
item.world.type === 'server' item.world.type === 'server'
? serverData[item.world.address].refreshing && !serverData[item.world.address].status ? serverData[item.world.address].refreshing && !serverData[item.world.address].status
: undefined : undefined
" "
supports-quick-play supports-quick-play
:server-status=" :server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined item.world.type === 'server' ? serverData[item.world.address].status : undefined
" "
:rendered-motd=" :rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
" "
:current-protocol="protocolVersions[item.instance.path]" :current-protocol="protocolVersions[item.instance.path]"
:game-mode=" :game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
" "
:instance-path="item.instance.path" :instance-path="item.instance.path"
:instance-name="item.instance.name" :instance-name="item.instance.name"
:instance-icon="item.instance.icon_path" :instance-icon="item.instance.icon_path"
@refresh=" @refresh="
() => () =>
item.world.type === 'server' item.world.type === 'server'
? refreshServer(item.world.address, item.instance.path) ? refreshServer(item.world.address, item.instance.path)
: {} : {}
" "
@update="() => populateJumpBackIn()" @update="() => populateJumpBackIn()"
@play=" @play="
() => { () => {
currentProfile = item.instance.path currentProfile = item.instance.path
currentWorld = getWorldIdentifier(item.world) currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world) joinWorld(item.world)
} }
" "
@play-instance=" @play-instance="
() => { () => {
currentProfile = item.instance.path currentProfile = item.instance.path
playInstance(item.instance) playInstance(item.instance)
} }
" "
@stop="() => stopInstance(item.instance.path)" @stop="() => stopInstance(item.instance.path)"
/> />
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" /> <InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.grid-when-huge { .grid-when-huge {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl' import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue' import { computed } from 'vue'
import { Checkbox } from '@modrinth/ui'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const value = defineModel<boolean>({ required: true }) const value = defineModel<boolean>({ required: true })
const labelMessage = defineMessage({ const labelMessage = defineMessage({
id: 'instance.edit-world.hide-from-home', id: 'instance.edit-world.hide-from-home',
defaultMessage: `Hide from the Home page`, defaultMessage: `Hide from the Home page`,
}) })
const label = computed(() => formatMessage(labelMessage)) const label = computed(() => formatMessage(labelMessage))
</script> </script>
<template> <template>
<Checkbox v-model="value" :label="label" /> <Checkbox v-model="value" :label="label" />
</template> </template>

View File

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

View File

@@ -1,20 +1,21 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import cssContent from '@/assets/stylesheets/macFix.css?inline' import cssContent from '@/assets/stylesheets/macFix.css?inline'
export async function useCheckDisableMouseover() { export async function useCheckDisableMouseover() {
try { try {
// Fetch the CSS content from the Rust backend // Fetch the CSS content from the Rust backend
let should_disable_mouseover = await invoke('plugin:utils|should_disable_mouseover') let should_disable_mouseover = await invoke('plugin:utils|should_disable_mouseover')
if (should_disable_mouseover) { if (should_disable_mouseover) {
// Create a style element and set its content // Create a style element and set its content
const styleElement = document.createElement('style') const styleElement = document.createElement('style')
styleElement.innerHTML = cssContent styleElement.innerHTML = cssContent
// Append the style element to the document's head // Append the style element to the document's head
document.head.appendChild(styleElement) document.head.appendChild(styleElement)
} }
} catch (error) { } catch (error) {
console.error('Error checking OS version from Rust backend', error) console.error('Error checking OS version from Rust backend', error)
} }
} }

View File

@@ -1,22 +1,23 @@
import { get_max_memory } from '@/helpers/jre.js'
import { injectNotificationManager } from '@modrinth/ui' import { injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
export default async function () { export default async function () {
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024)) const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
const snapPoints = computed(() => { const snapPoints = computed(() => {
let points = [] let points = []
let memory = 2048 let memory = 2048
while (memory <= maxMemory.value) { while (memory <= maxMemory.value) {
points.push(memory) points.push(memory)
memory *= 2 memory *= 2
} }
return points return points
}) })
return { maxMemory, snapPoints } return { maxMemory, snapPoints }
} }

View File

@@ -1,21 +1,24 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function init_ads_window(overrideShown = false) { export async function init_ads_window(overrideShown = false) {
return await invoke('plugin:ads|init_ads_window', { overrideShown, dpr: window.devicePixelRatio }) return await invoke('plugin:ads|init_ads_window', {
overrideShown,
dpr: window.devicePixelRatio,
})
} }
export async function show_ads_window() { export async function show_ads_window() {
return await invoke('plugin:ads|show_ads_window', { dpr: window.devicePixelRatio }) return await invoke('plugin:ads|show_ads_window', { dpr: window.devicePixelRatio })
} }
export async function hide_ads_window(reset) { export async function hide_ads_window(reset) {
return await invoke('plugin:ads|hide_ads_window', { reset }) return await invoke('plugin:ads|hide_ads_window', { reset })
} }
export async function record_ads_click() { export async function record_ads_click() {
return await invoke('plugin:ads|record_ads_click') return await invoke('plugin:ads|record_ads_click')
} }
export async function open_ads_link(path, origin) { export async function open_ads_link(path, origin) {
return await invoke('plugin:ads|open_link', { path, origin }) return await invoke('plugin:ads|open_link', { path, origin })
} }

View File

@@ -1,24 +1,24 @@
import { posthog } from 'posthog-js' import { posthog } from 'posthog-js'
export const initAnalytics = () => { export const initAnalytics = () => {
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', { posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage', persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com', api_host: 'https://posthog.modrinth.com',
}) })
} }
export const debugAnalytics = () => { export const debugAnalytics = () => {
posthog.debug() posthog.debug()
} }
export const optOutAnalytics = () => { export const optOutAnalytics = () => {
posthog.opt_out_capturing() posthog.opt_out_capturing()
} }
export const optInAnalytics = () => { export const optInAnalytics = () => {
posthog.opt_in_capturing() posthog.opt_in_capturing()
} }
export const trackEvent = (eventName, properties) => { export const trackEvent = (eventName, properties) => {
posthog.capture(eventName, properties) posthog.capture(eventName, properties)
} }

View File

@@ -22,7 +22,7 @@ import { invoke } from '@tauri-apps/api/core'
* @property {string} user_code - The code to enter on the verification_uri page. * @property {string} user_code - The code to enter on the verification_uri page.
*/ */
export async function login() { export async function login() {
return await invoke('plugin:auth|login') return await invoke('plugin:auth|login')
} }
/** /**
@@ -30,7 +30,7 @@ export async function login() {
* @return {Promise<UUID | undefined>} * @return {Promise<UUID | undefined>}
*/ */
export async function get_default_user() { export async function get_default_user() {
return await invoke('plugin:auth|get_default_user') return await invoke('plugin:auth|get_default_user')
} }
/** /**
@@ -38,7 +38,7 @@ export async function get_default_user() {
* @param {UUID} user * @param {UUID} user
*/ */
export async function set_default_user(user) { export async function set_default_user(user) {
return await invoke('plugin:auth|set_default_user', { user }) return await invoke('plugin:auth|set_default_user', { user })
} }
/** /**
@@ -46,7 +46,7 @@ export async function set_default_user(user) {
* @param {UUID} user * @param {UUID} user
*/ */
export async function remove_user(user) { export async function remove_user(user) {
return await invoke('plugin:auth|remove_user', { user }) return await invoke('plugin:auth|remove_user', { user })
} }
/** /**
@@ -54,5 +54,5 @@ export async function remove_user(user) {
* @returns {Promise<Credential[]>} * @returns {Promise<Credential[]>}
*/ */
export async function users() { export async function users() {
return await invoke('plugin:auth|get_users') return await invoke('plugin:auth|get_users')
} }

View File

@@ -1,53 +1,53 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function get_project(id, cacheBehaviour) { export async function get_project(id, cacheBehaviour) {
return await invoke('plugin:cache|get_project', { id, cacheBehaviour }) return await invoke('plugin:cache|get_project', { id, cacheBehaviour })
} }
export async function get_project_many(ids, cacheBehaviour) { export async function get_project_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour })
} }
export async function get_version(id, cacheBehaviour) { export async function get_version(id, cacheBehaviour) {
return await invoke('plugin:cache|get_version', { id, cacheBehaviour }) return await invoke('plugin:cache|get_version', { id, cacheBehaviour })
} }
export async function get_version_many(ids, cacheBehaviour) { export async function get_version_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_version_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_version_many', { ids, cacheBehaviour })
} }
export async function get_user(id, cacheBehaviour) { export async function get_user(id, cacheBehaviour) {
return await invoke('plugin:cache|get_user', { id, cacheBehaviour }) return await invoke('plugin:cache|get_user', { id, cacheBehaviour })
} }
export async function get_user_many(ids, cacheBehaviour) { export async function get_user_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_user_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_user_many', { ids, cacheBehaviour })
} }
export async function get_team(id, cacheBehaviour) { export async function get_team(id, cacheBehaviour) {
return await invoke('plugin:cache|get_team', { id, cacheBehaviour }) return await invoke('plugin:cache|get_team', { id, cacheBehaviour })
} }
export async function get_team_many(ids, cacheBehaviour) { export async function get_team_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_team_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_team_many', { ids, cacheBehaviour })
} }
export async function get_organization(id, cacheBehaviour) { export async function get_organization(id, cacheBehaviour) {
return await invoke('plugin:cache|get_organization', { id, cacheBehaviour }) return await invoke('plugin:cache|get_organization', { id, cacheBehaviour })
} }
export async function get_organization_many(ids, cacheBehaviour) { export async function get_organization_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_organization_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_organization_many', { ids, cacheBehaviour })
} }
export async function get_search_results(id, cacheBehaviour) { export async function get_search_results(id, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results', { id, cacheBehaviour }) return await invoke('plugin:cache|get_search_results', { id, cacheBehaviour })
} }
export async function get_search_results_many(ids, cacheBehaviour) { export async function get_search_results_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour })
} }
export async function purge_cache_types(cacheTypes) { export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes }) return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
} }

View File

@@ -41,7 +41,7 @@ import { listen } from '@tauri-apps/api/event'
} }
*/ */
export async function loading_listener(callback) { export async function loading_listener(callback) {
return await listen('loading', (event) => callback(event.payload)) return await listen('loading', (event) => callback(event.payload))
} }
/// Payload for the 'process' event /// Payload for the 'process' event
@@ -54,7 +54,7 @@ export async function loading_listener(callback) {
} }
*/ */
export async function process_listener(callback) { export async function process_listener(callback) {
return await listen('process', (event) => callback(event.payload)) return await listen('process', (event) => callback(event.payload))
} }
/// Payload for the 'profile' event /// Payload for the 'profile' event
@@ -68,7 +68,7 @@ export async function process_listener(callback) {
} }
*/ */
export async function profile_listener(callback) { export async function profile_listener(callback) {
return await listen('profile', (event) => callback(event.payload)) return await listen('profile', (event) => callback(event.payload))
} }
/// Payload for the 'command' event /// Payload for the 'command' event
@@ -79,9 +79,9 @@ export async function profile_listener(callback) {
} }
*/ */
export async function command_listener(callback) { export async function command_listener(callback) {
return await listen('command', (event) => { return await listen('command', (event) => {
callback(event.payload) callback(event.payload)
}) })
} }
/// Payload for the 'warning' event /// Payload for the 'warning' event
@@ -91,9 +91,9 @@ export async function command_listener(callback) {
} }
*/ */
export async function warning_listener(callback) { export async function warning_listener(callback) {
return await listen('warning', (event) => callback(event.payload)) return await listen('warning', (event) => callback(event.payload))
} }
export async function friend_listener(callback) { export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload)) return await listen('friend', (event) => callback(event.payload))
} }

View File

@@ -3,17 +3,17 @@ import { getVersion } from '@tauri-apps/api/app'
import { fetch } from '@tauri-apps/plugin-http' import { fetch } from '@tauri-apps/plugin-http'
export const useFetch = async (url, item, isSilent) => { export const useFetch = async (url, item, isSilent) => {
try { try {
const version = await getVersion() const version = await getVersion()
return await fetch(url, { return await fetch(url, {
method: 'GET', method: 'GET',
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` }, headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
}) })
} catch (err) { } catch (err) {
if (!isSilent) { if (!isSilent) {
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
handleError({ message: `Error fetching ${item}` }) handleError({ message: `Error fetching ${item}` })
} }
console.error(err) console.error(err)
} }
} }

View File

@@ -1,17 +1,17 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function friends() { export async function friends() {
return await invoke('plugin:friends|friends') return await invoke('plugin:friends|friends')
} }
export async function friend_statuses() { export async function friend_statuses() {
return await invoke('plugin:friends|friend_statuses') return await invoke('plugin:friends|friend_statuses')
} }
export async function add_friend(userId) { export async function add_friend(userId) {
return await invoke('plugin:friends|add_friend', { userId }) return await invoke('plugin:friends|add_friend', { userId })
} }
export async function remove_friend(userId) { export async function remove_friend(userId) {
return await invoke('plugin:friends|remove_friend', { userId }) return await invoke('plugin:friends|remove_friend', { userId })
} }

View File

@@ -4,6 +4,7 @@
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { create } from './profile' import { create } from './profile'
/* /*
@@ -27,37 +28,37 @@ import { create } from './profile'
/// eg: get_importable_instances("MultiMC", "C:/MultiMC") /// eg: get_importable_instances("MultiMC", "C:/MultiMC")
/// returns ["Instance 1", "Instance 2"] /// returns ["Instance 1", "Instance 2"]
export async function get_importable_instances(launcherType, basePath) { export async function get_importable_instances(launcherType, basePath) {
return await invoke('plugin:import|get_importable_instances', { launcherType, basePath }) return await invoke('plugin:import|get_importable_instances', { launcherType, basePath })
} }
/// Import an instance from a launcher type and base path /// Import an instance from a launcher type and base path
/// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1") /// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1")
export async function import_instance(launcherType, basePath, instanceFolder) { export async function import_instance(launcherType, basePath, instanceFolder) {
// create a basic, empty instance (most properties will be filled in by the import process) // create a basic, empty instance (most properties will be filled in by the import process)
// We do NOT watch the fs for changes to avoid duplicate events during installation // We do NOT watch the fs for changes to avoid duplicate events during installation
// fs watching will be enabled once the instance is imported // fs watching will be enabled once the instance is imported
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true) const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true)
return await invoke('plugin:import|import_instance', { return await invoke('plugin:import|import_instance', {
profilePath, profilePath,
launcherType, launcherType,
basePath, basePath,
instanceFolder, instanceFolder,
}) })
} }
/// Checks if this instance is valid for importing, given a certain launcher type /// Checks if this instance is valid for importing, given a certain launcher type
/// eg: is_valid_importable_instance("C:/MultiMC/Instance 1", "MultiMC") /// eg: is_valid_importable_instance("C:/MultiMC/Instance 1", "MultiMC")
export async function is_valid_importable_instance(instanceFolder, launcherType) { export async function is_valid_importable_instance(instanceFolder, launcherType) {
return await invoke('plugin:import|is_valid_importable_instance', { return await invoke('plugin:import|is_valid_importable_instance', {
instanceFolder, instanceFolder,
launcherType, launcherType,
}) })
} }
/// Gets the default path for the given launcher type /// Gets the default path for the given launcher type
/// null if it can't be found or doesn't exist /// null if it can't be found or doesn't exist
/// eg: get_default_launcher_path("MultiMC") /// eg: get_default_launcher_path("MultiMC")
export async function get_default_launcher_path(launcherType) { export async function get_default_launcher_path(launcherType) {
return await invoke('plugin:import|get_default_launcher_path', { launcherType }) return await invoke('plugin:import|get_default_launcher_path', { launcherType })
} }

View File

@@ -15,37 +15,37 @@ JavaVersion {
*/ */
export async function get_java_versions() { export async function get_java_versions() {
return await invoke('plugin:jre|get_java_versions') return await invoke('plugin:jre|get_java_versions')
} }
export async function set_java_version(javaVersion) { export async function set_java_version(javaVersion) {
return await invoke('plugin:jre|set_java_version', { javaVersion }) return await invoke('plugin:jre|set_java_version', { javaVersion })
} }
// Finds all the installation of Java 7, if it exists // Finds all the installation of Java 7, if it exists
// Returns [JavaVersion] // Returns [JavaVersion]
export async function find_filtered_jres(version) { export async function find_filtered_jres(version) {
return await invoke('plugin:jre|jre_find_filtered_jres', { version }) return await invoke('plugin:jre|jre_find_filtered_jres', { version })
} }
// Gets java version from a specific path by trying to run 'java -version' on it. // Gets java version from a specific path by trying to run 'java -version' on it.
// This also validates it, as it returns null if no valid java version is found at the path // This also validates it, as it returns null if no valid java version is found at the path
export async function get_jre(path) { export async function get_jre(path) {
return await invoke('plugin:jre|jre_get_jre', { path }) return await invoke('plugin:jre|jre_get_jre', { path })
} }
// Tests JRE version by running 'java -version' on it. // Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction) // Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion) { export async function test_jre(path, majorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion }) return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
} }
// Automatically installs specified java version // Automatically installs specified java version
export async function auto_install_java(javaVersion) { export async function auto_install_java(javaVersion) {
return await invoke('plugin:jre|jre_auto_install_java', { javaVersion }) return await invoke('plugin:jre|jre_auto_install_java', { javaVersion })
} }
// Get max memory in KiB // Get max memory in KiB
export async function get_max_memory() { export async function get_max_memory() {
return await invoke('plugin:jre|jre_get_max_memory') return await invoke('plugin:jre|jre_get_max_memory')
} }

View File

@@ -18,31 +18,35 @@ pub struct Logs {
/// Get all logs that exist for a given profile /// Get all logs that exist for a given profile
/// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created) /// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created)
export async function get_logs(profilePath, clearContents) { export async function get_logs(profilePath, clearContents) {
return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents }) return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents })
} }
/// Get a profile's log by filename /// Get a profile's log by filename
export async function get_logs_by_filename(profilePath, logType, filename) { export async function get_logs_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, logType, filename }) return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, logType, filename })
} }
/// Get a profile's log text only by filename /// Get a profile's log text only by filename
export async function get_output_by_filename(profilePath, logType, filename) { export async function get_output_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_get_output_by_filename', { profilePath, logType, filename }) return await invoke('plugin:logs|logs_get_output_by_filename', {
profilePath,
logType,
filename,
})
} }
/// Delete a profile's log by filename /// Delete a profile's log by filename
export async function delete_logs_by_filename(profilePath, logType, filename) { export async function delete_logs_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_delete_logs_by_filename', { return await invoke('plugin:logs|logs_delete_logs_by_filename', {
profilePath, profilePath,
logType, logType,
filename, filename,
}) })
} }
/// Delete all logs for a given profile /// Delete all logs for a given profile
export async function delete_logs(profilePath) { export async function delete_logs(profilePath) {
return await invoke('plugin:logs|logs_delete_logs', { profilePath }) return await invoke('plugin:logs|logs_delete_logs', { profilePath })
} }
/// Get the latest log for a given profile and cursor (startpoint to read withi nthe file) /// Get the latest log for a given profile and cursor (startpoint to read withi nthe file)
@@ -57,5 +61,5 @@ export async function delete_logs(profilePath) {
// From latest.log directly // From latest.log directly
export async function get_latest_log_cursor(profilePath, cursor) { export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor }) return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
} }

View File

@@ -3,11 +3,11 @@ import { invoke } from '@tauri-apps/api/core'
/// Gets the game versions from daedalus /// Gets the game versions from daedalus
// Returns a VersionManifest // Returns a VersionManifest
export async function get_game_versions() { export async function get_game_versions() {
return await invoke('plugin:metadata|metadata_get_game_versions') return await invoke('plugin:metadata|metadata_get_game_versions')
} }
// Gets the given loader versions from daedalus // Gets the given loader versions from daedalus
// Returns Manifest // Returns Manifest
export async function get_loader_versions(loader) { export async function get_loader_versions(loader) {
return await invoke('plugin:metadata|metadata_get_loader_versions', { loader }) return await invoke('plugin:metadata|metadata_get_loader_versions', { loader })
} }

View File

@@ -6,17 +6,17 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function login() { export async function login() {
return await invoke('plugin:mr-auth|modrinth_login') return await invoke('plugin:mr-auth|modrinth_login')
} }
export async function logout() { export async function logout() {
return await invoke('plugin:mr-auth|logout') return await invoke('plugin:mr-auth|logout')
} }
export async function get() { export async function get() {
return await invoke('plugin:mr-auth|get') return await invoke('plugin:mr-auth|get')
} }
export async function cancelLogin() { export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login') return await invoke('plugin:mr-auth|cancel_modrinth_login')
} }

View File

@@ -4,61 +4,62 @@
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { create } from './profile' import { create } from './profile'
// Installs pack from a version ID // Installs pack from a version ID
export async function create_profile_and_install( export async function create_profile_and_install(
projectId, projectId,
versionId, versionId,
packTitle, packTitle,
iconUrl, iconUrl,
createInstanceCallback = () => {}, createInstanceCallback = () => {},
) { ) {
const location = { const location = {
type: 'fromVersionId', type: 'fromVersionId',
project_id: projectId, project_id: projectId,
version_id: versionId, version_id: versionId,
title: packTitle, title: packTitle,
icon_url: iconUrl, icon_url: iconUrl,
} }
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location }) const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create( const profile = await create(
profile_creator.name, profile_creator.name,
profile_creator.gameVersion, profile_creator.gameVersion,
profile_creator.modloader, profile_creator.modloader,
profile_creator.loaderVersion, profile_creator.loaderVersion,
null, null,
true, true,
) )
createInstanceCallback(profile) createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile }) return await invoke('plugin:pack|pack_install', { location, profile })
} }
export async function install_to_existing_profile(projectId, versionId, title, profilePath) { export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
const location = { const location = {
type: 'fromVersionId', type: 'fromVersionId',
project_id: projectId, project_id: projectId,
version_id: versionId, version_id: versionId,
title, title,
} }
return await invoke('plugin:pack|pack_install', { location, profile: profilePath }) return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
} }
// Installs pack from a path // Installs pack from a path
export async function create_profile_and_install_from_file(path) { export async function create_profile_and_install_from_file(path) {
const location = { const location = {
type: 'fromFile', type: 'fromFile',
path: path, path: path,
} }
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location }) const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create( const profile = await create(
profile_creator.name, profile_creator.name,
profile_creator.gameVersion, profile_creator.gameVersion,
profile_creator.modloader, profile_creator.modloader,
profile_creator.loaderVersion, profile_creator.loaderVersion,
null, null,
true, true,
) )
return await invoke('plugin:pack|pack_install', { location, profile }) return await invoke('plugin:pack|pack_install', { location, profile })
} }

View File

@@ -8,16 +8,16 @@ import { invoke } from '@tauri-apps/api/core'
/// Gets all running process IDs with a given profile path /// Gets all running process IDs with a given profile path
/// Returns [u32] /// Returns [u32]
export async function get_by_profile_path(path) { export async function get_by_profile_path(path) {
return await invoke('plugin:process|process_get_by_profile_path', { path }) return await invoke('plugin:process|process_get_by_profile_path', { path })
} }
/// Gets all running process IDs with a given profile path /// Gets all running process IDs with a given profile path
/// Returns [u32] /// Returns [u32]
export async function get_all() { export async function get_all() {
return await invoke('plugin:process|process_get_all') return await invoke('plugin:process|process_get_all')
} }
/// Kills a process by UUID /// Kills a process by UUID
export async function kill(uuid) { export async function kill(uuid) {
return await invoke('plugin:process|process_kill', { uuid }) return await invoke('plugin:process|process_kill', { uuid })
} }

View File

@@ -3,10 +3,11 @@
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized, * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { install_to_existing_profile } from '@/helpers/pack.js'
import { injectNotificationManager } from '@modrinth/ui' import { injectNotificationManager } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js'
/// Add instance /// Add instance
/* /*
name: String, // the name of the profile, and relative path to create name: String, // the name of the profile, and relative path to create
@@ -19,142 +20,145 @@ import { invoke } from '@tauri-apps/api/core'
*/ */
export async function create(name, gameVersion, modloader, loaderVersion, iconPath, skipInstall) { export async function create(name, gameVersion, modloader, loaderVersion, iconPath, skipInstall) {
//Trim string name to avoid "Unable to find directory" //Trim string name to avoid "Unable to find directory"
name = name.trim() name = name.trim()
return await invoke('plugin:profile-create|profile_create', { return await invoke('plugin:profile-create|profile_create', {
name, name,
gameVersion, gameVersion,
modloader, modloader,
loaderVersion, loaderVersion,
iconPath, iconPath,
skipInstall, skipInstall,
}) })
} }
// duplicate a profile // duplicate a profile
export async function duplicate(path) { export async function duplicate(path) {
return await invoke('plugin:profile-create|profile_duplicate', { path }) return await invoke('plugin:profile-create|profile_duplicate', { path })
} }
// Remove a profile // Remove a profile
export async function remove(path) { export async function remove(path) {
return await invoke('plugin:profile|profile_remove', { path }) return await invoke('plugin:profile|profile_remove', { path })
} }
// Get a profile by path // Get a profile by path
// Returns a Profile // Returns a Profile
export async function get(path) { export async function get(path) {
return await invoke('plugin:profile|profile_get', { path }) return await invoke('plugin:profile|profile_get', { path })
} }
export async function get_many(paths) { export async function get_many(paths) {
return await invoke('plugin:profile|profile_get_many', { paths }) return await invoke('plugin:profile|profile_get_many', { paths })
} }
// Get a profile's projects // Get a profile's projects
// Returns a map of a path to profile file // Returns a map of a path to profile file
export async function get_projects(path, cacheBehaviour) { export async function get_projects(path, cacheBehaviour) {
return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour }) return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
} }
// Get a profile's full fs path // Get a profile's full fs path
// Returns a path // Returns a path
export async function get_full_path(path) { export async function get_full_path(path) {
return await invoke('plugin:profile|profile_get_full_path', { path }) return await invoke('plugin:profile|profile_get_full_path', { path })
} }
// Get's a mod's full fs path // Get's a mod's full fs path
// Returns a path // Returns a path
export async function get_mod_full_path(path, projectPath) { export async function get_mod_full_path(path, projectPath) {
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath }) return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
} }
// Get optimal java version from profile // Get optimal java version from profile
// Returns a java version // Returns a java version
export async function get_optimal_jre_key(path) { export async function get_optimal_jre_key(path) {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path }) return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
} }
// Get a copy of the profile set // Get a copy of the profile set
// Returns hashmap of path -> Profile // Returns hashmap of path -> Profile
export async function list() { export async function list() {
return await invoke('plugin:profile|profile_list') return await invoke('plugin:profile|profile_list')
} }
export async function check_installed(path, projectId) { export async function check_installed(path, projectId) {
return await invoke('plugin:profile|profile_check_installed', { path, projectId }) return await invoke('plugin:profile|profile_check_installed', { path, projectId })
} }
// Installs/Repairs a profile // Installs/Repairs a profile
export async function install(path, force) { export async function install(path, force) {
return await invoke('plugin:profile|profile_install', { path, force }) return await invoke('plugin:profile|profile_install', { path, force })
} }
// Updates all of a profile's projects // Updates all of a profile's projects
export async function update_all(path) { export async function update_all(path) {
return await invoke('plugin:profile|profile_update_all', { path }) return await invoke('plugin:profile|profile_update_all', { path })
} }
// Updates a specified project // Updates a specified project
export async function update_project(path, projectPath) { export async function update_project(path, projectPath) {
return await invoke('plugin:profile|profile_update_project', { path, projectPath }) return await invoke('plugin:profile|profile_update_project', { path, projectPath })
} }
// Add a project to a profile from a version // Add a project to a profile from a version
// Returns a path to the new project file // Returns a path to the new project file
export async function add_project_from_version(path, versionId) { export async function add_project_from_version(path, versionId) {
return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId }) return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
} }
// Add a project to a profile from a path + project_type // Add a project to a profile from a path + project_type
// Returns a path to the new project file // Returns a path to the new project file
export async function add_project_from_path(path, projectPath, projectType) { export async function add_project_from_path(path, projectPath, projectType) {
return await invoke('plugin:profile|profile_add_project_from_path', { return await invoke('plugin:profile|profile_add_project_from_path', {
path, path,
projectPath, projectPath,
projectType, projectType,
}) })
} }
// Toggle disabling a project // Toggle disabling a project
export async function toggle_disable_project(path, projectPath) { export async function toggle_disable_project(path, projectPath) {
return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath }) return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
} }
// Remove a project // Remove a project
export async function remove_project(path, projectPath) { export async function remove_project(path, projectPath) {
return await invoke('plugin:profile|profile_remove_project', { path, projectPath }) return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
} }
// Update a managed Modrinth profile to a specific version // Update a managed Modrinth profile to a specific version
export async function update_managed_modrinth_version(path, versionId) { export async function update_managed_modrinth_version(path, versionId) {
return await invoke('plugin:profile|profile_update_managed_modrinth_version', { path, versionId }) return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
path,
versionId,
})
} }
// Repair a managed Modrinth profile // Repair a managed Modrinth profile
export async function update_repair_modrinth(path) { export async function update_repair_modrinth(path) {
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path }) return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
} }
// Export a profile to .mrpack // Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs') /// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5) // Version id is optional (ie: 1.1.5)
export async function export_profile_mrpack( export async function export_profile_mrpack(
path, path,
exportLocation, exportLocation,
includedOverrides, includedOverrides,
versionId, versionId,
description, description,
name, name,
) { ) {
return await invoke('plugin:profile|profile_export_mrpack', { return await invoke('plugin:profile|profile_export_mrpack', {
path, path,
exportLocation, exportLocation,
includedOverrides, includedOverrides,
versionId, versionId,
description, description,
name, name,
}) })
} }
// Given a folder path, populate an array of all the subfolders // Given a folder path, populate an array of all the subfolders
@@ -166,40 +170,40 @@ export async function export_profile_mrpack(
// => [mods, resourcepacks] // => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_profile_mrpack // allows selection for 'included_overrides' in export_profile_mrpack
export async function get_pack_export_candidates(profilePath) { export async function get_pack_export_candidates(profilePath) {
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath }) return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
} }
// Run Minecraft using a pathed profile // Run Minecraft using a pathed profile
// Returns PID of child // Returns PID of child
export async function run(path) { export async function run(path) {
return await invoke('plugin:profile|profile_run', { path }) return await invoke('plugin:profile|profile_run', { path })
} }
export async function kill(path) { export async function kill(path) {
return await invoke('plugin:profile|profile_kill', { path }) return await invoke('plugin:profile|profile_kill', { path })
} }
// Edits a profile // Edits a profile
export async function edit(path, editProfile) { export async function edit(path, editProfile) {
return await invoke('plugin:profile|profile_edit', { path, editProfile }) return await invoke('plugin:profile|profile_edit', { path, editProfile })
} }
// Edits a profile's icon // Edits a profile's icon
export async function edit_icon(path, iconPath) { export async function edit_icon(path, iconPath) {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath }) return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
} }
export async function finish_install(instance) { export async function finish_install(instance) {
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
if (instance.install_stage !== 'pack_installed') { if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data let linkedData = instance.linked_data
await install_to_existing_profile( await install_to_existing_profile(
linkedData.project_id, linkedData.project_id,
linkedData.version_id, linkedData.version_id,
instance.name, instance.name,
instance.path, instance.path,
).catch(handleError) ).catch(handleError)
} else { } else {
await install(instance.path, false).catch(handleError) await install(instance.path, false).catch(handleError)
} }
} }

View File

@@ -1,191 +1,192 @@
import * as THREE from 'three'
import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue'
import {
setupSkinModel,
disposeCaches,
loadTexture,
applyCapeTexture,
createTransparentTexture,
} from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import { headStorage } from '../storage/head-storage'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets' import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import {
applyCapeTexture,
createTransparentTexture,
disposeCaches,
loadTexture,
setupSkinModel,
} from '@modrinth/utils'
import * as THREE from 'three'
import { reactive } from 'vue'
import type { Cape, Skin } from '../skins'
import { determineModelType, get_normalized_skin_texture } from '../skins'
import { headStorage } from '../storage/head-storage'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
export interface RenderResult { export interface RenderResult {
forwards: string forwards: string
backwards: string backwards: string
} }
export interface RawRenderResult { export interface RawRenderResult {
forwards: Blob forwards: Blob
backwards: Blob backwards: Blob
} }
class BatchSkinRenderer { class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer | null = null private renderer: THREE.WebGLRenderer | null = null
private scene: THREE.Scene | null = null private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null private currentModel: THREE.Group | null = null
private readonly width: number private readonly width: number
private readonly height: number private readonly height: number
constructor(width: number = 360, height: number = 504) { constructor(width: number = 360, height: number = 504) {
this.width = width this.width = width
this.height = height this.height = height
} }
private initializeRenderer(): void { private initializeRenderer(): void {
if (this.renderer) return if (this.renderer) return
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = this.width canvas.width = this.width
canvas.height = this.height canvas.height = this.height
this.renderer = new THREE.WebGLRenderer({ this.renderer = new THREE.WebGLRenderer({
canvas: canvas, canvas: canvas,
antialias: true, antialias: true,
alpha: true, alpha: true,
preserveDrawingBuffer: true, preserveDrawingBuffer: true,
}) })
this.renderer.outputColorSpace = THREE.SRGBColorSpace this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.NoToneMapping this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0 this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0) this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(this.width, this.height) this.renderer.setSize(this.width, this.height)
this.scene = new THREE.Scene() this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000) this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2) const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3) directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight) this.scene.add(ambientLight)
this.scene.add(directionalLight) this.scene.add(directionalLight)
} }
public async renderSkin( public async renderSkin(
textureUrl: string, textureUrl: string,
modelUrl: string, modelUrl: string,
capeUrl?: string, capeUrl?: string,
): Promise<RawRenderResult> { ): Promise<RawRenderResult> {
this.initializeRenderer() this.initializeRenderer()
this.clearScene() this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl) await this.setupModel(modelUrl, textureUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head') const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number] let lookAtTarget: [number, number, number]
if (headPart) { if (headPart) {
const headPosition = new THREE.Vector3() const headPosition = new THREE.Vector3()
headPart.getWorldPosition(headPosition) headPart.getWorldPosition(headPosition)
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z] lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
} else { } else {
throw new Error("Failed to find 'Head' object in model.") throw new Error("Failed to find 'Head' object in model.")
} }
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3] const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5] const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
const forwards = await this.renderView(frontCameraPos, lookAtTarget) const forwards = await this.renderView(frontCameraPos, lookAtTarget)
const backwards = await this.renderView(backCameraPos, lookAtTarget) const backwards = await this.renderView(backCameraPos, lookAtTarget)
return { forwards, backwards } return { forwards, backwards }
} }
private async renderView( private async renderView(
cameraPosition: [number, number, number], cameraPosition: [number, number, number],
lookAtPosition: [number, number, number], lookAtPosition: [number, number, number],
): Promise<Blob> { ): Promise<Blob> {
if (!this.camera || !this.renderer || !this.scene) { if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized') throw new Error('Renderer not initialized')
} }
this.camera.position.set(...cameraPosition) this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition) this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera)
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9) const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
const response = await fetch(dataUrl) const response = await fetch(dataUrl)
return await response.blob() return await response.blob()
} }
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> { private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
if (!this.scene) { if (!this.scene) {
throw new Error('Renderer not initialized') throw new Error('Renderer not initialized')
} }
const { model } = await setupSkinModel(modelUrl, textureUrl) const { model } = await setupSkinModel(modelUrl, textureUrl)
if (capeUrl) { if (capeUrl) {
const capeTexture = await loadTexture(capeUrl) const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture) applyCapeTexture(model, capeTexture)
} else { } else {
const transparentTexture = createTransparentTexture() const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture) applyCapeTexture(model, null, transparentTexture)
} }
const group = new THREE.Group() const group = new THREE.Group()
group.add(model) group.add(model)
group.position.set(0, 0.3, 1.95) group.position.set(0, 0.3, 1.95)
group.scale.set(0.8, 0.8, 0.8) group.scale.set(0.8, 0.8, 0.8)
this.scene.add(group) this.scene.add(group)
this.currentModel = group this.currentModel = group
} }
private clearScene(): void { private clearScene(): void {
if (!this.scene) return if (!this.scene) return
while (this.scene.children.length > 0) { while (this.scene.children.length > 0) {
const child = this.scene.children[0] const child = this.scene.children[0]
this.scene.remove(child) this.scene.remove(child)
if (child instanceof THREE.Mesh) { if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose() if (child.geometry) child.geometry.dispose()
if (child.material) { if (child.material) {
if (Array.isArray(child.material)) { if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose()) child.material.forEach((material) => material.dispose())
} else { } else {
child.material.dispose() child.material.dispose()
} }
} }
} }
} }
const ambientLight = new THREE.AmbientLight(0xffffff, 2) const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3) directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight) this.scene.add(ambientLight)
this.scene.add(directionalLight) this.scene.add(directionalLight)
this.currentModel = null this.currentModel = null
} }
public dispose(): void { public dispose(): void {
if (this.renderer) { if (this.renderer) {
this.renderer.dispose() this.renderer.dispose()
} }
disposeCaches() disposeCaches()
} }
} }
function getModelUrlForVariant(variant: string): string { function getModelUrlForVariant(variant: string): string {
switch (variant) { switch (variant) {
case 'SLIM': case 'SLIM':
return SlimPlayerModel return SlimPlayerModel
case 'CLASSIC': case 'CLASSIC':
case 'UNKNOWN': case 'UNKNOWN':
default: default:
return ClassicPlayerModel return ClassicPlayerModel
} }
} }
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>()) export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
@@ -194,253 +195,253 @@ const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null let sharedRenderer: BatchSkinRenderer | null = null
function getSharedRenderer(): BatchSkinRenderer { function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) { if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer() sharedRenderer = new BatchSkinRenderer()
} }
return sharedRenderer return sharedRenderer
} }
export function disposeSharedRenderer(): void { export function disposeSharedRenderer(): void {
if (sharedRenderer) { if (sharedRenderer) {
sharedRenderer.dispose() sharedRenderer.dispose()
sharedRenderer = null sharedRenderer = null
} }
} }
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> { export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>() const validKeys = new Set<string>()
const validHeadKeys = new Set<string>() const validHeadKeys = new Set<string>()
for (const skin of skins) { for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const headKey = `${skin.texture_key}-head` const headKey = `${skin.texture_key}-head`
validKeys.add(key) validKeys.add(key)
validHeadKeys.add(headKey) validHeadKeys.add(headKey)
} }
try { try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys) await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await headStorage.cleanupInvalidKeys(validHeadKeys) await headStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) { } catch (error) {
console.warn('Failed to cleanup unused skin previews:', error) console.warn('Failed to cleanup unused skin previews:', error)
} }
} }
export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise<Blob> { export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = new Image()
img.crossOrigin = 'anonymous' img.crossOrigin = 'anonymous'
img.onload = () => { img.onload = () => {
try { try {
const sourceCanvas = document.createElement('canvas') const sourceCanvas = document.createElement('canvas')
const sourceCtx = sourceCanvas.getContext('2d') const sourceCtx = sourceCanvas.getContext('2d')
if (!sourceCtx) { if (!sourceCtx) {
throw new Error('Could not get 2D context from source canvas') throw new Error('Could not get 2D context from source canvas')
} }
sourceCanvas.width = img.width sourceCanvas.width = img.width
sourceCanvas.height = img.height sourceCanvas.height = img.height
sourceCtx.drawImage(img, 0, 0) sourceCtx.drawImage(img, 0, 0)
const outputCanvas = document.createElement('canvas') const outputCanvas = document.createElement('canvas')
const outputCtx = outputCanvas.getContext('2d') const outputCtx = outputCanvas.getContext('2d')
if (!outputCtx) { if (!outputCtx) {
throw new Error('Could not get 2D context from output canvas') throw new Error('Could not get 2D context from output canvas')
} }
outputCanvas.width = size outputCanvas.width = size
outputCanvas.height = size outputCanvas.height = size
outputCtx.imageSmoothingEnabled = false outputCtx.imageSmoothingEnabled = false
const headImageData = sourceCtx.getImageData(8, 8, 8, 8) const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
const headCanvas = document.createElement('canvas') const headCanvas = document.createElement('canvas')
const headCtx = headCanvas.getContext('2d') const headCtx = headCanvas.getContext('2d')
if (!headCtx) { if (!headCtx) {
throw new Error('Could not get 2D context from head canvas') throw new Error('Could not get 2D context from head canvas')
} }
headCanvas.width = 8 headCanvas.width = 8
headCanvas.height = 8 headCanvas.height = 8
headCtx.putImageData(headImageData, 0, 0) headCtx.putImageData(headImageData, 0, 0)
outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size) outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
const hatImageData = sourceCtx.getImageData(40, 8, 8, 8) const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
const hatCanvas = document.createElement('canvas') const hatCanvas = document.createElement('canvas')
const hatCtx = hatCanvas.getContext('2d') const hatCtx = hatCanvas.getContext('2d')
if (!hatCtx) { if (!hatCtx) {
throw new Error('Could not get 2D context from hat canvas') throw new Error('Could not get 2D context from hat canvas')
} }
hatCanvas.width = 8 hatCanvas.width = 8
hatCanvas.height = 8 hatCanvas.height = 8
hatCtx.putImageData(hatImageData, 0, 0) hatCtx.putImageData(hatImageData, 0, 0)
const hatPixels = hatImageData.data const hatPixels = hatImageData.data
let hasHat = false let hasHat = false
for (let i = 3; i < hatPixels.length; i += 4) { for (let i = 3; i < hatPixels.length; i += 4) {
if (hatPixels[i] > 0) { if (hatPixels[i] > 0) {
hasHat = true hasHat = true
break break
} }
} }
if (hasHat) { if (hasHat) {
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size) outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
} }
outputCanvas.toBlob( outputCanvas.toBlob(
(blob) => { (blob) => {
if (blob) { if (blob) {
resolve(blob) resolve(blob)
} else { } else {
reject(new Error('Failed to create blob from canvas')) reject(new Error('Failed to create blob from canvas'))
} }
}, },
'image/webp', 'image/webp',
0.9, 0.9,
) )
} catch (error) { } catch (error) {
reject(error) reject(error)
} }
} }
img.onerror = () => { img.onerror = () => {
reject(new Error('Failed to load skin texture image')) reject(new Error('Failed to load skin texture image'))
} }
img.src = skinUrl img.src = skinUrl
}) })
} }
async function generateHeadRender(skin: Skin): Promise<string> { async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head` const headKey = `${skin.texture_key}-head`
if (headBlobUrlMap.has(headKey)) { if (headBlobUrlMap.has(headKey)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const url = headBlobUrlMap.get(headKey)! const url = headBlobUrlMap.get(headKey)!
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
headBlobUrlMap.delete(headKey) headBlobUrlMap.delete(headKey)
} else { } else {
return headBlobUrlMap.get(headKey)! return headBlobUrlMap.get(headKey)!
} }
} }
const skinUrl = await get_normalized_skin_texture(skin) const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64) const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob) const headUrl = URL.createObjectURL(headBlob)
headBlobUrlMap.set(headKey, headUrl) headBlobUrlMap.set(headKey, headUrl)
try { try {
await headStorage.store(headKey, headBlob) await headStorage.store(headKey, headBlob)
} catch (error) { } catch (error) {
console.warn('Failed to store head render in persistent storage:', error) console.warn('Failed to store head render in persistent storage:', error)
} }
return headUrl return headUrl
} }
export async function getPlayerHeadUrl(skin: Skin): Promise<string> { export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
return await generateHeadRender(skin) return await generateHeadRender(skin)
} }
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> { export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
try { try {
const skinKeys = skins.map( const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`, (skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
) )
const headKeys = skins.map((skin) => `${skin.texture_key}-head`) const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([ const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys), skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys), headStorage.batchRetrieve(headKeys),
]) ])
for (let i = 0; i < skins.length; i++) { for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i] const skinKey = skinKeys[i]
const headKey = headKeys[i] const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey] const rawCached = cachedSkinPreviews[skinKey]
if (rawCached) { if (rawCached) {
const cached: RenderResult = { const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards), forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards), backwards: URL.createObjectURL(rawCached.backwards),
} }
skinBlobUrlMap.set(skinKey, cached) skinBlobUrlMap.set(skinKey, cached)
} }
const cachedHead = cachedHeadPreviews[headKey] const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead) { if (cachedHead) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead)) headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
} }
} }
for (const skin of skins) { for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) { if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const result = skinBlobUrlMap.get(key)! const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards) URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards) URL.revokeObjectURL(result.backwards)
skinBlobUrlMap.delete(key) skinBlobUrlMap.delete(key)
} else continue } else continue
} }
const renderer = getSharedRenderer() const renderer = getSharedRenderer()
let variant = skin.variant let variant = skin.variant
if (variant === 'UNKNOWN') { if (variant === 'UNKNOWN') {
try { try {
variant = await determineModelType(skin.texture) variant = await determineModelType(skin.texture)
} catch (error) { } catch (error) {
console.error(`Failed to determine model type for skin ${key}:`, error) console.error(`Failed to determine model type for skin ${key}:`, error)
variant = 'CLASSIC' variant = 'CLASSIC'
} }
} }
const modelUrl = getModelUrlForVariant(variant) const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const rawRenderResult = await renderer.renderSkin( const rawRenderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin), await get_normalized_skin_texture(skin),
modelUrl, modelUrl,
cape?.texture, cape?.texture,
) )
const renderResult: RenderResult = { const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards), forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards), backwards: URL.createObjectURL(rawRenderResult.backwards),
} }
skinBlobUrlMap.set(key, renderResult) skinBlobUrlMap.set(key, renderResult)
try { try {
await skinPreviewStorage.store(key, rawRenderResult) await skinPreviewStorage.store(key, rawRenderResult)
} catch (error) { } catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error) console.warn('Failed to store skin preview in persistent storage:', error)
} }
const headKey = `${skin.texture_key}-head` const headKey = `${skin.texture_key}-head`
if (!headBlobUrlMap.has(headKey)) { if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin) await generateHeadRender(skin)
} }
} }
} finally { } finally {
disposeSharedRenderer() disposeSharedRenderer()
await cleanupUnusedPreviews(skins) await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage() await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage() await headStorage.debugCalculateStorage()
} }
} }

View File

@@ -4,8 +4,9 @@
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types' import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
// Settings object // Settings object
/* /*
@@ -31,49 +32,49 @@ Memorysettings {
*/ */
export type AppSettings = { export type AppSettings = {
max_concurrent_downloads: number max_concurrent_downloads: number
max_concurrent_writes: number max_concurrent_writes: number
theme: ColorTheme theme: ColorTheme
default_page: 'home' | 'library' default_page: 'home' | 'library'
collapsed_navigation: boolean collapsed_navigation: boolean
hide_nametag_skins_page: boolean hide_nametag_skins_page: boolean
advanced_rendering: boolean advanced_rendering: boolean
native_decorations: boolean native_decorations: boolean
toggle_sidebar: boolean toggle_sidebar: boolean
telemetry: boolean telemetry: boolean
discord_rpc: boolean discord_rpc: boolean
personalized_ads: boolean personalized_ads: boolean
onboarded: boolean onboarded: boolean
extra_launch_args: string[] extra_launch_args: string[]
custom_env_vars: [string, string][] custom_env_vars: [string, string][]
memory: MemorySettings memory: MemorySettings
force_fullscreen: boolean force_fullscreen: boolean
game_resolution: WindowSize game_resolution: WindowSize
hide_on_process_start: boolean hide_on_process_start: boolean
hooks: Hooks hooks: Hooks
custom_dir?: string | null custom_dir?: string | null
prev_custom_dir?: string | null prev_custom_dir?: string | null
migrated: boolean migrated: boolean
developer_mode: boolean developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean> feature_flags: Record<FeatureFlag, boolean>
} }
// Get full settings object // Get full settings object
export async function get() { export async function get() {
return (await invoke('plugin:settings|settings_get')) as AppSettings return (await invoke('plugin:settings|settings_get')) as AppSettings
} }
// Set full settings object // Set full settings object
export async function set(settings: AppSettings) { export async function set(settings: AppSettings) {
return await invoke('plugin:settings|settings_set', { settings }) return await invoke('plugin:settings|settings_set', { settings })
} }
export async function cancel_directory_change(): Promise<void> { export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change') return await invoke('plugin:settings|cancel_directory_change')
} }

View File

@@ -3,163 +3,163 @@ import { arrayBufferToBase64 } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export interface Cape { export interface Cape {
id: string id: string
name: string name: string
texture: string texture: string
is_default: boolean is_default: boolean
is_equipped: boolean is_equipped: boolean
} }
export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN' export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
export type SkinSource = 'default' | 'custom_external' | 'custom' export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin { export interface Skin {
texture_key: string texture_key: string
name?: string name?: string
variant: SkinModel variant: SkinModel
cape_id?: string cape_id?: string
texture: string texture: string
source: SkinSource source: SkinSource
is_equipped: boolean is_equipped: boolean
} }
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[] export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
export const DEFAULT_MODELS: Record<string, SkinModel> = { export const DEFAULT_MODELS: Record<string, SkinModel> = {
Steve: 'CLASSIC', Steve: 'CLASSIC',
Alex: 'SLIM', Alex: 'SLIM',
Zuri: 'CLASSIC', Zuri: 'CLASSIC',
Sunny: 'CLASSIC', Sunny: 'CLASSIC',
Noor: 'SLIM', Noor: 'SLIM',
Makena: 'SLIM', Makena: 'SLIM',
Kai: 'CLASSIC', Kai: 'CLASSIC',
Efe: 'SLIM', Efe: 'SLIM',
Ari: 'CLASSIC', Ari: 'CLASSIC',
} }
export function filterSavedSkins(list: Skin[]) { export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default') const customSkins = list.filter((s) => s.source !== 'default')
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
fixUnknownSkins(customSkins).catch(handleError) fixUnknownSkins(customSkins).catch(handleError)
return customSkins return customSkins
} }
export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> { export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
if (!context) { if (!context) {
return reject(new Error('Failed to create canvas rendering context.')) return reject(new Error('Failed to create canvas rendering context.'))
} }
const image = new Image() const image = new Image()
image.crossOrigin = 'anonymous' image.crossOrigin = 'anonymous'
image.src = texture image.src = texture
image.onload = () => { image.onload = () => {
canvas.width = image.width canvas.width = image.width
canvas.height = image.height canvas.height = image.height
context.drawImage(image, 0, 0) context.drawImage(image, 0, 0)
const armX = 54 const armX = 54
const armY = 20 const armY = 20
const armWidth = 2 const armWidth = 2
const armHeight = 12 const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) { for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
if (imageData[alphaIndex] !== 0) { if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC') resolve('CLASSIC')
return return
} }
} }
canvas.remove() canvas.remove()
resolve('SLIM') resolve('SLIM')
} }
image.onerror = () => { image.onerror = () => {
canvas.remove() canvas.remove()
reject(new Error('Failed to load the image.')) reject(new Error('Failed to load the image.'))
} }
}) })
} }
export async function fixUnknownSkins(list: Skin[]) { export async function fixUnknownSkins(list: Skin[]) {
const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN') const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
for (const unknownSkin of unknownSkins) { for (const unknownSkin of unknownSkins) {
unknownSkin.variant = await determineModelType(unknownSkin.texture) unknownSkin.variant = await determineModelType(unknownSkin.texture)
} }
} }
export function filterDefaultSkins(list: Skin[]) { export function filterDefaultSkins(list: Skin[]) {
return list return list
.filter( .filter(
(s) => (s) =>
s.source === 'default' && s.source === 'default' &&
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]), (!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
) )
.sort((a, b) => { .sort((a, b) => {
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1 const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1 const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
}) })
} }
export async function get_available_capes(): Promise<Cape[]> { export async function get_available_capes(): Promise<Cape[]> {
return invoke('plugin:minecraft-skins|get_available_capes', {}) return invoke('plugin:minecraft-skins|get_available_capes', {})
} }
export async function get_available_skins(): Promise<Skin[]> { export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {}) return invoke('plugin:minecraft-skins|get_available_skins', {})
} }
export async function add_and_equip_custom_skin( export async function add_and_equip_custom_skin(
textureBlob: Uint8Array, textureBlob: Uint8Array,
variant: SkinModel, variant: SkinModel,
capeOverride?: Cape, capeOverride?: Cape,
): Promise<void> { ): Promise<void> {
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', { await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob, textureBlob,
variant, variant,
capeOverride, capeOverride,
}) })
} }
export async function set_default_cape(cape?: Cape): Promise<void> { export async function set_default_cape(cape?: Cape): Promise<void> {
await invoke('plugin:minecraft-skins|set_default_cape', { await invoke('plugin:minecraft-skins|set_default_cape', {
cape, cape,
}) })
} }
export async function equip_skin(skin: Skin): Promise<void> { export async function equip_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|equip_skin', { await invoke('plugin:minecraft-skins|equip_skin', {
skin, skin,
}) })
} }
export async function remove_custom_skin(skin: Skin): Promise<void> { export async function remove_custom_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|remove_custom_skin', { await invoke('plugin:minecraft-skins|remove_custom_skin', {
skin, skin,
}) })
} }
export async function get_normalized_skin_texture(skin: Skin): Promise<string> { export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture) const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data) const base64 = arrayBufferToBase64(data)
return `data:image/png;base64,${base64}` return `data:image/png;base64,${base64}`
} }
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> { export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture }) return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
} }
export async function unequip_skin(): Promise<void> { export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin') await invoke('plugin:minecraft-skins|unequip_skin')
} }
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> { export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path }) const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data) return new Uint8Array(data)
} }

View File

@@ -8,12 +8,12 @@ import { invoke } from '@tauri-apps/api/core'
// Initialize the theseus API state // Initialize the theseus API state
// This should be called during the initializion/opening of the launcher // This should be called during the initializion/opening of the launcher
export async function initialize_state() { export async function initialize_state() {
return await invoke('initialize_state') return await invoke('initialize_state')
} }
// Gets active progress bars // Gets active progress bars
export async function progress_bars_list() { export async function progress_bars_list() {
return await invoke('plugin:utils|progress_bars_list') return await invoke('plugin:utils|progress_bars_list')
} }
// Get opening command // Get opening command
@@ -21,5 +21,5 @@ export async function progress_bars_list() {
// This should be called once and only when the app is done booting up and ready to receive a command // This should be called once and only when the app is done booting up and ready to receive a command
// Returns a Command struct- see events.js // Returns a Command struct- see events.js
export async function get_opening_command() { export async function get_opening_command() {
return await invoke('plugin:utils|get_opening_command') return await invoke('plugin:utils|get_opening_command')
} }

View File

@@ -1,229 +1,229 @@
interface StoredHead { interface StoredHead {
blob: Blob blob: Blob
timestamp: number timestamp: number
} }
export class HeadStorage { export class HeadStorage {
private dbName = 'head-storage' private dbName = 'head-storage'
private version = 1 private version = 1
private db: IDBDatabase | null = null private db: IDBDatabase | null = null
async init(): Promise<void> { async init(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version) const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
request.onsuccess = () => { request.onsuccess = () => {
this.db = request.result this.db = request.result
resolve() resolve()
} }
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
const db = request.result const db = request.result
if (!db.objectStoreNames.contains('heads')) { if (!db.objectStoreNames.contains('heads')) {
db.createObjectStore('heads') db.createObjectStore('heads')
} }
} }
}) })
} }
async store(key: string, blob: Blob): Promise<void> { async store(key: string, blob: Blob): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite') const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads') const store = transaction.objectStore('heads')
const storedHead: StoredHead = { const storedHead: StoredHead = {
blob, blob,
timestamp: Date.now(), timestamp: Date.now(),
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.put(storedHead, key) const request = store.put(storedHead, key)
request.onsuccess = () => resolve() request.onsuccess = () => resolve()
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async retrieve(key: string): Promise<string | null> { async retrieve(key: string): Promise<string | null> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly') const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads') const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.get(key) const request = store.get(key)
request.onsuccess = () => { request.onsuccess = () => {
const result = request.result as StoredHead | undefined const result = request.result as StoredHead | undefined
if (!result) { if (!result) {
resolve(null) resolve(null)
return return
} }
const url = URL.createObjectURL(result.blob) const url = URL.createObjectURL(result.blob)
resolve(url) resolve(url)
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> { async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly') const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads') const store = transaction.objectStore('heads')
const results: Record<string, Blob | null> = {} const results: Record<string, Blob | null> = {}
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
let completedRequests = 0 let completedRequests = 0
if (keys.length === 0) { if (keys.length === 0) {
resolve(results) resolve(results)
return return
} }
for (const key of keys) { for (const key of keys) {
const request = store.get(key) const request = store.get(key)
request.onsuccess = () => { request.onsuccess = () => {
const result = request.result as StoredHead | undefined const result = request.result as StoredHead | undefined
if (result) { if (result) {
results[key] = result.blob results[key] = result.blob
} else { } else {
results[key] = null results[key] = null
} }
completedRequests++ completedRequests++
if (completedRequests === keys.length) { if (completedRequests === keys.length) {
resolve(results) resolve(results)
} }
} }
request.onerror = () => { request.onerror = () => {
results[key] = null results[key] = null
completedRequests++ completedRequests++
if (completedRequests === keys.length) { if (completedRequests === keys.length) {
resolve(results) resolve(results)
} }
} }
} }
}) })
} }
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> { async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite') const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads') const store = transaction.objectStore('heads')
let deletedCount = 0 let deletedCount = 0
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.openCursor() const request = store.openCursor()
request.onsuccess = (event) => { request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) { if (cursor) {
const key = cursor.primaryKey as string const key = cursor.primaryKey as string
if (!validKeys.has(key)) { if (!validKeys.has(key)) {
const deleteRequest = cursor.delete() const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => { deleteRequest.onsuccess = () => {
deletedCount++ deletedCount++
} }
deleteRequest.onerror = () => { deleteRequest.onerror = () => {
console.warn('Failed to delete invalid head entry:', key) console.warn('Failed to delete invalid head entry:', key)
} }
} }
cursor.continue() cursor.continue()
} else { } else {
resolve(deletedCount) resolve(deletedCount)
} }
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async debugCalculateStorage(): Promise<void> { async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly') const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads') const store = transaction.objectStore('heads')
let totalSize = 0 let totalSize = 0
let count = 0 let count = 0
const entries: Array<{ key: string; size: number }> = [] const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.openCursor() const request = store.openCursor()
request.onsuccess = (event) => { request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) { if (cursor) {
const key = cursor.primaryKey as string const key = cursor.primaryKey as string
const value = cursor.value as StoredHead const value = cursor.value as StoredHead
const entrySize = value.blob.size const entrySize = value.blob.size
totalSize += entrySize totalSize += entrySize
count++ count++
entries.push({ entries.push({
key, key,
size: entrySize, size: entrySize,
}) })
cursor.continue() cursor.continue()
} else { } else {
console.group('🗄️ Head Storage Debug Info') console.group('🗄️ Head Storage Debug Info')
console.log(`Total entries: ${count}`) console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`) console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log( console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`, `Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
) )
if (entries.length > 0) { if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size) const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log( console.log(
'Largest entry:', 'Largest entry:',
sortedEntries[0].key, sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)', '(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
) )
console.log( console.log(
'Smallest entry:', 'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key, sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)', '(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
) )
} }
console.groupEnd() console.groupEnd()
resolve() resolve()
} }
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite') const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads') const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.clear() const request = store.clear()
request.onsuccess = () => resolve() request.onsuccess = () => resolve()
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
} }
export const headStorage = new HeadStorage() export const headStorage = new HeadStorage()

View File

@@ -1,218 +1,218 @@
import type { RawRenderResult } from '../rendering/batch-skin-renderer' import type { RawRenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview { interface StoredPreview {
forwards: Blob forwards: Blob
backwards: Blob backwards: Blob
timestamp: number timestamp: number
} }
export class SkinPreviewStorage { export class SkinPreviewStorage {
private dbName = 'skin-previews' private dbName = 'skin-previews'
private version = 1 private version = 1
private db: IDBDatabase | null = null private db: IDBDatabase | null = null
async init(): Promise<void> { async init(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version) const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
request.onsuccess = () => { request.onsuccess = () => {
this.db = request.result this.db = request.result
resolve() resolve()
} }
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
const db = request.result const db = request.result
if (!db.objectStoreNames.contains('previews')) { if (!db.objectStoreNames.contains('previews')) {
db.createObjectStore('previews') db.createObjectStore('previews')
} }
} }
}) })
} }
async store(key: string, result: RawRenderResult): Promise<void> { async store(key: string, result: RawRenderResult): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite') const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = { const storedPreview: StoredPreview = {
forwards: result.forwards, forwards: result.forwards,
backwards: result.backwards, backwards: result.backwards,
timestamp: Date.now(), timestamp: Date.now(),
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.put(storedPreview, key) const request = store.put(storedPreview, key)
request.onsuccess = () => resolve() request.onsuccess = () => resolve()
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async retrieve(key: string): Promise<RawRenderResult | null> { async retrieve(key: string): Promise<RawRenderResult | null> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly') const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.get(key) const request = store.get(key)
request.onsuccess = () => { request.onsuccess = () => {
const result = request.result as StoredPreview | undefined const result = request.result as StoredPreview | undefined
if (!result) { if (!result) {
resolve(null) resolve(null)
return return
} }
resolve({ forwards: result.forwards, backwards: result.backwards }) resolve({ forwards: result.forwards, backwards: result.backwards })
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> { async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly') const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
const results: Record<string, RawRenderResult | null> = {} const results: Record<string, RawRenderResult | null> = {}
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
let completedRequests = 0 let completedRequests = 0
if (keys.length === 0) { if (keys.length === 0) {
resolve(results) resolve(results)
return return
} }
for (const key of keys) { for (const key of keys) {
const request = store.get(key) const request = store.get(key)
request.onsuccess = () => { request.onsuccess = () => {
const result = request.result as StoredPreview | undefined const result = request.result as StoredPreview | undefined
if (result) { if (result) {
results[key] = { forwards: result.forwards, backwards: result.backwards } results[key] = { forwards: result.forwards, backwards: result.backwards }
} else { } else {
results[key] = null results[key] = null
} }
completedRequests++ completedRequests++
if (completedRequests === keys.length) { if (completedRequests === keys.length) {
resolve(results) resolve(results)
} }
} }
request.onerror = () => { request.onerror = () => {
results[key] = null results[key] = null
completedRequests++ completedRequests++
if (completedRequests === keys.length) { if (completedRequests === keys.length) {
resolve(results) resolve(results)
} }
} }
} }
}) })
} }
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> { async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite') const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
let deletedCount = 0 let deletedCount = 0
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.openCursor() const request = store.openCursor()
request.onsuccess = (event) => { request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) { if (cursor) {
const key = cursor.primaryKey as string const key = cursor.primaryKey as string
if (!validKeys.has(key)) { if (!validKeys.has(key)) {
const deleteRequest = cursor.delete() const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => { deleteRequest.onsuccess = () => {
deletedCount++ deletedCount++
} }
deleteRequest.onerror = () => { deleteRequest.onerror = () => {
console.warn('Failed to delete invalid entry:', key) console.warn('Failed to delete invalid entry:', key)
} }
} }
cursor.continue() cursor.continue()
} else { } else {
resolve(deletedCount) resolve(deletedCount)
} }
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async debugCalculateStorage(): Promise<void> { async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly') const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
let totalSize = 0 let totalSize = 0
let count = 0 let count = 0
const entries: Array<{ key: string; size: number }> = [] const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.openCursor() const request = store.openCursor()
request.onsuccess = (event) => { request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) { if (cursor) {
const key = cursor.primaryKey as string const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview const value = cursor.value as StoredPreview
const entrySize = value.forwards.size + value.backwards.size const entrySize = value.forwards.size + value.backwards.size
totalSize += entrySize totalSize += entrySize
count++ count++
entries.push({ entries.push({
key, key,
size: entrySize, size: entrySize,
}) })
cursor.continue() cursor.continue()
} else { } else {
console.group('🗄️ Skin Preview Storage Debug Info') console.group('🗄️ Skin Preview Storage Debug Info')
console.log(`Total entries: ${count}`) console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`) console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log( console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`, `Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
) )
if (entries.length > 0) { if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size) const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log( console.log(
'Largest entry:', 'Largest entry:',
sortedEntries[0].key, sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)', '(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
) )
console.log( console.log(
'Smallest entry:', 'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key, sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)', '(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
) )
} }
console.groupEnd() console.groupEnd()
resolve() resolve()
} }
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
} }
export const skinPreviewStorage = new SkinPreviewStorage() export const skinPreviewStorage = new SkinPreviewStorage()

View File

@@ -7,25 +7,25 @@ import { invoke } from '@tauri-apps/api/core'
// Gets cached category tags // Gets cached category tags
export async function get_categories() { export async function get_categories() {
return await invoke('plugin:tags|tags_get_categories') return await invoke('plugin:tags|tags_get_categories')
} }
// Gets cached loaders tags // Gets cached loaders tags
export async function get_loaders() { export async function get_loaders() {
return await invoke('plugin:tags|tags_get_loaders') return await invoke('plugin:tags|tags_get_loaders')
} }
// Gets cached game_versions tags // Gets cached game_versions tags
export async function get_game_versions() { export async function get_game_versions() {
return await invoke('plugin:tags|tags_get_game_versions') return await invoke('plugin:tags|tags_get_game_versions')
} }
// Gets cached donation_platforms tags // Gets cached donation_platforms tags
export async function get_donation_platforms() { export async function get_donation_platforms() {
return await invoke('plugin:tags|tags_get_donation_platforms') return await invoke('plugin:tags|tags_get_donation_platforms')
} }
// Gets cached licenses tags // Gets cached licenses tags
export async function get_report_types() { export async function get_report_types() {
return await invoke('plugin:tags|tags_get_report_types') return await invoke('plugin:tags|tags_get_report_types')
} }

View File

@@ -1,142 +1,142 @@
import type { ModrinthId } from '@modrinth/utils' import type { ModrinthId } from '@modrinth/utils'
type GameInstance = { type GameInstance = {
path: string path: string
install_stage: InstallStage install_stage: InstallStage
name: string name: string
icon_path?: string icon_path?: string
game_version: string game_version: string
loader: InstanceLoader loader: InstanceLoader
loader_version?: string loader_version?: string
groups: string[] groups: string[]
linked_data?: LinkedData linked_data?: LinkedData
created: Date created: Date
modified: Date modified: Date
last_played?: Date last_played?: Date
submitted_time_played: number submitted_time_played: number
recent_time_played: number recent_time_played: number
java_path?: string java_path?: string
extra_launch_args?: string[] extra_launch_args?: string[]
custom_env_vars?: [string, string][] custom_env_vars?: [string, string][]
memory?: MemorySettings memory?: MemorySettings
force_fullscreen?: boolean force_fullscreen?: boolean
game_resolution?: [number, number] game_resolution?: [number, number]
hooks: Hooks hooks: Hooks
} }
type InstallStage = type InstallStage =
| 'installed' | 'installed'
| 'minecraft_installing' | 'minecraft_installing'
| 'pack_installed' | 'pack_installed'
| 'pack_installing' | 'pack_installing'
| 'not_installed' | 'not_installed'
type LinkedData = { type LinkedData = {
project_id: ModrinthId project_id: ModrinthId
version_id: ModrinthId version_id: ModrinthId
locked: boolean locked: boolean
} }
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = { type ContentFile = {
hash: string hash: string
file_name: string file_name: string
size: number size: number
metadata?: FileMetadata metadata?: FileMetadata
update_version_id?: string update_version_id?: string
project_type: ContentFileProjectType project_type: ContentFileProjectType
} }
type FileMetadata = { type FileMetadata = {
project_id: string project_id: string
version_id: string version_id: string
} }
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack' type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
type CacheBehaviour = type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored // Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline' | 'stale_while_revalidate_skip_offline'
// Serve expired data, revalidate in background // Serve expired data, revalidate in background
| 'stale_while_revalidate' | 'stale_while_revalidate'
// Must revalidate if data is expired // Must revalidate if data is expired
| 'must_revalidate' | 'must_revalidate'
// Ignore cache- always fetch updated data from origin // Ignore cache- always fetch updated data from origin
| 'bypass' | 'bypass'
type MemorySettings = { type MemorySettings = {
maximum: number maximum: number
} }
type WindowSize = { type WindowSize = {
width: number width: number
height: number height: number
} }
type Hooks = { type Hooks = {
pre_launch?: string pre_launch?: string
wrapper?: string wrapper?: string
post_exit?: string post_exit?: string
} }
type Manifest = { type Manifest = {
gameVersions: ManifestGameVersion[] gameVersions: ManifestGameVersion[]
} }
type ManifestGameVersion = { type ManifestGameVersion = {
id: string id: string
stable: boolean stable: boolean
loaders: ManifestLoaderVersion[] loaders: ManifestLoaderVersion[]
} }
type ManifestLoaderVersion = { type ManifestLoaderVersion = {
id: string id: string
url: string url: string
stable: boolean stable: boolean
} }
type AppSettings = { type AppSettings = {
max_concurrent_downloads: number max_concurrent_downloads: number
max_concurrent_writes: number max_concurrent_writes: number
theme: 'dark' | 'light' | 'oled' theme: 'dark' | 'light' | 'oled'
default_page: 'Home' | 'Library' default_page: 'Home' | 'Library'
collapsed_navigation: boolean collapsed_navigation: boolean
advanced_rendering: boolean advanced_rendering: boolean
native_decorations: boolean native_decorations: boolean
worlds_in_home: boolean worlds_in_home: boolean
telemetry: boolean telemetry: boolean
discord_rpc: boolean discord_rpc: boolean
developer_mode: boolean developer_mode: boolean
personalized_ads: boolean personalized_ads: boolean
onboarded: boolean onboarded: boolean
extra_launch_args: string[] extra_launch_args: string[]
custom_env_vars: [string, string][] custom_env_vars: [string, string][]
memory: MemorySettings memory: MemorySettings
force_fullscreen: boolean force_fullscreen: boolean
game_resolution: [number, number] game_resolution: [number, number]
hide_on_process_start: boolean hide_on_process_start: boolean
hooks: Hooks hooks: Hooks
custom_dir?: string custom_dir?: string
prev_custom_dir?: string prev_custom_dir?: string
migrated: boolean migrated: boolean
} }
export type InstanceSettingsTabProps = { export type InstanceSettingsTabProps = {
instance: GameInstance instance: GameInstance
offline?: boolean offline?: boolean
} }

View File

@@ -1,62 +1,63 @@
import { get_full_path, get_mod_full_path } from '@/helpers/profile'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { get_full_path, get_mod_full_path } from '@/helpers/profile'
export async function isDev() { export async function isDev() {
return await invoke('is_dev') return await invoke('is_dev')
} }
// One of 'Windows', 'Linux', 'MacOS' // One of 'Windows', 'Linux', 'MacOS'
export async function getOS() { export async function getOS() {
return await invoke('plugin:utils|get_os') return await invoke('plugin:utils|get_os')
} }
export async function openPath(path) { export async function openPath(path) {
return await invoke('plugin:utils|open_path', { path }) return await invoke('plugin:utils|open_path', { path })
} }
export async function highlightInFolder(path) { export async function highlightInFolder(path) {
return await invoke('plugin:utils|highlight_in_folder', { path }) return await invoke('plugin:utils|highlight_in_folder', { path })
} }
export async function showLauncherLogsFolder() { export async function showLauncherLogsFolder() {
return await invoke('plugin:utils|show_launcher_logs_folder', {}) return await invoke('plugin:utils|show_launcher_logs_folder', {})
} }
// Opens a profile's folder in the OS file explorer // Opens a profile's folder in the OS file explorer
export async function showProfileInFolder(path) { export async function showProfileInFolder(path) {
const fullPath = await get_full_path(path) const fullPath = await get_full_path(path)
return await openPath(fullPath) return await openPath(fullPath)
} }
export async function highlightModInProfile(profilePath, projectPath) { export async function highlightModInProfile(profilePath, projectPath) {
const fullPath = await get_mod_full_path(profilePath, projectPath) const fullPath = await get_mod_full_path(profilePath, projectPath)
return await highlightInFolder(fullPath) return await highlightInFolder(fullPath)
} }
export async function restartApp() { export async function restartApp() {
return await invoke('restart_app') return await invoke('restart_app')
} }
/** /**
* @deprecated This method is no longer needed, and just returns its parameter * @deprecated This method is no longer needed, and just returns its parameter
*/ */
export function sanitizePotentialFileUrl(url) { export function sanitizePotentialFileUrl(url) {
return url return url
} }
export const releaseColor = (releaseType) => { export const releaseColor = (releaseType) => {
switch (releaseType) { switch (releaseType) {
case 'release': case 'release':
return 'green' return 'green'
case 'beta': case 'beta':
return 'orange' return 'orange'
case 'alpha': case 'alpha':
return 'red' return 'red'
default: default:
return '' return ''
} }
} }
export async function copyToClipboard(text) { export async function copyToClipboard(text) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
} }

View File

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

View File

@@ -1,35 +1,37 @@
import App from '@/App.vue' import 'floating-vue/dist/style.css'
import router from '@/routes'
import * as Sentry from '@sentry/vue' import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker' import { VueScanPlugin } from '@taijased/vue-render-tracker'
import { createPlugin } from '@vintl/vintl/plugin' import { createPlugin } from '@vintl/vintl/plugin'
import FloatingVue from 'floating-vue' import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/routes'
const VIntlPlugin = createPlugin({ const VIntlPlugin = createPlugin({
controllerOpts: { controllerOpts: {
defaultLocale: 'en-US', defaultLocale: 'en-US',
locale: 'en-US', locale: 'en-US',
locales: [ locales: [
{ {
tag: 'en-US', tag: 'en-US',
meta: { meta: {
displayName: 'American English', displayName: 'American English',
}, },
}, },
], ],
}, },
globalMixin: true, globalMixin: true,
injectInto: [], injectInto: [],
}) })
const vueScan = new VueScanPlugin({ const vueScan = new VueScanPlugin({
enabled: false, // Enable or disable the tracker enabled: false, // Enable or disable the tracker
showOverlay: true, // Show overlay to visualize renders showOverlay: true, // Show overlay to visualize renders
log: false, // Log render events to the console log: false, // Log render events to the console
playSound: false, // Play sound on each render playSound: false, // Play sound on each render
}) })
const pinia = createPinia() const pinia = createPinia()
@@ -37,24 +39,24 @@ const pinia = createPinia()
let app = createApp(App) let app = createApp(App)
Sentry.init({ Sentry.init({
app, app,
dsn: 'https://9508775ee5034536bc70433f5f531dd4@o485889.ingest.us.sentry.io/4504579615227904', dsn: 'https://9508775ee5034536bc70433f5f531dd4@o485889.ingest.us.sentry.io/4504579615227904',
integrations: [Sentry.browserTracingIntegration({ router })], integrations: [Sentry.browserTracingIntegration({ router })],
tracesSampleRate: 0.1, tracesSampleRate: 0.1,
}) })
app.use(vueScan) app.use(vueScan)
app.use(router) app.use(router)
app.use(pinia) app.use(pinia)
app.use(FloatingVue, { app.use(FloatingVue, {
themes: { themes: {
'ribbit-popout': { 'ribbit-popout': {
$extend: 'dropdown', $extend: 'dropdown',
placement: 'bottom-end', placement: 'bottom-end',
instantMove: true, instantMove: true,
distance: 8, distance: 8,
}, },
}, },
}) })
app.use(VIntlPlugin) app.use(VIntlPlugin)

View File

@@ -1,4 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon, XIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import {
Button,
Checkbox,
DropdownSelect,
injectNotificationManager,
LoadingIndicator,
Pagination,
SearchFilterControl,
SearchSidebarFilter,
useSearch,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Ref } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import type Instance from '@/components/ui/Instance.vue' import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
@@ -8,25 +28,6 @@ import { get_search_results } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js' import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon, XIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import {
Button,
Checkbox,
DropdownSelect,
injectNotificationManager,
LoadingIndicator,
Pagination,
SearchFilterControl,
SearchSidebarFilter,
useSearch,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Ref } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -35,34 +36,34 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const projectTypes = computed(() => { const projectTypes = computed(() => {
return [route.params.projectType as ProjectType] return [route.params.projectType as ProjectType]
}) })
const [categories, loaders, availableGameVersions] = await Promise.all([ const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories().catch(handleError).then(ref), get_categories().catch(handleError).then(ref),
get_loaders().catch(handleError).then(ref), get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref), get_game_versions().catch(handleError).then(ref),
]) ])
const tags: Ref<Tags> = computed(() => ({ const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value as GameVersion[], gameVersions: availableGameVersions.value as GameVersion[],
loaders: loaders.value as Platform[], loaders: loaders.value as Platform[],
categories: categories.value as Category[], categories: categories.value as Category[],
})) }))
type Instance = { type Instance = {
game_version: string game_version: string
loader: string loader: string
path: string path: string
install_stage: string install_stage: string
icon_path?: string icon_path?: string
name: string name: string
} }
type InstanceProject = { type InstanceProject = {
metadata: { metadata: {
project_id: string project_id: string
} }
} }
const instance: Ref<Instance | null> = ref(null) const instance: Ref<Instance | null> = ref(null)
@@ -75,98 +76,98 @@ const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await updateInstanceContext() await updateInstanceContext()
watch(route, () => { watch(route, () => {
updateInstanceContext() updateInstanceContext()
}) })
async function updateInstanceContext() { async function updateInstanceContext() {
if (route.query.i) { if (route.query.i) {
;[instance.value, instanceProjects.value] = await Promise.all([ ;[instance.value, instanceProjects.value] = await Promise.all([
getInstance(route.query.i).catch(handleError), getInstance(route.query.i).catch(handleError),
getInstanceProjects(route.query.i).catch(handleError), getInstanceProjects(route.query.i).catch(handleError),
]) ])
newlyInstalled.value = [] newlyInstalled.value = []
} }
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) { if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
instanceHideInstalled.value = route.query.ai === 'true' instanceHideInstalled.value = route.query.ai === 'true'
} }
if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) { if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
instance.value = null instance.value = null
instanceHideInstalled.value = false instanceHideInstalled.value = false
} }
} }
const instanceFilters = computed(() => { const instanceFilters = computed(() => {
const filters = [] const filters = []
if (instance.value) { if (instance.value) {
const gameVersion = instance.value.game_version const gameVersion = instance.value.game_version
if (gameVersion) { if (gameVersion) {
filters.push({ filters.push({
type: 'game_version', type: 'game_version',
option: gameVersion, option: gameVersion,
}) })
} }
const platform = instance.value.loader const platform = instance.value.loader
const supportedModLoaders = ['fabric', 'forge', 'quilt', 'neoforge'] const supportedModLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
if (platform && projectTypes.value.includes('mod') && supportedModLoaders.includes(platform)) { if (platform && projectTypes.value.includes('mod') && supportedModLoaders.includes(platform)) {
filters.push({ filters.push({
type: 'mod_loader', type: 'mod_loader',
option: platform, option: platform,
}) })
} }
if (instanceHideInstalled.value && instanceProjects.value) { if (instanceHideInstalled.value && instanceProjects.value) {
const installedMods = Object.values(instanceProjects.value) const installedMods = Object.values(instanceProjects.value)
.filter((x) => x.metadata) .filter((x) => x.metadata)
.map((x) => x.metadata.project_id) .map((x) => x.metadata.project_id)
installedMods.push(...newlyInstalled.value) installedMods.push(...newlyInstalled.value)
installedMods installedMods
?.map((x) => ({ ?.map((x) => ({
type: 'project_id', type: 'project_id',
option: `project_id:${x}`, option: `project_id:${x}`,
negative: true, negative: true,
})) }))
.forEach((x) => filters.push(x)) .forEach((x) => filters.push(x))
} }
} }
return filters return filters
}) })
const { const {
// Selections // Selections
query, query,
currentSortType, currentSortType,
currentFilters, currentFilters,
toggledGroups, toggledGroups,
maxResults, maxResults,
currentPage, currentPage,
overriddenProvidedFilterTypes, overriddenProvidedFilterTypes,
// Lists // Lists
filters, filters,
sortTypes, sortTypes,
// Computed // Computed
requestParams, requestParams,
// Functions // Functions
createPageParams, createPageParams,
} = useSearch(projectTypes, tags, instanceFilters) } = useSearch(projectTypes, tags, instanceFilters)
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
offline.value = true offline.value = true
}) })
window.addEventListener('online', () => { window.addEventListener('online', () => {
offline.value = false offline.value = false
}) })
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
@@ -177,350 +178,350 @@ const loading = ref(true)
const projectType = ref(route.params.projectType) const projectType = ref(route.params.projectType)
watch(projectType, () => { watch(projectType, () => {
loading.value = true loading.value = true
}) })
type SearchResult = { type SearchResult = {
project_id: string project_id: string
} }
type SearchResults = { type SearchResults = {
total_hits: number total_hits: number
limit: number limit: number
hits: SearchResult[] hits: SearchResult[]
} }
const results: Ref<SearchResults | null> = shallowRef(null) const results: Ref<SearchResults | null> = shallowRef(null)
const pageCount = computed(() => const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1, results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
) )
watch(requestParams, () => { watch(requestParams, () => {
if (!route.params.projectType) return if (!route.params.projectType) return
refreshSearch() refreshSearch()
}) })
async function refreshSearch() { async function refreshSearch() {
let rawResults = await get_search_results(requestParams.value) let rawResults = await get_search_results(requestParams.value)
if (!rawResults) { if (!rawResults) {
rawResults = { rawResults = {
result: { result: {
hits: [], hits: [],
total_hits: 0, total_hits: 0,
limit: 1, limit: 1,
}, },
} }
} }
if (instance.value) { if (instance.value) {
for (const val of rawResults.result.hits) { for (const val of rawResults.result.hits) {
val.installed = val.installed =
newlyInstalled.value.includes(val.project_id) || newlyInstalled.value.includes(val.project_id) ||
Object.values(instanceProjects.value).some( Object.values(instanceProjects.value).some(
(x) => x.metadata && x.metadata.project_id === val.project_id, (x) => x.metadata && x.metadata.project_id === val.project_id,
) )
} }
} }
results.value = rawResults.result results.value = rawResults.result
currentPage.value = 1 currentPage.value = 1
const persistentParams: LocationQuery = {} const persistentParams: LocationQuery = {}
for (const [key, value] of Object.entries(route.query)) { for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) { if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value persistentParams[key] = value
} }
} }
if (instanceHideInstalled.value) { if (instanceHideInstalled.value) {
persistentParams.ai = 'true' persistentParams.ai = 'true'
} else { } else {
delete persistentParams.ai delete persistentParams.ai
} }
const params = { const params = {
...persistentParams, ...persistentParams,
...createPageParams(), ...createPageParams(),
} }
breadcrumbs.setContext({ breadcrumbs.setContext({
name: 'Discover content', name: 'Discover content',
link: `/browse/${projectType.value}`, link: `/browse/${projectType.value}`,
query: params, query: params,
}) })
await router.replace({ path: route.path, query: params }) await router.replace({ path: route.path, query: params })
loading.value = false loading.value = false
} }
async function setPage(newPageNumber: number) { async function setPage(newPageNumber: number) {
currentPage.value = newPageNumber currentPage.value = newPageNumber
await onSearchChangeToTop() await onSearchChangeToTop()
} }
const searchWrapper: Ref<HTMLElement | null> = ref(null) const searchWrapper: Ref<HTMLElement | null> = ref(null)
async function onSearchChangeToTop() { async function onSearchChangeToTop() {
await nextTick() await nextTick()
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function clearSearch() { function clearSearch() {
query.value = '' query.value = ''
currentPage.value = 1 currentPage.value = 1
} }
watch( watch(
() => route.params.projectType, () => route.params.projectType,
async (newType) => { async (newType) => {
// Check if the newType is not the same as the current value // Check if the newType is not the same as the current value
if (!newType || newType === projectType.value) return if (!newType || newType === projectType.value) return
projectType.value = newType projectType.value = newType
currentSortType.value = { display: 'Relevance', name: 'relevance' } currentSortType.value = { display: 'Relevance', name: 'relevance' }
query.value = '' query.value = ''
}, },
) )
const selectableProjectTypes = computed(() => { const selectableProjectTypes = computed(() => {
let dataPacks = false, let dataPacks = false,
mods = false, mods = false,
modpacks = false modpacks = false
if (instance.value) { if (instance.value) {
if ( if (
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <= availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13') availableGameVersions.value.findIndex((x) => x.version === '1.13')
) { ) {
dataPacks = true dataPacks = true
} }
if (instance.value.loader !== 'vanilla') { if (instance.value.loader !== 'vanilla') {
mods = true mods = true
} }
} else { } else {
dataPacks = true dataPacks = true
mods = true mods = true
modpacks = true modpacks = true
} }
const params: LocationQuery = {} const params: LocationQuery = {}
if (route.query.i) { if (route.query.i) {
params.i = route.query.i params.i = route.query.i
} }
if (route.query.ai) { if (route.query.ai) {
params.ai = route.query.ai params.ai = route.query.ai
} }
const links = [ const links = [
{ label: 'Modpacks', href: `/browse/modpack`, shown: modpacks }, { label: 'Modpacks', href: `/browse/modpack`, shown: modpacks },
{ label: 'Mods', href: `/browse/mod`, shown: mods }, { label: 'Mods', href: `/browse/mod`, shown: mods },
{ label: 'Resource Packs', href: `/browse/resourcepack` }, { label: 'Resource Packs', href: `/browse/resourcepack` },
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks }, { label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks },
{ label: 'Shaders', href: `/browse/shader` }, { label: 'Shaders', href: `/browse/shader` },
] ]
if (params) { if (params) {
return links.map((link) => { return links.map((link) => {
return { return {
...link, ...link,
href: { href: {
path: link.href, path: link.href,
query: params, query: params,
}, },
} }
}) })
} }
return links return links
}) })
const messages = defineMessages({ const messages = defineMessages({
gameVersionProvidedByInstance: { gameVersionProvidedByInstance: {
id: 'search.filter.locked.instance-game-version.title', id: 'search.filter.locked.instance-game-version.title',
defaultMessage: 'Game version is provided by the instance', defaultMessage: 'Game version is provided by the instance',
}, },
modLoaderProvidedByInstance: { modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title', id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance', defaultMessage: 'Loader is provided by the instance',
}, },
providedByInstance: { providedByInstance: {
id: 'search.filter.locked.instance', id: 'search.filter.locked.instance',
defaultMessage: 'Provided by the instance', defaultMessage: 'Provided by the instance',
}, },
syncFilterButton: { syncFilterButton: {
id: 'search.filter.locked.instance.sync', id: 'search.filter.locked.instance.sync',
defaultMessage: 'Sync with instance', defaultMessage: 'Sync with instance',
}, },
}) })
const options = ref(null) const options = ref(null)
const handleRightClick = (event, result) => { const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [ options.value.showMenu(event, result, [
{ {
name: 'open_link', name: 'open_link',
}, },
{ {
name: 'copy_link', name: 'copy_link',
}, },
]) ])
} }
const handleOptionsClick = (args) => { const handleOptionsClick = (args) => {
switch (args.option) { switch (args.option) {
case 'open_link': case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`) openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break break
case 'copy_link': case 'copy_link':
navigator.clipboard.writeText( navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`, `https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
) )
break break
} }
} }
await refreshSearch() await refreshSearch()
</script> </script>
<template> <template>
<Teleport v-if="filters" to="#sidebar-teleport-target"> <Teleport v-if="filters" to="#sidebar-teleport-target">
<div <div
v-if="instance" v-if="instance"
class="border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid" class="border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
> >
<Checkbox <Checkbox
v-model="instanceHideInstalled" v-model="instanceHideInstalled"
label="Hide installed content" label="Hide installed content"
class="filter-checkbox" class="filter-checkbox"
@update:model-value="onSearchChangeToTop()" @update:model-value="onSearchChangeToTop()"
@click.prevent.stop @click.prevent.stop
/> />
</div> </div>
<SearchSidebarFilter <SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')" v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`" :key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters" v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups" v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes" v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="instanceFilters" :provided-filters="instanceFilters"
:filter-type="filter" :filter-type="filter"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid" class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg" button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3" content-class="mb-3"
inner-panel-class="ml-2 mr-3" inner-panel-class="ml-2 mr-3"
:open-by-default=" :open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license' filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
" "
> >
<template #header> <template #header>
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3> <h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template> </template>
<template #locked-game_version> <template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }} {{ formatMessage(messages.gameVersionProvidedByInstance) }}
</template> </template>
<template #locked-mod_loader> <template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByInstance) }} {{ formatMessage(messages.modLoaderProvidedByInstance) }}
</template> </template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template> <template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
</SearchSidebarFilter> </SearchSidebarFilter>
</Teleport> </Teleport>
<div ref="searchWrapper" class="flex flex-col gap-3 p-6"> <div ref="searchWrapper" class="flex flex-col gap-3 p-6">
<template v-if="instance"> <template v-if="instance">
<InstanceIndicator :instance="instance" /> <InstanceIndicator :instance="instance" />
<h1 class="m-0 mb-1 text-xl">Install content to instance</h1> <h1 class="m-0 mb-1 text-xl">Install content to instance</h1>
</template> </template>
<NavTabs :links="selectableProjectTypes" /> <NavTabs :links="selectableProjectTypes" />
<div class="iconified-input"> <div class="iconified-input">
<SearchIcon aria-hidden="true" class="text-lg" /> <SearchIcon aria-hidden="true" class="text-lg" />
<input <input
v-model="query" v-model="query"
class="h-12 card-shadow" class="h-12 card-shadow"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
type="text" type="text"
:placeholder="`Search ${projectType}s...`" :placeholder="`Search ${projectType}s...`"
/> />
<Button v-if="query" class="r-btn" @click="() => clearSearch()"> <Button v-if="query" class="r-btn" @click="() => clearSearch()">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<DropdownSelect <DropdownSelect
v-slot="{ selected }" v-slot="{ selected }"
v-model="currentSortType" v-model="currentSortType"
class="max-w-[16rem]" class="max-w-[16rem]"
name="Sort by" name="Sort by"
:options="sortTypes as any" :options="sortTypes as any"
:display-name="(option: SortType | undefined) => option?.display" :display-name="(option: SortType | undefined) => option?.display"
> >
<span class="font-semibold text-primary">Sort by: </span> <span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span> <span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect> </DropdownSelect>
<DropdownSelect <DropdownSelect
v-slot="{ selected }" v-slot="{ selected }"
v-model="maxResults" v-model="maxResults"
name="Max results" name="Max results"
:options="[5, 10, 15, 20, 50, 100]" :options="[5, 10, 15, 20, 50, 100]"
class="max-w-[9rem]" class="max-w-[9rem]"
> >
<span class="font-semibold text-primary">View: </span> <span class="font-semibold text-primary">View: </span>
<span class="font-semibold text-secondary">{{ selected }}</span> <span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect> </DropdownSelect>
<Pagination :page="currentPage" :count="pageCount" class="ml-auto" @switch-page="setPage" /> <Pagination :page="currentPage" :count="pageCount" class="ml-auto" @switch-page="setPage" />
</div> </div>
<SearchFilterControl <SearchFilterControl
v-model:selected-filters="currentFilters" v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')" :filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="instanceFilters" :provided-filters="instanceFilters"
:overridden-provided-filter-types="overriddenProvidedFilterTypes" :overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-message="messages.providedByInstance" :provided-message="messages.providedByInstance"
/> />
<div class="search"> <div class="search">
<section v-if="loading" class="offline"> <section v-if="loading" class="offline">
<LoadingIndicator /> <LoadingIndicator />
</section> </section>
<section v-else-if="offline && results.total_hits === 0" class="offline"> <section v-else-if="offline && results.total_hits === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth! You are currently offline. Connect to the internet to browse Modrinth!
</section> </section>
<section v-else class="project-list display-mode--list instance-results" role="list"> <section v-else class="project-list display-mode--list instance-results" role="list">
<SearchCard <SearchCard
v-for="result in results.hits" v-for="result in results.hits"
:key="result?.project_id" :key="result?.project_id"
:project="result" :project="result"
:instance="instance" :instance="instance"
:categories="[ :categories="[
...categories.filter( ...categories.filter(
(cat) => (cat) =>
result?.display_categories.includes(cat.name) && cat.project_type === projectType, result?.display_categories.includes(cat.name) && cat.project_type === projectType,
), ),
...loaders.filter( ...loaders.filter(
(loader) => (loader) =>
result?.display_categories.includes(loader.name) && result?.display_categories.includes(loader.name) &&
loader.supported_project_types?.includes(projectType), loader.supported_project_types?.includes(projectType),
), ),
]" ]"
:installed="result.installed || newlyInstalled.includes(result.project_id)" :installed="result.installed || newlyInstalled.includes(result.project_id)"
@install=" @install="
(id) => { (id) => {
newlyInstalled.push(id) newlyInstalled.push(id)
} }
" "
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)" @contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
/> />
<ContextMenu ref="options" @option-clicked="handleOptionsClick"> <ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template> <template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template> <template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu> </ContextMenu>
</section> </section>
<div class="flex justify-end"> <div class="flex justify-end">
<pagination <pagination
:page="currentPage" :page="currentPage"
:count="pageCount" :count="pageCount"
class="pagination-after" class="pagination-after"
@switch-page="setPage" @switch-page="setPage"
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue' import RowDisplay from '@/components/RowDisplay.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import { get_search_results } from '@/helpers/cache.js' import { get_search_results } from '@/helpers/cache.js'
@@ -6,11 +12,6 @@ import { profile_listener } from '@/helpers/events'
import { list } from '@/helpers/profile.js' import { list } from '@/helpers/profile.js'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
@@ -25,102 +26,102 @@ const featuredMods = ref<SearchResult[]>([])
const installedModpacksFilter = ref('') const installedModpacksFilter = ref('')
const recentInstances = computed(() => const recentInstances = computed(() =>
instances.value instances.value
.filter((x) => x.last_played) .filter((x) => x.last_played)
.slice() .slice()
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))), .sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
) )
const hasFeaturedProjects = computed( const hasFeaturedProjects = computed(
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0, () => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
) )
const offline = ref<boolean>(!navigator.onLine) const offline = ref<boolean>(!navigator.onLine)
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
offline.value = true offline.value = true
}) })
window.addEventListener('online', () => { window.addEventListener('online', () => {
offline.value = false offline.value = false
}) })
async function fetchInstances() { async function fetchInstances() {
instances.value = await list().catch(handleError) instances.value = await list().catch(handleError)
const filters = [] const filters = []
for (const instance of instances.value) { for (const instance of instances.value) {
if (instance.linked_data && instance.linked_data.project_id) { if (instance.linked_data && instance.linked_data.project_id) {
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`) filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
} }
} }
installedModpacksFilter.value = filters.join(' AND ') installedModpacksFilter.value = filters.join(' AND ')
} }
async function fetchFeaturedModpacks() { async function fetchFeaturedModpacks() {
const response = await get_search_results( const response = await get_search_results(
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`, `?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
) )
if (response) { if (response) {
featuredModpacks.value = response.result.hits featuredModpacks.value = response.result.hits
} else { } else {
featuredModpacks.value = [] featuredModpacks.value = []
} }
} }
async function fetchFeaturedMods() { async function fetchFeaturedMods() {
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows') const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
if (response) { if (response) {
featuredMods.value = response.result.hits featuredMods.value = response.result.hits
} else { } else {
featuredModpacks.value = [] featuredModpacks.value = []
} }
} }
async function refreshFeaturedProjects() { async function refreshFeaturedProjects() {
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()]) await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
} }
await fetchInstances() await fetchInstances()
await refreshFeaturedProjects() await refreshFeaturedProjects()
const unlistenProfile = await profile_listener( const unlistenProfile = await profile_listener(
async (e: { event: string; profile_path_id: string }) => { async (e: { event: string; profile_path_id: string }) => {
await fetchInstances() await fetchInstances()
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects() await refreshFeaturedProjects()
} }
}, },
) )
onUnmounted(() => { onUnmounted(() => {
unlistenProfile() unlistenProfile()
}) })
</script> </script>
<template> <template>
<div class="p-6 flex flex-col gap-2"> <div class="p-6 flex flex-col gap-2">
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1> <h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1> <h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" /> <RecentWorldsList :recent-instances="recentInstances" />
<RowDisplay <RowDisplay
v-if="hasFeaturedProjects" v-if="hasFeaturedProjects"
:instances="[ :instances="[
{ {
label: 'Discover a modpack', label: 'Discover a modpack',
route: '/browse/modpack', route: '/browse/modpack',
instances: featuredModpacks, instances: featuredModpacks,
downloaded: false, downloaded: false,
}, },
{ {
label: 'Discover mods', label: 'Discover mods',
route: '/browse/mod', route: '/browse/mod',
instances: featuredMods, instances: featuredMods,
downloaded: false, downloaded: false,
}, },
]" ]"
:can-paginate="true" :can-paginate="true"
/> />
</div> </div>
</template> </template>

Some files were not shown because too many files have changed in this diff Show More