mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
fix: app library post release (#7211)
* fix: fixed height on modal * fix: conditional auto padding being applied on user avatars * feat: allow ungrouped to be dragged * feat: add drag handle to resize num items in jump in * qa * feat: add context menu submenus * feat: hook up icon editing in sub menu * fix: breadcrumb and sidebar not matching * fix: bring back signal icon for ping and players online * feat: add compact mode for library * smaller checkmark * pnpm prepr * feat: bring back loader/game version sort * add loader symbols * refactor: compact mode with sync'd app settings * pnpm prepr * move loaders up * qa * feat: exclude loaders from randomizing
This commit is contained in:
@@ -1007,6 +1007,7 @@ watch(
|
|||||||
if (behavior && appSettings.syncBehaviorAcrossDevices) {
|
if (behavior && appSettings.syncBehaviorAcrossDevices) {
|
||||||
const behaviorFeatureFlags = {
|
const behaviorFeatureFlags = {
|
||||||
worlds_in_home: behavior.show_jump_in,
|
worlds_in_home: behavior.show_jump_in,
|
||||||
|
compact_instance_cards: behavior.compact_instance_cards,
|
||||||
show_instance_play_time: behavior.show_play_time,
|
show_instance_play_time: behavior.show_play_time,
|
||||||
skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks,
|
skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks,
|
||||||
skip_non_essential_warnings: behavior.skip_non_essential_warnings,
|
skip_non_essential_warnings: behavior.skip_non_essential_warnings,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -20,6 +20,7 @@
|
|||||||
<div class="flex gap-2 w-full min-w-0">
|
<div class="flex gap-2 w-full min-w-0">
|
||||||
<Avatar
|
<Avatar
|
||||||
size="36px"
|
size="36px"
|
||||||
|
disable-conditional-icon-padding
|
||||||
:src="
|
:src="
|
||||||
selectedAccount
|
selectedAccount
|
||||||
? avatarUrl
|
? avatarUrl
|
||||||
@@ -46,7 +47,11 @@
|
|||||||
class="w-5 h-5 text-brand shrink-0"
|
class="w-5 h-5 text-brand shrink-0"
|
||||||
/>
|
/>
|
||||||
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
|
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
|
||||||
<Avatar :src="getAccountAvatarUrl(account)" size="24px" />
|
<Avatar
|
||||||
|
:src="getAccountAvatarUrl(account)"
|
||||||
|
size="24px"
|
||||||
|
disable-conditional-icon-padding
|
||||||
|
/>
|
||||||
<p
|
<p
|
||||||
class="m-0 truncate min-w-0"
|
class="m-0 truncate min-w-0"
|
||||||
:class="
|
:class="
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
<template>
|
|
||||||
<transition name="fade">
|
|
||||||
<div
|
|
||||||
v-show="shown"
|
|
||||||
ref="contextMenu"
|
|
||||||
class="context-menu"
|
|
||||||
:style="{
|
|
||||||
left: left,
|
|
||||||
top: top,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
|
||||||
<hr v-if="option.type === 'divider'" class="divider" />
|
|
||||||
<div
|
|
||||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
|
||||||
class="item clickable"
|
|
||||||
:class="[option.color ?? 'base']"
|
|
||||||
>
|
|
||||||
<slot :name="option.name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
|
|
||||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
|
||||||
|
|
||||||
const item = ref(null)
|
|
||||||
const contextMenu = ref(null)
|
|
||||||
const options = ref([])
|
|
||||||
const left = ref('0px')
|
|
||||||
const top = ref('0px')
|
|
||||||
const shown = ref(false)
|
|
||||||
const contextMenuId = Symbol()
|
|
||||||
const contextMenuOpenEvent = 'modrinth-context-menu-open'
|
|
||||||
|
|
||||||
const hideContextMenu = () => {
|
|
||||||
shown.value = false
|
|
||||||
emit('menu-closed')
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
showMenu: (event, passedItem, passedOptions) => {
|
|
||||||
window.dispatchEvent(new CustomEvent(contextMenuOpenEvent, { detail: contextMenuId }))
|
|
||||||
|
|
||||||
item.value = passedItem
|
|
||||||
options.value = passedOptions
|
|
||||||
|
|
||||||
// show to get dimensions
|
|
||||||
shown.value = true
|
|
||||||
|
|
||||||
// then, adjust position if overflowing
|
|
||||||
nextTick(() => {
|
|
||||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
|
||||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
|
||||||
const minFromEdge = 10
|
|
||||||
|
|
||||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
|
||||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
|
||||||
} else {
|
|
||||||
left.value = event.pageX + minFromEdge + 'px'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
|
||||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
|
||||||
} else {
|
|
||||||
top.value = event.pageY + minFromEdge + 'px'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hideMenu: hideContextMenu,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isInstanceLink = (item) => {
|
|
||||||
if (item.instance != undefined && item.instance.link) {
|
|
||||||
return true
|
|
||||||
} else if (item != undefined && item.link) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionClicked = (option) => {
|
|
||||||
emit('option-clicked', {
|
|
||||||
item: item.value,
|
|
||||||
option: option,
|
|
||||||
})
|
|
||||||
hideContextMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onEscKeyRelease = (event) => {
|
|
||||||
if (event.keyCode === 27) {
|
|
||||||
hideContextMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContextMenuOpen = (event) => {
|
|
||||||
if (shown.value && event.detail !== contextMenuId) {
|
|
||||||
hideContextMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
|
||||||
if (
|
|
||||||
contextMenu.value &&
|
|
||||||
contextMenu.value.$el !== event.target &&
|
|
||||||
!elements.includes(contextMenu.value.$el)
|
|
||||||
) {
|
|
||||||
hideContextMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('click', handleClickOutside)
|
|
||||||
window.addEventListener(contextMenuOpenEvent, handleContextMenuOpen)
|
|
||||||
document.body.addEventListener('keyup', onEscKeyRelease)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('click', handleClickOutside)
|
|
||||||
window.removeEventListener(contextMenuOpenEvent, handleContextMenuOpen)
|
|
||||||
document.removeEventListener('keyup', onEscKeyRelease)
|
|
||||||
})
|
|
||||||
</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);
|
|
||||||
|
|
||||||
.item {
|
|
||||||
align-items: center;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
gap: var(--gap-sm);
|
|
||||||
padding: var(--gap-sm);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
|
|
||||||
:deep(svg) {
|
|
||||||
color: var(--color-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
border: 1px solid var(--color-divider);
|
|
||||||
margin: var(--gap-sm);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="#teleports">
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-show="shown && !isMobileActiveSubmenu"
|
||||||
|
ref="contextMenu"
|
||||||
|
class="context-menu"
|
||||||
|
:style="menuStyle"
|
||||||
|
role="menu"
|
||||||
|
@keydown="handleMenuKeydown"
|
||||||
|
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
|
||||||
|
>
|
||||||
|
<template v-for="(option, index) in options" :key="index">
|
||||||
|
<hr v-if="isDivider(option)" class="divider" />
|
||||||
|
<button
|
||||||
|
v-else-if="isOptionVisible(option)"
|
||||||
|
:ref="(element) => setOptionButtonRef(index, element)"
|
||||||
|
type="button"
|
||||||
|
class="item clickable"
|
||||||
|
:class="[
|
||||||
|
option.color ?? 'base',
|
||||||
|
{
|
||||||
|
active: index === activeOptionIndex,
|
||||||
|
'safe-triangle-hover': index === pendingOptionIndex,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
role="menuitem"
|
||||||
|
:aria-haspopup="hasChildren(option) ? 'menu' : undefined"
|
||||||
|
:aria-expanded="hasChildren(option) ? index === activeOptionIndex : undefined"
|
||||||
|
@click.stop="handleOptionClick(option, index)"
|
||||||
|
@focus="handleOptionFocus(option, index)"
|
||||||
|
@mouseenter="handleOptionMouseEnter(option, index)"
|
||||||
|
>
|
||||||
|
<span class="item-content"><slot :name="option.name" /></span>
|
||||||
|
<ChevronRightIcon v-if="hasChildren(option)" class="submenu-chevron" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Teleport to="#teleports">
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="shown && activeOption && hasSubmenuPosition"
|
||||||
|
ref="submenu"
|
||||||
|
class="context-menu submenu"
|
||||||
|
:style="submenuStyle"
|
||||||
|
role="menu"
|
||||||
|
@keydown="handleSubmenuKeydown"
|
||||||
|
@mouseenter="handleSubmenuMouseEnter"
|
||||||
|
@mouseleave="isCursorInsideSubmenu = false"
|
||||||
|
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="isMobileSubmenuLayout"
|
||||||
|
type="button"
|
||||||
|
class="item clickable base mobile-back"
|
||||||
|
@click.stop="returnToMenu"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon class="submenu-chevron" />
|
||||||
|
<span class="item-content"><slot :name="activeOption.name" /></span>
|
||||||
|
</button>
|
||||||
|
<template v-for="(option, index) in activeOption.children" :key="index">
|
||||||
|
<hr v-if="isDivider(option)" class="divider" />
|
||||||
|
<button
|
||||||
|
v-else-if="isOptionVisible(option)"
|
||||||
|
:ref="(element) => setSubmenuButtonRef(index, element)"
|
||||||
|
type="button"
|
||||||
|
class="item clickable"
|
||||||
|
:class="option.color ?? 'base'"
|
||||||
|
role="menuitem"
|
||||||
|
@click.stop="optionClicked(option.name)"
|
||||||
|
>
|
||||||
|
<span class="item-content"><slot :name="option.name" /></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
|
||||||
|
|
||||||
|
import type { ContextMenuEmit } from './types'
|
||||||
|
import { useContextMenu } from './use-context-menu'
|
||||||
|
import { hasChildren, isDivider } from './utils'
|
||||||
|
|
||||||
|
const emit = defineEmits<ContextMenuEmit>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
shown,
|
||||||
|
contextMenu,
|
||||||
|
submenu,
|
||||||
|
options,
|
||||||
|
menuStyle,
|
||||||
|
submenuStyle,
|
||||||
|
activeOption,
|
||||||
|
activeOptionIndex,
|
||||||
|
pendingOptionIndex,
|
||||||
|
hasSubmenuPosition,
|
||||||
|
isMobileSubmenuLayout,
|
||||||
|
isMobileActiveSubmenu,
|
||||||
|
isCursorInsideSubmenu,
|
||||||
|
isOptionVisible,
|
||||||
|
setOptionButtonRef,
|
||||||
|
setSubmenuButtonRef,
|
||||||
|
showMenu,
|
||||||
|
hideMenu,
|
||||||
|
handleOptionClick,
|
||||||
|
optionClicked,
|
||||||
|
handleOptionFocus,
|
||||||
|
handleOptionMouseEnter,
|
||||||
|
returnToMenu,
|
||||||
|
handleSubmenuMouseEnter,
|
||||||
|
handleMenuMouseMove,
|
||||||
|
handleMenuKeydown,
|
||||||
|
handleSubmenuKeydown,
|
||||||
|
} = useContextMenu(emit)
|
||||||
|
|
||||||
|
defineExpose({ showMenu, hideMenu })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.context-menu {
|
||||||
|
background-color: var(--color-raised-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-floating);
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
margin: 0;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000000;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--gap-sm);
|
||||||
|
min-width: 12rem;
|
||||||
|
max-width: calc(100vw - 1.25rem);
|
||||||
|
|
||||||
|
&.submenu {
|
||||||
|
z-index: 1000001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--gap-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-chevron {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
margin-right: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(svg) {
|
||||||
|
color: var(--color-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.safe-triangle-hover),
|
||||||
|
&:active,
|
||||||
|
&:focus-visible,
|
||||||
|
&.active {
|
||||||
|
:deep(svg) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.base {
|
||||||
|
background-color: var(--color-button-bg);
|
||||||
|
color: var(--color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background-color: var(--color-red);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.contrast {
|
||||||
|
background-color: var(--color-orange);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-back {
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
border-radius: 0;
|
||||||
|
margin: calc(var(--gap-sm) * -1) calc(var(--gap-sm) * -1) var(--gap-sm);
|
||||||
|
width: calc(100% + var(--gap-sm) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
margin: var(--gap-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
|
|
||||||
|
export type ContextMenuDivider = {
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextMenuAction = {
|
||||||
|
name: string
|
||||||
|
color?: string
|
||||||
|
children?: ContextMenuOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextMenuParentAction = ContextMenuAction & {
|
||||||
|
children: ContextMenuOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
|
||||||
|
|
||||||
|
export type ContextMenuSelection = {
|
||||||
|
item: unknown
|
||||||
|
option: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextMenuEmit = {
|
||||||
|
(event: 'menu-closed'): void
|
||||||
|
(event: 'option-clicked', selection: ContextMenuSelection): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Point = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewportRect = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
offsetTop: number
|
||||||
|
offsetLeft: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ButtonRefElement = Element | ComponentPublicInstance | null
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { CSSProperties, Ref } from 'vue'
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { ContextMenuParentAction, Point, ViewportRect } from './types'
|
||||||
|
|
||||||
|
const MENU_GAP = 8
|
||||||
|
const VIEWPORT_MARGIN = 10
|
||||||
|
|
||||||
|
type ContextMenuPositionOptions = {
|
||||||
|
shown: Ref<boolean>
|
||||||
|
activeOption: Ref<ContextMenuParentAction | null>
|
||||||
|
activeOptionIndex: Ref<number | null>
|
||||||
|
isMobileSubmenuLayout: Ref<boolean>
|
||||||
|
contextMenu: Ref<HTMLElement | null>
|
||||||
|
submenu: Ref<HTMLElement | null>
|
||||||
|
optionButtonRefs: Map<number, HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextMenuPosition({
|
||||||
|
shown,
|
||||||
|
activeOption,
|
||||||
|
activeOptionIndex,
|
||||||
|
isMobileSubmenuLayout,
|
||||||
|
contextMenu,
|
||||||
|
submenu,
|
||||||
|
optionButtonRefs,
|
||||||
|
}: ContextMenuPositionOptions) {
|
||||||
|
const menuStyle = ref<CSSProperties>({ left: '0px', top: '0px' })
|
||||||
|
const menuAnchor = ref<Point>({ x: 0, y: 0 })
|
||||||
|
const submenuPosition = ref<Point>({ x: 0, y: 0 })
|
||||||
|
const hasSubmenuPosition = ref(false)
|
||||||
|
let positionRafId: number | null = null
|
||||||
|
|
||||||
|
const submenuStyle = computed<CSSProperties>(() => {
|
||||||
|
if (isMobileSubmenuLayout.value) return menuStyle.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${submenuPosition.value.x}px`,
|
||||||
|
top: `${submenuPosition.value.y}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateMenuPosition(x: number, y: number) {
|
||||||
|
if (!contextMenu.value) return
|
||||||
|
|
||||||
|
const viewport = getViewportRect()
|
||||||
|
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||||
|
const viewportLeft = viewport.offsetLeft
|
||||||
|
const viewportTop = viewport.offsetTop
|
||||||
|
const viewportRight = viewport.offsetLeft + viewport.width
|
||||||
|
const viewportBottom = viewport.offsetTop + viewport.height
|
||||||
|
const anchorX = x + viewport.offsetLeft
|
||||||
|
const anchorY = y + viewport.offsetTop
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
|
||||||
|
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
|
||||||
|
)
|
||||||
|
const top = Math.min(
|
||||||
|
Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
|
||||||
|
Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
|
||||||
|
)
|
||||||
|
|
||||||
|
menuStyle.value = { left: `${left}px`, top: `${top}px` }
|
||||||
|
if (activeOption.value) scheduleSubmenuPositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubmenuPosition() {
|
||||||
|
if (!activeOption.value || activeOptionIndex.value === null) return false
|
||||||
|
|
||||||
|
if (isMobileSubmenuLayout.value) {
|
||||||
|
hasSubmenuPosition.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionButton = optionButtonRefs.get(activeOptionIndex.value)
|
||||||
|
if (!optionButton || !contextMenu.value) return false
|
||||||
|
|
||||||
|
const viewport = getViewportRect()
|
||||||
|
const buttonRect = optionButton.getBoundingClientRect()
|
||||||
|
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||||
|
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||||
|
const submenuWidth = submenuRect?.width ?? menuRect.width
|
||||||
|
const submenuHeight = submenuRect?.height ?? 100
|
||||||
|
const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
|
||||||
|
const preferredLeft =
|
||||||
|
direction === 'right'
|
||||||
|
? buttonRect.right + MENU_GAP
|
||||||
|
: buttonRect.left - submenuWidth - MENU_GAP
|
||||||
|
const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
|
||||||
|
const maxLeft = Math.max(
|
||||||
|
minLeft,
|
||||||
|
viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
|
||||||
|
)
|
||||||
|
const minTop = viewport.offsetTop + VIEWPORT_MARGIN
|
||||||
|
const maxTop = Math.max(
|
||||||
|
minTop,
|
||||||
|
viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
submenuPosition.value = {
|
||||||
|
x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
|
||||||
|
y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
|
||||||
|
}
|
||||||
|
hasSubmenuPosition.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSubmenuPositionUpdate(retries = 8) {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!shown.value || !activeOption.value) return
|
||||||
|
|
||||||
|
const hasRenderedSubmenu = submenu.value !== null
|
||||||
|
if (updateSubmenuPosition()) {
|
||||||
|
if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePositionUpdate() {
|
||||||
|
if (!shown.value || positionRafId !== null) return
|
||||||
|
|
||||||
|
positionRafId = window.requestAnimationFrame(() => {
|
||||||
|
positionRafId = null
|
||||||
|
updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPositionUpdate() {
|
||||||
|
if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
menuStyle,
|
||||||
|
menuAnchor,
|
||||||
|
submenuStyle,
|
||||||
|
hasSubmenuPosition,
|
||||||
|
updateMenuPosition,
|
||||||
|
scheduleSubmenuPositionUpdate,
|
||||||
|
schedulePositionUpdate,
|
||||||
|
cancelPositionUpdate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewportRect(): ViewportRect {
|
||||||
|
const visualViewport = window.visualViewport
|
||||||
|
return {
|
||||||
|
width: visualViewport?.width ?? window.innerWidth,
|
||||||
|
height: visualViewport?.height ?? window.innerHeight,
|
||||||
|
offsetTop: visualViewport?.offsetTop ?? 0,
|
||||||
|
offsetLeft: visualViewport?.offsetLeft ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmenuOpenDirection(
|
||||||
|
menuRect: DOMRect,
|
||||||
|
submenuWidth: number,
|
||||||
|
viewport: ViewportRect,
|
||||||
|
): 'left' | 'right' {
|
||||||
|
const viewportLeft = viewport.offsetLeft
|
||||||
|
const viewportRight = viewport.offsetLeft + viewport.width
|
||||||
|
const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
|
||||||
|
const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
|
||||||
|
|
||||||
|
if (rightSpace >= submenuWidth) return 'right'
|
||||||
|
if (leftSpace >= submenuWidth) return 'left'
|
||||||
|
return rightSpace >= leftSpace ? 'right' : 'left'
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ButtonRefElement,
|
||||||
|
ContextMenuAction,
|
||||||
|
ContextMenuEmit,
|
||||||
|
ContextMenuOption,
|
||||||
|
ContextMenuParentAction,
|
||||||
|
Point,
|
||||||
|
} from './types'
|
||||||
|
import { useContextMenuPosition } from './use-context-menu-position'
|
||||||
|
import {
|
||||||
|
focusRelativeButton,
|
||||||
|
getFocusableButtons,
|
||||||
|
hasChildren,
|
||||||
|
isAction,
|
||||||
|
isInstanceLink,
|
||||||
|
isPointInTriangle,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
|
||||||
|
const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
|
||||||
|
|
||||||
|
export function useContextMenu(emit: ContextMenuEmit) {
|
||||||
|
const item = ref<unknown>(null)
|
||||||
|
const contextMenu = ref<HTMLElement | null>(null)
|
||||||
|
const submenu = ref<HTMLElement | null>(null)
|
||||||
|
const options = ref<ContextMenuOption[]>([])
|
||||||
|
const shown = ref(false)
|
||||||
|
const activeOptionIndex = ref<number | null>(null)
|
||||||
|
const pendingOptionIndex = ref<number | null>(null)
|
||||||
|
const isCursorInsideSubmenu = ref(false)
|
||||||
|
const isMobileSubmenuLayout = ref(false)
|
||||||
|
const lastMousePosition = ref<Point | null>(null)
|
||||||
|
const contextMenuId = Symbol()
|
||||||
|
const optionButtonRefs = new Map<number, HTMLElement>()
|
||||||
|
const submenuButtonRefs = new Map<number, HTMLElement>()
|
||||||
|
let previousMousePosition: Point | null = null
|
||||||
|
let pendingOptionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let mobileSubmenuMediaQuery: MediaQueryList | null = null
|
||||||
|
|
||||||
|
const activeOption = computed<ContextMenuParentAction | null>(() => {
|
||||||
|
if (activeOptionIndex.value === null) return null
|
||||||
|
const option = options.value[activeOptionIndex.value]
|
||||||
|
return option && isAction(option) && hasChildren(option) ? option : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
menuStyle,
|
||||||
|
menuAnchor,
|
||||||
|
submenuStyle,
|
||||||
|
hasSubmenuPosition,
|
||||||
|
updateMenuPosition,
|
||||||
|
scheduleSubmenuPositionUpdate,
|
||||||
|
schedulePositionUpdate,
|
||||||
|
cancelPositionUpdate,
|
||||||
|
} = useContextMenuPosition({
|
||||||
|
shown,
|
||||||
|
activeOption,
|
||||||
|
activeOptionIndex,
|
||||||
|
isMobileSubmenuLayout,
|
||||||
|
contextMenu,
|
||||||
|
submenu,
|
||||||
|
optionButtonRefs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMobileActiveSubmenu = computed(
|
||||||
|
() => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
|
||||||
|
return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptionButtonRef(index: number, element: ButtonRefElement) {
|
||||||
|
setButtonRef(optionButtonRefs, index, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
|
||||||
|
setButtonRef(submenuButtonRefs, index, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMenu() {
|
||||||
|
if (!shown.value) return
|
||||||
|
|
||||||
|
shown.value = false
|
||||||
|
deactivateSubmenu()
|
||||||
|
emit('menu-closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
|
||||||
|
window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
|
||||||
|
|
||||||
|
item.value = passedItem
|
||||||
|
options.value = passedOptions
|
||||||
|
menuAnchor.value = { x: event.clientX, y: event.clientY }
|
||||||
|
shown.value = true
|
||||||
|
deactivateSubmenu()
|
||||||
|
syncMobileSubmenuLayout()
|
||||||
|
nextTick(() => updateMenuPosition(event.clientX, event.clientY))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOptionClick(option: ContextMenuAction, index: number) {
|
||||||
|
if (hasChildren(option)) {
|
||||||
|
activateSubmenu(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
optionClicked(option.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionClicked(option: string) {
|
||||||
|
emit('option-clicked', { item: item.value, option })
|
||||||
|
hideMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOptionFocus(option: ContextMenuAction, index: number) {
|
||||||
|
if (hasChildren(option) && !isMobileSubmenuLayout.value) {
|
||||||
|
activateSubmenu(index)
|
||||||
|
} else if (!hasChildren(option)) {
|
||||||
|
deactivateSubmenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
|
||||||
|
if (isMobileSubmenuLayout.value) return
|
||||||
|
|
||||||
|
if (activeOptionIndex.value === null) {
|
||||||
|
if (hasChildren(option)) activateSubmenu(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeOptionIndex.value === index) return
|
||||||
|
|
||||||
|
if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
|
||||||
|
commitHoveredOption(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingOptionIndex.value = index
|
||||||
|
clearPendingOptionTimeout()
|
||||||
|
pendingOptionTimeout = setTimeout(() => {
|
||||||
|
if (pendingOptionIndex.value !== index) return
|
||||||
|
if (isCursorInsideSubmenu.value) {
|
||||||
|
pendingOptionIndex.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commitHoveredOption(index)
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitHoveredOption(index: number) {
|
||||||
|
const option = options.value[index]
|
||||||
|
if (option && hasChildren(option)) {
|
||||||
|
activateSubmenu(index)
|
||||||
|
} else {
|
||||||
|
deactivateSubmenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateSubmenu(index: number) {
|
||||||
|
clearPendingOptionTimeout()
|
||||||
|
pendingOptionIndex.value = null
|
||||||
|
activeOptionIndex.value = index
|
||||||
|
hasSubmenuPosition.value = false
|
||||||
|
scheduleSubmenuPositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateSubmenu() {
|
||||||
|
clearPendingOptionTimeout()
|
||||||
|
activeOptionIndex.value = null
|
||||||
|
pendingOptionIndex.value = null
|
||||||
|
hasSubmenuPosition.value = false
|
||||||
|
isCursorInsideSubmenu.value = false
|
||||||
|
lastMousePosition.value = null
|
||||||
|
previousMousePosition = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToMenu() {
|
||||||
|
const previousIndex = activeOptionIndex.value
|
||||||
|
deactivateSubmenu()
|
||||||
|
nextTick(() => {
|
||||||
|
if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmenuMouseEnter() {
|
||||||
|
isCursorInsideSubmenu.value = true
|
||||||
|
clearPendingOptionTimeout()
|
||||||
|
pendingOptionIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
|
||||||
|
previousMousePosition = lastMousePosition.value
|
||||||
|
lastMousePosition.value = { x: event.clientX, y: event.clientY }
|
||||||
|
|
||||||
|
if (
|
||||||
|
source === 'menu' &&
|
||||||
|
pendingOptionIndex.value !== null &&
|
||||||
|
!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
|
||||||
|
) {
|
||||||
|
commitHoveredOption(pendingOptionIndex.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
|
||||||
|
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||||
|
if (!submenuRect || !cursor || !origin) return false
|
||||||
|
|
||||||
|
const submenuTargetX =
|
||||||
|
origin.x <= submenuRect.left
|
||||||
|
? submenuRect.left
|
||||||
|
: origin.x >= submenuRect.right
|
||||||
|
? submenuRect.right
|
||||||
|
: cursor.x <= submenuRect.left
|
||||||
|
? submenuRect.left
|
||||||
|
: submenuRect.right
|
||||||
|
const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
|
||||||
|
const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
|
||||||
|
|
||||||
|
return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMenuKeydown(event: KeyboardEvent) {
|
||||||
|
const buttons = getFocusableButtons(optionButtonRefs)
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||||
|
} else if (event.key === 'Home' || event.key === 'End') {
|
||||||
|
event.preventDefault()
|
||||||
|
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
const focusedIndex = [...optionButtonRefs.entries()].find(
|
||||||
|
([, button]) => button === document.activeElement,
|
||||||
|
)?.[0]
|
||||||
|
const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
|
||||||
|
if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
|
||||||
|
event.preventDefault()
|
||||||
|
activateSubmenu(focusedIndex)
|
||||||
|
nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
hideMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmenuKeydown(event: KeyboardEvent) {
|
||||||
|
const buttons = getFocusableButtons(submenuButtonRefs)
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||||
|
} else if (event.key === 'Home' || event.key === 'End') {
|
||||||
|
event.preventDefault()
|
||||||
|
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
returnToMenu()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
hideMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
|
||||||
|
isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||||
|
if (shown.value && event.key === 'Escape') hideMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextMenuOpen(event: Event) {
|
||||||
|
if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof Node)) return
|
||||||
|
if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
|
||||||
|
syncMobileSubmenuLayout()
|
||||||
|
mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
|
||||||
|
window.addEventListener('click', handleClickOutside)
|
||||||
|
window.addEventListener('resize', schedulePositionUpdate)
|
||||||
|
window.addEventListener('scroll', schedulePositionUpdate, true)
|
||||||
|
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
|
||||||
|
window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
|
||||||
|
window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||||
|
document.addEventListener('keydown', handleDocumentKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPendingOptionTimeout()
|
||||||
|
cancelPositionUpdate()
|
||||||
|
mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
|
||||||
|
window.removeEventListener('click', handleClickOutside)
|
||||||
|
window.removeEventListener('resize', schedulePositionUpdate)
|
||||||
|
window.removeEventListener('scroll', schedulePositionUpdate, true)
|
||||||
|
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
|
||||||
|
window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
|
||||||
|
window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||||
|
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearPendingOptionTimeout() {
|
||||||
|
if (!pendingOptionTimeout) return
|
||||||
|
clearTimeout(pendingOptionTimeout)
|
||||||
|
pendingOptionTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shown,
|
||||||
|
contextMenu,
|
||||||
|
submenu,
|
||||||
|
options,
|
||||||
|
menuStyle,
|
||||||
|
submenuStyle,
|
||||||
|
activeOption,
|
||||||
|
activeOptionIndex,
|
||||||
|
pendingOptionIndex,
|
||||||
|
hasSubmenuPosition,
|
||||||
|
isMobileSubmenuLayout,
|
||||||
|
isMobileActiveSubmenu,
|
||||||
|
isCursorInsideSubmenu,
|
||||||
|
isOptionVisible,
|
||||||
|
setOptionButtonRef,
|
||||||
|
setSubmenuButtonRef,
|
||||||
|
showMenu,
|
||||||
|
hideMenu,
|
||||||
|
handleOptionClick,
|
||||||
|
optionClicked,
|
||||||
|
handleOptionFocus,
|
||||||
|
handleOptionMouseEnter,
|
||||||
|
returnToMenu,
|
||||||
|
handleSubmenuMouseEnter,
|
||||||
|
handleMenuMouseMove,
|
||||||
|
handleMenuKeydown,
|
||||||
|
handleSubmenuKeydown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonRef(
|
||||||
|
buttonRefs: Map<number, HTMLElement>,
|
||||||
|
index: number,
|
||||||
|
element: ButtonRefElement,
|
||||||
|
) {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
buttonRefs.set(index, element)
|
||||||
|
} else {
|
||||||
|
buttonRefs.delete(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
ContextMenuAction,
|
||||||
|
ContextMenuDivider,
|
||||||
|
ContextMenuOption,
|
||||||
|
ContextMenuParentAction,
|
||||||
|
Point,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export function isAction(option: ContextMenuOption): option is ContextMenuAction {
|
||||||
|
return 'name' in option
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
|
||||||
|
return 'type' in option && option.type === 'divider'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
|
||||||
|
return isAction(option) && Boolean(option.children?.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInstanceLink(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
|
||||||
|
if ('instance' in value) {
|
||||||
|
const instance = value.instance
|
||||||
|
return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'link' in value && Boolean(value.link)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFocusableButtons(buttonRefs: Map<number, HTMLElement>) {
|
||||||
|
return [...buttonRefs.entries()]
|
||||||
|
.sort(([left], [right]) => left - right)
|
||||||
|
.map(([, button]) => button)
|
||||||
|
.filter((button) => button.offsetParent !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
|
||||||
|
if (!buttons.length) return
|
||||||
|
|
||||||
|
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
|
||||||
|
buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
|
||||||
|
const area = triangleArea(a, b, c)
|
||||||
|
const area1 = triangleArea(point, b, c)
|
||||||
|
const area2 = triangleArea(a, point, c)
|
||||||
|
const area3 = triangleArea(a, b, point)
|
||||||
|
|
||||||
|
return Math.abs(area - (area1 + area2 + area3)) < 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
function triangleArea(a: Point, b: Point, c: Point) {
|
||||||
|
return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { useTemplateRef } from 'vue'
|
import { useTemplateRef } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
+51
-2
@@ -1,4 +1,4 @@
|
|||||||
import { defineMessages } from '@modrinth/ui'
|
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
|
||||||
|
|
||||||
import backpack from '@/assets/instance-icons/backpack.png'
|
import backpack from '@/assets/instance-icons/backpack.png'
|
||||||
import beacon from '@/assets/instance-icons/beacon.png'
|
import beacon from '@/assets/instance-icons/beacon.png'
|
||||||
@@ -18,6 +18,8 @@ import enchantingTable from '@/assets/instance-icons/enchanting-table.png'
|
|||||||
import enderChest from '@/assets/instance-icons/ender-chest.png'
|
import enderChest from '@/assets/instance-icons/ender-chest.png'
|
||||||
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
|
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
|
||||||
import engine from '@/assets/instance-icons/engine.png'
|
import engine from '@/assets/instance-icons/engine.png'
|
||||||
|
import fabric from '@/assets/instance-icons/fabric.png'
|
||||||
|
import forge from '@/assets/instance-icons/forge.png'
|
||||||
import furnace from '@/assets/instance-icons/furnace.png'
|
import furnace from '@/assets/instance-icons/furnace.png'
|
||||||
import gizmo from '@/assets/instance-icons/gizmo.png'
|
import gizmo from '@/assets/instance-icons/gizmo.png'
|
||||||
import globe from '@/assets/instance-icons/globe.png'
|
import globe from '@/assets/instance-icons/globe.png'
|
||||||
@@ -25,11 +27,13 @@ import grassBlock from '@/assets/instance-icons/grass-block.png'
|
|||||||
import lantern from '@/assets/instance-icons/lantern.png'
|
import lantern from '@/assets/instance-icons/lantern.png'
|
||||||
import moobloom from '@/assets/instance-icons/moobloom.png'
|
import moobloom from '@/assets/instance-icons/moobloom.png'
|
||||||
import mrPack from '@/assets/instance-icons/mr-pack.png'
|
import mrPack from '@/assets/instance-icons/mr-pack.png'
|
||||||
|
import neoForge from '@/assets/instance-icons/neoforge.png'
|
||||||
import orb from '@/assets/instance-icons/orb.png'
|
import orb from '@/assets/instance-icons/orb.png'
|
||||||
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
|
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
|
||||||
import pancakes from '@/assets/instance-icons/pancakes.png'
|
import pancakes from '@/assets/instance-icons/pancakes.png'
|
||||||
import pickaxe from '@/assets/instance-icons/pickaxe.png'
|
import pickaxe from '@/assets/instance-icons/pickaxe.png'
|
||||||
import pokeBall from '@/assets/instance-icons/poke-ball.png'
|
import pokeBall from '@/assets/instance-icons/poke-ball.png'
|
||||||
|
import quilt from '@/assets/instance-icons/quilt.png'
|
||||||
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
|
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
|
||||||
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
|
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
|
||||||
import skeleton from '@/assets/instance-icons/skeleton.png'
|
import skeleton from '@/assets/instance-icons/skeleton.png'
|
||||||
@@ -197,8 +201,20 @@ const names = defineMessages({
|
|||||||
defaultMessage: 'Modrinth Wrench',
|
defaultMessage: 'Modrinth Wrench',
|
||||||
},
|
},
|
||||||
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
|
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
|
||||||
|
fabric: { id: 'instance.icon-editor.symbol.fabric', defaultMessage: 'Fabric' },
|
||||||
|
forge: { id: 'instance.icon-editor.symbol.forge', defaultMessage: 'Forge' },
|
||||||
|
neoForge: { id: 'instance.icon-editor.symbol.neoforge', defaultMessage: 'NeoForge' },
|
||||||
|
quilt: { id: 'instance.icon-editor.symbol.quilt', defaultMessage: 'Quilt' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export interface SymbolOption {
|
||||||
|
id: string
|
||||||
|
name: MessageDescriptor
|
||||||
|
asset: string
|
||||||
|
category: 'loader' | 'modded' | 'vanilla'
|
||||||
|
excludeFromRandomization?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const backgroundOptions = [
|
export const backgroundOptions = [
|
||||||
{
|
{
|
||||||
id: 'rose',
|
id: 'rose',
|
||||||
@@ -339,6 +355,39 @@ export const backgroundOptions = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const symbolOptions = [
|
export const symbolOptions = [
|
||||||
|
/////////////////////////
|
||||||
|
// loaders
|
||||||
|
/////////////////////////
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'fabric',
|
||||||
|
name: names.fabric,
|
||||||
|
asset: fabric,
|
||||||
|
category: 'loader',
|
||||||
|
excludeFromRandomization: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forge',
|
||||||
|
name: names.forge,
|
||||||
|
asset: forge,
|
||||||
|
category: 'loader',
|
||||||
|
excludeFromRandomization: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'neoforge',
|
||||||
|
name: names.neoForge,
|
||||||
|
asset: neoForge,
|
||||||
|
category: 'loader',
|
||||||
|
excludeFromRandomization: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quilt',
|
||||||
|
name: names.quilt,
|
||||||
|
asset: quilt,
|
||||||
|
category: 'loader',
|
||||||
|
excludeFromRandomization: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Cobblemon: Poké Ball
|
// Cobblemon: Poké Ball
|
||||||
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
|
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
|
||||||
|
|
||||||
@@ -438,7 +487,7 @@ export const symbolOptions = [
|
|||||||
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
|
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
|
||||||
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
|
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
|
||||||
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
|
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
|
||||||
] as const
|
] as const satisfies readonly SymbolOption[]
|
||||||
|
|
||||||
export type BackgroundId = (typeof backgroundOptions)[number]['id']
|
export type BackgroundId = (typeof backgroundOptions)[number]['id']
|
||||||
export type SymbolId = (typeof symbolOptions)[number]['id']
|
export type SymbolId = (typeof symbolOptions)[number]['id']
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
DEFAULT_SYMBOL_ID,
|
DEFAULT_SYMBOL_ID,
|
||||||
RANDOM_CONFIG_BLACKLIST,
|
RANDOM_CONFIG_BLACKLIST,
|
||||||
type SymbolId,
|
type SymbolId,
|
||||||
|
type SymbolOption,
|
||||||
symbolOptions,
|
symbolOptions,
|
||||||
} from './editor-catalog'
|
} from './editor-catalog'
|
||||||
|
|
||||||
@@ -201,7 +203,8 @@ function surpriseMe() {
|
|||||||
const configurations = backgroundOptions.flatMap((background) =>
|
const configurations = backgroundOptions.flatMap((background) =>
|
||||||
symbolOptions
|
symbolOptions
|
||||||
.filter(
|
.filter(
|
||||||
(symbol) =>
|
(symbol: SymbolOption) =>
|
||||||
|
!symbol.excludeFromRandomization &&
|
||||||
background.id !== selectedBackground.value &&
|
background.id !== selectedBackground.value &&
|
||||||
symbol.id !== selectedSymbol.value &&
|
symbol.id !== selectedSymbol.value &&
|
||||||
!RANDOM_CONFIG_BLACKLIST.some(
|
!RANDOM_CONFIG_BLACKLIST.some(
|
||||||
@@ -342,37 +345,37 @@ const messages = defineMessages({
|
|||||||
class="flex w-[244px] shrink-0 flex-col gap-4 overflow-y-auto border-0 border-r border-solid border-surface-5 p-6"
|
class="flex w-[244px] shrink-0 flex-col gap-4 overflow-y-auto border-0 border-r border-solid border-surface-5 p-6"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex w-full flex-col items-center gap-3 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-4"
|
class="flex w-full flex-col items-center gap-3.5 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-5"
|
||||||
>
|
>
|
||||||
<div
|
<Avatar
|
||||||
class="icon-outline relative size-[132px] overflow-hidden rounded-[20px]"
|
:src="selectedSymbolOption.asset"
|
||||||
|
size="132px"
|
||||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||||
>
|
no-shadow
|
||||||
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
|
/>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<div
|
<Avatar
|
||||||
class="icon-outline relative size-12 overflow-hidden rounded-2xl"
|
:src="selectedSymbolOption.asset"
|
||||||
|
size="40px"
|
||||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||||
>
|
no-shadow
|
||||||
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
|
/>
|
||||||
</div>
|
<Avatar
|
||||||
<div
|
:src="selectedSymbolOption.asset"
|
||||||
class="icon-outline relative size-8 overflow-hidden rounded-[10px]"
|
size="30px"
|
||||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||||
>
|
no-shadow
|
||||||
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
|
/>
|
||||||
</div>
|
<Avatar
|
||||||
<div
|
:src="selectedSymbolOption.asset"
|
||||||
class="icon-outline relative size-4 overflow-hidden rounded-[5px]"
|
size="20px"
|
||||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||||
>
|
no-shadow
|
||||||
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button class="w-full !shadow-none" @click="surpriseMe">
|
<Button class="w-full" @click="surpriseMe">
|
||||||
<RefreshCwIcon />
|
<RefreshCwIcon />
|
||||||
{{ formatMessage(messages.surpriseMe) }}
|
{{ formatMessage(messages.surpriseMe) }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
<span class="text-2xl font-semibold text-contrast">
|
<span class="text-2xl font-semibold text-contrast">
|
||||||
{{
|
{{
|
||||||
formatMessage(messages.title, {
|
formatMessage(messages.title, {
|
||||||
groupName: groupInstancesModalGroup?.name ?? '',
|
groupName:
|
||||||
|
groupInstancesModalGroup?.id === 'group:none'
|
||||||
|
? formatMessage(messages.ungrouped)
|
||||||
|
: (groupInstancesModalGroup?.name ?? ''),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
@@ -53,6 +56,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
|
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
|
||||||
|
:disabled="
|
||||||
|
groupInstancesModalGroup?.id === 'group:none' &&
|
||||||
|
selectedGroupInstanceIds.has(instance.id)
|
||||||
|
"
|
||||||
@click="toggleGroupInstance(instance.id)"
|
@click="toggleGroupInstance(instance.id)"
|
||||||
>
|
>
|
||||||
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
|
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
|
||||||
@@ -100,6 +107,10 @@ import { getInstanceIconUrl } from '@/helpers/instance'
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
ungrouped: {
|
||||||
|
id: 'app.library.group.ungrouped',
|
||||||
|
defaultMessage: 'Ungrouped',
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
id: 'app.library.group.instances-modal.title',
|
id: 'app.library.group.instances-modal.title',
|
||||||
defaultMessage: 'Add instances to "{groupName}"',
|
defaultMessage: 'Add instances to "{groupName}"',
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
|
EditIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
|
PaletteIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
UploadIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
|
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
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 GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
|
||||||
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
|
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
|
||||||
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
|
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
|
||||||
@@ -55,6 +59,30 @@ const messages = defineMessages({
|
|||||||
id: 'app.library.instance.action.view-instance',
|
id: 'app.library.instance.action.view-instance',
|
||||||
defaultMessage: '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: {
|
duplicateInstance: {
|
||||||
id: 'app.library.instance.action.duplicate',
|
id: 'app.library.instance.action.duplicate',
|
||||||
defaultMessage: 'Duplicate instance',
|
defaultMessage: 'Duplicate instance',
|
||||||
@@ -78,10 +106,13 @@ const {
|
|||||||
reorderGroups,
|
reorderGroups,
|
||||||
instanceOptions,
|
instanceOptions,
|
||||||
confirmDeleteModal,
|
confirmDeleteModal,
|
||||||
|
iconEditorModal,
|
||||||
|
currentIconEditorInstance,
|
||||||
currentDeleteInstances,
|
currentDeleteInstances,
|
||||||
clearLibraryInstanceSelection,
|
clearLibraryInstanceSelection,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
handleInstanceOption,
|
handleInstanceOption,
|
||||||
|
handleInstanceIconSaved,
|
||||||
selectedLibraryInstances,
|
selectedLibraryInstances,
|
||||||
setSelectedLibraryInstances,
|
setSelectedLibraryInstances,
|
||||||
toggleLibraryInstanceSelection,
|
toggleLibraryInstanceSelection,
|
||||||
@@ -100,40 +131,35 @@ const visibleInstanceGroups = computed(() =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleCustomGroups = computed(() =>
|
const visibleReorderableGroups = computed(() =>
|
||||||
displayState.value.group === 'Group'
|
displayState.value.group === 'Group'
|
||||||
? visibleInstanceGroups.value.filter(
|
? visibleInstanceGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID)
|
||||||
(group) => group.id !== FAVORITES_GROUP_ID && group.id !== 'group:none',
|
|
||||||
)
|
|
||||||
: [],
|
: [],
|
||||||
)
|
)
|
||||||
const visibleFavoritesGroup = computed(() =>
|
const visibleFavoritesGroup = computed(() =>
|
||||||
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
|
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
|
||||||
)
|
)
|
||||||
const visibleUngroupedGroup = computed(() =>
|
const draggableGroups = ref<InstanceGroupType[]>([])
|
||||||
visibleInstanceGroups.value.find((group) => group.id === 'group:none'),
|
|
||||||
)
|
|
||||||
const draggableCustomGroups = ref<InstanceGroupType[]>([])
|
|
||||||
const libraryGroupsContainer = ref<HTMLElement>()
|
const libraryGroupsContainer = ref<HTMLElement>()
|
||||||
const isDraggingGroup = ref(false)
|
const isDraggingGroup = ref(false)
|
||||||
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
|
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
|
||||||
const canDragReorderGroups = computed(
|
const canDragReorderGroups = computed(
|
||||||
() => !reorderingGroups.value && draggableCustomGroups.value.length > 1,
|
() => !reorderingGroups.value && draggableGroups.value.length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
visibleCustomGroups,
|
visibleReorderableGroups,
|
||||||
(groups) => {
|
(groups) => {
|
||||||
if (!isDraggingGroup.value) {
|
if (!isDraggingGroup.value) {
|
||||||
const previousGroupTops = getCustomGroupTops()
|
const previousGroupTops = getReorderableGroupTops()
|
||||||
draggableCustomGroups.value = [...groups]
|
draggableGroups.value = [...groups]
|
||||||
void nextTick(() => animateCustomGroupReorder(previousGroupTops))
|
void nextTick(() => animateGroupReorder(previousGroupTops))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
function getCustomGroupTops() {
|
function getReorderableGroupTops() {
|
||||||
const groupTops = new Map<string, number>()
|
const groupTops = new Map<string, number>()
|
||||||
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
|
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
|
||||||
'[data-instance-group-reorder-id]',
|
'[data-instance-group-reorder-id]',
|
||||||
@@ -149,7 +175,7 @@ function getCustomGroupTops() {
|
|||||||
return groupTops
|
return groupTops
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateCustomGroupReorder(previousGroupTops: Map<string, number>) {
|
function animateGroupReorder(previousGroupTops: Map<string, number>) {
|
||||||
if (
|
if (
|
||||||
previousGroupTops.size === 0 ||
|
previousGroupTops.size === 0 ||
|
||||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
@@ -185,10 +211,10 @@ function onGroupDragEnd() {
|
|||||||
isDraggingGroup.value = false
|
isDraggingGroup.value = false
|
||||||
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
|
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
|
||||||
|
|
||||||
const currentGroupIds = visibleCustomGroups.value.map((group) => group.id)
|
const currentGroupIds = visibleReorderableGroups.value.map((group) => group.id)
|
||||||
const orderedGroupIds = draggableCustomGroups.value.map((group) => group.id)
|
const orderedGroupIds = draggableGroups.value.map((group) => group.id)
|
||||||
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
|
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
|
||||||
draggableCustomGroups.value = [...visibleCustomGroups.value]
|
draggableGroups.value = [...visibleReorderableGroups.value]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +285,10 @@ function setConfirmDeleteModal(component: unknown) {
|
|||||||
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
|
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setIconEditorModal(component: unknown) {
|
||||||
|
iconEditorModal.value = component as InstanceType<typeof IconEditorModal> | null
|
||||||
|
}
|
||||||
|
|
||||||
watch(selectedLibraryInstances, (selectedInstances) => {
|
watch(selectedLibraryInstances, (selectedInstances) => {
|
||||||
if (selectedInstances.size === 0) {
|
if (selectedInstances.size === 0) {
|
||||||
anchorInstance.value = null
|
anchorInstance.value = null
|
||||||
@@ -313,7 +343,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Draggable
|
<Draggable
|
||||||
:list="draggableCustomGroups"
|
:list="draggableGroups"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
:disabled="!canDragReorderGroups"
|
:disabled="!canDragReorderGroups"
|
||||||
@@ -341,6 +371,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
|||||||
>
|
>
|
||||||
<InstanceGroup
|
<InstanceGroup
|
||||||
:can-drag-reorder="canDragReorderGroups"
|
:can-drag-reorder="canDragReorderGroups"
|
||||||
|
:hide-header="visibleInstanceGroups.length === 1"
|
||||||
:instance-group="instanceGroup"
|
:instance-group="instanceGroup"
|
||||||
:selection-anchor-instance-id="
|
:selection-anchor-instance-id="
|
||||||
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
|
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
|
||||||
@@ -353,20 +384,6 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
|
|
||||||
<div v-if="visibleUngroupedGroup" class="min-w-0">
|
|
||||||
<InstanceGroup
|
|
||||||
:hide-header="visibleInstanceGroups.length === 1"
|
|
||||||
:instance-group="visibleUngroupedGroup"
|
|
||||||
:selection-anchor-instance-id="
|
|
||||||
anchorInstance?.groupId === 'group:none' ? anchorInstance.instanceId : null
|
|
||||||
"
|
|
||||||
@toggle-selection="
|
|
||||||
(instanceId: string, shiftKey: boolean) =>
|
|
||||||
handleToggleInstance('group:none', instanceId, shiftKey)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
@@ -407,6 +424,12 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
|||||||
:instances="currentDeleteInstances"
|
:instances="currentDeleteInstances"
|
||||||
@delete="deleteInstance"
|
@delete="deleteInstance"
|
||||||
/>
|
/>
|
||||||
|
<IconEditorModal
|
||||||
|
:ref="setIconEditorModal"
|
||||||
|
:instance-id="currentIconEditorInstance?.id"
|
||||||
|
:config="currentIconEditorInstance?.icon_config"
|
||||||
|
@saved="handleInstanceIconSaved"
|
||||||
|
/>
|
||||||
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
|
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
|
||||||
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
|
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
|
||||||
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
|
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
|
||||||
@@ -419,6 +442,14 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
|||||||
</template>
|
</template>
|
||||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </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>
|
<template #duplicate>
|
||||||
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+21
-10
@@ -2,16 +2,25 @@
|
|||||||
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
|
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
|
||||||
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
|
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(
|
||||||
deleting?: boolean
|
defineProps<{
|
||||||
canMoveDown: boolean
|
deleting?: boolean
|
||||||
canMoveUp: boolean
|
canMoveDown: boolean
|
||||||
onAddToGroup: () => void
|
canMoveUp: boolean
|
||||||
onDeleteGroup: () => void
|
onAddToGroup: () => void
|
||||||
onEditGroupName: () => void
|
onDeleteGroup: () => void
|
||||||
onMoveDown: () => void
|
onEditGroupName: () => void
|
||||||
onMoveUp: () => void
|
onMoveDown: () => void
|
||||||
}>()
|
onMoveUp: () => void
|
||||||
|
showDelete?: boolean
|
||||||
|
showEdit?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
deleting: false,
|
||||||
|
showDelete: true,
|
||||||
|
showEdit: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
@@ -64,6 +73,7 @@ const messages = defineMessages({
|
|||||||
<ArrowDownIcon />
|
<ArrowDownIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
v-if="showEdit"
|
||||||
v-tooltip="formatMessage(messages.editGroupName)"
|
v-tooltip="formatMessage(messages.editGroupName)"
|
||||||
:label="formatMessage(messages.editGroupName)"
|
:label="formatMessage(messages.editGroupName)"
|
||||||
type="quiet"
|
type="quiet"
|
||||||
@@ -82,6 +92,7 @@ const messages = defineMessages({
|
|||||||
<SquarePlusIcon />
|
<SquarePlusIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
v-if="showDelete"
|
||||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||||
:label="formatMessage(messages.deleteGroup)"
|
:label="formatMessage(messages.deleteGroup)"
|
||||||
type="quiet"
|
type="quiet"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
|
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||||
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.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 InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
|
||||||
import type {
|
import type {
|
||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
InstanceGroup as InstanceGroupType,
|
InstanceGroup as InstanceGroupType,
|
||||||
} from '@/components/ui/library/use-library'
|
} from '@/components/ui/library/use-library'
|
||||||
import { useLibrary } from '@/components/ui/library/use-library'
|
import { useLibrary } from '@/components/ui/library/use-library'
|
||||||
|
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||||
import { FAVORITES_GROUP_ID, MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
|
import { FAVORITES_GROUP_ID, MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
|
||||||
|
|
||||||
const INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY = 500
|
const INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY = 500
|
||||||
@@ -48,6 +49,8 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
const appSettings = useAppSettings()
|
||||||
|
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const {
|
const {
|
||||||
isSectionCollapsed,
|
isSectionCollapsed,
|
||||||
@@ -84,6 +87,9 @@ const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID
|
|||||||
const isCustomGroup = computed(
|
const isCustomGroup = computed(
|
||||||
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
|
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
|
||||||
)
|
)
|
||||||
|
const isReorderableGroup = computed(
|
||||||
|
() => displayState.value.group === 'Group' && !isFavorites.value,
|
||||||
|
)
|
||||||
const groupContextMenuOpen = ref(false)
|
const groupContextMenuOpen = ref(false)
|
||||||
const isGroupToggleBlocked = computed(
|
const isGroupToggleBlocked = computed(
|
||||||
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
|
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
|
||||||
@@ -383,7 +389,7 @@ onMounted(startInstanceGridResizeObserver)
|
|||||||
v-if="!hideHeader"
|
v-if="!hideHeader"
|
||||||
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||||
:class="{
|
:class="{
|
||||||
'instance-group-reorder-handle': isCustomGroup && canDragReorder,
|
'instance-group-reorder-handle': isReorderableGroup && canDragReorder,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -442,7 +448,7 @@ onMounted(startInstanceGridResizeObserver)
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1" />
|
<div class="min-w-0 flex-1" />
|
||||||
<GroupActionButtons
|
<GroupActionButtons
|
||||||
v-if="isCustomGroup"
|
v-if="isCustomGroup || isUngrouped"
|
||||||
:can-move-down="canMoveGroupDown(instanceGroup.id)"
|
:can-move-down="canMoveGroupDown(instanceGroup.id)"
|
||||||
:can-move-up="canMoveGroupUp(instanceGroup.id)"
|
:can-move-up="canMoveGroupUp(instanceGroup.id)"
|
||||||
:deleting="deletingGroup"
|
:deleting="deletingGroup"
|
||||||
@@ -451,6 +457,8 @@ onMounted(startInstanceGridResizeObserver)
|
|||||||
:on-edit-group-name="() => groupNameInput?.startEditing()"
|
:on-edit-group-name="() => groupNameInput?.startEditing()"
|
||||||
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
|
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
|
||||||
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
|
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
|
||||||
|
:show-delete="!isUngrouped"
|
||||||
|
:show-edit="!isUngrouped"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Accordion
|
<Accordion
|
||||||
@@ -470,7 +478,12 @@ onMounted(startInstanceGridResizeObserver)
|
|||||||
<div ref="instanceGridContent">
|
<div ref="instanceGridContent">
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
tag="section"
|
tag="section"
|
||||||
class="grid min-h-[45px] w-full grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))] gap-3 overflow-y-auto scroll-smooth"
|
class="grid min-h-[45px] w-full gap-3 overflow-y-auto scroll-smooth"
|
||||||
|
:class="
|
||||||
|
compactMode
|
||||||
|
? 'grid-cols-[repeat(auto-fill,minmax(min(15rem,100%),1fr))]'
|
||||||
|
: 'grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))]'
|
||||||
|
"
|
||||||
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||||
enter-active-class="transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none"
|
enter-active-class="transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none"
|
||||||
enter-from-class="opacity-0"
|
enter-from-class="opacity-0"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Avatar, truncatedTooltip } from '@modrinth/ui'
|
import { Avatar, truncatedTooltip } from '@modrinth/ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||||
import { getInstanceIconUrl } from '@/helpers/instance'
|
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const iconSrc = computed(() => getInstanceIconUrl(props.instance.icon_path))
|
const iconSrc = computed(() => getInstanceIconUrl(props.instance.icon_path))
|
||||||
|
const appSettings = useAppSettings()
|
||||||
|
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
|
||||||
|
|
||||||
const nameRef = ref<HTMLElement | null>(null)
|
const nameRef = ref<HTMLElement | null>(null)
|
||||||
const versionRef = ref<HTMLElement | null>(null)
|
const versionRef = ref<HTMLElement | null>(null)
|
||||||
@@ -23,30 +26,40 @@ const versionRef = ref<HTMLElement | null>(null)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative flex w-full min-w-0 select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid bg-surface-3 p-3 text-left transition-all"
|
class="relative flex w-full min-w-0 select-none overflow-clip border border-solid bg-surface-3 text-left transition-all"
|
||||||
:class="{
|
:class="{
|
||||||
|
'flex-row items-center justify-start gap-2.5 rounded-xl p-2.5': compactMode,
|
||||||
|
'flex-col items-start justify-end gap-3 rounded-[20px] p-3': !compactMode,
|
||||||
'[border-color:color-mix(in_srgb,var(--color-text-primary)_40%,transparent)] brightness-110':
|
'[border-color:color-mix(in_srgb,var(--color-text-primary)_40%,transparent)] brightness-110':
|
||||||
selected,
|
selected,
|
||||||
'border-surface-4': !selected,
|
'border-surface-4': !selected,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative flex aspect-square min-w-full shrink-0 items-center overflow-clip rounded-2xl"
|
class="relative flex shrink-0 items-center overflow-clip"
|
||||||
|
:class="compactMode ? 'size-10 rounded-lg' : 'aspect-square min-w-full rounded-2xl'"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
class="pointer-events-none !rounded-2xl outline-none"
|
class="pointer-events-none outline-none"
|
||||||
|
:class="compactMode ? '!rounded-lg' : '!rounded-2xl'"
|
||||||
size="100%"
|
size="100%"
|
||||||
:src="iconSrc"
|
:src="iconSrc"
|
||||||
:tint-by="instance.id"
|
:tint-by="instance.id"
|
||||||
alt=""
|
alt=""
|
||||||
no-shadow
|
no-shadow
|
||||||
/>
|
/>
|
||||||
<slot name="loading" />
|
<slot name="loading" :compact="compactMode" />
|
||||||
<div class="absolute bottom-1.5 right-1.5 z-[1] flex size-12 items-center justify-center">
|
<div
|
||||||
<slot name="leading" />
|
class="absolute z-[1] flex items-center justify-center"
|
||||||
|
:class="compactMode ? 'inset-0' : 'bottom-1.5 right-1.5 size-12'"
|
||||||
|
>
|
||||||
|
<slot name="leading" :compact="compactMode" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 w-full flex-col items-start justify-center gap-1 px-0.5">
|
<div
|
||||||
|
class="flex min-w-0 w-full flex-col items-start justify-center gap-1 px-0.5"
|
||||||
|
:class="{ 'pr-10': compactMode }"
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
ref="nameRef"
|
ref="nameRef"
|
||||||
v-tooltip="truncatedTooltip(nameRef, instance.name)"
|
v-tooltip="truncatedTooltip(nameRef, instance.name)"
|
||||||
@@ -62,6 +75,6 @@ const versionRef = ref<HTMLElement | null>(null)
|
|||||||
{{ instance.loader }} {{ instance.game_version }}
|
{{ instance.loader }} {{ instance.game_version }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<slot name="overlay" />
|
<slot name="overlay" :compact="compactMode" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ onMounted(() => {
|
|||||||
@mouseenter="checkProcess"
|
@mouseenter="checkProcess"
|
||||||
@pointerdown="handlePointerDown"
|
@pointerdown="handlePointerDown"
|
||||||
>
|
>
|
||||||
<template #loading>
|
<template #loading="{ compact }">
|
||||||
<div
|
<div
|
||||||
v-if="loadingIndicatorVisible"
|
v-if="loadingIndicatorVisible"
|
||||||
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
|
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
|
||||||
@@ -293,13 +293,17 @@ onMounted(() => {
|
|||||||
<div class="absolute inset-0 bg-surface-1 opacity-30" />
|
<div class="absolute inset-0 bg-surface-1 opacity-30" />
|
||||||
<SpinnerIcon
|
<SpinnerIcon
|
||||||
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
|
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
|
||||||
class="relative size-[30%] animate-spin text-contrast"
|
class="relative animate-spin text-contrast"
|
||||||
|
:class="compact ? 'size-5' : 'size-[30%]'"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #leading>
|
<template #leading="{ compact }">
|
||||||
<div class="relative flex size-12 shrink-0 items-center justify-center">
|
<div
|
||||||
|
class="relative flex shrink-0 items-center justify-center"
|
||||||
|
:class="compact ? 'size-10' : 'size-12'"
|
||||||
|
>
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<IconButton
|
<IconButton
|
||||||
v-if="playing"
|
v-if="playing"
|
||||||
@@ -307,7 +311,7 @@ onMounted(() => {
|
|||||||
:label="formatMessage(messages.stop)"
|
:label="formatMessage(messages.stop)"
|
||||||
type="colored"
|
type="colored"
|
||||||
color="red"
|
color="red"
|
||||||
size="lg"
|
:size="compact ? 'md' : 'lg'"
|
||||||
@click="(e) => stop(e, 'InstanceCard')"
|
@click="(e) => stop(e, 'InstanceCard')"
|
||||||
@mouseenter="checkProcess"
|
@mouseenter="checkProcess"
|
||||||
>
|
>
|
||||||
@@ -325,7 +329,7 @@ onMounted(() => {
|
|||||||
:label="formatMessage(messages.repair)"
|
:label="formatMessage(messages.repair)"
|
||||||
type="colored"
|
type="colored"
|
||||||
color="brand"
|
color="brand"
|
||||||
size="lg"
|
:size="compact ? 'md' : 'lg'"
|
||||||
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||||
@click="(e) => repair(e)"
|
@click="(e) => repair(e)"
|
||||||
>
|
>
|
||||||
@@ -342,7 +346,7 @@ onMounted(() => {
|
|||||||
:label="formatMessage(messages.play)"
|
:label="formatMessage(messages.play)"
|
||||||
type="colored"
|
type="colored"
|
||||||
color="brand"
|
color="brand"
|
||||||
size="lg"
|
:size="compact ? 'md' : 'lg'"
|
||||||
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||||
@click="(e) => play(e, 'InstanceCard')"
|
@click="(e) => play(e, 'InstanceCard')"
|
||||||
@mouseenter="checkProcess"
|
@mouseenter="checkProcess"
|
||||||
@@ -352,18 +356,21 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #overlay>
|
<template #overlay="{ compact }">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selection-button group/selection absolute right-2 top-1.5 z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
|
class="selection-button group/selection absolute z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
|
||||||
|
:class="compact ? '-right-1 -top-1' : 'right-2 top-1.5'"
|
||||||
:aria-label="formatMessage(selected ? messages.deselect : messages.select)"
|
:aria-label="formatMessage(selected ? messages.deselect : messages.select)"
|
||||||
:aria-pressed="selected"
|
:aria-pressed="selected"
|
||||||
@click.stop="toggleSelection"
|
@click.stop="toggleSelection"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-tooltip="formatMessage(selected ? messages.deselect : messages.select)"
|
v-tooltip="formatMessage(selected ? messages.deselect : messages.select)"
|
||||||
class="relative flex size-[24px] items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
|
class="relative flex items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
|
||||||
:class="{
|
:class="{
|
||||||
|
'size-[20px]': compact,
|
||||||
|
'size-[24px]': !compact,
|
||||||
'border-0 !opacity-100': selected,
|
'border-0 !opacity-100': selected,
|
||||||
'border-2 border-solid border-primary bg-transparent': !selected,
|
'border-2 border-solid border-primary bg-transparent': !selected,
|
||||||
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
|
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||||||
import { useEventListener, useStorage } from '@vueuse/core'
|
import { useEventListener, useStorage } from '@vueuse/core'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
type InjectionKey,
|
type InjectionKey,
|
||||||
|
nextTick,
|
||||||
provide,
|
provide,
|
||||||
type Ref,
|
type Ref,
|
||||||
ref,
|
ref,
|
||||||
@@ -13,10 +15,11 @@ import {
|
|||||||
watchEffect,
|
watchEffect,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
|
|
||||||
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { get_project_v3_many } from '@/helpers/cache.js'
|
import { get_project_v3_many } from '@/helpers/cache.js'
|
||||||
import { toError } from '@/helpers/errors'
|
import { toError } from '@/helpers/errors'
|
||||||
import { install_duplicate_instance } from '@/helpers/install'
|
import { install_duplicate_instance } from '@/helpers/install'
|
||||||
import { edit, remove } from '@/helpers/instance'
|
import { edit, edit_icon, remove } from '@/helpers/instance'
|
||||||
import {
|
import {
|
||||||
create_group as createInstanceGroup,
|
create_group as createInstanceGroup,
|
||||||
delete_group as deleteInstanceGroup,
|
delete_group as deleteInstanceGroup,
|
||||||
@@ -28,7 +31,7 @@ import {
|
|||||||
set_group_memberships as setInstanceGroupMemberships,
|
set_group_memberships as setInstanceGroupMemberships,
|
||||||
set_group_order as setInstanceGroupOrder,
|
set_group_order as setInstanceGroupOrder,
|
||||||
} from '@/helpers/instance-groups'
|
} from '@/helpers/instance-groups'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance, InstanceIconConfig } from '@/helpers/types'
|
||||||
|
|
||||||
export const librarySortOptions = [
|
export const librarySortOptions = [
|
||||||
'Name',
|
'Name',
|
||||||
@@ -102,6 +105,10 @@ type ConfirmDeleteModal = {
|
|||||||
show: () => void
|
show: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IconEditorModal = {
|
||||||
|
show: () => void
|
||||||
|
}
|
||||||
|
|
||||||
type ContextMenuSelection = {
|
type ContextMenuSelection = {
|
||||||
option: string
|
option: string
|
||||||
item: InstanceCard
|
item: InstanceCard
|
||||||
@@ -155,17 +162,25 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
)
|
)
|
||||||
const currentContextGroupId = ref<string | null>(null)
|
const currentContextGroupId = ref<string | null>(null)
|
||||||
const confirmDeleteModal = ref<ConfirmDeleteModal | null>(null)
|
const confirmDeleteModal = ref<ConfirmDeleteModal | null>(null)
|
||||||
|
const iconEditorModal = ref<IconEditorModal | null>(null)
|
||||||
|
const currentIconEditorInstanceId = ref<string | null>(null)
|
||||||
|
const currentIconEditorInstance = computed(
|
||||||
|
() =>
|
||||||
|
instances.value.find((instance) => instance.id === currentIconEditorInstanceId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
const displayState = useStorage<{
|
const displayState = useStorage<{
|
||||||
group: LibraryGroupBy
|
group: LibraryGroupBy
|
||||||
sortBy: LibrarySort
|
sortBy: LibrarySort
|
||||||
collapsedGroups: string[]
|
collapsedGroups: string[]
|
||||||
|
ungroupedGroupPosition: number
|
||||||
}>(
|
}>(
|
||||||
'Instances-grid-display-state',
|
'Instances-grid-display-state',
|
||||||
{
|
{
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
sortBy: 'Last played',
|
sortBy: 'Last played',
|
||||||
collapsedGroups: [],
|
collapsedGroups: [],
|
||||||
|
ungroupedGroupPosition: Number.MAX_SAFE_INTEGER,
|
||||||
},
|
},
|
||||||
localStorage,
|
localStorage,
|
||||||
{ mergeDefaults: true },
|
{ mergeDefaults: true },
|
||||||
@@ -211,10 +226,15 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const groupInstancesModalGroup = computed(
|
const groupInstancesModalGroup = computed(() => {
|
||||||
() =>
|
if (groupInstancesModalGroupId.value === 'group:none') {
|
||||||
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null,
|
return { id: 'group:none', name: 'None' }
|
||||||
)
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null
|
||||||
|
)
|
||||||
|
})
|
||||||
const groupInstances = computed(() => {
|
const groupInstances = computed(() => {
|
||||||
const query = groupInstancesSearch.value.trim().toLowerCase()
|
const query = groupInstancesSearch.value.trim().toLowerCase()
|
||||||
|
|
||||||
@@ -229,8 +249,23 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
const customLibraryGroups = computed(() =>
|
const customLibraryGroups = computed(() =>
|
||||||
libraryGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID),
|
libraryGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID),
|
||||||
)
|
)
|
||||||
const customGroupOrder = computed(
|
const orderedLibraryGroupIds = computed(() => {
|
||||||
() => new Map(customLibraryGroups.value.map((group, index) => [group.id, index])),
|
const groupIds = customLibraryGroups.value.map((group) => group.id)
|
||||||
|
const storedUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
|
||||||
|
const ungroupedGroupPosition = Math.min(
|
||||||
|
Math.max(
|
||||||
|
Number.isFinite(storedUngroupedGroupPosition)
|
||||||
|
? Math.trunc(storedUngroupedGroupPosition)
|
||||||
|
: Number.MAX_SAFE_INTEGER,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
groupIds.length,
|
||||||
|
)
|
||||||
|
groupIds.splice(ungroupedGroupPosition, 0, 'group:none')
|
||||||
|
return groupIds
|
||||||
|
})
|
||||||
|
const libraryGroupOrder = computed(
|
||||||
|
() => new Map(orderedLibraryGroupIds.value.map((groupId, index) => [groupId, index])),
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshGroups = async () => {
|
const refreshGroups = async () => {
|
||||||
@@ -450,11 +485,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
if (a.id === b.id) return 0
|
if (a.id === b.id) return 0
|
||||||
if (a.id === FAVORITES_GROUP_ID) return -1
|
if (a.id === FAVORITES_GROUP_ID) return -1
|
||||||
if (b.id === FAVORITES_GROUP_ID) return 1
|
if (b.id === FAVORITES_GROUP_ID) return 1
|
||||||
if (a.id === 'group:none') return 1
|
|
||||||
if (b.id === 'group:none') return -1
|
|
||||||
|
|
||||||
const aOrder = customGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
const aOrder = libraryGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||||
const bOrder = customGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
const bOrder = libraryGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||||
return aOrder - bOrder || a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
|
return aOrder - bOrder || a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -715,13 +748,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
|
|
||||||
const openGroupInstancesModal = (groupId: string) => {
|
const openGroupInstancesModal = (groupId: string) => {
|
||||||
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
|
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
|
||||||
if (!group) return
|
if (!group && groupId !== 'group:none') return
|
||||||
|
|
||||||
groupInstancesModalGroupId.value = groupId
|
groupInstancesModalGroupId.value = groupId
|
||||||
groupInstancesSearch.value = ''
|
groupInstancesSearch.value = ''
|
||||||
selectedGroupInstanceIds.value = new Set(
|
selectedGroupInstanceIds.value = new Set(
|
||||||
instances.value
|
instances.value
|
||||||
.filter((instance) => instance.group_ids.includes(groupId))
|
.filter((instance) =>
|
||||||
|
groupId === 'group:none'
|
||||||
|
? instance.group_ids.length === 0
|
||||||
|
: instance.group_ids.includes(groupId),
|
||||||
|
)
|
||||||
.map((instance) => instance.id),
|
.map((instance) => instance.id),
|
||||||
)
|
)
|
||||||
isGroupInstancesModalOpen.value = true
|
isGroupInstancesModalOpen.value = true
|
||||||
@@ -736,6 +773,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
const selectedIds = new Set(selectedGroupInstanceIds.value)
|
const selectedIds = new Set(selectedGroupInstanceIds.value)
|
||||||
|
|
||||||
if (selectedIds.has(instanceId)) {
|
if (selectedIds.has(instanceId)) {
|
||||||
|
if (groupInstancesModalGroupId.value === 'group:none') return
|
||||||
selectedIds.delete(instanceId)
|
selectedIds.delete(instanceId)
|
||||||
} else {
|
} else {
|
||||||
selectedIds.add(instanceId)
|
selectedIds.add(instanceId)
|
||||||
@@ -748,15 +786,20 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
const groupId = groupInstancesModalGroupId.value
|
const groupId = groupInstancesModalGroupId.value
|
||||||
if (!groupId || savingGroupInstances.value) return false
|
if (!groupId || savingGroupInstances.value) return false
|
||||||
|
|
||||||
const changedInstances = instances.value.filter(
|
const isUngrouped = groupId === 'group:none'
|
||||||
(instance) =>
|
const changedInstances = instances.value.filter((instance) => {
|
||||||
instance.group_ids.includes(groupId) !== selectedGroupInstanceIds.value.has(instance.id),
|
const isSelected = selectedGroupInstanceIds.value.has(instance.id)
|
||||||
)
|
return isUngrouped
|
||||||
|
? isSelected && instance.group_ids.length > 0
|
||||||
|
: instance.group_ids.includes(groupId) !== isSelected
|
||||||
|
})
|
||||||
const operations = changedInstances.map((instance) => {
|
const operations = changedInstances.map((instance) => {
|
||||||
const shouldIncludeGroup = selectedGroupInstanceIds.value.has(instance.id)
|
const shouldIncludeGroup = selectedGroupInstanceIds.value.has(instance.id)
|
||||||
const nextGroupIds = shouldIncludeGroup
|
const nextGroupIds = isUngrouped
|
||||||
? [...instance.group_ids, groupId]
|
? []
|
||||||
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
|
: shouldIncludeGroup
|
||||||
|
? [...instance.group_ids, groupId]
|
||||||
|
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instance,
|
instance,
|
||||||
@@ -978,14 +1021,16 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
|
|
||||||
const canMoveGroupUp = (groupId: string) =>
|
const canMoveGroupUp = (groupId: string) =>
|
||||||
!reorderingGroups.value &&
|
!reorderingGroups.value &&
|
||||||
customLibraryGroups.value.findIndex((group) => group.id === groupId) > 0
|
orderedLibraryGroupIds.value.findIndex((orderedGroupId) => orderedGroupId === groupId) > 0
|
||||||
|
|
||||||
const canMoveGroupDown = (groupId: string) => {
|
const canMoveGroupDown = (groupId: string) => {
|
||||||
const groupIndex = customLibraryGroups.value.findIndex((group) => group.id === groupId)
|
const groupIndex = orderedLibraryGroupIds.value.findIndex(
|
||||||
|
(orderedGroupId) => orderedGroupId === groupId,
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
!reorderingGroups.value &&
|
!reorderingGroups.value &&
|
||||||
groupIndex >= 0 &&
|
groupIndex >= 0 &&
|
||||||
groupIndex < customLibraryGroups.value.length - 1
|
groupIndex < orderedLibraryGroupIds.value.length - 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,35 +1038,48 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
if (reorderingGroups.value) return false
|
if (reorderingGroups.value) return false
|
||||||
|
|
||||||
const previousGroups = libraryGroups.value
|
const previousGroups = libraryGroups.value
|
||||||
|
const previousUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
|
||||||
const customGroupsById = new Map(customLibraryGroups.value.map((group) => [group.id, group]))
|
const customGroupsById = new Map(customLibraryGroups.value.map((group) => [group.id, group]))
|
||||||
|
const reorderableGroupIds = new Set([...customGroupsById.keys(), 'group:none'])
|
||||||
const orderedGroupIdSet = new Set(orderedGroupIds)
|
const orderedGroupIdSet = new Set(orderedGroupIds)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
orderedGroupIdSet.size !== orderedGroupIds.length ||
|
orderedGroupIdSet.size !== orderedGroupIds.length ||
|
||||||
orderedGroupIds.some((groupId) => !customGroupsById.has(groupId))
|
orderedGroupIds.some((groupId) => !reorderableGroupIds.has(groupId))
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedGroups = orderedGroupIds.map((groupId) => customGroupsById.get(groupId)!)
|
|
||||||
let orderedGroupIndex = 0
|
let orderedGroupIndex = 0
|
||||||
const reorderedCustomGroups = customLibraryGroups.value.map((group) =>
|
const reorderedGroupIds = orderedLibraryGroupIds.value.map((groupId) =>
|
||||||
orderedGroupIdSet.has(group.id) ? orderedGroups[orderedGroupIndex++] : group,
|
orderedGroupIdSet.has(groupId) ? orderedGroupIds[orderedGroupIndex++] : groupId,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (reorderedCustomGroups.every((group, index) => group === customLibraryGroups.value[index])) {
|
if (
|
||||||
|
reorderedGroupIds.every((groupId, index) => groupId === orderedLibraryGroupIds.value[index])
|
||||||
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reorderedCustomGroups = reorderedGroupIds
|
||||||
|
.filter((groupId) => groupId !== 'group:none')
|
||||||
|
.map((groupId) => customGroupsById.get(groupId)!)
|
||||||
|
const customGroupOrderChanged = reorderedCustomGroups.some(
|
||||||
|
(group, index) => group !== customLibraryGroups.value[index],
|
||||||
|
)
|
||||||
const favoriteGroups = previousGroups.filter((group) => group.id === FAVORITES_GROUP_ID)
|
const favoriteGroups = previousGroups.filter((group) => group.id === FAVORITES_GROUP_ID)
|
||||||
libraryGroups.value = [...favoriteGroups, ...reorderedCustomGroups]
|
libraryGroups.value = [...favoriteGroups, ...reorderedCustomGroups]
|
||||||
|
displayState.value.ungroupedGroupPosition = reorderedGroupIds.indexOf('group:none')
|
||||||
reorderingGroups.value = true
|
reorderingGroups.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
|
if (customGroupOrderChanged) {
|
||||||
|
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
libraryGroups.value = previousGroups
|
libraryGroups.value = previousGroups
|
||||||
|
displayState.value.ungroupedGroupPosition = previousUngroupedGroupPosition
|
||||||
handleError(toError(error))
|
handleError(toError(error))
|
||||||
await refreshGroups()
|
await refreshGroups()
|
||||||
return false
|
return false
|
||||||
@@ -1031,7 +1089,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const moveGroup = async (groupId: string, direction: -1 | 1) => {
|
const moveGroup = async (groupId: string, direction: -1 | 1) => {
|
||||||
const orderedGroupIds = customLibraryGroups.value.map((group) => group.id)
|
const orderedGroupIds = [...orderedLibraryGroupIds.value]
|
||||||
const groupIndex = orderedGroupIds.indexOf(groupId)
|
const groupIndex = orderedGroupIds.indexOf(groupId)
|
||||||
const targetIndex = groupIndex + direction
|
const targetIndex = groupIndex + direction
|
||||||
|
|
||||||
@@ -1057,6 +1115,47 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
await install_duplicate_instance(instanceId).catch((error) => handleError(toError(error)))
|
await install_duplicate_instance(instanceId).catch((error) => handleError(toError(error)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectInstanceIcon = async (item: InstanceCard) => {
|
||||||
|
const iconPath = await openDialog({
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: 'Image',
|
||||||
|
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!iconPath) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await edit_icon(item.instance.id, iconPath)
|
||||||
|
trackEvent('InstanceSetIcon')
|
||||||
|
} catch (error) {
|
||||||
|
handleError(toError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeInstanceIcon = async (item: InstanceCard) => {
|
||||||
|
try {
|
||||||
|
await edit_icon(item.instance.id, null)
|
||||||
|
trackEvent('InstanceRemoveIcon')
|
||||||
|
} catch (error) {
|
||||||
|
handleError(toError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openInstanceIconEditor = async (item: InstanceCard) => {
|
||||||
|
currentIconEditorInstanceId.value = item.instance.id
|
||||||
|
await nextTick()
|
||||||
|
iconEditorModal.value?.show()
|
||||||
|
trackEvent(item.instance.icon_config ? 'InstanceEditCreatedIcon' : 'InstanceCreateIcon')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInstanceIconSaved = (_iconPath: string, _config: InstanceIconConfig) => {
|
||||||
|
trackEvent('InstanceSaveCreatedIcon')
|
||||||
|
}
|
||||||
|
|
||||||
const handleInstanceContextMenu = (
|
const handleInstanceContextMenu = (
|
||||||
event: MouseEvent,
|
event: MouseEvent,
|
||||||
item: InstanceCard,
|
item: InstanceCard,
|
||||||
@@ -1083,6 +1182,14 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
{ name: 'duplicate' },
|
{ name: 'duplicate' },
|
||||||
{ name: 'open' },
|
{ name: 'open' },
|
||||||
{ name: 'copy' },
|
{ 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' }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
...(currentContextGroupId.value
|
...(currentContextGroupId.value
|
||||||
? [{ name: 'remove_from_group' }, { type: 'divider' }]
|
? [{ name: 'remove_from_group' }, { type: 'divider' }]
|
||||||
: [{ type: 'divider' }]),
|
: [{ type: 'divider' }]),
|
||||||
@@ -1127,6 +1234,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
case 'edit':
|
case 'edit':
|
||||||
await item.seeInstance()
|
await item.seeInstance()
|
||||||
break
|
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':
|
case 'duplicate':
|
||||||
if (item.instance.install_stage === 'installed') {
|
if (item.instance.install_stage === 'installed') {
|
||||||
await duplicateInstance(item.instance.id)
|
await duplicateInstance(item.instance.id)
|
||||||
@@ -1189,6 +1307,8 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
canCreateGroup,
|
canCreateGroup,
|
||||||
instanceOptions,
|
instanceOptions,
|
||||||
confirmDeleteModal,
|
confirmDeleteModal,
|
||||||
|
iconEditorModal,
|
||||||
|
currentIconEditorInstance,
|
||||||
currentDeleteInstances,
|
currentDeleteInstances,
|
||||||
isSectionCollapsed,
|
isSectionCollapsed,
|
||||||
setSectionCollapsed,
|
setSectionCollapsed,
|
||||||
@@ -1220,6 +1340,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
|||||||
deleteInstance,
|
deleteInstance,
|
||||||
handleInstanceContextMenu,
|
handleInstanceContextMenu,
|
||||||
handleInstanceOption,
|
handleInstanceOption,
|
||||||
|
handleInstanceIconSaved,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -154,7 +154,7 @@ defineExpose({ show, hide })
|
|||||||
:disable-close="applying"
|
:disable-close="applying"
|
||||||
class="!overflow-hidden !rounded-[20px]"
|
class="!overflow-hidden !rounded-[20px]"
|
||||||
>
|
>
|
||||||
<div class="grid h-[384px] w-[768px] max-w-full grid-cols-2">
|
<div class="grid w-[768px] max-w-full grid-cols-2">
|
||||||
<section class="flex min-w-0 flex-col gap-6 bg-surface-3 p-8">
|
<section class="flex min-w-0 flex-col gap-6 bg-surface-3 p-8">
|
||||||
<div
|
<div
|
||||||
class="flex h-8 w-fit items-center rounded-full border border-solid border-brand bg-brand-highlight px-2.5 text-sm font-base leading-5 text-brand"
|
class="flex h-8 w-fit items-center rounded-full border border-solid border-brand bg-brand-highlight px-2.5 text-sm font-base leading-5 text-brand"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const { updatePreferences } = injectUserPreferences()
|
|||||||
const settingsModal = inject(appSettingsModalContextKey, null)
|
const settingsModal = inject(appSettingsModalContextKey, null)
|
||||||
|
|
||||||
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
||||||
|
const compactInstanceCardsFlag: FeatureFlag = 'compact_instance_cards'
|
||||||
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
|
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
|
||||||
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
|
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
|
||||||
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
|
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
|
||||||
@@ -87,6 +88,14 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Show recently played worlds or instances in the "Jump in" section on the Home page.',
|
'Show recently played worlds or instances in the "Jump in" section on the Home page.',
|
||||||
},
|
},
|
||||||
|
compactModeTitle: {
|
||||||
|
id: 'app.appearance-settings.compact-mode.title',
|
||||||
|
defaultMessage: 'Compact mode',
|
||||||
|
},
|
||||||
|
compactModeDescription: {
|
||||||
|
id: 'app.appearance-settings.compact-mode.description',
|
||||||
|
defaultMessage: 'Display library instances in a compact row layout.',
|
||||||
|
},
|
||||||
showPlayTimeTitle: {
|
showPlayTimeTitle: {
|
||||||
id: 'app.appearance-settings.show-play-time.title',
|
id: 'app.appearance-settings.show-play-time.title',
|
||||||
defaultMessage: 'Show play time',
|
defaultMessage: 'Show play time',
|
||||||
@@ -128,6 +137,7 @@ type BehaviorSettingsState = {
|
|||||||
minimizeApp: boolean
|
minimizeApp: boolean
|
||||||
hideRightSidebar: boolean
|
hideRightSidebar: boolean
|
||||||
showJumpIn: boolean
|
showJumpIn: boolean
|
||||||
|
compactInstanceCards: boolean
|
||||||
showPlayTime: boolean
|
showPlayTime: boolean
|
||||||
hideNametag: boolean
|
hideNametag: boolean
|
||||||
warnOnUnknownModpacks: boolean
|
warnOnUnknownModpacks: boolean
|
||||||
@@ -142,6 +152,9 @@ function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState
|
|||||||
minimizeApp: settings.hide_on_process_start,
|
minimizeApp: settings.hide_on_process_start,
|
||||||
hideRightSidebar: settings.toggle_sidebar,
|
hideRightSidebar: settings.toggle_sidebar,
|
||||||
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
|
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
|
||||||
|
compactInstanceCards:
|
||||||
|
settings.feature_flags[compactInstanceCardsFlag] ??
|
||||||
|
DEFAULT_FEATURE_FLAGS[compactInstanceCardsFlag],
|
||||||
showPlayTime:
|
showPlayTime:
|
||||||
settings.feature_flags[showPlayTimeFlag] ?? DEFAULT_FEATURE_FLAGS[showPlayTimeFlag],
|
settings.feature_flags[showPlayTimeFlag] ?? DEFAULT_FEATURE_FLAGS[showPlayTimeFlag],
|
||||||
hideNametag: settings.hide_nametag_skins_page,
|
hideNametag: settings.hide_nametag_skins_page,
|
||||||
@@ -166,6 +179,7 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
|
|||||||
minimize_app: value.minimizeApp,
|
minimize_app: value.minimizeApp,
|
||||||
hide_right_sidebar: value.hideRightSidebar,
|
hide_right_sidebar: value.hideRightSidebar,
|
||||||
show_jump_in: value.showJumpIn,
|
show_jump_in: value.showJumpIn,
|
||||||
|
compact_instance_cards: value.compactInstanceCards,
|
||||||
show_play_time: value.showPlayTime,
|
show_play_time: value.showPlayTime,
|
||||||
hide_nametag: value.hideNametag,
|
hide_nametag: value.hideNametag,
|
||||||
warn_on_unknown_modpacks: value.warnOnUnknownModpacks,
|
warn_on_unknown_modpacks: value.warnOnUnknownModpacks,
|
||||||
@@ -183,6 +197,7 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
|
|||||||
feature_flags: {
|
feature_flags: {
|
||||||
...persistedSettings.value.feature_flags,
|
...persistedSettings.value.feature_flags,
|
||||||
[worldsInHomeFlag]: value.showJumpIn,
|
[worldsInHomeFlag]: value.showJumpIn,
|
||||||
|
[compactInstanceCardsFlag]: value.compactInstanceCards,
|
||||||
[showPlayTimeFlag]: value.showPlayTime,
|
[showPlayTimeFlag]: value.showPlayTime,
|
||||||
[skipUnknownPackWarningFlag]: !value.warnOnUnknownModpacks,
|
[skipUnknownPackWarningFlag]: !value.warnOnUnknownModpacks,
|
||||||
[skipNonEssentialWarningsFlag]: value.skipNonEssentialWarnings,
|
[skipNonEssentialWarningsFlag]: value.skipNonEssentialWarnings,
|
||||||
@@ -195,6 +210,7 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
|
|||||||
appSettings.toggleSidebar = value.hideRightSidebar
|
appSettings.toggleSidebar = value.hideRightSidebar
|
||||||
appSettings.hideNametagSkinsPage = value.hideNametag
|
appSettings.hideNametagSkinsPage = value.hideNametag
|
||||||
appSettings.featureFlags[worldsInHomeFlag] = value.showJumpIn
|
appSettings.featureFlags[worldsInHomeFlag] = value.showJumpIn
|
||||||
|
appSettings.featureFlags[compactInstanceCardsFlag] = value.compactInstanceCards
|
||||||
appSettings.featureFlags[showPlayTimeFlag] = value.showPlayTime
|
appSettings.featureFlags[showPlayTimeFlag] = value.showPlayTime
|
||||||
appSettings.featureFlags[skipUnknownPackWarningFlag] = !value.warnOnUnknownModpacks
|
appSettings.featureFlags[skipUnknownPackWarningFlag] = !value.warnOnUnknownModpacks
|
||||||
appSettings.featureFlags[skipNonEssentialWarningsFlag] = value.skipNonEssentialWarnings
|
appSettings.featureFlags[skipNonEssentialWarningsFlag] = value.skipNonEssentialWarnings
|
||||||
@@ -298,6 +314,16 @@ onBeforeUnmount(() => {
|
|||||||
<Toggle id="jump-back-into-worlds" v-model="current.showJumpIn" />
|
<Toggle id="jump-back-into-worlds" v-model="current.showJumpIn" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.compactModeTitle) }}
|
||||||
|
</h3>
|
||||||
|
<p class="m-0 mt-1">{{ formatMessage(messages.compactModeDescription) }}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle id="compact-mode" v-model="current.compactInstanceCards" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LoaderCircleIcon } from '@modrinth/assets'
|
import { LoaderCircleIcon } from '@modrinth/assets'
|
||||||
import type { GameVersion } from '@modrinth/ui'
|
import {
|
||||||
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
Accordion,
|
||||||
|
defineMessages,
|
||||||
|
GAME_MODES,
|
||||||
|
type GameVersion,
|
||||||
|
injectNotificationManager,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
import { platform } from '@tauri-apps/plugin-os'
|
import { platform } from '@tauri-apps/plugin-os'
|
||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
@@ -36,6 +42,7 @@ const { handleError } = injectNotificationManager()
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
jumpIn: { id: 'app.home.jump-back-in.title', defaultMessage: 'Jump in' },
|
jumpIn: { id: 'app.home.jump-back-in.title', defaultMessage: 'Jump in' },
|
||||||
|
resize: { id: 'app.home.jump-back-in.resize', defaultMessage: 'Drag to resize' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -54,6 +61,100 @@ const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []
|
|||||||
const MAX_JUMP_BACK_IN = 5
|
const MAX_JUMP_BACK_IN = 5
|
||||||
const MAX_NEW_INSTANCES = 3
|
const MAX_NEW_INSTANCES = 3
|
||||||
const MAX_LINUX_POPULATES = 3
|
const MAX_LINUX_POPULATES = 3
|
||||||
|
const ITEM_DRAG_DISTANCE = 92
|
||||||
|
const STORAGE_KEY = 'modrinth-jump-back-in-count'
|
||||||
|
|
||||||
|
const storedVisibleCount = Number.parseInt(localStorage.getItem(STORAGE_KEY) ?? '', 10)
|
||||||
|
const visibleItemLimit = ref(
|
||||||
|
Number.isFinite(storedVisibleCount)
|
||||||
|
? Math.max(1, Math.min(storedVisibleCount, MAX_JUMP_BACK_IN))
|
||||||
|
: MAX_JUMP_BACK_IN,
|
||||||
|
)
|
||||||
|
const maxVisibleItems = computed(() => Math.min(jumpBackInItems.value.length, MAX_JUMP_BACK_IN))
|
||||||
|
const visibleItemCount = computed(() => Math.min(visibleItemLimit.value, maxVisibleItems.value))
|
||||||
|
const visibleJumpBackInItems = computed(() =>
|
||||||
|
jumpBackInItems.value.slice(0, visibleItemCount.value),
|
||||||
|
)
|
||||||
|
const canResize = computed(() => maxVisibleItems.value > 1)
|
||||||
|
const resizing = ref(false)
|
||||||
|
const showOverdrag = ref(false)
|
||||||
|
|
||||||
|
function setVisibleItemLimit(count: number) {
|
||||||
|
const clamped = Math.max(1, Math.min(count, maxVisibleItems.value))
|
||||||
|
if (clamped >= maxVisibleItems.value) {
|
||||||
|
visibleItemLimit.value = MAX_JUMP_BACK_IN
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} else {
|
||||||
|
visibleItemLimit.value = clamped
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(clamped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustVisibleItemLimit(delta: number) {
|
||||||
|
const target = visibleItemCount.value + delta
|
||||||
|
if (target < 1 || target > maxVisibleItems.value) {
|
||||||
|
flashOverdrag()
|
||||||
|
}
|
||||||
|
setVisibleItemLimit(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragStartY = 0
|
||||||
|
let dragStartCount = 0
|
||||||
|
let wasOverdragging = false
|
||||||
|
let overdragTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function clearOverdragFlash() {
|
||||||
|
showOverdrag.value = false
|
||||||
|
if (overdragTimeout) {
|
||||||
|
clearTimeout(overdragTimeout)
|
||||||
|
overdragTimeout = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashOverdrag() {
|
||||||
|
showOverdrag.value = true
|
||||||
|
if (overdragTimeout) clearTimeout(overdragTimeout)
|
||||||
|
overdragTimeout = setTimeout(() => {
|
||||||
|
showOverdrag.value = false
|
||||||
|
overdragTimeout = undefined
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizePointerDown(event: PointerEvent) {
|
||||||
|
if (!canResize.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
resizing.value = true
|
||||||
|
wasOverdragging = false
|
||||||
|
clearOverdragFlash()
|
||||||
|
dragStartY = event.clientY
|
||||||
|
dragStartCount = visibleItemCount.value
|
||||||
|
document.body.classList.add('recent-worlds-resizing')
|
||||||
|
const handle = event.currentTarget as HTMLElement
|
||||||
|
handle.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizePointerMove(event: PointerEvent) {
|
||||||
|
if (!resizing.value) return
|
||||||
|
const target = dragStartCount + Math.round((event.clientY - dragStartY) / ITEM_DRAG_DISTANCE)
|
||||||
|
const isOverdragging = target < 1 || target > maxVisibleItems.value
|
||||||
|
if (isOverdragging && !wasOverdragging) {
|
||||||
|
flashOverdrag()
|
||||||
|
}
|
||||||
|
wasOverdragging = isOverdragging
|
||||||
|
setVisibleItemLimit(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function endResize(event?: PointerEvent) {
|
||||||
|
if (!resizing.value) return
|
||||||
|
resizing.value = false
|
||||||
|
wasOverdragging = false
|
||||||
|
clearOverdragFlash()
|
||||||
|
document.body.classList.remove('recent-worlds-resizing')
|
||||||
|
const target = event?.currentTarget as HTMLElement | undefined
|
||||||
|
if (event && target?.hasPointerCapture(event.pointerId)) {
|
||||||
|
target.releasePointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track populate calls on Linux to prevent server ping spam
|
// Track populate calls on Linux to prevent server ping spam
|
||||||
const isLinux = platform() === 'linux'
|
const isLinux = platform() === 'linux'
|
||||||
@@ -276,100 +377,180 @@ onMounted(() => {
|
|||||||
checkProcesses()
|
checkProcesses()
|
||||||
linuxPopulateCount.value = 0
|
linuxPopulateCount.value = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.body.classList.remove('recent-worlds-resizing')
|
||||||
|
clearOverdragFlash()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="flex flex-col gap-2">
|
<Accordion
|
||||||
<span
|
v-if="loading || jumpBackInItems.length > 0"
|
||||||
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
|
open-by-default
|
||||||
>
|
button-class="group mt-1 mb-3 flex w-fit cursor-pointer items-center border-0 bg-transparent p-0 text-left"
|
||||||
{{ formatMessage(messages.jumpIn) }}
|
>
|
||||||
</span>
|
<template #title>
|
||||||
<div class="text-center py-4">
|
<span class="flex items-center gap-1 text-2xl font-semibold leading-none text-contrast mr-1">
|
||||||
|
{{ formatMessage(messages.jumpIn) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div v-if="loading" class="text-center py-4">
|
||||||
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
|
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else class="grid-when-huge relative flex w-full flex-col gap-3">
|
||||||
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
|
<TransitionGroup name="jump-back-in-item">
|
||||||
<span
|
<div
|
||||||
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
|
v-for="item in visibleJumpBackInItems"
|
||||||
>
|
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
|
||||||
{{ formatMessage(messages.jumpIn) }}
|
class="jump-back-in-item min-w-0"
|
||||||
</span>
|
>
|
||||||
<div class="grid-when-huge flex flex-col w-full gap-3">
|
<WorldItem
|
||||||
<template
|
v-if="item.type === 'world'"
|
||||||
v-for="item in jumpBackInItems"
|
:world="item.world"
|
||||||
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
|
:playing-instance="runningInstances.includes(item.instance.id)"
|
||||||
>
|
:playing-world="
|
||||||
<WorldItem
|
currentInstance === item.instance.id &&
|
||||||
v-if="item.type === 'world'"
|
currentWorld === getWorldIdentifier(item.world)
|
||||||
:world="item.world"
|
"
|
||||||
:playing-instance="runningInstances.includes(item.instance.id)"
|
:refreshing="
|
||||||
:playing-world="
|
|
||||||
currentInstance === item.instance.id && currentWorld === getWorldIdentifier(item.world)
|
|
||||||
"
|
|
||||||
:refreshing="
|
|
||||||
item.world.type === 'server'
|
|
||||||
? serverData[item.world.address].refreshing && !serverData[item.world.address].status
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:supports-server-quick-play="
|
|
||||||
item.world.type === 'server' &&
|
|
||||||
hasServerQuickPlaySupport(gameVersions, item.instance.game_version || '')
|
|
||||||
"
|
|
||||||
:supports-world-quick-play="
|
|
||||||
item.world.type === 'singleplayer' &&
|
|
||||||
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
|
|
||||||
"
|
|
||||||
:quarantined="item.instance.quarantined"
|
|
||||||
:server-status="
|
|
||||||
item.world.type === 'server' ? serverData[item.world.address].status : undefined
|
|
||||||
"
|
|
||||||
:rendered-motd="
|
|
||||||
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
|
|
||||||
"
|
|
||||||
:current-protocol="protocolVersions[item.instance.id]"
|
|
||||||
:game-mode="
|
|
||||||
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
|
||||||
"
|
|
||||||
:instance-id="item.instance.id"
|
|
||||||
:instance-name="item.instance.name"
|
|
||||||
:instance-icon="item.instance.icon_path"
|
|
||||||
@refresh="
|
|
||||||
() =>
|
|
||||||
item.world.type === 'server'
|
item.world.type === 'server'
|
||||||
? refreshServer(item.world.address, item.instance.id)
|
? serverData[item.world.address].refreshing &&
|
||||||
: {}
|
!serverData[item.world.address].status
|
||||||
"
|
: undefined
|
||||||
@update="() => populateJumpBackIn()"
|
"
|
||||||
@play="
|
:supports-server-quick-play="
|
||||||
() => {
|
item.world.type === 'server' &&
|
||||||
currentInstance = item.instance.id
|
hasServerQuickPlaySupport(gameVersions, item.instance.game_version || '')
|
||||||
currentWorld = getWorldIdentifier(item.world)
|
"
|
||||||
joinWorld(item.world, item.instance)
|
:supports-world-quick-play="
|
||||||
}
|
item.world.type === 'singleplayer' &&
|
||||||
"
|
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
|
||||||
@play-instance="
|
"
|
||||||
() => {
|
:quarantined="item.instance.quarantined"
|
||||||
currentInstance = item.instance.id
|
:server-status="
|
||||||
playInstance(item.instance)
|
item.world.type === 'server' ? serverData[item.world.address].status : undefined
|
||||||
}
|
"
|
||||||
"
|
:rendered-motd="
|
||||||
@stop="() => stopInstance(item.instance.id)"
|
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
|
||||||
/>
|
"
|
||||||
<InstanceItem
|
:current-protocol="protocolVersions[item.instance.id]"
|
||||||
v-else
|
:game-mode="
|
||||||
:instance="item.instance"
|
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
||||||
:last_played="item.sort_time"
|
"
|
||||||
:newly-added="item.newly_added"
|
:instance-id="item.instance.id"
|
||||||
@play="() => markInstancePlayed(item)"
|
:instance-name="item.instance.name"
|
||||||
/>
|
:instance-icon="item.instance.icon_path"
|
||||||
</template>
|
@refresh="
|
||||||
|
() =>
|
||||||
|
item.world.type === 'server'
|
||||||
|
? refreshServer(item.world.address, item.instance.id)
|
||||||
|
: {}
|
||||||
|
"
|
||||||
|
@update="() => populateJumpBackIn()"
|
||||||
|
@play="
|
||||||
|
() => {
|
||||||
|
currentInstance = item.instance.id
|
||||||
|
currentWorld = getWorldIdentifier(item.world)
|
||||||
|
joinWorld(item.world, item.instance)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@play-instance="
|
||||||
|
() => {
|
||||||
|
currentInstance = item.instance.id
|
||||||
|
playInstance(item.instance)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@stop="() => stopInstance(item.instance.id)"
|
||||||
|
/>
|
||||||
|
<InstanceItem
|
||||||
|
v-else
|
||||||
|
:instance="item.instance"
|
||||||
|
:last_played="item.sort_time"
|
||||||
|
:newly-added="item.newly_added"
|
||||||
|
@play="() => markInstancePlayed(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
<div
|
||||||
|
v-if="canResize"
|
||||||
|
v-tooltip="resizing ? null : formatMessage(messages.resize)"
|
||||||
|
role="separator"
|
||||||
|
tabindex="0"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
:aria-label="formatMessage(messages.resize)"
|
||||||
|
:aria-valuemin="1"
|
||||||
|
:aria-valuemax="maxVisibleItems"
|
||||||
|
:aria-valuenow="visibleItemCount"
|
||||||
|
class="group/resize relative inset-x-0 bottom-0 flex h-6 -mt-3 w-1/3 mx-auto cursor-ns-resize touch-none select-none items-center justify-center opacity-0 transition-opacity duration-200 hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
:class="{ 'opacity-100': resizing }"
|
||||||
|
@pointerdown="onResizePointerDown"
|
||||||
|
@pointermove="onResizePointerMove"
|
||||||
|
@pointerup="endResize"
|
||||||
|
@pointercancel="endResize"
|
||||||
|
@keydown.up.prevent="adjustVisibleItemLimit(-1)"
|
||||||
|
@keydown.down.prevent="adjustVisibleItemLimit(1)"
|
||||||
|
@keydown.home.prevent="setVisibleItemLimit(1)"
|
||||||
|
@keydown.end.prevent="setVisibleItemLimit(maxVisibleItems)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
class="h-px w-6 transition-colors"
|
||||||
|
:class="showOverdrag ? 'bg-red' : 'bg-surface-3 group-hover/resize:bg-secondary'"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="h-px w-6 transition-colors"
|
||||||
|
:class="showOverdrag ? 'bg-red' : 'bg-surface-3 group-hover/resize:bg-secondary'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Accordion>
|
||||||
</template>
|
</template>
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.grid-when-huge {
|
.grid-when-huge {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-back-in-item {
|
||||||
|
height: 5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-back-in-item-enter-active,
|
||||||
|
.jump-back-in-item-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease,
|
||||||
|
transform 0.25s ease,
|
||||||
|
height 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-back-in-item-enter-from,
|
||||||
|
.jump-back-in-item-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.98);
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.jump-back-in-item-enter-active,
|
||||||
|
.jump-back-in-item-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-back-in-item-enter-from,
|
||||||
|
.jump-back-in-item-leave-to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
height: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
body.recent-worlds-resizing,
|
||||||
|
body.recent-worlds-resizing * {
|
||||||
|
cursor: ns-resize !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
NoSignalIcon,
|
NoSignalIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
|
SignalIcon,
|
||||||
SkullIcon,
|
SkullIcon,
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
@@ -25,15 +26,17 @@ import {
|
|||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
ServerOnlinePlayers,
|
|
||||||
SmartClickable,
|
SmartClickable,
|
||||||
TagItem,
|
TagItem,
|
||||||
TeleportOverflowMenu,
|
TeleportOverflowMenu,
|
||||||
useFormatDateTime,
|
useFormatDateTime,
|
||||||
|
useFormatNumber,
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { getPingLevel } from '@modrinth/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import { Tooltip } from 'floating-vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -53,6 +56,7 @@ import { LockIcon } from '../../../../../../packages/assets/generated-icons'
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const formatRelativeTime = useRelativeTime()
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
const formatNumber = useFormatNumber()
|
||||||
const formatDateTime = useFormatDateTime({
|
const formatDateTime = useFormatDateTime({
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
dateStyle: 'long',
|
dateStyle: 'long',
|
||||||
@@ -121,6 +125,9 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
|
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
|
||||||
|
const hasPlayersTooltip = computed(
|
||||||
|
() => !!props.serverStatus?.players?.sample && props.serverStatus.players.sample.length > 0,
|
||||||
|
)
|
||||||
const serverIncompatible = computed(
|
const serverIncompatible = computed(
|
||||||
() =>
|
() =>
|
||||||
!!props.serverStatus &&
|
!!props.serverStatus &&
|
||||||
@@ -238,6 +245,10 @@ const messages = defineMessages({
|
|||||||
id: 'app.world.world-item.incompatible-version',
|
id: 'app.world.world-item.incompatible-version',
|
||||||
defaultMessage: 'Incompatible version {version}',
|
defaultMessage: 'Incompatible version {version}',
|
||||||
},
|
},
|
||||||
|
playersOnline: {
|
||||||
|
id: 'app.world.world-item.players-online',
|
||||||
|
defaultMessage: '{count} online',
|
||||||
|
},
|
||||||
offline: {
|
offline: {
|
||||||
id: 'app.world.world-item.offline',
|
id: 'app.world.world-item.offline',
|
||||||
defaultMessage: 'Offline',
|
defaultMessage: 'Offline',
|
||||||
@@ -315,13 +326,34 @@ const messages = defineMessages({
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="flex items-center gap-2">
|
<template v-else>
|
||||||
<ServerOnlinePlayers
|
<SignalIcon
|
||||||
:online="serverStatus.players?.online ?? 0"
|
v-tooltip="`${serverStatus.ping}ms`"
|
||||||
status-online
|
aria-hidden="true"
|
||||||
hide-label
|
:style="`--_signal-${getPingLevel(serverStatus.ping ?? 0)}: var(--color-green)`"
|
||||||
|
stroke-width="3px"
|
||||||
|
class="shrink-0 smart-clickable:allow-pointer-events"
|
||||||
/>
|
/>
|
||||||
</div>
|
<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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
|
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
|||||||
pride_fundraiser: true,
|
pride_fundraiser: true,
|
||||||
i18n_debug: false,
|
i18n_debug: false,
|
||||||
show_instance_play_time: true,
|
show_instance_play_time: true,
|
||||||
|
compact_instance_cards: false,
|
||||||
advanced_filters_collapsed: true,
|
advanced_filters_collapsed: true,
|
||||||
always_show_copy_details: false,
|
always_show_copy_details: false,
|
||||||
hide_installed_modpacks: false,
|
hide_installed_modpacks: false,
|
||||||
|
|||||||
@@ -149,6 +149,12 @@
|
|||||||
"app.ads-consent.title": {
|
"app.ads-consent.title": {
|
||||||
"message": "Your privacy and how ads support Modrinth"
|
"message": "Your privacy and how ads support Modrinth"
|
||||||
},
|
},
|
||||||
|
"app.appearance-settings.compact-mode.description": {
|
||||||
|
"message": "Display library instances in a compact row layout."
|
||||||
|
},
|
||||||
|
"app.appearance-settings.compact-mode.title": {
|
||||||
|
"message": "Compact mode"
|
||||||
|
},
|
||||||
"app.appearance-settings.default-landing-page.home": {
|
"app.appearance-settings.default-landing-page.home": {
|
||||||
"message": "Home"
|
"message": "Home"
|
||||||
},
|
},
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"app.home.jump-back-in.new-instance": {
|
"app.home.jump-back-in.new-instance": {
|
||||||
"message": "New instance"
|
"message": "New instance"
|
||||||
},
|
},
|
||||||
|
"app.home.jump-back-in.resize": {
|
||||||
|
"message": "Drag to resize"
|
||||||
|
},
|
||||||
"app.home.jump-back-in.title": {
|
"app.home.jump-back-in.title": {
|
||||||
"message": "Jump in"
|
"message": "Jump in"
|
||||||
},
|
},
|
||||||
@@ -1739,6 +1748,9 @@
|
|||||||
"app.world.world-item.offline": {
|
"app.world.world-item.offline": {
|
||||||
"message": "Offline"
|
"message": "Offline"
|
||||||
},
|
},
|
||||||
|
"app.world.world-item.players-online": {
|
||||||
|
"message": "{count} online"
|
||||||
|
},
|
||||||
"content.shared-instance.change-version-body": {
|
"content.shared-instance.change-version-body": {
|
||||||
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
|
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
|
||||||
},
|
},
|
||||||
@@ -2066,6 +2078,12 @@
|
|||||||
"instance.icon-editor.symbol.engine": {
|
"instance.icon-editor.symbol.engine": {
|
||||||
"message": "Engine"
|
"message": "Engine"
|
||||||
},
|
},
|
||||||
|
"instance.icon-editor.symbol.fabric": {
|
||||||
|
"message": "Fabric"
|
||||||
|
},
|
||||||
|
"instance.icon-editor.symbol.forge": {
|
||||||
|
"message": "Forge"
|
||||||
|
},
|
||||||
"instance.icon-editor.symbol.furnace": {
|
"instance.icon-editor.symbol.furnace": {
|
||||||
"message": "Furnace"
|
"message": "Furnace"
|
||||||
},
|
},
|
||||||
@@ -2087,6 +2105,9 @@
|
|||||||
"instance.icon-editor.symbol.mr-pack": {
|
"instance.icon-editor.symbol.mr-pack": {
|
||||||
"message": "Mr Pack"
|
"message": "Mr Pack"
|
||||||
},
|
},
|
||||||
|
"instance.icon-editor.symbol.neoforge": {
|
||||||
|
"message": "NeoForge"
|
||||||
|
},
|
||||||
"instance.icon-editor.symbol.orb": {
|
"instance.icon-editor.symbol.orb": {
|
||||||
"message": "Orb"
|
"message": "Orb"
|
||||||
},
|
},
|
||||||
@@ -2102,6 +2123,9 @@
|
|||||||
"instance.icon-editor.symbol.poke-ball": {
|
"instance.icon-editor.symbol.poke-ball": {
|
||||||
"message": "Poke Ball"
|
"message": "Poke Ball"
|
||||||
},
|
},
|
||||||
|
"instance.icon-editor.symbol.quilt": {
|
||||||
|
"message": "Quilt"
|
||||||
|
},
|
||||||
"instance.icon-editor.symbol.redstone-block": {
|
"instance.icon-editor.symbol.redstone-block": {
|
||||||
"message": "Redstone Block"
|
"message": "Redstone Block"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
|||||||
import type { LocationQuery } from 'vue-router'
|
import type { LocationQuery } from 'vue-router'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||||
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
|
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
|
||||||
import { useAppEvent } from '@/composables/use-app-event'
|
import { useAppEvent } from '@/composables/use-app-event'
|
||||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { HomeIcon, PlusIcon } from '@modrinth/assets'
|
import { PlayIcon, PlusIcon } from '@modrinth/assets'
|
||||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { computed, inject, onActivated, ref } from 'vue'
|
import { computed, inject, onActivated, ref } from 'vue'
|
||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||||
import LibrarySection from '@/components/ui/library/index.vue'
|
import LibrarySection from '@/components/ui/library/index.vue'
|
||||||
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
|
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
|
||||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||||
@@ -43,7 +43,7 @@ const homeBreadcrumb = useRootBreadcrumb({
|
|||||||
id: 'home',
|
id: 'home',
|
||||||
label: formatMessage(messages.home),
|
label: formatMessage(messages.home),
|
||||||
to: '/',
|
to: '/',
|
||||||
visual: { type: 'icon', component: HomeIcon },
|
visual: { type: 'icon', component: PlayIcon },
|
||||||
})
|
})
|
||||||
onActivated(homeBreadcrumb.reset)
|
onActivated(homeBreadcrumb.reset)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ function handlePageOption({ option }: { option: string }) {
|
|||||||
<div
|
<div
|
||||||
v-else-if="isReady"
|
v-else-if="isReady"
|
||||||
data-library-page-background
|
data-library-page-background
|
||||||
class="flex flex-col gap-6 p-6"
|
class="flex flex-col gap-3 p-6"
|
||||||
@contextmenu="openPageContextMenu"
|
@contextmenu="openPageContextMenu"
|
||||||
>
|
>
|
||||||
<RecentWorldsList
|
<RecentWorldsList
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const client = injectModrinthClient()
|
|||||||
useRootBreadcrumb({
|
useRootBreadcrumb({
|
||||||
slot: 'root',
|
slot: 'root',
|
||||||
id: 'servers',
|
id: 'servers',
|
||||||
label: 'Servers',
|
label: 'Hosting',
|
||||||
to: '/hosting/manage/',
|
to: '/hosting/manage/',
|
||||||
visual: { type: 'icon', component: ServerStackIcon },
|
visual: { type: 'icon', component: ServerStackIcon },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
|
|||||||
import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from 'vue'
|
import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||||
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ import { computed, ref, shallowRef, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { SwapIcon } from '@/assets/icons/index.js'
|
import { SwapIcon } from '@/assets/icons/index.js'
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||||
import {
|
import {
|
||||||
fetchCachedServerStatus,
|
fetchCachedServerStatus,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
|
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
||||||
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow circle />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<Avatar v-else size="xs" :raised="raised" no-shadow />
|
<Avatar v-else size="xs" :raised="raised" no-shadow />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 rounded-2xl border border-solid border-surface-5 bg-surface-4 p-4"
|
class="flex items-center gap-2 rounded-2xl border border-solid border-surface-5 bg-surface-4 p-4"
|
||||||
>
|
>
|
||||||
<Avatar :src="auth.user.avatar_url" size="32px" />
|
<Avatar :src="auth.user.avatar_url" size="32px" circle />
|
||||||
<span class="font-medium text-contrast">{{ auth.user.username }}</span>
|
<span class="font-medium text-contrast">{{ auth.user.username }}</span>
|
||||||
|
|
||||||
<Button type="quiet" color="red" native-type="button" class="ml-auto" @click="logout">
|
<Button type="quiet" color="red" native-type="button" class="ml-auto" @click="logout">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub struct BehaviorPreferences {
|
|||||||
pub minimize_app: bool,
|
pub minimize_app: bool,
|
||||||
pub hide_right_sidebar: bool,
|
pub hide_right_sidebar: bool,
|
||||||
pub show_jump_in: bool,
|
pub show_jump_in: bool,
|
||||||
|
pub compact_instance_cards: bool,
|
||||||
pub show_play_time: bool,
|
pub show_play_time: bool,
|
||||||
pub hide_nametag: bool,
|
pub hide_nametag: bool,
|
||||||
pub warn_on_unknown_modpacks: bool,
|
pub warn_on_unknown_modpacks: bool,
|
||||||
@@ -53,6 +54,7 @@ impl Default for BehaviorPreferences {
|
|||||||
minimize_app: false,
|
minimize_app: false,
|
||||||
hide_right_sidebar: false,
|
hide_right_sidebar: false,
|
||||||
show_jump_in: true,
|
show_jump_in: true,
|
||||||
|
compact_instance_cards: false,
|
||||||
show_play_time: true,
|
show_play_time: true,
|
||||||
hide_nametag: false,
|
hide_nametag: false,
|
||||||
warn_on_unknown_modpacks: true,
|
warn_on_unknown_modpacks: true,
|
||||||
|
|||||||
@@ -1724,6 +1724,7 @@ export namespace Labrinth {
|
|||||||
minimize_app: boolean
|
minimize_app: boolean
|
||||||
hide_right_sidebar: boolean
|
hide_right_sidebar: boolean
|
||||||
show_jump_in: boolean
|
show_jump_in: boolean
|
||||||
|
compact_instance_cards: boolean
|
||||||
show_play_time: boolean
|
show_play_time: boolean
|
||||||
hide_nametag: boolean
|
hide_nametag: boolean
|
||||||
warn_on_unknown_modpacks: boolean
|
warn_on_unknown_modpacks: boolean
|
||||||
|
|||||||
@@ -730,7 +730,7 @@ pub(crate) async fn update_recent_instance_icon_config(
|
|||||||
SELECT rowid
|
SELECT rowid
|
||||||
FROM recent_instance_icon_configs
|
FROM recent_instance_icon_configs
|
||||||
ORDER BY used_at DESC, background, symbol
|
ORDER BY used_at DESC, background, symbol
|
||||||
LIMIT 8
|
LIMIT 16
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
@@ -748,7 +748,7 @@ pub(crate) async fn get_recent_instance_icon_configs(
|
|||||||
SELECT background, symbol
|
SELECT background, symbol
|
||||||
FROM recent_instance_icon_configs
|
FROM recent_instance_icon_configs
|
||||||
ORDER BY used_at DESC, background, symbol
|
ORDER BY used_at DESC, background, symbol
|
||||||
LIMIT 8
|
LIMIT 16
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ pub enum FeatureFlag {
|
|||||||
ServerProjectQa,
|
ServerProjectQa,
|
||||||
I18nDebug,
|
I18nDebug,
|
||||||
ShowInstancePlayTime,
|
ShowInstancePlayTime,
|
||||||
|
CompactInstanceCards,
|
||||||
SkipNonEssentialWarnings,
|
SkipNonEssentialWarnings,
|
||||||
AdvancedFiltersCollapsed,
|
AdvancedFiltersCollapsed,
|
||||||
AlwaysShowCopyDetails,
|
AlwaysShowCopyDetails,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
circle: circle,
|
circle: circle,
|
||||||
detecting: !hasDetectedCorners,
|
detecting: !hasDetectedCorners,
|
||||||
'no-shadow': noShadow,
|
'no-shadow': noShadow,
|
||||||
padded: hasTransparentCorners,
|
padded: hasTransparentCorners && !circle && !disableConditionalIconPadding,
|
||||||
raised: raised,
|
raised: raised,
|
||||||
pixelated: pixelated,
|
pixelated: pixelated,
|
||||||
}"
|
}"
|
||||||
@@ -72,6 +72,7 @@ const props = withDefaults(
|
|||||||
size?: string
|
size?: string
|
||||||
circle?: boolean
|
circle?: boolean
|
||||||
noShadow?: boolean
|
noShadow?: boolean
|
||||||
|
disableConditionalIconPadding?: boolean
|
||||||
loading?: 'eager' | 'lazy'
|
loading?: 'eager' | 'lazy'
|
||||||
raised?: boolean
|
raised?: boolean
|
||||||
tintBy?: string | null
|
tintBy?: string | null
|
||||||
@@ -82,6 +83,7 @@ const props = withDefaults(
|
|||||||
size: '2rem',
|
size: '2rem',
|
||||||
circle: false,
|
circle: false,
|
||||||
noShadow: false,
|
noShadow: false,
|
||||||
|
disableConditionalIconPadding: false,
|
||||||
loading: 'eager',
|
loading: 'eager',
|
||||||
raised: false,
|
raised: false,
|
||||||
tintBy: null,
|
tintBy: null,
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { CopyIcon, FolderOpenIcon, PlayIcon, SettingsIcon, TrashIcon } from '@modrinth/assets'
|
||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
import { fn } from 'storybook/test'
|
||||||
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import ContextMenu from '../../../../../apps/app-frontend/src/components/ui/context-menu/index.vue'
|
||||||
|
import type { ContextMenuOption } from '../../../../../apps/app-frontend/src/components/ui/context-menu/types'
|
||||||
|
|
||||||
|
const options: ContextMenuOption[] = [
|
||||||
|
{ name: 'play', color: 'primary' },
|
||||||
|
{
|
||||||
|
name: 'copy',
|
||||||
|
children: [{ name: 'copy_name' }, { name: 'copy_path' }, { name: 'copy_id' }],
|
||||||
|
},
|
||||||
|
{ name: 'open_folder' },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ name: 'settings' },
|
||||||
|
{ name: 'delete', color: 'danger' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'App/Context Menu',
|
||||||
|
component: ContextMenu,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
onMenuClosed: fn(),
|
||||||
|
onOptionClicked: fn(),
|
||||||
|
},
|
||||||
|
render: (args) => ({
|
||||||
|
components: {
|
||||||
|
ContextMenu,
|
||||||
|
CopyIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
PlayIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
TrashIcon,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||||
|
const target = ref<HTMLElement>()
|
||||||
|
const item = { id: 'storybook-instance', name: 'Storybook Instance' }
|
||||||
|
|
||||||
|
function openMenu(event: MouseEvent) {
|
||||||
|
contextMenu.value?.showMenu(event, item, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
const rect = target.value?.getBoundingClientRect()
|
||||||
|
openMenu(
|
||||||
|
new MouseEvent('contextmenu', {
|
||||||
|
clientX: (rect?.left ?? 80) + 80,
|
||||||
|
clientY: (rect?.top ?? 80) + 80,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { args, contextMenu, openMenu, target }
|
||||||
|
},
|
||||||
|
template: /*html*/ `
|
||||||
|
<div
|
||||||
|
ref="target"
|
||||||
|
style="box-sizing: border-box; min-height: 100vh; padding: 5rem; background: var(--color-bg); color: var(--color-text-primary);"
|
||||||
|
@contextmenu.prevent.stop="openMenu"
|
||||||
|
>
|
||||||
|
<div style="max-width: 32rem; border: 1px dashed var(--color-divider); border-radius: var(--radius-lg); padding: 2rem;">
|
||||||
|
<p style="margin: 0; color: var(--color-text-secondary);">
|
||||||
|
Right-click anywhere in this panel to reopen the menu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
ref="contextMenu"
|
||||||
|
@menu-closed="args.onMenuClosed"
|
||||||
|
@option-clicked="args.onOptionClicked"
|
||||||
|
>
|
||||||
|
<template #play><PlayIcon /> Play</template>
|
||||||
|
<template #copy><CopyIcon /> Copy</template>
|
||||||
|
<template #copy_name><CopyIcon /> Copy name</template>
|
||||||
|
<template #copy_path><CopyIcon /> Copy path</template>
|
||||||
|
<template #copy_id> <CopyIcon /> Copy ID</template>
|
||||||
|
<template #open_folder><FolderOpenIcon /> Open folder</template>
|
||||||
|
<template #settings><SettingsIcon /> Settings</template>
|
||||||
|
<template #delete><TrashIcon /> Delete</template>
|
||||||
|
</ContextMenu>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
} satisfies Meta<typeof ContextMenu>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {}
|
||||||
Reference in New Issue
Block a user