Compare commits

...
Author SHA1 Message Date
Prospector 5d47594302 changelog 2026-08-27 11:54:34 -07:00
Prospector 8a0d683ae2 fix moderation advanced filters 2026-08-27 11:52:29 -07:00
Prospector ffd864a9e2 fix moderation collapsibles & do a consistency pass (#7330)
* fix moderation collapsibles & do a consistency pass

* extract tech review sources

* improve buttons

* buttn

* improve markdown styling

* fix nested details

* save filters + fix reports reloading

* prepr
2026-08-27 18:46:13 +00:00
Calum H. 263aaedc8f fix: screenshots page lagging with lots of images (#7336) 2026-08-27 18:27:33 +00:00
Calum H. d3b5ba2567 feat: changelog 0.19.0 (#7335) 2026-08-27 19:11:52 +02:00
Calum H.andtdgao 7c67cca7a7 feat(instance-sync): screenshots sync (#7218)
* pnpm prepr

* feat(instance-sync): screenshots sync

* fix: prepr + fmt

* fix: qa

* feat: screenshit editor

* feat: screenshot* editor qa

* fix: qa

* fix: lint

* fix: aecsocket rev

* qa: a11y tab navigation focus is cut off

* qa: Change “Edited” badge to be bg-highlight-green

* qa: friends list input style wrong

* qa: toolbar width + labels

* qa: multiselect changes

* qa: anim changes

* qa: card hover colors

* qa: copy feedback

* qa: hide date

* qa: instance icon in overflow/contextmenu

* qa: spacing

* qa: group empty state text

* qa: drag preview badge

* qa: group deletion skip warning if no screenshots

* qa: redesign editor

* qa: remove screenshot parent/edited badge stuff

* qa: control font size consistency

* qa: viewer copy control

* qa: editor fixes

* qa: redirect on disable screenshot sync

* qa: margin police

* fix: crop

* fix: qa

* feat: initial start on basic instance file syncing (#7220)

* qa: final

* qa: final 2

* fix: lint + prepr

* fix: copy

* fix: screenshot editing outside of app causing ghost files in db + sync fail rollback impl

* chore: split up

* fix: fmt

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-08-27 16:49:46 +00:00
Sychic 2bd108c278 fix(app-lib): treat all errors as missing icon (#7333)
* fix(app-lib): treat all errors as missing icon

* style(app-lib): cargo fmt
2026-08-27 16:37:30 +00:00
Michael H. 25fb06ce27 fix: set NODE_ENV in wrangler 2026-08-27 13:55:40 +02:00
Michael H. e0cd019869 chore: get rid of debug logs on prod 2026-08-27 13:44:29 +02:00
Prospector b68a199814 menu refactor + web account switcher (#7307)
* add account switcher, migrate context menus to overflow menu system, update version filter controls, only pad instance icons

* app account switcher, improve some context menus, theme stuff

* prepr

* handle reauthentication

* fix a couple sign in issues, admin page cleanup, fix org status badges, fix official account badge, open in local keybind, publish plus icons

* rename generic menus to ButtonMenu (& types)

* fix hydration issue w/ dropdowns + middleware error
2026-08-27 00:35:34 +00:00
chyz 3439a43880 Make "Open Image in New Tab" open raw image (when possible) (#7315)
* tell search engines not to index collections because bots keep spamming them and I can basically guarantee not a single human being is using collections in a way where this will be detrimental to them (besides the advertisement spam)

* Index creator created collections

* Open raw images from open image in new tab option in context menu
2026-08-26 18:59:15 +00:00
François-Xavier Talbot c88efd48af fix(labrinth): permissive Tremendous cancellation response (#7322)
* fix(labrinth): accept any response from Tremendous cancel

* temp disable ping test
2026-08-26 18:46:37 +00:00
coolbot 529c6f14dd Tweak disclosure and AI moderation msgs (#7318) 2026-08-26 18:01:43 +00:00
Michael H. c3c6760818 feat: add redis cache to usercheck/nb responses (#7316)
* feat: add redis cache to usercheck responses

* feat: add redis cache to neverbounce responses
2026-08-26 14:07:15 +02:00
Michael H. 36f9dbb47a chore: omit ip from usercheck decision requests 2026-08-26 12:28:25 +02:00
EpicPix 715f4954cf fix: handle minecraft version grouping for versions containing release name (#7313)
fix: handle minecraft version grouping for version containing release name better
2026-08-25 23:53:24 +00:00
Seungyeop Lee ffdf7ff745 Fixed settings modal category headers being cut off (#7258)
fix settings modal category headers being cut off
2026-08-25 23:20:21 +00:00
ThatGravyBoatandProspector a921cfdb7a feat: scan file for c2pa content (#7297)
* feat: scan file for c2pa content

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-25 22:41:39 +00:00
chyz 1007b7b69e Prevent SEO Slop (#7274)
* tell search engines not to index collections because bots keep spamming them and I can basically guarantee not a single human being is using collections in a way where this will be detrimental to them (besides the advertisement spam)

* Index creator created collections
2026-08-25 22:41:21 +00:00
Calum H.andProspector c28ec83060 feat: moderation additions for shared instances (#7285)
* feat: moderation additions for shared instances

* fix: lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-25 21:18:51 +00:00
Truman Gao 7701aef048 fix: creating a new group when only one group exists does not show the group (#7303)
fix: creating a new group when only one instance exists does not show the group
2026-08-25 19:47:37 +00:00
Sychic 7420560784 fix(app-lib): remove timeout for shared instance uploads (#7304)
* fix(app-lib): remove timeout for shared instance uploads

* style(app-lib): cargo fmt

* style(app-lib): remove unused import
2026-08-25 18:28:47 +00:00
aecsocket e33ef5f252 fix: cleanup typos with typos:off/on comments (#7301)
* fix: cleanup typos with typos:off/on comments

* tombi

* bump tombi version
2026-08-25 15:18:27 +00:00
Michael H. bb35950340 feat: usercheck & oauth email verification (#7291)
* feat: usercheck

* feat: make oauth users not skip email verification

* slightly improve message
2026-08-25 12:54:52 +00:00
Calum H.andProspector c67f980c4a refactor: StyledInput refactor -> Input components (#6733)
* feat: align style

* feat: new input components

* refactor: migrate to new input components

* refactor: rename component

* prepr

* chore: group inputs like buttons

* fix: lint

* feat: docs

* fix: lint

* fix: bugs

* prepr

* fix slug + adjust create server project

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-25 06:15:38 +00:00
Prospector 80607c1963 new dev favicons + settings favicon (#7295)
* new dev favicons + settings favicon

* prepr
2026-08-24 22:54:34 -07:00
Prospector ad83117ebf add keybinds to copy/open prod links (#7294) 2026-08-24 16:29:36 -07:00
Prospector 4b5edb9a99 update not found icons (#7289) 2026-08-24 16:29:24 -07:00
Prospector e753801e0c lazily load c2pa client-side to reduce CF size (#7292) 2026-08-24 16:17:48 -07:00
Truman Gao aed00aab13 fix: app sign in with button to open app (#7259)
* fix: app sign in with button to open app

* pnpm prepr
2026-08-24 16:57:27 +00:00
Calum H. 8d7e491126 fix: combobox behind advanced filters panel (#7281) 2026-08-24 16:45:14 +00:00
646 changed files with 32005 additions and 18710 deletions
+2 -2
View File
@@ -20,8 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
- uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
with:
tool: tombi
tool: tombi@1.4.1
- run: tombi lint
- run: tombi fmt --check
+3 -10
View File
@@ -42,9 +42,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink"
] }
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
@@ -61,9 +59,7 @@ censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
chumsky = "0.9.3"
cidre = { version = "0.15.0", default-features = false, features = [
"macos_15_0"
] }
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
clap = "4.5.48"
clickhouse = "0.14.0"
color-eyre = "0.6.5"
@@ -163,10 +159,7 @@ redis = "1.4.1"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
rust_decimal = { version = "1.39.0", features = [
"serde-with-float",
"serde-with-str"
] }
rust_decimal = { version = "1.39.0", features = ["serde-with-float", "serde-with-str"] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.37.0", default-features = false, features = [
"fail-on-err",
+10 -7
View File
@@ -6,13 +6,16 @@ extend-exclude = [
"packages/utils/",
"packages/ui/",
"packages/blog/",
# contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts",
"packages/moderation/src/utils.ts",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
"apps/labrinth/src/queue/payouts/mod.rs",
# contains domain names with deliberate typos
"apps/labrinth/assets/disposable_email_blocklist.txt"
]
[default]
extend-ignore-re = [
# Ignore lines that end with `// typos:ignore`
"(?Rm)^.*(#|//)\\s*typos:ignore$",
# Ignore the line after `// typos:ignore`:
"(#|//)\\s*typos:ignore\\n.*",
# Ignore blocks between `// typos:off` and `// typos:on`
"(?s)(#|//)\\s*typos:off.*?\\n\\s*(#|//)\\s*typos:on",
]
[default.extend-words]
+2
View File
@@ -32,6 +32,7 @@
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.43.5",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
@@ -45,6 +46,7 @@
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8",
"vue3-ace-editor": "^2.2.4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
+384 -88
View File
@@ -10,9 +10,11 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ArrowLeftRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
ImagesIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
@@ -23,20 +25,25 @@ import {
ServerStackIcon,
SettingsIcon,
ShirtIcon,
SpinnerIcon,
ToggleRightIcon,
UserIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
AccountSwitchOverlay,
Admonition,
Avatar,
ButtonLink,
commonMessages,
commonSettingsMessages,
ContentInstallModal,
ContentUpdaterModal,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
IconButton,
IntlFormatted,
LoadingBar,
NewsArticleCard,
NotificationPanel,
@@ -51,18 +58,20 @@ import {
useDebugLogger,
useFormatBytes,
useHostingIntercom,
UserRoleIcon,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import AccountsCard from '@/components/ui/AccountsCard.vue'
@@ -96,8 +105,9 @@ import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useError } from '@/composables/use-error.js'
import { useTheme } from '@/composables/use-theme.ts'
import { isDarkTheme, useTheme } from '@/composables/use-theme.ts'
import { config } from '@/config'
import { getAccountAppearance, rememberAccountAppearance } from '@/helpers/account-appearance.ts'
import {
hide_ads_window,
init_ads_window,
@@ -108,14 +118,27 @@ import {
} from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { get_user, get_user_many, get_version } from '@/helpers/cache.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import {
can_current_user_use_shared_instances,
get as getInstance,
get_global_synced_options,
run,
} from '@/helpers/instance'
import {
get as getCreds,
getAll as getAllCreds,
login,
logout,
removeUser,
setActive,
} from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
import { get_user_preferences } from '@/helpers/user-preferences.ts'
import { parse_modrinth_user_link } from '@/helpers/users'
import {
areUpdatesEnabled,
@@ -157,7 +180,10 @@ import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
import {
appSettingsModalOpenProfileKey,
appSettingsModalOpenSyncedOptionsKey,
} from './providers/app-settings-modal'
const appSettings = useAppSettings()
const appTheme = useTheme()
@@ -201,6 +227,7 @@ const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
const storedModrinthAccounts = ref([])
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
watch(
@@ -421,10 +448,20 @@ const os = ref('')
const isDevEnvironment = ref(false)
const stateInitialized = ref(false)
const globalSyncedOptionsQuery = useQuery({
queryKey: ['global-synced-options'],
queryFn: get_global_synced_options,
enabled: computed(() => stateInitialized.value),
})
const criticalErrorMessage = ref()
const isMaximized = ref(false)
const isFullscreen = ref(false)
watch([os, isFullscreen], ([osName, fullscreen]) => {
document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen)
})
const authUnreachableDebug = useDebugLogger('AuthReachableChecker')
const authServerQuery = useQuery({
@@ -447,7 +484,24 @@ const authUnreachable = computed(() => {
return false
})
let unlistenEditMenu
function handleEditMenuAction(action) {
const event = new CustomEvent(`edit-menu:${action}`, { cancelable: true })
if (document.dispatchEvent(event)) document.execCommand(action)
}
onMounted(async () => {
try {
const listeners = await Promise.all([
listen('edit-menu://undo', () => handleEditMenuAction('undo')),
listen('edit-menu://redo', () => handleEditMenuAction('redo')),
])
unlistenEditMenu = () => listeners.forEach((unlisten) => unlisten())
} catch (error) {
handleError(error)
}
await useCheckDisableMouseover()
try {
handleAdsConsentRequired(await should_show_ads_consent_popup())
@@ -457,6 +511,7 @@ onMounted(async () => {
document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
document.querySelector('body').addEventListener('contextmenu', handleContextMenu)
document.addEventListener('fullscreenchange', handleFullscreenChange)
checkUpdates()
@@ -465,7 +520,9 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
document.querySelector('body').removeEventListener('contextmenu', handleContextMenu)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
unlistenEditMenu?.()
clearDelayedUpdatePopup()
if (fullscreenAdsWindowHold) {
@@ -480,7 +537,6 @@ const formatBytes = useFormatBytes()
const messages = defineMessages({
warning: { id: 'app.notification.warning', defaultMessage: 'Warning' },
moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' },
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
@@ -534,6 +590,10 @@ const messages = defineMessages({
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
},
screenshots: {
id: 'app.nav.screenshots',
defaultMessage: 'Screenshots',
},
createNewInstance: {
id: 'app.nav.create-new-instance',
defaultMessage: 'Create new instance',
@@ -542,13 +602,33 @@ const messages = defineMessages({
id: 'app.nav.modrinth-account',
defaultMessage: 'Modrinth account',
},
signedInAs: {
id: 'app.nav.signed-in-as',
defaultMessage: 'Signed in as <user>{username}</user>',
viewProfile: {
id: 'app.nav.view-profile',
defaultMessage: 'View profile',
},
addFriend: {
id: 'friends.action.add-friend',
defaultMessage: 'Add a friend',
},
signInToModrinthAccount: {
id: 'app.nav.sign-in-to-modrinth-account',
defaultMessage: 'Sign in to a Modrinth account',
defaultMessage: 'Sign into Modrinth',
},
loadingProfile: {
id: 'app.nav.loading-profile',
defaultMessage: 'Loading profile...',
},
switchAccount: {
id: 'app.nav.switch-account',
defaultMessage: 'Switch account',
},
addAccount: {
id: 'app.nav.add-account',
defaultMessage: 'Add account',
},
removeAccount: {
id: 'app.nav.remove-account',
defaultMessage: 'Remove account',
},
restarting: {
id: 'app.restarting',
@@ -652,6 +732,9 @@ async function setupApp() {
i18n.global.locale.value = locale
}
Object.assign(appSettings.featureFlags, feature_flags)
isMaximized.value = await getCurrentWindow().isMaximized()
isFullscreen.value = await getCurrentWindow().isFullscreen()
os.value = await getOS()
const dev = await isDev()
isDevEnvironment.value = dev
@@ -666,13 +749,11 @@ async function setupApp() {
appSettings.hideNametagSkinsPage = hide_nametag_skins_page
appSettings.toggleSidebar = toggle_sidebar
appSettings.devMode = developer_mode
Object.assign(appSettings.featureFlags, feature_flags)
stateInitialized.value = true
isMaximized.value = await getCurrentWindow().isMaximized()
await getCurrentWindow().onResized(async () => {
isMaximized.value = await getCurrentWindow().isMaximized()
isFullscreen.value = await getCurrentWindow().isFullscreen()
})
if (telemetry) {
@@ -681,8 +762,6 @@ async function setupApp() {
trackEvent('Launched', { version, dev })
}
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
const osType = await type()
if (osType === 'macos') {
document.getElementsByTagName('html')[0].classList.add('mac')
@@ -957,6 +1036,7 @@ const updateToPlayModal = ref()
const modrinthLoginModal = ref()
const appSettingsModal = ref()
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
provide(appSettingsModalOpenSyncedOptionsKey, () => appSettingsModal.value?.showSyncedOptions())
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -984,6 +1064,13 @@ watch(
.then(async () => {
const settings = await getSettings()
const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme
const userId = credentials.value?.user_id ?? credentials.value?.user?.id
if (userId) {
rememberAccountAppearance(userId, preferences.appearance)
}
if (appTheme.syncAcrossDevices && isDarkTheme(preferences.appearance.theme)) {
appTheme.preferredDark = preferences.appearance.theme
}
const locale = preferences.localization.locale
const behavior = preferences.behavior
let settingsChanged = false
@@ -1073,10 +1160,12 @@ async function fetchCredentials() {
if (refreshId !== credentialsRefreshId) return
clearLiveNotifications()
await logout().catch(handleError)
await removeUser(creds.user_id).catch(handleError)
if (refreshId !== credentialsRefreshId) return
credentials.value = null
liveNotificationsEnabled = false
await fetchStoredModrinthAccounts()
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
@@ -1084,11 +1173,12 @@ async function fetchCredentials() {
}
credentials.value = creds ?? null
liveNotificationsEnabled = !!creds?.session
await fetchStoredModrinthAccounts()
}
async function signIn(flow = 'sign-in') {
async function signIn(flow = 'sign-in', addAccount = false) {
try {
await login(flow)
await login(flow, addAccount)
await fetchCredentials()
} catch (error) {
if (
@@ -1096,35 +1186,190 @@ async function signIn(flow = 'sign-in') {
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
// user closed the login window
} else {
handleError(error)
}
}
}
async function requestSignIn(flow = 'sign-in') {
await modrinthLoginModal.value?.showSigningIn(flow)
async function requestSignIn(flow = 'sign-in', addAccount = false) {
await modrinthLoginModal.value?.showSigningIn(flow, addAccount)
}
async function requestModrinthAuth(flow = 'sign-in') {
await signIn(flow)
async function requestModrinthAuth(flow = 'sign-in', addAccount = false) {
await signIn(flow, addAccount)
return !!credentials.value?.session
}
async function logOut() {
await performLogOut()
if (!credentials.value?.user) return
await completeAccountSwitch(() => logout())
}
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
clearLiveNotifications()
async function fetchStoredModrinthAccounts() {
const all = (await getAllCreds().catch(handleError)) ?? []
const ids = all.map((account) => account.user_id)
const users = ids.length ? ((await get_user_many(ids).catch(handleError)) ?? []) : []
const usersById = new Map(users.map((user) => [user.id, user]))
await logout().catch(handleError)
await fetchCredentials()
storedModrinthAccounts.value = all.map((account) => ({
...account,
user: usersById.get(account.user_id) ?? {
id: account.user_id,
username: account.user_id,
avatar_url: null,
role: 'developer',
},
}))
}
const accountSwitcherAccounts = computed(() => {
const currentId = credentials.value?.session ? credentials.value.user_id : null
return storedModrinthAccounts.value.map((account) => ({
...account,
optionId: `account-${account.user_id}`,
current: account.user_id === currentId,
}))
})
const profileButtonTooltip = computed(() => {
if (credentials.value === undefined) return formatMessage(messages.loadingProfile)
if (credentials.value?.user) return formatMessage(messages.modrinthAccount)
return formatMessage(messages.signInToModrinthAccount)
})
const accountSwitcherOptions = computed(() => [
...accountSwitcherAccounts.value.map((account) => ({
id: account.optionId,
label: account.user.username,
selected: account.current,
action: () => switchModrinthAccount(account),
trailingAction: {
label: formatMessage(messages.removeAccount),
icon: XIcon,
color: 'red',
action: () => forgetModrinthAccount(account.user_id),
},
})),
{
type: 'divider',
},
{
id: 'add-account',
label: formatMessage(messages.addAccount),
icon: PlusIcon,
action: () => requestSignIn('sign-in', true),
},
])
const isSwitchingAccount = ref(false)
async function persistAppearanceTheme(appearance) {
const selectedTheme = appearance.auto ? 'system' : appearance.theme
appTheme.applyAccountAppearance(appearance)
const settings = await getSettings()
if (settings.theme !== selectedTheme) {
settings.theme = selectedTheme
await setSettings(settings)
}
}
async function completeAccountSwitch(task) {
isSwitchingAccount.value = true
await nextTick()
try {
await task()
window.location.reload()
} catch (error) {
isSwitchingAccount.value = false
handleError(error)
}
}
async function switchModrinthAccount(account) {
if (account.current) return
const cached = getAccountAppearance(account.user_id)
if (cached) await persistAppearanceTheme(cached)
await nextTick()
await completeAccountSwitch(async () => {
await setActive(account.user_id)
if (cached) return
try {
const preferences = await get_user_preferences(account.user_id)
rememberAccountAppearance(account.user_id, preferences.appearance)
await persistAppearanceTheme(preferences.appearance)
await nextTick()
} catch {
// no saved appearance for this account
}
})
}
async function forgetModrinthAccount(userId) {
const isCurrent = credentials.value?.user_id === userId && !!credentials.value?.session
if (isCurrent) {
await completeAccountSwitch(() => removeUser(userId))
return
}
await removeUser(userId).catch(handleError)
await fetchStoredModrinthAccounts()
}
const modrinthAccountMenuOptions = computed(() => [
{
id: 'view-profile',
label: formatMessage(messages.viewProfile),
icon: UserIcon,
action: () => router.push(`/user/${encodeURIComponent(credentials.value.user.username)}`),
},
{
id: 'plus',
label: formatMessage(messages.upgradeToModrinthPlus),
icon: ArrowBigUpDashIcon,
type: 'link',
href: 'https://modrinth.plus?app',
target: '_blank',
tone: 'purple',
shown: !hasPlus.value,
},
{
id: 'add-friend',
label: formatMessage(messages.addFriend),
icon: UserPlusIcon,
action: () => friendsList.value?.showAddFriendModal(),
},
{
id: 'flags',
label: formatMessage(commonSettingsMessages.featureFlags),
icon: ToggleRightIcon,
shown: appSettings.devMode,
action: () => appSettingsModal.value?.showFeatureFlags(),
},
{
type: 'divider',
},
{
id: 'switch-account',
label: formatMessage(messages.switchAccount),
icon: ArrowLeftRightIcon,
type: 'submenu',
options: accountSwitcherOptions.value,
},
{
id: 'sign-out',
label: formatMessage(commonMessages.signOutButton),
icon: LogOutIcon,
tone: 'red',
action: () => logOut(),
},
])
async function fetchIntercomToken() {
const creds = await getCreds()
if (!creds?.session) {
@@ -1184,6 +1429,7 @@ onMounted(() => {
})
const accounts = ref(null)
const friendsList = ref(null)
provide('accountsCard', accounts)
useAppEvent('command', handleCommand, appEvents)
@@ -1711,12 +1957,39 @@ function handleAuxClick(e) {
}
}
function handleContextMenu(event) {
const target = event.target
if (target instanceof Element) {
if (target.closest('img, textarea, [contenteditable="true"]')) return
const input = target.closest('input')
if (
input &&
!['button', 'checkbox', 'radio', 'submit', 'reset', 'file', 'range'].includes(input.type)
) {
return
}
}
const selection = window.getSelection()
if (
target instanceof Node &&
selection &&
!selection.isCollapsed &&
selection.containsNode(target, true)
) {
return
}
event.preventDefault()
}
provideAppUpdateDownloadProgress(appUpdateDownload)
</script>
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div>
<AccountSwitchOverlay :show="isSwitchingAccount" />
<div
v-if="stateInitialized"
class="app-grid-layout relative"
@@ -1775,7 +2048,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
"
>
<PlayIcon />
<PlayIcon class="ml-0.5" />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
@@ -1790,6 +2063,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-if="globalSyncedOptionsQuery.data.value?.screenshots"
v-tooltip.right="formatMessage(messages.screenshots)"
to="/screenshots"
>
<ImagesIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
@@ -1819,59 +2099,69 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<SettingsIcon />
</NavButton>
<TeleportOverflowMenu
v-if="credentials?.user"
v-tooltip.right="formatMessage(messages.modrinthAccount)"
type="quiet"
size="xl"
:label="formatMessage(messages.moreOptions)"
:options="[
{
id: 'view-profile',
label: formatMessage(messages.signedInAs, {
username: credentials.user.username,
}),
action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`),
},
{
id: 'sign-out',
label: formatMessage(commonMessages.signOutButton),
tone: 'red',
action: () => logOut(),
},
]"
placement="right-end"
:distance="4"
>
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
<IntlFormatted
:message-id="messages.signedInAs"
:values="{ username: credentials?.user?.username }"
>
<template #user="{ children }">
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</span>
</template>
<template #sign-out>
<LogOutIcon />
{{ formatMessage(commonMessages.signOutButton) }}
</template>
</TeleportOverflowMenu>
<NavButton
v-else
v-tooltip.right="formatMessage(messages.signInToModrinthAccount)"
:to="() => requestSignIn()"
>
<LogInIcon class="text-brand" />
</NavButton>
<span v-tooltip.right="profileButtonTooltip" class="inline-flex">
<IconButton
v-if="credentials === undefined"
type="quiet"
size="xl"
disabled
class="pointer-events-none"
:label="formatMessage(messages.loadingProfile)"
>
<SpinnerIcon class="animate-spin" />
</IconButton>
<TeleportOverflowMenu
v-else-if="credentials?.user"
type="quiet"
size="xl"
:label="formatMessage(messages.modrinthAccount)"
:options="modrinthAccountMenuOptions"
placement="right-end"
:distance="4"
class="brightness-100 hover:!brightness-100 focus-visible:!brightness-100"
>
<Avatar
:src="credentials?.user?.avatar_url"
alt=""
size="32px"
circle
no-shadow
class="pointer-events-none !size-8"
/>
<template
v-for="account in accountSwitcherAccounts"
:key="account.user_id"
#[account.optionId]
>
<Avatar :src="account.user.avatar_url" size="1.25rem" aria-hidden="true" circle />
{{ account.user.username }}
<UserRoleIcon :role="account.user.role" />
</template>
</TeleportOverflowMenu>
<TeleportOverflowMenu
v-else-if="accountSwitcherAccounts.length > 0"
type="quiet"
size="xl"
:label="formatMessage(messages.signInToModrinthAccount)"
:options="accountSwitcherOptions"
placement="right-end"
:distance="4"
>
<LogInIcon class="!text-brand" />
<template
v-for="account in accountSwitcherAccounts"
:key="account.user_id"
#[account.optionId]
>
<Avatar :src="account.user.avatar_url" size="1.25rem" aria-hidden="true" circle />
{{ account.user.username }}
<UserRoleIcon :role="account.user.role" />
</template>
</TeleportOverflowMenu>
<NavButton v-else :to="() => requestSignIn()">
<LogInIcon class="text-brand" />
</NavButton>
</span>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
@@ -2020,7 +2310,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
<FriendsList
ref="friendsList"
:credentials="credentials"
:sign-in="() => requestSignIn()"
/>
</suspense>
</div>
<PrideFundraiserBanner
@@ -2190,7 +2484,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
background: var(--brand-gradient-bg);
--color-button-bg: var(--brand-gradient-button);
--surface-4: var(--brand-gradient-button);
--color-button-bg-hover: var(--brand-gradient-border);
--surface-5: var(--brand-gradient-border);
--color-divider: var(--brand-gradient-border);
--color-divider-dark: var(--brand-gradient-border);
}
@@ -2336,7 +2632,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.mac {
.mac-traffic-lights {
.app-grid-statusbar {
padding-left: 5rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

@@ -55,6 +55,10 @@
);
}
html.light-mode {
color-scheme: light;
}
body {
position: fixed;
width: 100%;
@@ -20,7 +20,6 @@
<div class="flex gap-2 w-full min-w-0">
<Avatar
size="36px"
disable-conditional-icon-padding
:src="
selectedAccount
? avatarUrl
@@ -35,7 +34,7 @@
</div>
</div>
</template>
<div class="bg-button-bg pt-1 pb-2 border border-solid border-surface-5">
<div class="bg-button-bg pt-1 pb-2 border-0 border-t border-solid border-surface-5">
<template v-if="accounts.length > 0">
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
<button
@@ -47,11 +46,7 @@
class="w-5 h-5 text-brand shrink-0"
/>
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
<Avatar
:src="getAccountAvatarUrl(account)"
size="24px"
disable-conditional-icon-padding
/>
<Avatar :src="getAccountAvatarUrl(account)" size="24px" />
<p
class="m-0 truncate min-w-0"
:class="
@@ -3,6 +3,7 @@ import {
CheckIcon,
CopyIcon,
DropdownIcon,
FolderOpenIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
@@ -13,8 +14,10 @@ import {
Button,
ButtonLink,
Collapsible,
defineMessages,
IconButton,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
@@ -25,8 +28,17 @@ import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
openBackupsFolder: {
id: 'app.error.state-init.open-backups-folder',
defaultMessage: 'Open backups folder',
},
})
const errorModal = ref()
const error = ref()
@@ -123,6 +135,10 @@ function retryDirectoryChange() {
window.location.reload()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
const loadingRepair = ref(false)
async function repairInstance() {
loadingRepair.value = true
@@ -310,6 +326,10 @@ async function copyToClipboard(text) {
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
>
{{ debugInfo }}
<button class="btn" @click="openDbBackupsFolder">
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(messages.openBackupsFolder) }}
</button>
</div>
<IconButton
v-tooltip="'Copy debug info'"
@@ -7,8 +7,9 @@ import {
FileTreeSelect,
injectNotificationManager,
injectPopupNotificationManager,
Input,
NewModal,
StyledInput,
Textarea,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
@@ -194,7 +195,7 @@ function normalizeExportPath(path) {
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
<Input
v-model="nameInput"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
@@ -206,7 +207,7 @@ function normalizeExportPath(path) {
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<StyledInput
<Input
v-model="versionInput"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
@@ -219,9 +220,8 @@ function normalizeExportPath(path) {
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<StyledInput
<Textarea
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
@@ -179,6 +179,7 @@ onMounted(() => {
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
:alt="formatMessage(messages.instanceIcon)"
pad-transparent-corners
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
@@ -244,6 +245,7 @@ onMounted(() => {
:tint-by="instance.id"
:alt="formatMessage(messages.instanceIcon)"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
pad-transparent-corners
/>
<div class="absolute inset-0 flex items-center justify-center">
<IconButton
@@ -40,7 +40,12 @@ const instanceLink = computed(() => {
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
<span class="flex items-center gap-2">
<Avatar :src="getInstanceIconUrl(instance.icon_path)" :alt="instance.name" size="48px" />
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:alt="instance.name"
size="48px"
pad-transparent-corners
/>
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
{{ instance.name }}
@@ -2,7 +2,7 @@
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div :id="props.id" class="toggle-setting" :class="{ compact }">
<div class="input-with-status">
<StyledInput
<Input
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
@@ -98,13 +98,7 @@ import {
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Button, defineMessages, injectNotificationManager, Input, useVIntl } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -1,13 +1,25 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { EyeIcon, FolderOpenIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import {
Avatar,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import NavButton from '@/components/ui/NavButton.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { getInstanceIconUrl, list } from '@/helpers/instance'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { getInstanceIconUrl, kill, list, run } from '@/helpers/instance'
import { get_all } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
@@ -16,6 +28,9 @@ const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const instanceOptions = ref()
const runningInstances = ref([])
const { formatMessage } = useVIntl()
@@ -153,8 +168,11 @@ useAppEvent('instance', async (event) => {
}
})
useAppEvent('process', checkProcesses)
onMounted(() => {
window.addEventListener('resize', updateMaxAuto)
checkProcesses()
})
onUnmounted(() => {
@@ -172,6 +190,18 @@ const messages = defineMessages({
id: 'app.quick-instance-switcher.drag-show-tooltip',
defaultMessage: 'Drag to show recent instances',
},
viewInstance: {
id: 'app.quick-instance-switcher.view-instance',
defaultMessage: 'View instance',
},
instanceActions: {
id: 'app.quick-instance-switcher.actions.label',
defaultMessage: 'Instance actions',
},
instanceLocked: {
id: 'app.quick-instance-switcher.instance-locked',
defaultMessage: 'This instance has been locked',
},
})
const dividerTooltip = computed(() => {
@@ -180,6 +210,69 @@ const dividerTooltip = computed(() => {
}
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
})
async function checkProcesses() {
const processes = (await get_all().catch(handleError)) ?? []
runningInstances.value = processes.map((process) => process.instance_id)
}
async function playInstance(instance) {
if (instance.quarantined || instance.install_stage !== 'installed') return
await run(instance.id)
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
.finally(() => {
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'QuickInstanceSwitcher',
})
})
}
async function stopInstance(instance) {
await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'QuickInstanceSwitcher',
})
}
function openContextMenu(event, instance) {
const playing = runningInstances.value.includes(instance.id)
instanceOptions.value?.open(event, [
playing
? {
id: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
tone: 'red',
action: () => stopInstance(instance),
}
: {
id: 'play',
label: formatMessage(commonMessages.playButton),
icon: PlayIcon,
tone: 'brand',
disabled: instance.quarantined || instance.install_stage !== 'installed',
tooltip: instance.quarantined ? formatMessage(messages.instanceLocked) : undefined,
action: () => playInstance(instance),
},
{ type: 'divider' },
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
icon: EyeIcon,
action: () => router.push(`/instance/${encodeURIComponent(instance.id)}`),
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
icon: FolderOpenIcon,
action: () => showInstanceInFolder(instance.id),
},
])
}
</script>
<template>
@@ -197,6 +290,7 @@ const dividerTooltip = computed(() => {
:key="instance.id"
v-tooltip.right="instance.name"
class="quick-instance-item"
@contextmenu.prevent.stop="(event) => openContextMenu(event, instance)"
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
@@ -204,6 +298,7 @@ const dividerTooltip = computed(() => {
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
pad-transparent-corners
/>
<div
v-if="instance.install_stage !== 'installed'"
@@ -214,6 +309,7 @@ const dividerTooltip = computed(() => {
</NavButton>
</div>
</TransitionGroup>
<ContextMenu ref="instanceOptions" :label="formatMessage(messages.instanceActions)" />
<div
v-tooltip.right="dividerTooltip"
class="flex items-center justify-center py-2 select-none"
@@ -1,6 +1,6 @@
<template>
<Transition name="splash-fade" @after-leave="onAfterLeave">
<div v-if="!doneLoading" class="splash-screen dark">
<div v-if="!doneLoading" class="splash-screen" :class="`${theme.active}-mode`">
<div class="app-logo-wrapper" data-tauri-drag-region>
<svg
class="app-logo"
@@ -83,6 +83,9 @@ import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { useTheme } from '@/composables/use-theme.ts'
const theme = useTheme()
const doneLoading = ref(false)
const loadingProgress = ref(0)
@@ -145,6 +148,12 @@ useAppEvent('loading', (e) => {
position: fixed;
inset: 0;
z-index: 10000;
--splash-cube-image: url('@/assets/loading/cube.png');
&.light-mode {
--splash-cube-image: url('@/assets/loading/cube-light.webp');
}
}
.splash-fade-leave-active {
@@ -166,6 +175,7 @@ useAppEvent('loading', (e) => {
align-items: center;
gap: 1rem;
color: var(--color-contrast);
z-index: 9998;
}
@@ -184,8 +194,8 @@ useAppEvent('loading', (e) => {
height: 100vh;
width: 100vw;
background:
linear-gradient(180deg, rgba(66, 131, 92, 0.275) 0%, rgba(17, 35, 43, 0.5) 97.29%),
linear-gradient(0deg, rgba(22, 24, 28, 0.64), rgba(22, 24, 28, 0.64));
linear-gradient(180deg, var(--splash-tint-top) 0%, var(--splash-tint-bottom) 97.29%),
linear-gradient(0deg, var(--splash-overlay), var(--splash-overlay));
z-index: 9997;
}
@@ -198,11 +208,19 @@ useAppEvent('loading', (e) => {
width: 180vw;
height: 180vh;
opacity: 0.8;
background: #16181c url('@/assets/loading/cube.png') center no-repeat;
background-size: contain;
background-color: var(--color-bg);
z-index: 9996;
&::after {
content: '';
position: absolute;
inset: 0;
background: var(--splash-cube-image) center no-repeat;
background-size: contain;
opacity: var(--splash-cube-opacity);
mix-blend-mode: var(--splash-cube-blend);
}
}
.base-bg {
@@ -1,35 +1,38 @@
<template>
<section
v-if="showControls"
class="flex items-center gap-2 mr-1.5"
data-tauri-drag-region-exclude
>
<IconButton
type="quiet"
label="Minimize window"
class="relative expanded-button"
@click="() => getCurrentWindow().minimize()"
<Teleport to="body">
<section
v-if="showControls"
ref="controlsEl"
class="pointer-events-auto fixed top-0 right-0 z-[10001] flex h-12 items-center gap-2 bg-bg-raised rounded-bl-2xl px-1.5"
data-tauri-drag-region-exclude
>
<MinimizeIcon />
</IconButton>
<IconButton
type="quiet"
label="Toggle maximize window"
class="relative expanded-button"
@click="() => getCurrentWindow().toggleMaximize()"
>
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</IconButton>
<IconButton
type="quiet"
label="Close window"
class="relative expanded-button close-button"
@click="handleClose"
>
<XIcon />
</IconButton>
</section>
<IconButton
type="quiet"
label="Minimize window"
class="relative expanded-button"
@click="getCurrentWindow().minimize()"
>
<MinimizeIcon />
</IconButton>
<IconButton
type="quiet"
label="Toggle maximize window"
class="relative expanded-button"
@click="getCurrentWindow().toggleMaximize()"
>
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</IconButton>
<IconButton
type="quiet"
label="Close window"
class="relative expanded-button close-button"
@click="handleClose"
>
<XIcon />
</IconButton>
</section>
</Teleport>
</template>
<script setup>
@@ -37,7 +40,7 @@ import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets
import { IconButton } from '@modrinth/ui'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings } from '@/helpers/settings.ts'
@@ -48,6 +51,7 @@ const appSettings = useAppSettings()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
const controlsEl = ref(null)
const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
@@ -57,6 +61,26 @@ const showControls = computed(
(!nativeDecorations.value && (os.value === 'Windows' || os.value === 'Linux')),
)
function setWindowControlsWidth(width) {
document.documentElement.style.setProperty('--window-controls-width', `${width}px`)
}
let resizeObserver
watch(controlsEl, (el) => {
resizeObserver?.disconnect()
resizeObserver = undefined
if (!el) {
setWindowControlsWidth(0)
return
}
resizeObserver = new ResizeObserver(() => {
setWindowControlsWidth(el.getBoundingClientRect().width)
})
resizeObserver.observe(el)
setWindowControlsWidth(el.getBoundingClientRect().width)
})
onMounted(async () => {
os.value = await getOS()
@@ -78,6 +102,11 @@ onMounted(async () => {
})
})
onUnmounted(() => {
resizeObserver?.disconnect()
document.documentElement.style.removeProperty('--window-controls-width')
})
const handleClose = async () => {
await saveWindowState(StateFlags.ALL)
await getCurrentWindow().close()
@@ -1,236 +0,0 @@
<template>
<Teleport to="#teleports">
<transition name="fade">
<div
v-show="shown && !isMobileActiveSubmenu"
ref="contextMenu"
class="context-menu"
:style="menuStyle"
role="menu"
@keydown="handleMenuKeydown"
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
>
<template v-for="(option, index) in options" :key="index">
<hr v-if="isDivider(option)" class="divider" />
<button
v-else-if="isOptionVisible(option)"
:ref="(element) => setOptionButtonRef(index, element)"
type="button"
class="item clickable"
:class="[
option.color ?? 'base',
{
active: index === activeOptionIndex,
'safe-triangle-hover': index === pendingOptionIndex,
},
]"
role="menuitem"
:aria-haspopup="hasChildren(option) ? 'menu' : undefined"
:aria-expanded="hasChildren(option) ? index === activeOptionIndex : undefined"
@click.stop="handleOptionClick(option, index)"
@focus="handleOptionFocus(option, index)"
@mouseenter="handleOptionMouseEnter(option, index)"
>
<span class="item-content"><slot :name="option.name" /></span>
<ChevronRightIcon v-if="hasChildren(option)" class="submenu-chevron" />
</button>
</template>
</div>
</transition>
</Teleport>
<Teleport to="#teleports">
<transition name="fade">
<div
v-if="shown && activeOption && hasSubmenuPosition"
ref="submenu"
class="context-menu submenu"
:style="submenuStyle"
role="menu"
@keydown="handleSubmenuKeydown"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="isCursorInsideSubmenu = false"
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
>
<button
v-if="isMobileSubmenuLayout"
type="button"
class="item clickable base mobile-back"
@click.stop="returnToMenu"
>
<ChevronLeftIcon class="submenu-chevron" />
<span class="item-content"><slot :name="activeOption.name" /></span>
</button>
<template v-for="(option, index) in activeOption.children" :key="index">
<hr v-if="isDivider(option)" class="divider" />
<button
v-else-if="isOptionVisible(option)"
:ref="(element) => setSubmenuButtonRef(index, element)"
type="button"
class="item clickable"
:class="option.color ?? 'base'"
role="menuitem"
@click.stop="optionClicked(option.name)"
>
<span class="item-content"><slot :name="option.name" /></span>
</button>
</template>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import type { ContextMenuEmit } from './types'
import { useContextMenu } from './use-context-menu'
import { hasChildren, isDivider } from './utils'
const emit = defineEmits<ContextMenuEmit>()
const {
shown,
contextMenu,
submenu,
options,
menuStyle,
submenuStyle,
activeOption,
activeOptionIndex,
pendingOptionIndex,
hasSubmenuPosition,
isMobileSubmenuLayout,
isMobileActiveSubmenu,
isCursorInsideSubmenu,
isOptionVisible,
setOptionButtonRef,
setSubmenuButtonRef,
showMenu,
hideMenu,
handleOptionClick,
optionClicked,
handleOptionFocus,
handleOptionMouseEnter,
returnToMenu,
handleSubmenuMouseEnter,
handleMenuMouseMove,
handleMenuKeydown,
handleSubmenuKeydown,
} = useContextMenu(emit)
defineExpose({ showMenu, hideMenu })
</script>
<style lang="scss" scoped>
.context-menu {
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-floating);
border: 1px solid var(--color-divider);
margin: 0;
position: fixed;
z-index: 1000000;
overflow: hidden;
padding: var(--gap-sm);
min-width: 12rem;
max-width: calc(100vw - 1.25rem);
&.submenu {
z-index: 1000001;
}
.item {
align-items: center;
background: transparent;
border: 0;
box-shadow: none;
color: var(--color-text-primary);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: inherit;
font-weight: 500;
gap: var(--gap-sm);
justify-content: space-between;
padding: var(--gap-sm);
border-radius: var(--radius-sm);
text-align: left;
width: 100%;
.item-content {
align-items: center;
display: flex;
gap: var(--gap-sm);
min-width: 0;
}
.submenu-chevron {
color: var(--color-text-secondary);
flex-shrink: 0;
height: 1.25rem;
width: 1.25rem;
margin-right: -0.25rem;
}
:deep(svg) {
color: var(--color-base);
}
&:hover:not(.safe-triangle-hover),
&:active,
&:focus-visible,
&.active {
:deep(svg) {
color: inherit;
}
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: 500;
}
}
}
.mobile-back {
border-bottom: 1px solid var(--color-divider);
border-radius: 0;
margin: calc(var(--gap-sm) * -1) calc(var(--gap-sm) * -1) var(--gap-sm);
width: calc(100% + var(--gap-sm) * 2);
}
.divider {
border: 1px solid var(--color-divider);
margin: var(--gap-sm);
pointer-events: none;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -1,41 +0,0 @@
import type { ComponentPublicInstance } from 'vue'
export type ContextMenuDivider = {
type: string
}
export type ContextMenuAction = {
name: string
color?: string
children?: ContextMenuOption[]
}
export type ContextMenuParentAction = ContextMenuAction & {
children: ContextMenuOption[]
}
export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
export type ContextMenuSelection = {
item: unknown
option: string
}
export type ContextMenuEmit = {
(event: 'menu-closed'): void
(event: 'option-clicked', selection: ContextMenuSelection): void
}
export type Point = {
x: number
y: number
}
export type ViewportRect = {
width: number
height: number
offsetTop: number
offsetLeft: number
}
export type ButtonRefElement = Element | ComponentPublicInstance | null
@@ -1,170 +0,0 @@
import type { CSSProperties, Ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import type { ContextMenuParentAction, Point, ViewportRect } from './types'
const MENU_GAP = 8
const VIEWPORT_MARGIN = 10
type ContextMenuPositionOptions = {
shown: Ref<boolean>
activeOption: Ref<ContextMenuParentAction | null>
activeOptionIndex: Ref<number | null>
isMobileSubmenuLayout: Ref<boolean>
contextMenu: Ref<HTMLElement | null>
submenu: Ref<HTMLElement | null>
optionButtonRefs: Map<number, HTMLElement>
}
export function useContextMenuPosition({
shown,
activeOption,
activeOptionIndex,
isMobileSubmenuLayout,
contextMenu,
submenu,
optionButtonRefs,
}: ContextMenuPositionOptions) {
const menuStyle = ref<CSSProperties>({ left: '0px', top: '0px' })
const menuAnchor = ref<Point>({ x: 0, y: 0 })
const submenuPosition = ref<Point>({ x: 0, y: 0 })
const hasSubmenuPosition = ref(false)
let positionRafId: number | null = null
const submenuStyle = computed<CSSProperties>(() => {
if (isMobileSubmenuLayout.value) return menuStyle.value
return {
left: `${submenuPosition.value.x}px`,
top: `${submenuPosition.value.y}px`,
}
})
function updateMenuPosition(x: number, y: number) {
if (!contextMenu.value) return
const viewport = getViewportRect()
const menuRect = contextMenu.value.getBoundingClientRect()
const viewportLeft = viewport.offsetLeft
const viewportTop = viewport.offsetTop
const viewportRight = viewport.offsetLeft + viewport.width
const viewportBottom = viewport.offsetTop + viewport.height
const anchorX = x + viewport.offsetLeft
const anchorY = y + viewport.offsetTop
const left = Math.min(
Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
)
const top = Math.min(
Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
)
menuStyle.value = { left: `${left}px`, top: `${top}px` }
if (activeOption.value) scheduleSubmenuPositionUpdate()
}
function updateSubmenuPosition() {
if (!activeOption.value || activeOptionIndex.value === null) return false
if (isMobileSubmenuLayout.value) {
hasSubmenuPosition.value = true
return true
}
const optionButton = optionButtonRefs.get(activeOptionIndex.value)
if (!optionButton || !contextMenu.value) return false
const viewport = getViewportRect()
const buttonRect = optionButton.getBoundingClientRect()
const menuRect = contextMenu.value.getBoundingClientRect()
const submenuRect = submenu.value?.getBoundingClientRect()
const submenuWidth = submenuRect?.width ?? menuRect.width
const submenuHeight = submenuRect?.height ?? 100
const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
const preferredLeft =
direction === 'right'
? buttonRect.right + MENU_GAP
: buttonRect.left - submenuWidth - MENU_GAP
const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
const maxLeft = Math.max(
minLeft,
viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
)
const minTop = viewport.offsetTop + VIEWPORT_MARGIN
const maxTop = Math.max(
minTop,
viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
)
submenuPosition.value = {
x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
}
hasSubmenuPosition.value = true
return true
}
function scheduleSubmenuPositionUpdate(retries = 8) {
nextTick(() => {
if (!shown.value || !activeOption.value) return
const hasRenderedSubmenu = submenu.value !== null
if (updateSubmenuPosition()) {
if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
return
}
if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
})
}
function schedulePositionUpdate() {
if (!shown.value || positionRafId !== null) return
positionRafId = window.requestAnimationFrame(() => {
positionRafId = null
updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
})
}
function cancelPositionUpdate() {
if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
}
return {
menuStyle,
menuAnchor,
submenuStyle,
hasSubmenuPosition,
updateMenuPosition,
scheduleSubmenuPositionUpdate,
schedulePositionUpdate,
cancelPositionUpdate,
}
}
function getViewportRect(): ViewportRect {
const visualViewport = window.visualViewport
return {
width: visualViewport?.width ?? window.innerWidth,
height: visualViewport?.height ?? window.innerHeight,
offsetTop: visualViewport?.offsetTop ?? 0,
offsetLeft: visualViewport?.offsetLeft ?? 0,
}
}
function getSubmenuOpenDirection(
menuRect: DOMRect,
submenuWidth: number,
viewport: ViewportRect,
): 'left' | 'right' {
const viewportLeft = viewport.offsetLeft
const viewportRight = viewport.offsetLeft + viewport.width
const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
if (rightSpace >= submenuWidth) return 'right'
if (leftSpace >= submenuWidth) return 'left'
return rightSpace >= leftSpace ? 'right' : 'left'
}
@@ -1,357 +0,0 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type {
ButtonRefElement,
ContextMenuAction,
ContextMenuEmit,
ContextMenuOption,
ContextMenuParentAction,
Point,
} from './types'
import { useContextMenuPosition } from './use-context-menu-position'
import {
focusRelativeButton,
getFocusableButtons,
hasChildren,
isAction,
isInstanceLink,
isPointInTriangle,
} from './utils'
const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
export function useContextMenu(emit: ContextMenuEmit) {
const item = ref<unknown>(null)
const contextMenu = ref<HTMLElement | null>(null)
const submenu = ref<HTMLElement | null>(null)
const options = ref<ContextMenuOption[]>([])
const shown = ref(false)
const activeOptionIndex = ref<number | null>(null)
const pendingOptionIndex = ref<number | null>(null)
const isCursorInsideSubmenu = ref(false)
const isMobileSubmenuLayout = ref(false)
const lastMousePosition = ref<Point | null>(null)
const contextMenuId = Symbol()
const optionButtonRefs = new Map<number, HTMLElement>()
const submenuButtonRefs = new Map<number, HTMLElement>()
let previousMousePosition: Point | null = null
let pendingOptionTimeout: ReturnType<typeof setTimeout> | null = null
let mobileSubmenuMediaQuery: MediaQueryList | null = null
const activeOption = computed<ContextMenuParentAction | null>(() => {
if (activeOptionIndex.value === null) return null
const option = options.value[activeOptionIndex.value]
return option && isAction(option) && hasChildren(option) ? option : null
})
const {
menuStyle,
menuAnchor,
submenuStyle,
hasSubmenuPosition,
updateMenuPosition,
scheduleSubmenuPositionUpdate,
schedulePositionUpdate,
cancelPositionUpdate,
} = useContextMenuPosition({
shown,
activeOption,
activeOptionIndex,
isMobileSubmenuLayout,
contextMenu,
submenu,
optionButtonRefs,
})
const isMobileActiveSubmenu = computed(
() => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
)
function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
}
function setOptionButtonRef(index: number, element: ButtonRefElement) {
setButtonRef(optionButtonRefs, index, element)
}
function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
setButtonRef(submenuButtonRefs, index, element)
}
function hideMenu() {
if (!shown.value) return
shown.value = false
deactivateSubmenu()
emit('menu-closed')
}
function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
item.value = passedItem
options.value = passedOptions
menuAnchor.value = { x: event.clientX, y: event.clientY }
shown.value = true
deactivateSubmenu()
syncMobileSubmenuLayout()
nextTick(() => updateMenuPosition(event.clientX, event.clientY))
}
function handleOptionClick(option: ContextMenuAction, index: number) {
if (hasChildren(option)) {
activateSubmenu(index)
return
}
optionClicked(option.name)
}
function optionClicked(option: string) {
emit('option-clicked', { item: item.value, option })
hideMenu()
}
function handleOptionFocus(option: ContextMenuAction, index: number) {
if (hasChildren(option) && !isMobileSubmenuLayout.value) {
activateSubmenu(index)
} else if (!hasChildren(option)) {
deactivateSubmenu()
}
}
function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
if (isMobileSubmenuLayout.value) return
if (activeOptionIndex.value === null) {
if (hasChildren(option)) activateSubmenu(index)
return
}
if (activeOptionIndex.value === index) return
if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
commitHoveredOption(index)
return
}
pendingOptionIndex.value = index
clearPendingOptionTimeout()
pendingOptionTimeout = setTimeout(() => {
if (pendingOptionIndex.value !== index) return
if (isCursorInsideSubmenu.value) {
pendingOptionIndex.value = null
return
}
commitHoveredOption(index)
}, 180)
}
function commitHoveredOption(index: number) {
const option = options.value[index]
if (option && hasChildren(option)) {
activateSubmenu(index)
} else {
deactivateSubmenu()
}
}
function activateSubmenu(index: number) {
clearPendingOptionTimeout()
pendingOptionIndex.value = null
activeOptionIndex.value = index
hasSubmenuPosition.value = false
scheduleSubmenuPositionUpdate()
}
function deactivateSubmenu() {
clearPendingOptionTimeout()
activeOptionIndex.value = null
pendingOptionIndex.value = null
hasSubmenuPosition.value = false
isCursorInsideSubmenu.value = false
lastMousePosition.value = null
previousMousePosition = null
}
function returnToMenu() {
const previousIndex = activeOptionIndex.value
deactivateSubmenu()
nextTick(() => {
if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
})
}
function handleSubmenuMouseEnter() {
isCursorInsideSubmenu.value = true
clearPendingOptionTimeout()
pendingOptionIndex.value = null
}
function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
previousMousePosition = lastMousePosition.value
lastMousePosition.value = { x: event.clientX, y: event.clientY }
if (
source === 'menu' &&
pendingOptionIndex.value !== null &&
!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
) {
commitHoveredOption(pendingOptionIndex.value)
}
}
function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
const submenuRect = submenu.value?.getBoundingClientRect()
if (!submenuRect || !cursor || !origin) return false
const submenuTargetX =
origin.x <= submenuRect.left
? submenuRect.left
: origin.x >= submenuRect.right
? submenuRect.right
: cursor.x <= submenuRect.left
? submenuRect.left
: submenuRect.right
const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
}
function handleMenuKeydown(event: KeyboardEvent) {
const buttons = getFocusableButtons(optionButtonRefs)
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
} else if (event.key === 'ArrowRight') {
const focusedIndex = [...optionButtonRefs.entries()].find(
([, button]) => button === document.activeElement,
)?.[0]
const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
event.preventDefault()
activateSubmenu(focusedIndex)
nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
}
} else if (event.key === 'Escape') {
event.preventDefault()
hideMenu()
}
}
function handleSubmenuKeydown(event: KeyboardEvent) {
const buttons = getFocusableButtons(submenuButtonRefs)
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
returnToMenu()
} else if (event.key === 'Escape') {
event.preventDefault()
hideMenu()
}
}
function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (shown.value && event.key === 'Escape') hideMenu()
}
function handleContextMenuOpen(event: Event) {
if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
}
function handleClickOutside(event: MouseEvent) {
const target = event.target
if (!(target instanceof Node)) return
if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
}
onMounted(() => {
mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
syncMobileSubmenuLayout()
mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
window.addEventListener('click', handleClickOutside)
window.addEventListener('resize', schedulePositionUpdate)
window.addEventListener('scroll', schedulePositionUpdate, true)
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
document.addEventListener('keydown', handleDocumentKeydown)
})
onBeforeUnmount(() => {
clearPendingOptionTimeout()
cancelPositionUpdate()
mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', schedulePositionUpdate)
window.removeEventListener('scroll', schedulePositionUpdate, true)
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
document.removeEventListener('keydown', handleDocumentKeydown)
})
function clearPendingOptionTimeout() {
if (!pendingOptionTimeout) return
clearTimeout(pendingOptionTimeout)
pendingOptionTimeout = null
}
return {
shown,
contextMenu,
submenu,
options,
menuStyle,
submenuStyle,
activeOption,
activeOptionIndex,
pendingOptionIndex,
hasSubmenuPosition,
isMobileSubmenuLayout,
isMobileActiveSubmenu,
isCursorInsideSubmenu,
isOptionVisible,
setOptionButtonRef,
setSubmenuButtonRef,
showMenu,
hideMenu,
handleOptionClick,
optionClicked,
handleOptionFocus,
handleOptionMouseEnter,
returnToMenu,
handleSubmenuMouseEnter,
handleMenuMouseMove,
handleMenuKeydown,
handleSubmenuKeydown,
}
}
function setButtonRef(
buttonRefs: Map<number, HTMLElement>,
index: number,
element: ButtonRefElement,
) {
if (element instanceof HTMLElement) {
buttonRefs.set(index, element)
} else {
buttonRefs.delete(index)
}
}
@@ -1,59 +0,0 @@
import type {
ContextMenuAction,
ContextMenuDivider,
ContextMenuOption,
ContextMenuParentAction,
Point,
} from './types'
export function isAction(option: ContextMenuOption): option is ContextMenuAction {
return 'name' in option
}
export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
return 'type' in option && option.type === 'divider'
}
export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
return isAction(option) && Boolean(option.children?.length)
}
export function isInstanceLink(value: unknown) {
if (!value || typeof value !== 'object') return false
if ('instance' in value) {
const instance = value.instance
return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
}
return 'link' in value && Boolean(value.link)
}
export function getFocusableButtons(buttonRefs: Map<number, HTMLElement>) {
return [...buttonRefs.entries()]
.sort(([left], [right]) => left - right)
.map(([, button]) => button)
.filter((button) => button.offsetParent !== null)
}
export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
if (!buttons.length) return
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
const nextIndex =
currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
}
export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
const area = triangleArea(a, b, c)
const area1 = triangleArea(point, b, c)
const area2 = triangleArea(a, point, c)
const area3 = triangleArea(a, b, point)
return Math.abs(area - (area1 + area2 + area3)) < 0.5
}
function triangleArea(a: Point, b: Point, c: Point) {
return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
}
@@ -6,8 +6,8 @@ import {
defineMessages,
IconButton,
injectNotificationManager,
Input,
IntlFormatted,
StyledInput,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -122,6 +122,11 @@ function addFriendFromModal() {
username.value = ''
}
function showAddFriendModal() {
username.value = ''
addFriendModal.value?.show()
}
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
@@ -130,6 +135,8 @@ function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
defineExpose({ showAddFriendModal })
const messages = defineMessages({
addFriend: {
id: 'friends.action.add-friend',
@@ -250,7 +257,7 @@ const messages = defineMessages({
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<StyledInput
<Input
v-model="username"
:icon="UserIcon"
type="text"
@@ -280,14 +287,15 @@ const messages = defineMessages({
>
<UserPlusIcon />
</IconButton>
<StyledInput
<Input
v-model="search"
:icon="SearchIcon"
type="text"
appearance="transparent"
:placeholder="formatMessage(messages.searchFriends)"
clearable
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
input-class="!text-primary !placeholder:text-primary"
wrapper-class="flex-1 !border-button-bg [&>span:first-child]:!text-primary [&>span:first-child]:!opacity-100"
@keyup.esc="search = ''"
/>
</template>
@@ -1,17 +1,18 @@
<script setup lang="ts">
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import type { ButtonMenuOption } from '@modrinth/ui'
import {
Accordion,
Avatar,
ContextMenu,
defineMessages,
IconButton,
TeleportOverflowMenu,
UserAvatar,
useVIntl,
} from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
@@ -36,27 +37,30 @@ const emit = defineEmits<{
onClose: []
}>()
function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
{
name: 'view-profile',
},
{
name: 'remove-friend',
color: 'danger',
},
]
} else {
return [
{
name: 'view-profile',
},
{
name: 'cancel-request',
},
]
}
function createContextMenuOptions(friend: FriendWithUserData): ButtonMenuOption[] {
return [
{
id: 'view-profile',
label: formatMessage(messages.viewProfile),
icon: UserIcon,
action: () => openProfile(friend.username),
},
friend.accepted
? {
id: 'remove-friend',
label: formatMessage(messages.removeFriend),
icon: TrashIcon,
tone: 'red',
hoverFilledOnly: true,
action: () => void props.removeFriend(friend),
}
: {
id: 'cancel-request',
label: formatMessage(messages.cancelRequest),
icon: XIcon,
action: () => void props.removeFriend(friend),
},
]
}
function openProfile(username: string) {
@@ -64,16 +68,6 @@ function openProfile(username: string) {
}
const friendOptions = useTemplateRef('friendOptions')
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
switch (args.option) {
case 'remove-friend':
case 'cancel-request':
await props.removeFriend(args.item)
break
case 'view-profile':
openProfile(args.item.username)
}
}
const messages = defineMessages({
removeFriend: {
@@ -96,18 +90,15 @@ const messages = defineMessages({
id: 'friends.friend.view-profile',
defaultMessage: 'View profile',
},
friendActionsLabel: {
id: 'friends.friend.actions.label',
defaultMessage: 'Friend actions',
},
})
</script>
<template>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
</ContextMenu>
<ContextMenu ref="friendOptions" :label="formatMessage(messages.friendActionsLabel)" />
<Accordion
:open-by-default="openByDefault"
:force-open="isSearching"
@@ -130,32 +121,24 @@ const messages = defineMessages({
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1 select-none"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
(event) => friendOptions?.open(event, createContextMenuOptions(friend))
"
>
<RouterLink
:to="`/user/${encodeURIComponent(friend.username)}`"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline group no-click-animation"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<UserAvatar
:src="friend.avatar"
size="32px"
:badge="friend.online"
:grayscale="!friend.online && friend.accepted"
/>
<div class="flex flex-col">
<span
class="text-sm m-0"
class="text-sm m-0 group-hover:underline"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
@@ -6,7 +6,7 @@ import {
Button,
defineMessages,
injectNotificationManager,
StyledInput,
Input,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -103,7 +103,7 @@ async function addServer(instance) {
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
<StyledInput
<Input
v-model="searchFilter"
:icon="SearchIcon"
type="search"
@@ -121,7 +121,11 @@ async function addServer(instance) {
:to="`/instance/${encodeURIComponent(instance.id)}`"
@click="modal.hide()"
>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" class="mr-2 [--size:2rem]" />
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
class="mr-2 [--size:2rem]"
pad-transparent-corners
/>
{{ instance.name }}
</router-link>
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
@@ -23,7 +23,7 @@
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<StyledInput
<Input
v-model="groupInstancesSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder)"
@@ -51,6 +51,7 @@
:alt="instance.name"
size="2rem"
rounded="md"
pad-transparent-corners
/>
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
</div>
@@ -96,8 +97,8 @@ import {
Button,
commonMessages,
defineMessages,
Input,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
@@ -1,23 +1,9 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EditIcon,
EyeIcon,
FolderOpenIcon,
MinusIcon,
PaletteIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { StarIcon } from '@modrinth/assets'
import { ContextMenu, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
@@ -44,55 +30,9 @@ const messages = defineMessages({
id: 'app.library.search.no-results.title',
defaultMessage: 'No instances match your search.',
},
play: { id: 'app.library.instance.action.play', defaultMessage: 'Play' },
stop: { id: 'app.library.instance.action.stop', defaultMessage: 'Stop' },
addToFavorites: {
id: 'app.library.instance.action.add-to-favorites',
defaultMessage: 'Add to favorites',
},
removeFromFavorites: {
id: 'app.library.instance.action.remove-from-favorites',
defaultMessage: 'Remove from favorites',
},
addContent: { id: 'app.library.instance.action.add-content', defaultMessage: 'Add content' },
viewInstance: {
id: 'app.library.instance.action.view-instance',
defaultMessage: 'View instance',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
createIcon: {
id: 'instance.settings.tabs.general.edit-icon.create',
defaultMessage: 'Create an icon',
},
editCreatedIcon: {
id: 'instance.settings.tabs.general.edit-icon.edit-created',
defaultMessage: 'Edit icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'app.library.instance.action.duplicate',
defaultMessage: 'Duplicate instance',
},
delete: { id: 'app.library.instance.action.delete', defaultMessage: 'Delete' },
openFolder: { id: 'app.library.instance.action.open-folder', defaultMessage: 'Open folder' },
copyPath: { id: 'app.library.instance.action.copy-path', defaultMessage: 'Copy path' },
removeFromGroup: {
id: 'app.library.instance.action.remove-from-group',
defaultMessage: 'Remove from group',
instanceActionsLabel: {
id: 'app.library.instance.actions.label',
defaultMessage: 'Instance actions',
},
})
@@ -111,7 +51,6 @@ const {
currentDeleteInstances,
clearLibraryInstanceSelection,
deleteInstance,
handleInstanceOption,
handleInstanceIconSaved,
selectedLibraryInstances,
setSelectedLibraryInstances,
@@ -371,7 +310,9 @@ watch(selectedLibraryInstances, (selectedInstances) => {
>
<InstanceGroup
:can-drag-reorder="canDragReorderGroups"
:hide-header="visibleInstanceGroups.length === 1"
:hide-header="
instanceGroup.id === 'group:none' && visibleInstanceGroups.length === 1
"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
@@ -430,34 +371,10 @@ watch(selectedLibraryInstances, (selectedInstances) => {
:config="currentIconEditorInstance?.icon_config"
@saved="handleInstanceIconSaved"
/>
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
<template #add_to_favorites>
<StarIcon /> {{ formatMessage(messages.addToFavorites) }}
</template>
<template #remove_from_favorites>
<ContextMenu :ref="setInstanceOptions" :label="formatMessage(messages.instanceActionsLabel)">
<template #remove_from_favorites="{ option }">
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
{{ formatMessage(messages.removeFromFavorites) }}
</template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #edit_icon> <EditIcon /> {{ formatMessage(messages.editIcon) }} </template>
<template #select_icon> <UploadIcon /> {{ formatMessage(messages.selectIcon) }} </template>
<template #replace_icon> <UploadIcon /> {{ formatMessage(messages.replaceIcon) }} </template>
<template #create_icon> <PaletteIcon /> {{ formatMessage(messages.createIcon) }} </template>
<template #edit_created_icon>
<PaletteIcon /> {{ formatMessage(messages.editCreatedIcon) }}
</template>
<template #remove_icon> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
<template #duplicate>
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
</template>
<template #delete> <TrashIcon /> {{ formatMessage(messages.delete) }} </template>
<template #open> <FolderOpenIcon /> {{ formatMessage(messages.openFolder) }} </template>
<template #copy> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
<template #remove_from_group>
<MinusIcon /> {{ formatMessage(messages.removeFromGroup) }}
{{ option.label }}
</template>
</ContextMenu>
</template>
@@ -12,6 +12,7 @@ import {
Accordion,
Button,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
InlineEditableText,
@@ -21,7 +22,6 @@ import {
} from '@modrinth/ui'
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
import type {
@@ -163,6 +163,10 @@ const messages = defineMessages({
id: 'app.library.group.empty',
defaultMessage: 'Drag and drop to add instances.',
},
groupActionsLabel: {
id: 'app.library.group.actions.label',
defaultMessage: 'Group actions',
},
})
function openInstanceContextMenu(event: MouseEvent, instanceId: string, instanceGroupId: string) {
@@ -204,49 +208,45 @@ function requestGroupDeletion() {
function openGroupContextMenu(event: MouseEvent) {
groupContextMenuOpen.value = true
groupOptions.value?.showMenu(
event,
props.instanceGroup,
isFavorites.value
? [{ name: 'new_instance' }]
: isCustomGroup.value
? [
{ name: 'new_instance' },
{ name: 'add_instances' },
{ name: 'edit_name' },
{ type: 'divider' },
{ name: 'delete_group', color: 'danger' },
]
: [{ name: 'new_instance' }],
)
}
function handleGroupOption({ option }: { option: string }) {
if (option === 'new_instance') {
showCreationModal?.()
return
}
if (option === 'add_instances') {
openGroupInstancesModal(props.instanceGroup.id)
return
}
if (option === 'edit_name') {
void groupNameInput.value?.startEditing()
return
}
if (option === 'delete_group') {
requestGroupDeletion()
}
groupOptions.value?.open(event, [
{
id: 'new_instance',
label: formatMessage(messages.newInstance),
icon: PlusIcon,
action: () => showCreationModal?.(),
},
{
id: 'add_instances',
label: formatMessage(messages.addToGroup),
icon: SquarePlusIcon,
shown: isCustomGroup.value,
action: () => openGroupInstancesModal(props.instanceGroup.id),
},
{
id: 'edit_name',
label: formatMessage(messages.editGroupName),
icon: EditIcon,
shown: isCustomGroup.value,
action: () => void groupNameInput.value?.startEditing(),
},
{ type: 'divider', shown: isCustomGroup.value },
{
id: 'delete_group',
label: formatMessage(messages.deleteGroup),
icon: TrashIcon,
shown: isCustomGroup.value,
tone: 'red',
hoverFilledOnly: true,
action: requestGroupDeletion,
},
])
}
function prepareGroupToggle(event: PointerEvent) {
const editor = groupNameInput.value
const contextMenuWasOpen = groupContextMenuOpen.value
if (contextMenuWasOpen) {
groupOptions.value?.hideMenu()
groupOptions.value?.close()
}
shouldSkipGroupToggle = Boolean(
@@ -523,16 +523,9 @@ onMounted(startInstanceGridResizeObserver)
<ContextMenu
ref="groupOptions"
@menu-closed="groupContextMenuOpen = false"
@option-clicked="handleGroupOption"
>
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
<template #add_instances>
<SquarePlusIcon /> {{ formatMessage(messages.addToGroup) }}
</template>
<template #edit_name> <EditIcon /> {{ formatMessage(messages.editGroupName) }} </template>
<template #delete_group> <TrashIcon /> {{ formatMessage(messages.deleteGroup) }} </template>
</ContextMenu>
:label="formatMessage(messages.groupActionsLabel)"
@close="groupContextMenuOpen = false"
/>
<NewModal
ref="confirmDeleteGroupModal"
@@ -47,6 +47,7 @@ const versionRef = ref<HTMLElement | null>(null)
:tint-by="instance.id"
alt=""
no-shadow
pad-transparent-corners
/>
<slot name="loading" :compact="compactMode" />
<div
@@ -242,6 +242,9 @@ defineExpose({
openFolder,
addContent,
instance: props.instance,
get playing() {
return playing.value
},
})
useAppEvent('process', (event) => {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { PlusIcon, SearchIcon, SquarePlusIcon } from '@modrinth/assets'
import { Button, defineMessages, StyledInput, useVIntl } from '@modrinth/ui'
import { Button, defineMessages, Input, useVIntl } from '@modrinth/ui'
import { computed, inject } from 'vue'
import FilterMenu from '@/components/ui/library/library-toolbar/filter-menu.vue'
@@ -29,7 +29,7 @@ function openNewGroup() {
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<StyledInput
<Input
v-model="search"
:icon="SearchIcon"
type="text"
@@ -18,7 +18,7 @@
<label for="new-group-name" class="font-semibold text-contrast">
{{ formatMessage(messages.groupName) }}
</label>
<StyledInput
<Input
id="new-group-name"
ref="groupNameInput"
v-model="newGroupName"
@@ -32,7 +32,7 @@
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<StyledInput
<Input
v-model="newGroupSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchInstance)"
@@ -60,6 +60,7 @@
:alt="instance.name"
size="2rem"
rounded="md"
pad-transparent-corners
/>
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
@@ -103,15 +104,7 @@
<script setup lang="ts">
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
defineMessages,
NewModal,
StyledInput,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { Avatar, Button, defineMessages, Input, NewModal, TagItem, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
@@ -157,7 +150,7 @@ const {
} = useLibrary()
const modal = ref<InstanceType<typeof NewModal>>()
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
const groupNameInput = ref<InstanceType<typeof Input>>()
const groupNamesById = computed(
() => new Map(libraryGroups.value.map((group) => [group.id, group.name])),
)
@@ -1,5 +1,20 @@
import type { Labrinth } from '@modrinth/api-client'
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import {
ClipboardCopyIcon,
EditIcon,
EyeIcon,
FolderOpenIcon,
MinusIcon,
PaletteIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import type { ButtonMenuLeafOption, ButtonMenuOption } from '@modrinth/ui'
import { defineMessages, formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { useEventListener, useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
@@ -98,7 +113,7 @@ export type InstanceCard = {
}
type InstanceContextMenu = {
showMenu: (event: MouseEvent, item: InstanceCard, options: unknown[]) => void
open: (event: MouseEvent, options: ButtonMenuOption[]) => void
}
type ConfirmDeleteModal = {
@@ -109,10 +124,58 @@ type IconEditorModal = {
show: () => void
}
type ContextMenuSelection = {
option: string
item: InstanceCard
}
const instanceActionMessages = defineMessages({
play: { id: 'app.library.instance.action.play', defaultMessage: 'Play' },
stop: { id: 'app.library.instance.action.stop', defaultMessage: 'Stop' },
addToFavorites: {
id: 'app.library.instance.action.add-to-favorites',
defaultMessage: 'Add to favorites',
},
removeFromFavorites: {
id: 'app.library.instance.action.remove-from-favorites',
defaultMessage: 'Remove from favorites',
},
addContent: { id: 'app.library.instance.action.add-content', defaultMessage: 'Add content' },
viewInstance: {
id: 'app.library.instance.action.view-instance',
defaultMessage: 'View instance',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
createIcon: {
id: 'instance.settings.tabs.general.edit-icon.create',
defaultMessage: 'Create an icon',
},
editCreatedIcon: {
id: 'instance.settings.tabs.general.edit-icon.edit-created',
defaultMessage: 'Edit icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'app.library.instance.action.duplicate',
defaultMessage: 'Duplicate instance',
},
delete: { id: 'app.library.instance.action.delete', defaultMessage: 'Delete' },
openFolder: { id: 'app.library.instance.action.open-folder', defaultMessage: 'Open folder' },
copyPath: { id: 'app.library.instance.action.copy-path', defaultMessage: 'Copy path' },
removeFromGroup: {
id: 'app.library.instance.action.remove-from-group',
defaultMessage: 'Remove from group',
},
})
function createLibraryState(instances: Ref<GameInstance[]>) {
const { handleError } = injectNotificationManager()
@@ -160,7 +223,6 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const currentDeleteInstances = computed(() =>
instances.value.filter((instance) => instance.id === currentDeleteInstanceId.value),
)
const currentContextGroupId = ref<string | null>(null)
const confirmDeleteModal = ref<ConfirmDeleteModal | null>(null)
const iconEditorModal = ref<IconEditorModal | null>(null)
const currentIconEditorInstanceId = ref<string | null>(null)
@@ -1156,121 +1218,153 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
trackEvent('InstanceSaveCreatedIcon')
}
const setInstanceGroups = async (item: InstanceCard, groupIds: string[]) =>
await edit(item.instance.id, { group_ids: groupIds }).catch((error) =>
handleError(toError(error)),
)
const buildInstanceIconOptions = (item: InstanceCard): ButtonMenuLeafOption[] => [
{
id: item.instance.icon_path ? 'replace_icon' : 'select_icon',
label: formatMessage(
item.instance.icon_path
? instanceActionMessages.replaceIcon
: instanceActionMessages.selectIcon,
),
icon: UploadIcon,
action: () => void selectInstanceIcon(item),
},
{
id: item.instance.icon_config ? 'edit_created_icon' : 'create_icon',
label: formatMessage(
item.instance.icon_config
? instanceActionMessages.editCreatedIcon
: instanceActionMessages.createIcon,
),
icon: PaletteIcon,
action: () => void openInstanceIconEditor(item),
},
{
id: 'remove_icon',
label: formatMessage(instanceActionMessages.removeIcon),
icon: TrashIcon,
shown: !!item.instance.icon_path,
action: () => void removeInstanceIcon(item),
},
]
const handleInstanceContextMenu = (
event: MouseEvent,
item: InstanceCard,
instanceGroupId: string,
) => {
currentContextGroupId.value =
const removableGroupId =
displayState.value.group === 'Group' &&
instanceGroupId !== 'group:none' &&
instanceGroupId !== FAVORITES_GROUP_ID
? instanceGroupId
: null
const isFavorite = item.instance.group_ids.includes(FAVORITES_GROUP_ID)
const canAddContent = !item.instance.quarantined && !item.instance.link
const baseOptions = [
instanceOptions.value?.open(event, [
{
name: item.instance.group_ids.includes(FAVORITES_GROUP_ID)
? 'remove_from_favorites'
: 'add_to_favorites',
id: 'stop',
label: formatMessage(instanceActionMessages.stop),
icon: StopCircleIcon,
shown: item.playing,
tone: 'red',
action: () => void item.stop(null, 'InstanceGridContextMenu'),
},
{
id: 'play',
label: formatMessage(instanceActionMessages.play),
icon: PlayIcon,
shown: !item.playing && !item.instance.quarantined,
tone: 'brand',
action: () => void item.play(null, 'InstanceGridContextMenu'),
},
{
id: isFavorite ? 'remove_from_favorites' : 'add_to_favorites',
label: formatMessage(
isFavorite
? instanceActionMessages.removeFromFavorites
: instanceActionMessages.addToFavorites,
),
icon: StarIcon,
action: () =>
void setInstanceGroups(
item,
isFavorite
? item.instance.group_ids.filter((groupId) => groupId !== FAVORITES_GROUP_ID)
: [...new Set([...item.instance.group_ids, FAVORITES_GROUP_ID])],
),
},
{ type: 'divider' },
...(!item.instance.quarantined && !item.instance.link
? [{ name: 'add_content' }, { type: 'divider' }]
: []),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{
name: 'edit_icon',
children: [
{ name: item.instance.icon_path ? 'replace_icon' : 'select_icon' },
{ name: item.instance.icon_config ? 'edit_created_icon' : 'create_icon' },
...(item.instance.icon_path ? [{ name: 'remove_icon' }] : []),
],
id: 'add_content',
label: formatMessage(instanceActionMessages.addContent),
icon: PlusIcon,
shown: canAddContent,
action: () => void item.addContent(),
},
...(currentContextGroupId.value
? [{ name: 'remove_from_group' }, { type: 'divider' }]
: [{ type: 'divider' }]),
{ name: 'delete', color: 'danger' },
]
instanceOptions.value?.showMenu(
event,
item,
item.playing
? [{ name: 'stop', color: 'danger' }, ...baseOptions]
: [
...(item.instance.quarantined ? [] : [{ name: 'play', color: 'primary' }]),
...baseOptions,
],
)
}
const handleInstanceOption = async ({ option, item }: ContextMenuSelection) => {
switch (option) {
case 'play':
await item.play(null, 'InstanceGridContextMenu')
break
case 'stop':
await item.stop(null, 'InstanceGridContextMenu')
break
case 'add_content':
await item.addContent()
break
case 'add_to_favorites':
await edit(item.instance.id, {
group_ids: [...new Set([...item.instance.group_ids, FAVORITES_GROUP_ID])],
}).catch((error) => handleError(toError(error)))
break
case 'remove_from_favorites':
await edit(item.instance.id, {
group_ids: item.instance.group_ids.filter(
(instanceGroupId) => instanceGroupId !== FAVORITES_GROUP_ID,
{ type: 'divider', shown: canAddContent },
{
id: 'edit',
label: formatMessage(instanceActionMessages.viewInstance),
icon: EyeIcon,
action: () => void item.seeInstance(),
},
{
id: 'duplicate',
label: formatMessage(instanceActionMessages.duplicateInstance),
icon: ClipboardCopyIcon,
disabled: item.instance.install_stage !== 'installed',
action: () => void duplicateInstance(item.instance.id),
},
{
id: 'open',
label: formatMessage(instanceActionMessages.openFolder),
icon: FolderOpenIcon,
action: () => void item.openFolder(),
},
{
id: 'copy',
label: formatMessage(instanceActionMessages.copyPath),
icon: ClipboardCopyIcon,
action: () => void navigator.clipboard.writeText(item.instance.id),
},
{
type: 'submenu',
id: 'edit_icon',
label: formatMessage(instanceActionMessages.editIcon),
icon: EditIcon,
options: buildInstanceIconOptions(item),
},
{
id: 'remove_from_group',
label: formatMessage(instanceActionMessages.removeFromGroup),
icon: MinusIcon,
shown: !!removableGroupId,
action: () =>
void setInstanceGroups(
item,
item.instance.group_ids.filter((groupId) => groupId !== removableGroupId),
),
}).catch((error) => handleError(toError(error)))
break
case 'edit':
await item.seeInstance()
break
case 'select_icon':
case 'replace_icon':
await selectInstanceIcon(item)
break
case 'create_icon':
case 'edit_created_icon':
await openInstanceIconEditor(item)
break
case 'remove_icon':
await removeInstanceIcon(item)
break
case 'duplicate':
if (item.instance.install_stage === 'installed') {
await duplicateInstance(item.instance.id)
}
break
case 'open':
await item.openFolder()
break
case 'copy':
await navigator.clipboard.writeText(item.instance.id)
break
case 'remove_from_group':
if (currentContextGroupId.value) {
const groupId = currentContextGroupId.value
await edit(item.instance.id, {
group_ids: item.instance.group_ids.filter(
(instanceGroupId) => instanceGroupId !== groupId,
),
}).catch((error) => handleError(toError(error)))
}
break
case 'delete':
currentDeleteInstanceId.value = item.instance.id
confirmDeleteModal.value?.show()
break
}
},
{ type: 'divider' },
{
id: 'delete',
label: formatMessage(instanceActionMessages.delete),
icon: TrashIcon,
tone: 'red',
hoverFilledOnly: true,
action: () => {
currentDeleteInstanceId.value = item.instance.id
confirmDeleteModal.value?.show()
},
},
])
}
return {
@@ -1339,7 +1433,6 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
moveGroup,
deleteInstance,
handleInstanceContextMenu,
handleInstanceOption,
handleInstanceIconSaved,
}
}
@@ -1,12 +1,12 @@
<script setup lang="ts">
import {
CoffeeIcon,
GameIcon,
GaugeIcon,
HeartHandshakeIcon,
LanguagesIcon,
ModrinthIcon,
PaintbrushIcon,
RefreshCwIcon,
Settings2Icon,
ShieldIcon,
ToggleRightIcon,
@@ -33,7 +33,7 @@ import AppearanceSettings from '@/components/ui/settings/display/AppearanceSetti
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.vue'
import InstancesSyncedSettings from '@/components/ui/settings/instances/InstancesSyncedSettings.vue'
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
@@ -130,12 +130,12 @@ const tabs = [
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default game options',
id: 'app.settings.tabs.synced-options',
defaultMessage: 'Synced settings',
}),
category: tabCategories.instances,
icon: GameIcon,
content: DefaultInstanceSettings,
icon: RefreshCwIcon,
content: InstancesSyncedSettings,
},
{
name: defineMessage({
@@ -222,7 +222,27 @@ function showProfile(): void {
modal.value?.show()
}
defineExpose({ show, showProfile })
function showFeatureFlags(): void {
const featureFlagsTabIndex = availableTabs.value.findIndex(
(tab) => tab.content === FeatureFlagSettings,
)
if (featureFlagsTabIndex >= 0) {
modal.value?.setTab(featureFlagsTabIndex)
}
modal.value?.show()
}
function showSyncedOptions(): void {
const syncedOptionsTabIndex = availableTabs.value.findIndex(
(tab) => tab.content === InstancesSyncedSettings,
)
if (syncedOptionsTabIndex >= 0) {
modal.value?.setTab(syncedOptionsTabIndex)
}
modal.value?.show()
}
defineExpose({ show, showProfile, showFeatureFlags, showSyncedOptions })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -39,6 +39,7 @@
size="40px"
class="!rounded-xl"
no-shadow
pad-transparent-corners
/>
<div class="flex min-w-0 flex-col gap-1.5">
<span class="min-w-0 truncate font-semibold text-contrast">
@@ -120,7 +120,7 @@ import { ref } from 'vue'
import { cancelLogin, type ModrinthAuthFlow } from '@/helpers/mr_auth'
const props = defineProps<{
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
requestAuth: (flow: ModrinthAuthFlow, addAccount?: boolean) => Promise<boolean>
}>()
const { formatMessage } = useVIntl()
@@ -130,6 +130,7 @@ const reopeningBrowser = ref(false)
let resolveShow: ((signedIn: boolean) => void) | undefined
let authenticationId = 0
let activeAuthentication: Promise<void> | undefined
let addingAccount = false
function show(event?: MouseEvent) {
resetAuthentication(true)
@@ -143,9 +144,9 @@ function show(event?: MouseEvent) {
})
}
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', addAccount = false, event?: MouseEvent) {
const result = show(event)
authenticate(flow)
authenticate(flow, addAccount)
return result
}
@@ -154,13 +155,14 @@ function finish(signedIn: boolean) {
resolveShow = undefined
}
function authenticate(flow: ModrinthAuthFlow) {
function authenticate(flow: ModrinthAuthFlow, addAccount = false) {
const id = ++authenticationId
authenticating.value = flow
addingAccount = addAccount
const authentication = (async () => {
try {
if ((await props.requestAuth(flow)) && authenticationId === id) {
if ((await props.requestAuth(flow, addAccount)) && authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
finish(true)
@@ -188,7 +190,7 @@ async function reopenBrowser() {
try {
await cancelLogin()
await previousAuthentication?.catch(() => undefined)
if (authenticating.value === flow) authenticate(flow)
if (authenticating.value === flow) authenticate(flow, addingAccount)
} finally {
reopeningBrowser.value = false
}
@@ -0,0 +1,195 @@
<script lang="ts"></script>
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useFormatDateTime, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
const loadedScreenshotUrls = new Set<string>()
const props = defineProps<{
screenshot: InstanceScreenshot
selectionKey: string
selected: boolean
selectionActive: boolean
activeDragged: boolean
canDrag: boolean
showInstanceName: boolean
highlighted: boolean
copied: boolean
}>()
const emit = defineEmits<{
(e: 'activate', event: MouseEvent | KeyboardEvent): void
(e: 'copy' | 'edit' | 'toggle-selection'): void
(e: 'more', event: MouseEvent): void
}>()
const card = ref<HTMLElement>()
const image = ref<HTMLImageElement>()
const loaded = ref(loadedScreenshotUrls.has(props.screenshot.url))
const { formatMessage } = useVIntl()
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
})
const sensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
useDraggable({
id: computed(() => `screenshot:${props.selectionKey}`),
element: card,
disabled: computed(() => !props.canDrag),
sensors,
data: computed(() => ({
selectionKey: props.selectionKey,
instanceId: props.screenshot.instance_id,
})),
})
function activate(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent) {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
event.preventDefault()
}
emit('activate', event)
}
function markImageLoaded() {
loadedScreenshotUrls.add(props.screenshot.url)
loaded.value = true
}
onMounted(() => {
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
watch(
() => props.screenshot.url,
(url) => {
loaded.value = loadedScreenshotUrls.has(url)
},
)
</script>
<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-contrast brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
'opacity-50': activeDragged,
'cursor-grab active:cursor-grabbing': canDrag,
}"
data-screenshot-card
:data-screenshot-id="screenshot.id"
:data-selection-key="selectionKey"
:aria-label="
selectionActive
? formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
: screenshot.file_name
"
:aria-pressed="selectionActive ? selected : undefined"
@click="activate"
@contextmenu.prevent.stop="emit('more', $event)"
@keydown="activate"
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[2] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
"
:aria-pressed="selected"
@click.stop="emit('toggle-selection')"
>
<span
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
:class="
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-contrast" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<div v-if="!loaded" class="absolute inset-0 animate-pulse bg-surface-3" />
<img
ref="image"
:src="screenshot.url"
:alt="screenshot.file_name"
loading="lazy"
draggable="false"
class="h-full w-full object-cover transition duration-200"
:class="loaded ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
{{ screenshot.file_name }}
</div>
<div class="truncate text-xs text-secondary">
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
</div>
</div>
<div
v-if="!selectionActive"
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
@click.stop
>
<IconButton
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
class="bg-surface-2 text-contrast hover:bg-surface-3"
@click="emit('edit')"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
:label="formatMessage(copied ? messages.copied : messages.copy)"
type="quiet"
class="bg-surface-2 text-contrast hover:bg-surface-3"
@click="emit('copy')"
>
<CheckIcon v-if="copied" class="text-green" />
<ClipboardCopyIcon v-else />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moreActions)"
:label="formatMessage(messages.moreActions)"
type="quiet"
class="bg-surface-2 text-contrast hover:bg-surface-3"
@click="emit('more', $event)"
>
<MoreHorizontalIcon />
</IconButton>
</div>
</div>
</article>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ScreenshotDragPreview from './drag-preview.vue'
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
const props = defineProps<{
items: ScreenshotDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.selectionKey"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<ScreenshotDragPreview :screenshot="item.screenshot" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
import { gatherDuration } from './use-screenshot-drag-gather'
const props = withDefaults(
defineProps<{
screenshot: InstanceScreenshot
count?: number
}>(),
{
count: 1,
},
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<div
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
>
<img :src="screenshot.url" alt="" class="h-full w-full object-cover" />
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-secondary motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</div>
</div>
</template>
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
import ScreenshotCard from './card.vue'
import ScreenshotSection from './section.vue'
const props = defineProps<{
id: string
title: string
screenshots: InstanceScreenshot[]
renderedScreenshots?: InstanceScreenshot[]
virtualGridHeight?: number
virtualGridTop?: number
selectedKeys: ReadonlySet<string>
selectionActive: boolean
activeDraggedKeys: ReadonlySet<string>
showDropOutline: boolean
canDrag: boolean
dropInstanceId?: string
dropCustomGroup?: boolean
dropCustomGroupId?: string
showInstanceName: boolean
highlightedScreenshotId?: string
copiedScreenshotIds: ReadonlySet<string>
forceOpen: boolean
animateEntry: boolean
hideHeader?: boolean
editableTitle?: boolean
startEditingTitle?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>()
const collapsed = defineModel<boolean>('collapsed', { required: true })
const dropTarget = ref<HTMLElement>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
emptyGroup: {
id: 'app.screenshots.group.empty',
defaultMessage: 'Drag and drop to add screenshots.',
},
})
const shouldShowGrid = computed(() =>
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
)
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
const renderGrid = ref(shouldShowGrid.value)
const virtualGridStyle = computed(() =>
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
)
const visibleGridStyle = computed(() =>
props.virtualGridTop === undefined
? undefined
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
},
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
const emit = defineEmits<{
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
}>()
useDroppable({
id: computed(() => `screenshot-group:${props.id}`),
element: dropTarget,
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
data: computed(() =>
props.dropCustomGroup
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
: { groupId: props.id, instanceId: props.dropInstanceId },
),
})
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
</script>
<template>
<div
ref="dropTarget"
class="group/instance-container relative select-none pb-3 transition-colors"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="showDropOutline"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-contrast bg-transparent opacity-40"
/>
</Transition>
<ScreenshotSection
v-model:collapsed="collapsed"
:title="title"
:count="screenshots.length"
:force-open="forceOpen"
:hide-header="hideHeader"
:editable="editableTitle"
:start-editing="startEditingTitle"
:max-title-length="maxTitleLength"
:validate-title="validateTitle"
:on-title-change="onTitleChange"
>
<template #actions="{ startEditing }">
<slot name="actions" :start-editing="startEditing" />
</template>
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<TransitionGroup
tag="div"
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
:enter-active-class="
animateEntry
? 'transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none'
: ''
"
:enter-from-class="animateEntry ? 'opacity-0' : ''"
enter-to-class="opacity-100 scale-100"
>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-secondary opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</TransitionGroup>
</div>
</ScreenshotSection>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import {
Accordion,
commonMessages,
defineMessages,
InlineEditableText,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { nextTick, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
title: string
count: number
collapsed?: boolean
forceOpen?: boolean
hideHeader?: boolean
editable?: boolean
startEditing?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
collapsed: false,
forceOpen: false,
hideHeader: false,
editable: false,
startEditing: false,
},
)
const emit = defineEmits<{
(e: 'update:collapsed', collapsed: boolean): void
}>()
const { formatMessage } = useVIntl()
const accordion = ref<InstanceType<typeof Accordion>>()
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
const titleModel = ref(props.title)
const messages = defineMessages({
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
})
function toggle() {
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
accordion.value?.open()
}
}
async function startTitleEditing() {
if (!props.editable) return
await titleInput.value?.startEditing()
}
watch(
() => props.title,
(title) => {
titleModel.value = title
},
)
watch(
() => props.startEditing,
async (shouldStartEditing) => {
if (!shouldStartEditing) return
await nextTick()
await startTitleEditing()
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<section class="flex w-full flex-col">
<div
v-if="!hideHeader"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
>
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
<button
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
:aria-expanded="accordion?.isOpen"
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
@click.stop="toggle"
>
<DropdownIcon
class="size-5 shrink-0 text-secondary transition-all duration-300 group-hover/open-target:text-primary"
:class="{ 'rotate-180': accordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="editable"
ref="titleInput"
v-model="titleModel"
activation-mode="manual"
class="!h-10 select-none text-base font-semibold text-primary group-hover/open-target:text-contrast"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="maxTitleLength"
:validate="validateTitle"
:on-change="onTitleChange"
/>
<span
v-else
class="select-none truncate text-base font-semibold text-primary group-hover/open-target:text-contrast"
>
{{ title }}
</span>
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
{{ count }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<slot name="actions" :start-editing="startTitleEditing" />
</div>
<Accordion
ref="accordion"
:open-by-default="hideHeader || !props.collapsed"
:force-open="forceOpen"
overflow-visible
class="w-full"
@on-open="emit('update:collapsed', false)"
@on-close="emit('update:collapsed', true)"
>
<div class="mt-2.5">
<slot />
</div>
</Accordion>
</section>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ArrowUpDownIcon, LayoutGridIcon, SearchIcon, SquarePlusIcon } from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
defineMessages,
Input,
useVIntl,
} from '@modrinth/ui'
const search = defineModel<string>('search', { required: true })
const sort = defineModel<string>('sort', { required: true })
const group = defineModel<string>('group', { required: true })
defineProps<{
sortOptions: ComboboxOption<string>[]
groupOptions: ComboboxOption<string>[]
}>()
const emit = defineEmits<{
(e: 'new-group'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<Input
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="emit('new-group')">
<SquarePlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Combobox
v-model="sort"
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<ArrowUpDownIcon
class="size-5 text-primary"
:aria-label="formatMessage(messages.sortBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
<Combobox
v-model="group"
class="w-max"
:options="groupOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<LayoutGridIcon
class="size-5 text-primary"
:aria-label="formatMessage(messages.groupBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
</div>
</div>
</template>
@@ -0,0 +1,130 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
type Point = {
x: number
y: number
}
export type ActiveScreenshotDrag = {
primarySelectionKey: string
selectionKeys: string[]
}
export const gatherDuration = 500
export type ScreenshotDragGatherItem = {
screenshot: InstanceScreenshot
selectionKey: string
rect: {
left: number
top: number
width: number
height: number
}
}
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
const items = ref<ScreenshotDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
const screenshotCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
)
const sourceCard = screenshotCards.find(
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
const screenshotsByKey = new Map(
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
)
items.value = drag.selectionKeys.flatMap((selectionKey) => {
const screenshot = screenshotsByKey.get(selectionKey)
const card = screenshotCards.find(
(candidate) =>
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
)
if (!screenshot || !card) return []
const rect = card.getBoundingClientRect()
return [
{
screenshot,
selectionKey,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}
@@ -88,7 +88,7 @@ watch(
</div>
</div>
<div class="mt-8 flex items-center justify-between gap-4">
<div class="mt-8 first:mt-0 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.telemetryTitle) }}
@@ -8,7 +8,7 @@ import {
} from '@modrinth/ui'
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { type ColorTheme, useTheme } from '@/composables/use-theme.ts'
import { type ColorTheme, isDarkTheme, useTheme } from '@/composables/use-theme.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
@@ -60,6 +60,9 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
await set(nextSettings)
settings.value = nextSettings
if (isDarkTheme(value.theme)) {
theme.preferredDark = value.theme
}
theme.preferred = value.theme
theme.syncAcrossDevices = value.syncAcrossDevices
theme.advancedRendering = value.advancedRendering
@@ -73,6 +76,10 @@ const themeOptions = computed(() =>
),
)
const preferredDarkTheme = computed(() =>
isDarkTheme(current.value.theme) ? current.value.theme : theme.preferredDark,
)
function setTheme(value: ColorTheme): void {
current.value.theme = value
}
@@ -126,7 +133,8 @@ provideAppearanceSettings({
theme: {
current: computed(() => current.value.theme),
options: themeOptions,
system: computed(() => theme.native),
system: computed(() => (theme.native === 'light' ? 'light' : preferredDarkTheme.value)),
preferredDark: preferredDarkTheme,
set: setTheme,
syncAcrossDevices: {
value: computed(() => current.value.syncAcrossDevices),
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { SearchIcon } from '@modrinth/assets'
import { Button, Input, Toggle } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import {
DEFAULT_FEATURE_FLAGS,
@@ -12,7 +14,22 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
const appSettings = useAppSettings()
const settings = ref(await getSettings())
const options = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
const searchQuery = ref('')
const allFlags = computed(() => Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
const fuse = computed(
() =>
new Fuse(allFlags.value, {
threshold: 0.4,
}),
)
const filteredFlags = computed(() => {
if (!searchQuery.value.trim()) {
return allFlags.value
}
return fuse.value.search(searchQuery.value).map((result) => result.item)
})
function setFeatureFlag(key: FeatureFlag, value: boolean) {
appSettings.featureFlags[key] = value
@@ -29,7 +46,14 @@ watch(
</script>
<template>
<div class="flex flex-col gap-2.5">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<Input
v-model="searchQuery"
type="search"
:icon="SearchIcon"
placeholder="Search flags..."
wrapper-class="w-full"
/>
<div v-for="option in filteredFlags" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
@@ -50,5 +74,8 @@ watch(
/>
</div>
</div>
<p v-if="filteredFlags.length === 0" class="text-center text-secondary">
No flags found matching "{{ searchQuery }}"
</p>
</div>
</template>
@@ -1,362 +0,0 @@
<script setup lang="ts">
import {
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { get, parseEnvVars, serializeEnvVars, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
fullscreenTitle: {
id: 'app.settings.default-instance-options.fullscreen.title',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'app.settings.default-instance-options.fullscreen.description',
defaultMessage: 'Start instances in fullscreen by updating their options.txt file.',
},
widthTitle: {
id: 'app.settings.default-instance-options.width.title',
defaultMessage: 'Width',
},
widthDescription: {
id: 'app.settings.default-instance-options.width.description',
defaultMessage: 'The width of the game window when launched.',
},
widthPlaceholder: {
id: 'app.settings.default-instance-options.width.placeholder',
defaultMessage: 'Enter width...',
},
heightTitle: {
id: 'app.settings.default-instance-options.height.title',
defaultMessage: 'Height',
},
heightDescription: {
id: 'app.settings.default-instance-options.height.description',
defaultMessage: 'The height of the game window when launched.',
},
heightPlaceholder: {
id: 'app.settings.default-instance-options.height.placeholder',
defaultMessage: 'Enter height...',
},
memoryAllocationTitle: {
id: 'app.settings.default-instance-options.memory-allocation.title',
defaultMessage: 'Memory allocation',
},
memoryAllocationDescription: {
id: 'app.settings.default-instance-options.memory-allocation.description',
defaultMessage: 'Maximum memory available to each instance.',
},
javaArgumentsTitle: {
id: 'app.settings.default-instance-options.java-arguments.title',
defaultMessage: 'Java arguments',
},
javaArgumentsPlaceholder: {
id: 'app.settings.default-instance-options.java-arguments.placeholder',
defaultMessage: 'Enter Java arguments...',
},
javaArgumentsDescription: {
id: 'app.settings.default-instance-options.java-arguments.description',
defaultMessage: 'Arguments passed to Java when launching an instance.',
},
environmentVariablesTitle: {
id: 'app.settings.default-instance-options.environment-variables.title',
defaultMessage: 'Environment variables',
},
environmentVariablesPlaceholder: {
id: 'app.settings.default-instance-options.environment-variables.placeholder',
defaultMessage: 'Enter environment variables...',
},
environmentVariablesDescription: {
id: 'app.settings.default-instance-options.environment-variables.description',
defaultMessage: 'Environment variables set when launching an instance.',
},
preLaunchHookTitle: {
id: 'app.settings.default-instance-options.pre-launch-hook.title',
defaultMessage: 'Pre-launch hook',
},
preLaunchHookPlaceholder: {
id: 'app.settings.default-instance-options.pre-launch-hook.placeholder',
defaultMessage: 'Enter pre-launch command...',
},
preLaunchHookDescription: {
id: 'app.settings.default-instance-options.pre-launch-hook.description',
defaultMessage: 'Runs before the instance starts.',
},
wrapperHookTitle: {
id: 'app.settings.default-instance-options.wrapper-hook.title',
defaultMessage: 'Wrapper hook',
},
wrapperHookPlaceholder: {
id: 'app.settings.default-instance-options.wrapper-hook.placeholder',
defaultMessage: 'Enter wrapper command...',
},
wrapperHookDescription: {
id: 'app.settings.default-instance-options.wrapper-hook.description',
defaultMessage: 'Command used to wrap the Minecraft launch process.',
},
postExitHookTitle: {
id: 'app.settings.default-instance-options.post-exit-hook.title',
defaultMessage: 'Post-exit hook',
},
postExitHookPlaceholder: {
id: 'app.settings.default-instance-options.post-exit-hook.placeholder',
defaultMessage: 'Enter post-exit command...',
},
postExitHookDescription: {
id: 'app.settings.default-instance-options.post-exit-hook.description',
defaultMessage: 'Runs after the game closes.',
},
hookVariablesDescription: {
id: 'instance.settings.tabs.hooks.variables.description',
defaultMessage:
'Hooks run in the working directory of the instance, with the following variables:',
},
instanceNameDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-name.description',
defaultMessage: '$INST_NAME: The name of the instance',
},
instanceIdDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-id.description',
defaultMessage: "$INST_ID: The name of the instance's folder",
},
instanceDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
},
instanceMcDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
},
instanceJavaDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java.description',
defaultMessage: '$INST_JAVA: The absolute path to the java binary',
},
instanceJavaArgsDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
},
})
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = serializeEnvVars(fetchSettings.custom_env_vars)
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = parseEnvVars(setSettings.envVars)
delete setSettings.launchArgs
delete setSettings.envVars
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings).catch(handleError)
},
{ deep: true },
)
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreenTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.widthTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.widthPlaceholder)"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.heightTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.heightPlaceholder)"
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.memoryAllocationTitle) }}
</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">
{{ formatMessage(messages.memoryAllocationDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaArgumentsTitle) }}
</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.javaArgumentsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.environmentVariablesTitle) }}
</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.environmentVariablesDescription) }}
</p>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunchHookTitle) }}
</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.preLaunchHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.preLaunchHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapperHookTitle) }}
</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.wrapperHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.wrapperHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExitHookTitle) }}
</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.postExitHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.postExitHookDescription) }}
</p>
</div>
<div class="m-0 leading-tight">
{{ formatMessage(messages.hookVariablesDescription) }}
<ul>
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
</div>
</div>
</div>
</template>
@@ -0,0 +1,887 @@
<script setup lang="ts">
import {
EditIcon,
// FolderOpenIcon,
SaveIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
IconButton,
injectNotificationManager,
Input,
NewModal,
Slider,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Component } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import {
get_command_history,
get_global_synced_options,
type GlobalSyncedOptions,
list_synced_servers,
// open_synced_options_folder,
remove_synced_server,
set_command_history,
set_global_synced_option,
type SyncedOption,
type SyncedServer,
update_synced_server,
} from '@/helpers/instance'
import { get, parseEnvVars, serializeEnvVars, set } from '@/helpers/settings.ts'
import {
refreshServerData,
refreshServers,
type ServerData,
type ServerWorld,
} from '@/helpers/worlds.ts'
import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const messages = defineMessages({
// syncedDescription: {
// id: 'app.settings.synced-options.description',
// defaultMessage:
// 'Sync options and config across instances so you dont have to set them up every time.',
// },
// syncedFolder: {
// id: 'app.settings.synced-options.folder',
// defaultMessage: 'Synced folder',
// },
multiplayerServers: {
id: 'app.settings.synced-options.multiplayer-servers',
defaultMessage: 'Multiplayer servers',
},
multiplayerServersDescription: {
id: 'app.settings.synced-options.multiplayer-servers.description',
defaultMessage: 'Sync multiplayer servers across your instances.',
},
commandHistory: {
id: 'app.settings.synced-options.command-history',
defaultMessage: 'Command history',
},
commandHistoryDescription: {
id: 'app.settings.synced-options.command-history.description',
defaultMessage: 'Sync command history across your instances.',
},
creativeHotbars: {
id: 'app.settings.synced-options.creative-hotbars',
defaultMessage: 'Saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'app.settings.synced-options.creative-hotbars.description',
defaultMessage: 'Sync saved creative hotbars across your instances.',
},
screenshots: {
id: 'app.settings.synced-options.screenshots',
defaultMessage: 'Screenshots',
},
screenshotsDescription: {
id: 'app.settings.synced-options.screenshots.description',
defaultMessage: 'View screenshots from your instances in one place.',
},
commandHistoryEditorTitle: {
id: 'app.settings.synced-options.command-history.editor-title',
defaultMessage: 'Edit command history',
},
serverEditorTitle: {
id: 'app.settings.synced-options.multiplayer-servers.editor-title',
defaultMessage: 'Edit synced servers',
},
editServerTitle: {
id: 'instance.edit-server.title',
defaultMessage: 'Edit server',
},
serverName: {
id: 'app.settings.synced-options.multiplayer-servers.name',
defaultMessage: 'Server name',
},
serverAddress: {
id: 'app.settings.synced-options.multiplayer-servers.address',
defaultMessage: 'Server address',
},
noSyncedServers: {
id: 'app.settings.synced-options.multiplayer-servers.empty',
defaultMessage: 'No user-added servers are currently synced.',
},
noServersSyncedYet: {
id: 'app.settings.synced-options.multiplayer-servers.none-synced-yet',
defaultMessage: 'No servers synced yet',
},
windowSectionTitle: {
id: 'app.settings.default-instance-options.window.title',
defaultMessage: 'Window',
},
javaAndMemorySectionTitle: {
id: 'app.settings.default-instance-options.java-and-memory.title',
defaultMessage: 'Java and memory',
},
launchHooksSectionTitle: {
id: 'app.settings.default-instance-options.launch-hooks.title',
defaultMessage: 'Launch hooks',
},
fullscreenTitle: {
id: 'app.settings.default-instance-options.fullscreen.title',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'app.settings.default-instance-options.fullscreen.description',
defaultMessage: 'Start instances in fullscreen by updating their options.txt file.',
},
widthTitle: {
id: 'app.settings.default-instance-options.width.title',
defaultMessage: 'Width',
},
widthDescription: {
id: 'app.settings.default-instance-options.width.description',
defaultMessage: 'The width of the game window when launched.',
},
widthPlaceholder: {
id: 'app.settings.default-instance-options.width.placeholder',
defaultMessage: 'Enter width...',
},
heightTitle: {
id: 'app.settings.default-instance-options.height.title',
defaultMessage: 'Height',
},
heightDescription: {
id: 'app.settings.default-instance-options.height.description',
defaultMessage: 'The height of the game window when launched.',
},
heightPlaceholder: {
id: 'app.settings.default-instance-options.height.placeholder',
defaultMessage: 'Enter height...',
},
memoryAllocationTitle: {
id: 'app.settings.default-instance-options.memory-allocation.title',
defaultMessage: 'Memory allocation',
},
memoryAllocationDescription: {
id: 'app.settings.default-instance-options.memory-allocation.description',
defaultMessage: 'Maximum memory available to each instance.',
},
javaArgumentsTitle: {
id: 'app.settings.default-instance-options.java-arguments.title',
defaultMessage: 'Java arguments',
},
javaArgumentsPlaceholder: {
id: 'app.settings.default-instance-options.java-arguments.placeholder',
defaultMessage: 'Enter Java arguments...',
},
javaArgumentsDescription: {
id: 'app.settings.default-instance-options.java-arguments.description',
defaultMessage: 'Arguments passed to Java when launching an instance.',
},
environmentVariablesTitle: {
id: 'app.settings.default-instance-options.environment-variables.title',
defaultMessage: 'Environment variables',
},
environmentVariablesPlaceholder: {
id: 'app.settings.default-instance-options.environment-variables.placeholder',
defaultMessage: 'Enter environment variables...',
},
environmentVariablesDescription: {
id: 'app.settings.default-instance-options.environment-variables.description',
defaultMessage: 'Environment variables set when launching an instance.',
},
preLaunchHookTitle: {
id: 'app.settings.default-instance-options.pre-launch-hook.title',
defaultMessage: 'Pre-launch hook',
},
preLaunchHookPlaceholder: {
id: 'app.settings.default-instance-options.pre-launch-hook.placeholder',
defaultMessage: 'Enter pre-launch command...',
},
preLaunchHookDescription: {
id: 'app.settings.default-instance-options.pre-launch-hook.description',
defaultMessage: 'Runs before the instance starts.',
},
wrapperHookTitle: {
id: 'app.settings.default-instance-options.wrapper-hook.title',
defaultMessage: 'Wrapper hook',
},
wrapperHookPlaceholder: {
id: 'app.settings.default-instance-options.wrapper-hook.placeholder',
defaultMessage: 'Enter wrapper command...',
},
wrapperHookDescription: {
id: 'app.settings.default-instance-options.wrapper-hook.description',
defaultMessage: 'Command used to wrap the Minecraft launch process.',
},
postExitHookTitle: {
id: 'app.settings.default-instance-options.post-exit-hook.title',
defaultMessage: 'Post-exit hook',
},
postExitHookPlaceholder: {
id: 'app.settings.default-instance-options.post-exit-hook.placeholder',
defaultMessage: 'Enter post-exit command...',
},
postExitHookDescription: {
id: 'app.settings.default-instance-options.post-exit-hook.description',
defaultMessage: 'Runs after the game closes.',
},
hookVariablesDescription: {
id: 'instance.settings.tabs.hooks.variables.description',
defaultMessage:
'Hooks run in the working directory of the instance, with the following variables:',
},
instanceNameDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-name.description',
defaultMessage: '$INST_NAME: The name of the instance',
},
instanceIdDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-id.description',
defaultMessage: "$INST_ID: The name of the instance's folder",
},
instanceDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
},
instanceMcDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
},
instanceJavaDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java.description',
defaultMessage: '$INST_JAVA: The absolute path to the java binary',
},
instanceJavaArgsDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
},
})
const globalRows: Array<{
option: SyncedOption
title: keyof typeof messages
description?: keyof typeof messages
editable?: 'servers' | 'commands'
}> = [
{
option: 'multiplayer_servers',
title: 'multiplayerServers',
description: 'multiplayerServersDescription',
editable: 'servers',
},
{
option: 'command_history',
title: 'commandHistory',
description: 'commandHistoryDescription',
editable: 'commands',
},
{
option: 'creative_hotbars',
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
{
option: 'screenshots',
title: 'screenshots',
description: 'screenshotsDescription',
},
]
const globalSyncedOptionsQueryKey = ['global-synced-options'] as const
const globalSyncedOptionsMutationKey = ['global-synced-options', 'set'] as const
const defaultGlobalOptions: GlobalSyncedOptions = {
command_history: false,
multiplayer_servers: false,
creative_hotbars: false,
screenshots: false,
}
const globalOptionsQuery = useQuery({
queryKey: globalSyncedOptionsQueryKey,
queryFn: get_global_synced_options,
})
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
const commandHistoryModal = ref<InstanceType<typeof NewModal> | null>(null)
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
const commandHistory = ref('')
const syncedServers = ref<SyncedServer[]>(
(await list_synced_servers().catch((error) => {
handleError(error)
return []
})) ?? [],
)
const editedServer = ref<SyncedServer | null>(null)
const serverData = ref<Record<string, ServerData>>({})
const editorComponent = shallowRef<Component | null>(null)
const syncedServerCards = computed(() =>
syncedServers.value.map((server, index) => ({
server,
world: {
name: server.name,
type: 'server',
index,
server_id: server.id,
address: server.address,
pack_status:
server.accept_textures === true
? 'enabled'
: server.accept_textures === false
? 'disabled'
: 'prompt',
display_status: 'normal',
} satisfies ServerWorld,
})),
)
async function invalidateSyncedOptions() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: globalSyncedOptionsQueryKey }),
queryClient.invalidateQueries({ queryKey: screenshotKeys.all }),
])
}
type GlobalOptionMutationVariables = {
option: SyncedOption
enabled: boolean
}
const globalOptionMutation = useMutation({
mutationKey: globalSyncedOptionsMutationKey,
mutationFn: ({ option, enabled }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled),
onMutate: async ({ option, enabled }) => {
await queryClient.cancelQueries({ queryKey: globalSyncedOptionsQueryKey })
const previous = globalOptions.value[option]
queryClient.setQueryData<GlobalSyncedOptions>(globalSyncedOptionsQueryKey, (current) => ({
...(current ?? defaultGlobalOptions),
[option]: enabled,
}))
return { previous }
},
onError: (error, { option }, context) => {
queryClient.setQueryData<GlobalSyncedOptions>(globalSyncedOptionsQueryKey, (current) => ({
...(current ?? defaultGlobalOptions),
[option]: context?.previous ?? defaultGlobalOptions[option],
}))
handleError(error)
},
onSettled: async () => {
if (queryClient.isMutating({ mutationKey: globalSyncedOptionsMutationKey }) === 1) {
await invalidateSyncedOptions()
}
},
})
function applyGlobalOption(option: SyncedOption, enabled: boolean) {
globalOptionMutation.mutate({ option, enabled })
}
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
applyGlobalOption(option, enabled)
}
async function openCommandHistoryEditor() {
commandHistory.value = await get_command_history().catch((error) => {
handleError(error)
return ''
})
if (!editorComponent.value) {
const [editor] = await Promise.all([
import('vue3-ace-editor'),
import('@modrinth/ui/src/utils/ace-theme'),
import('@modrinth/ui/src/utils/ace-mode-mcfunction'),
])
editorComponent.value = editor.VAceEditor
}
commandHistoryModal.value?.show()
}
async function saveCommandHistory() {
try {
commandHistory.value = await set_command_history(commandHistory.value)
commandHistoryModal.value?.hide()
} catch (error) {
handleError(error)
}
}
async function openServerEditor() {
syncedServers.value = await list_synced_servers().catch((error) => {
handleError(error)
return []
})
serverData.value = {}
serverEditorModal.value?.show()
await refreshServers(
syncedServerCards.value.map(({ world }) => world),
serverData.value,
null,
)
}
function openSyncedServerEditor(server: SyncedServer) {
editedServer.value = { ...server }
editServerModal.value?.show()
}
async function saveSyncedServer() {
if (!editedServer.value) return
const server = editedServer.value
try {
await update_synced_server(server)
const index = syncedServers.value.findIndex(({ id }) => id === server.id)
if (index !== -1) {
syncedServers.value[index] = { ...server }
}
editServerModal.value?.hide()
serverData.value[server.address] = { refreshing: true }
await refreshServerData(serverData.value[server.address], null, server.address)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
} catch (error) {
handleError(error)
}
}
async function refreshSyncedServer(address: string) {
serverData.value[address] ??= { refreshing: true }
await refreshServerData(serverData.value[address], null, address)
}
async function removeSyncedServer(serverId: string) {
try {
await remove_synced_server(serverId)
syncedServers.value = syncedServers.value.filter((server) => server.id !== serverId)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
} catch (error) {
handleError(error)
}
}
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = serializeEnvVars(fetchSettings.custom_env_vars)
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = parseEnvVars(setSettings.envVars)
delete setSettings.launchArgs
delete setSettings.envVars
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings).catch(handleError)
},
{ deep: true },
)
</script>
<template>
<div>
<NewModal
ref="commandHistoryModal"
:header="formatMessage(messages.commandHistoryEditorTitle)"
class="command-history-modal"
max-width="700px"
width="700px"
>
<component
:is="editorComponent"
v-if="editorComponent"
v-model:value="commandHistory"
lang="mcfunction"
theme="modrinth"
:print-margin="false"
class="command-history-editor ace-modrinth rounded-[20px] !border !border-solid !border-surface-5"
style="height: 420px; font-size: 0.875rem"
/>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="commandHistoryModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="saveCommandHistory">
<SaveIcon />
{{ formatMessage(commonMessages.saveButton) }}
</Button>
</div>
</template>
</NewModal>
<NewModal
ref="serverEditorModal"
:header="formatMessage(messages.serverEditorTitle)"
scrollable
actions-divider
no-padding
max-content-height="34.5rem"
max-width="750px"
width="750px"
>
<p v-if="syncedServers.length === 0" class="m-0 px-6 py-4 text-secondary">
{{ formatMessage(messages.noSyncedServers) }}
</p>
<div v-else class="flex flex-col gap-2 px-6 py-4">
<WorldItem
v-for="{ server, world } in syncedServerCards"
:key="server.id"
:world="world"
card-background="surface-2"
:show-play-button="false"
:refreshing="serverData[server.address]?.refreshing"
:server-status="serverData[server.address]?.status"
:rendered-motd="serverData[server.address]?.renderedMotd"
@refresh="refreshSyncedServer(server.address)"
@edit="openSyncedServerEditor(server)"
@delete="removeSyncedServer(server.id)"
/>
</div>
<template #actions>
<div class="flex justify-end">
<Button type="outlined" @click="serverEditorModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</Button>
</div>
</template>
</NewModal>
<NewModal
ref="editServerModal"
:header="formatMessage(messages.editServerTitle)"
max-width="500px"
width="500px"
>
<div v-if="editedServer" class="flex flex-col gap-4">
<label class="flex flex-col gap-2 font-semibold text-contrast">
{{ formatMessage(messages.serverName) }}
<Input v-model="editedServer.name" autocomplete="off" wrapper-class="w-full" />
</label>
<label class="flex flex-col gap-2 font-semibold text-contrast">
{{ formatMessage(messages.serverAddress) }}
<Input v-model="editedServer.address" autocomplete="off" wrapper-class="w-full" />
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="editServerModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!editedServer?.address"
@click="saveSyncedServer"
>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
<section class="border-0 border-b border-solid border-divider pb-6">
<div class="flex flex-col gap-6">
<!--
<div class="flex items-center justify-between gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.syncedDescription) }}</p>
<Button @click="open_synced_options_folder().catch(handleError)">
<FolderOpenIcon />
{{ formatMessage(messages.syncedFolder) }}
</Button>
</div>
-->
<div class="flex flex-col gap-4">
<div
v-for="row in globalRows"
:key="row.option"
class="flex items-center justify-between gap-6"
>
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages[row.title]) }}
</h2>
<p v-if="row.description" class="m-0 text-secondary">
{{ formatMessage(messages[row.description]) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="row.editable"
v-tooltip="
row.editable === 'servers' && syncedServers.length === 0
? formatMessage(messages.noServersSyncedYet)
: formatMessage(commonMessages.editButton)
"
class="flex"
>
<IconButton
type="outlined"
circular
:disabled="
!globalOptions[row.option] ||
(row.editable === 'servers' && syncedServers.length === 0)
"
:label="formatMessage(commonMessages.editButton)"
@click="
row.editable === 'commands' ? openCommandHistoryEditor() : openServerEditor()
"
>
<EditIcon />
</IconButton>
</span>
<Toggle
:id="`global-sync-${row.option}`"
:model-value="globalOptions[row.option]"
@update:model-value="(enabled) => toggleGlobalOption(row.option, enabled)"
/>
</div>
</div>
</div>
</div>
</section>
<section class="mt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.windowSectionTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreenTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.widthTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<Input
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.widthPlaceholder)"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.heightTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<Input
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.heightPlaceholder)"
/>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.javaAndMemorySectionTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.memoryAllocationTitle) }}
</h3>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">
{{ formatMessage(messages.memoryAllocationDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaArgumentsTitle) }}
</h3>
<Input
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.javaArgumentsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.environmentVariablesTitle) }}
</h3>
<Input
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.environmentVariablesDescription) }}
</p>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.launchHooksSectionTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunchHookTitle) }}
</h3>
<Input
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.preLaunchHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.preLaunchHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapperHookTitle) }}
</h3>
<Input
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.wrapperHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.wrapperHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExitHookTitle) }}
</h3>
<Input
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.postExitHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.postExitHookDescription) }}
</p>
</div>
<div class="m-0 leading-tight">
{{ formatMessage(messages.hookVariablesDescription) }}
<ul>
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
</div>
</div>
</section>
</div>
</template>
<style>
.command-history-editor.ace-modrinth {
background-color: var(--surface-2);
}
.command-history-editor.ace-modrinth .ace_gutter {
background: var(--surface-1);
}
.command-history-editor.ace-modrinth .ace_marker-layer .ace_active-line {
background: var(--surface-2-5);
}
.command-history-editor.ace-modrinth .ace_gutter-active-line {
background-color: var(--surface-1-5);
}
.command-history-editor.ace-modrinth.ace_multiselect .ace_selection.ace_start {
box-shadow: 0 0 3px 0 var(--surface-2);
}
.command-history-modal > [data-modal-content] {
padding-bottom: 0;
}
</style>
@@ -5,8 +5,8 @@ import {
defineMessages,
IconButton,
injectNotificationManager,
Input,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
@@ -176,7 +176,7 @@ async function findLauncherDir() {
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDirectoryTitle) }}
</h2>
<StyledInput
<Input
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
@@ -193,7 +193,7 @@ async function findLauncherDir() {
<FolderSearchIcon aria-hidden="true" />
</IconButton>
</template>
</StyledInput>
</Input>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDirectoryDescription) }}
</p>
@@ -16,6 +16,7 @@
size="56px"
no-shadow
class="!rounded-2xl"
pad-transparent-corners
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ preview.name }}</span>
@@ -12,7 +12,9 @@ import {
Avatar,
BulletDivider,
Button,
type ButtonMenuOption,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
SmartClickable,
@@ -24,7 +26,7 @@ import {
} from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import type { Dayjs } from 'dayjs'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
@@ -80,6 +82,10 @@ const messages = defineMessages({
id: 'app.home.jump-back-in.view-instance',
defaultMessage: 'View instance',
},
instanceActions: {
id: 'app.home.jump-back-in.actions.label',
defaultMessage: 'Instance actions',
},
})
const instanceIcon = computed(() => props.instance.icon_path)
@@ -144,115 +150,149 @@ const checkProcess = async () => {
onMounted(() => {
checkProcess()
})
const cardOptions = useTemplateRef('cardOptions')
const showStop = computed(() => playing.value && !loading.value)
const playDisabled = computed(() => props.instance.quarantined || playing.value || loading.value)
const overflowOptions = computed((): ButtonMenuOption[] => [
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
icon: EyeIcon,
shown: !!props.instance.id,
action: () => router.push(encodeURI(`/instance/${props.instance.id}`)),
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
icon: FolderOpenIcon,
action: () => showInstanceInFolder(props.instance.id),
},
])
const contextMenuOptions = computed((): ButtonMenuOption[] => [
showStop.value
? {
id: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
tone: 'red',
action: (event) => stop(event),
}
: {
id: 'play',
label: formatMessage(commonMessages.playButton),
icon: PlayIcon,
tone: 'brand',
disabled: playDisabled.value,
tooltip: props.instance.quarantined
? formatMessage(messages.lockedTooltip)
: playing.value
? formatMessage(messages.alreadyOpenTooltip)
: undefined,
action: (event) => play(event),
},
{ type: 'divider' },
...overflowOptions.value,
])
function openContextMenu(event: MouseEvent) {
cardOptions.value?.open(event, contextMenuOptions.value)
}
</script>
<template>
<SmartClickable class="[--active-scale:0.99]">
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.id)}`"
/>
</template>
<div
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 border border-surface-4 rounded-[20px] smart-clickable:highlight-on-hover transition-[filter] ease-out [--hover-brightness:1.1] min-h-20 p-3"
:class="newlyAdded ? 'border-dashed bg-surface-2' : 'bg-bg-raised border-solid'"
>
<Avatar
:src="getInstanceIconUrl(instanceIcon)"
:tint-by="instance.id"
no-shadow
class="!rounded-[14px]"
size="48px"
/>
<div class="flex flex-col col-span-2 justify-center gap-1 h-full">
<div class="flex items-center gap-1.5">
<div class="text-contrast truncate text-base font-semibold">
{{ instance.name }}
<div @contextmenu.prevent.stop="openContextMenu">
<SmartClickable class="[--active-scale:0.99]">
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.id)}`"
/>
</template>
<div
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 border border-surface-4 rounded-[20px] smart-clickable:highlight-on-hover transition-[filter] ease-out [--hover-brightness:1.1] min-h-20 p-3"
:class="newlyAdded ? 'border-dashed bg-surface-2' : 'bg-bg-raised border-solid'"
>
<Avatar
:src="getInstanceIconUrl(instanceIcon)"
:tint-by="instance.id"
no-shadow
class="!rounded-[14px]"
size="48px"
pad-transparent-corners
/>
<div class="flex flex-col col-span-2 justify-center gap-1 h-full">
<div class="flex items-center gap-1.5">
<div class="text-contrast truncate text-base font-semibold">
{{ instance.name }}
</div>
<TagItem
v-if="newlyAdded"
class="!border-green !bg-bg-green !px-2 !font-medium !text-green"
>
<SparklesIcon aria-hidden="true" />
{{ formatMessage(messages.newInstance) }}
</TagItem>
</div>
<TagItem
v-if="newlyAdded"
class="!border-green !bg-bg-green !px-2 !font-medium !text-green"
>
<SparklesIcon aria-hidden="true" />
{{ formatMessage(messages.newInstance) }}
</TagItem>
</div>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<span class="flex items-center gap-1 truncate text-secondary">
{{ loader }}
{{ instance.game_version }}
</span>
<BulletDivider class="shrink-0" />
<div
v-tooltip="!newlyAdded ? formatDateTime(last_played.toDate()) : null"
class="w-fit shrink-0"
:class="{
'cursor-help smart-clickable:allow-pointer-events': !newlyAdded,
}"
>
<template v-if="newlyAdded">
{{ formatMessage(messages.neverPlayed) }}
</template>
<template v-else-if="last_played">
{{ formatRelativeTime(last_played.toISOString?.()) }}
</template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<span class="flex items-center gap-1 truncate text-secondary">
{{ loader }}
{{ instance.game_version }}
</span>
<BulletDivider class="shrink-0" />
<div
v-tooltip="!newlyAdded ? formatDateTime(last_played.toDate()) : null"
class="w-fit shrink-0"
:class="{
'cursor-help smart-clickable:allow-pointer-events': !newlyAdded,
}"
>
<template v-if="newlyAdded">
{{ formatMessage(messages.neverPlayed) }}
</template>
<template v-else-if="last_played">
{{ formatRelativeTime(last_played.toISOString?.()) }}
</template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
</div>
</div>
</div>
<div data-no-card-click class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<Button v-if="showStop" type="colored" color="red" @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="
instance.quarantined
? formatMessage(messages.lockedTooltip)
: playing
? formatMessage(messages.alreadyOpenTooltip)
: null
"
:disabled="playDisabled"
type="colored"
color="green"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
:label="formatMessage(messages.moreOptions)"
:options="overflowOptions"
>
<MoreVerticalIcon aria-hidden="true" />
</TeleportOverflowMenu>
</div>
</div>
<div data-no-card-click class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<Button v-if="playing && !loading" type="colored" color="red" @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="
instance.quarantined
? formatMessage(messages.lockedTooltip)
: playing
? formatMessage(messages.alreadyOpenTooltip)
: null
"
:disabled="instance.quarantined || playing || loading"
type="colored"
color="green"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
:label="formatMessage(messages.moreOptions)"
:options="[
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
shown: !!instance.id,
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
action: () => showInstanceInFolder(instance.id),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</TeleportOverflowMenu>
</div>
</div>
</SmartClickable>
</SmartClickable>
<ContextMenu ref="cardOptions" :label="formatMessage(messages.instanceActions)" />
</div>
</template>
<style scoped>
.clickable-card:has([data-no-card-click]:hover) {
@@ -18,12 +18,13 @@ import {
UserIcon,
XIcon,
} from '@modrinth/assets'
import type { MessageDescriptor } from '@modrinth/ui'
import type { ButtonMenuOption, MessageDescriptor } from '@modrinth/ui'
import {
Avatar,
BulletDivider,
Button,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
SmartClickable,
@@ -38,7 +39,7 @@ import { getPingLevel } from '@modrinth/utils'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { getInstanceIconUrl } from '@/helpers/instance'
@@ -66,7 +67,7 @@ const router = useRouter()
const { addNotification } = injectNotificationManager()
const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete' | 'desync'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>()
@@ -94,6 +95,8 @@ const props = withDefaults(
}
managed?: boolean
showPlayButton?: boolean
cardBackground?: 'raised' | 'surface-2'
// Instance
instanceId?: string
@@ -116,6 +119,8 @@ const props = withDefaults(
gameMode: undefined,
managed: false,
showPlayButton: true,
cardBackground: 'raised',
instanceId: undefined,
instanceName: undefined,
@@ -217,6 +222,10 @@ const messages = defineMessages({
id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address',
},
desync: {
id: 'instance.worlds.desync_server',
defaultMessage: 'Desync',
},
viewInstance: {
id: 'instance.worlds.view_instance',
defaultMessage: 'View instance',
@@ -225,6 +234,10 @@ const messages = defineMessages({
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
},
playWorld: {
id: 'instance.worlds.play_world',
defaultMessage: 'Play world',
},
worldInUse: {
id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use',
@@ -257,358 +270,387 @@ const messages = defineMessages({
id: 'app.world.world-item.not-played-yet',
defaultMessage: 'Not played yet',
},
worldActions: {
id: 'instance.worlds.actions.label',
defaultMessage: 'World actions',
},
})
const cardOptions = useTemplateRef('cardOptions')
const showStop = computed(
() =>
props.showPlayButton &&
(props.playingWorld || (locked.value && props.playingInstance)) &&
!props.startingInstance,
)
const playDisabled = computed(
() =>
props.quarantined ||
playingOtherWorld.value ||
props.startingInstance ||
(props.world.type === 'server' && !props.supportsServerQuickPlay) ||
(props.world.type === 'singleplayer' && !props.supportsWorldQuickPlay),
)
const playTooltip = computed(() => {
if (props.quarantined) return formatMessage(messages.instanceLocked)
if (props.world.type === 'server') {
if (!props.supportsServerQuickPlay) return formatMessage(messages.noServerQuickPlay)
if (playingOtherWorld.value) return formatMessage(messages.gameAlreadyOpen)
if (!props.serverStatus) return formatMessage(messages.noContact)
if (serverIncompatible.value) return formatMessage(messages.incompatibleServer)
return null
}
if (!props.supportsWorldQuickPlay) return formatMessage(messages.noSingleplayerQuickPlay)
if (playingOtherWorld.value || locked.value) return formatMessage(messages.gameAlreadyOpen)
return null
})
const overflowOptions = computed((): ButtonMenuOption[] => [
{
id: 'play-instance',
label: formatMessage(messages.playInstance),
icon: PlayIcon,
shown: !!props.instanceId,
disabled: props.playingInstance || props.quarantined,
action: () => emit('play-instance'),
},
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
icon: EyeIcon,
shown: !!props.instanceId,
action: () => {
if (!props.instanceId) return
void router.push(`/instance/${encodeURIComponent(props.instanceId)}`)
},
},
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
icon: UpdatedIcon,
shown: props.world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
label: formatMessage(messages.copyAddress),
icon: ClipboardCopyIcon,
shown: props.world.type === 'server',
action: () => copyToClipboard((props.world as ServerWorld).address),
},
{
id: 'desync',
label: formatMessage(messages.desync),
icon: XIcon,
action: () => emit('desync'),
shown:
!props.instanceId &&
props.world.type === 'server' &&
(props.world as ServerWorld).source === 'user_synced' &&
!!(props.world as ServerWorld).server_id,
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
icon: EditIcon,
action: () => emit('edit'),
shown: !props.instanceId,
disabled: locked.value || managed.value,
tooltip: locked.value
? formatMessage(messages.worldInUse)
: managed.value
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
icon: FolderOpenIcon,
shown: props.world.type === 'singleplayer',
action: () => (props.world.type === 'singleplayer' ? emit('open-folder', props.world) : {}),
},
{
type: 'divider',
shown: !!props.instanceId,
},
{
id: 'dont-show-on-home',
label: formatMessage(messages.dontShowOnHome),
icon: XIcon,
shown: !!props.instanceId,
action: () => {
if (!props.instanceId) return
set_world_display_status(
props.instanceId,
props.world.type,
getWorldIdentifier(props.world),
'hidden',
).then(() => {
emit('update')
})
},
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
icon: ExternalIcon,
shown: !!shortcutInstanceId.value && !props.quarantined,
action: () => createShortcut(),
},
{
type: 'divider',
shown: !props.instanceId,
},
{
id: 'delete',
label: formatMessage(
props.world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
),
icon: TrashIcon,
tone: 'red',
action: () => emit('delete'),
shown: !props.instanceId,
disabled: locked.value || managed.value,
tooltip: locked.value
? formatMessage(messages.worldInUse)
: managed.value
? formatMessage(messages.linkedServer)
: undefined,
},
])
const contextMenuOptions = computed((): ButtonMenuOption[] => [
showStop.value
? {
id: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
tone: 'red',
shown: props.showPlayButton,
action: () => emit('stop'),
}
: {
id: 'play',
label: formatMessage(messages.playWorld),
icon: PlayIcon,
tone: 'brand',
shown: props.showPlayButton,
disabled: playDisabled.value,
tooltip: playTooltip.value ?? undefined,
action: () => emit('play'),
},
{ type: 'divider', shown: props.showPlayButton },
...overflowOptions.value,
])
function openContextMenu(event: MouseEvent) {
cardOptions.value?.open(event, contextMenuOptions.value)
}
</script>
<template>
<SmartClickable class="[--active-scale:0.985]">
<template v-if="instanceId" #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instanceId)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
/>
</template>
<div
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised border border-solid border-surface-4 smart-clickable:highlight-on-hover rounded-[20px] transition-[filter] ease-out [--hover-brightness:1.25] min-h-20"
:class="{
'world-item-highlighted': highlighted,
}"
>
<Avatar
:src="
world.type === 'server' && serverStatus
? (serverStatus.favicon ?? world.icon)
: world.icon
"
size="48px"
no-shadow
class="!rounded-[14px]"
/>
<div class="flex flex-col justify-center gap-0.5 h-full">
<div class="flex items-center gap-1.5">
<div class="text-base text-contrast font-semibold truncate">
{{ world.name }}
</div>
<TagItem
v-if="managed"
v-tooltip="formatMessage(messages.linkedServer)"
class="border !border-solid border-blue bg-highlight-blue text-xs"
:style="`--_color: var(--color-blue)`"
>
<LockIcon aria-hidden="true" class="h-5 w-5" />
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<UserIcon
aria-hidden="true"
class="h-4 w-4 text-secondary shrink-0"
stroke-width="3px"
/>
{{ formatMessage(commonMessages.singleplayerLabel) }}
</div>
<div
v-else-if="world.type === 'server'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<template v-else-if="serverStatus">
<template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange">
{{
formatMessage(messages.incompatibleVersion, {
version: serverStatus.version?.name,
})
}}
</span>
<div @contextmenu.prevent.stop="openContextMenu">
<SmartClickable class="[--active-scale:0.985]">
<template v-if="instanceId" #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instanceId)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
/>
</template>
<div
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 border border-solid border-surface-4 smart-clickable:highlight-on-hover rounded-[20px] transition-[filter] ease-out [--hover-brightness:1.25] min-h-20"
:class="{
'world-item-highlighted': highlighted,
'bg-bg-raised': cardBackground === 'raised',
'bg-surface-2': cardBackground === 'surface-2',
}"
>
<Avatar
:src="
world.type === 'server' && serverStatus
? (serverStatus.favicon ?? world.icon)
: world.icon
"
size="48px"
no-shadow
class="!rounded-[14px]"
/>
<div class="flex flex-col justify-center gap-0.5 h-full">
<div class="flex items-center gap-1.5">
<div class="text-base text-contrast font-semibold truncate">
{{ world.name }}
</div>
<TagItem
v-if="managed"
v-tooltip="formatMessage(messages.linkedServer)"
class="border !border-solid border-blue bg-highlight-blue text-xs"
:style="`--_color: var(--color-blue)`"
>
<LockIcon aria-hidden="true" class="h-5 w-5" />
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<UserIcon
aria-hidden="true"
class="h-4 w-4 text-secondary shrink-0"
stroke-width="3px"
/>
{{ formatMessage(commonMessages.singleplayerLabel) }}
</div>
<div
v-else-if="world.type === 'server'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<template v-else>
<SignalIcon
v-tooltip="`${serverStatus.ping}ms`"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping ?? 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0 smart-clickable:allow-pointer-events"
/>
<Tooltip :disabled="!hasPlayersTooltip">
<span
class="smart-clickable:allow-pointer-events"
:class="{ 'cursor-help': hasPlayersTooltip }"
>
<template v-else-if="serverStatus">
<template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange">
{{
formatMessage(messages.playersOnline, {
count: formatNumber(serverStatus.players?.online ?? 0),
formatMessage(messages.incompatibleVersion, {
version: serverStatus.version?.name,
})
}}
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.id">
{{ player.name }}
</span>
</div>
</template>
</Tooltip>
</template>
<template v-else>
<SignalIcon
v-tooltip="`${serverStatus.ping}ms`"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping ?? 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0 smart-clickable:allow-pointer-events"
/>
<Tooltip :disabled="!hasPlayersTooltip">
<span
class="smart-clickable:allow-pointer-events"
:class="{ 'cursor-help': hasPlayersTooltip }"
>
{{
formatMessage(messages.playersOnline, {
count: formatNumber(serverStatus.players?.online ?? 0),
})
}}
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.id">
{{ player.name }}
</span>
</div>
</template>
</Tooltip>
</template>
</template>
<template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
{{ formatMessage(messages.offline) }}
</template>
</div>
</div>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<template v-if="instanceId">
<router-link
data-no-card-click
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instanceId}`"
>
<Avatar
:src="getInstanceIconUrl(instanceIcon)"
size="16px"
:tint-by="instanceId"
class="shrink-0"
no-shadow
pad-transparent-corners
/>
<span class="truncate">{{ instanceName }}</span>
</router-link>
<BulletDivider class="shrink-0" />
</template>
<div
v-tooltip="world.last_played ? formatDateTime(world.last_played) : null"
class="w-fit shrink-0"
:class="{
'cursor-help smart-clickable:allow-pointer-events': world.last_played,
}"
>
<template v-if="world.last_played">
{{ formatRelativeTime(dayjs(world.last_played).toISOString()) }}
</template>
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
</div>
</div>
</div>
<div
class="font-semibold flex items-center gap-1 justify-center text-center"
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
>
<template v-if="world.type === 'server'">
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin" />
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<div
v-else-if="renderedMotd"
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
v-html="renderedMotd"
/>
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
{{ formatMessage(messages.cantConnect) }}
</div>
<div v-else class="font-normal font-minecraft text-secondary leading-5">
{{ formatMessage(messages.aMinecraftServer) }}
</div>
</template>
<template v-else-if="world.type === 'singleplayer' && gameMode">
<template v-if="world.hardcore">
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(messages.hardcore) }}
</template>
<template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
{{ formatMessage(messages.offline) }}
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(gameMode.message) }}
</template>
</div>
</div>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<template v-if="instanceId">
<router-link
data-no-card-click
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instanceId}`"
>
<Avatar
:src="getInstanceIconUrl(instanceIcon)"
size="16px"
:tint-by="instanceId"
class="shrink-0"
no-shadow
/>
<span class="truncate">{{ instanceName }}</span>
</router-link>
<BulletDivider class="shrink-0" />
</template>
<div
v-tooltip="world.last_played ? formatDateTime(world.last_played) : null"
class="w-fit shrink-0"
:class="{
'cursor-help smart-clickable:allow-pointer-events': world.last_played,
}"
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<Button
v-if="showPlayButton && showStop"
type="colored"
color="red"
@click="emit('stop')"
>
<template v-if="world.last_played">
{{ formatRelativeTime(dayjs(world.last_played).toISOString()) }}
</template>
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
</div>
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else-if="showPlayButton"
v-tooltip="playTooltip"
:disabled="playDisabled"
type="colored"
color="brand"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
:label="formatMessage(messages.moreOptions)"
:options="overflowOptions"
>
<MoreVerticalIcon aria-hidden="true" />
</TeleportOverflowMenu>
</div>
</div>
<div
class="font-semibold flex items-center gap-1 justify-center text-center"
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
>
<template v-if="world.type === 'server'">
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin" />
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<div
v-else-if="renderedMotd"
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
v-html="renderedMotd"
/>
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
{{ formatMessage(messages.cantConnect) }}
</div>
<div v-else class="font-normal font-minecraft text-secondary leading-5">
{{ formatMessage(messages.aMinecraftServer) }}
</div>
</template>
<template v-else-if="world.type === 'singleplayer' && gameMode">
<template v-if="world.hardcore">
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(messages.hardcore) }}
</template>
<template v-else>
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(gameMode.message) }}
</template>
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<Button
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
type="colored"
color="red"
@click="emit('stop')"
>
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="
quarantined
? formatMessage(messages.instanceLocked)
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
type="colored"
color="brand"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
:label="formatMessage(messages.moreOptions)"
:options="[
{
id: 'play-instance',
label: formatMessage(messages.playInstance),
shown: !!instanceId,
disabled: playingInstance || quarantined,
action: () => emit('play-instance'),
},
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
shown: !!instanceId,
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
},
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
label: formatMessage(messages.copyAddress),
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
action: () => emit('edit'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
type: 'divider',
shown: !!instanceId,
},
{
id: 'dont-show-on-home',
label: formatMessage(messages.dontShowOnHome),
shown: !!instanceId,
action: () => {
set_world_display_status(
instanceId,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
type: 'divider',
shown: !instanceId,
},
{
id: 'delete',
label: formatMessage(
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
),
tone: 'red',
action: () => emit('delete'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" />
{{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #create-shortcut>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.createShortcut) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
)
}}
</template>
</TeleportOverflowMenu>
</div>
</div>
</SmartClickable>
</SmartClickable>
<ContextMenu ref="cardOptions" :label="formatMessage(messages.worldActions)" />
</div>
</template>
<style scoped lang="scss">
.clickable-card:has([data-no-card-click]:hover) {
@@ -16,6 +16,7 @@ const { formatMessage } = useVIntl()
const props = defineProps<{
world: World | null
otherSyncedInstanceCount: number
}>()
const emit = defineEmits<{
@@ -44,6 +45,11 @@ const messages = defineMessages({
defaultMessage:
'This server will be removed from your server list and from the in-game server list. You can add it again later if you know the address.',
},
syncedServerRemovalNotice: {
id: 'app.instance.worlds.remove-server-modal.synced-removal-notice',
defaultMessage:
'This server will also be removed from {count, plural, one {# other instance} other {# other instances}} because its synced.',
},
deleteWorldWarningBody: {
id: 'app.instance.worlds.delete-world-modal.warning-body',
defaultMessage:
@@ -62,6 +68,9 @@ const messages = defineMessages({
const modal = ref<InstanceType<typeof NewModal>>()
const isServer = computed(() => props.world?.type === 'server')
const isSyncedServer = computed(
() => props.world?.type === 'server' && props.world.source === 'user_synced',
)
const isSingleplayer = computed(() => props.world?.type === 'singleplayer')
const titleMessage = computed(() =>
isServer.value ? messages.removeServerTitle : messages.deleteWorldTitle,
@@ -102,6 +111,13 @@ defineExpose({ show, hide })
>
{{ formatMessage(warningBodyMessage) }}
</Admonition>
<p v-if="isSyncedServer" class="m-0 text-secondary">
{{
formatMessage(messages.syncedServerRemovalNotice, {
count: otherSyncedInstanceCount,
})
}}
</p>
</div>
<template #actions>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { LinkIcon, TrashIcon, XIcon } from '@modrinth/assets'
import { Button, commonMessages, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import type { DesyncServerMode, ServerWorld } from '@/helpers/worlds'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const server = ref<ServerWorld | null>(null)
const emit = defineEmits<{
confirm: [server: ServerWorld, mode: DesyncServerMode]
}>()
const messages = defineMessages({
title: {
id: 'instance.worlds.desync-server.title',
defaultMessage: 'Desync server',
},
description: {
id: 'instance.worlds.desync-server.description',
defaultMessage:
'Do you want to keep this server in other synced instances, or remove it from them?',
},
keep: {
id: 'instance.worlds.desync-server.keep',
defaultMessage: 'Keep',
},
remove: {
id: 'instance.worlds.desync-server.remove',
defaultMessage: 'Remove',
},
})
function show(value: ServerWorld) {
server.value = value
modal.value?.show()
}
function confirm(mode: DesyncServerMode) {
if (server.value) emit('confirm', server.value, mode)
modal.value?.hide()
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="540px">
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" class="whitespace-nowrap" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button class="whitespace-nowrap" @click="confirm('keep_in_other_instances')">
<LinkIcon />
{{ formatMessage(messages.keep) }}
</Button>
<Button
type="colored"
color="red"
class="whitespace-nowrap"
@click="confirm('remove_from_other_instances')"
>
<TrashIcon />
{{ formatMessage(messages.remove) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -6,7 +6,7 @@ import {
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
Input,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
@@ -104,7 +104,7 @@ const messages = defineMessages({
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<StyledInput
<Input
v-model="name"
:placeholder="formatMessage(messages.placeholderName)"
autocomplete="off"
@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
Combobox,
defineMessages,
type MessageDescriptor,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Combobox, defineMessages, Input, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { ServerPackStatus } from '@/helpers/worlds.ts'
@@ -65,7 +59,7 @@ defineExpose({ resourcePackOptions })
<div class="space-y-4 w-full">
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
<StyledInput
<Input
v-model="name"
:placeholder="formatMessage(messages.placeholderName)"
autocomplete="off"
@@ -74,7 +68,7 @@ defineExpose({ resourcePackOptions })
</label>
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.address) }}</span>
<StyledInput
<Input
v-model="address"
:placeholder="formatMessage(messages.placeholderAddress)"
autocomplete="off"
@@ -1,6 +1,14 @@
import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlayIcon, PlusIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui'
import {
CheckIcon,
ClipboardCopyIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import type { ButtonMenuOption, CardAction } from '@modrinth/ui'
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -26,16 +34,7 @@ interface BrowseServerInstance {
}
interface ContextMenuHandle {
showMenu: (
event: MouseEvent,
result: Labrinth.Search.v3.ResultSearchProject,
options: { name: string }[],
) => void
}
interface ContextMenuOptionClick {
option: 'open_link' | 'copy_link'
item: Labrinth.Search.v3.ResultSearchProject
open: (event: MouseEvent, options: ButtonMenuOption[]) => void
}
export interface UseAppServerBrowseOptions {
@@ -275,19 +274,21 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}
function handleRightClick(event: MouseEvent, result: Labrinth.Search.v3.ResultSearchProject) {
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
}
function handleOptionsClick(args: ContextMenuOptionClick) {
const url = getProjectUrl(args.item)
switch (args.option) {
case 'open_link':
openUrl(url)
break
case 'copy_link':
navigator.clipboard.writeText(url)
break
}
const url = getProjectUrl(result)
contextMenuRef.value?.open(event, [
{
id: 'open_link',
label: formatMessage(commonMessages.openInModrinthButton),
icon: GlobeIcon,
action: () => void openUrl(url),
},
{
id: 'copy_link',
label: formatMessage(commonMessages.copyLinkButton),
icon: ClipboardCopyIcon,
action: () => void navigator.clipboard.writeText(url),
},
])
}
useAppEvent('process', (event) => {
@@ -314,7 +315,6 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
}
}
@@ -22,6 +22,7 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_offline_collapsed: true,
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
localhost_sign_in: false,
}
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
+100 -4
View File
@@ -1,28 +1,107 @@
import { prepareThemeColorTransition } from '@modrinth/ui'
import { computed, reactive, ref, watch } from 'vue'
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
export const DARK_THEMES = ['dark', 'oled', 'retro'] as const
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type DarkTheme = (typeof DARK_THEMES)[number]
type Theme = Exclude<ColorTheme, 'system'>
type NativeTheme = 'light' | 'dark'
const preferred = ref<ColorTheme>('dark')
const PREFERRED_THEME_KEY = 'modrinth-theme'
const PREFERRED_DARK_THEME_KEY = 'modrinth-preferred-dark-theme'
export function isDarkTheme(theme: string): theme is DarkTheme {
return (DARK_THEMES as readonly string[]).includes(theme)
}
function loadPreferredTheme(): ColorTheme {
try {
const stored = window.localStorage.getItem(PREFERRED_THEME_KEY)
if (stored && (THEME_OPTIONS as readonly string[]).includes(stored)) {
return stored as ColorTheme
}
} catch {
// storage blocked or full
}
for (const option of THEME_OPTIONS) {
if (option !== 'system' && document.documentElement.classList.contains(`${option}-mode`)) {
return option
}
}
return 'dark'
}
function loadPreferredDarkTheme(): DarkTheme {
try {
const stored = window.localStorage.getItem(PREFERRED_DARK_THEME_KEY)
if (stored && isDarkTheme(stored)) {
return stored
}
} catch {
// storage blocked or full
}
return 'dark'
}
const preferred = ref<ColorTheme>(loadPreferredTheme())
const preview = ref<ColorTheme | null>(null)
const preferredDark = ref<DarkTheme>(loadPreferredDarkTheme())
const advancedRendering = ref(true)
const syncAcrossDevices = ref(false)
const nativeThemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
const native = ref<Theme>(nativeThemeQuery.matches ? 'dark' : 'light')
const native = ref<NativeTheme>(nativeThemeQuery.matches ? 'dark' : 'light')
const active = computed<Theme>(() => {
const selectedTheme = preview.value ?? preferred.value
return selectedTheme === 'system' ? native.value : selectedTheme
if (selectedTheme !== 'system') {
return selectedTheme
}
return native.value === 'light' ? 'light' : preferredDark.value
})
nativeThemeQuery.addEventListener('change', (event) => {
native.value = event.matches ? 'dark' : 'light'
})
watch([preferred, preview], ([selectedPreferred, selectedPreview]) => {
const selectedTheme = selectedPreview ?? selectedPreferred
if (isDarkTheme(selectedTheme)) {
preferredDark.value = selectedTheme
}
})
watch(
preferred,
(theme) => {
try {
window.localStorage.setItem(PREFERRED_THEME_KEY, theme)
} catch {
// storage blocked or full
}
},
{ immediate: true },
)
watch(preferredDark, (theme) => {
try {
window.localStorage.setItem(PREFERRED_DARK_THEME_KEY, theme)
} catch {
// storage blocked or full
}
})
watch(
active,
(theme) => {
(theme, previousTheme) => {
if (previousTheme && previousTheme !== theme) {
prepareThemeColorTransition()
}
const html = document.documentElement
for (const option of THEME_OPTIONS) {
html.classList.remove(`${option}-mode`)
@@ -32,14 +111,31 @@ watch(
{ immediate: true },
)
function applyAccountAppearance(appearance: { auto: boolean; theme: string }): void {
if (isDarkTheme(appearance.theme)) {
preferredDark.value = appearance.theme
}
if (appearance.auto) {
preferred.value = 'system'
return
}
if ((THEME_OPTIONS as readonly string[]).includes(appearance.theme)) {
preferred.value = appearance.theme as ColorTheme
}
}
const theme = reactive({
preferred,
preview,
preferredDark,
active,
native,
syncAcrossDevices,
advancedRendering,
options: THEME_OPTIONS,
applyAccountAppearance,
})
export function useTheme() {
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstancePayload = { instance_id: string, } & ({ "event": "created" } | { "event": "synced" } | { "event": "servers_updated" } | { "event": "world_updated", world: string, } | { "event": "server_joined", host: string, port: number, timestamp: string, } | { "event": "edited" } | { "event": "content_install_finished", project_ids: Array<string>, } | { "event": "content_install_failed", project_ids: Array<string>, message: string, } | { "event": "removed" });
export type InstancePayload = { instance_id: string, } & ({ "event": "created" } | { "event": "synced" } | { "event": "servers_updated" } | { "event": "screenshots_updated" } | { "event": "world_updated", world: string, } | { "event": "server_joined", host: string, port: number, timestamp: string, } | { "event": "edited" } | { "event": "content_install_finished", project_ids: Array<string>, } | { "event": "content_install_failed", project_ids: Array<string>, message: string, } | { "event": "removed" });
@@ -44,7 +44,7 @@ export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { ta
export type ProcessPayload = { instance_id: string, uuid: string, event: ProcessPayloadType, message: string }
export type ProcessPayloadType = { tag: "launched" } | { tag: "finished" }
export type InstancePayload = { instance_id: string, event: InstancePayloadType }
export type InstancePayloadType = { tag: "created" } | { tag: "synced" } | { tag: "servers_updated" } | { tag: "world_updated", value: { world: string } } | { tag: "server_joined", value: { host: string, port: u16, timestamp: string } } | { tag: "edited" } | { tag: "content_install_finished", value: { project_ids: string[] } } | { tag: "content_install_failed", value: { project_ids: string[], message: string } } | { tag: "removed" }
export type InstancePayloadType = { tag: "created" } | { tag: "synced" } | { tag: "servers_updated" } | { tag: "screenshots_updated" } | { tag: "world_updated", value: { world: string } } | { tag: "server_joined", value: { host: string, port: u16, timestamp: string } } | { tag: "edited" } | { tag: "content_install_finished", value: { project_ids: string[] } } | { tag: "content_install_failed", value: { project_ids: string[], message: string } } | { tag: "removed" }
export type InstanceGroupsChangedPayload = { instance_ids: string[] }
export type FriendPayload = { tag: "friend_request", value: { from: string } } | { tag: "user_offline", value: { id: string } } | { tag: "status_update", value: { user_status: FriendStatusPayload } } | { tag: "status_sync" }
export type FriendStatusPayload = { user_id: string, profile_name: string | undefined, last_update: string }
@@ -351,13 +351,17 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
tag: "servers_updated"
};
case 3:
return {
tag: "screenshots_updated"
};
case 4:
return {
tag: "world_updated",
value: {
world: d.deserialize_string()
}
};
case 4:
case 5:
return {
tag: "server_joined",
value: {
@@ -366,18 +370,18 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
timestamp: d.deserialize_string()
}
};
case 5:
case 6:
return {
tag: "edited"
};
case 6:
case 7:
return {
tag: "content_install_finished",
value: {
project_ids: d.deserialize_array(() => d.deserialize_string())
}
};
case 7:
case 8:
return {
tag: "content_install_failed",
value: {
@@ -385,7 +389,7 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
message: d.deserialize_string()
}
};
case 8:
case 9:
return {
tag: "removed"
};
@@ -0,0 +1,29 @@
const STORAGE_KEY = 'modrinth-account-appearances'
function readCache(): Record<string, { auto: boolean; theme: string }> {
try {
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '')
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return parsed
} catch {
return {}
}
}
export function rememberAccountAppearance(
userId: string,
appearance: { auto: boolean; theme: string },
) {
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...readCache(), [userId]: appearance }),
)
} catch {
// storage blocked or full
}
}
export function getAccountAppearance(userId: string) {
return readCache()[userId]
}
+213
View File
@@ -167,6 +167,219 @@ export async function get_mod_full_path(instanceId: string, projectPath: string)
return await invoke('plugin:instance|instance_get_mod_full_path', { instanceId, projectPath })
}
export type ScreenshotKey = {
instance_id: string
file_name: string
}
export type InstanceScreenshot = ScreenshotKey & {
id: string
instance_name: string
created_at: string
modified_at: number
group_id?: string | null
path: string
url: string
}
export type ScreenshotGroup = {
id: string
name: string
}
export type ScreenshotGroupMembershipUpdate = {
screenshot_id: string
group_id?: string | null
}
export type ScreenshotGroupImport = ScreenshotGroup & {
screenshot_ids: string[]
}
export async function list_instance_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
}
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_all_screenshots')
}
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_synced_screenshots')
}
export async function save_edited_screenshot(
key: ScreenshotKey,
pngBytes: Uint8Array,
mode: 'create_copy' | 'replace_edit',
): Promise<InstanceScreenshot> {
return await invoke('plugin:instance|instance_save_edited_screenshot', {
key,
pngBytes: Array.from(pngBytes),
mode,
})
}
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
return await invoke('plugin:instance|instance_list_screenshot_groups')
}
export async function create_screenshot_group(
name: string,
screenshotIds: string[],
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_create_screenshot_group', {
name,
screenshotIds,
})
}
export async function rename_screenshot_group(
id: string,
newName: string,
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
}
export async function delete_screenshot_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
}
export async function set_screenshot_group_memberships(
updates: ScreenshotGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
}
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
}
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
}
export async function export_screenshots(keys: ScreenshotKey[], exportPath: string): Promise<void> {
return await invoke('plugin:instance|instance_export_screenshots', { keys, exportPath })
}
export async function move_screenshots(
keys: ScreenshotKey[],
targetInstanceId: string,
): Promise<ScreenshotKey[]> {
return await invoke('plugin:instance|instance_move_screenshots', { keys, targetInstanceId })
}
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
return await invoke('plugin:instance|instance_open_screenshot', { key })
}
export async function set_synced_option(
instanceId: string,
option: SyncedOption,
enabled: boolean,
resolution?: SyncedOptionJoinResolution,
): Promise<GameInstance> {
return await invoke('plugin:instance|instance_set_synced_option', {
instanceId,
option,
enabled,
resolution,
})
}
export type SyncedOption =
| 'command_history'
| 'multiplayer_servers'
| 'creative_hotbars'
| 'screenshots'
export type GlobalSyncedOptions = Record<SyncedOption, boolean>
export type SyncedOptionJoinAction = 'seed_shared' | 'attach' | 'merge' | 'requires_resolution'
export type SyncedOptionJoinResolution = 'use_synced' | 'use_instance'
export type SyncedOptionJoinPreview = {
action: SyncedOptionJoinAction
}
export type SyncedOptionCapability = {
option: SyncedOption
supported: boolean
disabled_reason?: string | null
}
export type SyncedOptionsOverview = {
global_options: GlobalSyncedOptions
capabilities: SyncedOptionCapability[]
}
export async function get_synced_option_join_preview(
instanceId: string,
option: SyncedOption,
): Promise<SyncedOptionJoinPreview> {
return await invoke('plugin:instance|instance_get_synced_option_join_preview', {
instanceId,
option,
})
}
export async function get_synced_options_overview(
instanceId: string,
): Promise<SyncedOptionsOverview> {
return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId })
}
export async function get_global_synced_options(): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_get_global_synced_options')
}
export async function set_global_synced_option(
option: SyncedOption,
enabled: boolean,
): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_set_global_synced_option', {
option,
enabled,
})
}
export async function get_command_history(): Promise<string> {
return await invoke('plugin:instance|instance_get_command_history')
}
export async function set_command_history(contents: string): Promise<string> {
return await invoke('plugin:instance|instance_set_command_history', { contents })
}
export async function open_synced_options_folder(): Promise<void> {
return await invoke('plugin:instance|instance_open_synced_options_folder')
}
export type SyncedServer = {
id: string
name: string
address: string
accept_textures?: boolean | null
}
export async function list_synced_servers(): Promise<SyncedServer[]> {
return await invoke('plugin:instance|instance_list_synced_servers')
}
export async function update_synced_server(server: SyncedServer): Promise<void> {
return await invoke('plugin:instance|instance_update_synced_server', { server })
}
export async function remove_synced_server(serverId: string): Promise<void> {
return await invoke('plugin:instance|instance_remove_synced_server', { serverId })
}
export async function rebuild_synced_options(instanceId?: string): Promise<void> {
return await invoke('plugin:instance|instance_rebuild_synced_options', { instanceId })
}
export interface JavaVersion {
parsed_version: number
version: string
+17 -2
View File
@@ -14,8 +14,11 @@ export type ModrinthCredentials = {
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow })
export async function login(
flow: ModrinthAuthFlow = 'sign-in',
addAccount = false,
): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow, addAccount })
}
export async function logout(): Promise<void> {
@@ -26,6 +29,18 @@ export async function get(): Promise<ModrinthCredentials | null> {
return await invoke('plugin:mr-auth|get')
}
export async function getAll(): Promise<ModrinthCredentials[]> {
return await invoke('plugin:mr-auth|get_all')
}
export async function setActive(userId: string): Promise<void> {
return await invoke('plugin:mr-auth|set_active', { userId })
}
export async function removeUser(userId: string): Promise<void> {
return await invoke('plugin:mr-auth|remove_account', { userId })
}
export async function cancelLogin(): Promise<void> {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}
+6
View File
@@ -16,6 +16,12 @@ export type GameInstance = {
loader_version?: string
group_ids: string[]
synced_options: {
command_history: boolean
multiplayer_servers: boolean
creative_hotbars: boolean
screenshots: boolean
}
link?: InstanceLink | null
shared_instance?: SharedInstanceAttachment | null
+14 -33
View File
@@ -29,12 +29,18 @@ export type SingleplayerWorld = BaseWorld & {
export type ServerWorld = BaseWorld & {
type: 'server'
index: number
server_id?: string
source?: ServerSource
address: string
pack_status: ServerPackStatus
project_id?: string
content_kind?: string
}
export type ServerSource = 'user_synced' | 'modpack' | 'linked_server_project' | 'local_desynced'
export type DesyncServerMode = 'keep_in_other_instances' | 'remove_from_other_instances'
export type World = SingleplayerWorld | ServerWorld
export type WorldWithInstance = {
@@ -44,6 +50,14 @@ export type WorldWithInstance = {
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
export async function desync_server(
instanceId: string,
serverId: string,
mode: DesyncServerMode,
): Promise<void> {
return await invoke('plugin:worlds|desync_server', { instanceId, serverId, mode })
}
export type ServerStatus = {
// https://minecraft.wiki/w/Text_component_format
description?: string | Chat
@@ -260,17 +274,6 @@ function parseServerHost(address: string): string {
return trimmedAddress.toLowerCase()
}
function isIPv4Host(host: string): boolean {
const segments = host.split('.')
if (segments.length !== 4) return false
return segments.every((segment) => {
if (!/^\d+$/.test(segment)) return false
const value = Number.parseInt(segment, 10)
return value >= 0 && value <= 255
})
}
/**
* Normalization converts addresses to a canonical form (lowercase-host:port, default port 25565)
*/
@@ -307,28 +310,6 @@ export function normalizeServerAddress(address: string): string {
return `${host}:${port}`
}
/**
* Domain key used for deduping server entries by removing a single leading subdomain.
* Example: test.cobblemon.gg and cobblemon.gg map to cobblemon.gg
*/
export function getServerDomainKey(address: string): string {
const normalizedAddress = normalizeServerAddress(address)
if (!normalizedAddress) return ''
const separator = normalizedAddress.lastIndexOf(':')
if (separator <= 0 || separator === normalizedAddress.length - 1) return normalizedAddress
const host = normalizedAddress.slice(0, separator).replace(/\.+$/, '')
if (!host) return normalizedAddress
if (host.includes(':') || isIPv4Host(host)) return normalizedAddress
const segments = host.split('.').filter(Boolean)
if (segments.length <= 2) return host
return segments.slice(1).join('.')
}
export function resolveManagedServerWorld(
worlds: World[],
managedName: string | null | undefined,
@@ -1154,9 +1154,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "تسجيل الدخول إلى حساب Modrinth"
},
"app.nav.signed-in-as": {
"message": "تسجيل الدخول ك <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "الترقية إلى Modrinth+"
},
@@ -1169,9 +1166,6 @@
"app.navigation.home": {
"message": "الصفحة الرئيسية"
},
"app.navigation.more-options": {
"message": "المزيد من الخيارات"
},
"app.navigation.next-image": {
"message": "الصورة التالية"
},
@@ -1406,9 +1400,6 @@
"app.settings.tabs.behavior": {
"message": "السلوك"
},
"app.settings.tabs.default-instance-options": {
"message": "خيارات اللعبة الافتراضية"
},
"app.settings.tabs.java-installations": {
"message": "تثبيتات جافا"
},
@@ -2216,12 +2207,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "اختر قناة التحديث"
},
"instance.settings.tabs.hooks": {
"message": "إجراءات تشغيل إضافية"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "إجراءات تشغيل إضافية مخصصة"
},
"instance.settings.tabs.hooks.description": {
"message": "إجراءات التشغيل المضافة تسمح للمستخدمين المتقدمين بإنشاء أوامر نظامية محددة قبل و عند تشغيل اللعبة."
},
@@ -2285,9 +2270,6 @@
"instance.settings.tabs.installation.locked": {
"message": "إعدادات التثبيت غير متاحة أثناء قفل هذه النسخة."
},
"instance.settings.tabs.java": {
"message": "جافا والذاكرة"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "متغيرات البيئة المخصصة"
},
@@ -1151,9 +1151,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Přihlásit se do Modrinth účtu"
},
"app.nav.signed-in-as": {
"message": "Přihlášen jako <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Upgradovat na Modrinth+"
},
@@ -1166,9 +1163,6 @@
"app.navigation.home": {
"message": "Domov"
},
"app.navigation.more-options": {
"message": "Více možností"
},
"app.navigation.next-image": {
"message": "Další obrázek"
},
@@ -1697,12 +1691,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Vyber aktualizační kanál"
},
"instance.settings.tabs.hooks": {
"message": "Spouštěcí hooky"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Vlastní spouštěcí hooky"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooky umožňují pokročilým uživatelům spustit určité systémové příkazy před a po spuštění hry."
},
@@ -1742,9 +1730,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} verze"
},
"instance.settings.tabs.java": {
"message": "Java a pamět"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Vlastní proměnná prostředí"
},
@@ -881,12 +881,6 @@
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
},
"instance.settings.tabs.hooks": {
"message": "Startkommandoer"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Brugerfinansieret startkommandoer"
},
"instance.settings.tabs.hooks.description": {
"message": "Startkommandoer giver avancerede brugere mulighed for at køre bestemte systemkommandoer før og efter spillet startes."
},
@@ -926,9 +920,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} version"
},
"instance.settings.tabs.java": {
"message": "Java og hukommelse"
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Indskriv Java argumenter..."
},
@@ -1232,9 +1232,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Bei einem Modrinth-Konto anmelden"
},
"app.nav.signed-in-as": {
"message": "Angemeldet als <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Auf Modrinth+ upgraden"
},
@@ -1247,9 +1244,6 @@
"app.navigation.home": {
"message": "Start"
},
"app.navigation.more-options": {
"message": "Weitere Optionen"
},
"app.navigation.next-image": {
"message": "Nächstes Bild"
},
@@ -1484,9 +1478,6 @@
"app.settings.tabs.behavior": {
"message": "Verhalten"
},
"app.settings.tabs.default-instance-options": {
"message": "Standard-Spieloptionen"
},
"app.settings.tabs.java-installations": {
"message": "Java Installationen"
},
@@ -2309,12 +2300,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Update-Kanal auswählen"
},
"instance.settings.tabs.hooks": {
"message": "Start Hooks"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Benutzerdefinierte Start Hooks"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooks ermöglichen es erfahrenen Benutzern, bestimmte Systembefehle vor und nach dem Start des Spiels auszuführen."
},
@@ -2378,9 +2363,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Installationseinstellungen sind nicht verfügbar, solange diese Instanz gesperrt ist."
},
"instance.settings.tabs.java": {
"message": "Java und Arbeitsspeicher"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Benutzerdefinierte Umgebungsvariablen"
},
@@ -1232,9 +1232,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Bei einem Modrinth-Konto anmelden"
},
"app.nav.signed-in-as": {
"message": "Angemeldet als <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Auf Modrinth+ upgraden"
},
@@ -1247,9 +1244,6 @@
"app.navigation.home": {
"message": "Start"
},
"app.navigation.more-options": {
"message": "Weitere Optionen"
},
"app.navigation.next-image": {
"message": "Nächstes Bild"
},
@@ -1484,9 +1478,6 @@
"app.settings.tabs.behavior": {
"message": "Verhalten"
},
"app.settings.tabs.default-instance-options": {
"message": "Standard-Spieloptionen"
},
"app.settings.tabs.java-installations": {
"message": "Java-Installationen"
},
@@ -2309,12 +2300,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Aktualisierungskanal auswählen"
},
"instance.settings.tabs.hooks": {
"message": "Startargumente"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Benutzerdefinierte Startargumente"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooks ermöglichen es fortgeschrittenen Benutzern, bestimmte Systembefehle vor und nach dem Spielstart auszuführen."
},
@@ -2378,9 +2363,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Installationseinstellungen sind nicht verfügbar, solange diese Instanz gesperrt ist."
},
"instance.settings.tabs.java": {
"message": "Java und Arbeitsspeicher"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Benutzerdefinierte Umgebungsvariablen"
},
+354 -24
View File
@@ -260,6 +260,9 @@
"app.browse.hide-installed-modpacks": {
"message": "Hide already installed"
},
"app.browse.project-actions.label": {
"message": "Project actions"
},
"app.browse.project-type.data-packs": {
"message": "Data Packs"
},
@@ -287,6 +290,9 @@
"app.content-install.no-compatible-versions": {
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
},
"app.error.state-init.open-backups-folder": {
"message": "Open backups folder"
},
"app.export-modal.description-placeholder": {
"message": "Enter modpack description..."
},
@@ -314,6 +320,9 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.home.jump-back-in.actions.label": {
"message": "Instance actions"
},
"app.home.jump-back-in.instance-locked": {
"message": "This instance has been locked"
},
@@ -464,6 +473,9 @@
"app.instance.action.stop": {
"message": "Stop"
},
"app.instance.actions.label": {
"message": "Instance actions"
},
"app.instance.add-server.add": {
"message": "Add"
},
@@ -746,6 +758,9 @@
"app.instance.tab.logs": {
"message": "Logs"
},
"app.instance.tab.screenshots": {
"message": "Screenshots"
},
"app.instance.tab.share": {
"message": "Share"
},
@@ -794,6 +809,9 @@
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Remove server"
},
"app.instance.worlds.remove-server-modal.synced-removal-notice": {
"message": "This server will also be removed from {count, plural, one {# other instance} other {# other instances}} because its synced."
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Remove server"
},
@@ -851,6 +869,9 @@
"app.java-selector.test-installation": {
"message": "Test Java installation"
},
"app.library.actions.label": {
"message": "Library actions"
},
"app.library.context-menu.create-instance": {
"message": "New instance"
},
@@ -899,6 +920,9 @@
"app.library.group-by.none": {
"message": "No grouping"
},
"app.library.group.actions.label": {
"message": "Group actions"
},
"app.library.group.collapse": {
"message": "Collapse group"
},
@@ -1022,6 +1046,9 @@
"app.library.instance.action.view-instance": {
"message": "View instance"
},
"app.library.instance.actions.label": {
"message": "Instance actions"
},
"app.library.instance.deselect": {
"message": "Deselect instance"
},
@@ -1217,27 +1244,42 @@
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.nav.add-account": {
"message": "Add account"
},
"app.nav.create-new-instance": {
"message": "Create new instance"
},
"app.nav.home": {
"message": "Home"
},
"app.nav.loading-profile": {
"message": "Loading profile..."
},
"app.nav.modrinth-account": {
"message": "Modrinth account"
},
"app.nav.modrinth-hosting": {
"message": "Modrinth Hosting"
},
"app.nav.sign-in-to-modrinth-account": {
"message": "Sign in to a Modrinth account"
"app.nav.remove-account": {
"message": "Remove account"
},
"app.nav.signed-in-as": {
"message": "Signed in as <user>{username}</user>"
"app.nav.screenshots": {
"message": "Screenshots"
},
"app.nav.sign-in-to-modrinth-account": {
"message": "Sign into Modrinth"
},
"app.nav.switch-account": {
"message": "Switch account"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"app.nav.view-profile": {
"message": "View profile"
},
"app.navigation.go-back": {
"message": "Go back"
},
@@ -1247,9 +1289,6 @@
"app.navigation.home": {
"message": "Home"
},
"app.navigation.more-options": {
"message": "More options"
},
"app.navigation.next-image": {
"message": "Next image"
},
@@ -1262,6 +1301,9 @@
"app.notification.warning": {
"message": "Warning"
},
"app.project.actions.label": {
"message": "Project actions"
},
"app.project.coming-soon": {
"message": "Coming soon"
},
@@ -1304,15 +1346,177 @@
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.quick-instance-switcher.actions.label": {
"message": "Instance actions"
},
"app.quick-instance-switcher.drag-show-tooltip": {
"message": "Drag to show recent instances"
},
"app.quick-instance-switcher.drag-tooltip": {
"message": "Drag to resize"
},
"app.quick-instance-switcher.instance-locked": {
"message": "This instance has been locked"
},
"app.quick-instance-switcher.view-instance": {
"message": "View instance"
},
"app.restarting": {
"message": "Restarting..."
},
"app.screenshots.copied": {
"message": "Copied"
},
"app.screenshots.copy": {
"message": "Copy image"
},
"app.screenshots.delete-description": {
"message": "Permanently delete {name}? This action cannot be undone."
},
"app.screenshots.delete-success": {
"message": "Screenshot deleted"
},
"app.screenshots.delete-title": {
"message": "Delete screenshot"
},
"app.screenshots.deselect": {
"message": "Deselect {name}"
},
"app.screenshots.edit": {
"message": "Edit screenshot"
},
"app.screenshots.empty-description": {
"message": "Screenshots you take in-game will appear here."
},
"app.screenshots.empty-heading": {
"message": "No screenshots yet"
},
"app.screenshots.error-heading": {
"message": "Failed to load screenshots"
},
"app.screenshots.go-to-instance": {
"message": "Go to instance"
},
"app.screenshots.group-by": {
"message": "Group by"
},
"app.screenshots.group.all-screenshots": {
"message": "All screenshots"
},
"app.screenshots.group.collapse": {
"message": "Collapse group"
},
"app.screenshots.group.custom": {
"message": "Custom group"
},
"app.screenshots.group.date": {
"message": "Date"
},
"app.screenshots.group.delete": {
"message": "Delete group"
},
"app.screenshots.group.delete-description": {
"message": "Screenshots in this group will become ungrouped."
},
"app.screenshots.group.edit": {
"message": "Edit group name"
},
"app.screenshots.group.empty": {
"message": "Drag and drop to add screenshots."
},
"app.screenshots.group.expand": {
"message": "Expand group"
},
"app.screenshots.group.instance": {
"message": "Instance"
},
"app.screenshots.group.new": {
"message": "New group"
},
"app.screenshots.group.none": {
"message": "No grouping"
},
"app.screenshots.group.this-month": {
"message": "This month"
},
"app.screenshots.group.this-week": {
"message": "This week"
},
"app.screenshots.group.today": {
"message": "Today"
},
"app.screenshots.group.ungrouped": {
"message": "Ungrouped"
},
"app.screenshots.group.yesterday": {
"message": "Yesterday"
},
"app.screenshots.heading": {
"message": "Screenshots"
},
"app.screenshots.more-actions": {
"message": "More actions"
},
"app.screenshots.no-results-description": {
"message": "Try a different search or filter."
},
"app.screenshots.no-results-heading": {
"message": "No matching screenshots"
},
"app.screenshots.preview.instance-and-date": {
"message": "{instance} · {date}"
},
"app.screenshots.search": {
"message": "Search"
},
"app.screenshots.select": {
"message": "Select {name}"
},
"app.screenshots.selection.aria-label": {
"message": "Selected screenshots"
},
"app.screenshots.selection.delete-description": {
"message": "Delete {count, plural, one {# screenshot} other {# screenshots}}? This action cannot be undone."
},
"app.screenshots.selection.delete-success": {
"message": "{count, plural, one {# screenshot deleted} other {# screenshots deleted}}"
},
"app.screenshots.selection.delete-title": {
"message": "Delete selected screenshots"
},
"app.screenshots.selection.export-zip": {
"message": "Export ZIP"
},
"app.screenshots.selection.global-export-filename": {
"message": "Modrinth screenshots.zip"
},
"app.screenshots.selection.instance-export-filename": {
"message": "{instance} screenshots.zip"
},
"app.screenshots.selection.remove-from-group": {
"message": "Remove from group"
},
"app.screenshots.selection.selected-count": {
"message": "{count} selected"
},
"app.screenshots.selection.zip-archive": {
"message": "ZIP archive"
},
"app.screenshots.show-in-folder": {
"message": "Show in folder"
},
"app.screenshots.sort-by": {
"message": "Sort by"
},
"app.screenshots.sort.name": {
"message": "Name"
},
"app.screenshots.sort.newest": {
"message": "Newest"
},
"app.screenshots.sort.oldest": {
"message": "Oldest"
},
"app.settings.app-version": {
"message": "Modrinth App {version}"
},
@@ -1340,6 +1544,9 @@
"app.settings.default-instance-options.height.title": {
"message": "Height"
},
"app.settings.default-instance-options.java-and-memory.title": {
"message": "Java and memory"
},
"app.settings.default-instance-options.java-arguments.description": {
"message": "Arguments passed to Java when launching an instance."
},
@@ -1349,6 +1556,9 @@
"app.settings.default-instance-options.java-arguments.title": {
"message": "Java arguments"
},
"app.settings.default-instance-options.launch-hooks.title": {
"message": "Launch hooks"
},
"app.settings.default-instance-options.memory-allocation.description": {
"message": "Maximum memory available to each instance."
},
@@ -1382,6 +1592,9 @@
"app.settings.default-instance-options.width.title": {
"message": "Width"
},
"app.settings.default-instance-options.window.title": {
"message": "Window"
},
"app.settings.default-instance-options.wrapper-hook.description": {
"message": "Command used to wrap the Minecraft launch process."
},
@@ -1478,15 +1691,54 @@
"app.settings.sidebar.label.instances": {
"message": "Instances"
},
"app.settings.synced-options.command-history": {
"message": "Command history"
},
"app.settings.synced-options.command-history.description": {
"message": "Sync command history across your instances."
},
"app.settings.synced-options.command-history.editor-title": {
"message": "Edit command history"
},
"app.settings.synced-options.creative-hotbars": {
"message": "Saved creative hotbars"
},
"app.settings.synced-options.creative-hotbars.description": {
"message": "Sync saved creative hotbars across your instances."
},
"app.settings.synced-options.multiplayer-servers": {
"message": "Multiplayer servers"
},
"app.settings.synced-options.multiplayer-servers.address": {
"message": "Server address"
},
"app.settings.synced-options.multiplayer-servers.description": {
"message": "Sync multiplayer servers across your instances."
},
"app.settings.synced-options.multiplayer-servers.editor-title": {
"message": "Edit synced servers"
},
"app.settings.synced-options.multiplayer-servers.empty": {
"message": "No user-added servers are currently synced."
},
"app.settings.synced-options.multiplayer-servers.name": {
"message": "Server name"
},
"app.settings.synced-options.multiplayer-servers.none-synced-yet": {
"message": "No servers synced yet"
},
"app.settings.synced-options.screenshots": {
"message": "Screenshots"
},
"app.settings.synced-options.screenshots.description": {
"message": "View screenshots from your instances in one place."
},
"app.settings.tabs.appearance": {
"message": "Appearance"
},
"app.settings.tabs.behavior": {
"message": "Behavior"
},
"app.settings.tabs.default-instance-options": {
"message": "Default game options"
},
"app.settings.tabs.java-installations": {
"message": "Java installations"
},
@@ -1499,6 +1751,9 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.settings.tabs.synced-options": {
"message": "Synced settings"
},
"app.sidebar.playing-as": {
"message": "Playing as"
},
@@ -1808,6 +2063,9 @@
"friends.add-friends-to-share": {
"message": "<link>Add friends</link> to see what they're playing!"
},
"friends.friend.actions.label": {
"message": "Friend actions"
},
"friends.friend.cancel-request": {
"message": "Cancel request"
},
@@ -2309,12 +2567,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Select update channel"
},
"instance.settings.tabs.hooks": {
"message": "Launch hooks"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Custom launch hooks"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooks allow advanced users to run certain system commands before and after launching the game."
},
@@ -2378,20 +2630,17 @@
"instance.settings.tabs.installation.locked": {
"message": "Installation settings are unavailable while this instance is locked."
},
"instance.settings.tabs.java": {
"message": "Java and memory"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Custom environment variables"
"message": "Use custom environment variables for this instance."
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Custom Java arguments"
"message": "Use custom Java arguments for this instance."
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Custom Java installation"
"message": "Use a custom Java installation for this instance."
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Custom memory allocation"
"message": "Use a custom memory allocation for this instance."
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Enter environmental variables..."
@@ -2417,14 +2666,74 @@
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.settings-overrides": {
"message": "Sync overrides"
},
"instance.settings.tabs.sharing": {
"message": "Sharing"
},
"instance.settings.tabs.synced-options.command-history": {
"message": "Command history"
},
"instance.settings.tabs.synced-options.command-history.disabled-in-app": {
"message": "Command history syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.command-history.exclude-description": {
"message": "Exclude this instance from command history syncing."
},
"instance.settings.tabs.synced-options.creative-hotbars": {
"message": "Saved creative hotbars"
},
"instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app": {
"message": "Saved creative hotbar syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.creative-hotbars.exclude-description": {
"message": "Exclude this instance from saved creative hotbar syncing."
},
"instance.settings.tabs.synced-options.hotbars-conflict.backup-description": {
"message": "The version being replaced will be backed up before anything changes."
},
"instance.settings.tabs.synced-options.hotbars-conflict.description": {
"message": "{instance} and your synced version have different creative hotbars. Choose which one to use across your instances."
},
"instance.settings.tabs.synced-options.hotbars-conflict.title": {
"message": "Choose creative hotbars"
},
"instance.settings.tabs.synced-options.hotbars-conflict.use-instance": {
"message": "Overwrite others"
},
"instance.settings.tabs.synced-options.hotbars-conflict.use-synced": {
"message": "Use synced"
},
"instance.settings.tabs.synced-options.multiplayer-servers": {
"message": "Multiplayer servers"
},
"instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app": {
"message": "Multiplayer server syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.multiplayer-servers.exclude-description": {
"message": "Exclude this instance from multiplayer server syncing."
},
"instance.settings.tabs.synced-options.open-app-settings": {
"message": "Open synced settings"
},
"instance.settings.tabs.synced-options.screenshots": {
"message": "Screenshots"
},
"instance.settings.tabs.synced-options.screenshots.disabled-in-app": {
"message": "Screenshots are turned off in app settings."
},
"instance.settings.tabs.synced-options.screenshots.exclude-description": {
"message": "Exclude this instances screenshots from the Screenshots page."
},
"instance.settings.tabs.synced-options.shared-settings.description": {
"message": "Game settings can be shared between instances. Choose what to share in app settings."
},
"instance.settings.tabs.window": {
"message": "Window"
},
"instance.settings.tabs.window.custom-window-settings": {
"message": "Custom window settings"
"message": "Use custom window settings for this instance."
},
"instance.settings.tabs.window.fullscreen": {
"message": "Fullscreen"
@@ -2510,6 +2819,9 @@
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
"instance.worlds.actions.label": {
"message": "World actions"
},
"instance.worlds.cant_connect": {
"message": "Can't connect to server"
},
@@ -2519,6 +2831,21 @@
"instance.worlds.create_shortcut": {
"message": "Create shortcut"
},
"instance.worlds.desync-server.description": {
"message": "Do you want to keep this server in other synced instances, or remove it from them?"
},
"instance.worlds.desync-server.keep": {
"message": "Keep"
},
"instance.worlds.desync-server.remove": {
"message": "Remove"
},
"instance.worlds.desync-server.title": {
"message": "Desync server"
},
"instance.worlds.desync_server": {
"message": "Desync"
},
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
@@ -2552,6 +2879,9 @@
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.play_world": {
"message": "Play world"
},
"instance.worlds.shortcut-created": {
"message": "Shortcut created"
},
@@ -1232,9 +1232,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Inicia sesión con una cuenta Modrinth"
},
"app.nav.signed-in-as": {
"message": "Sesión iniciada como <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Mejorar a Modrinth+"
},
@@ -1247,9 +1244,6 @@
"app.navigation.home": {
"message": "Inicio"
},
"app.navigation.more-options": {
"message": "Más opciones"
},
"app.navigation.next-image": {
"message": "Siguiente imágen"
},
@@ -1484,9 +1478,6 @@
"app.settings.tabs.behavior": {
"message": "Comportamiento"
},
"app.settings.tabs.default-instance-options": {
"message": "Opciones por defecto del juego"
},
"app.settings.tabs.java-installations": {
"message": "Instalaciones de Java"
},
@@ -2309,12 +2300,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Selecciona el canal de actualizaciones"
},
"instance.settings.tabs.hooks": {
"message": "Hooks de inicio"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Hooks personalizados"
},
"instance.settings.tabs.hooks.description": {
"message": "Los hooks permiten que usuarios avanzados ejecuten comandos del sistema antes y despues de lanzar el juego."
},
@@ -2378,9 +2363,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Los ajustes de instalación no están disponibles mientras esta instancia esté bloqueada."
},
"instance.settings.tabs.java": {
"message": "Java y memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables de entorno personalizadas"
},
@@ -1202,9 +1202,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Iniciar sesión en una cuenta de Modrinth"
},
"app.nav.signed-in-as": {
"message": "Sesión iniciada como <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Actualizar a Modrinth+"
},
@@ -1217,9 +1214,6 @@
"app.navigation.home": {
"message": "Inicio"
},
"app.navigation.more-options": {
"message": "Más opciones"
},
"app.navigation.next-image": {
"message": "Siguiente imagen"
},
@@ -1418,9 +1412,6 @@
"app.settings.tabs.behavior": {
"message": "Comportamiento"
},
"app.settings.tabs.default-instance-options": {
"message": "Opciones predeterminadas del juego"
},
"app.settings.tabs.java-installations": {
"message": "Instalaciones de Java"
},
@@ -2237,12 +2228,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Seleccionar canal de actualizaciones"
},
"instance.settings.tabs.hooks": {
"message": "Hooks de lanzamiento"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Hooks de lanzamiento personalizados"
},
"instance.settings.tabs.hooks.description": {
"message": "Los hooks permiten a los usuarios avanzados ejecutar ciertas funciones del sistema antes y después de lanzar el juego."
},
@@ -2300,9 +2285,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Los ajustes de instalación no están disponibles mientras esta instancia esté bloqueada."
},
"instance.settings.tabs.java": {
"message": "Java y memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables de entorno personalizadas"
},
@@ -728,12 +728,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Valitse päivitys kanava"
},
"instance.settings.tabs.hooks": {
"message": "Aloitustoiminnot"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Mukautetut aloitustoiminnot"
},
"instance.settings.tabs.hooks.description": {
"message": "Koukut antavat edistyneiden käyttäjien suorittaa tiettyjä järjestelmä komentoja pelin käynnistystä ennen ja sen jälkeen."
},
@@ -773,9 +767,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} versio"
},
"instance.settings.tabs.java": {
"message": "Java ja muisti"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Mukautetut ympäristö muuttujat"
},
@@ -494,12 +494,6 @@
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
},
"instance.settings.tabs.hooks": {
"message": "Mga launch hook"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Mga pasadyang launch hook"
},
"instance.settings.tabs.hooks.description": {
"message": "Binibigyan-daan ng mga hook ang mga ekspertong tagagamit na makapagtakbo ng mga pansistemang utos o system command bago at pagkatapos ma-launch ang laro."
},
@@ -539,9 +533,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Bersiyon ng {loader}"
},
"instance.settings.tabs.java": {
"message": "Java at memorya"
},
"instance.settings.tabs.java.environment-variables": {
"message": "Environment variables"
},
@@ -1196,9 +1196,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Se connecter à un compte Modrinth"
},
"app.nav.signed-in-as": {
"message": "Connecté en tant que <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Passer à Modrinth+"
},
@@ -1211,9 +1208,6 @@
"app.navigation.home": {
"message": "Accueil"
},
"app.navigation.more-options": {
"message": "Plus d'options"
},
"app.navigation.next-image": {
"message": "Prochaine image"
},
@@ -1445,9 +1439,6 @@
"app.settings.tabs.behavior": {
"message": "Comportement"
},
"app.settings.tabs.default-instance-options": {
"message": "Options de jeu par défaut"
},
"app.settings.tabs.java-installations": {
"message": "Installations de Java"
},
@@ -2165,12 +2156,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Sélectionner le canal de mise à jour"
},
"instance.settings.tabs.hooks": {
"message": "Crochets de lancement"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Crochets de lancement personnalisés"
},
"instance.settings.tabs.hooks.description": {
"message": "Les crochets permettent aux usagers avancés d'exécuter certaines commandes systèmes avant et après le lancement du jeu."
},
@@ -2234,9 +2219,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Les paramètres d'installation ne sont pas disponible pendant que cette instance est verrouillée."
},
"instance.settings.tabs.java": {
"message": "Java et mémoire"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables d'environnement personnalisé"
},
@@ -389,12 +389,6 @@
"instance.settings.tabs.general.name": {
"message": "שם"
},
"instance.settings.tabs.hooks": {
"message": "פעולות בעת הפעלה"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "פעולות בעת הפעלה מותאמות אישית"
},
"instance.settings.tabs.hooks.description": {
"message": "פעולות בעת הפעלה מאפשרות למשתמשים מתקדמים להריץ פקודות מערכת מסוימות לפני ואחרי שהמשחק מופעל."
},
@@ -434,9 +428,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} גרסה"
},
"instance.settings.tabs.java": {
"message": "ג'אווה וזיכרון"
},
"instance.settings.tabs.java.environment-variables": {
"message": "משתני סביבה"
},
@@ -983,9 +983,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Bejelentkezés egy Modrinth-fiókba"
},
"app.nav.signed-in-as": {
"message": "Bejelentkezve mint <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Frissíts a Modrinth+-ra"
},
@@ -1217,9 +1214,6 @@
"app.settings.tabs.behavior": {
"message": "Működés"
},
"app.settings.tabs.default-instance-options": {
"message": "A játék alapbeállításai"
},
"app.settings.tabs.java-installations": {
"message": "Telepített Java példányok"
},
@@ -1820,12 +1814,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Frissítési csatorna kiválasztása"
},
"instance.settings.tabs.hooks": {
"message": "Indítási horgok"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Saját indítási horgok"
},
"instance.settings.tabs.hooks.description": {
"message": "A horgok lehetővé teszik a haladó felhasználók számára, hogy bizonyos rendszerparancsokat futtassanak a játék elindítása előtt és után."
},
@@ -1880,9 +1868,6 @@
"instance.settings.tabs.installation.locked": {
"message": "A telepítési beállítások nem érhetők el, amíg ez a példány zárolva van."
},
"instance.settings.tabs.java": {
"message": "Java és memória"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Egyéni környezeti változók"
},
@@ -593,12 +593,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Pilih kanal pembaruan"
},
"instance.settings.tabs.hooks": {
"message": "Luncurkan kait"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Luncurkan kait tersuai"
},
"instance.settings.tabs.hooks.description": {
"message": "Kait membolehkan pengguna ahli untuk menjalankan beberapa perintah sistem sebelum dan setelah meluncurkan permainan."
},
@@ -638,9 +632,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Versi {loader}"
},
"instance.settings.tabs.java": {
"message": "Java dan memori"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variabel lingkungan tersuai"
},
@@ -1226,9 +1226,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Accedi a un account Modrinth"
},
"app.nav.signed-in-as": {
"message": "Accesso effettuato come <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Abbonati a Modrinth+"
},
@@ -1241,9 +1238,6 @@
"app.navigation.home": {
"message": "Home"
},
"app.navigation.more-options": {
"message": "Altre opzioni"
},
"app.navigation.next-image": {
"message": "Immagine successiva"
},
@@ -1478,9 +1472,6 @@
"app.settings.tabs.behavior": {
"message": "Comportamento"
},
"app.settings.tabs.default-instance-options": {
"message": "Opzioni di gioco predefinite"
},
"app.settings.tabs.java-installations": {
"message": "Installazioni Java"
},
@@ -2303,12 +2294,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Seleziona il canale di aggiornamento"
},
"instance.settings.tabs.hooks": {
"message": "Hook di avvio"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Hook di avvio personalizzati"
},
"instance.settings.tabs.hooks.description": {
"message": "I hook permettono a utenti avanzati di eseguire comandi di sistema prima e dopo l'avvio del gioco."
},
@@ -2372,9 +2357,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Le impostazioni non sono disponibili mentre l'istanza è bloccata."
},
"instance.settings.tabs.java": {
"message": "Java e memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Personalizzate"
},
@@ -776,9 +776,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Modrinthアカウントにサインイン"
},
"app.nav.signed-in-as": {
"message": "<user>{username}</user>としてサインイン中"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Modrinth+にアップグレード"
},
@@ -992,9 +989,6 @@
"app.settings.tabs.behavior": {
"message": "動作"
},
"app.settings.tabs.default-instance-options": {
"message": "デフォルトのゲーム設定"
},
"app.settings.tabs.java-installations": {
"message": "Javaのインストール設定"
},
@@ -1685,12 +1679,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "アップデートチャンネルを選択"
},
"instance.settings.tabs.hooks": {
"message": "起動フック"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "カスタム起動フック"
},
"instance.settings.tabs.hooks.description": {
"message": "フックを使用して、ゲームの起動前後にシステムコマンドを実行するようにできます。"
},
@@ -1754,9 +1742,6 @@
"instance.settings.tabs.installation.locked": {
"message": "このインスタンスがロックされている間は、インストール設定を利用できません。"
},
"instance.settings.tabs.java": {
"message": "Javaとメモリ"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "カスタム環境変数"
},
@@ -1175,9 +1175,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Modrinth 계정으로 로그인"
},
"app.nav.signed-in-as": {
"message": "<user>{username}</user> 으로 로그인됨"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Modrinth+ 로 업그레이드"
},
@@ -1400,9 +1397,6 @@
"app.settings.tabs.behavior": {
"message": "동작"
},
"app.settings.tabs.default-instance-options": {
"message": "기본 게임 옵션"
},
"app.settings.tabs.java-installations": {
"message": "Java 설치"
},
@@ -1961,12 +1955,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "업데이트 채널 선택"
},
"instance.settings.tabs.hooks": {
"message": "실행 후크"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "사용자 지정 실행 후크"
},
"instance.settings.tabs.hooks.description": {
"message": "후크 기능을 사용하면 고급 사용자가 게임 실행 전후에 특정 시스템 명령을 실행할 수 있습니다."
},
@@ -2009,9 +1997,6 @@
"instance.settings.tabs.installation.locked": {
"message": "이 인스턴스가 잠긴 상태에서는 설치 설정을 사용할 수 없습니다."
},
"instance.settings.tabs.java": {
"message": "Java 및 메모리"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "사용자 지정 환경 변수"
},
@@ -665,12 +665,6 @@
"instance.settings.tabs.general.update-channel.release": {
"message": "Terbitan"
},
"instance.settings.tabs.hooks": {
"message": "Cangkuk pelancaran"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Cangkuk pelancaran tersuai"
},
"instance.settings.tabs.hooks.description": {
"message": "Cangkuk membenarkan pengguna lanjutan menjalankan perintah sistem tertentu sebelum dan selepas melancarkan permainan."
},
@@ -710,9 +704,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Versi {loader}"
},
"instance.settings.tabs.java": {
"message": "Java dan ingatan"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Pembolehubah persekitaran tersuai"
},
@@ -758,9 +758,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Meld je aan bij een Modrinth-account"
},
"app.nav.signed-in-as": {
"message": "Aangemeld als <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade naar Modrinth+"
},
@@ -974,9 +971,6 @@
"app.settings.tabs.behavior": {
"message": "Gedrag"
},
"app.settings.tabs.default-instance-options": {
"message": "Standaard spelopties"
},
"app.settings.tabs.java-installations": {
"message": "Java installaties"
},
@@ -1535,12 +1529,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Selecteer updatekanaal"
},
"instance.settings.tabs.hooks": {
"message": "Opstart haakjes"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Aangepaste opstart haakjes"
},
"instance.settings.tabs.hooks.description": {
"message": "Haakjes maken het mogelijk voor gevorderde gebruikers om bepaalde systeemcommando's uit te voeren voor en na het opstarten van het spel."
},
@@ -1604,9 +1592,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Installatie-instellingen zijn niet beschikbaar zolang deze instantie is vergrendeld."
},
"instance.settings.tabs.java": {
"message": "Java en geheugen"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Aangepaste omgevingsvariabelen"
},
@@ -362,12 +362,6 @@
"instance.settings.tabs.general.name": {
"message": "Navn"
},
"instance.settings.tabs.hooks": {
"message": "Startkroker"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Egendefinerte startkroker"
},
"instance.settings.tabs.hooks.description": {
"message": "Startkroker lar avanserte brukere køre visse systemkommandoer før og etter spillet blir startet."
},
@@ -407,9 +401,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} versjon"
},
"instance.settings.tabs.java": {
"message": "Java og minne"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Egendefinert minneallokasjon"
},
@@ -1082,18 +1082,12 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Zaloguj się na konto Modrinth"
},
"app.nav.signed-in-as": {
"message": "Zalogowano jako <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Ulepsz do Modrinth+"
},
"app.navigation.home": {
"message": "Strona główna"
},
"app.navigation.more-options": {
"message": "Więcej opcji"
},
"app.news.title": {
"message": "Aktualności"
},
@@ -1286,9 +1280,6 @@
"app.settings.tabs.behavior": {
"message": "Zachowanie"
},
"app.settings.tabs.default-instance-options": {
"message": "Domyślne opcje gry"
},
"app.settings.tabs.java-installations": {
"message": "Instalacje Java"
},
@@ -2024,12 +2015,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Wybierz kanał aktualizacji"
},
"instance.settings.tabs.hooks": {
"message": "Haczyki startowe"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Własne haczyki przy uruchomieniu"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooki pozwalają zaawansowanym użytkownikom uruchamiać określone systemowe komendy przed i po uruchomieniu gry."
},
@@ -2075,9 +2060,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Wersja {loader}"
},
"instance.settings.tabs.java": {
"message": "Java i pamięć"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Zmienne środowiskowe"
},
@@ -851,9 +851,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Iniciar sessão em uma conta Modrinth"
},
"app.nav.signed-in-as": {
"message": "Conectado como <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Assinar o Modrinth+"
},
@@ -1067,9 +1064,6 @@
"app.settings.tabs.behavior": {
"message": "Comportamento"
},
"app.settings.tabs.default-instance-options": {
"message": "Configurações padrão do jogo"
},
"app.settings.tabs.java-installations": {
"message": "Instalações Java"
},
@@ -1631,12 +1625,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Selecionar canal de atualização"
},
"instance.settings.tabs.hooks": {
"message": "Ações de inicialização"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Ações de inicialização personalizadas"
},
"instance.settings.tabs.hooks.description": {
"message": "As ações permitem usuários experientes que executem certos comandos do sistema antes ou após iniciar o jogo."
},
@@ -1700,9 +1688,6 @@
"instance.settings.tabs.installation.locked": {
"message": "As opções de instalação estarão indisponíveis enquanto esta instância estiver privada."
},
"instance.settings.tabs.java": {
"message": "Java e memória"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variáveis de ambiente personalizadas"
},
@@ -806,12 +806,6 @@
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
},
"instance.settings.tabs.hooks": {
"message": "Ganchos de inicio"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Ganchos de inicio personalizados"
},
"instance.settings.tabs.hooks.description": {
"message": "Ganchos permitem a utilizadores avançados executarem certos comandos de sistema antes e após abrir o jogo."
},
@@ -869,9 +863,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Versão {loader}"
},
"instance.settings.tabs.java": {
"message": "Java e memória"
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variáveis de ambiente"
},
@@ -347,12 +347,6 @@
"instance.settings.tabs.general.name": {
"message": "Nume"
},
"instance.settings.tabs.hooks": {
"message": "Cârlige de lansare"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Cârlige de lansare personalizate"
},
"instance.settings.tabs.hooks.description": {
"message": "Cârligele permit utilizatorilor avansați să ruleze anumite comenzi de sistem înainte și după lansarea jocului."
},
@@ -392,9 +386,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Versiune {loader}"
},
"instance.settings.tabs.java": {
"message": "Java și memorie"
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variabile de mediu"
},
@@ -1211,9 +1211,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Войти в аккаунт Modrinth"
},
"app.nav.signed-in-as": {
"message": "Текущий аккаунт: <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Перейти на Modrinth+"
},
@@ -1226,9 +1223,6 @@
"app.navigation.home": {
"message": "Главная"
},
"app.navigation.more-options": {
"message": "Дополнительно"
},
"app.navigation.next-image": {
"message": "Следующее изображение"
},
@@ -1463,9 +1457,6 @@
"app.settings.tabs.behavior": {
"message": "Поведение"
},
"app.settings.tabs.default-instance-options": {
"message": "Предустановки игры"
},
"app.settings.tabs.java-installations": {
"message": "Расположение Java"
},
@@ -2285,12 +2276,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Выберите канал обновления"
},
"instance.settings.tabs.hooks": {
"message": "Команды запуска"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Переопределение команд запуска"
},
"instance.settings.tabs.hooks.description": {
"message": "Позволяет опытным пользователям задать системные команды, выполняемые до и после запуска игры."
},
@@ -2354,9 +2339,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Параметры установки недоступны, пока сборка заблокирована."
},
"instance.settings.tabs.java": {
"message": "Java и память"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Переопределение переменных среды"
},
@@ -848,12 +848,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Izaberi kanal ažuiranja"
},
"instance.settings.tabs.hooks": {
"message": "Hookovi pokreta"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Prilagođeni hookovi za lansiranje"
},
"instance.settings.tabs.hooks.description": {
"message": "Hookovi omogućavaju naprednim korisnicima da pokreću određene sistemske komande pre i posle pokretanja igre."
},
@@ -893,9 +887,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Verzija {loader}"
},
"instance.settings.tabs.java": {
"message": "Java i radna memorija"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Prilagođene promenljive okruženja"
},
@@ -1178,9 +1178,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Logga in till ett Modrinth-konto"
},
"app.nav.signed-in-as": {
"message": "Inloggad som <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Uppgradera till Modrinth+"
},
@@ -1193,9 +1190,6 @@
"app.navigation.home": {
"message": "Hem"
},
"app.navigation.more-options": {
"message": "Mer alternativ"
},
"app.navigation.next-image": {
"message": "Nästa bild"
},
@@ -1379,9 +1373,6 @@
"app.settings.tabs.behavior": {
"message": "Beteende"
},
"app.settings.tabs.default-instance-options": {
"message": "Standard spelinställningar"
},
"app.settings.tabs.java-installations": {
"message": "Java-installationer"
},
@@ -2144,12 +2135,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Välj uppdateringskanal"
},
"instance.settings.tabs.hooks": {
"message": "Startkrokar"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Egendefinierade startkrokar"
},
"instance.settings.tabs.hooks.description": {
"message": "Krokar gör det möjligt för avancerade användare att köra vissa systemkommandon före och efter att spelet startas."
},
@@ -2204,9 +2189,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Installationsinställningar är otillgängligt medan den här instansen är låst."
},
"instance.settings.tabs.java": {
"message": "Java och minne"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Anpassade miljövariabler"
},
@@ -578,12 +578,6 @@
"instance.settings.tabs.general.name": {
"message": "ชื่อ"
},
"instance.settings.tabs.hooks": {
"message": "Launch Hook"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "กำหนด Launch Hook เอง"
},
"instance.settings.tabs.hooks.description": {
"message": "Launch Hook จะช่วยให้ผู้เล่นที่ชำนาญสามารถใช้คำสั่งต่อระบบบางอย่างได้ ทั้งก่อนและหลังเกมถูกเปิด"
},
@@ -623,9 +617,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "เวอร์ชัน {loader}"
},
"instance.settings.tabs.java": {
"message": "Java และ หน่วยความจำ"
},
"instance.settings.tabs.java.environment-variables": {
"message": "Environment Variables"
},
@@ -1232,9 +1232,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Bir Modrinth hesabıyla oturum açın"
},
"app.nav.signed-in-as": {
"message": "<user>{username}</user> olarak oturum açıldı"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Modrinth+'a yükselt"
},
@@ -1247,9 +1244,6 @@
"app.navigation.home": {
"message": "Ana Sayfa"
},
"app.navigation.more-options": {
"message": "Diğer ayarlar"
},
"app.navigation.next-image": {
"message": "Sonraki görsel"
},
@@ -1484,9 +1478,6 @@
"app.settings.tabs.behavior": {
"message": "Davranış"
},
"app.settings.tabs.default-instance-options": {
"message": "Varsayılan oyun seçenekleri"
},
"app.settings.tabs.java-installations": {
"message": "Java kurulumları"
},
@@ -2309,12 +2300,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Güncelleme kanalını seç"
},
"instance.settings.tabs.hooks": {
"message": "Başlatma kancaları"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Özel başlatma kancaları"
},
"instance.settings.tabs.hooks.description": {
"message": "Kancalar gelişmiş kullanıcıların oyunu başlattıktan önce ve sonra belirli sistem komutları çalıştırmasını sağlar."
},
@@ -2378,9 +2363,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Bu örnek kilitliyken kurulum ayarlarına erişilemez."
},
"instance.settings.tabs.java": {
"message": "Java ve bellek"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Özel ortam değişkenleri"
},
@@ -1022,18 +1022,12 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "Увійти в обліковий запис Modrinth"
},
"app.nav.signed-in-as": {
"message": "Вхід виконано як <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Оновити до Modrinth+"
},
"app.navigation.home": {
"message": "Головна"
},
"app.navigation.more-options": {
"message": "Більше налаштувань"
},
"app.navigation.next-image": {
"message": "Наступне зображення"
},
@@ -1259,9 +1253,6 @@
"app.settings.tabs.behavior": {
"message": "Поведінка"
},
"app.settings.tabs.default-instance-options": {
"message": "Стандартні параметри гри"
},
"app.settings.tabs.java-installations": {
"message": "Інсталяції Java"
},
@@ -2009,12 +2000,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Оберіть випуск оновлення"
},
"instance.settings.tabs.hooks": {
"message": "Параметри запуску"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Користувацькі параметри запуску"
},
"instance.settings.tabs.hooks.description": {
"message": "Дають змогу досвідченим користувачам застосовувати певні системні команди до та після запуску гри."
},
@@ -2078,9 +2063,6 @@
"instance.settings.tabs.installation.locked": {
"message": "Налаштування інсталяції недоступні, поки ця збірка заблокована."
},
"instance.settings.tabs.java": {
"message": "Java та пам’ять"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Власні змінні середовища"
},
@@ -938,12 +938,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "Chọn kênh cập nhật"
},
"instance.settings.tabs.hooks": {
"message": "Khởi chạy Hooks"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Hooks khởi chạy tuỳ biến"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooks cho phép người dùng nâng cao chạy câu lệnh hệ thống trước và sau khi chạy game."
},
@@ -983,9 +977,6 @@
"instance.settings.tabs.installation.loader-version": {
"message": "Phiên bản {loader}"
},
"instance.settings.tabs.java": {
"message": "Java và bộ nhớ"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Tùy chọn biến môi trường"
},
@@ -1232,9 +1232,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "登录Modrinth账户"
},
"app.nav.signed-in-as": {
"message": "以<user>{username}</user>身份登录"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "升级至Modrinth+"
},
@@ -1247,9 +1244,6 @@
"app.navigation.home": {
"message": "主页"
},
"app.navigation.more-options": {
"message": "更多选项"
},
"app.navigation.next-image": {
"message": "下一张图片"
},
@@ -1484,9 +1478,6 @@
"app.settings.tabs.behavior": {
"message": "行为"
},
"app.settings.tabs.default-instance-options": {
"message": "默认游戏选项"
},
"app.settings.tabs.java-installations": {
"message": "Java 管理"
},
@@ -2309,12 +2300,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "选择更新渠道"
},
"instance.settings.tabs.hooks": {
"message": "启动钩子"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "自定义启动钩子"
},
"instance.settings.tabs.hooks.description": {
"message": "钩子供高级用户使用,可设置游戏启动前后运行的特定系统命令。"
},
@@ -2378,9 +2363,6 @@
"instance.settings.tabs.installation.locked": {
"message": "当实例被锁定时,安装设置不可用。"
},
"instance.settings.tabs.java": {
"message": "Java 及内存"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "自定义环境变量"
},
@@ -1232,9 +1232,6 @@
"app.nav.sign-in-to-modrinth-account": {
"message": "登入 Modrinth 帳號"
},
"app.nav.signed-in-as": {
"message": "以 <user>{username}</user> 登入"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "升級至 Modrinth+"
},
@@ -1247,9 +1244,6 @@
"app.navigation.home": {
"message": "首頁"
},
"app.navigation.more-options": {
"message": "更多選項"
},
"app.navigation.next-image": {
"message": "下一張圖片"
},
@@ -1484,9 +1478,6 @@
"app.settings.tabs.behavior": {
"message": "行為"
},
"app.settings.tabs.default-instance-options": {
"message": "預設遊戲選項"
},
"app.settings.tabs.java-installations": {
"message": "Java 安裝"
},
@@ -2309,12 +2300,6 @@
"instance.settings.tabs.general.update-channel.select": {
"message": "選擇更新通道"
},
"instance.settings.tabs.hooks": {
"message": "啟動掛勾"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "自訂啟動掛勾"
},
"instance.settings.tabs.hooks.description": {
"message": "掛勾讓進階使用者能在遊戲啟動前和啟動後執行特定的系統指令。"
},
@@ -2378,9 +2363,6 @@
"instance.settings.tabs.installation.locked": {
"message": "實例被鎖定時無法使用安裝設定選項。"
},
"instance.settings.tabs.java": {
"message": "Java 和記憶體"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "自訂環境變數"
},

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