menu refactor + web account switcher (#7307)

* add account switcher, migrate context menus to overflow menu system, update version filter controls, only pad instance icons

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

* prepr

* handle reauthentication

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

* rename generic menus to ButtonMenu (& types)

* fix hydration issue w/ dropdowns + middleware error
This commit is contained in:
Prospector
2026-08-27 00:35:34 +00:00
committed by GitHub
parent 3439a43880
commit b68a199814
226 changed files with 6585 additions and 4078 deletions
+337 -86
View File
@@ -10,6 +10,7 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ArrowLeftRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
@@ -23,20 +24,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,6 +57,7 @@ import {
useDebugLogger,
useFormatBytes,
useHostingIntercom,
UserRoleIcon,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
@@ -62,7 +69,7 @@ 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 +103,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 +116,22 @@ 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 {
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,
@@ -201,6 +217,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(
@@ -425,6 +442,11 @@ const stateInitialized = ref(false)
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({
@@ -457,6 +479,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,6 +488,7 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
document.querySelector('body').removeEventListener('contextmenu', handleContextMenu)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
clearDelayedUpdatePopup()
@@ -480,7 +504,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' },
@@ -542,13 +565,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 +695,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 +712,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 +725,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')
@@ -984,6 +1026,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 +1122,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 +1135,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 +1148,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 +1391,7 @@ onMounted(() => {
})
const accounts = ref(null)
const friendsList = ref(null)
provide('accountsCard', accounts)
useAppEvent('command', handleCommand, appEvents)
@@ -1711,12 +1919,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 +2010,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)"
@@ -1819,59 +2054,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 +2265,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 +2439,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 +2587,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'"
@@ -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 }}
@@ -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)
}
@@ -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',
@@ -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 }}
@@ -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)">
@@ -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>
@@ -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,
@@ -432,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) => {
@@ -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>
@@ -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,
}
}
@@ -222,7 +222,17 @@ 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()
}
defineExpose({ show, showProfile, showFeatureFlags })
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
}
@@ -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>
@@ -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'
@@ -225,6 +226,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 +262,364 @@ 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.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: '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',
action: () => emit('stop'),
}
: {
id: 'play',
label: formatMessage(messages.playWorld),
icon: PlayIcon,
tone: 'brand',
disabled: playDisabled.value,
tooltip: playTooltip.value ?? undefined,
action: () => emit('play'),
},
{ type: 'divider' },
...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 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>
<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="showStop" type="colored" color="red" @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="playTooltip"
:disabled="playDisabled"
type="colored"
color="brand"
@click="emit('play')"
>
<template v-if="world.last_played">
{{ formatRelativeTime(dayjs(world.last_played).toISOString()) }}
</template>
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
</div>
<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) {
@@ -1,6 +1,14 @@
import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlayIcon, PlusIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui'
import {
CheckIcon,
ClipboardCopyIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import type { ButtonMenuOption, CardAction } from '@modrinth/ui'
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -26,16 +34,7 @@ interface BrowseServerInstance {
}
interface ContextMenuHandle {
showMenu: (
event: MouseEvent,
result: Labrinth.Search.v3.ResultSearchProject,
options: { name: string }[],
) => void
}
interface ContextMenuOptionClick {
option: 'open_link' | 'copy_link'
item: Labrinth.Search.v3.ResultSearchProject
open: (event: MouseEvent, options: ButtonMenuOption[]) => void
}
export interface UseAppServerBrowseOptions {
@@ -275,19 +274,21 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}
function handleRightClick(event: MouseEvent, result: Labrinth.Search.v3.ResultSearchProject) {
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
}
function handleOptionsClick(args: ContextMenuOptionClick) {
const url = getProjectUrl(args.item)
switch (args.option) {
case 'open_link':
openUrl(url)
break
case 'copy_link':
navigator.clipboard.writeText(url)
break
}
const url = getProjectUrl(result)
contextMenuRef.value?.open(event, [
{
id: 'open_link',
label: formatMessage(commonMessages.openInModrinthButton),
icon: GlobeIcon,
action: () => void openUrl(url),
},
{
id: 'copy_link',
label: formatMessage(commonMessages.copyLinkButton),
icon: ClipboardCopyIcon,
action: () => void navigator.clipboard.writeText(url),
},
])
}
useAppEvent('process', (event) => {
@@ -314,7 +315,6 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
}
}
@@ -22,6 +22,7 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_offline_collapsed: true,
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
localhost_sign_in: false,
}
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
+100 -4
View File
@@ -1,28 +1,107 @@
import { prepareThemeColorTransition } from '@modrinth/ui'
import { computed, reactive, ref, watch } from 'vue'
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
export const DARK_THEMES = ['dark', 'oled', 'retro'] as const
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type DarkTheme = (typeof DARK_THEMES)[number]
type Theme = Exclude<ColorTheme, 'system'>
type NativeTheme = 'light' | 'dark'
const preferred = ref<ColorTheme>('dark')
const PREFERRED_THEME_KEY = 'modrinth-theme'
const PREFERRED_DARK_THEME_KEY = 'modrinth-preferred-dark-theme'
export function isDarkTheme(theme: string): theme is DarkTheme {
return (DARK_THEMES as readonly string[]).includes(theme)
}
function loadPreferredTheme(): ColorTheme {
try {
const stored = window.localStorage.getItem(PREFERRED_THEME_KEY)
if (stored && (THEME_OPTIONS as readonly string[]).includes(stored)) {
return stored as ColorTheme
}
} catch {
// storage blocked or full
}
for (const option of THEME_OPTIONS) {
if (option !== 'system' && document.documentElement.classList.contains(`${option}-mode`)) {
return option
}
}
return 'dark'
}
function loadPreferredDarkTheme(): DarkTheme {
try {
const stored = window.localStorage.getItem(PREFERRED_DARK_THEME_KEY)
if (stored && isDarkTheme(stored)) {
return stored
}
} catch {
// storage blocked or full
}
return 'dark'
}
const preferred = ref<ColorTheme>(loadPreferredTheme())
const preview = ref<ColorTheme | null>(null)
const preferredDark = ref<DarkTheme>(loadPreferredDarkTheme())
const advancedRendering = ref(true)
const syncAcrossDevices = ref(false)
const nativeThemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
const native = ref<Theme>(nativeThemeQuery.matches ? 'dark' : 'light')
const native = ref<NativeTheme>(nativeThemeQuery.matches ? 'dark' : 'light')
const active = computed<Theme>(() => {
const selectedTheme = preview.value ?? preferred.value
return selectedTheme === 'system' ? native.value : selectedTheme
if (selectedTheme !== 'system') {
return selectedTheme
}
return native.value === 'light' ? 'light' : preferredDark.value
})
nativeThemeQuery.addEventListener('change', (event) => {
native.value = event.matches ? 'dark' : 'light'
})
watch([preferred, preview], ([selectedPreferred, selectedPreview]) => {
const selectedTheme = selectedPreview ?? selectedPreferred
if (isDarkTheme(selectedTheme)) {
preferredDark.value = selectedTheme
}
})
watch(
preferred,
(theme) => {
try {
window.localStorage.setItem(PREFERRED_THEME_KEY, theme)
} catch {
// storage blocked or full
}
},
{ immediate: true },
)
watch(preferredDark, (theme) => {
try {
window.localStorage.setItem(PREFERRED_DARK_THEME_KEY, theme)
} catch {
// storage blocked or full
}
})
watch(
active,
(theme) => {
(theme, previousTheme) => {
if (previousTheme && previousTheme !== theme) {
prepareThemeColorTransition()
}
const html = document.documentElement
for (const option of THEME_OPTIONS) {
html.classList.remove(`${option}-mode`)
@@ -32,14 +111,31 @@ watch(
{ immediate: true },
)
function applyAccountAppearance(appearance: { auto: boolean; theme: string }): void {
if (isDarkTheme(appearance.theme)) {
preferredDark.value = appearance.theme
}
if (appearance.auto) {
preferred.value = 'system'
return
}
if ((THEME_OPTIONS as readonly string[]).includes(appearance.theme)) {
preferred.value = appearance.theme as ColorTheme
}
}
const theme = reactive({
preferred,
preview,
preferredDark,
active,
native,
syncAcrossDevices,
advancedRendering,
options: THEME_OPTIONS,
applyAccountAppearance,
})
export function useTheme() {
@@ -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]
}
+17 -2
View File
@@ -14,8 +14,11 @@ export type ModrinthCredentials = {
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow })
export async function login(
flow: ModrinthAuthFlow = 'sign-in',
addAccount = false,
): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow, addAccount })
}
export async function logout(): Promise<void> {
@@ -26,6 +29,18 @@ export async function get(): Promise<ModrinthCredentials | null> {
return await invoke('plugin:mr-auth|get')
}
export async function getAll(): Promise<ModrinthCredentials[]> {
return await invoke('plugin:mr-auth|get_all')
}
export async function setActive(userId: string): Promise<void> {
return await invoke('plugin:mr-auth|set_active', { userId })
}
export async function removeUser(userId: string): Promise<void> {
return await invoke('plugin:mr-auth|remove_account', { userId })
}
export async function cancelLogin(): Promise<void> {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}
@@ -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": "الصورة التالية"
},
@@ -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"
},
@@ -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"
},
@@ -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"
},
+58 -7
View File
@@ -260,6 +260,9 @@
"app.browse.hide-installed-modpacks": {
"message": "Hide already installed"
},
"app.browse.project-actions.label": {
"message": "Project actions"
},
"app.browse.project-type.data-packs": {
"message": "Data Packs"
},
@@ -287,6 +290,9 @@
"app.content-install.no-compatible-versions": {
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
},
"app.error.state-init.open-backups-folder": {
"message": "Open backups folder"
},
"app.export-modal.description-placeholder": {
"message": "Enter modpack description..."
},
@@ -314,6 +320,9 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.home.jump-back-in.actions.label": {
"message": "Instance actions"
},
"app.home.jump-back-in.instance-locked": {
"message": "This instance has been locked"
},
@@ -464,6 +473,9 @@
"app.instance.action.stop": {
"message": "Stop"
},
"app.instance.actions.label": {
"message": "Instance actions"
},
"app.instance.add-server.add": {
"message": "Add"
},
@@ -851,6 +863,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 +914,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 +1040,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 +1238,39 @@
"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.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 +1280,6 @@
"app.navigation.home": {
"message": "Home"
},
"app.navigation.more-options": {
"message": "More options"
},
"app.navigation.next-image": {
"message": "Next image"
},
@@ -1262,6 +1292,9 @@
"app.notification.warning": {
"message": "Warning"
},
"app.project.actions.label": {
"message": "Project actions"
},
"app.project.coming-soon": {
"message": "Coming soon"
},
@@ -1304,12 +1337,21 @@
"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..."
},
@@ -1808,6 +1850,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"
},
@@ -2510,6 +2555,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"
},
@@ -2552,6 +2600,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"
},
@@ -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"
},
@@ -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"
},
@@ -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"
},
@@ -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"
},
@@ -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+にアップグレード"
},
@@ -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+ 로 업그레이드"
},
@@ -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+"
},
@@ -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"
},
@@ -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+"
},
@@ -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": "Следующее изображение"
},
@@ -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"
},
@@ -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"
},
@@ -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": "Наступне зображення"
},
@@ -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": "下一张图片"
},
@@ -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": "下一張圖片"
},
+8 -9
View File
@@ -2,7 +2,6 @@
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ClipboardCopyIcon,
CompassIcon,
ExternalIcon,
GlobeIcon,
@@ -15,6 +14,7 @@ import {
BrowsePageLayout,
BrowseSidebar,
commonMessages,
ContextMenu,
CreationFlowModal,
defineMessages,
formatProjectTypeSentence,
@@ -38,7 +38,6 @@ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
@@ -516,7 +515,6 @@ const {
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
} = useAppServerBrowse({
instance,
isFromWorlds,
@@ -551,6 +549,10 @@ const messages = defineMessages({
id: 'app.browse.add-servers-to-instance',
defaultMessage: 'Adding server to instance',
},
projectActionsLabel: {
id: 'app.browse.project-actions.label',
defaultMessage: 'Project actions',
},
addToAnInstance: {
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
@@ -1331,12 +1333,9 @@ provideBrowseManager({
<div class="flex flex-col gap-3 p-6">
<BrowsePageLayout>
<template #after>
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
<template #open_link>
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
</template>
<template #copy_link>
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
<ContextMenu ref="contextMenuRef" :label="formatMessage(messages.projectActionsLabel)">
<template #open_link="{ option }">
<GlobeIcon /> {{ option.label }} <ExternalIcon />
</template>
</ContextMenu>
</template>
+14 -12
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { PlayIcon, PlusIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ContextMenu, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, inject, onActivated, ref } from 'vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import LibrarySection from '@/components/ui/library/index.vue'
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
@@ -36,6 +35,10 @@ const messages = defineMessages({
id: 'app.library.context-menu.create-instance',
defaultMessage: 'New instance',
},
libraryActionsLabel: {
id: 'app.library.actions.label',
defaultMessage: 'Library actions',
},
})
const homeBreadcrumb = useRootBreadcrumb({
@@ -87,13 +90,14 @@ function openPageContextMenu(event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
pageOptions.value?.showMenu(event, {}, [{ name: 'new_instance' }])
}
function handlePageOption({ option }: { option: string }) {
if (option === 'new_instance') {
showCreationModal?.()
}
pageOptions.value?.open(event, [
{
id: 'new_instance',
label: formatMessage(messages.newInstance),
icon: PlusIcon,
action: () => showCreationModal?.(),
},
])
}
</script>
@@ -110,8 +114,6 @@ function handlePageOption({ option }: { option: string }) {
:recent-instances="recentInstances"
/>
<LibrarySection :instances="instances" />
<ContextMenu ref="pageOptions" @option-clicked="handlePageOption">
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
</ContextMenu>
<ContextMenu ref="pageOptions" :label="formatMessage(messages.libraryActionsLabel)" />
</div>
</template>
@@ -1,7 +1,13 @@
<template>
<PageHeader :title="instance.name">
<template #leading>
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
<Avatar
:src="iconSrc"
:alt="instance.name"
size="64px"
:tint-by="instance.id"
pad-transparent-corners
/>
</template>
<template v-if="instance.shared_instance || instance.quarantined" #badges>
@@ -181,10 +187,10 @@ import {
import { Button, IconButton, SplitButton, TeleportOverflowMenu } from '@modrinth/ui'
import {
Avatar,
type ButtonMenuOption,
commonMessages,
defineMessages,
formatLoaderLabel,
type OverflowMenuOption,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
@@ -344,7 +350,7 @@ const playtimeLabel = computed(() => {
return `${seconds} second${seconds === 1 ? '' : 's'}`
})
const serverPlayOptions = computed<OverflowMenuOption[]>(() => [
const serverPlayOptions = computed<ButtonMenuOption[]>(() => [
{
id: 'launch_instance',
label: formatMessage(messages.launchInstance),
@@ -352,8 +358,8 @@ const serverPlayOptions = computed<OverflowMenuOption[]>(() => [
action: () => emit('play'),
},
])
const moreActions = computed<OverflowMenuOption[]>(() => {
const actions: OverflowMenuOption[] = [
const moreActions = computed<ButtonMenuOption[]>(() => {
const actions: ButtonMenuOption[] = [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
@@ -344,6 +344,7 @@ const messages = defineMessages({
class="transition-[filter] group-hover:brightness-75"
:tint-by="instance.id"
no-shadow
pad-transparent-corners
/>
<div
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
@@ -200,6 +200,7 @@ defineExpose({ show, hide })
:src="getInstanceIconUrl(instance.icon_path)"
size="24px"
:tint-by="props.instance.id"
pad-transparent-corners
/>
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
@@ -92,6 +92,7 @@ import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, FolderOpenIcon, LockIcon, LockOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
type ButtonMenuOption,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
@@ -107,7 +108,6 @@ import {
type ManagedContentModalState,
type ManagedContentProject,
type ManagedContentVersion,
type OverflowMenuOption,
provideContentManager,
ReadyTransition,
summarizeManagedContent,
@@ -1473,8 +1473,8 @@ async function handleShareItems(
await shareModal.value?.show(text)
}
function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
const options: OverflowMenuOption[] = []
function getOverflowOptions(item: ContentItem): ButtonMenuOption[] {
const options: ButtonMenuOption[] = []
options.push({
id: 'show-file',
+57 -65
View File
@@ -82,16 +82,7 @@
</template>
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EditIcon /> {{ formatMessage(messages.edit) }} </template>
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
<template #open_folder>
<FolderOpenIcon /> {{ formatMessage(messages.openFolder) }}
</template>
</ContextMenu>
<ContextMenu ref="options" :label="formatMessage(messages.instanceActionsLabel)" />
</div>
</template>
<script setup lang="ts">
@@ -109,6 +100,7 @@ import {
} from '@modrinth/assets'
import {
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
NavTabs,
@@ -122,7 +114,6 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
@@ -190,6 +181,10 @@ const messages = defineMessages({
edit: { id: 'app.instance.action.edit', defaultMessage: 'Edit' },
copyPath: { id: 'app.instance.action.copy-path', defaultMessage: 'Copy path' },
openFolder: { id: 'app.instance.action.open-folder', defaultMessage: 'Open folder' },
instanceActionsLabel: {
id: 'app.instance.actions.label',
defaultMessage: 'Instance actions',
},
contentTab: { id: 'app.instance.tab.content', defaultMessage: 'Content' },
filesTab: { id: 'app.instance.tab.files', defaultMessage: 'Files' },
worldsTab: { id: 'app.instance.tab.worlds', defaultMessage: 'Worlds' },
@@ -765,63 +760,60 @@ async function deleteSelectedInstance() {
}
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
...(instance.value?.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'open_folder' },
{ name: 'copy_path' },
]
const canAddContent = !instance.value?.quarantined
options.value?.showMenu(
event,
instance.value,
playing.value
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
...(instance.value?.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
options.value?.open(event, [
{
id: 'stop',
label: formatMessage(messages.stop),
icon: StopCircleIcon,
shown: playing.value,
tone: 'red',
action: () => void stopInstance('InstancePageContextMenu'),
},
{
id: 'play',
label: formatMessage(messages.play),
icon: PlayIcon,
shown: !playing.value && canAddContent,
tone: 'brand',
action: () => void startInstance('InstancePageContextMenu'),
},
{
id: 'add_content',
label: formatMessage(messages.addContent),
icon: PlusIcon,
shown: canAddContent,
action: () => void browseContent(instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'),
},
{ type: 'divider', shown: canAddContent },
{
id: 'edit',
label: formatMessage(messages.edit),
icon: EditIcon,
action: openSettings,
},
{
id: 'open_folder',
label: formatMessage(messages.openFolder),
icon: FolderOpenIcon,
action: () => {
if (instance.value) void showInstanceInFolder(instance.value.id)
},
},
{
id: 'copy_path',
label: formatMessage(messages.copyPath),
icon: ClipboardCopyIcon,
action: () => void copyInstancePath(),
},
])
}
const handleOptionsClick = async (args: { option: string; item: unknown }) => {
switch (args.option) {
case 'play':
await startInstance('InstancePageContextMenu')
break
case 'stop':
await stopInstance('InstancePageContextMenu')
break
case 'add_content':
await browseContent(instance.value?.loader === 'vanilla' ? 'datapack' : 'mod')
break
case 'edit':
openSettings()
break
case 'open_folder':
if (instance.value) await showInstanceInFolder(instance.value.id)
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value.id)
await navigator.clipboard.writeText(fullPath)
}
break
}
}
const copyInstancePath = async () => {
if (!instance.value) return
const fullPath = await get_full_path(instance.value.id)
await navigator.clipboard.writeText(fullPath)
}
provideInstancePage({
+25 -34
View File
@@ -208,15 +208,9 @@
v-if="projectInstallContext"
:install-context="projectInstallContext"
/>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #install>
<DownloadIcon /> {{ formatMessage(commonMessages.installButton) }}
</template>
<template #open_link>
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
</template>
<template #copy_link>
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
<ContextMenu ref="options" :label="formatMessage(messages.projectActionsLabel)">
<template #open_link="{ option }">
<GlobeIcon /> {{ option.label }} <ExternalIcon />
</template>
</ContextMenu>
<CreationFlowModal
@@ -260,6 +254,7 @@ import {
BrowseInstallHeader,
Button,
commonMessages,
ContextMenu,
CreationFlowModal,
defineMessages,
getTargetInstallPreferences,
@@ -287,7 +282,6 @@ import { computed, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import {
fetchCachedServerStatus,
@@ -353,6 +347,7 @@ const { formatMessage } = useVIntl()
const messages = defineMessages({
moreOptions: { id: 'app.project.more-options', defaultMessage: 'More options' },
projectActionsLabel: { id: 'app.project.actions.label', defaultMessage: 'Project actions' },
descriptionTab: { id: 'app.project.tab.description', defaultMessage: 'Description' },
versionsTab: { id: 'app.project.tab.versions', defaultMessage: 'Versions' },
galleryTab: { id: 'app.project.tab.gallery', defaultMessage: 'Gallery' },
@@ -926,36 +921,32 @@ async function install(version) {
const options = ref(null)
const handleRightClick = (event) => {
options.value.showMenu(event, data.value, [
const project = data.value
options.value.open(event, [
{
name: 'install',
id: 'install',
label: formatMessage(commonMessages.installButton),
icon: DownloadIcon,
action: () => install(null),
},
{ type: 'divider' },
{
id: 'open_link',
label: formatMessage(commonMessages.openInModrinthButton),
icon: GlobeIcon,
action: () => openProjectLink(project),
},
{
type: 'divider',
},
{
name: 'open_link',
},
{
name: 'copy_link',
id: 'copy_link',
label: formatMessage(commonMessages.copyLinkButton),
icon: ClipboardCopyIcon,
action: () => copyProjectLink(project),
},
])
}
const handleOptionsClick = (args) => {
switch (args.option) {
case 'install':
install(null)
break
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break
case 'copy_link':
navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
)
break
}
}
const getProjectLink = (project) => `https://modrinth.com/${project.project_type}/${project.slug}`
const openProjectLink = (project) => openUrl(getProjectLink(project))
const copyProjectLink = (project) => navigator.clipboard.writeText(getProjectLink(project))
</script>
<style scoped lang="scss">