Compare commits

..
Author SHA1 Message Date
Calum H. (IMB11) a03ab1354a fix: prepr 2026-08-24 13:12:26 +01:00
Calum H. (IMB11) 0748b8732e fix: qa 2026-08-24 13:11:11 +01:00
Calum H. (IMB11) b0c77bb395 fix: qa 2026-08-24 12:46:43 +01:00
Calum H. (IMB11) cba59f9848 fix: qa 2026-08-24 12:46:11 +01:00
Calum H. (IMB11) 08901e725f fix: qa 2026-08-24 12:37:53 +01:00
Calum H. (IMB11) 0fbd431169 feat: start on content tab env focused design 2026-08-24 12:28:17 +01:00
673 changed files with 19847 additions and 33259 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@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: tombi@1.4.1
tool: tombi
- run: tombi lint
- run: tombi fmt --check
+10 -3
View File
@@ -42,7 +42,9 @@ 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 = [
@@ -59,7 +61,9 @@ 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"
@@ -159,7 +163,10 @@ 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",
+7 -10
View File
@@ -6,16 +6,13 @@ extend-exclude = [
"packages/utils/",
"packages/ui/",
"packages/blog/",
]
[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",
# 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-words]
-2
View File
@@ -32,7 +32,6 @@
"@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",
@@ -46,7 +45,6 @@
"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": {
+88 -384
View File
@@ -10,11 +10,9 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ArrowLeftRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
ImagesIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
@@ -25,25 +23,20 @@ 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,
@@ -58,20 +51,18 @@ 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, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import AccountsCard from '@/components/ui/AccountsCard.vue'
@@ -105,9 +96,8 @@ 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 { isDarkTheme, useTheme } from '@/composables/use-theme.ts'
import { 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,
@@ -118,27 +108,14 @@ import {
} from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_user_many, get_version } from '@/helpers/cache.js'
import { get_user, 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,
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 { 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 { 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,
@@ -180,10 +157,7 @@ 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,
appSettingsModalOpenSyncedOptionsKey,
} from './providers/app-settings-modal'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const appSettings = useAppSettings()
const appTheme = useTheme()
@@ -227,7 +201,6 @@ 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(
@@ -448,20 +421,10 @@ 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({
@@ -484,24 +447,7 @@ 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())
@@ -511,7 +457,6 @@ 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()
@@ -520,9 +465,7 @@ 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) {
@@ -537,6 +480,7 @@ 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' },
@@ -590,10 +534,6 @@ 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',
@@ -602,33 +542,13 @@ const messages = defineMessages({
id: 'app.nav.modrinth-account',
defaultMessage: 'Modrinth account',
},
viewProfile: {
id: 'app.nav.view-profile',
defaultMessage: 'View profile',
},
addFriend: {
id: 'friends.action.add-friend',
defaultMessage: 'Add a friend',
signedInAs: {
id: 'app.nav.signed-in-as',
defaultMessage: 'Signed in as <user>{username}</user>',
},
signInToModrinthAccount: {
id: 'app.nav.sign-in-to-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',
defaultMessage: 'Sign in to a Modrinth account',
},
restarting: {
id: 'app.restarting',
@@ -732,9 +652,6 @@ 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
@@ -749,11 +666,13 @@ 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) {
@@ -762,6 +681,8 @@ 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')
@@ -1036,7 +957,6 @@ const updateToPlayModal = ref()
const modrinthLoginModal = ref()
const appSettingsModal = ref()
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
provide(appSettingsModalOpenSyncedOptionsKey, () => appSettingsModal.value?.showSyncedOptions())
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -1064,13 +984,6 @@ 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
@@ -1160,12 +1073,10 @@ async function fetchCredentials() {
if (refreshId !== credentialsRefreshId) return
clearLiveNotifications()
await removeUser(creds.user_id).catch(handleError)
await logout().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)
@@ -1173,12 +1084,11 @@ async function fetchCredentials() {
}
credentials.value = creds ?? null
liveNotificationsEnabled = !!creds?.session
await fetchStoredModrinthAccounts()
}
async function signIn(flow = 'sign-in', addAccount = false) {
async function signIn(flow = 'sign-in') {
try {
await login(flow, addAccount)
await login(flow)
await fetchCredentials()
} catch (error) {
if (
@@ -1186,190 +1096,35 @@ async function signIn(flow = 'sign-in', addAccount = false) {
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// user closed the login window
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
}
}
async function requestSignIn(flow = 'sign-in', addAccount = false) {
await modrinthLoginModal.value?.showSigningIn(flow, addAccount)
async function requestSignIn(flow = 'sign-in') {
await modrinthLoginModal.value?.showSigningIn(flow)
}
async function requestModrinthAuth(flow = 'sign-in', addAccount = false) {
await signIn(flow, addAccount)
async function requestModrinthAuth(flow = 'sign-in') {
await signIn(flow)
return !!credentials.value?.session
}
async function logOut() {
if (!credentials.value?.user) return
await completeAccountSwitch(() => logout())
await performLogOut()
}
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]))
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
clearLiveNotifications()
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',
},
}))
await logout().catch(handleError)
await fetchCredentials()
}
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) {
@@ -1429,7 +1184,6 @@ onMounted(() => {
})
const accounts = ref(null)
const friendsList = ref(null)
provide('accountsCard', accounts)
useAppEvent('command', handleCommand, appEvents)
@@ -1957,39 +1711,12 @@ 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"
@@ -2048,7 +1775,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
"
>
<PlayIcon class="ml-0.5" />
<PlayIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
@@ -2063,13 +1790,6 @@ 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"
@@ -2099,69 +1819,59 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<SettingsIcon />
</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>
<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>
</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">
@@ -2310,11 +2020,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<suspense>
<FriendsList
ref="friendsList"
:credentials="credentials"
:sign-in="() => requestSignIn()"
/>
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
</suspense>
</div>
<PrideFundraiserBanner
@@ -2484,9 +2190,7 @@ 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);
}
@@ -2632,7 +2336,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.mac-traffic-lights {
.mac {
.app-grid-statusbar {
padding-left: 5rem;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

@@ -55,10 +55,6 @@
);
}
html.light-mode {
color-scheme: light;
}
body {
position: fixed;
width: 100%;
@@ -20,6 +20,7 @@
<div class="flex gap-2 w-full min-w-0">
<Avatar
size="36px"
disable-conditional-icon-padding
:src="
selectedAccount
? avatarUrl
@@ -34,7 +35,7 @@
</div>
</div>
</template>
<div class="bg-button-bg pt-1 pb-2 border-0 border-t border-solid border-surface-5">
<div class="bg-button-bg pt-1 pb-2 border 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
@@ -46,7 +47,11 @@
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" />
<Avatar
:src="getAccountAvatarUrl(account)"
size="24px"
disable-conditional-icon-padding
/>
<p
class="m-0 truncate min-w-0"
:class="
@@ -3,7 +3,6 @@ import {
CheckIcon,
CopyIcon,
DropdownIcon,
FolderOpenIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
@@ -14,10 +13,8 @@ import {
Button,
ButtonLink,
Collapsible,
defineMessages,
IconButton,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
@@ -28,17 +25,8 @@ 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()
@@ -135,10 +123,6 @@ function retryDirectoryChange() {
window.location.reload()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
const loadingRepair = ref(false)
async function repairInstance() {
loadingRepair.value = true
@@ -326,10 +310,6 @@ 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,9 +7,8 @@ import {
FileTreeSelect,
injectNotificationManager,
injectPopupNotificationManager,
Input,
NewModal,
Textarea,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
@@ -195,7 +194,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>
<Input
<StyledInput
v-model="nameInput"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
@@ -207,7 +206,7 @@ function normalizeExportPath(path) {
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<Input
<StyledInput
v-model="versionInput"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
@@ -220,8 +219,9 @@ function normalizeExportPath(path) {
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<Textarea
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
@@ -179,7 +179,6 @@ 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>
@@ -245,7 +244,6 @@ 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,12 +40,7 @@ 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"
pad-transparent-corners
/>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" :alt="instance.name" size="48px" />
<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">
<Input
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
@@ -98,7 +98,13 @@ import {
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, Input, useVIntl } from '@modrinth/ui'
import {
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -1,25 +1,13 @@
<script setup>
import { EyeIcon, FolderOpenIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import {
Avatar,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, 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 { 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 { getInstanceIconUrl, list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
@@ -28,9 +16,6 @@ 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()
@@ -168,11 +153,8 @@ useAppEvent('instance', async (event) => {
}
})
useAppEvent('process', checkProcesses)
onMounted(() => {
window.addEventListener('resize', updateMaxAuto)
checkProcesses()
})
onUnmounted(() => {
@@ -190,18 +172,6 @@ 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(() => {
@@ -210,69 +180,6 @@ 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>
@@ -290,7 +197,6 @@ function openContextMenu(event, instance) {
: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
@@ -298,7 +204,6 @@ function openContextMenu(event, instance) {
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'"
@@ -309,7 +214,6 @@ function openContextMenu(event, instance) {
</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" :class="`${theme.active}-mode`">
<div v-if="!doneLoading" class="splash-screen dark">
<div class="app-logo-wrapper" data-tauri-drag-region>
<svg
class="app-logo"
@@ -83,9 +83,6 @@ 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)
@@ -148,12 +145,6 @@ 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 {
@@ -175,7 +166,6 @@ useAppEvent('loading', (e) => {
align-items: center;
gap: 1rem;
color: var(--color-contrast);
z-index: 9998;
}
@@ -194,8 +184,8 @@ useAppEvent('loading', (e) => {
height: 100vh;
width: 100vw;
background:
linear-gradient(180deg, var(--splash-tint-top) 0%, var(--splash-tint-bottom) 97.29%),
linear-gradient(0deg, var(--splash-overlay), var(--splash-overlay));
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));
z-index: 9997;
}
@@ -208,19 +198,11 @@ useAppEvent('loading', (e) => {
width: 180vw;
height: 180vh;
background-color: var(--color-bg);
opacity: 0.8;
background: #16181c url('@/assets/loading/cube.png') center no-repeat;
background-size: contain;
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,38 +1,35 @@
<template>
<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
<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()"
>
<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>
<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>
</template>
<script setup>
@@ -40,7 +37,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, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings } from '@/helpers/settings.ts'
@@ -51,7 +48,6 @@ 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'))
@@ -61,26 +57,6 @@ 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()
@@ -102,11 +78,6 @@ onMounted(async () => {
})
})
onUnmounted(() => {
resizeObserver?.disconnect()
document.documentElement.style.removeProperty('--window-controls-width')
})
const handleClose = async () => {
await saveWindowState(StateFlags.ALL)
await getCurrentWindow().close()
@@ -0,0 +1,236 @@
<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>
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,170 @@
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'
}
@@ -0,0 +1,357 @@
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)
}
}
@@ -0,0 +1,59 @@
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,11 +122,6 @@ function addFriendFromModal() {
username.value = ''
}
function showAddFriendModal() {
username.value = ''
addFriendModal.value?.show()
}
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
@@ -135,8 +130,6 @@ function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
defineExpose({ showAddFriendModal })
const messages = defineMessages({
addFriend: {
id: 'friends.action.add-friend',
@@ -257,7 +250,7 @@ const messages = defineMessages({
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<Input
<StyledInput
v-model="username"
:icon="UserIcon"
type="text"
@@ -287,15 +280,14 @@ const messages = defineMessages({
>
<UserPlusIcon />
</IconButton>
<Input
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
appearance="transparent"
:placeholder="formatMessage(messages.searchFriends)"
clearable
input-class="!text-primary !placeholder:text-primary"
wrapper-class="flex-1 !border-button-bg [&>span:first-child]:!text-primary [&>span:first-child]:!opacity-100"
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"
@keyup.esc="search = ''"
/>
</template>
@@ -1,18 +1,17 @@
<script setup lang="ts">
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import type { ButtonMenuOption } from '@modrinth/ui'
import {
Accordion,
ContextMenu,
Avatar,
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()
@@ -37,30 +36,27 @@ const emit = defineEmits<{
onClose: []
}>()
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 createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
{
name: 'view-profile',
},
{
name: 'remove-friend',
color: 'danger',
},
]
} else {
return [
{
name: 'view-profile',
},
{
name: 'cancel-request',
},
]
}
}
function openProfile(username: string) {
@@ -68,6 +64,16 @@ 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: {
@@ -90,15 +96,18 @@ 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" :label="formatMessage(messages.friendActionsLabel)" />
<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>
<Accordion
:open-by-default="openByDefault"
:force-open="isSearching"
@@ -121,24 +130,32 @@ 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 select-none"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.open(event, createContextMenuOptions(friend))
(event) => friendOptions?.showMenu(event, friend, 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 group no-click-animation"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
>
<UserAvatar
:src="friend.avatar"
size="32px"
:badge="friend.online"
:grayscale="!friend.online && friend.accepted"
/>
<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>
<div class="flex flex-col">
<span
class="text-sm m-0 group-hover:underline"
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
@@ -6,7 +6,7 @@ import {
Button,
defineMessages,
injectNotificationManager,
Input,
StyledInput,
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)" />
<Input
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
@@ -121,11 +121,7 @@ async function addServer(instance) {
:to="`/instance/${encodeURIComponent(instance.id)}`"
@click="modal.hide()"
>
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
class="mr-2 [--size:2rem]"
pad-transparent-corners
/>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" class="mr-2 [--size:2rem]" />
{{ 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">
<Input
<StyledInput
v-model="groupInstancesSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder)"
@@ -51,7 +51,6 @@
:alt="instance.name"
size="2rem"
rounded="md"
pad-transparent-corners
/>
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
</div>
@@ -97,8 +96,8 @@ import {
Button,
commonMessages,
defineMessages,
Input,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
@@ -1,9 +1,23 @@
<script setup lang="ts">
import { StarIcon } from '@modrinth/assets'
import { ContextMenu, defineMessages, useVIntl } from '@modrinth/ui'
import {
ClipboardCopyIcon,
EditIcon,
EyeIcon,
FolderOpenIcon,
MinusIcon,
PaletteIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { 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'
@@ -30,9 +44,55 @@ const messages = defineMessages({
id: 'app.library.search.no-results.title',
defaultMessage: 'No instances match your search.',
},
instanceActionsLabel: {
id: 'app.library.instance.actions.label',
defaultMessage: 'Instance actions',
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',
},
})
@@ -51,6 +111,7 @@ const {
currentDeleteInstances,
clearLibraryInstanceSelection,
deleteInstance,
handleInstanceOption,
handleInstanceIconSaved,
selectedLibraryInstances,
setSelectedLibraryInstances,
@@ -310,9 +371,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
>
<InstanceGroup
:can-drag-reorder="canDragReorderGroups"
:hide-header="
instanceGroup.id === 'group:none' && visibleInstanceGroups.length === 1
"
:hide-header="visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
@@ -371,10 +430,34 @@ watch(selectedLibraryInstances, (selectedInstances) => {
:config="currentIconEditorInstance?.icon_config"
@saved="handleInstanceIconSaved"
/>
<ContextMenu :ref="setInstanceOptions" :label="formatMessage(messages.instanceActionsLabel)">
<template #remove_from_favorites="{ option }">
<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>
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
{{ option.label }}
{{ 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) }}
</template>
</ContextMenu>
</template>
@@ -12,7 +12,6 @@ import {
Accordion,
Button,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
InlineEditableText,
@@ -22,6 +21,7 @@ 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,10 +163,6 @@ 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) {
@@ -208,45 +204,49 @@ function requestGroupDeletion() {
function openGroupContextMenu(event: MouseEvent) {
groupContextMenuOpen.value = true
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,
},
])
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()
}
}
function prepareGroupToggle(event: PointerEvent) {
const editor = groupNameInput.value
const contextMenuWasOpen = groupContextMenuOpen.value
if (contextMenuWasOpen) {
groupOptions.value?.close()
groupOptions.value?.hideMenu()
}
shouldSkipGroupToggle = Boolean(
@@ -523,9 +523,16 @@ onMounted(startInstanceGridResizeObserver)
<ContextMenu
ref="groupOptions"
:label="formatMessage(messages.groupActionsLabel)"
@close="groupContextMenuOpen = false"
/>
@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>
<NewModal
ref="confirmDeleteGroupModal"
@@ -47,7 +47,6 @@ const versionRef = ref<HTMLElement | null>(null)
:tint-by="instance.id"
alt=""
no-shadow
pad-transparent-corners
/>
<slot name="loading" :compact="compactMode" />
<div
@@ -242,9 +242,6 @@ 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, Input, useVIntl } from '@modrinth/ui'
import { Button, defineMessages, StyledInput, 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">
<Input
<StyledInput
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>
<Input
<StyledInput
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">
<Input
<StyledInput
v-model="newGroupSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchInstance)"
@@ -60,7 +60,6 @@
: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>
@@ -104,7 +103,15 @@
<script setup lang="ts">
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, defineMessages, Input, NewModal, TagItem, useVIntl } from '@modrinth/ui'
import {
Avatar,
Button,
defineMessages,
NewModal,
StyledInput,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
@@ -150,7 +157,7 @@ const {
} = useLibrary()
const modal = ref<InstanceType<typeof NewModal>>()
const groupNameInput = ref<InstanceType<typeof Input>>()
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
const groupNamesById = computed(
() => new Map(libraryGroups.value.map((group) => [group.id, group.name])),
)
@@ -1,20 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
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 { 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'
@@ -113,7 +98,7 @@ export type InstanceCard = {
}
type InstanceContextMenu = {
open: (event: MouseEvent, options: ButtonMenuOption[]) => void
showMenu: (event: MouseEvent, item: InstanceCard, options: unknown[]) => void
}
type ConfirmDeleteModal = {
@@ -124,58 +109,10 @@ type IconEditorModal = {
show: () => void
}
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',
},
})
type ContextMenuSelection = {
option: string
item: InstanceCard
}
function createLibraryState(instances: Ref<GameInstance[]>) {
const { handleError } = injectNotificationManager()
@@ -223,6 +160,7 @@ 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)
@@ -1218,153 +1156,121 @@ 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,
) => {
const removableGroupId =
currentContextGroupId.value =
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
instanceOptions.value?.open(event, [
const baseOptions = [
{
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])],
),
name: item.instance.group_ids.includes(FAVORITES_GROUP_ID)
? 'remove_from_favorites'
: 'add_to_favorites',
},
{ type: 'divider' },
...(!item.instance.quarantined && !item.instance.link
? [{ name: 'add_content' }, { type: 'divider' }]
: []),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{
id: 'add_content',
label: formatMessage(instanceActionMessages.addContent),
icon: PlusIcon,
shown: canAddContent,
action: () => void item.addContent(),
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' }] : []),
],
},
{ 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),
...(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' },
{
id: 'delete',
label: formatMessage(instanceActionMessages.delete),
icon: TrashIcon,
tone: 'red',
hoverFilledOnly: true,
action: () => {
currentDeleteInstanceId.value = item.instance.id
confirmDeleteModal.value?.show()
},
},
])
}).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
}
}
return {
@@ -1433,6 +1339,7 @@ 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 InstancesSyncedSettings from '@/components/ui/settings/instances/InstancesSyncedSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.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.synced-options',
defaultMessage: 'Synced settings',
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default game options',
}),
category: tabCategories.instances,
icon: RefreshCwIcon,
content: InstancesSyncedSettings,
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
@@ -222,27 +222,7 @@ function showProfile(): void {
modal.value?.show()
}
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 })
defineExpose({ show, showProfile })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -39,7 +39,6 @@
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, addAccount?: boolean) => Promise<boolean>
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
}>()
const { formatMessage } = useVIntl()
@@ -130,7 +130,6 @@ 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)
@@ -144,9 +143,9 @@ function show(event?: MouseEvent) {
})
}
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', addAccount = false, event?: MouseEvent) {
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
const result = show(event)
authenticate(flow, addAccount)
authenticate(flow)
return result
}
@@ -155,14 +154,13 @@ function finish(signedIn: boolean) {
resolveShow = undefined
}
function authenticate(flow: ModrinthAuthFlow, addAccount = false) {
function authenticate(flow: ModrinthAuthFlow) {
const id = ++authenticationId
authenticating.value = flow
addingAccount = addAccount
const authentication = (async () => {
try {
if ((await props.requestAuth(flow, addAccount)) && authenticationId === id) {
if ((await props.requestAuth(flow)) && authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
finish(true)
@@ -190,7 +188,7 @@ async function reopenBrowser() {
try {
await cancelLogin()
await previousAuthentication?.catch(() => undefined)
if (authenticating.value === flow) authenticate(flow, addingAccount)
if (authenticating.value === flow) authenticate(flow)
} finally {
reopeningBrowser.value = false
}
@@ -1,282 +0,0 @@
<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,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
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 imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
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)
}
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = props.screenshot.url
const generation = loadGeneration
if (!loadedImage) return
try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
if (
generation !== loadGeneration ||
image.value !== loadedImage ||
props.screenshot.url !== loadedUrl
) {
return
}
const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}
function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}
onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})
watch(
() => props.screenshot.url,
(url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
},
)
</script>
<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative isolate 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-[3] 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>
<img
ref="image"
:src="screenshot.url"
:alt="screenshot.file_name"
loading="eager"
decoding="async"
draggable="false"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-button-bg"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] 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>
<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
@@ -1,96 +0,0 @@
<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>
@@ -1,65 +0,0 @@
<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>
@@ -1,243 +0,0 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, 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
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 debugLayout = useDebugLogger('Screenshots:Group')
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
let resizeObserver: ResizeObserver | undefined
async function logGeometry(reason: string) {
await nextTick()
const rect = dropTarget.value?.getBoundingClientRect()
debugLayout(reason, {
id: props.id,
title: props.title,
collapsed: collapsed.value,
shouldShowGrid: shouldShowGrid.value,
renderGrid: renderGrid.value,
screenshotCount: props.screenshots.length,
renderedCount: visibleScreenshots.value.length,
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
virtualGridHeight: props.virtualGridHeight,
virtualGridTop: props.virtualGridTop,
actual: rect
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
: undefined,
})
}
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
void logGeometry('rendered screenshots changed')
},
)
watch(
() => [props.virtualGridHeight, props.virtualGridTop] as const,
() => void logGeometry('virtual grid geometry changed'),
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
void logGeometry('grid shown')
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
void logGeometry('grid unmounted after collapse')
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onMounted(() => {
debugLayout('mounted', { id: props.id, title: props.title })
void logGeometry('mounted geometry')
if (dropTarget.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const rect = entry?.target.getBoundingClientRect()
debugLayout('actual size changed', {
id: props.id,
title: props.title,
width: rect?.width,
height: rect?.height,
expectedHeight:
(props.hideHeader ? 0 : 40) +
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
12,
})
})
resizeObserver.observe(dropTarget.value)
}
})
onBeforeUnmount(() => {
debugLayout('unmounted', {
id: props.id,
title: props.title,
renderedCount: visibleScreenshots.value.length,
})
resizeObserver?.disconnect()
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">
<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"
>
<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>
</div>
</div>
</ScreenshotSection>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import {
Accordion,
commonMessages,
defineMessages,
InlineEditableText,
TagItem,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { nextTick, onBeforeUnmount, onMounted, 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 debugLayout = useDebugLogger('Screenshots:Section')
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() {
debugLayout('toggle requested', {
title: props.title,
count: props.count,
isOpen: accordion.value?.isOpen,
})
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
accordion.value?.open()
}
}
function handleOpen() {
debugLayout('opened', { title: props.title, count: props.count })
emit('update:collapsed', false)
}
function handleClose() {
debugLayout('closed', { title: props.title, count: props.count })
emit('update:collapsed', true)
}
onMounted(() => {
debugLayout('mounted', {
title: props.title,
count: props.count,
collapsed: props.collapsed,
hideHeader: props.hideHeader,
forceOpen: props.forceOpen,
})
})
onBeforeUnmount(() => {
debugLayout('unmounted', { title: props.title, count: props.count })
})
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="handleOpen"
@on-close="handleClose"
>
<div class="mt-2.5">
<slot />
</div>
</Accordion>
</section>
</template>
@@ -1,87 +0,0 @@
<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>
@@ -1,130 +0,0 @@
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 first:mt-0 flex items-center justify-between gap-4">
<div class="mt-8 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, isDarkTheme, useTheme } from '@/composables/use-theme.ts'
import { type ColorTheme, 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,9 +60,6 @@ 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
@@ -76,10 +73,6 @@ const themeOptions = computed(() =>
),
)
const preferredDarkTheme = computed(() =>
isDarkTheme(current.value.theme) ? current.value.theme : theme.preferredDark,
)
function setTheme(value: ColorTheme): void {
current.value.theme = value
}
@@ -133,8 +126,7 @@ provideAppearanceSettings({
theme: {
current: computed(() => current.value.theme),
options: themeOptions,
system: computed(() => (theme.native === 'light' ? 'light' : preferredDarkTheme.value)),
preferredDark: preferredDarkTheme,
system: computed(() => theme.native),
set: setTheme,
syncAcrossDevices: {
value: computed(() => current.value.syncAcrossDevices),
@@ -7,7 +7,6 @@ import {
useSavable,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import {
@@ -15,13 +14,7 @@ import {
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import {
get_global_synced_options,
type GlobalSyncedOptions,
set_global_synced_option,
} from '@/helpers/instance.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { screenshotKeys } from '@/pages/instance/query-options.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const appSettings = useAppSettings()
@@ -29,7 +22,6 @@ const { formatMessage } = useVIntl()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const queryClient = useQueryClient()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const compactInstanceCardsFlag: FeatureFlag = 'compact_instance_cards'
@@ -59,14 +51,6 @@ const messages = defineMessages({
id: 'app.behavior-settings.content.title',
defaultMessage: 'Home and content',
},
showAllScreenshotsTitle: {
id: 'app.behavior-settings.show-all-screenshots.title',
defaultMessage: 'Show all screenshots together',
},
showAllScreenshotsDescription: {
id: 'app.behavior-settings.show-all-screenshots.description',
defaultMessage: 'View screenshots from all your instances on the Screenshots page.',
},
confirmationsTitle: {
id: 'app.behavior-settings.confirmations.title',
defaultMessage: 'Confirmations',
@@ -153,7 +137,6 @@ type BehaviorSettingsState = {
minimizeApp: boolean
hideRightSidebar: boolean
showJumpIn: boolean
showAllScreenshots: boolean
compactInstanceCards: boolean
showPlayTime: boolean
hideNametag: boolean
@@ -161,23 +144,14 @@ type BehaviorSettingsState = {
skipNonEssentialWarnings: boolean
}
const [initialSettings, initialGlobalSyncedOptions] = await Promise.all([
get(),
get_global_synced_options(),
])
const persistedSettings = ref(initialSettings)
const persistedGlobalSyncedOptions = ref(initialGlobalSyncedOptions)
const persistedSettings = ref(await get())
function getBehaviorSettingsState(
settings: AppSettings,
globalSyncedOptions: GlobalSyncedOptions,
): BehaviorSettingsState {
function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState {
return {
syncBehaviorAcrossDevices: settings.sync_behavior_across_devices,
minimizeApp: settings.hide_on_process_start,
hideRightSidebar: settings.toggle_sidebar,
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
showAllScreenshots: globalSyncedOptions.screenshots,
compactInstanceCards:
settings.feature_flags[compactInstanceCardsFlag] ??
DEFAULT_FEATURE_FLAGS[compactInstanceCardsFlag],
@@ -195,7 +169,7 @@ function getBehaviorSettingsState(
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getBehaviorSettingsState(persistedSettings.value, persistedGlobalSyncedOptions.value),
() => getBehaviorSettingsState(persistedSettings.value),
async () => {
const value = current.value
@@ -230,20 +204,8 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
},
}
const screenshotsChanged =
value.showAllScreenshots !== persistedGlobalSyncedOptions.value.screenshots
const [, updatedGlobalSyncedOptions] = await Promise.all([
set(nextSettings),
screenshotsChanged
? set_global_synced_option('screenshots', value.showAllScreenshots)
: Promise.resolve(persistedGlobalSyncedOptions.value),
])
await set(nextSettings)
persistedSettings.value = nextSettings
persistedGlobalSyncedOptions.value = updatedGlobalSyncedOptions
queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions)
if (screenshotsChanged) {
await queryClient.invalidateQueries({ queryKey: screenshotKeys.all })
}
appSettings.setBehaviorSyncAcrossDevices(value.syncBehaviorAcrossDevices)
appSettings.toggleSidebar = value.hideRightSidebar
appSettings.hideNametagSkinsPage = value.hideNametag
@@ -340,18 +302,6 @@ onBeforeUnmount(() => {
{{ formatMessage(messages.contentTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showAllScreenshotsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.showAllScreenshotsDescription) }}
</p>
</div>
<Toggle id="show-all-screenshots" v-model="current.showAllScreenshots" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { SearchIcon } from '@modrinth/assets'
import { Button, Input, Toggle } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import {
DEFAULT_FEATURE_FLAGS,
@@ -14,22 +12,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
const appSettings = useAppSettings()
const settings = ref(await getSettings())
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)
})
const options = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
function setFeatureFlag(key: FeatureFlag, value: boolean) {
appSettings.featureFlags[key] = value
@@ -46,14 +29,7 @@ watch(
</script>
<template>
<div class="flex flex-col gap-2.5">
<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 v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
@@ -74,8 +50,5 @@ watch(
/>
</div>
</div>
<p v-if="filteredFlags.length === 0" class="text-center text-secondary">
No flags found matching "{{ searchQuery }}"
</p>
</div>
</template>
@@ -0,0 +1,362 @@
<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>
@@ -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>
<Input
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
@@ -193,7 +193,7 @@ async function findLauncherDir() {
<FolderSearchIcon aria-hidden="true" />
</IconButton>
</template>
</Input>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDirectoryDescription) }}
</p>
@@ -16,7 +16,6 @@
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,9 +12,7 @@ import {
Avatar,
BulletDivider,
Button,
type ButtonMenuOption,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
SmartClickable,
@@ -26,7 +24,7 @@ import {
} from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import type { Dayjs } from 'dayjs'
import { computed, onMounted, ref, useTemplateRef } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
@@ -82,10 +80,6 @@ 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)
@@ -150,149 +144,115 @@ 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>
<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>
<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>
<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>
<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 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"
<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,
}"
>
<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>
<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>
</SmartClickable>
<ContextMenu ref="cardOptions" :label="formatMessage(messages.instanceActions)" />
</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>
</template>
<style scoped>
.clickable-card:has([data-no-card-click]:hover) {
@@ -18,13 +18,12 @@ import {
UserIcon,
XIcon,
} from '@modrinth/assets'
import type { ButtonMenuOption, MessageDescriptor } from '@modrinth/ui'
import type { MessageDescriptor } from '@modrinth/ui'
import {
Avatar,
BulletDivider,
Button,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
SmartClickable,
@@ -39,7 +38,7 @@ import { getPingLevel } from '@modrinth/utils'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { getInstanceIconUrl } from '@/helpers/instance'
@@ -67,7 +66,7 @@ const router = useRouter()
const { addNotification } = injectNotificationManager()
const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete' | 'desync'): void
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>()
@@ -95,8 +94,6 @@ const props = withDefaults(
}
managed?: boolean
showPlayButton?: boolean
cardBackground?: 'raised' | 'surface-2'
// Instance
instanceId?: string
@@ -119,8 +116,6 @@ const props = withDefaults(
gameMode: undefined,
managed: false,
showPlayButton: true,
cardBackground: 'raised',
instanceId: undefined,
instanceName: undefined,
@@ -222,10 +217,6 @@ 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',
@@ -234,10 +225,6 @@ 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',
@@ -270,387 +257,358 @@ 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>
<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) }}
<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>
</template>
<template v-else-if="serverStatus">
<template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange">
<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.incompatibleVersion, {
version: serverStatus.version?.name,
formatMessage(messages.playersOnline, {
count: formatNumber(serverStatus.players?.online ?? 0),
})
}}
</span>
</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 #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>
<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>
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(gameMode.message) }}
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
{{ formatMessage(messages.offline) }}
</template>
</template>
</div>
</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')"
<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,
}"
>
<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>
<template v-if="world.last_played">
{{ formatRelativeTime(dayjs(world.last_played).toISOString()) }}
</template>
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
</div>
</div>
</div>
</SmartClickable>
<ContextMenu ref="cardOptions" :label="formatMessage(messages.worldActions)" />
</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>
</template>
<style scoped lang="scss">
.clickable-card:has([data-no-card-click]:hover) {
@@ -16,7 +16,6 @@ const { formatMessage } = useVIntl()
const props = defineProps<{
world: World | null
otherSyncedInstanceCount: number
}>()
const emit = defineEmits<{
@@ -45,11 +44,6 @@ 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:
@@ -68,9 +62,6 @@ 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,
@@ -111,13 +102,6 @@ defineExpose({ show, hide })
>
{{ formatMessage(warningBodyMessage) }}
</Admonition>
<p v-if="isSyncedServer" class="m-0 text-secondary">
{{
formatMessage(messages.syncedServerRemovalNotice, {
count: otherSyncedInstanceCount,
})
}}
</p>
</div>
<template #actions>
@@ -1,74 +0,0 @@
<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,
Input,
StyledInput,
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>
<Input
<StyledInput
v-model="name"
:placeholder="formatMessage(messages.placeholderName)"
autocomplete="off"
@@ -1,5 +1,11 @@
<script setup lang="ts">
import { Combobox, defineMessages, Input, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import {
Combobox,
defineMessages,
type MessageDescriptor,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { ServerPackStatus } from '@/helpers/worlds.ts'
@@ -59,7 +65,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>
<Input
<StyledInput
v-model="name"
:placeholder="formatMessage(messages.placeholderName)"
autocomplete="off"
@@ -68,7 +74,7 @@ defineExpose({ resourcePackOptions })
</label>
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.address) }}</span>
<Input
<StyledInput
v-model="address"
:placeholder="formatMessage(messages.placeholderAddress)"
autocomplete="off"
@@ -1,14 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ClipboardCopyIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import type { ButtonMenuOption, CardAction } from '@modrinth/ui'
import { CheckIcon, PlayIcon, PlusIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import type { 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'
@@ -34,7 +26,16 @@ interface BrowseServerInstance {
}
interface ContextMenuHandle {
open: (event: MouseEvent, options: ButtonMenuOption[]) => void
showMenu: (
event: MouseEvent,
result: Labrinth.Search.v3.ResultSearchProject,
options: { name: string }[],
) => void
}
interface ContextMenuOptionClick {
option: 'open_link' | 'copy_link'
item: Labrinth.Search.v3.ResultSearchProject
}
export interface UseAppServerBrowseOptions {
@@ -274,21 +275,19 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}
function handleRightClick(event: MouseEvent, result: Labrinth.Search.v3.ResultSearchProject) {
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),
},
])
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
}
}
useAppEvent('process', (event) => {
@@ -315,6 +314,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
}
}
@@ -22,7 +22,6 @@ 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
+4 -100
View File
@@ -1,107 +1,28 @@
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_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 preferred = ref<ColorTheme>('dark')
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<NativeTheme>(nativeThemeQuery.matches ? 'dark' : 'light')
const native = ref<Theme>(nativeThemeQuery.matches ? 'dark' : 'light')
const active = computed<Theme>(() => {
const selectedTheme = preview.value ?? preferred.value
if (selectedTheme !== 'system') {
return selectedTheme
}
return native.value === 'light' ? 'light' : preferredDark.value
return selectedTheme === 'system' ? native.value : selectedTheme
})
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, previousTheme) => {
if (previousTheme && previousTheme !== theme) {
prepareThemeColorTransition()
}
(theme) => {
const html = document.documentElement
for (const option of THEME_OPTIONS) {
html.classList.remove(`${option}-mode`)
@@ -111,31 +32,14 @@ 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": "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" });
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" });
@@ -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: "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 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 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,17 +351,13 @@ 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 5:
case 4:
return {
tag: "server_joined",
value: {
@@ -370,18 +366,18 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
timestamp: d.deserialize_string()
}
};
case 6:
case 5:
return {
tag: "edited"
};
case 7:
case 6:
return {
tag: "content_install_finished",
value: {
project_ids: d.deserialize_array(() => d.deserialize_string())
}
};
case 8:
case 7:
return {
tag: "content_install_failed",
value: {
@@ -389,7 +385,7 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
message: d.deserialize_string()
}
};
case 9:
case 8:
return {
tag: "removed"
};
@@ -1,29 +0,0 @@
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]
}
-215
View File
@@ -167,221 +167,6 @@ 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,
baseInstanceId?: string,
): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_set_global_synced_option', {
option,
enabled,
baseInstanceId,
})
}
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
+2 -17
View File
@@ -14,11 +14,8 @@ export type ModrinthCredentials = {
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
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 login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow })
}
export async function logout(): Promise<void> {
@@ -29,18 +26,6 @@ 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,12 +16,6 @@ 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
+33 -14
View File
@@ -29,18 +29,12 @@ 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 = {
@@ -50,14 +44,6 @@ 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
@@ -274,6 +260,17 @@ 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)
*/
@@ -310,6 +307,28 @@ 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,6 +1154,9 @@
"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+"
},
@@ -1166,6 +1169,9 @@
"app.navigation.home": {
"message": "الصفحة الرئيسية"
},
"app.navigation.more-options": {
"message": "المزيد من الخيارات"
},
"app.navigation.next-image": {
"message": "الصورة التالية"
},
@@ -1400,6 +1406,9 @@
"app.settings.tabs.behavior": {
"message": "السلوك"
},
"app.settings.tabs.default-instance-options": {
"message": "خيارات اللعبة الافتراضية"
},
"app.settings.tabs.java-installations": {
"message": "تثبيتات جافا"
},
@@ -2207,6 +2216,12 @@
"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": "إجراءات التشغيل المضافة تسمح للمستخدمين المتقدمين بإنشاء أوامر نظامية محددة قبل و عند تشغيل اللعبة."
},
@@ -2270,6 +2285,9 @@
"instance.settings.tabs.installation.locked": {
"message": "إعدادات التثبيت غير متاحة أثناء قفل هذه النسخة."
},
"instance.settings.tabs.java": {
"message": "جافا والذاكرة"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "متغيرات البيئة المخصصة"
},
@@ -1151,6 +1151,9 @@
"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+"
},
@@ -1163,6 +1166,9 @@
"app.navigation.home": {
"message": "Domov"
},
"app.navigation.more-options": {
"message": "Více možností"
},
"app.navigation.next-image": {
"message": "Další obrázek"
},
@@ -1691,6 +1697,12 @@
"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."
},
@@ -1730,6 +1742,9 @@
"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,6 +881,12 @@
"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."
},
@@ -920,6 +926,9 @@
"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,6 +1232,9 @@
"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"
},
@@ -1244,6 +1247,9 @@
"app.navigation.home": {
"message": "Start"
},
"app.navigation.more-options": {
"message": "Weitere Optionen"
},
"app.navigation.next-image": {
"message": "Nächstes Bild"
},
@@ -1478,6 +1484,9 @@
"app.settings.tabs.behavior": {
"message": "Verhalten"
},
"app.settings.tabs.default-instance-options": {
"message": "Standard-Spieloptionen"
},
"app.settings.tabs.java-installations": {
"message": "Java Installationen"
},
@@ -2300,6 +2309,12 @@
"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."
},
@@ -2363,6 +2378,9 @@
"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,6 +1232,9 @@
"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"
},
@@ -1244,6 +1247,9 @@
"app.navigation.home": {
"message": "Start"
},
"app.navigation.more-options": {
"message": "Weitere Optionen"
},
"app.navigation.next-image": {
"message": "Nächstes Bild"
},
@@ -1478,6 +1484,9 @@
"app.settings.tabs.behavior": {
"message": "Verhalten"
},
"app.settings.tabs.default-instance-options": {
"message": "Standard-Spieloptionen"
},
"app.settings.tabs.java-installations": {
"message": "Java-Installationen"
},
@@ -2300,6 +2309,12 @@
"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."
},
@@ -2363,6 +2378,9 @@
"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"
},
+23 -365
View File
@@ -215,12 +215,6 @@
"app.behavior-settings.content.title": {
"message": "Home and content"
},
"app.behavior-settings.show-all-screenshots.description": {
"message": "View screenshots from all your instances on the Screenshots page."
},
"app.behavior-settings.show-all-screenshots.title": {
"message": "Show all screenshots together"
},
"app.behavior-settings.startup-and-navigation.title": {
"message": "Startup and navigation"
},
@@ -266,9 +260,6 @@
"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"
},
@@ -296,9 +287,6 @@
"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..."
},
@@ -326,9 +314,6 @@
"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"
},
@@ -479,9 +464,6 @@
"app.instance.action.stop": {
"message": "Stop"
},
"app.instance.actions.label": {
"message": "Instance actions"
},
"app.instance.add-server.add": {
"message": "Add"
},
@@ -764,9 +746,6 @@
"app.instance.tab.logs": {
"message": "Logs"
},
"app.instance.tab.screenshots": {
"message": "Screenshots"
},
"app.instance.tab.share": {
"message": "Share"
},
@@ -815,9 +794,6 @@
"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"
},
@@ -875,9 +851,6 @@
"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"
},
@@ -926,9 +899,6 @@
"app.library.group-by.none": {
"message": "No grouping"
},
"app.library.group.actions.label": {
"message": "Group actions"
},
"app.library.group.collapse": {
"message": "Collapse group"
},
@@ -1052,9 +1022,6 @@
"app.library.instance.action.view-instance": {
"message": "View instance"
},
"app.library.instance.actions.label": {
"message": "Instance actions"
},
"app.library.instance.deselect": {
"message": "Deselect instance"
},
@@ -1250,42 +1217,27 @@
"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.remove-account": {
"message": "Remove account"
},
"app.nav.screenshots": {
"message": "Screenshots"
},
"app.nav.sign-in-to-modrinth-account": {
"message": "Sign into Modrinth"
"message": "Sign in to a Modrinth account"
},
"app.nav.switch-account": {
"message": "Switch account"
"app.nav.signed-in-as": {
"message": "Signed in as <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"app.nav.view-profile": {
"message": "View profile"
},
"app.navigation.go-back": {
"message": "Go back"
},
@@ -1295,6 +1247,9 @@
"app.navigation.home": {
"message": "Home"
},
"app.navigation.more-options": {
"message": "More options"
},
"app.navigation.next-image": {
"message": "Next image"
},
@@ -1307,9 +1262,6 @@
"app.notification.warning": {
"message": "Warning"
},
"app.project.actions.label": {
"message": "Project actions"
},
"app.project.coming-soon": {
"message": "Coming soon"
},
@@ -1352,177 +1304,15 @@
"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}"
},
@@ -1550,9 +1340,6 @@
"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."
},
@@ -1562,9 +1349,6 @@
"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."
},
@@ -1598,9 +1382,6 @@
"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."
},
@@ -1697,69 +1478,15 @@
"app.settings.sidebar.label.instances": {
"message": "Instances"
},
"app.settings.synced-options.choose-sync-source.command-history-description": {
"message": "Pick the instance whose command history becomes the shared copy."
},
"app.settings.synced-options.choose-sync-source.creative-hotbars-description": {
"message": "Pick the instance whose saved creative hotbars become the shared copy."
},
"app.settings.synced-options.choose-sync-source.multiplayer-servers-description": {
"message": "Pick the instance whose multiplayer servers become the shared copy."
},
"app.settings.synced-options.choose-sync-source.no-instances-found": {
"message": "No instances found"
},
"app.settings.synced-options.choose-sync-source.search-placeholder": {
"message": "Search instance"
},
"app.settings.synced-options.choose-sync-source.sync": {
"message": "Sync"
},
"app.settings.synced-options.choose-sync-source.title": {
"message": "Choose a sync source"
},
"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.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"
},
@@ -1772,9 +1499,6 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.settings.tabs.synced-options": {
"message": "Synced settings"
},
"app.sidebar.playing-as": {
"message": "Playing as"
},
@@ -2084,9 +1808,6 @@
"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"
},
@@ -2588,6 +2309,12 @@
"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."
},
@@ -2651,17 +2378,20 @@
"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": "Use custom environment variables for this instance."
"message": "Custom environment variables"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Use custom Java arguments for this instance."
"message": "Custom Java arguments"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Use a custom Java installation for this instance."
"message": "Custom Java installation"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Use a custom memory allocation for this instance."
"message": "Custom memory allocation"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Enter environmental variables..."
@@ -2687,65 +2417,14 @@
"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.override-description": {
"message": "Keep this instance's command history separate from synced command history."
},
"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.override-description": {
"message": "Keep this instance's saved creative hotbars separate from synced hotbars."
},
"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.override-description": {
"message": "Keep this instance's multiplayer servers separate from synced servers."
},
"instance.settings.tabs.synced-options.open-app-settings": {
"message": "Manage synced settings"
},
"instance.settings.tabs.synced-options.shared-settings.description": {
"message": "Enable an override to keep a synced setting separate for this instance."
},
"instance.settings.tabs.window": {
"message": "Window"
},
"instance.settings.tabs.window.custom-window-settings": {
"message": "Use custom window settings for this instance."
"message": "Custom window settings"
},
"instance.settings.tabs.window.fullscreen": {
"message": "Fullscreen"
@@ -2831,9 +2510,6 @@
"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"
},
@@ -2843,21 +2519,6 @@
"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"
},
@@ -2891,9 +2552,6 @@
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.play_world": {
"message": "Play world"
},
"instance.worlds.shortcut-created": {
"message": "Shortcut created"
},
@@ -1232,6 +1232,9 @@
"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+"
},
@@ -1244,6 +1247,9 @@
"app.navigation.home": {
"message": "Inicio"
},
"app.navigation.more-options": {
"message": "Más opciones"
},
"app.navigation.next-image": {
"message": "Siguiente imágen"
},
@@ -1478,6 +1484,9 @@
"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"
},
@@ -2300,6 +2309,12 @@
"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."
},
@@ -2363,6 +2378,9 @@
"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,6 +1202,9 @@
"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+"
},
@@ -1214,6 +1217,9 @@
"app.navigation.home": {
"message": "Inicio"
},
"app.navigation.more-options": {
"message": "Más opciones"
},
"app.navigation.next-image": {
"message": "Siguiente imagen"
},
@@ -1412,6 +1418,9 @@
"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"
},
@@ -2228,6 +2237,12 @@
"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."
},
@@ -2285,6 +2300,9 @@
"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,6 +728,12 @@
"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."
},
@@ -767,6 +773,9 @@
"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,6 +494,12 @@
"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."
},
@@ -533,6 +539,9 @@
"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,6 +1196,9 @@
"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+"
},
@@ -1208,6 +1211,9 @@
"app.navigation.home": {
"message": "Accueil"
},
"app.navigation.more-options": {
"message": "Plus d'options"
},
"app.navigation.next-image": {
"message": "Prochaine image"
},
@@ -1439,6 +1445,9 @@
"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"
},
@@ -2156,6 +2165,12 @@
"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."
},
@@ -2219,6 +2234,9 @@
"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,6 +389,12 @@
"instance.settings.tabs.general.name": {
"message": "שם"
},
"instance.settings.tabs.hooks": {
"message": "פעולות בעת הפעלה"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "פעולות בעת הפעלה מותאמות אישית"
},
"instance.settings.tabs.hooks.description": {
"message": "פעולות בעת הפעלה מאפשרות למשתמשים מתקדמים להריץ פקודות מערכת מסוימות לפני ואחרי שהמשחק מופעל."
},
@@ -428,6 +434,9 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} גרסה"
},
"instance.settings.tabs.java": {
"message": "ג'אווה וזיכרון"
},
"instance.settings.tabs.java.environment-variables": {
"message": "משתני סביבה"
},
@@ -983,6 +983,9 @@
"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"
},
@@ -1214,6 +1217,9 @@
"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"
},
@@ -1814,6 +1820,12 @@
"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."
},
@@ -1868,6 +1880,9 @@
"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,6 +593,12 @@
"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."
},
@@ -632,6 +638,9 @@
"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,6 +1226,9 @@
"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+"
},
@@ -1238,6 +1241,9 @@
"app.navigation.home": {
"message": "Home"
},
"app.navigation.more-options": {
"message": "Altre opzioni"
},
"app.navigation.next-image": {
"message": "Immagine successiva"
},
@@ -1472,6 +1478,9 @@
"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"
},
@@ -2294,6 +2303,12 @@
"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."
},
@@ -2357,6 +2372,9 @@
"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,6 +776,9 @@
"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+にアップグレード"
},
@@ -989,6 +992,9 @@
"app.settings.tabs.behavior": {
"message": "動作"
},
"app.settings.tabs.default-instance-options": {
"message": "デフォルトのゲーム設定"
},
"app.settings.tabs.java-installations": {
"message": "Javaのインストール設定"
},
@@ -1679,6 +1685,12 @@
"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": "フックを使用して、ゲームの起動前後にシステムコマンドを実行するようにできます。"
},
@@ -1742,6 +1754,9 @@
"instance.settings.tabs.installation.locked": {
"message": "このインスタンスがロックされている間は、インストール設定を利用できません。"
},
"instance.settings.tabs.java": {
"message": "Javaとメモリ"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "カスタム環境変数"
},
@@ -1175,6 +1175,9 @@
"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+ 로 업그레이드"
},
@@ -1397,6 +1400,9 @@
"app.settings.tabs.behavior": {
"message": "동작"
},
"app.settings.tabs.default-instance-options": {
"message": "기본 게임 옵션"
},
"app.settings.tabs.java-installations": {
"message": "Java 설치"
},
@@ -1955,6 +1961,12 @@
"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": "후크 기능을 사용하면 고급 사용자가 게임 실행 전후에 특정 시스템 명령을 실행할 수 있습니다."
},
@@ -1997,6 +2009,9 @@
"instance.settings.tabs.installation.locked": {
"message": "이 인스턴스가 잠긴 상태에서는 설치 설정을 사용할 수 없습니다."
},
"instance.settings.tabs.java": {
"message": "Java 및 메모리"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "사용자 지정 환경 변수"
},
@@ -665,6 +665,12 @@
"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."
},
@@ -704,6 +710,9 @@
"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,6 +758,9 @@
"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+"
},
@@ -971,6 +974,9 @@
"app.settings.tabs.behavior": {
"message": "Gedrag"
},
"app.settings.tabs.default-instance-options": {
"message": "Standaard spelopties"
},
"app.settings.tabs.java-installations": {
"message": "Java installaties"
},
@@ -1529,6 +1535,12 @@
"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."
},
@@ -1592,6 +1604,9 @@
"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,6 +362,12 @@
"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."
},
@@ -401,6 +407,9 @@
"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,12 +1082,18 @@
"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"
},
@@ -1280,6 +1286,9 @@
"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"
},
@@ -2015,6 +2024,12 @@
"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."
},
@@ -2060,6 +2075,9 @@
"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,6 +851,9 @@
"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+"
},
@@ -1064,6 +1067,9 @@
"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"
},
@@ -1625,6 +1631,12 @@
"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."
},
@@ -1688,6 +1700,9 @@
"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,6 +806,12 @@
"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."
},
@@ -863,6 +869,9 @@
"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,6 +347,12 @@
"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."
},
@@ -386,6 +392,9 @@
"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,6 +1211,9 @@
"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+"
},
@@ -1223,6 +1226,9 @@
"app.navigation.home": {
"message": "Главная"
},
"app.navigation.more-options": {
"message": "Дополнительно"
},
"app.navigation.next-image": {
"message": "Следующее изображение"
},
@@ -1457,6 +1463,9 @@
"app.settings.tabs.behavior": {
"message": "Поведение"
},
"app.settings.tabs.default-instance-options": {
"message": "Предустановки игры"
},
"app.settings.tabs.java-installations": {
"message": "Расположение Java"
},
@@ -2276,6 +2285,12 @@
"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": "Позволяет опытным пользователям задать системные команды, выполняемые до и после запуска игры."
},
@@ -2339,6 +2354,9 @@
"instance.settings.tabs.installation.locked": {
"message": "Параметры установки недоступны, пока сборка заблокирована."
},
"instance.settings.tabs.java": {
"message": "Java и память"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Переопределение переменных среды"
},
@@ -848,6 +848,12 @@
"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."
},
@@ -887,6 +893,9 @@
"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,6 +1178,9 @@
"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+"
},
@@ -1190,6 +1193,9 @@
"app.navigation.home": {
"message": "Hem"
},
"app.navigation.more-options": {
"message": "Mer alternativ"
},
"app.navigation.next-image": {
"message": "Nästa bild"
},
@@ -1373,6 +1379,9 @@
"app.settings.tabs.behavior": {
"message": "Beteende"
},
"app.settings.tabs.default-instance-options": {
"message": "Standard spelinställningar"
},
"app.settings.tabs.java-installations": {
"message": "Java-installationer"
},
@@ -2135,6 +2144,12 @@
"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."
},
@@ -2189,6 +2204,9 @@
"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,6 +578,12 @@
"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 จะช่วยให้ผู้เล่นที่ชำนาญสามารถใช้คำสั่งต่อระบบบางอย่างได้ ทั้งก่อนและหลังเกมถูกเปิด"
},
@@ -617,6 +623,9 @@
"instance.settings.tabs.installation.loader-version": {
"message": "เวอร์ชัน {loader}"
},
"instance.settings.tabs.java": {
"message": "Java และ หน่วยความจำ"
},
"instance.settings.tabs.java.environment-variables": {
"message": "Environment Variables"
},
@@ -1232,6 +1232,9 @@
"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"
},
@@ -1244,6 +1247,9 @@
"app.navigation.home": {
"message": "Ana Sayfa"
},
"app.navigation.more-options": {
"message": "Diğer ayarlar"
},
"app.navigation.next-image": {
"message": "Sonraki görsel"
},
@@ -1478,6 +1484,9 @@
"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ı"
},
@@ -2300,6 +2309,12 @@
"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."
},
@@ -2363,6 +2378,9 @@
"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,12 +1022,18 @@
"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": "Наступне зображення"
},
@@ -1253,6 +1259,9 @@
"app.settings.tabs.behavior": {
"message": "Поведінка"
},
"app.settings.tabs.default-instance-options": {
"message": "Стандартні параметри гри"
},
"app.settings.tabs.java-installations": {
"message": "Інсталяції Java"
},
@@ -2000,6 +2009,12 @@
"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": "Дають змогу досвідченим користувачам застосовувати певні системні команди до та після запуску гри."
},
@@ -2063,6 +2078,9 @@
"instance.settings.tabs.installation.locked": {
"message": "Налаштування інсталяції недоступні, поки ця збірка заблокована."
},
"instance.settings.tabs.java": {
"message": "Java та пам’ять"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Власні змінні середовища"
},
@@ -938,6 +938,12 @@
"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."
},
@@ -977,6 +983,9 @@
"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,6 +1232,9 @@
"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+"
},
@@ -1244,6 +1247,9 @@
"app.navigation.home": {
"message": "主页"
},
"app.navigation.more-options": {
"message": "更多选项"
},
"app.navigation.next-image": {
"message": "下一张图片"
},
@@ -1478,6 +1484,9 @@
"app.settings.tabs.behavior": {
"message": "行为"
},
"app.settings.tabs.default-instance-options": {
"message": "默认游戏选项"
},
"app.settings.tabs.java-installations": {
"message": "Java 管理"
},
@@ -2300,6 +2309,12 @@
"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": "钩子供高级用户使用,可设置游戏启动前后运行的特定系统命令。"
},
@@ -2363,6 +2378,9 @@
"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