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
+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">