mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 18:45:15 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d47594302 | ||
|
|
8a0d683ae2 | ||
|
|
ffd864a9e2 | ||
|
|
263aaedc8f | ||
|
|
d3b5ba2567 | ||
|
|
7c67cca7a7 | ||
|
|
2bd108c278 | ||
|
|
25fb06ce27 | ||
|
|
e0cd019869 | ||
|
|
b68a199814 | ||
|
|
3439a43880 | ||
|
|
c88efd48af | ||
|
|
529c6f14dd | ||
|
|
c3c6760818 | ||
|
|
36f9dbb47a | ||
|
|
715f4954cf | ||
|
|
ffdf7ff745 | ||
|
|
a921cfdb7a | ||
|
|
1007b7b69e | ||
|
|
c28ec83060 | ||
|
|
7701aef048 | ||
|
|
7420560784 | ||
|
|
e33ef5f252 | ||
|
|
bb35950340 | ||
|
|
c67f980c4a | ||
|
|
80607c1963 | ||
|
|
ad83117ebf | ||
|
|
4b5edb9a99 | ||
|
|
e753801e0c | ||
|
|
aed00aab13 | ||
|
|
8d7e491126 |
@@ -20,8 +20,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
- uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
|
||||
with:
|
||||
tool: tombi
|
||||
tool: tombi@1.4.1
|
||||
- run: tombi lint
|
||||
- run: tombi fmt --check
|
||||
|
||||
+3
-10
@@ -42,9 +42,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
] }
|
||||
async-trait = "0.1.89"
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = [
|
||||
"futures-03-sink"
|
||||
] }
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
|
||||
async-walkdir = "2.1.0"
|
||||
async_zip = "0.0.18"
|
||||
aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
||||
@@ -61,9 +59,7 @@ censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
chumsky = "0.9.3"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
|
||||
clap = "4.5.48"
|
||||
clickhouse = "0.14.0"
|
||||
color-eyre = "0.6.5"
|
||||
@@ -163,10 +159,7 @@ redis = "1.4.1"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
rust_decimal = { version = "1.39.0", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str"
|
||||
] }
|
||||
rust_decimal = { version = "1.39.0", features = ["serde-with-float", "serde-with-str"] }
|
||||
rust_iso3166 = "0.1.14"
|
||||
rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
|
||||
+10
-7
@@ -6,13 +6,16 @@ extend-exclude = [
|
||||
"packages/utils/",
|
||||
"packages/ui/",
|
||||
"packages/blog/",
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
"packages/moderation/src/utils.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts/mod.rs",
|
||||
# contains domain names with deliberate typos
|
||||
"apps/labrinth/assets/disposable_email_blocklist.txt"
|
||||
]
|
||||
|
||||
[default]
|
||||
extend-ignore-re = [
|
||||
# Ignore lines that end with `// typos:ignore`
|
||||
"(?Rm)^.*(#|//)\\s*typos:ignore$",
|
||||
# Ignore the line after `// typos:ignore`:
|
||||
"(#|//)\\s*typos:ignore\\n.*",
|
||||
# Ignore blocks between `// typos:off` and `// typos:on`
|
||||
"(?s)(#|//)\\s*typos:off.*?\\n\\s*(#|//)\\s*typos:on",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.43.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
@@ -45,6 +46,7 @@
|
||||
"vue-i18n": "^10.0.0",
|
||||
"vue-router": "^4.6.0",
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+384
-88
@@ -10,9 +10,11 @@ import {
|
||||
} from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ArrowLeftRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
ImagesIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
NewspaperIcon,
|
||||
@@ -23,20 +25,25 @@ import {
|
||||
ServerStackIcon,
|
||||
SettingsIcon,
|
||||
ShirtIcon,
|
||||
SpinnerIcon,
|
||||
ToggleRightIcon,
|
||||
UserIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
AccountSwitchOverlay,
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonLink,
|
||||
commonMessages,
|
||||
commonSettingsMessages,
|
||||
ContentInstallModal,
|
||||
ContentUpdaterModal,
|
||||
CreationFlowModal,
|
||||
defineMessages,
|
||||
I18nDebugPanel,
|
||||
IconButton,
|
||||
IntlFormatted,
|
||||
LoadingBar,
|
||||
NewsArticleCard,
|
||||
NotificationPanel,
|
||||
@@ -51,18 +58,20 @@ import {
|
||||
useDebugLogger,
|
||||
useFormatBytes,
|
||||
useHostingIntercom,
|
||||
UserRoleIcon,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { type } from '@tauri-apps/plugin-os'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
@@ -96,8 +105,9 @@ import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { useError } from '@/composables/use-error.js'
|
||||
import { useTheme } from '@/composables/use-theme.ts'
|
||||
import { isDarkTheme, useTheme } from '@/composables/use-theme.ts'
|
||||
import { config } from '@/config'
|
||||
import { getAccountAppearance, rememberAccountAppearance } from '@/helpers/account-appearance.ts'
|
||||
import {
|
||||
hide_ads_window,
|
||||
init_ads_window,
|
||||
@@ -108,14 +118,27 @@ import {
|
||||
} from '@/helpers/ads.js'
|
||||
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user, get_version } from '@/helpers/cache.js'
|
||||
import { get_user, get_user_many, get_version } from '@/helpers/cache.js'
|
||||
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
||||
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import {
|
||||
can_current_user_use_shared_instances,
|
||||
get as getInstance,
|
||||
get_global_synced_options,
|
||||
run,
|
||||
} from '@/helpers/instance'
|
||||
import {
|
||||
get as getCreds,
|
||||
getAll as getAllCreds,
|
||||
login,
|
||||
logout,
|
||||
removeUser,
|
||||
setActive,
|
||||
} from '@/helpers/mr_auth.ts'
|
||||
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
|
||||
import { get_user_preferences } from '@/helpers/user-preferences.ts'
|
||||
import { parse_modrinth_user_link } from '@/helpers/users'
|
||||
import {
|
||||
areUpdatesEnabled,
|
||||
@@ -157,7 +180,10 @@ import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { AppNotificationManager } from './providers/app-notifications'
|
||||
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
|
||||
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
|
||||
import {
|
||||
appSettingsModalOpenProfileKey,
|
||||
appSettingsModalOpenSyncedOptionsKey,
|
||||
} from './providers/app-settings-modal'
|
||||
|
||||
const appSettings = useAppSettings()
|
||||
const appTheme = useTheme()
|
||||
@@ -201,6 +227,7 @@ const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
|
||||
const credentials = ref()
|
||||
const storedModrinthAccounts = ref([])
|
||||
let credentialsRefreshId = 0
|
||||
const sidebarToggled = ref(true)
|
||||
watch(
|
||||
@@ -421,10 +448,20 @@ const os = ref('')
|
||||
const isDevEnvironment = ref(false)
|
||||
|
||||
const stateInitialized = ref(false)
|
||||
const globalSyncedOptionsQuery = useQuery({
|
||||
queryKey: ['global-synced-options'],
|
||||
queryFn: get_global_synced_options,
|
||||
enabled: computed(() => stateInitialized.value),
|
||||
})
|
||||
|
||||
const criticalErrorMessage = ref()
|
||||
|
||||
const isMaximized = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
watch([os, isFullscreen], ([osName, fullscreen]) => {
|
||||
document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen)
|
||||
})
|
||||
|
||||
const authUnreachableDebug = useDebugLogger('AuthReachableChecker')
|
||||
const authServerQuery = useQuery({
|
||||
@@ -447,7 +484,24 @@ const authUnreachable = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
let unlistenEditMenu
|
||||
|
||||
function handleEditMenuAction(action) {
|
||||
const event = new CustomEvent(`edit-menu:${action}`, { cancelable: true })
|
||||
if (document.dispatchEvent(event)) document.execCommand(action)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const listeners = await Promise.all([
|
||||
listen('edit-menu://undo', () => handleEditMenuAction('undo')),
|
||||
listen('edit-menu://redo', () => handleEditMenuAction('redo')),
|
||||
])
|
||||
unlistenEditMenu = () => listeners.forEach((unlisten) => unlisten())
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
await useCheckDisableMouseover()
|
||||
try {
|
||||
handleAdsConsentRequired(await should_show_ads_consent_popup())
|
||||
@@ -457,6 +511,7 @@ onMounted(async () => {
|
||||
|
||||
document.querySelector('body').addEventListener('click', handleClick)
|
||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||
document.querySelector('body').addEventListener('contextmenu', handleContextMenu)
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
|
||||
checkUpdates()
|
||||
@@ -465,7 +520,9 @@ onMounted(async () => {
|
||||
onUnmounted(async () => {
|
||||
document.querySelector('body').removeEventListener('click', handleClick)
|
||||
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
||||
document.querySelector('body').removeEventListener('contextmenu', handleContextMenu)
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
unlistenEditMenu?.()
|
||||
clearDelayedUpdatePopup()
|
||||
|
||||
if (fullscreenAdsWindowHold) {
|
||||
@@ -480,7 +537,6 @@ const formatBytes = useFormatBytes()
|
||||
|
||||
const messages = defineMessages({
|
||||
warning: { id: 'app.notification.warning', defaultMessage: 'Warning' },
|
||||
moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' },
|
||||
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
|
||||
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
|
||||
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
|
||||
@@ -534,6 +590,10 @@ const messages = defineMessages({
|
||||
id: 'app.nav.modrinth-hosting',
|
||||
defaultMessage: 'Modrinth Hosting',
|
||||
},
|
||||
screenshots: {
|
||||
id: 'app.nav.screenshots',
|
||||
defaultMessage: 'Screenshots',
|
||||
},
|
||||
createNewInstance: {
|
||||
id: 'app.nav.create-new-instance',
|
||||
defaultMessage: 'Create new instance',
|
||||
@@ -542,13 +602,33 @@ const messages = defineMessages({
|
||||
id: 'app.nav.modrinth-account',
|
||||
defaultMessage: 'Modrinth account',
|
||||
},
|
||||
signedInAs: {
|
||||
id: 'app.nav.signed-in-as',
|
||||
defaultMessage: 'Signed in as <user>{username}</user>',
|
||||
viewProfile: {
|
||||
id: 'app.nav.view-profile',
|
||||
defaultMessage: 'View profile',
|
||||
},
|
||||
addFriend: {
|
||||
id: 'friends.action.add-friend',
|
||||
defaultMessage: 'Add a friend',
|
||||
},
|
||||
signInToModrinthAccount: {
|
||||
id: 'app.nav.sign-in-to-modrinth-account',
|
||||
defaultMessage: 'Sign in to a Modrinth account',
|
||||
defaultMessage: 'Sign into Modrinth',
|
||||
},
|
||||
loadingProfile: {
|
||||
id: 'app.nav.loading-profile',
|
||||
defaultMessage: 'Loading profile...',
|
||||
},
|
||||
switchAccount: {
|
||||
id: 'app.nav.switch-account',
|
||||
defaultMessage: 'Switch account',
|
||||
},
|
||||
addAccount: {
|
||||
id: 'app.nav.add-account',
|
||||
defaultMessage: 'Add account',
|
||||
},
|
||||
removeAccount: {
|
||||
id: 'app.nav.remove-account',
|
||||
defaultMessage: 'Remove account',
|
||||
},
|
||||
restarting: {
|
||||
id: 'app.restarting',
|
||||
@@ -652,6 +732,9 @@ async function setupApp() {
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
|
||||
Object.assign(appSettings.featureFlags, feature_flags)
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
isFullscreen.value = await getCurrentWindow().isFullscreen()
|
||||
os.value = await getOS()
|
||||
const dev = await isDev()
|
||||
isDevEnvironment.value = dev
|
||||
@@ -666,13 +749,11 @@ async function setupApp() {
|
||||
appSettings.hideNametagSkinsPage = hide_nametag_skins_page
|
||||
appSettings.toggleSidebar = toggle_sidebar
|
||||
appSettings.devMode = developer_mode
|
||||
Object.assign(appSettings.featureFlags, feature_flags)
|
||||
stateInitialized.value = true
|
||||
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
|
||||
await getCurrentWindow().onResized(async () => {
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
isFullscreen.value = await getCurrentWindow().isFullscreen()
|
||||
})
|
||||
|
||||
if (telemetry) {
|
||||
@@ -681,8 +762,6 @@ async function setupApp() {
|
||||
trackEvent('Launched', { version, dev })
|
||||
}
|
||||
|
||||
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
|
||||
|
||||
const osType = await type()
|
||||
if (osType === 'macos') {
|
||||
document.getElementsByTagName('html')[0].classList.add('mac')
|
||||
@@ -957,6 +1036,7 @@ const updateToPlayModal = ref()
|
||||
const modrinthLoginModal = ref()
|
||||
const appSettingsModal = ref()
|
||||
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
|
||||
provide(appSettingsModalOpenSyncedOptionsKey, () => appSettingsModal.value?.showSyncedOptions())
|
||||
|
||||
watch(incompatibilityWarningModal, (modal) => {
|
||||
if (modal) {
|
||||
@@ -984,6 +1064,13 @@ watch(
|
||||
.then(async () => {
|
||||
const settings = await getSettings()
|
||||
const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme
|
||||
const userId = credentials.value?.user_id ?? credentials.value?.user?.id
|
||||
if (userId) {
|
||||
rememberAccountAppearance(userId, preferences.appearance)
|
||||
}
|
||||
if (appTheme.syncAcrossDevices && isDarkTheme(preferences.appearance.theme)) {
|
||||
appTheme.preferredDark = preferences.appearance.theme
|
||||
}
|
||||
const locale = preferences.localization.locale
|
||||
const behavior = preferences.behavior
|
||||
let settingsChanged = false
|
||||
@@ -1073,10 +1160,12 @@ async function fetchCredentials() {
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
|
||||
clearLiveNotifications()
|
||||
await logout().catch(handleError)
|
||||
await removeUser(creds.user_id).catch(handleError)
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
|
||||
credentials.value = null
|
||||
liveNotificationsEnabled = false
|
||||
await fetchStoredModrinthAccounts()
|
||||
return
|
||||
}
|
||||
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
|
||||
@@ -1084,11 +1173,12 @@ async function fetchCredentials() {
|
||||
}
|
||||
credentials.value = creds ?? null
|
||||
liveNotificationsEnabled = !!creds?.session
|
||||
await fetchStoredModrinthAccounts()
|
||||
}
|
||||
|
||||
async function signIn(flow = 'sign-in') {
|
||||
async function signIn(flow = 'sign-in', addAccount = false) {
|
||||
try {
|
||||
await login(flow)
|
||||
await login(flow, addAccount)
|
||||
await fetchCredentials()
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -1096,35 +1186,190 @@ async function signIn(flow = 'sign-in') {
|
||||
typeof error['message'] === 'string' &&
|
||||
error.message.includes('Login canceled')
|
||||
) {
|
||||
// Not really an error due to being a result of user interaction, show nothing
|
||||
// user closed the login window
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSignIn(flow = 'sign-in') {
|
||||
await modrinthLoginModal.value?.showSigningIn(flow)
|
||||
async function requestSignIn(flow = 'sign-in', addAccount = false) {
|
||||
await modrinthLoginModal.value?.showSigningIn(flow, addAccount)
|
||||
}
|
||||
|
||||
async function requestModrinthAuth(flow = 'sign-in') {
|
||||
await signIn(flow)
|
||||
async function requestModrinthAuth(flow = 'sign-in', addAccount = false) {
|
||||
await signIn(flow, addAccount)
|
||||
return !!credentials.value?.session
|
||||
}
|
||||
|
||||
async function logOut() {
|
||||
await performLogOut()
|
||||
if (!credentials.value?.user) return
|
||||
await completeAccountSwitch(() => logout())
|
||||
}
|
||||
|
||||
async function performLogOut() {
|
||||
credentialsRefreshId++
|
||||
credentials.value = undefined
|
||||
clearLiveNotifications()
|
||||
async function fetchStoredModrinthAccounts() {
|
||||
const all = (await getAllCreds().catch(handleError)) ?? []
|
||||
const ids = all.map((account) => account.user_id)
|
||||
const users = ids.length ? ((await get_user_many(ids).catch(handleError)) ?? []) : []
|
||||
const usersById = new Map(users.map((user) => [user.id, user]))
|
||||
|
||||
await logout().catch(handleError)
|
||||
await fetchCredentials()
|
||||
storedModrinthAccounts.value = all.map((account) => ({
|
||||
...account,
|
||||
user: usersById.get(account.user_id) ?? {
|
||||
id: account.user_id,
|
||||
username: account.user_id,
|
||||
avatar_url: null,
|
||||
role: 'developer',
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const accountSwitcherAccounts = computed(() => {
|
||||
const currentId = credentials.value?.session ? credentials.value.user_id : null
|
||||
|
||||
return storedModrinthAccounts.value.map((account) => ({
|
||||
...account,
|
||||
optionId: `account-${account.user_id}`,
|
||||
current: account.user_id === currentId,
|
||||
}))
|
||||
})
|
||||
|
||||
const profileButtonTooltip = computed(() => {
|
||||
if (credentials.value === undefined) return formatMessage(messages.loadingProfile)
|
||||
if (credentials.value?.user) return formatMessage(messages.modrinthAccount)
|
||||
return formatMessage(messages.signInToModrinthAccount)
|
||||
})
|
||||
|
||||
const accountSwitcherOptions = computed(() => [
|
||||
...accountSwitcherAccounts.value.map((account) => ({
|
||||
id: account.optionId,
|
||||
label: account.user.username,
|
||||
selected: account.current,
|
||||
action: () => switchModrinthAccount(account),
|
||||
trailingAction: {
|
||||
label: formatMessage(messages.removeAccount),
|
||||
icon: XIcon,
|
||||
color: 'red',
|
||||
action: () => forgetModrinthAccount(account.user_id),
|
||||
},
|
||||
})),
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'add-account',
|
||||
label: formatMessage(messages.addAccount),
|
||||
icon: PlusIcon,
|
||||
action: () => requestSignIn('sign-in', true),
|
||||
},
|
||||
])
|
||||
|
||||
const isSwitchingAccount = ref(false)
|
||||
|
||||
async function persistAppearanceTheme(appearance) {
|
||||
const selectedTheme = appearance.auto ? 'system' : appearance.theme
|
||||
appTheme.applyAccountAppearance(appearance)
|
||||
const settings = await getSettings()
|
||||
if (settings.theme !== selectedTheme) {
|
||||
settings.theme = selectedTheme
|
||||
await setSettings(settings)
|
||||
}
|
||||
}
|
||||
|
||||
async function completeAccountSwitch(task) {
|
||||
isSwitchingAccount.value = true
|
||||
await nextTick()
|
||||
try {
|
||||
await task()
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
isSwitchingAccount.value = false
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function switchModrinthAccount(account) {
|
||||
if (account.current) return
|
||||
|
||||
const cached = getAccountAppearance(account.user_id)
|
||||
if (cached) await persistAppearanceTheme(cached)
|
||||
await nextTick()
|
||||
|
||||
await completeAccountSwitch(async () => {
|
||||
await setActive(account.user_id)
|
||||
if (cached) return
|
||||
|
||||
try {
|
||||
const preferences = await get_user_preferences(account.user_id)
|
||||
rememberAccountAppearance(account.user_id, preferences.appearance)
|
||||
await persistAppearanceTheme(preferences.appearance)
|
||||
await nextTick()
|
||||
} catch {
|
||||
// no saved appearance for this account
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function forgetModrinthAccount(userId) {
|
||||
const isCurrent = credentials.value?.user_id === userId && !!credentials.value?.session
|
||||
if (isCurrent) {
|
||||
await completeAccountSwitch(() => removeUser(userId))
|
||||
return
|
||||
}
|
||||
|
||||
await removeUser(userId).catch(handleError)
|
||||
await fetchStoredModrinthAccounts()
|
||||
}
|
||||
|
||||
const modrinthAccountMenuOptions = computed(() => [
|
||||
{
|
||||
id: 'view-profile',
|
||||
label: formatMessage(messages.viewProfile),
|
||||
icon: UserIcon,
|
||||
action: () => router.push(`/user/${encodeURIComponent(credentials.value.user.username)}`),
|
||||
},
|
||||
{
|
||||
id: 'plus',
|
||||
label: formatMessage(messages.upgradeToModrinthPlus),
|
||||
icon: ArrowBigUpDashIcon,
|
||||
type: 'link',
|
||||
href: 'https://modrinth.plus?app',
|
||||
target: '_blank',
|
||||
tone: 'purple',
|
||||
shown: !hasPlus.value,
|
||||
},
|
||||
{
|
||||
id: 'add-friend',
|
||||
label: formatMessage(messages.addFriend),
|
||||
icon: UserPlusIcon,
|
||||
action: () => friendsList.value?.showAddFriendModal(),
|
||||
},
|
||||
{
|
||||
id: 'flags',
|
||||
label: formatMessage(commonSettingsMessages.featureFlags),
|
||||
icon: ToggleRightIcon,
|
||||
shown: appSettings.devMode,
|
||||
action: () => appSettingsModal.value?.showFeatureFlags(),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'switch-account',
|
||||
label: formatMessage(messages.switchAccount),
|
||||
icon: ArrowLeftRightIcon,
|
||||
type: 'submenu',
|
||||
options: accountSwitcherOptions.value,
|
||||
},
|
||||
{
|
||||
id: 'sign-out',
|
||||
label: formatMessage(commonMessages.signOutButton),
|
||||
icon: LogOutIcon,
|
||||
tone: 'red',
|
||||
action: () => logOut(),
|
||||
},
|
||||
])
|
||||
|
||||
async function fetchIntercomToken() {
|
||||
const creds = await getCreds()
|
||||
if (!creds?.session) {
|
||||
@@ -1184,6 +1429,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const accounts = ref(null)
|
||||
const friendsList = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
useAppEvent('command', handleCommand, appEvents)
|
||||
@@ -1711,12 +1957,39 @@ function handleAuxClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(event) {
|
||||
const target = event.target
|
||||
if (target instanceof Element) {
|
||||
if (target.closest('img, textarea, [contenteditable="true"]')) return
|
||||
const input = target.closest('input')
|
||||
if (
|
||||
input &&
|
||||
!['button', 'checkbox', 'radio', 'submit', 'reset', 'file', 'range'].includes(input.type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (
|
||||
target instanceof Node &&
|
||||
selection &&
|
||||
!selection.isCollapsed &&
|
||||
selection.containsNode(target, true)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<div id="teleports"></div>
|
||||
<AccountSwitchOverlay :show="isSwitchingAccount" />
|
||||
<div
|
||||
v-if="stateInitialized"
|
||||
class="app-grid-layout relative"
|
||||
@@ -1775,7 +2048,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
|
||||
"
|
||||
>
|
||||
<PlayIcon />
|
||||
<PlayIcon class="ml-0.5" />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
|
||||
@@ -1790,6 +2063,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
|
||||
<ShirtIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-if="globalSyncedOptionsQuery.data.value?.screenshots"
|
||||
v-tooltip.right="formatMessage(messages.screenshots)"
|
||||
to="/screenshots"
|
||||
>
|
||||
<ImagesIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.modrinthHosting)"
|
||||
to="/hosting/manage"
|
||||
@@ -1819,59 +2099,69 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
<TeleportOverflowMenu
|
||||
v-if="credentials?.user"
|
||||
v-tooltip.right="formatMessage(messages.modrinthAccount)"
|
||||
type="quiet"
|
||||
size="xl"
|
||||
:label="formatMessage(messages.moreOptions)"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
label: formatMessage(messages.signedInAs, {
|
||||
username: credentials.user.username,
|
||||
}),
|
||||
action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`),
|
||||
},
|
||||
{
|
||||
id: 'sign-out',
|
||||
label: formatMessage(commonMessages.signOutButton),
|
||||
tone: 'red',
|
||||
action: () => logOut(),
|
||||
},
|
||||
]"
|
||||
placement="right-end"
|
||||
:distance="4"
|
||||
>
|
||||
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<IntlFormatted
|
||||
:message-id="messages.signedInAs"
|
||||
:values="{ username: credentials?.user?.username }"
|
||||
>
|
||||
<template #user="{ children }">
|
||||
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
|
||||
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</template>
|
||||
<template #sign-out>
|
||||
<LogOutIcon />
|
||||
{{ formatMessage(commonMessages.signOutButton) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
<NavButton
|
||||
v-else
|
||||
v-tooltip.right="formatMessage(messages.signInToModrinthAccount)"
|
||||
:to="() => requestSignIn()"
|
||||
>
|
||||
<LogInIcon class="text-brand" />
|
||||
</NavButton>
|
||||
<span v-tooltip.right="profileButtonTooltip" class="inline-flex">
|
||||
<IconButton
|
||||
v-if="credentials === undefined"
|
||||
type="quiet"
|
||||
size="xl"
|
||||
disabled
|
||||
class="pointer-events-none"
|
||||
:label="formatMessage(messages.loadingProfile)"
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</IconButton>
|
||||
<TeleportOverflowMenu
|
||||
v-else-if="credentials?.user"
|
||||
type="quiet"
|
||||
size="xl"
|
||||
:label="formatMessage(messages.modrinthAccount)"
|
||||
:options="modrinthAccountMenuOptions"
|
||||
placement="right-end"
|
||||
:distance="4"
|
||||
class="brightness-100 hover:!brightness-100 focus-visible:!brightness-100"
|
||||
>
|
||||
<Avatar
|
||||
:src="credentials?.user?.avatar_url"
|
||||
alt=""
|
||||
size="32px"
|
||||
circle
|
||||
no-shadow
|
||||
class="pointer-events-none !size-8"
|
||||
/>
|
||||
<template
|
||||
v-for="account in accountSwitcherAccounts"
|
||||
:key="account.user_id"
|
||||
#[account.optionId]
|
||||
>
|
||||
<Avatar :src="account.user.avatar_url" size="1.25rem" aria-hidden="true" circle />
|
||||
{{ account.user.username }}
|
||||
<UserRoleIcon :role="account.user.role" />
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
<TeleportOverflowMenu
|
||||
v-else-if="accountSwitcherAccounts.length > 0"
|
||||
type="quiet"
|
||||
size="xl"
|
||||
:label="formatMessage(messages.signInToModrinthAccount)"
|
||||
:options="accountSwitcherOptions"
|
||||
placement="right-end"
|
||||
:distance="4"
|
||||
>
|
||||
<LogInIcon class="!text-brand" />
|
||||
<template
|
||||
v-for="account in accountSwitcherAccounts"
|
||||
:key="account.user_id"
|
||||
#[account.optionId]
|
||||
>
|
||||
<Avatar :src="account.user.avatar_url" size="1.25rem" aria-hidden="true" circle />
|
||||
{{ account.user.username }}
|
||||
<UserRoleIcon :role="account.user.role" />
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
<NavButton v-else :to="() => requestSignIn()">
|
||||
<LogInIcon class="text-brand" />
|
||||
</NavButton>
|
||||
</span>
|
||||
</div>
|
||||
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
||||
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
|
||||
@@ -2020,7 +2310,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<suspense>
|
||||
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
|
||||
<FriendsList
|
||||
ref="friendsList"
|
||||
:credentials="credentials"
|
||||
:sign-in="() => requestSignIn()"
|
||||
/>
|
||||
</suspense>
|
||||
</div>
|
||||
<PrideFundraiserBanner
|
||||
@@ -2190,7 +2484,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
background: var(--brand-gradient-bg);
|
||||
|
||||
--color-button-bg: var(--brand-gradient-button);
|
||||
--surface-4: var(--brand-gradient-button);
|
||||
--color-button-bg-hover: var(--brand-gradient-border);
|
||||
--surface-5: var(--brand-gradient-border);
|
||||
--color-divider: var(--brand-gradient-border);
|
||||
--color-divider-dark: var(--brand-gradient-border);
|
||||
}
|
||||
@@ -2336,7 +2632,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
--os-handle-bg-active: var(--color-scrollbar) !important;
|
||||
}
|
||||
|
||||
.mac {
|
||||
.mac-traffic-lights {
|
||||
.app-grid-statusbar {
|
||||
padding-left: 5rem;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
@@ -55,6 +55,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
html.light-mode {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
<div class="flex gap-2 w-full min-w-0">
|
||||
<Avatar
|
||||
size="36px"
|
||||
disable-conditional-icon-padding
|
||||
:src="
|
||||
selectedAccount
|
||||
? avatarUrl
|
||||
@@ -35,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="bg-button-bg pt-1 pb-2 border border-solid border-surface-5">
|
||||
<div class="bg-button-bg pt-1 pb-2 border-0 border-t border-solid border-surface-5">
|
||||
<template v-if="accounts.length > 0">
|
||||
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
|
||||
<button
|
||||
@@ -47,11 +46,7 @@
|
||||
class="w-5 h-5 text-brand shrink-0"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
|
||||
<Avatar
|
||||
:src="getAccountAvatarUrl(account)"
|
||||
size="24px"
|
||||
disable-conditional-icon-padding
|
||||
/>
|
||||
<Avatar :src="getAccountAvatarUrl(account)" size="24px" />
|
||||
<p
|
||||
class="m-0 truncate min-w-0"
|
||||
:class="
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DropdownIcon,
|
||||
FolderOpenIcon,
|
||||
HammerIcon,
|
||||
LogInIcon,
|
||||
UpdatedIcon,
|
||||
@@ -13,8 +14,10 @@ import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
Collapsible,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -25,8 +28,17 @@ import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
openBackupsFolder: {
|
||||
id: 'app.error.state-init.open-backups-folder',
|
||||
defaultMessage: 'Open backups folder',
|
||||
},
|
||||
})
|
||||
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
@@ -123,6 +135,10 @@ function retryDirectoryChange() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
async function openDbBackupsFolder() {
|
||||
await showAppDbBackupsFolder().catch(handleError)
|
||||
}
|
||||
|
||||
const loadingRepair = ref(false)
|
||||
async function repairInstance() {
|
||||
loadingRepair.value = true
|
||||
@@ -310,6 +326,10 @@ async function copyToClipboard(text) {
|
||||
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
|
||||
>
|
||||
{{ debugInfo }}
|
||||
<button class="btn" @click="openDbBackupsFolder">
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.openBackupsFolder) }}
|
||||
</button>
|
||||
</div>
|
||||
<IconButton
|
||||
v-tooltip="'Copy debug info'"
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
FileTreeSelect,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
Input,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
Textarea,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
@@ -194,7 +195,7 @@ function normalizeExportPath(path) {
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="nameInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
@@ -206,7 +207,7 @@ function normalizeExportPath(path) {
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.versionNumberLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="versionInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
@@ -219,9 +220,8 @@ function normalizeExportPath(path) {
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
<Textarea
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
@@ -179,6 +179,7 @@ onMounted(() => {
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:tint-by="instance.id"
|
||||
:alt="formatMessage(messages.instanceIcon)"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
@@ -244,6 +245,7 @@ onMounted(() => {
|
||||
:tint-by="instance.id"
|
||||
:alt="formatMessage(messages.instanceIcon)"
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
|
||||
@@ -40,7 +40,12 @@ const instanceLink = computed(() => {
|
||||
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
|
||||
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar :src="getInstanceIconUrl(instance.icon_path)" :alt="instance.name" size="48px" />
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:alt="instance.name"
|
||||
size="48px"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<span class="flex flex-col gap-2">
|
||||
<span class="font-extrabold bold text-contrast">
|
||||
{{ instance.name }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
|
||||
<div :id="props.id" class="toggle-setting" :class="{ compact }">
|
||||
<div class="input-with-status">
|
||||
<StyledInput
|
||||
<Input
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
@@ -98,13 +98,7 @@ import {
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { Button, defineMessages, injectNotificationManager, Input, useVIntl } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
<script setup>
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { EyeIcon, FolderOpenIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
commonMessages,
|
||||
ContextMenu,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { getInstanceIconUrl, list } from '@/helpers/instance'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { getInstanceIconUrl, kill, list, run } from '@/helpers/instance'
|
||||
import { get_all } from '@/helpers/process'
|
||||
import { showInstanceInFolder } from '@/helpers/utils'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const ITEM_SIZE = 52
|
||||
@@ -16,6 +28,9 @@ const STORAGE_KEY = 'modrinth-quick-instance-count'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const instanceOptions = ref()
|
||||
const runningInstances = ref([])
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -153,8 +168,11 @@ useAppEvent('instance', async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
useAppEvent('process', checkProcesses)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateMaxAuto)
|
||||
checkProcesses()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -172,6 +190,18 @@ const messages = defineMessages({
|
||||
id: 'app.quick-instance-switcher.drag-show-tooltip',
|
||||
defaultMessage: 'Drag to show recent instances',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'app.quick-instance-switcher.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
instanceActions: {
|
||||
id: 'app.quick-instance-switcher.actions.label',
|
||||
defaultMessage: 'Instance actions',
|
||||
},
|
||||
instanceLocked: {
|
||||
id: 'app.quick-instance-switcher.instance-locked',
|
||||
defaultMessage: 'This instance has been locked',
|
||||
},
|
||||
})
|
||||
|
||||
const dividerTooltip = computed(() => {
|
||||
@@ -180,6 +210,69 @@ const dividerTooltip = computed(() => {
|
||||
}
|
||||
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
|
||||
})
|
||||
|
||||
async function checkProcesses() {
|
||||
const processes = (await get_all().catch(handleError)) ?? []
|
||||
runningInstances.value = processes.map((process) => process.instance_id)
|
||||
}
|
||||
|
||||
async function playInstance(instance) {
|
||||
if (instance.quarantined || instance.install_stage !== 'installed') return
|
||||
await run(instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'QuickInstanceSwitcher',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function stopInstance(instance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'QuickInstanceSwitcher',
|
||||
})
|
||||
}
|
||||
|
||||
function openContextMenu(event, instance) {
|
||||
const playing = runningInstances.value.includes(instance.id)
|
||||
instanceOptions.value?.open(event, [
|
||||
playing
|
||||
? {
|
||||
id: 'stop',
|
||||
label: formatMessage(commonMessages.stopButton),
|
||||
icon: StopCircleIcon,
|
||||
tone: 'red',
|
||||
action: () => stopInstance(instance),
|
||||
}
|
||||
: {
|
||||
id: 'play',
|
||||
label: formatMessage(commonMessages.playButton),
|
||||
icon: PlayIcon,
|
||||
tone: 'brand',
|
||||
disabled: instance.quarantined || instance.install_stage !== 'installed',
|
||||
tooltip: instance.quarantined ? formatMessage(messages.instanceLocked) : undefined,
|
||||
action: () => playInstance(instance),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
id: 'open-instance',
|
||||
label: formatMessage(messages.viewInstance),
|
||||
icon: EyeIcon,
|
||||
action: () => router.push(`/instance/${encodeURIComponent(instance.id)}`),
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: formatMessage(commonMessages.openFolderButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => showInstanceInFolder(instance.id),
|
||||
},
|
||||
])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -197,6 +290,7 @@ const dividerTooltip = computed(() => {
|
||||
:key="instance.id"
|
||||
v-tooltip.right="instance.name"
|
||||
class="quick-instance-item"
|
||||
@contextmenu.prevent.stop="(event) => openContextMenu(event, instance)"
|
||||
>
|
||||
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
|
||||
<Avatar
|
||||
@@ -204,6 +298,7 @@ const dividerTooltip = computed(() => {
|
||||
size="28px"
|
||||
:tint-by="instance.id"
|
||||
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div
|
||||
v-if="instance.install_stage !== 'installed'"
|
||||
@@ -214,6 +309,7 @@ const dividerTooltip = computed(() => {
|
||||
</NavButton>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<ContextMenu ref="instanceOptions" :label="formatMessage(messages.instanceActions)" />
|
||||
<div
|
||||
v-tooltip.right="dividerTooltip"
|
||||
class="flex items-center justify-center py-2 select-none"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Transition name="splash-fade" @after-leave="onAfterLeave">
|
||||
<div v-if="!doneLoading" class="splash-screen dark">
|
||||
<div v-if="!doneLoading" class="splash-screen" :class="`${theme.active}-mode`">
|
||||
<div class="app-logo-wrapper" data-tauri-drag-region>
|
||||
<svg
|
||||
class="app-logo"
|
||||
@@ -83,6 +83,9 @@ import { ref, watch } from 'vue'
|
||||
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { useTheme } from '@/composables/use-theme.ts'
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
const doneLoading = ref(false)
|
||||
const loadingProgress = ref(0)
|
||||
@@ -145,6 +148,12 @@ useAppEvent('loading', (e) => {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
|
||||
--splash-cube-image: url('@/assets/loading/cube.png');
|
||||
|
||||
&.light-mode {
|
||||
--splash-cube-image: url('@/assets/loading/cube-light.webp');
|
||||
}
|
||||
}
|
||||
|
||||
.splash-fade-leave-active {
|
||||
@@ -166,6 +175,7 @@ useAppEvent('loading', (e) => {
|
||||
align-items: center;
|
||||
|
||||
gap: 1rem;
|
||||
color: var(--color-contrast);
|
||||
|
||||
z-index: 9998;
|
||||
}
|
||||
@@ -184,8 +194,8 @@ useAppEvent('loading', (e) => {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(66, 131, 92, 0.275) 0%, rgba(17, 35, 43, 0.5) 97.29%),
|
||||
linear-gradient(0deg, rgba(22, 24, 28, 0.64), rgba(22, 24, 28, 0.64));
|
||||
linear-gradient(180deg, var(--splash-tint-top) 0%, var(--splash-tint-bottom) 97.29%),
|
||||
linear-gradient(0deg, var(--splash-overlay), var(--splash-overlay));
|
||||
z-index: 9997;
|
||||
}
|
||||
|
||||
@@ -198,11 +208,19 @@ useAppEvent('loading', (e) => {
|
||||
|
||||
width: 180vw;
|
||||
height: 180vh;
|
||||
opacity: 0.8;
|
||||
background: #16181c url('@/assets/loading/cube.png') center no-repeat;
|
||||
background-size: contain;
|
||||
background-color: var(--color-bg);
|
||||
|
||||
z-index: 9996;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--splash-cube-image) center no-repeat;
|
||||
background-size: contain;
|
||||
opacity: var(--splash-cube-opacity);
|
||||
mix-blend-mode: var(--splash-cube-blend);
|
||||
}
|
||||
}
|
||||
|
||||
.base-bg {
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="showControls"
|
||||
class="flex items-center gap-2 mr-1.5"
|
||||
data-tauri-drag-region-exclude
|
||||
>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Minimize window"
|
||||
class="relative expanded-button"
|
||||
@click="() => getCurrentWindow().minimize()"
|
||||
<Teleport to="body">
|
||||
<section
|
||||
v-if="showControls"
|
||||
ref="controlsEl"
|
||||
class="pointer-events-auto fixed top-0 right-0 z-[10001] flex h-12 items-center gap-2 bg-bg-raised rounded-bl-2xl px-1.5"
|
||||
data-tauri-drag-region-exclude
|
||||
>
|
||||
<MinimizeIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Toggle maximize window"
|
||||
class="relative expanded-button"
|
||||
@click="() => getCurrentWindow().toggleMaximize()"
|
||||
>
|
||||
<RestoreIcon v-if="isMaximized" />
|
||||
<MaximizeIcon v-else />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Close window"
|
||||
class="relative expanded-button close-button"
|
||||
@click="handleClose"
|
||||
>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</section>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Minimize window"
|
||||
class="relative expanded-button"
|
||||
@click="getCurrentWindow().minimize()"
|
||||
>
|
||||
<MinimizeIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Toggle maximize window"
|
||||
class="relative expanded-button"
|
||||
@click="getCurrentWindow().toggleMaximize()"
|
||||
>
|
||||
<RestoreIcon v-if="isMaximized" />
|
||||
<MaximizeIcon v-else />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Close window"
|
||||
class="relative expanded-button close-button"
|
||||
@click="handleClose"
|
||||
>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</section>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -37,7 +40,7 @@ import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets
|
||||
import { IconButton } from '@modrinth/ui'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
@@ -48,6 +51,7 @@ const appSettings = useAppSettings()
|
||||
const nativeDecorations = ref(true)
|
||||
const isMaximized = ref(false)
|
||||
const os = ref('')
|
||||
const controlsEl = ref(null)
|
||||
|
||||
const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
|
||||
|
||||
@@ -57,6 +61,26 @@ const showControls = computed(
|
||||
(!nativeDecorations.value && (os.value === 'Windows' || os.value === 'Linux')),
|
||||
)
|
||||
|
||||
function setWindowControlsWidth(width) {
|
||||
document.documentElement.style.setProperty('--window-controls-width', `${width}px`)
|
||||
}
|
||||
|
||||
let resizeObserver
|
||||
watch(controlsEl, (el) => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = undefined
|
||||
if (!el) {
|
||||
setWindowControlsWidth(0)
|
||||
return
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
setWindowControlsWidth(el.getBoundingClientRect().width)
|
||||
})
|
||||
resizeObserver.observe(el)
|
||||
setWindowControlsWidth(el.getBoundingClientRect().width)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
os.value = await getOS()
|
||||
|
||||
@@ -78,6 +102,11 @@ onMounted(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
document.documentElement.style.removeProperty('--window-controls-width')
|
||||
})
|
||||
|
||||
const handleClose = async () => {
|
||||
await saveWindowState(StateFlags.ALL)
|
||||
await getCurrentWindow().close()
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown && !isMobileActiveSubmenu"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@keydown="handleMenuKeydown"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
|
||||
>
|
||||
<template v-for="(option, index) in options" :key="index">
|
||||
<hr v-if="isDivider(option)" class="divider" />
|
||||
<button
|
||||
v-else-if="isOptionVisible(option)"
|
||||
:ref="(element) => setOptionButtonRef(index, element)"
|
||||
type="button"
|
||||
class="item clickable"
|
||||
:class="[
|
||||
option.color ?? 'base',
|
||||
{
|
||||
active: index === activeOptionIndex,
|
||||
'safe-triangle-hover': index === pendingOptionIndex,
|
||||
},
|
||||
]"
|
||||
role="menuitem"
|
||||
:aria-haspopup="hasChildren(option) ? 'menu' : undefined"
|
||||
:aria-expanded="hasChildren(option) ? index === activeOptionIndex : undefined"
|
||||
@click.stop="handleOptionClick(option, index)"
|
||||
@focus="handleOptionFocus(option, index)"
|
||||
@mouseenter="handleOptionMouseEnter(option, index)"
|
||||
>
|
||||
<span class="item-content"><slot :name="option.name" /></span>
|
||||
<ChevronRightIcon v-if="hasChildren(option)" class="submenu-chevron" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="shown && activeOption && hasSubmenuPosition"
|
||||
ref="submenu"
|
||||
class="context-menu submenu"
|
||||
:style="submenuStyle"
|
||||
role="menu"
|
||||
@keydown="handleSubmenuKeydown"
|
||||
@mouseenter="handleSubmenuMouseEnter"
|
||||
@mouseleave="isCursorInsideSubmenu = false"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
|
||||
>
|
||||
<button
|
||||
v-if="isMobileSubmenuLayout"
|
||||
type="button"
|
||||
class="item clickable base mobile-back"
|
||||
@click.stop="returnToMenu"
|
||||
>
|
||||
<ChevronLeftIcon class="submenu-chevron" />
|
||||
<span class="item-content"><slot :name="activeOption.name" /></span>
|
||||
</button>
|
||||
<template v-for="(option, index) in activeOption.children" :key="index">
|
||||
<hr v-if="isDivider(option)" class="divider" />
|
||||
<button
|
||||
v-else-if="isOptionVisible(option)"
|
||||
:ref="(element) => setSubmenuButtonRef(index, element)"
|
||||
type="button"
|
||||
class="item clickable"
|
||||
:class="option.color ?? 'base'"
|
||||
role="menuitem"
|
||||
@click.stop="optionClicked(option.name)"
|
||||
>
|
||||
<span class="item-content"><slot :name="option.name" /></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import type { ContextMenuEmit } from './types'
|
||||
import { useContextMenu } from './use-context-menu'
|
||||
import { hasChildren, isDivider } from './utils'
|
||||
|
||||
const emit = defineEmits<ContextMenuEmit>()
|
||||
|
||||
const {
|
||||
shown,
|
||||
contextMenu,
|
||||
submenu,
|
||||
options,
|
||||
menuStyle,
|
||||
submenuStyle,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
pendingOptionIndex,
|
||||
hasSubmenuPosition,
|
||||
isMobileSubmenuLayout,
|
||||
isMobileActiveSubmenu,
|
||||
isCursorInsideSubmenu,
|
||||
isOptionVisible,
|
||||
setOptionButtonRef,
|
||||
setSubmenuButtonRef,
|
||||
showMenu,
|
||||
hideMenu,
|
||||
handleOptionClick,
|
||||
optionClicked,
|
||||
handleOptionFocus,
|
||||
handleOptionMouseEnter,
|
||||
returnToMenu,
|
||||
handleSubmenuMouseEnter,
|
||||
handleMenuMouseMove,
|
||||
handleMenuKeydown,
|
||||
handleSubmenuKeydown,
|
||||
} = useContextMenu(emit)
|
||||
|
||||
defineExpose({ showMenu, hideMenu })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
min-width: 12rem;
|
||||
max-width: calc(100vw - 1.25rem);
|
||||
|
||||
&.submenu {
|
||||
z-index: 1000001;
|
||||
}
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 500;
|
||||
gap: var(--gap-sm);
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
.item-content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.submenu-chevron {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: -0.25rem;
|
||||
}
|
||||
|
||||
:deep(svg) {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
&:hover:not(.safe-triangle-hover),
|
||||
&:active,
|
||||
&:focus-visible,
|
||||
&.active {
|
||||
:deep(svg) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-back {
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
border-radius: 0;
|
||||
margin: calc(var(--gap-sm) * -1) calc(var(--gap-sm) * -1) var(--gap-sm);
|
||||
width: calc(100% + var(--gap-sm) * 2);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
export type ContextMenuDivider = {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type ContextMenuAction = {
|
||||
name: string
|
||||
color?: string
|
||||
children?: ContextMenuOption[]
|
||||
}
|
||||
|
||||
export type ContextMenuParentAction = ContextMenuAction & {
|
||||
children: ContextMenuOption[]
|
||||
}
|
||||
|
||||
export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
|
||||
|
||||
export type ContextMenuSelection = {
|
||||
item: unknown
|
||||
option: string
|
||||
}
|
||||
|
||||
export type ContextMenuEmit = {
|
||||
(event: 'menu-closed'): void
|
||||
(event: 'option-clicked', selection: ContextMenuSelection): void
|
||||
}
|
||||
|
||||
export type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type ViewportRect = {
|
||||
width: number
|
||||
height: number
|
||||
offsetTop: number
|
||||
offsetLeft: number
|
||||
}
|
||||
|
||||
export type ButtonRefElement = Element | ComponentPublicInstance | null
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { ContextMenuParentAction, Point, ViewportRect } from './types'
|
||||
|
||||
const MENU_GAP = 8
|
||||
const VIEWPORT_MARGIN = 10
|
||||
|
||||
type ContextMenuPositionOptions = {
|
||||
shown: Ref<boolean>
|
||||
activeOption: Ref<ContextMenuParentAction | null>
|
||||
activeOptionIndex: Ref<number | null>
|
||||
isMobileSubmenuLayout: Ref<boolean>
|
||||
contextMenu: Ref<HTMLElement | null>
|
||||
submenu: Ref<HTMLElement | null>
|
||||
optionButtonRefs: Map<number, HTMLElement>
|
||||
}
|
||||
|
||||
export function useContextMenuPosition({
|
||||
shown,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
isMobileSubmenuLayout,
|
||||
contextMenu,
|
||||
submenu,
|
||||
optionButtonRefs,
|
||||
}: ContextMenuPositionOptions) {
|
||||
const menuStyle = ref<CSSProperties>({ left: '0px', top: '0px' })
|
||||
const menuAnchor = ref<Point>({ x: 0, y: 0 })
|
||||
const submenuPosition = ref<Point>({ x: 0, y: 0 })
|
||||
const hasSubmenuPosition = ref(false)
|
||||
let positionRafId: number | null = null
|
||||
|
||||
const submenuStyle = computed<CSSProperties>(() => {
|
||||
if (isMobileSubmenuLayout.value) return menuStyle.value
|
||||
|
||||
return {
|
||||
left: `${submenuPosition.value.x}px`,
|
||||
top: `${submenuPosition.value.y}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function updateMenuPosition(x: number, y: number) {
|
||||
if (!contextMenu.value) return
|
||||
|
||||
const viewport = getViewportRect()
|
||||
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||
const viewportLeft = viewport.offsetLeft
|
||||
const viewportTop = viewport.offsetTop
|
||||
const viewportRight = viewport.offsetLeft + viewport.width
|
||||
const viewportBottom = viewport.offsetTop + viewport.height
|
||||
const anchorX = x + viewport.offsetLeft
|
||||
const anchorY = y + viewport.offsetTop
|
||||
const left = Math.min(
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
|
||||
Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
|
||||
)
|
||||
|
||||
menuStyle.value = { left: `${left}px`, top: `${top}px` }
|
||||
if (activeOption.value) scheduleSubmenuPositionUpdate()
|
||||
}
|
||||
|
||||
function updateSubmenuPosition() {
|
||||
if (!activeOption.value || activeOptionIndex.value === null) return false
|
||||
|
||||
if (isMobileSubmenuLayout.value) {
|
||||
hasSubmenuPosition.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
const optionButton = optionButtonRefs.get(activeOptionIndex.value)
|
||||
if (!optionButton || !contextMenu.value) return false
|
||||
|
||||
const viewport = getViewportRect()
|
||||
const buttonRect = optionButton.getBoundingClientRect()
|
||||
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||
const submenuWidth = submenuRect?.width ?? menuRect.width
|
||||
const submenuHeight = submenuRect?.height ?? 100
|
||||
const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
|
||||
const preferredLeft =
|
||||
direction === 'right'
|
||||
? buttonRect.right + MENU_GAP
|
||||
: buttonRect.left - submenuWidth - MENU_GAP
|
||||
const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
|
||||
const maxLeft = Math.max(
|
||||
minLeft,
|
||||
viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
|
||||
)
|
||||
const minTop = viewport.offsetTop + VIEWPORT_MARGIN
|
||||
const maxTop = Math.max(
|
||||
minTop,
|
||||
viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
|
||||
)
|
||||
|
||||
submenuPosition.value = {
|
||||
x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
|
||||
y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
|
||||
}
|
||||
hasSubmenuPosition.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleSubmenuPositionUpdate(retries = 8) {
|
||||
nextTick(() => {
|
||||
if (!shown.value || !activeOption.value) return
|
||||
|
||||
const hasRenderedSubmenu = submenu.value !== null
|
||||
if (updateSubmenuPosition()) {
|
||||
if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
|
||||
return
|
||||
}
|
||||
|
||||
if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
|
||||
})
|
||||
}
|
||||
|
||||
function schedulePositionUpdate() {
|
||||
if (!shown.value || positionRafId !== null) return
|
||||
|
||||
positionRafId = window.requestAnimationFrame(() => {
|
||||
positionRafId = null
|
||||
updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
|
||||
})
|
||||
}
|
||||
|
||||
function cancelPositionUpdate() {
|
||||
if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
|
||||
}
|
||||
|
||||
return {
|
||||
menuStyle,
|
||||
menuAnchor,
|
||||
submenuStyle,
|
||||
hasSubmenuPosition,
|
||||
updateMenuPosition,
|
||||
scheduleSubmenuPositionUpdate,
|
||||
schedulePositionUpdate,
|
||||
cancelPositionUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
function getViewportRect(): ViewportRect {
|
||||
const visualViewport = window.visualViewport
|
||||
return {
|
||||
width: visualViewport?.width ?? window.innerWidth,
|
||||
height: visualViewport?.height ?? window.innerHeight,
|
||||
offsetTop: visualViewport?.offsetTop ?? 0,
|
||||
offsetLeft: visualViewport?.offsetLeft ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubmenuOpenDirection(
|
||||
menuRect: DOMRect,
|
||||
submenuWidth: number,
|
||||
viewport: ViewportRect,
|
||||
): 'left' | 'right' {
|
||||
const viewportLeft = viewport.offsetLeft
|
||||
const viewportRight = viewport.offsetLeft + viewport.width
|
||||
const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
|
||||
const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
|
||||
|
||||
if (rightSpace >= submenuWidth) return 'right'
|
||||
if (leftSpace >= submenuWidth) return 'left'
|
||||
return rightSpace >= leftSpace ? 'right' : 'left'
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ButtonRefElement,
|
||||
ContextMenuAction,
|
||||
ContextMenuEmit,
|
||||
ContextMenuOption,
|
||||
ContextMenuParentAction,
|
||||
Point,
|
||||
} from './types'
|
||||
import { useContextMenuPosition } from './use-context-menu-position'
|
||||
import {
|
||||
focusRelativeButton,
|
||||
getFocusableButtons,
|
||||
hasChildren,
|
||||
isAction,
|
||||
isInstanceLink,
|
||||
isPointInTriangle,
|
||||
} from './utils'
|
||||
|
||||
const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
|
||||
const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
|
||||
|
||||
export function useContextMenu(emit: ContextMenuEmit) {
|
||||
const item = ref<unknown>(null)
|
||||
const contextMenu = ref<HTMLElement | null>(null)
|
||||
const submenu = ref<HTMLElement | null>(null)
|
||||
const options = ref<ContextMenuOption[]>([])
|
||||
const shown = ref(false)
|
||||
const activeOptionIndex = ref<number | null>(null)
|
||||
const pendingOptionIndex = ref<number | null>(null)
|
||||
const isCursorInsideSubmenu = ref(false)
|
||||
const isMobileSubmenuLayout = ref(false)
|
||||
const lastMousePosition = ref<Point | null>(null)
|
||||
const contextMenuId = Symbol()
|
||||
const optionButtonRefs = new Map<number, HTMLElement>()
|
||||
const submenuButtonRefs = new Map<number, HTMLElement>()
|
||||
let previousMousePosition: Point | null = null
|
||||
let pendingOptionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let mobileSubmenuMediaQuery: MediaQueryList | null = null
|
||||
|
||||
const activeOption = computed<ContextMenuParentAction | null>(() => {
|
||||
if (activeOptionIndex.value === null) return null
|
||||
const option = options.value[activeOptionIndex.value]
|
||||
return option && isAction(option) && hasChildren(option) ? option : null
|
||||
})
|
||||
|
||||
const {
|
||||
menuStyle,
|
||||
menuAnchor,
|
||||
submenuStyle,
|
||||
hasSubmenuPosition,
|
||||
updateMenuPosition,
|
||||
scheduleSubmenuPositionUpdate,
|
||||
schedulePositionUpdate,
|
||||
cancelPositionUpdate,
|
||||
} = useContextMenuPosition({
|
||||
shown,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
isMobileSubmenuLayout,
|
||||
contextMenu,
|
||||
submenu,
|
||||
optionButtonRefs,
|
||||
})
|
||||
|
||||
const isMobileActiveSubmenu = computed(
|
||||
() => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
|
||||
)
|
||||
|
||||
function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
|
||||
return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
|
||||
}
|
||||
|
||||
function setOptionButtonRef(index: number, element: ButtonRefElement) {
|
||||
setButtonRef(optionButtonRefs, index, element)
|
||||
}
|
||||
|
||||
function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
|
||||
setButtonRef(submenuButtonRefs, index, element)
|
||||
}
|
||||
|
||||
function hideMenu() {
|
||||
if (!shown.value) return
|
||||
|
||||
shown.value = false
|
||||
deactivateSubmenu()
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
|
||||
window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
|
||||
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
menuAnchor.value = { x: event.clientX, y: event.clientY }
|
||||
shown.value = true
|
||||
deactivateSubmenu()
|
||||
syncMobileSubmenuLayout()
|
||||
nextTick(() => updateMenuPosition(event.clientX, event.clientY))
|
||||
}
|
||||
|
||||
function handleOptionClick(option: ContextMenuAction, index: number) {
|
||||
if (hasChildren(option)) {
|
||||
activateSubmenu(index)
|
||||
return
|
||||
}
|
||||
|
||||
optionClicked(option.name)
|
||||
}
|
||||
|
||||
function optionClicked(option: string) {
|
||||
emit('option-clicked', { item: item.value, option })
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
function handleOptionFocus(option: ContextMenuAction, index: number) {
|
||||
if (hasChildren(option) && !isMobileSubmenuLayout.value) {
|
||||
activateSubmenu(index)
|
||||
} else if (!hasChildren(option)) {
|
||||
deactivateSubmenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
|
||||
if (isMobileSubmenuLayout.value) return
|
||||
|
||||
if (activeOptionIndex.value === null) {
|
||||
if (hasChildren(option)) activateSubmenu(index)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeOptionIndex.value === index) return
|
||||
|
||||
if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
|
||||
commitHoveredOption(index)
|
||||
return
|
||||
}
|
||||
|
||||
pendingOptionIndex.value = index
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionTimeout = setTimeout(() => {
|
||||
if (pendingOptionIndex.value !== index) return
|
||||
if (isCursorInsideSubmenu.value) {
|
||||
pendingOptionIndex.value = null
|
||||
return
|
||||
}
|
||||
|
||||
commitHoveredOption(index)
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function commitHoveredOption(index: number) {
|
||||
const option = options.value[index]
|
||||
if (option && hasChildren(option)) {
|
||||
activateSubmenu(index)
|
||||
} else {
|
||||
deactivateSubmenu()
|
||||
}
|
||||
}
|
||||
|
||||
function activateSubmenu(index: number) {
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionIndex.value = null
|
||||
activeOptionIndex.value = index
|
||||
hasSubmenuPosition.value = false
|
||||
scheduleSubmenuPositionUpdate()
|
||||
}
|
||||
|
||||
function deactivateSubmenu() {
|
||||
clearPendingOptionTimeout()
|
||||
activeOptionIndex.value = null
|
||||
pendingOptionIndex.value = null
|
||||
hasSubmenuPosition.value = false
|
||||
isCursorInsideSubmenu.value = false
|
||||
lastMousePosition.value = null
|
||||
previousMousePosition = null
|
||||
}
|
||||
|
||||
function returnToMenu() {
|
||||
const previousIndex = activeOptionIndex.value
|
||||
deactivateSubmenu()
|
||||
nextTick(() => {
|
||||
if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleSubmenuMouseEnter() {
|
||||
isCursorInsideSubmenu.value = true
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionIndex.value = null
|
||||
}
|
||||
|
||||
function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
|
||||
previousMousePosition = lastMousePosition.value
|
||||
lastMousePosition.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
if (
|
||||
source === 'menu' &&
|
||||
pendingOptionIndex.value !== null &&
|
||||
!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
|
||||
) {
|
||||
commitHoveredOption(pendingOptionIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
|
||||
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||
if (!submenuRect || !cursor || !origin) return false
|
||||
|
||||
const submenuTargetX =
|
||||
origin.x <= submenuRect.left
|
||||
? submenuRect.left
|
||||
: origin.x >= submenuRect.right
|
||||
? submenuRect.right
|
||||
: cursor.x <= submenuRect.left
|
||||
? submenuRect.left
|
||||
: submenuRect.right
|
||||
const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
|
||||
const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
|
||||
|
||||
return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
|
||||
}
|
||||
|
||||
function handleMenuKeydown(event: KeyboardEvent) {
|
||||
const buttons = getFocusableButtons(optionButtonRefs)
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
const focusedIndex = [...optionButtonRefs.entries()].find(
|
||||
([, button]) => button === document.activeElement,
|
||||
)?.[0]
|
||||
const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
|
||||
if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
|
||||
event.preventDefault()
|
||||
activateSubmenu(focusedIndex)
|
||||
nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmenuKeydown(event: KeyboardEvent) {
|
||||
const buttons = getFocusableButtons(submenuButtonRefs)
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
returnToMenu()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
|
||||
isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (shown.value && event.key === 'Escape') hideMenu()
|
||||
}
|
||||
|
||||
function handleContextMenuOpen(event: Event) {
|
||||
if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
|
||||
syncMobileSubmenuLayout()
|
||||
mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', schedulePositionUpdate)
|
||||
window.addEventListener('scroll', schedulePositionUpdate, true)
|
||||
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
|
||||
window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
|
||||
window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||
document.addEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingOptionTimeout()
|
||||
cancelPositionUpdate()
|
||||
mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', schedulePositionUpdate)
|
||||
window.removeEventListener('scroll', schedulePositionUpdate, true)
|
||||
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
|
||||
window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
|
||||
window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
|
||||
function clearPendingOptionTimeout() {
|
||||
if (!pendingOptionTimeout) return
|
||||
clearTimeout(pendingOptionTimeout)
|
||||
pendingOptionTimeout = null
|
||||
}
|
||||
|
||||
return {
|
||||
shown,
|
||||
contextMenu,
|
||||
submenu,
|
||||
options,
|
||||
menuStyle,
|
||||
submenuStyle,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
pendingOptionIndex,
|
||||
hasSubmenuPosition,
|
||||
isMobileSubmenuLayout,
|
||||
isMobileActiveSubmenu,
|
||||
isCursorInsideSubmenu,
|
||||
isOptionVisible,
|
||||
setOptionButtonRef,
|
||||
setSubmenuButtonRef,
|
||||
showMenu,
|
||||
hideMenu,
|
||||
handleOptionClick,
|
||||
optionClicked,
|
||||
handleOptionFocus,
|
||||
handleOptionMouseEnter,
|
||||
returnToMenu,
|
||||
handleSubmenuMouseEnter,
|
||||
handleMenuMouseMove,
|
||||
handleMenuKeydown,
|
||||
handleSubmenuKeydown,
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonRef(
|
||||
buttonRefs: Map<number, HTMLElement>,
|
||||
index: number,
|
||||
element: ButtonRefElement,
|
||||
) {
|
||||
if (element instanceof HTMLElement) {
|
||||
buttonRefs.set(index, element)
|
||||
} else {
|
||||
buttonRefs.delete(index)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
ContextMenuAction,
|
||||
ContextMenuDivider,
|
||||
ContextMenuOption,
|
||||
ContextMenuParentAction,
|
||||
Point,
|
||||
} from './types'
|
||||
|
||||
export function isAction(option: ContextMenuOption): option is ContextMenuAction {
|
||||
return 'name' in option
|
||||
}
|
||||
|
||||
export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
|
||||
return 'type' in option && option.type === 'divider'
|
||||
}
|
||||
|
||||
export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
|
||||
return isAction(option) && Boolean(option.children?.length)
|
||||
}
|
||||
|
||||
export function isInstanceLink(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
if ('instance' in value) {
|
||||
const instance = value.instance
|
||||
return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
|
||||
}
|
||||
|
||||
return 'link' in value && Boolean(value.link)
|
||||
}
|
||||
|
||||
export function getFocusableButtons(buttonRefs: Map<number, HTMLElement>) {
|
||||
return [...buttonRefs.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, button]) => button)
|
||||
.filter((button) => button.offsetParent !== null)
|
||||
}
|
||||
|
||||
export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
|
||||
if (!buttons.length) return
|
||||
|
||||
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
|
||||
buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
|
||||
}
|
||||
|
||||
export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
|
||||
const area = triangleArea(a, b, c)
|
||||
const area1 = triangleArea(point, b, c)
|
||||
const area2 = triangleArea(a, point, c)
|
||||
const area3 = triangleArea(a, b, point)
|
||||
|
||||
return Math.abs(area - (area1 + area2 + area3)) < 0.5
|
||||
}
|
||||
|
||||
function triangleArea(a: Point, b: Point, c: Point) {
|
||||
return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
Input,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -122,6 +122,11 @@ function addFriendFromModal() {
|
||||
username.value = ''
|
||||
}
|
||||
|
||||
function showAddFriendModal() {
|
||||
username.value = ''
|
||||
addFriendModal.value?.show()
|
||||
}
|
||||
|
||||
function addFriend(friend: FriendWithUserData) {
|
||||
acceptFriend(friend)
|
||||
}
|
||||
@@ -130,6 +135,8 @@ function removeFriend(friend: FriendWithUserData) {
|
||||
removeFriendRecord(friend)
|
||||
}
|
||||
|
||||
defineExpose({ showAddFriendModal })
|
||||
|
||||
const messages = defineMessages({
|
||||
addFriend: {
|
||||
id: 'friends.action.add-friend',
|
||||
@@ -250,7 +257,7 @@ const messages = defineMessages({
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="username"
|
||||
:icon="UserIcon"
|
||||
type="text"
|
||||
@@ -280,14 +287,15 @@ const messages = defineMessages({
|
||||
>
|
||||
<UserPlusIcon />
|
||||
</IconButton>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
appearance="transparent"
|
||||
:placeholder="formatMessage(messages.searchFriends)"
|
||||
clearable
|
||||
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
|
||||
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
|
||||
input-class="!text-primary !placeholder:text-primary"
|
||||
wrapper-class="flex-1 !border-button-bg [&>span:first-child]:!text-primary [&>span:first-child]:!opacity-100"
|
||||
@keyup.esc="search = ''"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
|
||||
import type { ButtonMenuOption } from '@modrinth/ui'
|
||||
import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
ContextMenu,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
TeleportOverflowMenu,
|
||||
UserAvatar,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -36,27 +37,30 @@ const emit = defineEmits<{
|
||||
onClose: []
|
||||
}>()
|
||||
|
||||
function createContextMenuOptions(friend: FriendWithUserData) {
|
||||
if (friend.accepted) {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'remove-friend',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'cancel-request',
|
||||
},
|
||||
]
|
||||
}
|
||||
function createContextMenuOptions(friend: FriendWithUserData): ButtonMenuOption[] {
|
||||
return [
|
||||
{
|
||||
id: 'view-profile',
|
||||
label: formatMessage(messages.viewProfile),
|
||||
icon: UserIcon,
|
||||
action: () => openProfile(friend.username),
|
||||
},
|
||||
friend.accepted
|
||||
? {
|
||||
id: 'remove-friend',
|
||||
label: formatMessage(messages.removeFriend),
|
||||
icon: TrashIcon,
|
||||
tone: 'red',
|
||||
hoverFilledOnly: true,
|
||||
action: () => void props.removeFriend(friend),
|
||||
}
|
||||
: {
|
||||
id: 'cancel-request',
|
||||
label: formatMessage(messages.cancelRequest),
|
||||
icon: XIcon,
|
||||
action: () => void props.removeFriend(friend),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function openProfile(username: string) {
|
||||
@@ -64,16 +68,6 @@ function openProfile(username: string) {
|
||||
}
|
||||
|
||||
const friendOptions = useTemplateRef('friendOptions')
|
||||
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
|
||||
switch (args.option) {
|
||||
case 'remove-friend':
|
||||
case 'cancel-request':
|
||||
await props.removeFriend(args.item)
|
||||
break
|
||||
case 'view-profile':
|
||||
openProfile(args.item.username)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
removeFriend: {
|
||||
@@ -96,18 +90,15 @@ const messages = defineMessages({
|
||||
id: 'friends.friend.view-profile',
|
||||
defaultMessage: 'View profile',
|
||||
},
|
||||
friendActionsLabel: {
|
||||
id: 'friends.friend.actions.label',
|
||||
defaultMessage: 'Friend actions',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
|
||||
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
|
||||
</ContextMenu>
|
||||
<ContextMenu ref="friendOptions" :label="formatMessage(messages.friendActionsLabel)" />
|
||||
<Accordion
|
||||
:open-by-default="openByDefault"
|
||||
:force-open="isSearching"
|
||||
@@ -130,32 +121,24 @@ const messages = defineMessages({
|
||||
<div
|
||||
v-for="friend in friends"
|
||||
:key="friend.username"
|
||||
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
|
||||
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1 select-none"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
|
||||
(event) => friendOptions?.open(event, createContextMenuOptions(friend))
|
||||
"
|
||||
>
|
||||
<RouterLink
|
||||
:to="`/user/${encodeURIComponent(friend.username)}`"
|
||||
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
|
||||
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline group no-click-animation"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<UserAvatar
|
||||
:src="friend.avatar"
|
||||
size="32px"
|
||||
:badge="friend.online"
|
||||
:grayscale="!friend.online && friend.accepted"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
class="text-sm m-0 group-hover:underline"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Button,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Input,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -103,7 +103,7 @@ async function addServer(instance) {
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="flex flex-col gap-4 min-w-[350px]">
|
||||
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
@@ -121,7 +121,11 @@ async function addServer(instance) {
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
@click="modal.hide()"
|
||||
>
|
||||
<Avatar :src="getInstanceIconUrl(instance.icon_path)" class="mr-2 [--size:2rem]" />
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
class="mr-2 [--size:2rem]"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
{{ instance.name }}
|
||||
</router-link>
|
||||
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="groupInstancesSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
@@ -51,6 +51,7 @@
|
||||
:alt="instance.name"
|
||||
size="2rem"
|
||||
rounded="md"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
</div>
|
||||
@@ -96,8 +97,8 @@ import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
Input,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
MinusIcon,
|
||||
PaletteIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { StarIcon } from '@modrinth/assets'
|
||||
import { ContextMenu, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
|
||||
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
|
||||
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
|
||||
@@ -44,55 +30,9 @@ const messages = defineMessages({
|
||||
id: 'app.library.search.no-results.title',
|
||||
defaultMessage: 'No instances match your search.',
|
||||
},
|
||||
play: { id: 'app.library.instance.action.play', defaultMessage: 'Play' },
|
||||
stop: { id: 'app.library.instance.action.stop', defaultMessage: 'Stop' },
|
||||
addToFavorites: {
|
||||
id: 'app.library.instance.action.add-to-favorites',
|
||||
defaultMessage: 'Add to favorites',
|
||||
},
|
||||
removeFromFavorites: {
|
||||
id: 'app.library.instance.action.remove-from-favorites',
|
||||
defaultMessage: 'Remove from favorites',
|
||||
},
|
||||
addContent: { id: 'app.library.instance.action.add-content', defaultMessage: 'Add content' },
|
||||
viewInstance: {
|
||||
id: 'app.library.instance.action.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
editIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
createIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.create',
|
||||
defaultMessage: 'Create an icon',
|
||||
},
|
||||
editCreatedIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.edit-created',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
duplicateInstance: {
|
||||
id: 'app.library.instance.action.duplicate',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
delete: { id: 'app.library.instance.action.delete', defaultMessage: 'Delete' },
|
||||
openFolder: { id: 'app.library.instance.action.open-folder', defaultMessage: 'Open folder' },
|
||||
copyPath: { id: 'app.library.instance.action.copy-path', defaultMessage: 'Copy path' },
|
||||
removeFromGroup: {
|
||||
id: 'app.library.instance.action.remove-from-group',
|
||||
defaultMessage: 'Remove from group',
|
||||
instanceActionsLabel: {
|
||||
id: 'app.library.instance.actions.label',
|
||||
defaultMessage: 'Instance actions',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -111,7 +51,6 @@ const {
|
||||
currentDeleteInstances,
|
||||
clearLibraryInstanceSelection,
|
||||
deleteInstance,
|
||||
handleInstanceOption,
|
||||
handleInstanceIconSaved,
|
||||
selectedLibraryInstances,
|
||||
setSelectedLibraryInstances,
|
||||
@@ -371,7 +310,9 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
>
|
||||
<InstanceGroup
|
||||
:can-drag-reorder="canDragReorderGroups"
|
||||
:hide-header="visibleInstanceGroups.length === 1"
|
||||
:hide-header="
|
||||
instanceGroup.id === 'group:none' && visibleInstanceGroups.length === 1
|
||||
"
|
||||
:instance-group="instanceGroup"
|
||||
:selection-anchor-instance-id="
|
||||
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
|
||||
@@ -430,34 +371,10 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
:config="currentIconEditorInstance?.icon_config"
|
||||
@saved="handleInstanceIconSaved"
|
||||
/>
|
||||
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
|
||||
<template #add_to_favorites>
|
||||
<StarIcon /> {{ formatMessage(messages.addToFavorites) }}
|
||||
</template>
|
||||
<template #remove_from_favorites>
|
||||
<ContextMenu :ref="setInstanceOptions" :label="formatMessage(messages.instanceActionsLabel)">
|
||||
<template #remove_from_favorites="{ option }">
|
||||
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
|
||||
{{ formatMessage(messages.removeFromFavorites) }}
|
||||
</template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||||
<template #edit_icon> <EditIcon /> {{ formatMessage(messages.editIcon) }} </template>
|
||||
<template #select_icon> <UploadIcon /> {{ formatMessage(messages.selectIcon) }} </template>
|
||||
<template #replace_icon> <UploadIcon /> {{ formatMessage(messages.replaceIcon) }} </template>
|
||||
<template #create_icon> <PaletteIcon /> {{ formatMessage(messages.createIcon) }} </template>
|
||||
<template #edit_created_icon>
|
||||
<PaletteIcon /> {{ formatMessage(messages.editCreatedIcon) }}
|
||||
</template>
|
||||
<template #remove_icon> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
<template #duplicate>
|
||||
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
||||
</template>
|
||||
<template #delete> <TrashIcon /> {{ formatMessage(messages.delete) }} </template>
|
||||
<template #open> <FolderOpenIcon /> {{ formatMessage(messages.openFolder) }} </template>
|
||||
<template #copy> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
|
||||
<template #remove_from_group>
|
||||
<MinusIcon /> {{ formatMessage(messages.removeFromGroup) }}
|
||||
{{ option.label }}
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Accordion,
|
||||
Button,
|
||||
commonMessages,
|
||||
ContextMenu,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
InlineEditableText,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
|
||||
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
|
||||
import type {
|
||||
@@ -163,6 +163,10 @@ const messages = defineMessages({
|
||||
id: 'app.library.group.empty',
|
||||
defaultMessage: 'Drag and drop to add instances.',
|
||||
},
|
||||
groupActionsLabel: {
|
||||
id: 'app.library.group.actions.label',
|
||||
defaultMessage: 'Group actions',
|
||||
},
|
||||
})
|
||||
|
||||
function openInstanceContextMenu(event: MouseEvent, instanceId: string, instanceGroupId: string) {
|
||||
@@ -204,49 +208,45 @@ function requestGroupDeletion() {
|
||||
|
||||
function openGroupContextMenu(event: MouseEvent) {
|
||||
groupContextMenuOpen.value = true
|
||||
groupOptions.value?.showMenu(
|
||||
event,
|
||||
props.instanceGroup,
|
||||
isFavorites.value
|
||||
? [{ name: 'new_instance' }]
|
||||
: isCustomGroup.value
|
||||
? [
|
||||
{ name: 'new_instance' },
|
||||
{ name: 'add_instances' },
|
||||
{ name: 'edit_name' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_group', color: 'danger' },
|
||||
]
|
||||
: [{ name: 'new_instance' }],
|
||||
)
|
||||
}
|
||||
|
||||
function handleGroupOption({ option }: { option: string }) {
|
||||
if (option === 'new_instance') {
|
||||
showCreationModal?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'add_instances') {
|
||||
openGroupInstancesModal(props.instanceGroup.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'edit_name') {
|
||||
void groupNameInput.value?.startEditing()
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'delete_group') {
|
||||
requestGroupDeletion()
|
||||
}
|
||||
groupOptions.value?.open(event, [
|
||||
{
|
||||
id: 'new_instance',
|
||||
label: formatMessage(messages.newInstance),
|
||||
icon: PlusIcon,
|
||||
action: () => showCreationModal?.(),
|
||||
},
|
||||
{
|
||||
id: 'add_instances',
|
||||
label: formatMessage(messages.addToGroup),
|
||||
icon: SquarePlusIcon,
|
||||
shown: isCustomGroup.value,
|
||||
action: () => openGroupInstancesModal(props.instanceGroup.id),
|
||||
},
|
||||
{
|
||||
id: 'edit_name',
|
||||
label: formatMessage(messages.editGroupName),
|
||||
icon: EditIcon,
|
||||
shown: isCustomGroup.value,
|
||||
action: () => void groupNameInput.value?.startEditing(),
|
||||
},
|
||||
{ type: 'divider', shown: isCustomGroup.value },
|
||||
{
|
||||
id: 'delete_group',
|
||||
label: formatMessage(messages.deleteGroup),
|
||||
icon: TrashIcon,
|
||||
shown: isCustomGroup.value,
|
||||
tone: 'red',
|
||||
hoverFilledOnly: true,
|
||||
action: requestGroupDeletion,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
function prepareGroupToggle(event: PointerEvent) {
|
||||
const editor = groupNameInput.value
|
||||
const contextMenuWasOpen = groupContextMenuOpen.value
|
||||
if (contextMenuWasOpen) {
|
||||
groupOptions.value?.hideMenu()
|
||||
groupOptions.value?.close()
|
||||
}
|
||||
|
||||
shouldSkipGroupToggle = Boolean(
|
||||
@@ -523,16 +523,9 @@ onMounted(startInstanceGridResizeObserver)
|
||||
|
||||
<ContextMenu
|
||||
ref="groupOptions"
|
||||
@menu-closed="groupContextMenuOpen = false"
|
||||
@option-clicked="handleGroupOption"
|
||||
>
|
||||
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
|
||||
<template #add_instances>
|
||||
<SquarePlusIcon /> {{ formatMessage(messages.addToGroup) }}
|
||||
</template>
|
||||
<template #edit_name> <EditIcon /> {{ formatMessage(messages.editGroupName) }} </template>
|
||||
<template #delete_group> <TrashIcon /> {{ formatMessage(messages.deleteGroup) }} </template>
|
||||
</ContextMenu>
|
||||
:label="formatMessage(messages.groupActionsLabel)"
|
||||
@close="groupContextMenuOpen = false"
|
||||
/>
|
||||
|
||||
<NewModal
|
||||
ref="confirmDeleteGroupModal"
|
||||
|
||||
@@ -47,6 +47,7 @@ const versionRef = ref<HTMLElement | null>(null)
|
||||
:tint-by="instance.id"
|
||||
alt=""
|
||||
no-shadow
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<slot name="loading" :compact="compactMode" />
|
||||
<div
|
||||
|
||||
@@ -242,6 +242,9 @@ defineExpose({
|
||||
openFolder,
|
||||
addContent,
|
||||
instance: props.instance,
|
||||
get playing() {
|
||||
return playing.value
|
||||
},
|
||||
})
|
||||
|
||||
useAppEvent('process', (event) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SearchIcon, SquarePlusIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, StyledInput, useVIntl } from '@modrinth/ui'
|
||||
import { Button, defineMessages, Input, useVIntl } from '@modrinth/ui'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import FilterMenu from '@/components/ui/library/library-toolbar/filter-menu.vue'
|
||||
@@ -29,7 +29,7 @@ function openNewGroup() {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<label for="new-group-name" class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.groupName) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
<Input
|
||||
id="new-group-name"
|
||||
ref="groupNameInput"
|
||||
v-model="newGroupName"
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="newGroupSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchInstance)"
|
||||
@@ -60,6 +60,7 @@
|
||||
:alt="instance.name"
|
||||
size="2rem"
|
||||
rounded="md"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
@@ -103,15 +104,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { Avatar, Button, defineMessages, Input, NewModal, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
@@ -157,7 +150,7 @@ const {
|
||||
} = useLibrary()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const groupNameInput = ref<InstanceType<typeof Input>>()
|
||||
const groupNamesById = computed(
|
||||
() => new Map(libraryGroups.value.map((group) => [group.id, group.name])),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
MinusIcon,
|
||||
PaletteIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { ButtonMenuLeafOption, ButtonMenuOption } from '@modrinth/ui'
|
||||
import { defineMessages, formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||||
import { useEventListener, useStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -98,7 +113,7 @@ export type InstanceCard = {
|
||||
}
|
||||
|
||||
type InstanceContextMenu = {
|
||||
showMenu: (event: MouseEvent, item: InstanceCard, options: unknown[]) => void
|
||||
open: (event: MouseEvent, options: ButtonMenuOption[]) => void
|
||||
}
|
||||
|
||||
type ConfirmDeleteModal = {
|
||||
@@ -109,10 +124,58 @@ type IconEditorModal = {
|
||||
show: () => void
|
||||
}
|
||||
|
||||
type ContextMenuSelection = {
|
||||
option: string
|
||||
item: InstanceCard
|
||||
}
|
||||
const instanceActionMessages = defineMessages({
|
||||
play: { id: 'app.library.instance.action.play', defaultMessage: 'Play' },
|
||||
stop: { id: 'app.library.instance.action.stop', defaultMessage: 'Stop' },
|
||||
addToFavorites: {
|
||||
id: 'app.library.instance.action.add-to-favorites',
|
||||
defaultMessage: 'Add to favorites',
|
||||
},
|
||||
removeFromFavorites: {
|
||||
id: 'app.library.instance.action.remove-from-favorites',
|
||||
defaultMessage: 'Remove from favorites',
|
||||
},
|
||||
addContent: { id: 'app.library.instance.action.add-content', defaultMessage: 'Add content' },
|
||||
viewInstance: {
|
||||
id: 'app.library.instance.action.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
editIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
createIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.create',
|
||||
defaultMessage: 'Create an icon',
|
||||
},
|
||||
editCreatedIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.edit-created',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
duplicateInstance: {
|
||||
id: 'app.library.instance.action.duplicate',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
delete: { id: 'app.library.instance.action.delete', defaultMessage: 'Delete' },
|
||||
openFolder: { id: 'app.library.instance.action.open-folder', defaultMessage: 'Open folder' },
|
||||
copyPath: { id: 'app.library.instance.action.copy-path', defaultMessage: 'Copy path' },
|
||||
removeFromGroup: {
|
||||
id: 'app.library.instance.action.remove-from-group',
|
||||
defaultMessage: 'Remove from group',
|
||||
},
|
||||
})
|
||||
|
||||
function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -160,7 +223,6 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
const currentDeleteInstances = computed(() =>
|
||||
instances.value.filter((instance) => instance.id === currentDeleteInstanceId.value),
|
||||
)
|
||||
const currentContextGroupId = ref<string | null>(null)
|
||||
const confirmDeleteModal = ref<ConfirmDeleteModal | null>(null)
|
||||
const iconEditorModal = ref<IconEditorModal | null>(null)
|
||||
const currentIconEditorInstanceId = ref<string | null>(null)
|
||||
@@ -1156,121 +1218,153 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
trackEvent('InstanceSaveCreatedIcon')
|
||||
}
|
||||
|
||||
const setInstanceGroups = async (item: InstanceCard, groupIds: string[]) =>
|
||||
await edit(item.instance.id, { group_ids: groupIds }).catch((error) =>
|
||||
handleError(toError(error)),
|
||||
)
|
||||
|
||||
const buildInstanceIconOptions = (item: InstanceCard): ButtonMenuLeafOption[] => [
|
||||
{
|
||||
id: item.instance.icon_path ? 'replace_icon' : 'select_icon',
|
||||
label: formatMessage(
|
||||
item.instance.icon_path
|
||||
? instanceActionMessages.replaceIcon
|
||||
: instanceActionMessages.selectIcon,
|
||||
),
|
||||
icon: UploadIcon,
|
||||
action: () => void selectInstanceIcon(item),
|
||||
},
|
||||
{
|
||||
id: item.instance.icon_config ? 'edit_created_icon' : 'create_icon',
|
||||
label: formatMessage(
|
||||
item.instance.icon_config
|
||||
? instanceActionMessages.editCreatedIcon
|
||||
: instanceActionMessages.createIcon,
|
||||
),
|
||||
icon: PaletteIcon,
|
||||
action: () => void openInstanceIconEditor(item),
|
||||
},
|
||||
{
|
||||
id: 'remove_icon',
|
||||
label: formatMessage(instanceActionMessages.removeIcon),
|
||||
icon: TrashIcon,
|
||||
shown: !!item.instance.icon_path,
|
||||
action: () => void removeInstanceIcon(item),
|
||||
},
|
||||
]
|
||||
|
||||
const handleInstanceContextMenu = (
|
||||
event: MouseEvent,
|
||||
item: InstanceCard,
|
||||
instanceGroupId: string,
|
||||
) => {
|
||||
currentContextGroupId.value =
|
||||
const removableGroupId =
|
||||
displayState.value.group === 'Group' &&
|
||||
instanceGroupId !== 'group:none' &&
|
||||
instanceGroupId !== FAVORITES_GROUP_ID
|
||||
? instanceGroupId
|
||||
: null
|
||||
const isFavorite = item.instance.group_ids.includes(FAVORITES_GROUP_ID)
|
||||
const canAddContent = !item.instance.quarantined && !item.instance.link
|
||||
|
||||
const baseOptions = [
|
||||
instanceOptions.value?.open(event, [
|
||||
{
|
||||
name: item.instance.group_ids.includes(FAVORITES_GROUP_ID)
|
||||
? 'remove_from_favorites'
|
||||
: 'add_to_favorites',
|
||||
id: 'stop',
|
||||
label: formatMessage(instanceActionMessages.stop),
|
||||
icon: StopCircleIcon,
|
||||
shown: item.playing,
|
||||
tone: 'red',
|
||||
action: () => void item.stop(null, 'InstanceGridContextMenu'),
|
||||
},
|
||||
{
|
||||
id: 'play',
|
||||
label: formatMessage(instanceActionMessages.play),
|
||||
icon: PlayIcon,
|
||||
shown: !item.playing && !item.instance.quarantined,
|
||||
tone: 'brand',
|
||||
action: () => void item.play(null, 'InstanceGridContextMenu'),
|
||||
},
|
||||
{
|
||||
id: isFavorite ? 'remove_from_favorites' : 'add_to_favorites',
|
||||
label: formatMessage(
|
||||
isFavorite
|
||||
? instanceActionMessages.removeFromFavorites
|
||||
: instanceActionMessages.addToFavorites,
|
||||
),
|
||||
icon: StarIcon,
|
||||
action: () =>
|
||||
void setInstanceGroups(
|
||||
item,
|
||||
isFavorite
|
||||
? item.instance.group_ids.filter((groupId) => groupId !== FAVORITES_GROUP_ID)
|
||||
: [...new Set([...item.instance.group_ids, FAVORITES_GROUP_ID])],
|
||||
),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
...(!item.instance.quarantined && !item.instance.link
|
||||
? [{ name: 'add_content' }, { type: 'divider' }]
|
||||
: []),
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open' },
|
||||
{ name: 'copy' },
|
||||
{
|
||||
name: 'edit_icon',
|
||||
children: [
|
||||
{ name: item.instance.icon_path ? 'replace_icon' : 'select_icon' },
|
||||
{ name: item.instance.icon_config ? 'edit_created_icon' : 'create_icon' },
|
||||
...(item.instance.icon_path ? [{ name: 'remove_icon' }] : []),
|
||||
],
|
||||
id: 'add_content',
|
||||
label: formatMessage(instanceActionMessages.addContent),
|
||||
icon: PlusIcon,
|
||||
shown: canAddContent,
|
||||
action: () => void item.addContent(),
|
||||
},
|
||||
...(currentContextGroupId.value
|
||||
? [{ name: 'remove_from_group' }, { type: 'divider' }]
|
||||
: [{ type: 'divider' }]),
|
||||
{ name: 'delete', color: 'danger' },
|
||||
]
|
||||
|
||||
instanceOptions.value?.showMenu(
|
||||
event,
|
||||
item,
|
||||
item.playing
|
||||
? [{ name: 'stop', color: 'danger' }, ...baseOptions]
|
||||
: [
|
||||
...(item.instance.quarantined ? [] : [{ name: 'play', color: 'primary' }]),
|
||||
...baseOptions,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleInstanceOption = async ({ option, item }: ContextMenuSelection) => {
|
||||
switch (option) {
|
||||
case 'play':
|
||||
await item.play(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'stop':
|
||||
await item.stop(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'add_content':
|
||||
await item.addContent()
|
||||
break
|
||||
case 'add_to_favorites':
|
||||
await edit(item.instance.id, {
|
||||
group_ids: [...new Set([...item.instance.group_ids, FAVORITES_GROUP_ID])],
|
||||
}).catch((error) => handleError(toError(error)))
|
||||
break
|
||||
case 'remove_from_favorites':
|
||||
await edit(item.instance.id, {
|
||||
group_ids: item.instance.group_ids.filter(
|
||||
(instanceGroupId) => instanceGroupId !== FAVORITES_GROUP_ID,
|
||||
{ type: 'divider', shown: canAddContent },
|
||||
{
|
||||
id: 'edit',
|
||||
label: formatMessage(instanceActionMessages.viewInstance),
|
||||
icon: EyeIcon,
|
||||
action: () => void item.seeInstance(),
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: formatMessage(instanceActionMessages.duplicateInstance),
|
||||
icon: ClipboardCopyIcon,
|
||||
disabled: item.instance.install_stage !== 'installed',
|
||||
action: () => void duplicateInstance(item.instance.id),
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
label: formatMessage(instanceActionMessages.openFolder),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => void item.openFolder(),
|
||||
},
|
||||
{
|
||||
id: 'copy',
|
||||
label: formatMessage(instanceActionMessages.copyPath),
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => void navigator.clipboard.writeText(item.instance.id),
|
||||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
id: 'edit_icon',
|
||||
label: formatMessage(instanceActionMessages.editIcon),
|
||||
icon: EditIcon,
|
||||
options: buildInstanceIconOptions(item),
|
||||
},
|
||||
{
|
||||
id: 'remove_from_group',
|
||||
label: formatMessage(instanceActionMessages.removeFromGroup),
|
||||
icon: MinusIcon,
|
||||
shown: !!removableGroupId,
|
||||
action: () =>
|
||||
void setInstanceGroups(
|
||||
item,
|
||||
item.instance.group_ids.filter((groupId) => groupId !== removableGroupId),
|
||||
),
|
||||
}).catch((error) => handleError(toError(error)))
|
||||
break
|
||||
case 'edit':
|
||||
await item.seeInstance()
|
||||
break
|
||||
case 'select_icon':
|
||||
case 'replace_icon':
|
||||
await selectInstanceIcon(item)
|
||||
break
|
||||
case 'create_icon':
|
||||
case 'edit_created_icon':
|
||||
await openInstanceIconEditor(item)
|
||||
break
|
||||
case 'remove_icon':
|
||||
await removeInstanceIcon(item)
|
||||
break
|
||||
case 'duplicate':
|
||||
if (item.instance.install_stage === 'installed') {
|
||||
await duplicateInstance(item.instance.id)
|
||||
}
|
||||
break
|
||||
case 'open':
|
||||
await item.openFolder()
|
||||
break
|
||||
case 'copy':
|
||||
await navigator.clipboard.writeText(item.instance.id)
|
||||
break
|
||||
case 'remove_from_group':
|
||||
if (currentContextGroupId.value) {
|
||||
const groupId = currentContextGroupId.value
|
||||
await edit(item.instance.id, {
|
||||
group_ids: item.instance.group_ids.filter(
|
||||
(instanceGroupId) => instanceGroupId !== groupId,
|
||||
),
|
||||
}).catch((error) => handleError(toError(error)))
|
||||
}
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstanceId.value = item.instance.id
|
||||
confirmDeleteModal.value?.show()
|
||||
break
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
id: 'delete',
|
||||
label: formatMessage(instanceActionMessages.delete),
|
||||
icon: TrashIcon,
|
||||
tone: 'red',
|
||||
hoverFilledOnly: true,
|
||||
action: () => {
|
||||
currentDeleteInstanceId.value = item.instance.id
|
||||
confirmDeleteModal.value?.show()
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1339,7 +1433,6 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
moveGroup,
|
||||
deleteInstance,
|
||||
handleInstanceContextMenu,
|
||||
handleInstanceOption,
|
||||
handleInstanceIconSaved,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CoffeeIcon,
|
||||
GameIcon,
|
||||
GaugeIcon,
|
||||
HeartHandshakeIcon,
|
||||
LanguagesIcon,
|
||||
ModrinthIcon,
|
||||
PaintbrushIcon,
|
||||
RefreshCwIcon,
|
||||
Settings2Icon,
|
||||
ShieldIcon,
|
||||
ToggleRightIcon,
|
||||
@@ -33,7 +33,7 @@ import AppearanceSettings from '@/components/ui/settings/display/AppearanceSetti
|
||||
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
|
||||
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
|
||||
import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue'
|
||||
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.vue'
|
||||
import InstancesSyncedSettings from '@/components/ui/settings/instances/InstancesSyncedSettings.vue'
|
||||
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
|
||||
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
@@ -130,12 +130,12 @@ const tabs = [
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.default-instance-options',
|
||||
defaultMessage: 'Default game options',
|
||||
id: 'app.settings.tabs.synced-options',
|
||||
defaultMessage: 'Synced settings',
|
||||
}),
|
||||
category: tabCategories.instances,
|
||||
icon: GameIcon,
|
||||
content: DefaultInstanceSettings,
|
||||
icon: RefreshCwIcon,
|
||||
content: InstancesSyncedSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
@@ -222,7 +222,27 @@ function showProfile(): void {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ show, showProfile })
|
||||
function showFeatureFlags(): void {
|
||||
const featureFlagsTabIndex = availableTabs.value.findIndex(
|
||||
(tab) => tab.content === FeatureFlagSettings,
|
||||
)
|
||||
if (featureFlagsTabIndex >= 0) {
|
||||
modal.value?.setTab(featureFlagsTabIndex)
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function showSyncedOptions(): void {
|
||||
const syncedOptionsTabIndex = availableTabs.value.findIndex(
|
||||
(tab) => tab.content === InstancesSyncedSettings,
|
||||
)
|
||||
if (syncedOptionsTabIndex >= 0) {
|
||||
modal.value?.setTab(syncedOptionsTabIndex)
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ show, showProfile, showFeatureFlags, showSyncedOptions })
|
||||
|
||||
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
size="40px"
|
||||
class="!rounded-xl"
|
||||
no-shadow
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<span class="min-w-0 truncate font-semibold text-contrast">
|
||||
|
||||
@@ -120,7 +120,7 @@ import { ref } from 'vue'
|
||||
import { cancelLogin, type ModrinthAuthFlow } from '@/helpers/mr_auth'
|
||||
|
||||
const props = defineProps<{
|
||||
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
|
||||
requestAuth: (flow: ModrinthAuthFlow, addAccount?: boolean) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -130,6 +130,7 @@ const reopeningBrowser = ref(false)
|
||||
let resolveShow: ((signedIn: boolean) => void) | undefined
|
||||
let authenticationId = 0
|
||||
let activeAuthentication: Promise<void> | undefined
|
||||
let addingAccount = false
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resetAuthentication(true)
|
||||
@@ -143,9 +144,9 @@ function show(event?: MouseEvent) {
|
||||
})
|
||||
}
|
||||
|
||||
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
|
||||
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', addAccount = false, event?: MouseEvent) {
|
||||
const result = show(event)
|
||||
authenticate(flow)
|
||||
authenticate(flow, addAccount)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -154,13 +155,14 @@ function finish(signedIn: boolean) {
|
||||
resolveShow = undefined
|
||||
}
|
||||
|
||||
function authenticate(flow: ModrinthAuthFlow) {
|
||||
function authenticate(flow: ModrinthAuthFlow, addAccount = false) {
|
||||
const id = ++authenticationId
|
||||
authenticating.value = flow
|
||||
addingAccount = addAccount
|
||||
|
||||
const authentication = (async () => {
|
||||
try {
|
||||
if ((await props.requestAuth(flow)) && authenticationId === id) {
|
||||
if ((await props.requestAuth(flow, addAccount)) && authenticationId === id) {
|
||||
authenticating.value = null
|
||||
activeAuthentication = undefined
|
||||
finish(true)
|
||||
@@ -188,7 +190,7 @@ async function reopenBrowser() {
|
||||
try {
|
||||
await cancelLogin()
|
||||
await previousAuthentication?.catch(() => undefined)
|
||||
if (authenticating.value === flow) authenticate(flow)
|
||||
if (authenticating.value === flow) authenticate(flow, addingAccount)
|
||||
} finally {
|
||||
reopeningBrowser.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
|
||||
import { defineMessages, IconButton, useFormatDateTime, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
const loadedScreenshotUrls = new Set<string>()
|
||||
|
||||
const props = defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
selectionKey: string
|
||||
selected: boolean
|
||||
selectionActive: boolean
|
||||
activeDragged: boolean
|
||||
canDrag: boolean
|
||||
showInstanceName: boolean
|
||||
highlighted: boolean
|
||||
copied: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'activate', event: MouseEvent | KeyboardEvent): void
|
||||
(e: 'copy' | 'edit' | 'toggle-selection'): void
|
||||
(e: 'more', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const card = ref<HTMLElement>()
|
||||
const image = ref<HTMLImageElement>()
|
||||
const loaded = ref(loadedScreenshotUrls.has(props.screenshot.url))
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
const messages = defineMessages({
|
||||
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
|
||||
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
|
||||
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
|
||||
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
|
||||
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
|
||||
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
|
||||
})
|
||||
|
||||
const sensors = [
|
||||
PointerSensor.configure({
|
||||
preventActivation: () => false,
|
||||
}),
|
||||
KeyboardSensor,
|
||||
]
|
||||
|
||||
useDraggable({
|
||||
id: computed(() => `screenshot:${props.selectionKey}`),
|
||||
element: card,
|
||||
disabled: computed(() => !props.canDrag),
|
||||
sensors,
|
||||
data: computed(() => ({
|
||||
selectionKey: props.selectionKey,
|
||||
instanceId: props.screenshot.instance_id,
|
||||
})),
|
||||
})
|
||||
|
||||
function activate(event: MouseEvent | KeyboardEvent) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
emit('activate', event)
|
||||
}
|
||||
|
||||
function markImageLoaded() {
|
||||
loadedScreenshotUrls.add(props.screenshot.url)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.screenshot.url,
|
||||
(url) => {
|
||||
loaded.value = loadedScreenshotUrls.has(url)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
ref="card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group relative aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||
:class="{
|
||||
'!border-contrast brightness-110': selected,
|
||||
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
|
||||
'opacity-50': activeDragged,
|
||||
'cursor-grab active:cursor-grabbing': canDrag,
|
||||
}"
|
||||
data-screenshot-card
|
||||
:data-screenshot-id="screenshot.id"
|
||||
:data-selection-key="selectionKey"
|
||||
:aria-label="
|
||||
selectionActive
|
||||
? formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
})
|
||||
: screenshot.file_name
|
||||
"
|
||||
:aria-pressed="selectionActive ? selected : undefined"
|
||||
@click="activate"
|
||||
@contextmenu.prevent.stop="emit('more', $event)"
|
||||
@keydown="activate"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="selection-button group/selection absolute right-0.5 top-0 z-[2] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
|
||||
:aria-label="
|
||||
formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
})
|
||||
"
|
||||
:aria-pressed="selected"
|
||||
@click.stop="emit('toggle-selection')"
|
||||
>
|
||||
<span
|
||||
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
|
||||
:class="
|
||||
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
|
||||
"
|
||||
>
|
||||
<span v-if="selected" class="absolute inset-0 rounded-full bg-contrast" />
|
||||
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!loaded" class="absolute inset-0 animate-pulse bg-surface-3" />
|
||||
<img
|
||||
ref="image"
|
||||
:src="screenshot.url"
|
||||
:alt="screenshot.file_name"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
class="h-full w-full object-cover transition duration-200"
|
||||
:class="loaded ? 'opacity-100' : 'opacity-0'"
|
||||
@load="markImageLoaded"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
|
||||
{{ screenshot.file_name }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-secondary">
|
||||
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectionActive"
|
||||
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.edit)"
|
||||
:label="formatMessage(messages.edit)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-contrast hover:bg-surface-3"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
|
||||
:label="formatMessage(copied ? messages.copied : messages.copy)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-contrast hover:bg-surface-3"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
<CheckIcon v-if="copied" class="text-green" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moreActions)"
|
||||
:label="formatMessage(messages.moreActions)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-contrast hover:bg-surface-3"
|
||||
@click="emit('more', $event)"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import ScreenshotDragPreview from './drag-preview.vue'
|
||||
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
|
||||
|
||||
const props = defineProps<{
|
||||
items: ScreenshotDragGatherItem[]
|
||||
target: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const gathered = ref(false)
|
||||
const finishing = ref(false)
|
||||
let animationFrame: number | undefined
|
||||
let completionTimer: number | undefined
|
||||
|
||||
const itemContainerStyles = computed(() =>
|
||||
props.items.map((_, index) => ({
|
||||
left: `${props.target.x}px`,
|
||||
top: `${props.target.y}px`,
|
||||
zIndex: `${index}`,
|
||||
})),
|
||||
)
|
||||
|
||||
const itemStyles = computed(() =>
|
||||
props.items.map((item, index) => {
|
||||
const originX = item.rect.left - props.target.x
|
||||
const originY = item.rect.top - props.target.y
|
||||
|
||||
return {
|
||||
width: `${item.rect.width}px`,
|
||||
height: `${item.rect.height}px`,
|
||||
transform: gathered.value
|
||||
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
|
||||
: `translate3d(${originX}px, ${originY}px, 0)`,
|
||||
transitionDuration: `${gatherDuration}ms`,
|
||||
transitionDelay: `${Math.min(index, 8) * 10}ms`,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
gathered.value = true
|
||||
completionTimer = window.setTimeout(
|
||||
() => {
|
||||
finishing.value = true
|
||||
emit('complete')
|
||||
},
|
||||
Math.max(gatherDuration - 250, 0),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
if (completionTimer !== undefined) {
|
||||
clearTimeout(completionTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
|
||||
:class="finishing ? 'opacity-0' : 'opacity-100'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.selectionKey"
|
||||
class="fixed"
|
||||
:style="itemContainerStyles[index]"
|
||||
>
|
||||
<div
|
||||
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||
:style="itemStyles[index]"
|
||||
>
|
||||
<ScreenshotDragPreview :screenshot="item.screenshot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { TagItem } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
import { gatherDuration } from './use-screenshot-drag-gather'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
count?: number
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const showGatheredCount = ref(false)
|
||||
let countTimer: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
showGatheredCount.value = true
|
||||
return
|
||||
}
|
||||
|
||||
countTimer = window.setTimeout(() => {
|
||||
showGatheredCount.value = true
|
||||
countTimer = undefined
|
||||
}, gatherDuration - 150)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countTimer !== undefined) {
|
||||
clearTimeout(countTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-hidden="true" class="relative w-full select-none">
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
|
||||
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
|
||||
>
|
||||
<img :src="screenshot.url" alt="" class="h-full w-full object-cover" />
|
||||
<TagItem
|
||||
v-if="count > 1"
|
||||
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-secondary motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||
>
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
import ScreenshotCard from './card.vue'
|
||||
import ScreenshotSection from './section.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
title: string
|
||||
screenshots: InstanceScreenshot[]
|
||||
renderedScreenshots?: InstanceScreenshot[]
|
||||
virtualGridHeight?: number
|
||||
virtualGridTop?: number
|
||||
selectedKeys: ReadonlySet<string>
|
||||
selectionActive: boolean
|
||||
activeDraggedKeys: ReadonlySet<string>
|
||||
showDropOutline: boolean
|
||||
canDrag: boolean
|
||||
dropInstanceId?: string
|
||||
dropCustomGroup?: boolean
|
||||
dropCustomGroupId?: string
|
||||
showInstanceName: boolean
|
||||
highlightedScreenshotId?: string
|
||||
copiedScreenshotIds: ReadonlySet<string>
|
||||
forceOpen: boolean
|
||||
animateEntry: boolean
|
||||
hideHeader?: boolean
|
||||
editableTitle?: boolean
|
||||
startEditingTitle?: boolean
|
||||
maxTitleLength?: number
|
||||
validateTitle?: (value: string) => boolean
|
||||
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||
}>()
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed', { required: true })
|
||||
const dropTarget = ref<HTMLElement>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
emptyGroup: {
|
||||
id: 'app.screenshots.group.empty',
|
||||
defaultMessage: 'Drag and drop to add screenshots.',
|
||||
},
|
||||
})
|
||||
|
||||
const shouldShowGrid = computed(() =>
|
||||
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
|
||||
)
|
||||
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
|
||||
const renderGrid = ref(shouldShowGrid.value)
|
||||
const virtualGridStyle = computed(() =>
|
||||
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
|
||||
)
|
||||
const visibleGridStyle = computed(() =>
|
||||
props.virtualGridTop === undefined
|
||||
? undefined
|
||||
: { transform: `translateY(${props.virtualGridTop}px)` },
|
||||
)
|
||||
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
watch(
|
||||
() => props.renderedScreenshots ?? props.screenshots,
|
||||
(screenshots) => {
|
||||
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldShowGrid,
|
||||
(showGrid, previouslyShown) => {
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
if (showGrid) {
|
||||
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
|
||||
renderGrid.value = true
|
||||
return
|
||||
}
|
||||
if (!previouslyShown) {
|
||||
renderGrid.value = false
|
||||
return
|
||||
}
|
||||
unmountGridTimeout = setTimeout(() => {
|
||||
renderGrid.value = false
|
||||
unmountGridTimeout = undefined
|
||||
}, 300)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
|
||||
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
|
||||
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
useDroppable({
|
||||
id: computed(() => `screenshot-group:${props.id}`),
|
||||
element: dropTarget,
|
||||
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
|
||||
data: computed(() =>
|
||||
props.dropCustomGroup
|
||||
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
|
||||
: { groupId: props.id, instanceId: props.dropInstanceId },
|
||||
),
|
||||
})
|
||||
|
||||
function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropTarget"
|
||||
class="group/instance-container relative select-none pb-3 transition-colors"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="!opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="!opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropOutline"
|
||||
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-contrast bg-transparent opacity-40"
|
||||
/>
|
||||
</Transition>
|
||||
<ScreenshotSection
|
||||
v-model:collapsed="collapsed"
|
||||
:title="title"
|
||||
:count="screenshots.length"
|
||||
:force-open="forceOpen"
|
||||
:hide-header="hideHeader"
|
||||
:editable="editableTitle"
|
||||
:start-editing="startEditingTitle"
|
||||
:max-title-length="maxTitleLength"
|
||||
:validate-title="validateTitle"
|
||||
:on-title-change="onTitleChange"
|
||||
>
|
||||
<template #actions="{ startEditing }">
|
||||
<slot name="actions" :start-editing="startEditing" />
|
||||
</template>
|
||||
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
|
||||
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
|
||||
:style="visibleGridStyle"
|
||||
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
:enter-active-class="
|
||||
animateEntry
|
||||
? 'transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none'
|
||||
: ''
|
||||
"
|
||||
:enter-from-class="animateEntry ? 'opacity-0' : ''"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
>
|
||||
<ScreenshotCard
|
||||
v-for="screenshot in visibleScreenshots"
|
||||
:key="getSelectionKey(screenshot)"
|
||||
:screenshot="screenshot"
|
||||
:selection-key="getSelectionKey(screenshot)"
|
||||
:selected="selectedKeys.has(getSelectionKey(screenshot))"
|
||||
:selection-active="selectionActive"
|
||||
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
|
||||
:can-drag="canDrag"
|
||||
:show-instance-name="showInstanceName"
|
||||
:highlighted="highlightedScreenshotId === screenshot.id"
|
||||
:copied="copiedScreenshotIds.has(screenshot.id)"
|
||||
@activate="(event) => emit('activate', screenshot, event)"
|
||||
@toggle-selection="emit('toggle-selection', screenshot)"
|
||||
@copy="emit('copy', screenshot)"
|
||||
@edit="emit('edit', screenshot)"
|
||||
@more="(event) => emit('more', screenshot, event)"
|
||||
/>
|
||||
<p
|
||||
v-if="screenshots.length === 0"
|
||||
key="empty-group"
|
||||
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-secondary opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</ScreenshotSection>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
InlineEditableText,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
collapsed?: boolean
|
||||
forceOpen?: boolean
|
||||
hideHeader?: boolean
|
||||
editable?: boolean
|
||||
startEditing?: boolean
|
||||
maxTitleLength?: number
|
||||
validateTitle?: (value: string) => boolean
|
||||
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
forceOpen: false,
|
||||
hideHeader: false,
|
||||
editable: false,
|
||||
startEditing: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:collapsed', collapsed: boolean): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const accordion = ref<InstanceType<typeof Accordion>>()
|
||||
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const titleModel = ref(props.title)
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
|
||||
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
if (accordion.value?.isOpen) {
|
||||
accordion.value.close()
|
||||
} else {
|
||||
accordion.value?.open()
|
||||
}
|
||||
}
|
||||
|
||||
async function startTitleEditing() {
|
||||
if (!props.editable) return
|
||||
await titleInput.value?.startEditing()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(title) => {
|
||||
titleModel.value = title
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.startEditing,
|
||||
async (shouldStartEditing) => {
|
||||
if (!shouldStartEditing) return
|
||||
await nextTick()
|
||||
await startTitleEditing()
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex w-full flex-col">
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
>
|
||||
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
|
||||
:aria-expanded="accordion?.isOpen"
|
||||
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-5 shrink-0 text-secondary transition-all duration-300 group-hover/open-target:text-primary"
|
||||
:class="{ 'rotate-180': accordion?.isOpen }"
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="editable"
|
||||
ref="titleInput"
|
||||
v-model="titleModel"
|
||||
activation-mode="manual"
|
||||
class="!h-10 select-none text-base font-semibold text-primary group-hover/open-target:text-contrast"
|
||||
:edit-label="formatMessage(commonMessages.renameButton)"
|
||||
max-width="24rem"
|
||||
icon-text-class="select-none"
|
||||
:max-length="maxTitleLength"
|
||||
:validate="validateTitle"
|
||||
:on-change="onTitleChange"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="select-none truncate text-base font-semibold text-primary group-hover/open-target:text-contrast"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<slot name="actions" :start-editing="startTitleEditing" />
|
||||
</div>
|
||||
<Accordion
|
||||
ref="accordion"
|
||||
:open-by-default="hideHeader || !props.collapsed"
|
||||
:force-open="forceOpen"
|
||||
overflow-visible
|
||||
class="w-full"
|
||||
@on-open="emit('update:collapsed', false)"
|
||||
@on-close="emit('update:collapsed', true)"
|
||||
>
|
||||
<div class="mt-2.5">
|
||||
<slot />
|
||||
</div>
|
||||
</Accordion>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpDownIcon, LayoutGridIcon, SearchIcon, SquarePlusIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
Input,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const search = defineModel<string>('search', { required: true })
|
||||
const sort = defineModel<string>('sort', { required: true })
|
||||
const group = defineModel<string>('group', { required: true })
|
||||
|
||||
defineProps<{
|
||||
sortOptions: ComboboxOption<string>[]
|
||||
groupOptions: ComboboxOption<string>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'new-group'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
|
||||
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
|
||||
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
|
||||
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Input
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
clearable
|
||||
wrapper-class="min-w-[16rem] flex-1"
|
||||
/>
|
||||
<Button @click="emit('new-group')">
|
||||
<SquarePlusIcon />
|
||||
{{ formatMessage(messages.newGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
v-model="sort"
|
||||
class="w-max"
|
||||
:options="sortOptions"
|
||||
:show-icon-in-selected="false"
|
||||
dropdown-min-width="160px"
|
||||
>
|
||||
<template #prefix>
|
||||
<ArrowUpDownIcon
|
||||
class="size-5 text-primary"
|
||||
:aria-label="formatMessage(messages.sortBy)"
|
||||
/>
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-model="group"
|
||||
class="w-max"
|
||||
:options="groupOptions"
|
||||
:show-icon-in-selected="false"
|
||||
dropdown-min-width="160px"
|
||||
>
|
||||
<template #prefix>
|
||||
<LayoutGridIcon
|
||||
class="size-5 text-primary"
|
||||
:aria-label="formatMessage(messages.groupBy)"
|
||||
/>
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
import { onBeforeUnmount, type Ref, ref } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type ActiveScreenshotDrag = {
|
||||
primarySelectionKey: string
|
||||
selectionKeys: string[]
|
||||
}
|
||||
|
||||
export const gatherDuration = 500
|
||||
|
||||
export type ScreenshotDragGatherItem = {
|
||||
screenshot: InstanceScreenshot
|
||||
selectionKey: string
|
||||
rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
||||
}
|
||||
|
||||
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
|
||||
const items = ref<ScreenshotDragGatherItem[]>([])
|
||||
const target = ref<Point>({ x: 0, y: 0 })
|
||||
const targetOffset = ref<Point>({ x: 0, y: 0 })
|
||||
const isGathering = ref(false)
|
||||
let cleanupTimer: number | undefined
|
||||
|
||||
const clearCleanupTimer = () => {
|
||||
if (cleanupTimer !== undefined) {
|
||||
clearTimeout(cleanupTimer)
|
||||
cleanupTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
clearCleanupTimer()
|
||||
items.value = []
|
||||
targetOffset.value = { x: 0, y: 0 }
|
||||
isGathering.value = false
|
||||
}
|
||||
|
||||
const updateTarget = (pointer: Point) => {
|
||||
target.value = {
|
||||
x: pointer.x + targetOffset.value.x,
|
||||
y: pointer.y + targetOffset.value.y,
|
||||
}
|
||||
}
|
||||
|
||||
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
|
||||
clear()
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
|
||||
|
||||
const screenshotCards = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
|
||||
)
|
||||
const sourceCard = screenshotCards.find(
|
||||
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
|
||||
)
|
||||
|
||||
if (sourceCard) {
|
||||
const sourceRect = sourceCard.getBoundingClientRect()
|
||||
targetOffset.value = {
|
||||
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
|
||||
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
|
||||
}
|
||||
}
|
||||
|
||||
const screenshotsByKey = new Map(
|
||||
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
|
||||
)
|
||||
items.value = drag.selectionKeys.flatMap((selectionKey) => {
|
||||
const screenshot = screenshotsByKey.get(selectionKey)
|
||||
const card = screenshotCards.find(
|
||||
(candidate) =>
|
||||
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
|
||||
)
|
||||
if (!screenshot || !card) return []
|
||||
|
||||
const rect = card.getBoundingClientRect()
|
||||
return [
|
||||
{
|
||||
screenshot,
|
||||
selectionKey,
|
||||
rect: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
updateTarget(pointer)
|
||||
isGathering.value = items.value.length > 0
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
isGathering.value = false
|
||||
clearCleanupTimer()
|
||||
cleanupTimer = window.setTimeout(() => {
|
||||
items.value = []
|
||||
cleanupTimer = undefined
|
||||
}, 400)
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearCleanupTimer)
|
||||
|
||||
return {
|
||||
items,
|
||||
target,
|
||||
isGathering,
|
||||
start,
|
||||
updateTarget,
|
||||
clear,
|
||||
finish,
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center justify-between gap-4">
|
||||
<div class="mt-8 first:mt-0 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.telemetryTitle) }}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { type ColorTheme, useTheme } from '@/composables/use-theme.ts'
|
||||
import { type ColorTheme, isDarkTheme, useTheme } from '@/composables/use-theme.ts'
|
||||
import { type AppSettings, get, set } from '@/helpers/settings.ts'
|
||||
import { getOS } from '@/helpers/utils'
|
||||
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
|
||||
@@ -60,6 +60,9 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
|
||||
|
||||
await set(nextSettings)
|
||||
settings.value = nextSettings
|
||||
if (isDarkTheme(value.theme)) {
|
||||
theme.preferredDark = value.theme
|
||||
}
|
||||
theme.preferred = value.theme
|
||||
theme.syncAcrossDevices = value.syncAcrossDevices
|
||||
theme.advancedRendering = value.advancedRendering
|
||||
@@ -73,6 +76,10 @@ const themeOptions = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const preferredDarkTheme = computed(() =>
|
||||
isDarkTheme(current.value.theme) ? current.value.theme : theme.preferredDark,
|
||||
)
|
||||
|
||||
function setTheme(value: ColorTheme): void {
|
||||
current.value.theme = value
|
||||
}
|
||||
@@ -126,7 +133,8 @@ provideAppearanceSettings({
|
||||
theme: {
|
||||
current: computed(() => current.value.theme),
|
||||
options: themeOptions,
|
||||
system: computed(() => theme.native),
|
||||
system: computed(() => (theme.native === 'light' ? 'light' : preferredDarkTheme.value)),
|
||||
preferredDark: preferredDarkTheme,
|
||||
set: setTheme,
|
||||
syncAcrossDevices: {
|
||||
value: computed(() => current.value.syncAcrossDevices),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Toggle } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { Button, Input, Toggle } from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
DEFAULT_FEATURE_FLAGS,
|
||||
@@ -12,7 +14,22 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
const appSettings = useAppSettings()
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const options = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
|
||||
const searchQuery = ref('')
|
||||
const allFlags = computed(() => Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
|
||||
|
||||
const fuse = computed(
|
||||
() =>
|
||||
new Fuse(allFlags.value, {
|
||||
threshold: 0.4,
|
||||
}),
|
||||
)
|
||||
|
||||
const filteredFlags = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return allFlags.value
|
||||
}
|
||||
return fuse.value.search(searchQuery.value).map((result) => result.item)
|
||||
})
|
||||
|
||||
function setFeatureFlag(key: FeatureFlag, value: boolean) {
|
||||
appSettings.featureFlags[key] = value
|
||||
@@ -29,7 +46,14 @@ watch(
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-for="option in options" :key="option" class="flex items-center justify-between">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
placeholder="Search flags..."
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div v-for="option in filteredFlags" :key="option" class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
|
||||
{{ option.replaceAll('_', ' ') }}
|
||||
@@ -50,5 +74,8 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="filteredFlags.length === 0" class="text-center text-secondary">
|
||||
No flags found matching "{{ searchQuery }}"
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Slider,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { get, parseEnvVars, serializeEnvVars, set } from '@/helpers/settings.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
fullscreenTitle: {
|
||||
id: 'app.settings.default-instance-options.fullscreen.title',
|
||||
defaultMessage: 'Fullscreen',
|
||||
},
|
||||
fullscreenDescription: {
|
||||
id: 'app.settings.default-instance-options.fullscreen.description',
|
||||
defaultMessage: 'Start instances in fullscreen by updating their options.txt file.',
|
||||
},
|
||||
widthTitle: {
|
||||
id: 'app.settings.default-instance-options.width.title',
|
||||
defaultMessage: 'Width',
|
||||
},
|
||||
widthDescription: {
|
||||
id: 'app.settings.default-instance-options.width.description',
|
||||
defaultMessage: 'The width of the game window when launched.',
|
||||
},
|
||||
widthPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.width.placeholder',
|
||||
defaultMessage: 'Enter width...',
|
||||
},
|
||||
heightTitle: {
|
||||
id: 'app.settings.default-instance-options.height.title',
|
||||
defaultMessage: 'Height',
|
||||
},
|
||||
heightDescription: {
|
||||
id: 'app.settings.default-instance-options.height.description',
|
||||
defaultMessage: 'The height of the game window when launched.',
|
||||
},
|
||||
heightPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.height.placeholder',
|
||||
defaultMessage: 'Enter height...',
|
||||
},
|
||||
memoryAllocationTitle: {
|
||||
id: 'app.settings.default-instance-options.memory-allocation.title',
|
||||
defaultMessage: 'Memory allocation',
|
||||
},
|
||||
memoryAllocationDescription: {
|
||||
id: 'app.settings.default-instance-options.memory-allocation.description',
|
||||
defaultMessage: 'Maximum memory available to each instance.',
|
||||
},
|
||||
javaArgumentsTitle: {
|
||||
id: 'app.settings.default-instance-options.java-arguments.title',
|
||||
defaultMessage: 'Java arguments',
|
||||
},
|
||||
javaArgumentsPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.java-arguments.placeholder',
|
||||
defaultMessage: 'Enter Java arguments...',
|
||||
},
|
||||
javaArgumentsDescription: {
|
||||
id: 'app.settings.default-instance-options.java-arguments.description',
|
||||
defaultMessage: 'Arguments passed to Java when launching an instance.',
|
||||
},
|
||||
environmentVariablesTitle: {
|
||||
id: 'app.settings.default-instance-options.environment-variables.title',
|
||||
defaultMessage: 'Environment variables',
|
||||
},
|
||||
environmentVariablesPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.environment-variables.placeholder',
|
||||
defaultMessage: 'Enter environment variables...',
|
||||
},
|
||||
environmentVariablesDescription: {
|
||||
id: 'app.settings.default-instance-options.environment-variables.description',
|
||||
defaultMessage: 'Environment variables set when launching an instance.',
|
||||
},
|
||||
preLaunchHookTitle: {
|
||||
id: 'app.settings.default-instance-options.pre-launch-hook.title',
|
||||
defaultMessage: 'Pre-launch hook',
|
||||
},
|
||||
preLaunchHookPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.pre-launch-hook.placeholder',
|
||||
defaultMessage: 'Enter pre-launch command...',
|
||||
},
|
||||
preLaunchHookDescription: {
|
||||
id: 'app.settings.default-instance-options.pre-launch-hook.description',
|
||||
defaultMessage: 'Runs before the instance starts.',
|
||||
},
|
||||
wrapperHookTitle: {
|
||||
id: 'app.settings.default-instance-options.wrapper-hook.title',
|
||||
defaultMessage: 'Wrapper hook',
|
||||
},
|
||||
wrapperHookPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.wrapper-hook.placeholder',
|
||||
defaultMessage: 'Enter wrapper command...',
|
||||
},
|
||||
wrapperHookDescription: {
|
||||
id: 'app.settings.default-instance-options.wrapper-hook.description',
|
||||
defaultMessage: 'Command used to wrap the Minecraft launch process.',
|
||||
},
|
||||
postExitHookTitle: {
|
||||
id: 'app.settings.default-instance-options.post-exit-hook.title',
|
||||
defaultMessage: 'Post-exit hook',
|
||||
},
|
||||
postExitHookPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.post-exit-hook.placeholder',
|
||||
defaultMessage: 'Enter post-exit command...',
|
||||
},
|
||||
postExitHookDescription: {
|
||||
id: 'app.settings.default-instance-options.post-exit-hook.description',
|
||||
defaultMessage: 'Runs after the game closes.',
|
||||
},
|
||||
hookVariablesDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.description',
|
||||
defaultMessage:
|
||||
'Hooks run in the working directory of the instance, with the following variables:',
|
||||
},
|
||||
instanceNameDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-name.description',
|
||||
defaultMessage: '$INST_NAME: The name of the instance',
|
||||
},
|
||||
instanceIdDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-id.description',
|
||||
defaultMessage: "$INST_ID: The name of the instance's folder",
|
||||
},
|
||||
instanceDirDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
|
||||
defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
|
||||
},
|
||||
instanceMcDirDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
|
||||
defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
|
||||
},
|
||||
instanceJavaDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-java.description',
|
||||
defaultMessage: '$INST_JAVA: The absolute path to the java binary',
|
||||
},
|
||||
instanceJavaArgsDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
|
||||
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
|
||||
},
|
||||
})
|
||||
|
||||
const fetchSettings = await get()
|
||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||
fetchSettings.envVars = serializeEnvVars(fetchSettings.custom_env_vars)
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
|
||||
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
|
||||
maxMemory: number
|
||||
snapPoints: number[]
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
const setSettings = JSON.parse(JSON.stringify(settings.value))
|
||||
|
||||
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
|
||||
setSettings.custom_env_vars = parseEnvVars(setSettings.envVars)
|
||||
delete setSettings.launchArgs
|
||||
delete setSettings.envVars
|
||||
|
||||
if (!setSettings.custom_dir) {
|
||||
setSettings.custom_dir = null
|
||||
}
|
||||
|
||||
await set(setSettings).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.fullscreenTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.fullscreenDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.widthTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.widthDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<StyledInput
|
||||
id="width"
|
||||
v-model="settings.game_resolution[0]"
|
||||
:disabled="settings.force_fullscreen"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.widthPlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.heightTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.heightDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<StyledInput
|
||||
id="height"
|
||||
v-model="settings.game_resolution[1]"
|
||||
:disabled="settings.force_fullscreen"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.heightPlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-6 bg-button-border border-none h-[1px]" />
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.memoryAllocationTitle) }}
|
||||
</h2>
|
||||
<Slider
|
||||
id="max-memory"
|
||||
v-model="settings.memory.maximum"
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<p class="m-0 mt-1 leading-tight">
|
||||
{{ formatMessage(messages.memoryAllocationDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.javaArgumentsTitle) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="java-args"
|
||||
v-model="settings.launchArgs"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.javaArgumentsDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.environmentVariablesTitle) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="env-vars"
|
||||
v-model="settings.envVars"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.environmentVariablesDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-6 bg-button-border border-none h-[1px]" />
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preLaunchHookTitle) }}
|
||||
</h3>
|
||||
<StyledInput
|
||||
id="pre-launch"
|
||||
v-model="settings.hooks.pre_launch"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.preLaunchHookPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.preLaunchHookDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.wrapperHookTitle) }}
|
||||
</h3>
|
||||
<StyledInput
|
||||
id="wrapper"
|
||||
v-model="settings.hooks.wrapper"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.wrapperHookPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.wrapperHookDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.postExitHookTitle) }}
|
||||
</h3>
|
||||
<StyledInput
|
||||
id="post-exit"
|
||||
v-model="settings.hooks.post_exit"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.postExitHookPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.postExitHookDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.hookVariablesDescription) }}
|
||||
<ul>
|
||||
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,887 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EditIcon,
|
||||
// FolderOpenIcon,
|
||||
SaveIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
Input,
|
||||
NewModal,
|
||||
Slider,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import {
|
||||
get_command_history,
|
||||
get_global_synced_options,
|
||||
type GlobalSyncedOptions,
|
||||
list_synced_servers,
|
||||
// open_synced_options_folder,
|
||||
remove_synced_server,
|
||||
set_command_history,
|
||||
set_global_synced_option,
|
||||
type SyncedOption,
|
||||
type SyncedServer,
|
||||
update_synced_server,
|
||||
} from '@/helpers/instance'
|
||||
import { get, parseEnvVars, serializeEnvVars, set } from '@/helpers/settings.ts'
|
||||
import {
|
||||
refreshServerData,
|
||||
refreshServers,
|
||||
type ServerData,
|
||||
type ServerWorld,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const messages = defineMessages({
|
||||
// syncedDescription: {
|
||||
// id: 'app.settings.synced-options.description',
|
||||
// defaultMessage:
|
||||
// 'Sync options and config across instances so you don’t have to set them up every time.',
|
||||
// },
|
||||
// syncedFolder: {
|
||||
// id: 'app.settings.synced-options.folder',
|
||||
// defaultMessage: 'Synced folder',
|
||||
// },
|
||||
multiplayerServers: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers',
|
||||
defaultMessage: 'Multiplayer servers',
|
||||
},
|
||||
multiplayerServersDescription: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.description',
|
||||
defaultMessage: 'Sync multiplayer servers across your instances.',
|
||||
},
|
||||
commandHistory: {
|
||||
id: 'app.settings.synced-options.command-history',
|
||||
defaultMessage: 'Command history',
|
||||
},
|
||||
commandHistoryDescription: {
|
||||
id: 'app.settings.synced-options.command-history.description',
|
||||
defaultMessage: 'Sync command history across your instances.',
|
||||
},
|
||||
creativeHotbars: {
|
||||
id: 'app.settings.synced-options.creative-hotbars',
|
||||
defaultMessage: 'Saved creative hotbars',
|
||||
},
|
||||
creativeHotbarsDescription: {
|
||||
id: 'app.settings.synced-options.creative-hotbars.description',
|
||||
defaultMessage: 'Sync saved creative hotbars across your instances.',
|
||||
},
|
||||
screenshots: {
|
||||
id: 'app.settings.synced-options.screenshots',
|
||||
defaultMessage: 'Screenshots',
|
||||
},
|
||||
screenshotsDescription: {
|
||||
id: 'app.settings.synced-options.screenshots.description',
|
||||
defaultMessage: 'View screenshots from your instances in one place.',
|
||||
},
|
||||
commandHistoryEditorTitle: {
|
||||
id: 'app.settings.synced-options.command-history.editor-title',
|
||||
defaultMessage: 'Edit command history',
|
||||
},
|
||||
serverEditorTitle: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.editor-title',
|
||||
defaultMessage: 'Edit synced servers',
|
||||
},
|
||||
editServerTitle: {
|
||||
id: 'instance.edit-server.title',
|
||||
defaultMessage: 'Edit server',
|
||||
},
|
||||
serverName: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.name',
|
||||
defaultMessage: 'Server name',
|
||||
},
|
||||
serverAddress: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.address',
|
||||
defaultMessage: 'Server address',
|
||||
},
|
||||
noSyncedServers: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.empty',
|
||||
defaultMessage: 'No user-added servers are currently synced.',
|
||||
},
|
||||
noServersSyncedYet: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.none-synced-yet',
|
||||
defaultMessage: 'No servers synced yet',
|
||||
},
|
||||
windowSectionTitle: {
|
||||
id: 'app.settings.default-instance-options.window.title',
|
||||
defaultMessage: 'Window',
|
||||
},
|
||||
javaAndMemorySectionTitle: {
|
||||
id: 'app.settings.default-instance-options.java-and-memory.title',
|
||||
defaultMessage: 'Java and memory',
|
||||
},
|
||||
launchHooksSectionTitle: {
|
||||
id: 'app.settings.default-instance-options.launch-hooks.title',
|
||||
defaultMessage: 'Launch hooks',
|
||||
},
|
||||
fullscreenTitle: {
|
||||
id: 'app.settings.default-instance-options.fullscreen.title',
|
||||
defaultMessage: 'Fullscreen',
|
||||
},
|
||||
fullscreenDescription: {
|
||||
id: 'app.settings.default-instance-options.fullscreen.description',
|
||||
defaultMessage: 'Start instances in fullscreen by updating their options.txt file.',
|
||||
},
|
||||
widthTitle: {
|
||||
id: 'app.settings.default-instance-options.width.title',
|
||||
defaultMessage: 'Width',
|
||||
},
|
||||
widthDescription: {
|
||||
id: 'app.settings.default-instance-options.width.description',
|
||||
defaultMessage: 'The width of the game window when launched.',
|
||||
},
|
||||
widthPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.width.placeholder',
|
||||
defaultMessage: 'Enter width...',
|
||||
},
|
||||
heightTitle: {
|
||||
id: 'app.settings.default-instance-options.height.title',
|
||||
defaultMessage: 'Height',
|
||||
},
|
||||
heightDescription: {
|
||||
id: 'app.settings.default-instance-options.height.description',
|
||||
defaultMessage: 'The height of the game window when launched.',
|
||||
},
|
||||
heightPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.height.placeholder',
|
||||
defaultMessage: 'Enter height...',
|
||||
},
|
||||
memoryAllocationTitle: {
|
||||
id: 'app.settings.default-instance-options.memory-allocation.title',
|
||||
defaultMessage: 'Memory allocation',
|
||||
},
|
||||
memoryAllocationDescription: {
|
||||
id: 'app.settings.default-instance-options.memory-allocation.description',
|
||||
defaultMessage: 'Maximum memory available to each instance.',
|
||||
},
|
||||
javaArgumentsTitle: {
|
||||
id: 'app.settings.default-instance-options.java-arguments.title',
|
||||
defaultMessage: 'Java arguments',
|
||||
},
|
||||
javaArgumentsPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.java-arguments.placeholder',
|
||||
defaultMessage: 'Enter Java arguments...',
|
||||
},
|
||||
javaArgumentsDescription: {
|
||||
id: 'app.settings.default-instance-options.java-arguments.description',
|
||||
defaultMessage: 'Arguments passed to Java when launching an instance.',
|
||||
},
|
||||
environmentVariablesTitle: {
|
||||
id: 'app.settings.default-instance-options.environment-variables.title',
|
||||
defaultMessage: 'Environment variables',
|
||||
},
|
||||
environmentVariablesPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.environment-variables.placeholder',
|
||||
defaultMessage: 'Enter environment variables...',
|
||||
},
|
||||
environmentVariablesDescription: {
|
||||
id: 'app.settings.default-instance-options.environment-variables.description',
|
||||
defaultMessage: 'Environment variables set when launching an instance.',
|
||||
},
|
||||
preLaunchHookTitle: {
|
||||
id: 'app.settings.default-instance-options.pre-launch-hook.title',
|
||||
defaultMessage: 'Pre-launch hook',
|
||||
},
|
||||
preLaunchHookPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.pre-launch-hook.placeholder',
|
||||
defaultMessage: 'Enter pre-launch command...',
|
||||
},
|
||||
preLaunchHookDescription: {
|
||||
id: 'app.settings.default-instance-options.pre-launch-hook.description',
|
||||
defaultMessage: 'Runs before the instance starts.',
|
||||
},
|
||||
wrapperHookTitle: {
|
||||
id: 'app.settings.default-instance-options.wrapper-hook.title',
|
||||
defaultMessage: 'Wrapper hook',
|
||||
},
|
||||
wrapperHookPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.wrapper-hook.placeholder',
|
||||
defaultMessage: 'Enter wrapper command...',
|
||||
},
|
||||
wrapperHookDescription: {
|
||||
id: 'app.settings.default-instance-options.wrapper-hook.description',
|
||||
defaultMessage: 'Command used to wrap the Minecraft launch process.',
|
||||
},
|
||||
postExitHookTitle: {
|
||||
id: 'app.settings.default-instance-options.post-exit-hook.title',
|
||||
defaultMessage: 'Post-exit hook',
|
||||
},
|
||||
postExitHookPlaceholder: {
|
||||
id: 'app.settings.default-instance-options.post-exit-hook.placeholder',
|
||||
defaultMessage: 'Enter post-exit command...',
|
||||
},
|
||||
postExitHookDescription: {
|
||||
id: 'app.settings.default-instance-options.post-exit-hook.description',
|
||||
defaultMessage: 'Runs after the game closes.',
|
||||
},
|
||||
hookVariablesDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.description',
|
||||
defaultMessage:
|
||||
'Hooks run in the working directory of the instance, with the following variables:',
|
||||
},
|
||||
instanceNameDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-name.description',
|
||||
defaultMessage: '$INST_NAME: The name of the instance',
|
||||
},
|
||||
instanceIdDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-id.description',
|
||||
defaultMessage: "$INST_ID: The name of the instance's folder",
|
||||
},
|
||||
instanceDirDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
|
||||
defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
|
||||
},
|
||||
instanceMcDirDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
|
||||
defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
|
||||
},
|
||||
instanceJavaDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-java.description',
|
||||
defaultMessage: '$INST_JAVA: The absolute path to the java binary',
|
||||
},
|
||||
instanceJavaArgsDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
|
||||
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
|
||||
},
|
||||
})
|
||||
|
||||
const globalRows: Array<{
|
||||
option: SyncedOption
|
||||
title: keyof typeof messages
|
||||
description?: keyof typeof messages
|
||||
editable?: 'servers' | 'commands'
|
||||
}> = [
|
||||
{
|
||||
option: 'multiplayer_servers',
|
||||
title: 'multiplayerServers',
|
||||
description: 'multiplayerServersDescription',
|
||||
editable: 'servers',
|
||||
},
|
||||
{
|
||||
option: 'command_history',
|
||||
title: 'commandHistory',
|
||||
description: 'commandHistoryDescription',
|
||||
editable: 'commands',
|
||||
},
|
||||
{
|
||||
option: 'creative_hotbars',
|
||||
title: 'creativeHotbars',
|
||||
description: 'creativeHotbarsDescription',
|
||||
},
|
||||
{
|
||||
option: 'screenshots',
|
||||
title: 'screenshots',
|
||||
description: 'screenshotsDescription',
|
||||
},
|
||||
]
|
||||
|
||||
const globalSyncedOptionsQueryKey = ['global-synced-options'] as const
|
||||
const globalSyncedOptionsMutationKey = ['global-synced-options', 'set'] as const
|
||||
const defaultGlobalOptions: GlobalSyncedOptions = {
|
||||
command_history: false,
|
||||
multiplayer_servers: false,
|
||||
creative_hotbars: false,
|
||||
screenshots: false,
|
||||
}
|
||||
|
||||
const globalOptionsQuery = useQuery({
|
||||
queryKey: globalSyncedOptionsQueryKey,
|
||||
queryFn: get_global_synced_options,
|
||||
})
|
||||
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
|
||||
const commandHistoryModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const commandHistory = ref('')
|
||||
const syncedServers = ref<SyncedServer[]>(
|
||||
(await list_synced_servers().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? [],
|
||||
)
|
||||
const editedServer = ref<SyncedServer | null>(null)
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
const editorComponent = shallowRef<Component | null>(null)
|
||||
|
||||
const syncedServerCards = computed(() =>
|
||||
syncedServers.value.map((server, index) => ({
|
||||
server,
|
||||
world: {
|
||||
name: server.name,
|
||||
type: 'server',
|
||||
index,
|
||||
server_id: server.id,
|
||||
address: server.address,
|
||||
pack_status:
|
||||
server.accept_textures === true
|
||||
? 'enabled'
|
||||
: server.accept_textures === false
|
||||
? 'disabled'
|
||||
: 'prompt',
|
||||
display_status: 'normal',
|
||||
} satisfies ServerWorld,
|
||||
})),
|
||||
)
|
||||
|
||||
async function invalidateSyncedOptions() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
|
||||
queryClient.invalidateQueries({ queryKey: globalSyncedOptionsQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: screenshotKeys.all }),
|
||||
])
|
||||
}
|
||||
|
||||
type GlobalOptionMutationVariables = {
|
||||
option: SyncedOption
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const globalOptionMutation = useMutation({
|
||||
mutationKey: globalSyncedOptionsMutationKey,
|
||||
mutationFn: ({ option, enabled }: GlobalOptionMutationVariables) =>
|
||||
set_global_synced_option(option, enabled),
|
||||
onMutate: async ({ option, enabled }) => {
|
||||
await queryClient.cancelQueries({ queryKey: globalSyncedOptionsQueryKey })
|
||||
const previous = globalOptions.value[option]
|
||||
|
||||
queryClient.setQueryData<GlobalSyncedOptions>(globalSyncedOptionsQueryKey, (current) => ({
|
||||
...(current ?? defaultGlobalOptions),
|
||||
[option]: enabled,
|
||||
}))
|
||||
|
||||
return { previous }
|
||||
},
|
||||
onError: (error, { option }, context) => {
|
||||
queryClient.setQueryData<GlobalSyncedOptions>(globalSyncedOptionsQueryKey, (current) => ({
|
||||
...(current ?? defaultGlobalOptions),
|
||||
[option]: context?.previous ?? defaultGlobalOptions[option],
|
||||
}))
|
||||
handleError(error)
|
||||
},
|
||||
onSettled: async () => {
|
||||
if (queryClient.isMutating({ mutationKey: globalSyncedOptionsMutationKey }) === 1) {
|
||||
await invalidateSyncedOptions()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function applyGlobalOption(option: SyncedOption, enabled: boolean) {
|
||||
globalOptionMutation.mutate({ option, enabled })
|
||||
}
|
||||
|
||||
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
|
||||
applyGlobalOption(option, enabled)
|
||||
}
|
||||
|
||||
async function openCommandHistoryEditor() {
|
||||
commandHistory.value = await get_command_history().catch((error) => {
|
||||
handleError(error)
|
||||
return ''
|
||||
})
|
||||
if (!editorComponent.value) {
|
||||
const [editor] = await Promise.all([
|
||||
import('vue3-ace-editor'),
|
||||
import('@modrinth/ui/src/utils/ace-theme'),
|
||||
import('@modrinth/ui/src/utils/ace-mode-mcfunction'),
|
||||
])
|
||||
editorComponent.value = editor.VAceEditor
|
||||
}
|
||||
commandHistoryModal.value?.show()
|
||||
}
|
||||
|
||||
async function saveCommandHistory() {
|
||||
try {
|
||||
commandHistory.value = await set_command_history(commandHistory.value)
|
||||
commandHistoryModal.value?.hide()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function openServerEditor() {
|
||||
syncedServers.value = await list_synced_servers().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
serverData.value = {}
|
||||
serverEditorModal.value?.show()
|
||||
await refreshServers(
|
||||
syncedServerCards.value.map(({ world }) => world),
|
||||
serverData.value,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
function openSyncedServerEditor(server: SyncedServer) {
|
||||
editedServer.value = { ...server }
|
||||
editServerModal.value?.show()
|
||||
}
|
||||
|
||||
async function saveSyncedServer() {
|
||||
if (!editedServer.value) return
|
||||
const server = editedServer.value
|
||||
|
||||
try {
|
||||
await update_synced_server(server)
|
||||
const index = syncedServers.value.findIndex(({ id }) => id === server.id)
|
||||
if (index !== -1) {
|
||||
syncedServers.value[index] = { ...server }
|
||||
}
|
||||
editServerModal.value?.hide()
|
||||
serverData.value[server.address] = { refreshing: true }
|
||||
await refreshServerData(serverData.value[server.address], null, server.address)
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSyncedServer(address: string) {
|
||||
serverData.value[address] ??= { refreshing: true }
|
||||
await refreshServerData(serverData.value[address], null, address)
|
||||
}
|
||||
|
||||
async function removeSyncedServer(serverId: string) {
|
||||
try {
|
||||
await remove_synced_server(serverId)
|
||||
syncedServers.value = syncedServers.value.filter((server) => server.id !== serverId)
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSettings = await get()
|
||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||
fetchSettings.envVars = serializeEnvVars(fetchSettings.custom_env_vars)
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
|
||||
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
|
||||
maxMemory: number
|
||||
snapPoints: number[]
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
const setSettings = JSON.parse(JSON.stringify(settings.value))
|
||||
|
||||
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
|
||||
setSettings.custom_env_vars = parseEnvVars(setSettings.envVars)
|
||||
delete setSettings.launchArgs
|
||||
delete setSettings.envVars
|
||||
|
||||
if (!setSettings.custom_dir) {
|
||||
setSettings.custom_dir = null
|
||||
}
|
||||
|
||||
await set(setSettings).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NewModal
|
||||
ref="commandHistoryModal"
|
||||
:header="formatMessage(messages.commandHistoryEditorTitle)"
|
||||
class="command-history-modal"
|
||||
max-width="700px"
|
||||
width="700px"
|
||||
>
|
||||
<component
|
||||
:is="editorComponent"
|
||||
v-if="editorComponent"
|
||||
v-model:value="commandHistory"
|
||||
lang="mcfunction"
|
||||
theme="modrinth"
|
||||
:print-margin="false"
|
||||
class="command-history-editor ace-modrinth rounded-[20px] !border !border-solid !border-surface-5"
|
||||
style="height: 420px; font-size: 0.875rem"
|
||||
/>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" @click="commandHistoryModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" @click="saveCommandHistory">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="serverEditorModal"
|
||||
:header="formatMessage(messages.serverEditorTitle)"
|
||||
scrollable
|
||||
actions-divider
|
||||
no-padding
|
||||
max-content-height="34.5rem"
|
||||
max-width="750px"
|
||||
width="750px"
|
||||
>
|
||||
<p v-if="syncedServers.length === 0" class="m-0 px-6 py-4 text-secondary">
|
||||
{{ formatMessage(messages.noSyncedServers) }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col gap-2 px-6 py-4">
|
||||
<WorldItem
|
||||
v-for="{ server, world } in syncedServerCards"
|
||||
:key="server.id"
|
||||
:world="world"
|
||||
card-background="surface-2"
|
||||
:show-play-button="false"
|
||||
:refreshing="serverData[server.address]?.refreshing"
|
||||
:server-status="serverData[server.address]?.status"
|
||||
:rendered-motd="serverData[server.address]?.renderedMotd"
|
||||
@refresh="refreshSyncedServer(server.address)"
|
||||
@edit="openSyncedServerEditor(server)"
|
||||
@delete="removeSyncedServer(server.id)"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end">
|
||||
<Button type="outlined" @click="serverEditorModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.closeButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="editServerModal"
|
||||
:header="formatMessage(messages.editServerTitle)"
|
||||
max-width="500px"
|
||||
width="500px"
|
||||
>
|
||||
<div v-if="editedServer" class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(messages.serverName) }}
|
||||
<Input v-model="editedServer.name" autocomplete="off" wrapper-class="w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(messages.serverAddress) }}
|
||||
<Input v-model="editedServer.address" autocomplete="off" wrapper-class="w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" @click="editServerModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!editedServer?.address"
|
||||
@click="saveSyncedServer"
|
||||
>
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<section class="border-0 border-b border-solid border-divider pb-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<!--
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.syncedDescription) }}</p>
|
||||
<Button @click="open_synced_options_folder().catch(handleError)">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(messages.syncedFolder) }}
|
||||
</Button>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="row in globalRows"
|
||||
:key="row.option"
|
||||
class="flex items-center justify-between gap-6"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages[row.title]) }}
|
||||
</h2>
|
||||
<p v-if="row.description" class="m-0 text-secondary">
|
||||
{{ formatMessage(messages[row.description]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="row.editable"
|
||||
v-tooltip="
|
||||
row.editable === 'servers' && syncedServers.length === 0
|
||||
? formatMessage(messages.noServersSyncedYet)
|
||||
: formatMessage(commonMessages.editButton)
|
||||
"
|
||||
class="flex"
|
||||
>
|
||||
<IconButton
|
||||
type="outlined"
|
||||
circular
|
||||
:disabled="
|
||||
!globalOptions[row.option] ||
|
||||
(row.editable === 'servers' && syncedServers.length === 0)
|
||||
"
|
||||
:label="formatMessage(commonMessages.editButton)"
|
||||
@click="
|
||||
row.editable === 'commands' ? openCommandHistoryEditor() : openServerEditor()
|
||||
"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
<Toggle
|
||||
:id="`global-sync-${row.option}`"
|
||||
:model-value="globalOptions[row.option]"
|
||||
@update:model-value="(enabled) => toggleGlobalOption(row.option, enabled)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-6">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.windowSectionTitle) }}
|
||||
</h2>
|
||||
<div class="mt-4 flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.fullscreenTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.fullscreenDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.widthTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.widthDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="width"
|
||||
v-model="settings.game_resolution[0]"
|
||||
:disabled="settings.force_fullscreen"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.widthPlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.heightTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.heightDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="height"
|
||||
v-model="settings.game_resolution[1]"
|
||||
:disabled="settings.force_fullscreen"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.heightPlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.javaAndMemorySectionTitle) }}
|
||||
</h2>
|
||||
<div class="mt-4 flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.memoryAllocationTitle) }}
|
||||
</h3>
|
||||
<Slider
|
||||
id="max-memory"
|
||||
v-model="settings.memory.maximum"
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<p class="m-0 mt-1 leading-tight">
|
||||
{{ formatMessage(messages.memoryAllocationDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.javaArgumentsTitle) }}
|
||||
</h3>
|
||||
<Input
|
||||
id="java-args"
|
||||
v-model="settings.launchArgs"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.javaArgumentsDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.environmentVariablesTitle) }}
|
||||
</h3>
|
||||
<Input
|
||||
id="env-vars"
|
||||
v-model="settings.envVars"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.environmentVariablesDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.launchHooksSectionTitle) }}
|
||||
</h2>
|
||||
<div class="mt-4 flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preLaunchHookTitle) }}
|
||||
</h3>
|
||||
<Input
|
||||
id="pre-launch"
|
||||
v-model="settings.hooks.pre_launch"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.preLaunchHookPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.preLaunchHookDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.wrapperHookTitle) }}
|
||||
</h3>
|
||||
<Input
|
||||
id="wrapper"
|
||||
v-model="settings.hooks.wrapper"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.wrapperHookPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.wrapperHookDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.postExitHookTitle) }}
|
||||
</h3>
|
||||
<Input
|
||||
id="post-exit"
|
||||
v-model="settings.hooks.post_exit"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.postExitHookPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.postExitHookDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.hookVariablesDescription) }}
|
||||
<ul>
|
||||
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.command-history-editor.ace-modrinth {
|
||||
background-color: var(--surface-2);
|
||||
}
|
||||
|
||||
.command-history-editor.ace-modrinth .ace_gutter {
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.command-history-editor.ace-modrinth .ace_marker-layer .ace_active-line {
|
||||
background: var(--surface-2-5);
|
||||
}
|
||||
|
||||
.command-history-editor.ace-modrinth .ace_gutter-active-line {
|
||||
background-color: var(--surface-1-5);
|
||||
}
|
||||
|
||||
.command-history-editor.ace-modrinth.ace_multiselect .ace_selection.ace_start {
|
||||
box-shadow: 0 0 3px 0 var(--surface-2);
|
||||
}
|
||||
|
||||
.command-history-modal > [data-modal-content] {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
+3
-3
@@ -5,8 +5,8 @@ import {
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
Input,
|
||||
Slider,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -176,7 +176,7 @@ async function findLauncherDir() {
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.appDirectoryTitle) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
<Input
|
||||
id="appDir"
|
||||
v-model="settings.custom_dir"
|
||||
:icon="BoxIcon"
|
||||
@@ -193,7 +193,7 @@ async function findLauncherDir() {
|
||||
<FolderSearchIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
</template>
|
||||
</StyledInput>
|
||||
</Input>
|
||||
<p class="m-0 leading-tight text-secondary">
|
||||
{{ formatMessage(messages.appDirectoryDescription) }}
|
||||
</p>
|
||||
|
||||
+1
@@ -16,6 +16,7 @@
|
||||
size="56px"
|
||||
no-shadow
|
||||
class="!rounded-2xl"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ preview.name }}</span>
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
Avatar,
|
||||
BulletDivider,
|
||||
Button,
|
||||
type ButtonMenuOption,
|
||||
commonMessages,
|
||||
ContextMenu,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
SmartClickable,
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
@@ -80,6 +82,10 @@ const messages = defineMessages({
|
||||
id: 'app.home.jump-back-in.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
instanceActions: {
|
||||
id: 'app.home.jump-back-in.actions.label',
|
||||
defaultMessage: 'Instance actions',
|
||||
},
|
||||
})
|
||||
|
||||
const instanceIcon = computed(() => props.instance.icon_path)
|
||||
@@ -144,115 +150,149 @@ const checkProcess = async () => {
|
||||
onMounted(() => {
|
||||
checkProcess()
|
||||
})
|
||||
|
||||
const cardOptions = useTemplateRef('cardOptions')
|
||||
const showStop = computed(() => playing.value && !loading.value)
|
||||
const playDisabled = computed(() => props.instance.quarantined || playing.value || loading.value)
|
||||
|
||||
const overflowOptions = computed((): ButtonMenuOption[] => [
|
||||
{
|
||||
id: 'open-instance',
|
||||
label: formatMessage(messages.viewInstance),
|
||||
icon: EyeIcon,
|
||||
shown: !!props.instance.id,
|
||||
action: () => router.push(encodeURI(`/instance/${props.instance.id}`)),
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: formatMessage(commonMessages.openFolderButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => showInstanceInFolder(props.instance.id),
|
||||
},
|
||||
])
|
||||
|
||||
const contextMenuOptions = computed((): ButtonMenuOption[] => [
|
||||
showStop.value
|
||||
? {
|
||||
id: 'stop',
|
||||
label: formatMessage(commonMessages.stopButton),
|
||||
icon: StopCircleIcon,
|
||||
tone: 'red',
|
||||
action: (event) => stop(event),
|
||||
}
|
||||
: {
|
||||
id: 'play',
|
||||
label: formatMessage(commonMessages.playButton),
|
||||
icon: PlayIcon,
|
||||
tone: 'brand',
|
||||
disabled: playDisabled.value,
|
||||
tooltip: props.instance.quarantined
|
||||
? formatMessage(messages.lockedTooltip)
|
||||
: playing.value
|
||||
? formatMessage(messages.alreadyOpenTooltip)
|
||||
: undefined,
|
||||
action: (event) => play(event),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
...overflowOptions.value,
|
||||
])
|
||||
|
||||
function openContextMenu(event: MouseEvent) {
|
||||
cardOptions.value?.open(event, contextMenuOptions.value)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<SmartClickable class="[--active-scale:0.99]">
|
||||
<template #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 border border-surface-4 rounded-[20px] smart-clickable:highlight-on-hover transition-[filter] ease-out [--hover-brightness:1.1] min-h-20 p-3"
|
||||
:class="newlyAdded ? 'border-dashed bg-surface-2' : 'bg-bg-raised border-solid'"
|
||||
>
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(instanceIcon)"
|
||||
:tint-by="instance.id"
|
||||
no-shadow
|
||||
class="!rounded-[14px]"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col col-span-2 justify-center gap-1 h-full">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="text-contrast truncate text-base font-semibold">
|
||||
{{ instance.name }}
|
||||
<div @contextmenu.prevent.stop="openContextMenu">
|
||||
<SmartClickable class="[--active-scale:0.99]">
|
||||
<template #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 border border-surface-4 rounded-[20px] smart-clickable:highlight-on-hover transition-[filter] ease-out [--hover-brightness:1.1] min-h-20 p-3"
|
||||
:class="newlyAdded ? 'border-dashed bg-surface-2' : 'bg-bg-raised border-solid'"
|
||||
>
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(instanceIcon)"
|
||||
:tint-by="instance.id"
|
||||
no-shadow
|
||||
class="!rounded-[14px]"
|
||||
size="48px"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="flex flex-col col-span-2 justify-center gap-1 h-full">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="text-contrast truncate text-base font-semibold">
|
||||
{{ instance.name }}
|
||||
</div>
|
||||
<TagItem
|
||||
v-if="newlyAdded"
|
||||
class="!border-green !bg-bg-green !px-2 !font-medium !text-green"
|
||||
>
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.newInstance) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<TagItem
|
||||
v-if="newlyAdded"
|
||||
class="!border-green !bg-bg-green !px-2 !font-medium !text-green"
|
||||
>
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.newInstance) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<span class="flex items-center gap-1 truncate text-secondary">
|
||||
{{ loader }}
|
||||
{{ instance.game_version }}
|
||||
</span>
|
||||
<BulletDivider class="shrink-0" />
|
||||
<div
|
||||
v-tooltip="!newlyAdded ? formatDateTime(last_played.toDate()) : null"
|
||||
class="w-fit shrink-0"
|
||||
:class="{
|
||||
'cursor-help smart-clickable:allow-pointer-events': !newlyAdded,
|
||||
}"
|
||||
>
|
||||
<template v-if="newlyAdded">
|
||||
{{ formatMessage(messages.neverPlayed) }}
|
||||
</template>
|
||||
<template v-else-if="last_played">
|
||||
{{ formatRelativeTime(last_played.toISOString?.()) }}
|
||||
</template>
|
||||
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
|
||||
<div class="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<span class="flex items-center gap-1 truncate text-secondary">
|
||||
{{ loader }}
|
||||
{{ instance.game_version }}
|
||||
</span>
|
||||
<BulletDivider class="shrink-0" />
|
||||
<div
|
||||
v-tooltip="!newlyAdded ? formatDateTime(last_played.toDate()) : null"
|
||||
class="w-fit shrink-0"
|
||||
:class="{
|
||||
'cursor-help smart-clickable:allow-pointer-events': !newlyAdded,
|
||||
}"
|
||||
>
|
||||
<template v-if="newlyAdded">
|
||||
{{ formatMessage(messages.neverPlayed) }}
|
||||
</template>
|
||||
<template v-else-if="last_played">
|
||||
{{ formatRelativeTime(last_played.toISOString?.()) }}
|
||||
</template>
|
||||
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-no-card-click class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<Button v-if="showStop" type="colored" color="red" @click="stop">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
v-tooltip="
|
||||
instance.quarantined
|
||||
? formatMessage(messages.lockedTooltip)
|
||||
: playing
|
||||
? formatMessage(messages.alreadyOpenTooltip)
|
||||
: null
|
||||
"
|
||||
:disabled="playDisabled"
|
||||
type="colored"
|
||||
color="green"
|
||||
@click="play"
|
||||
>
|
||||
<SpinnerIcon v-if="loading" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</Button>
|
||||
<TeleportOverflowMenu
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.moreOptions)"
|
||||
:options="overflowOptions"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div data-no-card-click class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<Button v-if="playing && !loading" type="colored" color="red" @click="stop">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
v-tooltip="
|
||||
instance.quarantined
|
||||
? formatMessage(messages.lockedTooltip)
|
||||
: playing
|
||||
? formatMessage(messages.alreadyOpenTooltip)
|
||||
: null
|
||||
"
|
||||
:disabled="instance.quarantined || playing || loading"
|
||||
type="colored"
|
||||
color="green"
|
||||
@click="play"
|
||||
>
|
||||
<SpinnerIcon v-if="loading" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</Button>
|
||||
<TeleportOverflowMenu
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.moreOptions)"
|
||||
:options="[
|
||||
{
|
||||
id: 'open-instance',
|
||||
label: formatMessage(messages.viewInstance),
|
||||
shown: !!instance.id,
|
||||
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: formatMessage(commonMessages.openFolderButton),
|
||||
action: () => showInstanceInFolder(instance.id),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-instance>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
</template>
|
||||
<template #open-folder>
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.openFolderButton) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</SmartClickable>
|
||||
</SmartClickable>
|
||||
<ContextMenu ref="cardOptions" :label="formatMessage(messages.instanceActions)" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.clickable-card:has([data-no-card-click]:hover) {
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { MessageDescriptor } from '@modrinth/ui'
|
||||
import type { ButtonMenuOption, MessageDescriptor } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
BulletDivider,
|
||||
Button,
|
||||
commonMessages,
|
||||
ContextMenu,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
SmartClickable,
|
||||
@@ -38,7 +39,7 @@ import { getPingLevel } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||
@@ -66,7 +67,7 @@ const router = useRouter()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
|
||||
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete' | 'desync'): void
|
||||
(e: 'open-folder', world: SingleplayerWorld): void
|
||||
}>()
|
||||
|
||||
@@ -94,6 +95,8 @@ const props = withDefaults(
|
||||
}
|
||||
|
||||
managed?: boolean
|
||||
showPlayButton?: boolean
|
||||
cardBackground?: 'raised' | 'surface-2'
|
||||
|
||||
// Instance
|
||||
instanceId?: string
|
||||
@@ -116,6 +119,8 @@ const props = withDefaults(
|
||||
|
||||
gameMode: undefined,
|
||||
managed: false,
|
||||
showPlayButton: true,
|
||||
cardBackground: 'raised',
|
||||
|
||||
instanceId: undefined,
|
||||
instanceName: undefined,
|
||||
@@ -217,6 +222,10 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.copy_address',
|
||||
defaultMessage: 'Copy address',
|
||||
},
|
||||
desync: {
|
||||
id: 'instance.worlds.desync_server',
|
||||
defaultMessage: 'Desync',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'instance.worlds.view_instance',
|
||||
defaultMessage: 'View instance',
|
||||
@@ -225,6 +234,10 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.play_instance',
|
||||
defaultMessage: 'Play instance',
|
||||
},
|
||||
playWorld: {
|
||||
id: 'instance.worlds.play_world',
|
||||
defaultMessage: 'Play world',
|
||||
},
|
||||
worldInUse: {
|
||||
id: 'instance.worlds.world_in_use',
|
||||
defaultMessage: 'World is in use',
|
||||
@@ -257,358 +270,387 @@ const messages = defineMessages({
|
||||
id: 'app.world.world-item.not-played-yet',
|
||||
defaultMessage: 'Not played yet',
|
||||
},
|
||||
worldActions: {
|
||||
id: 'instance.worlds.actions.label',
|
||||
defaultMessage: 'World actions',
|
||||
},
|
||||
})
|
||||
|
||||
const cardOptions = useTemplateRef('cardOptions')
|
||||
const showStop = computed(
|
||||
() =>
|
||||
props.showPlayButton &&
|
||||
(props.playingWorld || (locked.value && props.playingInstance)) &&
|
||||
!props.startingInstance,
|
||||
)
|
||||
const playDisabled = computed(
|
||||
() =>
|
||||
props.quarantined ||
|
||||
playingOtherWorld.value ||
|
||||
props.startingInstance ||
|
||||
(props.world.type === 'server' && !props.supportsServerQuickPlay) ||
|
||||
(props.world.type === 'singleplayer' && !props.supportsWorldQuickPlay),
|
||||
)
|
||||
const playTooltip = computed(() => {
|
||||
if (props.quarantined) return formatMessage(messages.instanceLocked)
|
||||
if (props.world.type === 'server') {
|
||||
if (!props.supportsServerQuickPlay) return formatMessage(messages.noServerQuickPlay)
|
||||
if (playingOtherWorld.value) return formatMessage(messages.gameAlreadyOpen)
|
||||
if (!props.serverStatus) return formatMessage(messages.noContact)
|
||||
if (serverIncompatible.value) return formatMessage(messages.incompatibleServer)
|
||||
return null
|
||||
}
|
||||
if (!props.supportsWorldQuickPlay) return formatMessage(messages.noSingleplayerQuickPlay)
|
||||
if (playingOtherWorld.value || locked.value) return formatMessage(messages.gameAlreadyOpen)
|
||||
return null
|
||||
})
|
||||
|
||||
const overflowOptions = computed((): ButtonMenuOption[] => [
|
||||
{
|
||||
id: 'play-instance',
|
||||
label: formatMessage(messages.playInstance),
|
||||
icon: PlayIcon,
|
||||
shown: !!props.instanceId,
|
||||
disabled: props.playingInstance || props.quarantined,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'open-instance',
|
||||
label: formatMessage(messages.viewInstance),
|
||||
icon: EyeIcon,
|
||||
shown: !!props.instanceId,
|
||||
action: () => {
|
||||
if (!props.instanceId) return
|
||||
void router.push(`/instance/${encodeURIComponent(props.instanceId)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'refresh',
|
||||
label: formatMessage(commonMessages.refreshButton),
|
||||
icon: UpdatedIcon,
|
||||
shown: props.world.type === 'server',
|
||||
action: () => emit('refresh'),
|
||||
},
|
||||
{
|
||||
id: 'copy-address',
|
||||
label: formatMessage(messages.copyAddress),
|
||||
icon: ClipboardCopyIcon,
|
||||
shown: props.world.type === 'server',
|
||||
action: () => copyToClipboard((props.world as ServerWorld).address),
|
||||
},
|
||||
{
|
||||
id: 'desync',
|
||||
label: formatMessage(messages.desync),
|
||||
icon: XIcon,
|
||||
action: () => emit('desync'),
|
||||
shown:
|
||||
!props.instanceId &&
|
||||
props.world.type === 'server' &&
|
||||
(props.world as ServerWorld).source === 'user_synced' &&
|
||||
!!(props.world as ServerWorld).server_id,
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
label: formatMessage(commonMessages.editButton),
|
||||
icon: EditIcon,
|
||||
action: () => emit('edit'),
|
||||
shown: !props.instanceId,
|
||||
disabled: locked.value || managed.value,
|
||||
tooltip: locked.value
|
||||
? formatMessage(messages.worldInUse)
|
||||
: managed.value
|
||||
? formatMessage(messages.linkedServer)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: formatMessage(commonMessages.openFolderButton),
|
||||
icon: FolderOpenIcon,
|
||||
shown: props.world.type === 'singleplayer',
|
||||
action: () => (props.world.type === 'singleplayer' ? emit('open-folder', props.world) : {}),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
shown: !!props.instanceId,
|
||||
},
|
||||
{
|
||||
id: 'dont-show-on-home',
|
||||
label: formatMessage(messages.dontShowOnHome),
|
||||
icon: XIcon,
|
||||
shown: !!props.instanceId,
|
||||
action: () => {
|
||||
if (!props.instanceId) return
|
||||
set_world_display_status(
|
||||
props.instanceId,
|
||||
props.world.type,
|
||||
getWorldIdentifier(props.world),
|
||||
'hidden',
|
||||
).then(() => {
|
||||
emit('update')
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
label: formatMessage(messages.createShortcut),
|
||||
icon: ExternalIcon,
|
||||
shown: !!shortcutInstanceId.value && !props.quarantined,
|
||||
action: () => createShortcut(),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
shown: !props.instanceId,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: formatMessage(
|
||||
props.world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
|
||||
),
|
||||
icon: TrashIcon,
|
||||
tone: 'red',
|
||||
action: () => emit('delete'),
|
||||
shown: !props.instanceId,
|
||||
disabled: locked.value || managed.value,
|
||||
tooltip: locked.value
|
||||
? formatMessage(messages.worldInUse)
|
||||
: managed.value
|
||||
? formatMessage(messages.linkedServer)
|
||||
: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
const contextMenuOptions = computed((): ButtonMenuOption[] => [
|
||||
showStop.value
|
||||
? {
|
||||
id: 'stop',
|
||||
label: formatMessage(commonMessages.stopButton),
|
||||
icon: StopCircleIcon,
|
||||
tone: 'red',
|
||||
shown: props.showPlayButton,
|
||||
action: () => emit('stop'),
|
||||
}
|
||||
: {
|
||||
id: 'play',
|
||||
label: formatMessage(messages.playWorld),
|
||||
icon: PlayIcon,
|
||||
tone: 'brand',
|
||||
shown: props.showPlayButton,
|
||||
disabled: playDisabled.value,
|
||||
tooltip: playTooltip.value ?? undefined,
|
||||
action: () => emit('play'),
|
||||
},
|
||||
{ type: 'divider', shown: props.showPlayButton },
|
||||
...overflowOptions.value,
|
||||
])
|
||||
|
||||
function openContextMenu(event: MouseEvent) {
|
||||
cardOptions.value?.open(event, contextMenuOptions.value)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<SmartClickable class="[--active-scale:0.985]">
|
||||
<template v-if="instanceId" #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instanceId)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised border border-solid border-surface-4 smart-clickable:highlight-on-hover rounded-[20px] transition-[filter] ease-out [--hover-brightness:1.25] min-h-20"
|
||||
:class="{
|
||||
'world-item-highlighted': highlighted,
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:src="
|
||||
world.type === 'server' && serverStatus
|
||||
? (serverStatus.favicon ?? world.icon)
|
||||
: world.icon
|
||||
"
|
||||
size="48px"
|
||||
no-shadow
|
||||
class="!rounded-[14px]"
|
||||
/>
|
||||
<div class="flex flex-col justify-center gap-0.5 h-full">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="text-base text-contrast font-semibold truncate">
|
||||
{{ world.name }}
|
||||
</div>
|
||||
<TagItem
|
||||
v-if="managed"
|
||||
v-tooltip="formatMessage(messages.linkedServer)"
|
||||
class="border !border-solid border-blue bg-highlight-blue text-xs"
|
||||
:style="`--_color: var(--color-blue)`"
|
||||
>
|
||||
<LockIcon aria-hidden="true" class="h-5 w-5" />
|
||||
</TagItem>
|
||||
<div
|
||||
v-if="world.type === 'singleplayer'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 text-secondary shrink-0"
|
||||
stroke-width="3px"
|
||||
/>
|
||||
{{ formatMessage(commonMessages.singleplayerLabel) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="world.type === 'server'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<template v-if="refreshing">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
|
||||
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||
</template>
|
||||
<template v-else-if="serverStatus">
|
||||
<template v-if="serverIncompatible">
|
||||
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
|
||||
<span class="text-orange">
|
||||
{{
|
||||
formatMessage(messages.incompatibleVersion, {
|
||||
version: serverStatus.version?.name,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div @contextmenu.prevent.stop="openContextMenu">
|
||||
<SmartClickable class="[--active-scale:0.985]">
|
||||
<template v-if="instanceId" #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instanceId)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 border border-solid border-surface-4 smart-clickable:highlight-on-hover rounded-[20px] transition-[filter] ease-out [--hover-brightness:1.25] min-h-20"
|
||||
:class="{
|
||||
'world-item-highlighted': highlighted,
|
||||
'bg-bg-raised': cardBackground === 'raised',
|
||||
'bg-surface-2': cardBackground === 'surface-2',
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:src="
|
||||
world.type === 'server' && serverStatus
|
||||
? (serverStatus.favicon ?? world.icon)
|
||||
: world.icon
|
||||
"
|
||||
size="48px"
|
||||
no-shadow
|
||||
class="!rounded-[14px]"
|
||||
/>
|
||||
<div class="flex flex-col justify-center gap-0.5 h-full">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="text-base text-contrast font-semibold truncate">
|
||||
{{ world.name }}
|
||||
</div>
|
||||
<TagItem
|
||||
v-if="managed"
|
||||
v-tooltip="formatMessage(messages.linkedServer)"
|
||||
class="border !border-solid border-blue bg-highlight-blue text-xs"
|
||||
:style="`--_color: var(--color-blue)`"
|
||||
>
|
||||
<LockIcon aria-hidden="true" class="h-5 w-5" />
|
||||
</TagItem>
|
||||
<div
|
||||
v-if="world.type === 'singleplayer'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 text-secondary shrink-0"
|
||||
stroke-width="3px"
|
||||
/>
|
||||
{{ formatMessage(commonMessages.singleplayerLabel) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="world.type === 'server'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<template v-if="refreshing">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
|
||||
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<SignalIcon
|
||||
v-tooltip="`${serverStatus.ping}ms`"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${getPingLevel(serverStatus.ping ?? 0)}: var(--color-green)`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0 smart-clickable:allow-pointer-events"
|
||||
/>
|
||||
<Tooltip :disabled="!hasPlayersTooltip">
|
||||
<span
|
||||
class="smart-clickable:allow-pointer-events"
|
||||
:class="{ 'cursor-help': hasPlayersTooltip }"
|
||||
>
|
||||
<template v-else-if="serverStatus">
|
||||
<template v-if="serverIncompatible">
|
||||
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
|
||||
<span class="text-orange">
|
||||
{{
|
||||
formatMessage(messages.playersOnline, {
|
||||
count: formatNumber(serverStatus.players?.online ?? 0),
|
||||
formatMessage(messages.incompatibleVersion, {
|
||||
version: serverStatus.version?.name,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template #popper>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span v-for="player in serverStatus.players?.sample" :key="player.id">
|
||||
{{ player.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SignalIcon
|
||||
v-tooltip="`${serverStatus.ping}ms`"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${getPingLevel(serverStatus.ping ?? 0)}: var(--color-green)`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0 smart-clickable:allow-pointer-events"
|
||||
/>
|
||||
<Tooltip :disabled="!hasPlayersTooltip">
|
||||
<span
|
||||
class="smart-clickable:allow-pointer-events"
|
||||
:class="{ 'cursor-help': hasPlayersTooltip }"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.playersOnline, {
|
||||
count: formatNumber(serverStatus.players?.online ?? 0),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template #popper>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span v-for="player in serverStatus.players?.sample" :key="player.id">
|
||||
{{ player.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
|
||||
{{ formatMessage(messages.offline) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<template v-if="instanceId">
|
||||
<router-link
|
||||
data-no-card-click
|
||||
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
|
||||
:to="`/instance/${instanceId}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(instanceIcon)"
|
||||
size="16px"
|
||||
:tint-by="instanceId"
|
||||
class="shrink-0"
|
||||
no-shadow
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<span class="truncate">{{ instanceName }}</span>
|
||||
</router-link>
|
||||
<BulletDivider class="shrink-0" />
|
||||
</template>
|
||||
<div
|
||||
v-tooltip="world.last_played ? formatDateTime(world.last_played) : null"
|
||||
class="w-fit shrink-0"
|
||||
:class="{
|
||||
'cursor-help smart-clickable:allow-pointer-events': world.last_played,
|
||||
}"
|
||||
>
|
||||
<template v-if="world.last_played">
|
||||
{{ formatRelativeTime(dayjs(world.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="font-semibold flex items-center gap-1 justify-center text-center"
|
||||
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
|
||||
>
|
||||
<template v-if="world.type === 'server'">
|
||||
<template v-if="refreshing">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||
</template>
|
||||
<div
|
||||
v-else-if="renderedMotd"
|
||||
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
|
||||
v-html="renderedMotd"
|
||||
/>
|
||||
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
|
||||
{{ formatMessage(messages.cantConnect) }}
|
||||
</div>
|
||||
<div v-else class="font-normal font-minecraft text-secondary leading-5">
|
||||
{{ formatMessage(messages.aMinecraftServer) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="world.type === 'singleplayer' && gameMode">
|
||||
<template v-if="world.hardcore">
|
||||
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||
{{ formatMessage(messages.hardcore) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
|
||||
{{ formatMessage(messages.offline) }}
|
||||
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||
{{ formatMessage(gameMode.message) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<template v-if="instanceId">
|
||||
<router-link
|
||||
data-no-card-click
|
||||
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
|
||||
:to="`/instance/${instanceId}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(instanceIcon)"
|
||||
size="16px"
|
||||
:tint-by="instanceId"
|
||||
class="shrink-0"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="truncate">{{ instanceName }}</span>
|
||||
</router-link>
|
||||
<BulletDivider class="shrink-0" />
|
||||
</template>
|
||||
<div
|
||||
v-tooltip="world.last_played ? formatDateTime(world.last_played) : null"
|
||||
class="w-fit shrink-0"
|
||||
:class="{
|
||||
'cursor-help smart-clickable:allow-pointer-events': world.last_played,
|
||||
}"
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<Button
|
||||
v-if="showPlayButton && showStop"
|
||||
type="colored"
|
||||
color="red"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<template v-if="world.last_played">
|
||||
{{ formatRelativeTime(dayjs(world.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
|
||||
</div>
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="showPlayButton"
|
||||
v-tooltip="playTooltip"
|
||||
:disabled="playDisabled"
|
||||
type="colored"
|
||||
color="brand"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</Button>
|
||||
<TeleportOverflowMenu
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.moreOptions)"
|
||||
:options="overflowOptions"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="font-semibold flex items-center gap-1 justify-center text-center"
|
||||
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
|
||||
>
|
||||
<template v-if="world.type === 'server'">
|
||||
<template v-if="refreshing">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||
</template>
|
||||
<div
|
||||
v-else-if="renderedMotd"
|
||||
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
|
||||
v-html="renderedMotd"
|
||||
/>
|
||||
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
|
||||
{{ formatMessage(messages.cantConnect) }}
|
||||
</div>
|
||||
<div v-else class="font-normal font-minecraft text-secondary leading-5">
|
||||
{{ formatMessage(messages.aMinecraftServer) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="world.type === 'singleplayer' && gameMode">
|
||||
<template v-if="world.hardcore">
|
||||
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||
{{ formatMessage(messages.hardcore) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||
{{ formatMessage(gameMode.message) }}
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<Button
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
type="colored"
|
||||
color="red"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
v-tooltip="
|
||||
quarantined
|
||||
? formatMessage(messages.instanceLocked)
|
||||
: world.type === 'server'
|
||||
? !supportsServerQuickPlay
|
||||
? formatMessage(messages.noServerQuickPlay)
|
||||
: playingOtherWorld
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: !serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: null
|
||||
: !supportsWorldQuickPlay
|
||||
? formatMessage(messages.noSingleplayerQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="
|
||||
quarantined ||
|
||||
playingOtherWorld ||
|
||||
startingInstance ||
|
||||
(world.type == 'server' && !supportsServerQuickPlay) ||
|
||||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
|
||||
"
|
||||
type="colored"
|
||||
color="brand"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</Button>
|
||||
<TeleportOverflowMenu
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.moreOptions)"
|
||||
:options="[
|
||||
{
|
||||
id: 'play-instance',
|
||||
label: formatMessage(messages.playInstance),
|
||||
shown: !!instanceId,
|
||||
disabled: playingInstance || quarantined,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'open-instance',
|
||||
label: formatMessage(messages.viewInstance),
|
||||
shown: !!instanceId,
|
||||
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
|
||||
},
|
||||
{
|
||||
id: 'refresh',
|
||||
label: formatMessage(commonMessages.refreshButton),
|
||||
shown: world.type === 'server',
|
||||
action: () => emit('refresh'),
|
||||
},
|
||||
{
|
||||
id: 'copy-address',
|
||||
label: formatMessage(messages.copyAddress),
|
||||
shown: world.type === 'server',
|
||||
action: () => copyToClipboard((world as ServerWorld).address),
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
label: formatMessage(commonMessages.editButton),
|
||||
action: () => emit('edit'),
|
||||
shown: !instanceId,
|
||||
disabled: locked || managed,
|
||||
tooltip: locked
|
||||
? formatMessage(messages.worldInUse)
|
||||
: managed
|
||||
? formatMessage(messages.linkedServer)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: formatMessage(commonMessages.openFolderButton),
|
||||
shown: world.type === 'singleplayer',
|
||||
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
shown: !!instanceId,
|
||||
},
|
||||
{
|
||||
id: 'dont-show-on-home',
|
||||
label: formatMessage(messages.dontShowOnHome),
|
||||
shown: !!instanceId,
|
||||
action: () => {
|
||||
set_world_display_status(
|
||||
instanceId,
|
||||
world.type,
|
||||
getWorldIdentifier(world),
|
||||
'hidden',
|
||||
).then(() => {
|
||||
emit('update')
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
label: formatMessage(messages.createShortcut),
|
||||
shown: !!shortcutInstanceId && !quarantined,
|
||||
action: () => createShortcut(),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
shown: !instanceId,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: formatMessage(
|
||||
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
|
||||
),
|
||||
tone: 'red',
|
||||
action: () => emit('delete'),
|
||||
shown: !instanceId,
|
||||
disabled: locked || managed,
|
||||
tooltip: locked
|
||||
? formatMessage(messages.worldInUse)
|
||||
: managed
|
||||
? formatMessage(messages.linkedServer)
|
||||
: undefined,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #play-instance>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playInstance) }}
|
||||
</template>
|
||||
<template #open-instance>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
</template>
|
||||
<template #edit>
|
||||
<EditIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.editButton) }}
|
||||
</template>
|
||||
<template #open-folder>
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.openFolderButton) }}
|
||||
</template>
|
||||
<template #copy-address>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.copyAddress) }}
|
||||
</template>
|
||||
<template #refresh>
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</template>
|
||||
<template #create-shortcut>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createShortcut) }}
|
||||
</template>
|
||||
<template #dont-show-on-home>
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.dontShowOnHome) }}
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(
|
||||
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</SmartClickable>
|
||||
</SmartClickable>
|
||||
<ContextMenu ref="cardOptions" :label="formatMessage(messages.worldActions)" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.clickable-card:has([data-no-card-click]:hover) {
|
||||
|
||||
@@ -16,6 +16,7 @@ const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
world: World | null
|
||||
otherSyncedInstanceCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -44,6 +45,11 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'This server will be removed from your server list and from the in-game server list. You can add it again later if you know the address.',
|
||||
},
|
||||
syncedServerRemovalNotice: {
|
||||
id: 'app.instance.worlds.remove-server-modal.synced-removal-notice',
|
||||
defaultMessage:
|
||||
'This server will also be removed from {count, plural, one {# other instance} other {# other instances}} because it’s synced.',
|
||||
},
|
||||
deleteWorldWarningBody: {
|
||||
id: 'app.instance.worlds.delete-world-modal.warning-body',
|
||||
defaultMessage:
|
||||
@@ -62,6 +68,9 @@ const messages = defineMessages({
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const isServer = computed(() => props.world?.type === 'server')
|
||||
const isSyncedServer = computed(
|
||||
() => props.world?.type === 'server' && props.world.source === 'user_synced',
|
||||
)
|
||||
const isSingleplayer = computed(() => props.world?.type === 'singleplayer')
|
||||
const titleMessage = computed(() =>
|
||||
isServer.value ? messages.removeServerTitle : messages.deleteWorldTitle,
|
||||
@@ -102,6 +111,13 @@ defineExpose({ show, hide })
|
||||
>
|
||||
{{ formatMessage(warningBodyMessage) }}
|
||||
</Admonition>
|
||||
<p v-if="isSyncedServer" class="m-0 text-secondary">
|
||||
{{
|
||||
formatMessage(messages.syncedServerRemovalNotice, {
|
||||
count: otherSyncedInstanceCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { LinkIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, commonMessages, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { DesyncServerMode, ServerWorld } from '@/helpers/worlds'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const server = ref<ServerWorld | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [server: ServerWorld, mode: DesyncServerMode]
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'instance.worlds.desync-server.title',
|
||||
defaultMessage: 'Desync server',
|
||||
},
|
||||
description: {
|
||||
id: 'instance.worlds.desync-server.description',
|
||||
defaultMessage:
|
||||
'Do you want to keep this server in other synced instances, or remove it from them?',
|
||||
},
|
||||
keep: {
|
||||
id: 'instance.worlds.desync-server.keep',
|
||||
defaultMessage: 'Keep',
|
||||
},
|
||||
remove: {
|
||||
id: 'instance.worlds.desync-server.remove',
|
||||
defaultMessage: 'Remove',
|
||||
},
|
||||
})
|
||||
|
||||
function show(value: ServerWorld) {
|
||||
server.value = value
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm(mode: DesyncServerMode) {
|
||||
if (server.value) emit('confirm', server.value, mode)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="540px">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" class="whitespace-nowrap" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button class="whitespace-nowrap" @click="confirm('keep_in_other_instances')">
|
||||
<LinkIcon />
|
||||
{{ formatMessage(messages.keep) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="red"
|
||||
class="whitespace-nowrap"
|
||||
@click="confirm('remove_from_other_instances')"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.remove) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Input,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -104,7 +104,7 @@ const messages = defineMessages({
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="name"
|
||||
:placeholder="formatMessage(messages.placeholderName)"
|
||||
autocomplete="off"
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Combobox,
|
||||
defineMessages,
|
||||
type MessageDescriptor,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { Combobox, defineMessages, Input, type MessageDescriptor, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { ServerPackStatus } from '@/helpers/worlds.ts'
|
||||
|
||||
@@ -65,7 +59,7 @@ defineExpose({ resourcePackOptions })
|
||||
<div class="space-y-4 w-full">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="name"
|
||||
:placeholder="formatMessage(messages.placeholderName)"
|
||||
autocomplete="off"
|
||||
@@ -74,7 +68,7 @@ defineExpose({ resourcePackOptions })
|
||||
</label>
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.address) }}</span>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="address"
|
||||
:placeholder="formatMessage(messages.placeholderAddress)"
|
||||
autocomplete="off"
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckIcon, PlayIcon, PlusIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
|
||||
import type { CardAction } from '@modrinth/ui'
|
||||
import {
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
GlobeIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { ButtonMenuOption, CardAction } from '@modrinth/ui'
|
||||
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
@@ -26,16 +34,7 @@ interface BrowseServerInstance {
|
||||
}
|
||||
|
||||
interface ContextMenuHandle {
|
||||
showMenu: (
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
options: { name: string }[],
|
||||
) => void
|
||||
}
|
||||
|
||||
interface ContextMenuOptionClick {
|
||||
option: 'open_link' | 'copy_link'
|
||||
item: Labrinth.Search.v3.ResultSearchProject
|
||||
open: (event: MouseEvent, options: ButtonMenuOption[]) => void
|
||||
}
|
||||
|
||||
export interface UseAppServerBrowseOptions {
|
||||
@@ -275,19 +274,21 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
}
|
||||
|
||||
function handleRightClick(event: MouseEvent, result: Labrinth.Search.v3.ResultSearchProject) {
|
||||
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
|
||||
}
|
||||
|
||||
function handleOptionsClick(args: ContextMenuOptionClick) {
|
||||
const url = getProjectUrl(args.item)
|
||||
switch (args.option) {
|
||||
case 'open_link':
|
||||
openUrl(url)
|
||||
break
|
||||
case 'copy_link':
|
||||
navigator.clipboard.writeText(url)
|
||||
break
|
||||
}
|
||||
const url = getProjectUrl(result)
|
||||
contextMenuRef.value?.open(event, [
|
||||
{
|
||||
id: 'open_link',
|
||||
label: formatMessage(commonMessages.openInModrinthButton),
|
||||
icon: GlobeIcon,
|
||||
action: () => void openUrl(url),
|
||||
},
|
||||
{
|
||||
id: 'copy_link',
|
||||
label: formatMessage(commonMessages.copyLinkButton),
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => void navigator.clipboard.writeText(url),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
useAppEvent('process', (event) => {
|
||||
@@ -314,7 +315,6 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
getServerModpackContent,
|
||||
getServerCardActions,
|
||||
handleRightClick,
|
||||
handleOptionsClick,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
friends_offline_collapsed: true,
|
||||
friends_pending_collapsed: true,
|
||||
dismissed_photosensitivity_filter_warning: false,
|
||||
localhost_sign_in: false,
|
||||
}
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -1,28 +1,107 @@
|
||||
import { prepareThemeColorTransition } from '@modrinth/ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
|
||||
export const DARK_THEMES = ['dark', 'oled', 'retro'] as const
|
||||
|
||||
export type ColorTheme = (typeof THEME_OPTIONS)[number]
|
||||
export type DarkTheme = (typeof DARK_THEMES)[number]
|
||||
type Theme = Exclude<ColorTheme, 'system'>
|
||||
type NativeTheme = 'light' | 'dark'
|
||||
|
||||
const preferred = ref<ColorTheme>('dark')
|
||||
const PREFERRED_THEME_KEY = 'modrinth-theme'
|
||||
const PREFERRED_DARK_THEME_KEY = 'modrinth-preferred-dark-theme'
|
||||
|
||||
export function isDarkTheme(theme: string): theme is DarkTheme {
|
||||
return (DARK_THEMES as readonly string[]).includes(theme)
|
||||
}
|
||||
|
||||
function loadPreferredTheme(): ColorTheme {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(PREFERRED_THEME_KEY)
|
||||
if (stored && (THEME_OPTIONS as readonly string[]).includes(stored)) {
|
||||
return stored as ColorTheme
|
||||
}
|
||||
} catch {
|
||||
// storage blocked or full
|
||||
}
|
||||
|
||||
for (const option of THEME_OPTIONS) {
|
||||
if (option !== 'system' && document.documentElement.classList.contains(`${option}-mode`)) {
|
||||
return option
|
||||
}
|
||||
}
|
||||
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
function loadPreferredDarkTheme(): DarkTheme {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(PREFERRED_DARK_THEME_KEY)
|
||||
if (stored && isDarkTheme(stored)) {
|
||||
return stored
|
||||
}
|
||||
} catch {
|
||||
// storage blocked or full
|
||||
}
|
||||
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
const preferred = ref<ColorTheme>(loadPreferredTheme())
|
||||
const preview = ref<ColorTheme | null>(null)
|
||||
const preferredDark = ref<DarkTheme>(loadPreferredDarkTheme())
|
||||
const advancedRendering = ref(true)
|
||||
const syncAcrossDevices = ref(false)
|
||||
const nativeThemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const native = ref<Theme>(nativeThemeQuery.matches ? 'dark' : 'light')
|
||||
const native = ref<NativeTheme>(nativeThemeQuery.matches ? 'dark' : 'light')
|
||||
const active = computed<Theme>(() => {
|
||||
const selectedTheme = preview.value ?? preferred.value
|
||||
return selectedTheme === 'system' ? native.value : selectedTheme
|
||||
if (selectedTheme !== 'system') {
|
||||
return selectedTheme
|
||||
}
|
||||
|
||||
return native.value === 'light' ? 'light' : preferredDark.value
|
||||
})
|
||||
|
||||
nativeThemeQuery.addEventListener('change', (event) => {
|
||||
native.value = event.matches ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
watch([preferred, preview], ([selectedPreferred, selectedPreview]) => {
|
||||
const selectedTheme = selectedPreview ?? selectedPreferred
|
||||
if (isDarkTheme(selectedTheme)) {
|
||||
preferredDark.value = selectedTheme
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
preferred,
|
||||
(theme) => {
|
||||
try {
|
||||
window.localStorage.setItem(PREFERRED_THEME_KEY, theme)
|
||||
} catch {
|
||||
// storage blocked or full
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(preferredDark, (theme) => {
|
||||
try {
|
||||
window.localStorage.setItem(PREFERRED_DARK_THEME_KEY, theme)
|
||||
} catch {
|
||||
// storage blocked or full
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
active,
|
||||
(theme) => {
|
||||
(theme, previousTheme) => {
|
||||
if (previousTheme && previousTheme !== theme) {
|
||||
prepareThemeColorTransition()
|
||||
}
|
||||
|
||||
const html = document.documentElement
|
||||
for (const option of THEME_OPTIONS) {
|
||||
html.classList.remove(`${option}-mode`)
|
||||
@@ -32,14 +111,31 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function applyAccountAppearance(appearance: { auto: boolean; theme: string }): void {
|
||||
if (isDarkTheme(appearance.theme)) {
|
||||
preferredDark.value = appearance.theme
|
||||
}
|
||||
|
||||
if (appearance.auto) {
|
||||
preferred.value = 'system'
|
||||
return
|
||||
}
|
||||
|
||||
if ((THEME_OPTIONS as readonly string[]).includes(appearance.theme)) {
|
||||
preferred.value = appearance.theme as ColorTheme
|
||||
}
|
||||
}
|
||||
|
||||
const theme = reactive({
|
||||
preferred,
|
||||
preview,
|
||||
preferredDark,
|
||||
active,
|
||||
native,
|
||||
syncAcrossDevices,
|
||||
advancedRendering,
|
||||
options: THEME_OPTIONS,
|
||||
applyAccountAppearance,
|
||||
})
|
||||
|
||||
export function useTheme() {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type InstancePayload = { instance_id: string, } & ({ "event": "created" } | { "event": "synced" } | { "event": "servers_updated" } | { "event": "world_updated", world: string, } | { "event": "server_joined", host: string, port: number, timestamp: string, } | { "event": "edited" } | { "event": "content_install_finished", project_ids: Array<string>, } | { "event": "content_install_failed", project_ids: Array<string>, message: string, } | { "event": "removed" });
|
||||
export type InstancePayload = { instance_id: string, } & ({ "event": "created" } | { "event": "synced" } | { "event": "servers_updated" } | { "event": "screenshots_updated" } | { "event": "world_updated", world: string, } | { "event": "server_joined", host: string, port: number, timestamp: string, } | { "event": "edited" } | { "event": "content_install_finished", project_ids: Array<string>, } | { "event": "content_install_failed", project_ids: Array<string>, message: string, } | { "event": "removed" });
|
||||
|
||||
@@ -44,7 +44,7 @@ export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { ta
|
||||
export type ProcessPayload = { instance_id: string, uuid: string, event: ProcessPayloadType, message: string }
|
||||
export type ProcessPayloadType = { tag: "launched" } | { tag: "finished" }
|
||||
export type InstancePayload = { instance_id: string, event: InstancePayloadType }
|
||||
export type InstancePayloadType = { tag: "created" } | { tag: "synced" } | { tag: "servers_updated" } | { tag: "world_updated", value: { world: string } } | { tag: "server_joined", value: { host: string, port: u16, timestamp: string } } | { tag: "edited" } | { tag: "content_install_finished", value: { project_ids: string[] } } | { tag: "content_install_failed", value: { project_ids: string[], message: string } } | { tag: "removed" }
|
||||
export type InstancePayloadType = { tag: "created" } | { tag: "synced" } | { tag: "servers_updated" } | { tag: "screenshots_updated" } | { tag: "world_updated", value: { world: string } } | { tag: "server_joined", value: { host: string, port: u16, timestamp: string } } | { tag: "edited" } | { tag: "content_install_finished", value: { project_ids: string[] } } | { tag: "content_install_failed", value: { project_ids: string[], message: string } } | { tag: "removed" }
|
||||
export type InstanceGroupsChangedPayload = { instance_ids: string[] }
|
||||
export type FriendPayload = { tag: "friend_request", value: { from: string } } | { tag: "user_offline", value: { id: string } } | { tag: "status_update", value: { user_status: FriendStatusPayload } } | { tag: "status_sync" }
|
||||
export type FriendStatusPayload = { user_id: string, profile_name: string | undefined, last_update: string }
|
||||
|
||||
@@ -351,13 +351,17 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
|
||||
tag: "servers_updated"
|
||||
};
|
||||
case 3:
|
||||
return {
|
||||
tag: "screenshots_updated"
|
||||
};
|
||||
case 4:
|
||||
return {
|
||||
tag: "world_updated",
|
||||
value: {
|
||||
world: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 4:
|
||||
case 5:
|
||||
return {
|
||||
tag: "server_joined",
|
||||
value: {
|
||||
@@ -366,18 +370,18 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
|
||||
timestamp: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 5:
|
||||
case 6:
|
||||
return {
|
||||
tag: "edited"
|
||||
};
|
||||
case 6:
|
||||
case 7:
|
||||
return {
|
||||
tag: "content_install_finished",
|
||||
value: {
|
||||
project_ids: d.deserialize_array(() => d.deserialize_string())
|
||||
}
|
||||
};
|
||||
case 7:
|
||||
case 8:
|
||||
return {
|
||||
tag: "content_install_failed",
|
||||
value: {
|
||||
@@ -385,7 +389,7 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
|
||||
message: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 8:
|
||||
case 9:
|
||||
return {
|
||||
tag: "removed"
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const STORAGE_KEY = 'modrinth-account-appearances'
|
||||
|
||||
function readCache(): Record<string, { auto: boolean; theme: string }> {
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '')
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
||||
return parsed
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberAccountAppearance(
|
||||
userId: string,
|
||||
appearance: { auto: boolean; theme: string },
|
||||
) {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ ...readCache(), [userId]: appearance }),
|
||||
)
|
||||
} catch {
|
||||
// storage blocked or full
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountAppearance(userId: string) {
|
||||
return readCache()[userId]
|
||||
}
|
||||
@@ -167,6 +167,219 @@ export async function get_mod_full_path(instanceId: string, projectPath: string)
|
||||
return await invoke('plugin:instance|instance_get_mod_full_path', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
export type ScreenshotKey = {
|
||||
instance_id: string
|
||||
file_name: string
|
||||
}
|
||||
|
||||
export type InstanceScreenshot = ScreenshotKey & {
|
||||
id: string
|
||||
instance_name: string
|
||||
created_at: string
|
||||
modified_at: number
|
||||
group_id?: string | null
|
||||
path: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ScreenshotGroup = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ScreenshotGroupMembershipUpdate = {
|
||||
screenshot_id: string
|
||||
group_id?: string | null
|
||||
}
|
||||
|
||||
export type ScreenshotGroupImport = ScreenshotGroup & {
|
||||
screenshot_ids: string[]
|
||||
}
|
||||
|
||||
export async function list_instance_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
|
||||
}
|
||||
|
||||
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_all_screenshots')
|
||||
}
|
||||
|
||||
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_synced_screenshots')
|
||||
}
|
||||
|
||||
export async function save_edited_screenshot(
|
||||
key: ScreenshotKey,
|
||||
pngBytes: Uint8Array,
|
||||
mode: 'create_copy' | 'replace_edit',
|
||||
): Promise<InstanceScreenshot> {
|
||||
return await invoke('plugin:instance|instance_save_edited_screenshot', {
|
||||
key,
|
||||
pngBytes: Array.from(pngBytes),
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
|
||||
return await invoke('plugin:instance|instance_list_screenshot_groups')
|
||||
}
|
||||
|
||||
export async function create_screenshot_group(
|
||||
name: string,
|
||||
screenshotIds: string[],
|
||||
): Promise<ScreenshotGroup> {
|
||||
return await invoke('plugin:instance|instance_create_screenshot_group', {
|
||||
name,
|
||||
screenshotIds,
|
||||
})
|
||||
}
|
||||
|
||||
export async function rename_screenshot_group(
|
||||
id: string,
|
||||
newName: string,
|
||||
): Promise<ScreenshotGroup> {
|
||||
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
|
||||
}
|
||||
|
||||
export async function delete_screenshot_group(id: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
|
||||
}
|
||||
|
||||
export async function set_screenshot_group_memberships(
|
||||
updates: ScreenshotGroupMembershipUpdate[],
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
|
||||
}
|
||||
|
||||
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
|
||||
}
|
||||
|
||||
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
|
||||
}
|
||||
|
||||
export async function export_screenshots(keys: ScreenshotKey[], exportPath: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_export_screenshots', { keys, exportPath })
|
||||
}
|
||||
|
||||
export async function move_screenshots(
|
||||
keys: ScreenshotKey[],
|
||||
targetInstanceId: string,
|
||||
): Promise<ScreenshotKey[]> {
|
||||
return await invoke('plugin:instance|instance_move_screenshots', { keys, targetInstanceId })
|
||||
}
|
||||
|
||||
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_open_screenshot', { key })
|
||||
}
|
||||
|
||||
export async function set_synced_option(
|
||||
instanceId: string,
|
||||
option: SyncedOption,
|
||||
enabled: boolean,
|
||||
resolution?: SyncedOptionJoinResolution,
|
||||
): Promise<GameInstance> {
|
||||
return await invoke('plugin:instance|instance_set_synced_option', {
|
||||
instanceId,
|
||||
option,
|
||||
enabled,
|
||||
resolution,
|
||||
})
|
||||
}
|
||||
|
||||
export type SyncedOption =
|
||||
| 'command_history'
|
||||
| 'multiplayer_servers'
|
||||
| 'creative_hotbars'
|
||||
| 'screenshots'
|
||||
|
||||
export type GlobalSyncedOptions = Record<SyncedOption, boolean>
|
||||
|
||||
export type SyncedOptionJoinAction = 'seed_shared' | 'attach' | 'merge' | 'requires_resolution'
|
||||
|
||||
export type SyncedOptionJoinResolution = 'use_synced' | 'use_instance'
|
||||
|
||||
export type SyncedOptionJoinPreview = {
|
||||
action: SyncedOptionJoinAction
|
||||
}
|
||||
|
||||
export type SyncedOptionCapability = {
|
||||
option: SyncedOption
|
||||
supported: boolean
|
||||
disabled_reason?: string | null
|
||||
}
|
||||
|
||||
export type SyncedOptionsOverview = {
|
||||
global_options: GlobalSyncedOptions
|
||||
capabilities: SyncedOptionCapability[]
|
||||
}
|
||||
|
||||
export async function get_synced_option_join_preview(
|
||||
instanceId: string,
|
||||
option: SyncedOption,
|
||||
): Promise<SyncedOptionJoinPreview> {
|
||||
return await invoke('plugin:instance|instance_get_synced_option_join_preview', {
|
||||
instanceId,
|
||||
option,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_synced_options_overview(
|
||||
instanceId: string,
|
||||
): Promise<SyncedOptionsOverview> {
|
||||
return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId })
|
||||
}
|
||||
|
||||
export async function get_global_synced_options(): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_get_global_synced_options')
|
||||
}
|
||||
|
||||
export async function set_global_synced_option(
|
||||
option: SyncedOption,
|
||||
enabled: boolean,
|
||||
): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_set_global_synced_option', {
|
||||
option,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_command_history(): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_get_command_history')
|
||||
}
|
||||
|
||||
export async function set_command_history(contents: string): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_set_command_history', { contents })
|
||||
}
|
||||
|
||||
export async function open_synced_options_folder(): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_open_synced_options_folder')
|
||||
}
|
||||
|
||||
export type SyncedServer = {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
accept_textures?: boolean | null
|
||||
}
|
||||
|
||||
export async function list_synced_servers(): Promise<SyncedServer[]> {
|
||||
return await invoke('plugin:instance|instance_list_synced_servers')
|
||||
}
|
||||
|
||||
export async function update_synced_server(server: SyncedServer): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_update_synced_server', { server })
|
||||
}
|
||||
|
||||
export async function remove_synced_server(serverId: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_remove_synced_server', { serverId })
|
||||
}
|
||||
|
||||
export async function rebuild_synced_options(instanceId?: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_rebuild_synced_options', { instanceId })
|
||||
}
|
||||
|
||||
export interface JavaVersion {
|
||||
parsed_version: number
|
||||
version: string
|
||||
|
||||
@@ -14,8 +14,11 @@ export type ModrinthCredentials = {
|
||||
|
||||
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
|
||||
|
||||
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
|
||||
return await invoke('plugin:mr-auth|modrinth_login', { flow })
|
||||
export async function login(
|
||||
flow: ModrinthAuthFlow = 'sign-in',
|
||||
addAccount = false,
|
||||
): Promise<ModrinthCredentials> {
|
||||
return await invoke('plugin:mr-auth|modrinth_login', { flow, addAccount })
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
@@ -26,6 +29,18 @@ export async function get(): Promise<ModrinthCredentials | null> {
|
||||
return await invoke('plugin:mr-auth|get')
|
||||
}
|
||||
|
||||
export async function getAll(): Promise<ModrinthCredentials[]> {
|
||||
return await invoke('plugin:mr-auth|get_all')
|
||||
}
|
||||
|
||||
export async function setActive(userId: string): Promise<void> {
|
||||
return await invoke('plugin:mr-auth|set_active', { userId })
|
||||
}
|
||||
|
||||
export async function removeUser(userId: string): Promise<void> {
|
||||
return await invoke('plugin:mr-auth|remove_account', { userId })
|
||||
}
|
||||
|
||||
export async function cancelLogin(): Promise<void> {
|
||||
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
||||
}
|
||||
|
||||
+6
@@ -16,6 +16,12 @@ export type GameInstance = {
|
||||
loader_version?: string
|
||||
|
||||
group_ids: string[]
|
||||
synced_options: {
|
||||
command_history: boolean
|
||||
multiplayer_servers: boolean
|
||||
creative_hotbars: boolean
|
||||
screenshots: boolean
|
||||
}
|
||||
|
||||
link?: InstanceLink | null
|
||||
shared_instance?: SharedInstanceAttachment | null
|
||||
|
||||
@@ -29,12 +29,18 @@ export type SingleplayerWorld = BaseWorld & {
|
||||
export type ServerWorld = BaseWorld & {
|
||||
type: 'server'
|
||||
index: number
|
||||
server_id?: string
|
||||
source?: ServerSource
|
||||
address: string
|
||||
pack_status: ServerPackStatus
|
||||
project_id?: string
|
||||
content_kind?: string
|
||||
}
|
||||
|
||||
export type ServerSource = 'user_synced' | 'modpack' | 'linked_server_project' | 'local_desynced'
|
||||
|
||||
export type DesyncServerMode = 'keep_in_other_instances' | 'remove_from_other_instances'
|
||||
|
||||
export type World = SingleplayerWorld | ServerWorld
|
||||
|
||||
export type WorldWithInstance = {
|
||||
@@ -44,6 +50,14 @@ export type WorldWithInstance = {
|
||||
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
|
||||
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
|
||||
|
||||
export async function desync_server(
|
||||
instanceId: string,
|
||||
serverId: string,
|
||||
mode: DesyncServerMode,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:worlds|desync_server', { instanceId, serverId, mode })
|
||||
}
|
||||
|
||||
export type ServerStatus = {
|
||||
// https://minecraft.wiki/w/Text_component_format
|
||||
description?: string | Chat
|
||||
@@ -260,17 +274,6 @@ function parseServerHost(address: string): string {
|
||||
return trimmedAddress.toLowerCase()
|
||||
}
|
||||
|
||||
function isIPv4Host(host: string): boolean {
|
||||
const segments = host.split('.')
|
||||
if (segments.length !== 4) return false
|
||||
|
||||
return segments.every((segment) => {
|
||||
if (!/^\d+$/.test(segment)) return false
|
||||
const value = Number.parseInt(segment, 10)
|
||||
return value >= 0 && value <= 255
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalization converts addresses to a canonical form (lowercase-host:port, default port 25565)
|
||||
*/
|
||||
@@ -307,28 +310,6 @@ export function normalizeServerAddress(address: string): string {
|
||||
|
||||
return `${host}:${port}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain key used for deduping server entries by removing a single leading subdomain.
|
||||
* Example: test.cobblemon.gg and cobblemon.gg map to cobblemon.gg
|
||||
*/
|
||||
export function getServerDomainKey(address: string): string {
|
||||
const normalizedAddress = normalizeServerAddress(address)
|
||||
if (!normalizedAddress) return ''
|
||||
|
||||
const separator = normalizedAddress.lastIndexOf(':')
|
||||
if (separator <= 0 || separator === normalizedAddress.length - 1) return normalizedAddress
|
||||
|
||||
const host = normalizedAddress.slice(0, separator).replace(/\.+$/, '')
|
||||
if (!host) return normalizedAddress
|
||||
if (host.includes(':') || isIPv4Host(host)) return normalizedAddress
|
||||
|
||||
const segments = host.split('.').filter(Boolean)
|
||||
if (segments.length <= 2) return host
|
||||
|
||||
return segments.slice(1).join('.')
|
||||
}
|
||||
|
||||
export function resolveManagedServerWorld(
|
||||
worlds: World[],
|
||||
managedName: string | null | undefined,
|
||||
|
||||
@@ -1154,9 +1154,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "تسجيل الدخول إلى حساب Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "تسجيل الدخول ك <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "الترقية إلى Modrinth+"
|
||||
},
|
||||
@@ -1169,9 +1166,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "الصفحة الرئيسية"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "المزيد من الخيارات"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "الصورة التالية"
|
||||
},
|
||||
@@ -1406,9 +1400,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "السلوك"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "خيارات اللعبة الافتراضية"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "تثبيتات جافا"
|
||||
},
|
||||
@@ -2216,12 +2207,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "اختر قناة التحديث"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "إجراءات تشغيل إضافية"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "إجراءات تشغيل إضافية مخصصة"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "إجراءات التشغيل المضافة تسمح للمستخدمين المتقدمين بإنشاء أوامر نظامية محددة قبل و عند تشغيل اللعبة."
|
||||
},
|
||||
@@ -2285,9 +2270,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "إعدادات التثبيت غير متاحة أثناء قفل هذه النسخة."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "جافا والذاكرة"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "متغيرات البيئة المخصصة"
|
||||
},
|
||||
|
||||
@@ -1151,9 +1151,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Přihlásit se do Modrinth účtu"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Přihlášen jako <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Upgradovat na Modrinth+"
|
||||
},
|
||||
@@ -1166,9 +1163,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Domov"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Více možností"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Další obrázek"
|
||||
},
|
||||
@@ -1697,12 +1691,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Vyber aktualizační kanál"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Spouštěcí hooky"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Vlastní spouštěcí hooky"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooky umožňují pokročilým uživatelům spustit určité systémové příkazy před a po spuštění hry."
|
||||
},
|
||||
@@ -1742,9 +1730,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "{loader} verze"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java a pamět"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Vlastní proměnná prostředí"
|
||||
},
|
||||
|
||||
@@ -881,12 +881,6 @@
|
||||
"instance.settings.tabs.general.update-channel.beta": {
|
||||
"message": "Beta"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Startkommandoer"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Brugerfinansieret startkommandoer"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Startkommandoer giver avancerede brugere mulighed for at køre bestemte systemkommandoer før og efter spillet startes."
|
||||
},
|
||||
@@ -926,9 +920,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "{loader} version"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java og hukommelse"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Indskriv Java argumenter..."
|
||||
},
|
||||
|
||||
@@ -1232,9 +1232,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Bei einem Modrinth-Konto anmelden"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Angemeldet als <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Auf Modrinth+ upgraden"
|
||||
},
|
||||
@@ -1247,9 +1244,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Start"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Weitere Optionen"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Nächstes Bild"
|
||||
},
|
||||
@@ -1484,9 +1478,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Verhalten"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Standard-Spieloptionen"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java Installationen"
|
||||
},
|
||||
@@ -2309,12 +2300,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Update-Kanal auswählen"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Start Hooks"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Benutzerdefinierte Start Hooks"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooks ermöglichen es erfahrenen Benutzern, bestimmte Systembefehle vor und nach dem Start des Spiels auszuführen."
|
||||
},
|
||||
@@ -2378,9 +2363,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Installationseinstellungen sind nicht verfügbar, solange diese Instanz gesperrt ist."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java und Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Benutzerdefinierte Umgebungsvariablen"
|
||||
},
|
||||
|
||||
@@ -1232,9 +1232,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Bei einem Modrinth-Konto anmelden"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Angemeldet als <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Auf Modrinth+ upgraden"
|
||||
},
|
||||
@@ -1247,9 +1244,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Start"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Weitere Optionen"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Nächstes Bild"
|
||||
},
|
||||
@@ -1484,9 +1478,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Verhalten"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Standard-Spieloptionen"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java-Installationen"
|
||||
},
|
||||
@@ -2309,12 +2300,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Aktualisierungskanal auswählen"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Startargumente"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Benutzerdefinierte Startargumente"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooks ermöglichen es fortgeschrittenen Benutzern, bestimmte Systembefehle vor und nach dem Spielstart auszuführen."
|
||||
},
|
||||
@@ -2378,9 +2363,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Installationseinstellungen sind nicht verfügbar, solange diese Instanz gesperrt ist."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java und Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Benutzerdefinierte Umgebungsvariablen"
|
||||
},
|
||||
|
||||
@@ -260,6 +260,9 @@
|
||||
"app.browse.hide-installed-modpacks": {
|
||||
"message": "Hide already installed"
|
||||
},
|
||||
"app.browse.project-actions.label": {
|
||||
"message": "Project actions"
|
||||
},
|
||||
"app.browse.project-type.data-packs": {
|
||||
"message": "Data Packs"
|
||||
},
|
||||
@@ -287,6 +290,9 @@
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
|
||||
},
|
||||
"app.error.state-init.open-backups-folder": {
|
||||
"message": "Open backups folder"
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Enter modpack description..."
|
||||
},
|
||||
@@ -314,6 +320,9 @@
|
||||
"app.export-modal.version-number-placeholder": {
|
||||
"message": "1.0.0"
|
||||
},
|
||||
"app.home.jump-back-in.actions.label": {
|
||||
"message": "Instance actions"
|
||||
},
|
||||
"app.home.jump-back-in.instance-locked": {
|
||||
"message": "This instance has been locked"
|
||||
},
|
||||
@@ -464,6 +473,9 @@
|
||||
"app.instance.action.stop": {
|
||||
"message": "Stop"
|
||||
},
|
||||
"app.instance.actions.label": {
|
||||
"message": "Instance actions"
|
||||
},
|
||||
"app.instance.add-server.add": {
|
||||
"message": "Add"
|
||||
},
|
||||
@@ -746,6 +758,9 @@
|
||||
"app.instance.tab.logs": {
|
||||
"message": "Logs"
|
||||
},
|
||||
"app.instance.tab.screenshots": {
|
||||
"message": "Screenshots"
|
||||
},
|
||||
"app.instance.tab.share": {
|
||||
"message": "Share"
|
||||
},
|
||||
@@ -794,6 +809,9 @@
|
||||
"app.instance.worlds.remove-server-modal.remove-button": {
|
||||
"message": "Remove server"
|
||||
},
|
||||
"app.instance.worlds.remove-server-modal.synced-removal-notice": {
|
||||
"message": "This server will also be removed from {count, plural, one {# other instance} other {# other instances}} because it’s synced."
|
||||
},
|
||||
"app.instance.worlds.remove-server-modal.title": {
|
||||
"message": "Remove server"
|
||||
},
|
||||
@@ -851,6 +869,9 @@
|
||||
"app.java-selector.test-installation": {
|
||||
"message": "Test Java installation"
|
||||
},
|
||||
"app.library.actions.label": {
|
||||
"message": "Library actions"
|
||||
},
|
||||
"app.library.context-menu.create-instance": {
|
||||
"message": "New instance"
|
||||
},
|
||||
@@ -899,6 +920,9 @@
|
||||
"app.library.group-by.none": {
|
||||
"message": "No grouping"
|
||||
},
|
||||
"app.library.group.actions.label": {
|
||||
"message": "Group actions"
|
||||
},
|
||||
"app.library.group.collapse": {
|
||||
"message": "Collapse group"
|
||||
},
|
||||
@@ -1022,6 +1046,9 @@
|
||||
"app.library.instance.action.view-instance": {
|
||||
"message": "View instance"
|
||||
},
|
||||
"app.library.instance.actions.label": {
|
||||
"message": "Instance actions"
|
||||
},
|
||||
"app.library.instance.deselect": {
|
||||
"message": "Deselect instance"
|
||||
},
|
||||
@@ -1217,27 +1244,42 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "An update is required to play {name}. Please update to latest version to launch the game."
|
||||
},
|
||||
"app.nav.add-account": {
|
||||
"message": "Add account"
|
||||
},
|
||||
"app.nav.create-new-instance": {
|
||||
"message": "Create new instance"
|
||||
},
|
||||
"app.nav.home": {
|
||||
"message": "Home"
|
||||
},
|
||||
"app.nav.loading-profile": {
|
||||
"message": "Loading profile..."
|
||||
},
|
||||
"app.nav.modrinth-account": {
|
||||
"message": "Modrinth account"
|
||||
},
|
||||
"app.nav.modrinth-hosting": {
|
||||
"message": "Modrinth Hosting"
|
||||
},
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Sign in to a Modrinth account"
|
||||
"app.nav.remove-account": {
|
||||
"message": "Remove account"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Signed in as <user>{username}</user>"
|
||||
"app.nav.screenshots": {
|
||||
"message": "Screenshots"
|
||||
},
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Sign into Modrinth"
|
||||
},
|
||||
"app.nav.switch-account": {
|
||||
"message": "Switch account"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Upgrade to Modrinth+"
|
||||
},
|
||||
"app.nav.view-profile": {
|
||||
"message": "View profile"
|
||||
},
|
||||
"app.navigation.go-back": {
|
||||
"message": "Go back"
|
||||
},
|
||||
@@ -1247,9 +1289,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Home"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "More options"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Next image"
|
||||
},
|
||||
@@ -1262,6 +1301,9 @@
|
||||
"app.notification.warning": {
|
||||
"message": "Warning"
|
||||
},
|
||||
"app.project.actions.label": {
|
||||
"message": "Project actions"
|
||||
},
|
||||
"app.project.coming-soon": {
|
||||
"message": "Coming soon"
|
||||
},
|
||||
@@ -1304,15 +1346,177 @@
|
||||
"app.project.versions.already-installed": {
|
||||
"message": "Already installed"
|
||||
},
|
||||
"app.quick-instance-switcher.actions.label": {
|
||||
"message": "Instance actions"
|
||||
},
|
||||
"app.quick-instance-switcher.drag-show-tooltip": {
|
||||
"message": "Drag to show recent instances"
|
||||
},
|
||||
"app.quick-instance-switcher.drag-tooltip": {
|
||||
"message": "Drag to resize"
|
||||
},
|
||||
"app.quick-instance-switcher.instance-locked": {
|
||||
"message": "This instance has been locked"
|
||||
},
|
||||
"app.quick-instance-switcher.view-instance": {
|
||||
"message": "View instance"
|
||||
},
|
||||
"app.restarting": {
|
||||
"message": "Restarting..."
|
||||
},
|
||||
"app.screenshots.copied": {
|
||||
"message": "Copied"
|
||||
},
|
||||
"app.screenshots.copy": {
|
||||
"message": "Copy image"
|
||||
},
|
||||
"app.screenshots.delete-description": {
|
||||
"message": "Permanently delete {name}? This action cannot be undone."
|
||||
},
|
||||
"app.screenshots.delete-success": {
|
||||
"message": "Screenshot deleted"
|
||||
},
|
||||
"app.screenshots.delete-title": {
|
||||
"message": "Delete screenshot"
|
||||
},
|
||||
"app.screenshots.deselect": {
|
||||
"message": "Deselect {name}"
|
||||
},
|
||||
"app.screenshots.edit": {
|
||||
"message": "Edit screenshot"
|
||||
},
|
||||
"app.screenshots.empty-description": {
|
||||
"message": "Screenshots you take in-game will appear here."
|
||||
},
|
||||
"app.screenshots.empty-heading": {
|
||||
"message": "No screenshots yet"
|
||||
},
|
||||
"app.screenshots.error-heading": {
|
||||
"message": "Failed to load screenshots"
|
||||
},
|
||||
"app.screenshots.go-to-instance": {
|
||||
"message": "Go to instance"
|
||||
},
|
||||
"app.screenshots.group-by": {
|
||||
"message": "Group by"
|
||||
},
|
||||
"app.screenshots.group.all-screenshots": {
|
||||
"message": "All screenshots"
|
||||
},
|
||||
"app.screenshots.group.collapse": {
|
||||
"message": "Collapse group"
|
||||
},
|
||||
"app.screenshots.group.custom": {
|
||||
"message": "Custom group"
|
||||
},
|
||||
"app.screenshots.group.date": {
|
||||
"message": "Date"
|
||||
},
|
||||
"app.screenshots.group.delete": {
|
||||
"message": "Delete group"
|
||||
},
|
||||
"app.screenshots.group.delete-description": {
|
||||
"message": "Screenshots in this group will become ungrouped."
|
||||
},
|
||||
"app.screenshots.group.edit": {
|
||||
"message": "Edit group name"
|
||||
},
|
||||
"app.screenshots.group.empty": {
|
||||
"message": "Drag and drop to add screenshots."
|
||||
},
|
||||
"app.screenshots.group.expand": {
|
||||
"message": "Expand group"
|
||||
},
|
||||
"app.screenshots.group.instance": {
|
||||
"message": "Instance"
|
||||
},
|
||||
"app.screenshots.group.new": {
|
||||
"message": "New group"
|
||||
},
|
||||
"app.screenshots.group.none": {
|
||||
"message": "No grouping"
|
||||
},
|
||||
"app.screenshots.group.this-month": {
|
||||
"message": "This month"
|
||||
},
|
||||
"app.screenshots.group.this-week": {
|
||||
"message": "This week"
|
||||
},
|
||||
"app.screenshots.group.today": {
|
||||
"message": "Today"
|
||||
},
|
||||
"app.screenshots.group.ungrouped": {
|
||||
"message": "Ungrouped"
|
||||
},
|
||||
"app.screenshots.group.yesterday": {
|
||||
"message": "Yesterday"
|
||||
},
|
||||
"app.screenshots.heading": {
|
||||
"message": "Screenshots"
|
||||
},
|
||||
"app.screenshots.more-actions": {
|
||||
"message": "More actions"
|
||||
},
|
||||
"app.screenshots.no-results-description": {
|
||||
"message": "Try a different search or filter."
|
||||
},
|
||||
"app.screenshots.no-results-heading": {
|
||||
"message": "No matching screenshots"
|
||||
},
|
||||
"app.screenshots.preview.instance-and-date": {
|
||||
"message": "{instance} · {date}"
|
||||
},
|
||||
"app.screenshots.search": {
|
||||
"message": "Search"
|
||||
},
|
||||
"app.screenshots.select": {
|
||||
"message": "Select {name}"
|
||||
},
|
||||
"app.screenshots.selection.aria-label": {
|
||||
"message": "Selected screenshots"
|
||||
},
|
||||
"app.screenshots.selection.delete-description": {
|
||||
"message": "Delete {count, plural, one {# screenshot} other {# screenshots}}? This action cannot be undone."
|
||||
},
|
||||
"app.screenshots.selection.delete-success": {
|
||||
"message": "{count, plural, one {# screenshot deleted} other {# screenshots deleted}}"
|
||||
},
|
||||
"app.screenshots.selection.delete-title": {
|
||||
"message": "Delete selected screenshots"
|
||||
},
|
||||
"app.screenshots.selection.export-zip": {
|
||||
"message": "Export ZIP"
|
||||
},
|
||||
"app.screenshots.selection.global-export-filename": {
|
||||
"message": "Modrinth screenshots.zip"
|
||||
},
|
||||
"app.screenshots.selection.instance-export-filename": {
|
||||
"message": "{instance} screenshots.zip"
|
||||
},
|
||||
"app.screenshots.selection.remove-from-group": {
|
||||
"message": "Remove from group"
|
||||
},
|
||||
"app.screenshots.selection.selected-count": {
|
||||
"message": "{count} selected"
|
||||
},
|
||||
"app.screenshots.selection.zip-archive": {
|
||||
"message": "ZIP archive"
|
||||
},
|
||||
"app.screenshots.show-in-folder": {
|
||||
"message": "Show in folder"
|
||||
},
|
||||
"app.screenshots.sort-by": {
|
||||
"message": "Sort by"
|
||||
},
|
||||
"app.screenshots.sort.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"app.screenshots.sort.newest": {
|
||||
"message": "Newest"
|
||||
},
|
||||
"app.screenshots.sort.oldest": {
|
||||
"message": "Oldest"
|
||||
},
|
||||
"app.settings.app-version": {
|
||||
"message": "Modrinth App {version}"
|
||||
},
|
||||
@@ -1340,6 +1544,9 @@
|
||||
"app.settings.default-instance-options.height.title": {
|
||||
"message": "Height"
|
||||
},
|
||||
"app.settings.default-instance-options.java-and-memory.title": {
|
||||
"message": "Java and memory"
|
||||
},
|
||||
"app.settings.default-instance-options.java-arguments.description": {
|
||||
"message": "Arguments passed to Java when launching an instance."
|
||||
},
|
||||
@@ -1349,6 +1556,9 @@
|
||||
"app.settings.default-instance-options.java-arguments.title": {
|
||||
"message": "Java arguments"
|
||||
},
|
||||
"app.settings.default-instance-options.launch-hooks.title": {
|
||||
"message": "Launch hooks"
|
||||
},
|
||||
"app.settings.default-instance-options.memory-allocation.description": {
|
||||
"message": "Maximum memory available to each instance."
|
||||
},
|
||||
@@ -1382,6 +1592,9 @@
|
||||
"app.settings.default-instance-options.width.title": {
|
||||
"message": "Width"
|
||||
},
|
||||
"app.settings.default-instance-options.window.title": {
|
||||
"message": "Window"
|
||||
},
|
||||
"app.settings.default-instance-options.wrapper-hook.description": {
|
||||
"message": "Command used to wrap the Minecraft launch process."
|
||||
},
|
||||
@@ -1478,15 +1691,54 @@
|
||||
"app.settings.sidebar.label.instances": {
|
||||
"message": "Instances"
|
||||
},
|
||||
"app.settings.synced-options.command-history": {
|
||||
"message": "Command history"
|
||||
},
|
||||
"app.settings.synced-options.command-history.description": {
|
||||
"message": "Sync command history across your instances."
|
||||
},
|
||||
"app.settings.synced-options.command-history.editor-title": {
|
||||
"message": "Edit command history"
|
||||
},
|
||||
"app.settings.synced-options.creative-hotbars": {
|
||||
"message": "Saved creative hotbars"
|
||||
},
|
||||
"app.settings.synced-options.creative-hotbars.description": {
|
||||
"message": "Sync saved creative hotbars across your instances."
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers": {
|
||||
"message": "Multiplayer servers"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.address": {
|
||||
"message": "Server address"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.description": {
|
||||
"message": "Sync multiplayer servers across your instances."
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.editor-title": {
|
||||
"message": "Edit synced servers"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.empty": {
|
||||
"message": "No user-added servers are currently synced."
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.name": {
|
||||
"message": "Server name"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.none-synced-yet": {
|
||||
"message": "No servers synced yet"
|
||||
},
|
||||
"app.settings.synced-options.screenshots": {
|
||||
"message": "Screenshots"
|
||||
},
|
||||
"app.settings.synced-options.screenshots.description": {
|
||||
"message": "View screenshots from your instances in one place."
|
||||
},
|
||||
"app.settings.tabs.appearance": {
|
||||
"message": "Appearance"
|
||||
},
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Behavior"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Default game options"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java installations"
|
||||
},
|
||||
@@ -1499,6 +1751,9 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"app.settings.tabs.synced-options": {
|
||||
"message": "Synced settings"
|
||||
},
|
||||
"app.sidebar.playing-as": {
|
||||
"message": "Playing as"
|
||||
},
|
||||
@@ -1808,6 +2063,9 @@
|
||||
"friends.add-friends-to-share": {
|
||||
"message": "<link>Add friends</link> to see what they're playing!"
|
||||
},
|
||||
"friends.friend.actions.label": {
|
||||
"message": "Friend actions"
|
||||
},
|
||||
"friends.friend.cancel-request": {
|
||||
"message": "Cancel request"
|
||||
},
|
||||
@@ -2309,12 +2567,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Select update channel"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Launch hooks"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Custom launch hooks"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooks allow advanced users to run certain system commands before and after launching the game."
|
||||
},
|
||||
@@ -2378,20 +2630,17 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Installation settings are unavailable while this instance is locked."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java and memory"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Custom environment variables"
|
||||
"message": "Use custom environment variables for this instance."
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Custom Java arguments"
|
||||
"message": "Use custom Java arguments for this instance."
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Custom Java installation"
|
||||
"message": "Use a custom Java installation for this instance."
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Custom memory allocation"
|
||||
"message": "Use a custom memory allocation for this instance."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Enter environmental variables..."
|
||||
@@ -2417,14 +2666,74 @@
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.settings-overrides": {
|
||||
"message": "Sync overrides"
|
||||
},
|
||||
"instance.settings.tabs.sharing": {
|
||||
"message": "Sharing"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.command-history": {
|
||||
"message": "Command history"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.command-history.disabled-in-app": {
|
||||
"message": "Command history syncing is turned off in app settings."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.command-history.exclude-description": {
|
||||
"message": "Exclude this instance from command history syncing."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.creative-hotbars": {
|
||||
"message": "Saved creative hotbars"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app": {
|
||||
"message": "Saved creative hotbar syncing is turned off in app settings."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.creative-hotbars.exclude-description": {
|
||||
"message": "Exclude this instance from saved creative hotbar syncing."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.hotbars-conflict.backup-description": {
|
||||
"message": "The version being replaced will be backed up before anything changes."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.hotbars-conflict.description": {
|
||||
"message": "{instance} and your synced version have different creative hotbars. Choose which one to use across your instances."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.hotbars-conflict.title": {
|
||||
"message": "Choose creative hotbars"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.hotbars-conflict.use-instance": {
|
||||
"message": "Overwrite others"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.hotbars-conflict.use-synced": {
|
||||
"message": "Use synced"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.multiplayer-servers": {
|
||||
"message": "Multiplayer servers"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app": {
|
||||
"message": "Multiplayer server syncing is turned off in app settings."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.multiplayer-servers.exclude-description": {
|
||||
"message": "Exclude this instance from multiplayer server syncing."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.open-app-settings": {
|
||||
"message": "Open synced settings"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.screenshots": {
|
||||
"message": "Screenshots"
|
||||
},
|
||||
"instance.settings.tabs.synced-options.screenshots.disabled-in-app": {
|
||||
"message": "Screenshots are turned off in app settings."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.screenshots.exclude-description": {
|
||||
"message": "Exclude this instance’s screenshots from the Screenshots page."
|
||||
},
|
||||
"instance.settings.tabs.synced-options.shared-settings.description": {
|
||||
"message": "Game settings can be shared between instances. Choose what to share in app settings."
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Window"
|
||||
},
|
||||
"instance.settings.tabs.window.custom-window-settings": {
|
||||
"message": "Custom window settings"
|
||||
"message": "Use custom window settings for this instance."
|
||||
},
|
||||
"instance.settings.tabs.window.fullscreen": {
|
||||
"message": "Fullscreen"
|
||||
@@ -2510,6 +2819,9 @@
|
||||
"instance.worlds.a_minecraft_server": {
|
||||
"message": "A Minecraft Server"
|
||||
},
|
||||
"instance.worlds.actions.label": {
|
||||
"message": "World actions"
|
||||
},
|
||||
"instance.worlds.cant_connect": {
|
||||
"message": "Can't connect to server"
|
||||
},
|
||||
@@ -2519,6 +2831,21 @@
|
||||
"instance.worlds.create_shortcut": {
|
||||
"message": "Create shortcut"
|
||||
},
|
||||
"instance.worlds.desync-server.description": {
|
||||
"message": "Do you want to keep this server in other synced instances, or remove it from them?"
|
||||
},
|
||||
"instance.worlds.desync-server.keep": {
|
||||
"message": "Keep"
|
||||
},
|
||||
"instance.worlds.desync-server.remove": {
|
||||
"message": "Remove"
|
||||
},
|
||||
"instance.worlds.desync-server.title": {
|
||||
"message": "Desync server"
|
||||
},
|
||||
"instance.worlds.desync_server": {
|
||||
"message": "Desync"
|
||||
},
|
||||
"instance.worlds.dont_show_on_home": {
|
||||
"message": "Don't show on Home"
|
||||
},
|
||||
@@ -2552,6 +2879,9 @@
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Play instance"
|
||||
},
|
||||
"instance.worlds.play_world": {
|
||||
"message": "Play world"
|
||||
},
|
||||
"instance.worlds.shortcut-created": {
|
||||
"message": "Shortcut created"
|
||||
},
|
||||
|
||||
@@ -1232,9 +1232,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Inicia sesión con una cuenta Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Sesión iniciada como <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Mejorar a Modrinth+"
|
||||
},
|
||||
@@ -1247,9 +1244,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Inicio"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Más opciones"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Siguiente imágen"
|
||||
},
|
||||
@@ -1484,9 +1478,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Comportamiento"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Opciones por defecto del juego"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Instalaciones de Java"
|
||||
},
|
||||
@@ -2309,12 +2300,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Selecciona el canal de actualizaciones"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Hooks de inicio"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Hooks personalizados"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Los hooks permiten que usuarios avanzados ejecuten comandos del sistema antes y despues de lanzar el juego."
|
||||
},
|
||||
@@ -2378,9 +2363,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Los ajustes de instalación no están disponibles mientras esta instancia esté bloqueada."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java y memoria"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variables de entorno personalizadas"
|
||||
},
|
||||
|
||||
@@ -1202,9 +1202,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Iniciar sesión en una cuenta de Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Sesión iniciada como <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Actualizar a Modrinth+"
|
||||
},
|
||||
@@ -1217,9 +1214,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Inicio"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Más opciones"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Siguiente imagen"
|
||||
},
|
||||
@@ -1418,9 +1412,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Comportamiento"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Opciones predeterminadas del juego"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Instalaciones de Java"
|
||||
},
|
||||
@@ -2237,12 +2228,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Seleccionar canal de actualizaciones"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Hooks de lanzamiento"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Hooks de lanzamiento personalizados"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Los hooks permiten a los usuarios avanzados ejecutar ciertas funciones del sistema antes y después de lanzar el juego."
|
||||
},
|
||||
@@ -2300,9 +2285,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Los ajustes de instalación no están disponibles mientras esta instancia esté bloqueada."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java y memoria"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variables de entorno personalizadas"
|
||||
},
|
||||
|
||||
@@ -728,12 +728,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Valitse päivitys kanava"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Aloitustoiminnot"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Mukautetut aloitustoiminnot"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Koukut antavat edistyneiden käyttäjien suorittaa tiettyjä järjestelmä komentoja pelin käynnistystä ennen ja sen jälkeen."
|
||||
},
|
||||
@@ -773,9 +767,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "{loader} versio"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java ja muisti"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Mukautetut ympäristö muuttujat"
|
||||
},
|
||||
|
||||
@@ -494,12 +494,6 @@
|
||||
"instance.settings.tabs.general.update-channel.beta": {
|
||||
"message": "Beta"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Mga launch hook"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Mga pasadyang launch hook"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Binibigyan-daan ng mga hook ang mga ekspertong tagagamit na makapagtakbo ng mga pansistemang utos o system command bago at pagkatapos ma-launch ang laro."
|
||||
},
|
||||
@@ -539,9 +533,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Bersiyon ng {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java at memorya"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Environment variables"
|
||||
},
|
||||
|
||||
@@ -1196,9 +1196,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Se connecter à un compte Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Connecté en tant que <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Passer à Modrinth+"
|
||||
},
|
||||
@@ -1211,9 +1208,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Accueil"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Plus d'options"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Prochaine image"
|
||||
},
|
||||
@@ -1445,9 +1439,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Comportement"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Options de jeu par défaut"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Installations de Java"
|
||||
},
|
||||
@@ -2165,12 +2156,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Sélectionner le canal de mise à jour"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Crochets de lancement"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Crochets de lancement personnalisés"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Les crochets permettent aux usagers avancés d'exécuter certaines commandes systèmes avant et après le lancement du jeu."
|
||||
},
|
||||
@@ -2234,9 +2219,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Les paramètres d'installation ne sont pas disponible pendant que cette instance est verrouillée."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java et mémoire"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variables d'environnement personnalisé"
|
||||
},
|
||||
|
||||
@@ -389,12 +389,6 @@
|
||||
"instance.settings.tabs.general.name": {
|
||||
"message": "שם"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "פעולות בעת הפעלה"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "פעולות בעת הפעלה מותאמות אישית"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "פעולות בעת הפעלה מאפשרות למשתמשים מתקדמים להריץ פקודות מערכת מסוימות לפני ואחרי שהמשחק מופעל."
|
||||
},
|
||||
@@ -434,9 +428,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "{loader} גרסה"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "ג'אווה וזיכרון"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "משתני סביבה"
|
||||
},
|
||||
|
||||
@@ -983,9 +983,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Bejelentkezés egy Modrinth-fiókba"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Bejelentkezve mint <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Frissíts a Modrinth+-ra"
|
||||
},
|
||||
@@ -1217,9 +1214,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Működés"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "A játék alapbeállításai"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Telepített Java példányok"
|
||||
},
|
||||
@@ -1820,12 +1814,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Frissítési csatorna kiválasztása"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Indítási horgok"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Saját indítási horgok"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "A horgok lehetővé teszik a haladó felhasználók számára, hogy bizonyos rendszerparancsokat futtassanak a játék elindítása előtt és után."
|
||||
},
|
||||
@@ -1880,9 +1868,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "A telepítési beállítások nem érhetők el, amíg ez a példány zárolva van."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java és memória"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Egyéni környezeti változók"
|
||||
},
|
||||
|
||||
@@ -593,12 +593,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Pilih kanal pembaruan"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Luncurkan kait"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Luncurkan kait tersuai"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Kait membolehkan pengguna ahli untuk menjalankan beberapa perintah sistem sebelum dan setelah meluncurkan permainan."
|
||||
},
|
||||
@@ -638,9 +632,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Versi {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java dan memori"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variabel lingkungan tersuai"
|
||||
},
|
||||
|
||||
@@ -1226,9 +1226,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Accedi a un account Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Accesso effettuato come <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Abbonati a Modrinth+"
|
||||
},
|
||||
@@ -1241,9 +1238,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Home"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Altre opzioni"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Immagine successiva"
|
||||
},
|
||||
@@ -1478,9 +1472,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Comportamento"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Opzioni di gioco predefinite"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Installazioni Java"
|
||||
},
|
||||
@@ -2303,12 +2294,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Seleziona il canale di aggiornamento"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Hook di avvio"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Hook di avvio personalizzati"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "I hook permettono a utenti avanzati di eseguire comandi di sistema prima e dopo l'avvio del gioco."
|
||||
},
|
||||
@@ -2372,9 +2357,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Le impostazioni non sono disponibili mentre l'istanza è bloccata."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java e memoria"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Personalizzate"
|
||||
},
|
||||
|
||||
@@ -776,9 +776,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Modrinthアカウントにサインイン"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "<user>{username}</user>としてサインイン中"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Modrinth+にアップグレード"
|
||||
},
|
||||
@@ -992,9 +989,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "動作"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "デフォルトのゲーム設定"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Javaのインストール設定"
|
||||
},
|
||||
@@ -1685,12 +1679,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "アップデートチャンネルを選択"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "起動フック"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "カスタム起動フック"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "フックを使用して、ゲームの起動前後にシステムコマンドを実行するようにできます。"
|
||||
},
|
||||
@@ -1754,9 +1742,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "このインスタンスがロックされている間は、インストール設定を利用できません。"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Javaとメモリ"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "カスタム環境変数"
|
||||
},
|
||||
|
||||
@@ -1175,9 +1175,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Modrinth 계정으로 로그인"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "<user>{username}</user> 으로 로그인됨"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Modrinth+ 로 업그레이드"
|
||||
},
|
||||
@@ -1400,9 +1397,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "동작"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "기본 게임 옵션"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java 설치"
|
||||
},
|
||||
@@ -1961,12 +1955,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "업데이트 채널 선택"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "실행 후크"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "사용자 지정 실행 후크"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "후크 기능을 사용하면 고급 사용자가 게임 실행 전후에 특정 시스템 명령을 실행할 수 있습니다."
|
||||
},
|
||||
@@ -2009,9 +1997,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "이 인스턴스가 잠긴 상태에서는 설치 설정을 사용할 수 없습니다."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java 및 메모리"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "사용자 지정 환경 변수"
|
||||
},
|
||||
|
||||
@@ -665,12 +665,6 @@
|
||||
"instance.settings.tabs.general.update-channel.release": {
|
||||
"message": "Terbitan"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Cangkuk pelancaran"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Cangkuk pelancaran tersuai"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Cangkuk membenarkan pengguna lanjutan menjalankan perintah sistem tertentu sebelum dan selepas melancarkan permainan."
|
||||
},
|
||||
@@ -710,9 +704,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Versi {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java dan ingatan"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Pembolehubah persekitaran tersuai"
|
||||
},
|
||||
|
||||
@@ -758,9 +758,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Meld je aan bij een Modrinth-account"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Aangemeld als <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Upgrade naar Modrinth+"
|
||||
},
|
||||
@@ -974,9 +971,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Gedrag"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Standaard spelopties"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java installaties"
|
||||
},
|
||||
@@ -1535,12 +1529,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Selecteer updatekanaal"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Opstart haakjes"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Aangepaste opstart haakjes"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Haakjes maken het mogelijk voor gevorderde gebruikers om bepaalde systeemcommando's uit te voeren voor en na het opstarten van het spel."
|
||||
},
|
||||
@@ -1604,9 +1592,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Installatie-instellingen zijn niet beschikbaar zolang deze instantie is vergrendeld."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java en geheugen"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Aangepaste omgevingsvariabelen"
|
||||
},
|
||||
|
||||
@@ -362,12 +362,6 @@
|
||||
"instance.settings.tabs.general.name": {
|
||||
"message": "Navn"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Startkroker"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Egendefinerte startkroker"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Startkroker lar avanserte brukere køre visse systemkommandoer før og etter spillet blir startet."
|
||||
},
|
||||
@@ -407,9 +401,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "{loader} versjon"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java og minne"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Egendefinert minneallokasjon"
|
||||
},
|
||||
|
||||
@@ -1082,18 +1082,12 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Zaloguj się na konto Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Zalogowano jako <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Ulepsz do Modrinth+"
|
||||
},
|
||||
"app.navigation.home": {
|
||||
"message": "Strona główna"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Więcej opcji"
|
||||
},
|
||||
"app.news.title": {
|
||||
"message": "Aktualności"
|
||||
},
|
||||
@@ -1286,9 +1280,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Zachowanie"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Domyślne opcje gry"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Instalacje Java"
|
||||
},
|
||||
@@ -2024,12 +2015,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Wybierz kanał aktualizacji"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Haczyki startowe"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Własne haczyki przy uruchomieniu"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooki pozwalają zaawansowanym użytkownikom uruchamiać określone systemowe komendy przed i po uruchomieniu gry."
|
||||
},
|
||||
@@ -2075,9 +2060,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Wersja {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java i pamięć"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Zmienne środowiskowe"
|
||||
},
|
||||
|
||||
@@ -851,9 +851,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Iniciar sessão em uma conta Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Conectado como <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Assinar o Modrinth+"
|
||||
},
|
||||
@@ -1067,9 +1064,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Comportamento"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Configurações padrão do jogo"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Instalações Java"
|
||||
},
|
||||
@@ -1631,12 +1625,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Selecionar canal de atualização"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Ações de inicialização"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Ações de inicialização personalizadas"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "As ações permitem usuários experientes que executem certos comandos do sistema antes ou após iniciar o jogo."
|
||||
},
|
||||
@@ -1700,9 +1688,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "As opções de instalação estarão indisponíveis enquanto esta instância estiver privada."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java e memória"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variáveis de ambiente personalizadas"
|
||||
},
|
||||
|
||||
@@ -806,12 +806,6 @@
|
||||
"instance.settings.tabs.general.update-channel.beta": {
|
||||
"message": "Beta"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Ganchos de inicio"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Ganchos de inicio personalizados"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Ganchos permitem a utilizadores avançados executarem certos comandos de sistema antes e após abrir o jogo."
|
||||
},
|
||||
@@ -869,9 +863,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Versão {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java e memória"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variáveis de ambiente"
|
||||
},
|
||||
|
||||
@@ -347,12 +347,6 @@
|
||||
"instance.settings.tabs.general.name": {
|
||||
"message": "Nume"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Cârlige de lansare"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Cârlige de lansare personalizate"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Cârligele permit utilizatorilor avansați să ruleze anumite comenzi de sistem înainte și după lansarea jocului."
|
||||
},
|
||||
@@ -392,9 +386,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Versiune {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java și memorie"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variabile de mediu"
|
||||
},
|
||||
|
||||
@@ -1211,9 +1211,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Войти в аккаунт Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Текущий аккаунт: <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Перейти на Modrinth+"
|
||||
},
|
||||
@@ -1226,9 +1223,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Главная"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Дополнительно"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Следующее изображение"
|
||||
},
|
||||
@@ -1463,9 +1457,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Поведение"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Предустановки игры"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Расположение Java"
|
||||
},
|
||||
@@ -2285,12 +2276,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Выберите канал обновления"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Команды запуска"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Переопределение команд запуска"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Позволяет опытным пользователям задать системные команды, выполняемые до и после запуска игры."
|
||||
},
|
||||
@@ -2354,9 +2339,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Параметры установки недоступны, пока сборка заблокирована."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java и память"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Переопределение переменных среды"
|
||||
},
|
||||
|
||||
@@ -848,12 +848,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Izaberi kanal ažuiranja"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Hookovi pokreta"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Prilagođeni hookovi za lansiranje"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hookovi omogućavaju naprednim korisnicima da pokreću određene sistemske komande pre i posle pokretanja igre."
|
||||
},
|
||||
@@ -893,9 +887,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Verzija {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java i radna memorija"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Prilagođene promenljive okruženja"
|
||||
},
|
||||
|
||||
@@ -1178,9 +1178,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Logga in till ett Modrinth-konto"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Inloggad som <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Uppgradera till Modrinth+"
|
||||
},
|
||||
@@ -1193,9 +1190,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Hem"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Mer alternativ"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Nästa bild"
|
||||
},
|
||||
@@ -1379,9 +1373,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Beteende"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Standard spelinställningar"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java-installationer"
|
||||
},
|
||||
@@ -2144,12 +2135,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Välj uppdateringskanal"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Startkrokar"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Egendefinierade startkrokar"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Krokar gör det möjligt för avancerade användare att köra vissa systemkommandon före och efter att spelet startas."
|
||||
},
|
||||
@@ -2204,9 +2189,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Installationsinställningar är otillgängligt medan den här instansen är låst."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java och minne"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Anpassade miljövariabler"
|
||||
},
|
||||
|
||||
@@ -578,12 +578,6 @@
|
||||
"instance.settings.tabs.general.name": {
|
||||
"message": "ชื่อ"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Launch Hook"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "กำหนด Launch Hook เอง"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Launch Hook จะช่วยให้ผู้เล่นที่ชำนาญสามารถใช้คำสั่งต่อระบบบางอย่างได้ ทั้งก่อนและหลังเกมถูกเปิด"
|
||||
},
|
||||
@@ -623,9 +617,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "เวอร์ชัน {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java และ หน่วยความจำ"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Environment Variables"
|
||||
},
|
||||
|
||||
@@ -1232,9 +1232,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Bir Modrinth hesabıyla oturum açın"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "<user>{username}</user> olarak oturum açıldı"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Modrinth+'a yükselt"
|
||||
},
|
||||
@@ -1247,9 +1244,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "Ana Sayfa"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Diğer ayarlar"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Sonraki görsel"
|
||||
},
|
||||
@@ -1484,9 +1478,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Davranış"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Varsayılan oyun seçenekleri"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java kurulumları"
|
||||
},
|
||||
@@ -2309,12 +2300,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Güncelleme kanalını seç"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Başlatma kancaları"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Özel başlatma kancaları"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Kancalar gelişmiş kullanıcıların oyunu başlattıktan önce ve sonra belirli sistem komutları çalıştırmasını sağlar."
|
||||
},
|
||||
@@ -2378,9 +2363,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Bu örnek kilitliyken kurulum ayarlarına erişilemez."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java ve bellek"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Özel ortam değişkenleri"
|
||||
},
|
||||
|
||||
@@ -1022,18 +1022,12 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "Увійти в обліковий запис Modrinth"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "Вхід виконано як <user>{username}</user>"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Оновити до Modrinth+"
|
||||
},
|
||||
"app.navigation.home": {
|
||||
"message": "Головна"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "Більше налаштувань"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "Наступне зображення"
|
||||
},
|
||||
@@ -1259,9 +1253,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "Поведінка"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Стандартні параметри гри"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Інсталяції Java"
|
||||
},
|
||||
@@ -2009,12 +2000,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Оберіть випуск оновлення"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Параметри запуску"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Користувацькі параметри запуску"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Дають змогу досвідченим користувачам застосовувати певні системні команди до та після запуску гри."
|
||||
},
|
||||
@@ -2078,9 +2063,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "Налаштування інсталяції недоступні, поки ця збірка заблокована."
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java та пам’ять"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Власні змінні середовища"
|
||||
},
|
||||
|
||||
@@ -938,12 +938,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Chọn kênh cập nhật"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Khởi chạy Hooks"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "Hooks khởi chạy tuỳ biến"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooks cho phép người dùng nâng cao chạy câu lệnh hệ thống trước và sau khi chạy game."
|
||||
},
|
||||
@@ -983,9 +977,6 @@
|
||||
"instance.settings.tabs.installation.loader-version": {
|
||||
"message": "Phiên bản {loader}"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java và bộ nhớ"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Tùy chọn biến môi trường"
|
||||
},
|
||||
|
||||
@@ -1232,9 +1232,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "登录Modrinth账户"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "以<user>{username}</user>身份登录"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "升级至Modrinth+"
|
||||
},
|
||||
@@ -1247,9 +1244,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "主页"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "更多选项"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "下一张图片"
|
||||
},
|
||||
@@ -1484,9 +1478,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "行为"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "默认游戏选项"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java 管理"
|
||||
},
|
||||
@@ -2309,12 +2300,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "选择更新渠道"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "启动钩子"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "自定义启动钩子"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "钩子供高级用户使用,可设置游戏启动前后运行的特定系统命令。"
|
||||
},
|
||||
@@ -2378,9 +2363,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "当实例被锁定时,安装设置不可用。"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java 及内存"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "自定义环境变量"
|
||||
},
|
||||
|
||||
@@ -1232,9 +1232,6 @@
|
||||
"app.nav.sign-in-to-modrinth-account": {
|
||||
"message": "登入 Modrinth 帳號"
|
||||
},
|
||||
"app.nav.signed-in-as": {
|
||||
"message": "以 <user>{username}</user> 登入"
|
||||
},
|
||||
"app.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "升級至 Modrinth+"
|
||||
},
|
||||
@@ -1247,9 +1244,6 @@
|
||||
"app.navigation.home": {
|
||||
"message": "首頁"
|
||||
},
|
||||
"app.navigation.more-options": {
|
||||
"message": "更多選項"
|
||||
},
|
||||
"app.navigation.next-image": {
|
||||
"message": "下一張圖片"
|
||||
},
|
||||
@@ -1484,9 +1478,6 @@
|
||||
"app.settings.tabs.behavior": {
|
||||
"message": "行為"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "預設遊戲選項"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java 安裝"
|
||||
},
|
||||
@@ -2309,12 +2300,6 @@
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "選擇更新通道"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "啟動掛勾"
|
||||
},
|
||||
"instance.settings.tabs.hooks.custom-hooks": {
|
||||
"message": "自訂啟動掛勾"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "掛勾讓進階使用者能在遊戲啟動前和啟動後執行特定的系統指令。"
|
||||
},
|
||||
@@ -2378,9 +2363,6 @@
|
||||
"instance.settings.tabs.installation.locked": {
|
||||
"message": "實例被鎖定時無法使用安裝設定選項。"
|
||||
},
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java 和記憶體"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "自訂環境變數"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user