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
@@ -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,
}
}